Compare commits
52 Commits
pr/AndrewB
...
feat/fe-ac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4fdc05b5f | ||
|
|
4a0a5e6a04 | ||
|
|
085fbb2a9b | ||
|
|
05f2d8817b | ||
|
|
81fbb22c51 | ||
|
|
01cf59c663 | ||
|
|
5c8ebaff3e | ||
|
|
0e70c28324 | ||
|
|
88f6a4ae26 | ||
|
|
610538ca02 | ||
|
|
8970ff5c68 | ||
|
|
d1a564d5b8 | ||
|
|
8bb1d19c07 | ||
|
|
c1efa381f0 | ||
|
|
29171d1b6f | ||
|
|
e869d49e16 | ||
|
|
6496bea846 | ||
|
|
39842559b5 | ||
|
|
51efb35aa6 | ||
|
|
9402bb9285 | ||
|
|
5a516f7242 | ||
|
|
3b217d78e7 | ||
|
|
8e153b38dc | ||
|
|
6f38bfb148 | ||
|
|
82b6e08d68 | ||
|
|
31fd6567b7 | ||
|
|
25177bd635 | ||
|
|
6928eb7992 | ||
|
|
8300f9a0a2 | ||
|
|
525ba77739 | ||
|
|
6bc748a267 | ||
|
|
5230d2d3b8 | ||
|
|
c3531c9d8b | ||
|
|
b29c04c28d | ||
|
|
b2af353941 | ||
|
|
2ec29c47ad | ||
|
|
399a238bf4 | ||
|
|
b20ab72298 | ||
|
|
f723e6496a | ||
|
|
8c0aff8863 | ||
|
|
64c5077506 | ||
|
|
2afc87847d | ||
|
|
878ec833ce | ||
|
|
039de8015f | ||
|
|
f67b366b90 | ||
|
|
77e8a36ab0 | ||
|
|
d7cc9f5dbc | ||
|
|
4ba135f3b9 | ||
|
|
24894e05dc | ||
|
|
e2b668bee2 | ||
|
|
f112c46bb4 | ||
|
|
84b0c30d64 |
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*/**/node_modules
|
||||||
@@ -13,6 +13,7 @@ SESSION_SECRET='add some secret here'
|
|||||||
# Hoppscotch App Domain Config
|
# Hoppscotch App Domain Config
|
||||||
REDIRECT_URL="http://localhost:3000"
|
REDIRECT_URL="http://localhost:3000"
|
||||||
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
|
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
|
||||||
|
VITE_ALLOWED_AUTH_PROVIDERS = GOOGLE,GITHUB,MICROSOFT,EMAIL
|
||||||
|
|
||||||
# Google Auth Config
|
# Google Auth Config
|
||||||
GOOGLE_CLIENT_ID="************************************************"
|
GOOGLE_CLIENT_ID="************************************************"
|
||||||
|
|||||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -2,9 +2,9 @@ name: Node.js CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main, staging]
|
branches: [main, staging, "release/**"]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, staging]
|
branches: [main, staging, "release/**"]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ services:
|
|||||||
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
volumes:
|
volumes:
|
||||||
- ./packages/hoppscotch-backend/:/usr/src/app
|
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
|
||||||
|
# - ./packages/hoppscotch-backend/:/usr/src/app
|
||||||
- /usr/src/app/node_modules/
|
- /usr/src/app/node_modules/
|
||||||
depends_on:
|
depends_on:
|
||||||
- hoppscotch-db
|
hoppscotch-db:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "3170:3000"
|
- "3170:3000"
|
||||||
|
|
||||||
@@ -60,12 +62,20 @@ services:
|
|||||||
# you are using an external postgres instance
|
# you are using an external postgres instance
|
||||||
# This will be exposed at port 5432
|
# This will be exposed at port 5432
|
||||||
hoppscotch-db:
|
hoppscotch-db:
|
||||||
image: postgres
|
image: postgres:15
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
user: postgres
|
||||||
environment:
|
environment:
|
||||||
|
# The default user defined by the docker image
|
||||||
|
POSTGRES_USER: postgres
|
||||||
# NOTE: Please UPDATE THIS PASSWORD!
|
# NOTE: Please UPDATE THIS PASSWORD!
|
||||||
POSTGRES_PASSWORD: testpass
|
POSTGRES_PASSWORD: testpass
|
||||||
POSTGRES_DB: hoppscotch
|
POSTGRES_DB: hoppscotch
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "sh -c 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}'"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoppscotch-backend",
|
"name": "hoppscotch-backend",
|
||||||
"version": "2023.4.7",
|
"version": "2023.4.8",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -411,6 +411,23 @@ export class AdminResolver {
|
|||||||
return deletedTeam.right;
|
return deletedTeam.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description: 'Revoke a team Invite by Invite ID',
|
||||||
|
})
|
||||||
|
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||||
|
async revokeTeamInviteByAdmin(
|
||||||
|
@Args({
|
||||||
|
name: 'inviteID',
|
||||||
|
description: 'Team Invite ID',
|
||||||
|
type: () => ID,
|
||||||
|
})
|
||||||
|
inviteID: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const invite = await this.adminService.revokeTeamInviteByID(inviteID);
|
||||||
|
if (E.isLeft(invite)) throwErr(invite.left);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/* Subscriptions */
|
/* Subscriptions */
|
||||||
|
|
||||||
@Subscription(() => InvitedUser, {
|
@Subscription(() => InvitedUser, {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
INVALID_EMAIL,
|
INVALID_EMAIL,
|
||||||
ONLY_ONE_ADMIN_ACCOUNT,
|
ONLY_ONE_ADMIN_ACCOUNT,
|
||||||
TEAM_INVITE_ALREADY_MEMBER,
|
TEAM_INVITE_ALREADY_MEMBER,
|
||||||
|
TEAM_INVITE_NO_INVITE_FOUND,
|
||||||
USER_ALREADY_INVITED,
|
USER_ALREADY_INVITED,
|
||||||
USER_IS_ADMIN,
|
USER_IS_ADMIN,
|
||||||
USER_NOT_FOUND,
|
USER_NOT_FOUND,
|
||||||
@@ -181,7 +182,7 @@ export class AdminService {
|
|||||||
* @returns an array team invitations
|
* @returns an array team invitations
|
||||||
*/
|
*/
|
||||||
async pendingInvitationCountInTeam(teamID: string) {
|
async pendingInvitationCountInTeam(teamID: string) {
|
||||||
const invitations = await this.teamInvitationService.getAllTeamInvitations(
|
const invitations = await this.teamInvitationService.getTeamInvitations(
|
||||||
teamID,
|
teamID,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -257,7 +258,7 @@ export class AdminService {
|
|||||||
if (E.isRight(userInvitation)) {
|
if (E.isRight(userInvitation)) {
|
||||||
await this.teamInvitationService.revokeInvitation(
|
await this.teamInvitationService.revokeInvitation(
|
||||||
userInvitation.right.id,
|
userInvitation.right.id,
|
||||||
)();
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return E.right(addedUser.right);
|
return E.right(addedUser.right);
|
||||||
@@ -416,4 +417,19 @@ export class AdminService {
|
|||||||
if (E.isLeft(team)) return E.left(team.left);
|
if (E.isLeft(team)) return E.left(team.left);
|
||||||
return E.right(team.right);
|
return E.right(team.right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a team invite by ID
|
||||||
|
* @param inviteID Team Invite ID
|
||||||
|
* @returns an Either of boolean or error
|
||||||
|
*/
|
||||||
|
async revokeTeamInviteByID(inviteID: string) {
|
||||||
|
const teamInvite = await this.teamInvitationService.revokeInvitation(
|
||||||
|
inviteID,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (E.isLeft(teamInvite)) return E.left(teamInvite.left);
|
||||||
|
|
||||||
|
return E.right(teamInvite.right);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
InternalServerErrorException,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
|
||||||
Request,
|
Request,
|
||||||
Res,
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
@@ -19,12 +19,18 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|||||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||||
import { AuthUser } from 'src/types/AuthUser';
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
||||||
import { authCookieHandler, throwHTTPErr } from './helper';
|
import {
|
||||||
|
AuthProvider,
|
||||||
|
authCookieHandler,
|
||||||
|
authProviderCheck,
|
||||||
|
throwHTTPErr,
|
||||||
|
} from './helper';
|
||||||
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
||||||
import { GithubSSOGuard } from './guards/github-sso.guard';
|
import { GithubSSOGuard } from './guards/github-sso.guard';
|
||||||
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
|
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
|
||||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
|
|
||||||
@UseGuards(ThrottlerBehindProxyGuard)
|
@UseGuards(ThrottlerBehindProxyGuard)
|
||||||
@Controller({ path: 'auth', version: '1' })
|
@Controller({ path: 'auth', version: '1' })
|
||||||
@@ -39,6 +45,9 @@ export class AuthController {
|
|||||||
@Body() authData: SignInMagicDto,
|
@Body() authData: SignInMagicDto,
|
||||||
@Query('origin') origin: string,
|
@Query('origin') origin: string,
|
||||||
) {
|
) {
|
||||||
|
if (!authProviderCheck(AuthProvider.EMAIL))
|
||||||
|
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||||
|
|
||||||
const deviceIdToken = await this.authService.signInMagicLink(
|
const deviceIdToken = await this.authService.signInMagicLink(
|
||||||
authData.email,
|
authData.email,
|
||||||
origin,
|
origin,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { RTJwtStrategy } from './strategies/rt-jwt.strategy';
|
|||||||
import { GoogleStrategy } from './strategies/google.strategy';
|
import { GoogleStrategy } from './strategies/google.strategy';
|
||||||
import { GithubStrategy } from './strategies/github.strategy';
|
import { GithubStrategy } from './strategies/github.strategy';
|
||||||
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
||||||
|
import { AuthProvider, authProviderCheck } from './helper';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -26,9 +27,9 @@ import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
|||||||
AuthService,
|
AuthService,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
RTJwtStrategy,
|
RTJwtStrategy,
|
||||||
GoogleStrategy,
|
...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
|
||||||
GithubStrategy,
|
...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
|
||||||
MicrosoftStrategy,
|
...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ export class AuthService {
|
|||||||
url = process.env.VITE_BASE_URL;
|
url = process.env.VITE_BASE_URL;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.mailerService.sendAuthEmail(email, {
|
await this.mailerService.sendEmail(email, {
|
||||||
template: 'code-your-own',
|
template: 'code-your-own',
|
||||||
variables: {
|
variables: {
|
||||||
inviteeEmail: email,
|
inviteeEmail: email,
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GithubSSOGuard extends AuthGuard('github') {
|
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
|
||||||
|
canActivate(
|
||||||
|
context: ExecutionContext,
|
||||||
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
|
if (!authProviderCheck(AuthProvider.GITHUB))
|
||||||
|
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
getAuthenticateOptions(context: ExecutionContext) {
|
getAuthenticateOptions(context: ExecutionContext) {
|
||||||
const req = context.switchToHttp().getRequest();
|
const req = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoogleSSOGuard extends AuthGuard('google') {
|
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
|
||||||
|
canActivate(
|
||||||
|
context: ExecutionContext,
|
||||||
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
|
if (!authProviderCheck(AuthProvider.GOOGLE))
|
||||||
|
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
getAuthenticateOptions(context: ExecutionContext) {
|
getAuthenticateOptions(context: ExecutionContext) {
|
||||||
const req = context.switchToHttp().getRequest();
|
const req = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MicrosoftSSOGuard extends AuthGuard('microsoft') {
|
export class MicrosoftSSOGuard
|
||||||
|
extends AuthGuard('microsoft')
|
||||||
|
implements CanActivate
|
||||||
|
{
|
||||||
|
canActivate(
|
||||||
|
context: ExecutionContext,
|
||||||
|
): boolean | Promise<boolean> | Observable<boolean> {
|
||||||
|
if (!authProviderCheck(AuthProvider.MICROSOFT))
|
||||||
|
throwHTTPErr({
|
||||||
|
message: AUTH_PROVIDER_NOT_SPECIFIED,
|
||||||
|
statusCode: 404,
|
||||||
|
});
|
||||||
|
|
||||||
|
return super.canActivate(context);
|
||||||
|
}
|
||||||
|
|
||||||
getAuthenticateOptions(context: ExecutionContext) {
|
getAuthenticateOptions(context: ExecutionContext) {
|
||||||
const req = context.switchToHttp().getRequest();
|
const req = context.switchToHttp().getRequest();
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { ForbiddenException, HttpException, HttpStatus } from '@nestjs/common';
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { AuthError } from 'src/types/AuthError';
|
import { AuthError } from 'src/types/AuthError';
|
||||||
import { AuthTokens } from 'src/types/AuthTokens';
|
import { AuthTokens } from 'src/types/AuthTokens';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
import { COOKIES_NOT_FOUND } from 'src/errors';
|
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
|
||||||
|
import { throwErr } from 'src/utils';
|
||||||
|
|
||||||
enum AuthTokenType {
|
enum AuthTokenType {
|
||||||
ACCESS_TOKEN = 'access_token',
|
ACCESS_TOKEN = 'access_token',
|
||||||
@@ -16,6 +17,13 @@ export enum Origin {
|
|||||||
APP = 'app',
|
APP = 'app',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AuthProvider {
|
||||||
|
GOOGLE = 'GOOGLE',
|
||||||
|
GITHUB = 'GITHUB',
|
||||||
|
MICROSOFT = 'MICROSOFT',
|
||||||
|
EMAIL = 'EMAIL',
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function allows throw to be used as an expression
|
* This function allows throw to be used as an expression
|
||||||
* @param errMessage Message present in the error message
|
* @param errMessage Message present in the error message
|
||||||
@@ -97,3 +105,25 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
|
|||||||
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
|
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check to see if given auth provider is present in the VITE_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.VITE_ALLOWED_AUTH_PROVIDERS
|
||||||
|
? process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
|
||||||
|
provider.trim().toUpperCase(),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!envVariables.includes(provider.toUpperCase())) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,30 @@ export const AUTH_FAIL = 'auth/fail';
|
|||||||
*/
|
*/
|
||||||
export const JSON_INVALID = 'json_invalid';
|
export const JSON_INVALID = 'json_invalid';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth Provider not specified
|
||||||
|
* (Auth)
|
||||||
|
*/
|
||||||
|
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
|
||||||
|
*/
|
||||||
|
export const ENV_NOT_FOUND_KEY_AUTH_PROVIDERS =
|
||||||
|
'"VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file
|
||||||
|
*/
|
||||||
|
export const ENV_EMPTY_AUTH_PROVIDERS =
|
||||||
|
'"VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" contains unsupported provider in .env file
|
||||||
|
*/
|
||||||
|
export const ENV_NOT_SUPPORT_AUTH_PROVIDERS =
|
||||||
|
'"VITE_ALLOWED_AUTH_PROVIDERS" contains an unsupported auth provider in .env file';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tried to delete a user data document from fb firestore but failed.
|
* Tried to delete a user data document from fb firestore but failed.
|
||||||
* (FirebaseService)
|
* (FirebaseService)
|
||||||
@@ -312,6 +336,13 @@ export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const;
|
|||||||
*/
|
*/
|
||||||
export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
|
export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid TEAM ENVIRONMENT name
|
||||||
|
* (TeamEnvironmentsService)
|
||||||
|
*/
|
||||||
|
export const TEAM_ENVIRONMENT_SHORT_NAME =
|
||||||
|
'team_environment/short_name' as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user is not a member of the team of the given environment
|
* The user is not a member of the team of the given environment
|
||||||
* (GqlTeamEnvTeamGuard)
|
* (GqlTeamEnvTeamGuard)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
UserMagicLinkMailDescription,
|
UserMagicLinkMailDescription,
|
||||||
} from './MailDescriptions';
|
} from './MailDescriptions';
|
||||||
import { throwErr } from 'src/utils';
|
import { throwErr } from 'src/utils';
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import { EMAIL_FAILED } from 'src/errors';
|
import { EMAIL_FAILED } from 'src/errors';
|
||||||
import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
|
import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
|
||||||
|
|
||||||
@@ -35,33 +34,14 @@ export class MailerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends an email to the given email address given a mail description
|
* Sends an email to the given email address given a mail description
|
||||||
* @param to The email address to be sent to (NOTE: this is not validated)
|
* @param to Receiver's email id
|
||||||
* @param mailDesc Definition of what email to be sent
|
* @param mailDesc Definition of what email to be sent
|
||||||
|
* @returns Response if email was send successfully or not
|
||||||
*/
|
*/
|
||||||
sendMail(
|
async sendEmail(
|
||||||
to: string,
|
to: string,
|
||||||
mailDesc: MailDescription | UserMagicLinkMailDescription,
|
mailDesc: MailDescription | UserMagicLinkMailDescription,
|
||||||
) {
|
) {
|
||||||
return TE.tryCatch(
|
|
||||||
async () => {
|
|
||||||
await this.nestMailerService.sendMail({
|
|
||||||
to,
|
|
||||||
template: mailDesc.template,
|
|
||||||
subject: this.resolveSubjectForMailDesc(mailDesc),
|
|
||||||
context: mailDesc.variables,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
() => EMAIL_FAILED,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
* @param to Receiver's email id
|
|
||||||
* @param mailDesc Details of email to be sent for Magic-Link auth
|
|
||||||
* @returns Response if email was send successfully or not
|
|
||||||
*/
|
|
||||||
async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) {
|
|
||||||
try {
|
try {
|
||||||
await this.nestMailerService.sendMail({
|
await this.nestMailerService.sendMail({
|
||||||
to,
|
to,
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import * as cookieParser from 'cookie-parser';
|
|||||||
import { VersioningType } from '@nestjs/common';
|
import { VersioningType } from '@nestjs/common';
|
||||||
import * as session from 'express-session';
|
import * as session from 'express-session';
|
||||||
import { emitGQLSchemaFile } from './gql-schema';
|
import { emitGQLSchemaFile } from './gql-schema';
|
||||||
|
import { checkEnvironmentAuthProvider } from './utils';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
console.log(`Running in production: ${process.env.PRODUCTION}`);
|
console.log(`Running in production: ${process.env.PRODUCTION}`);
|
||||||
console.log(`Port: ${process.env.PORT}`);
|
console.log(`Port: ${process.env.PORT}`);
|
||||||
|
|
||||||
|
checkEnvironmentAuthProvider();
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import * as O from 'fp-ts/Option';
|
|
||||||
import * as S from 'fp-ts/string';
|
|
||||||
import { pipe } from 'fp-ts/function';
|
|
||||||
import {
|
|
||||||
getAnnotatedRequiredRoles,
|
|
||||||
getGqlArg,
|
|
||||||
getUserFromGQLContext,
|
|
||||||
throwErr,
|
|
||||||
} from 'src/utils';
|
|
||||||
import { TeamEnvironmentsService } from './team-environments.service';
|
import { TeamEnvironmentsService } from './team-environments.service';
|
||||||
import {
|
import {
|
||||||
BUG_AUTH_NO_USER_CTX,
|
BUG_AUTH_NO_USER_CTX,
|
||||||
@@ -19,6 +9,10 @@ import {
|
|||||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { TeamService } from 'src/team/team.service';
|
import { TeamService } from 'src/team/team.service';
|
||||||
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
|
import { TeamMemberRole } from '@prisma/client';
|
||||||
|
import { throwErr } from 'src/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A guard which checks whether the caller of a GQL Operation
|
* A guard which checks whether the caller of a GQL Operation
|
||||||
@@ -33,50 +27,31 @@ export class GqlTeamEnvTeamGuard implements CanActivate {
|
|||||||
private readonly teamService: TeamService,
|
private readonly teamService: TeamService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
return pipe(
|
const requireRoles = this.reflector.get<TeamMemberRole[]>(
|
||||||
TE.Do,
|
'requiresTeamRole',
|
||||||
|
context.getHandler(),
|
||||||
|
);
|
||||||
|
if (!requireRoles) throw new Error(BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES);
|
||||||
|
|
||||||
TE.bindW('requiredRoles', () =>
|
const gqlExecCtx = GqlExecutionContext.create(context);
|
||||||
pipe(
|
|
||||||
getAnnotatedRequiredRoles(this.reflector, context),
|
|
||||||
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.bindW('user', () =>
|
const { user } = gqlExecCtx.getContext().req;
|
||||||
pipe(
|
if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX);
|
||||||
getUserFromGQLContext(context),
|
|
||||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.bindW('envID', () =>
|
const { id } = gqlExecCtx.getArgs<{ id: string }>();
|
||||||
pipe(
|
if (!id) throwErr(BUG_TEAM_ENV_GUARD_NO_ENV_ID);
|
||||||
getGqlArg('id', context),
|
|
||||||
O.fromPredicate(S.isString),
|
|
||||||
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.bindW('membership', ({ envID, user }) =>
|
const teamEnvironment =
|
||||||
pipe(
|
await this.teamEnvironmentService.getTeamEnvironment(id);
|
||||||
this.teamEnvironmentService.getTeamEnvironment(envID),
|
if (E.isLeft(teamEnvironment)) throwErr(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
TE.fromTaskOption(() => TEAM_ENVIRONMENT_NOT_FOUND),
|
|
||||||
TE.chainW((env) =>
|
|
||||||
pipe(
|
|
||||||
this.teamService.getTeamMemberTE(env.teamID, user.uid),
|
|
||||||
TE.mapLeft(() => TEAM_ENVIRONMENT_NOT_TEAM_MEMBER),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.map(({ membership, requiredRoles }) =>
|
const member = await this.teamService.getTeamMember(
|
||||||
requiredRoles.includes(membership.role),
|
teamEnvironment.right.teamID,
|
||||||
),
|
user.uid,
|
||||||
|
);
|
||||||
|
if (!member) throwErr(TEAM_ENVIRONMENT_NOT_TEAM_MEMBER);
|
||||||
|
|
||||||
TE.getOrElse(throwErr),
|
return requireRoles.includes(member.role);
|
||||||
)();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ArgsType, Field, ID } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class CreateTeamEnvironmentArgs {
|
||||||
|
@Field({
|
||||||
|
name: 'name',
|
||||||
|
description: 'Name of the Team Environment',
|
||||||
|
})
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@Field(() => ID, {
|
||||||
|
name: 'teamID',
|
||||||
|
description: 'ID of the Team',
|
||||||
|
})
|
||||||
|
teamID: string;
|
||||||
|
|
||||||
|
@Field({
|
||||||
|
name: 'variables',
|
||||||
|
description: 'JSON string of the variables object',
|
||||||
|
})
|
||||||
|
variables: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class UpdateTeamEnvironmentArgs {
|
||||||
|
@Field(() => ID, {
|
||||||
|
name: 'id',
|
||||||
|
description: 'ID of the Team Environment',
|
||||||
|
})
|
||||||
|
id: string;
|
||||||
|
@Field({
|
||||||
|
name: 'name',
|
||||||
|
description: 'Name of the Team Environment',
|
||||||
|
})
|
||||||
|
name: string;
|
||||||
|
@Field({
|
||||||
|
name: 'variables',
|
||||||
|
description: 'JSON string of the variables object',
|
||||||
|
})
|
||||||
|
variables: string;
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ import { throwErr } from 'src/utils';
|
|||||||
import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
|
import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
|
||||||
import { TeamEnvironment } from './team-environments.model';
|
import { TeamEnvironment } from './team-environments.model';
|
||||||
import { TeamEnvironmentsService } from './team-environments.service';
|
import { TeamEnvironmentsService } from './team-environments.service';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
|
import {
|
||||||
|
CreateTeamEnvironmentArgs,
|
||||||
|
UpdateTeamEnvironmentArgs,
|
||||||
|
} from './input-type.args';
|
||||||
|
|
||||||
@UseGuards(GqlThrottlerGuard)
|
@UseGuards(GqlThrottlerGuard)
|
||||||
@Resolver(() => 'TeamEnvironment')
|
@Resolver(() => 'TeamEnvironment')
|
||||||
@@ -29,29 +34,18 @@ export class TeamEnvironmentsResolver {
|
|||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||||
createTeamEnvironment(
|
async createTeamEnvironment(
|
||||||
@Args({
|
@Args() args: CreateTeamEnvironmentArgs,
|
||||||
name: 'name',
|
|
||||||
description: 'Name of the Team Environment',
|
|
||||||
})
|
|
||||||
name: string,
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the Team',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
@Args({
|
|
||||||
name: 'variables',
|
|
||||||
description: 'JSON string of the variables object',
|
|
||||||
})
|
|
||||||
variables: string,
|
|
||||||
): Promise<TeamEnvironment> {
|
): Promise<TeamEnvironment> {
|
||||||
return this.teamEnvironmentsService.createTeamEnvironment(
|
const teamEnvironment =
|
||||||
name,
|
await this.teamEnvironmentsService.createTeamEnvironment(
|
||||||
teamID,
|
args.name,
|
||||||
variables,
|
args.teamID,
|
||||||
)();
|
args.variables,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
|
||||||
|
return teamEnvironment.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
@Mutation(() => Boolean, {
|
||||||
@@ -59,7 +53,7 @@ export class TeamEnvironmentsResolver {
|
|||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||||
deleteTeamEnvironment(
|
async deleteTeamEnvironment(
|
||||||
@Args({
|
@Args({
|
||||||
name: 'id',
|
name: 'id',
|
||||||
description: 'ID of the Team Environment',
|
description: 'ID of the Team Environment',
|
||||||
@@ -67,10 +61,12 @@ export class TeamEnvironmentsResolver {
|
|||||||
})
|
})
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
return pipe(
|
const isDeleted = await this.teamEnvironmentsService.deleteTeamEnvironment(
|
||||||
this.teamEnvironmentsService.deleteTeamEnvironment(id),
|
id,
|
||||||
TE.getOrElse(throwErr),
|
);
|
||||||
)();
|
|
||||||
|
if (E.isLeft(isDeleted)) throwErr(isDeleted.left);
|
||||||
|
return isDeleted.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => TeamEnvironment, {
|
@Mutation(() => TeamEnvironment, {
|
||||||
@@ -79,28 +75,19 @@ export class TeamEnvironmentsResolver {
|
|||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||||
updateTeamEnvironment(
|
async updateTeamEnvironment(
|
||||||
@Args({
|
@Args()
|
||||||
name: 'id',
|
args: UpdateTeamEnvironmentArgs,
|
||||||
description: 'ID of the Team Environment',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
id: string,
|
|
||||||
@Args({
|
|
||||||
name: 'name',
|
|
||||||
description: 'Name of the Team Environment',
|
|
||||||
})
|
|
||||||
name: string,
|
|
||||||
@Args({
|
|
||||||
name: 'variables',
|
|
||||||
description: 'JSON string of the variables object',
|
|
||||||
})
|
|
||||||
variables: string,
|
|
||||||
): Promise<TeamEnvironment> {
|
): Promise<TeamEnvironment> {
|
||||||
return pipe(
|
const updatedTeamEnvironment =
|
||||||
this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables),
|
await this.teamEnvironmentsService.updateTeamEnvironment(
|
||||||
TE.getOrElse(throwErr),
|
args.id,
|
||||||
)();
|
args.name,
|
||||||
|
args.variables,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (E.isLeft(updatedTeamEnvironment)) throwErr(updatedTeamEnvironment.left);
|
||||||
|
return updatedTeamEnvironment.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => TeamEnvironment, {
|
@Mutation(() => TeamEnvironment, {
|
||||||
@@ -108,7 +95,7 @@ export class TeamEnvironmentsResolver {
|
|||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||||
deleteAllVariablesFromTeamEnvironment(
|
async deleteAllVariablesFromTeamEnvironment(
|
||||||
@Args({
|
@Args({
|
||||||
name: 'id',
|
name: 'id',
|
||||||
description: 'ID of the Team Environment',
|
description: 'ID of the Team Environment',
|
||||||
@@ -116,10 +103,13 @@ export class TeamEnvironmentsResolver {
|
|||||||
})
|
})
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<TeamEnvironment> {
|
): Promise<TeamEnvironment> {
|
||||||
return pipe(
|
const teamEnvironment =
|
||||||
this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id),
|
await this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
||||||
TE.getOrElse(throwErr),
|
id,
|
||||||
)();
|
);
|
||||||
|
|
||||||
|
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
|
||||||
|
return teamEnvironment.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => TeamEnvironment, {
|
@Mutation(() => TeamEnvironment, {
|
||||||
@@ -127,7 +117,7 @@ export class TeamEnvironmentsResolver {
|
|||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||||
createDuplicateEnvironment(
|
async createDuplicateEnvironment(
|
||||||
@Args({
|
@Args({
|
||||||
name: 'id',
|
name: 'id',
|
||||||
description: 'ID of the Team Environment',
|
description: 'ID of the Team Environment',
|
||||||
@@ -135,10 +125,12 @@ export class TeamEnvironmentsResolver {
|
|||||||
})
|
})
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<TeamEnvironment> {
|
): Promise<TeamEnvironment> {
|
||||||
return pipe(
|
const res = await this.teamEnvironmentsService.createDuplicateEnvironment(
|
||||||
this.teamEnvironmentsService.createDuplicateEnvironment(id),
|
id,
|
||||||
TE.getOrElse(throwErr),
|
);
|
||||||
)();
|
|
||||||
|
if (E.isLeft(res)) throwErr(res.left);
|
||||||
|
return res.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Subscriptions */
|
/* Subscriptions */
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import { mockDeep, mockReset } from 'jest-mock-extended';
|
|||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { TeamEnvironment } from './team-environments.model';
|
import { TeamEnvironment } from './team-environments.model';
|
||||||
import { TeamEnvironmentsService } from './team-environments.service';
|
import { TeamEnvironmentsService } from './team-environments.service';
|
||||||
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
|
import {
|
||||||
|
JSON_INVALID,
|
||||||
|
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||||
|
TEAM_ENVIRONMENT_SHORT_NAME,
|
||||||
|
} from 'src/errors';
|
||||||
|
|
||||||
const mockPrisma = mockDeep<PrismaService>();
|
const mockPrisma = mockDeep<PrismaService>();
|
||||||
|
|
||||||
@@ -31,125 +35,81 @@ beforeEach(() => {
|
|||||||
|
|
||||||
describe('TeamEnvironmentsService', () => {
|
describe('TeamEnvironmentsService', () => {
|
||||||
describe('getTeamEnvironment', () => {
|
describe('getTeamEnvironment', () => {
|
||||||
test('queries the db with the id', async () => {
|
test('should successfully return a TeamEnvironment with valid ID', async () => {
|
||||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
|
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
|
||||||
|
teamEnvironment,
|
||||||
await teamEnvironmentsService.getTeamEnvironment('123')();
|
|
||||||
|
|
||||||
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: {
|
|
||||||
id: '123',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
test('requests prisma to reject the query promise if not found', async () => {
|
const result = await teamEnvironmentsService.getTeamEnvironment(
|
||||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
|
teamEnvironment.id,
|
||||||
|
|
||||||
await teamEnvironmentsService.getTeamEnvironment('123')();
|
|
||||||
|
|
||||||
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
rejectOnNotFound: true,
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
expect(result).toEqualRight(teamEnvironment);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return a Some of the correct environment if exists', async () => {
|
test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
|
||||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
|
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
|
||||||
|
'RejectOnNotFound',
|
||||||
|
);
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.getTeamEnvironment('123')();
|
const result = await teamEnvironmentsService.getTeamEnvironment(
|
||||||
|
teamEnvironment.id,
|
||||||
expect(result).toEqualSome(teamEnvironment);
|
);
|
||||||
});
|
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
|
|
||||||
test('should return a None if the environment does not exist', async () => {
|
|
||||||
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.getTeamEnvironment('123')();
|
|
||||||
|
|
||||||
expect(result).toBeNone();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createTeamEnvironment', () => {
|
describe('createTeamEnvironment', () => {
|
||||||
test('should create and return a new team environment given a valid name,variable and team ID', async () => {
|
test('should successfully create and return a new team environment given valid inputs', async () => {
|
||||||
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
|
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.createTeamEnvironment(
|
const result = await teamEnvironmentsService.createTeamEnvironment(
|
||||||
teamEnvironment.name,
|
teamEnvironment.name,
|
||||||
teamEnvironment.teamID,
|
teamEnvironment.teamID,
|
||||||
JSON.stringify(teamEnvironment.variables),
|
JSON.stringify(teamEnvironment.variables),
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqual(<TeamEnvironment>{
|
expect(result).toEqualRight({
|
||||||
id: teamEnvironment.id,
|
...teamEnvironment,
|
||||||
name: teamEnvironment.name,
|
|
||||||
teamID: teamEnvironment.teamID,
|
|
||||||
variables: JSON.stringify(teamEnvironment.variables),
|
variables: JSON.stringify(teamEnvironment.variables),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject if given team ID is invalid', async () => {
|
test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
|
||||||
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
|
const result = await teamEnvironmentsService.createTeamEnvironment(
|
||||||
|
'12',
|
||||||
|
teamEnvironment.teamID,
|
||||||
|
JSON.stringify(teamEnvironment.variables),
|
||||||
|
);
|
||||||
|
|
||||||
await expect(
|
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
|
||||||
teamEnvironmentsService.createTeamEnvironment(
|
|
||||||
teamEnvironment.name,
|
|
||||||
'invalidteamid',
|
|
||||||
JSON.stringify(teamEnvironment.variables),
|
|
||||||
),
|
|
||||||
).rejects.toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject if provided team environment name is not a string', async () => {
|
|
||||||
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
teamEnvironmentsService.createTeamEnvironment(
|
|
||||||
null as any,
|
|
||||||
teamEnvironment.teamID,
|
|
||||||
JSON.stringify(teamEnvironment.variables),
|
|
||||||
),
|
|
||||||
).rejects.toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject if provided variable is not a string', async () => {
|
|
||||||
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
teamEnvironmentsService.createTeamEnvironment(
|
|
||||||
teamEnvironment.name,
|
|
||||||
teamEnvironment.teamID,
|
|
||||||
null as any,
|
|
||||||
),
|
|
||||||
).rejects.toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is created successfully', async () => {
|
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is created successfully', async () => {
|
||||||
mockPrisma.teamEnvironment.create.mockResolvedValueOnce(teamEnvironment);
|
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.createTeamEnvironment(
|
const result = await teamEnvironmentsService.createTeamEnvironment(
|
||||||
teamEnvironment.name,
|
teamEnvironment.name,
|
||||||
teamEnvironment.teamID,
|
teamEnvironment.teamID,
|
||||||
JSON.stringify(teamEnvironment.variables),
|
JSON.stringify(teamEnvironment.variables),
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||||
`team_environment/${teamEnvironment.teamID}/created`,
|
`team_environment/${teamEnvironment.teamID}/created`,
|
||||||
result,
|
{
|
||||||
|
...teamEnvironment,
|
||||||
|
variables: JSON.stringify(teamEnvironment.variables),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteTeamEnvironment', () => {
|
describe('deleteTeamEnvironment', () => {
|
||||||
test('should resolve to true given a valid team environment ID', async () => {
|
test('should successfully delete a TeamEnvironment with a valid ID', async () => {
|
||||||
mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment);
|
mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment);
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualRight(true);
|
expect(result).toEqualRight(true);
|
||||||
});
|
});
|
||||||
@@ -159,7 +119,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
|
|
||||||
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
||||||
'invalidid',
|
'invalidid',
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
});
|
});
|
||||||
@@ -169,7 +129,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
|
|
||||||
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||||
`team_environment/${teamEnvironment.teamID}/deleted`,
|
`team_environment/${teamEnvironment.teamID}/deleted`,
|
||||||
@@ -182,7 +142,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('updateVariablesInTeamEnvironment', () => {
|
describe('updateVariablesInTeamEnvironment', () => {
|
||||||
test('should add new variable to a team environment', async () => {
|
test('should successfully add new variable to a team environment', async () => {
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
||||||
...teamEnvironment,
|
...teamEnvironment,
|
||||||
variables: [{ key: 'value' }],
|
variables: [{ key: 'value' }],
|
||||||
@@ -192,7 +152,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
teamEnvironment.name,
|
teamEnvironment.name,
|
||||||
JSON.stringify([{ key: 'value' }]),
|
JSON.stringify([{ key: 'value' }]),
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
expect(result).toEqualRight(<TeamEnvironment>{
|
||||||
...teamEnvironment,
|
...teamEnvironment,
|
||||||
@@ -200,7 +160,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should add new variable to already existing list of variables in a team environment', async () => {
|
test('should successfully add new variable to already existing list of variables in a team environment', async () => {
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
||||||
...teamEnvironment,
|
...teamEnvironment,
|
||||||
variables: [{ key: 'value' }, { key_2: 'value_2' }],
|
variables: [{ key: 'value' }, { key_2: 'value_2' }],
|
||||||
@@ -210,7 +170,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
teamEnvironment.name,
|
teamEnvironment.name,
|
||||||
JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
|
JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
expect(result).toEqualRight(<TeamEnvironment>{
|
||||||
...teamEnvironment,
|
...teamEnvironment,
|
||||||
@@ -218,7 +178,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should edit existing variables in a team environment', async () => {
|
test('should successfully edit existing variables in a team environment', async () => {
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
||||||
...teamEnvironment,
|
...teamEnvironment,
|
||||||
variables: [{ key: '1234' }],
|
variables: [{ key: '1234' }],
|
||||||
@@ -228,7 +188,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
teamEnvironment.name,
|
teamEnvironment.name,
|
||||||
JSON.stringify([{ key: '1234' }]),
|
JSON.stringify([{ key: '1234' }]),
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
expect(result).toEqualRight(<TeamEnvironment>{
|
||||||
...teamEnvironment,
|
...teamEnvironment,
|
||||||
@@ -236,22 +196,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should delete existing variable in a team environment', async () => {
|
test('should successfully edit name of an existing team environment', async () => {
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
|
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
|
||||||
teamEnvironment.id,
|
|
||||||
teamEnvironment.name,
|
|
||||||
JSON.stringify([{}]),
|
|
||||||
)();
|
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
|
||||||
...teamEnvironment,
|
|
||||||
variables: JSON.stringify([{}]),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should edit name of an existing team environment', async () => {
|
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
||||||
...teamEnvironment,
|
...teamEnvironment,
|
||||||
variables: [{ key: '123' }],
|
variables: [{ key: '123' }],
|
||||||
@@ -261,7 +206,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
teamEnvironment.name,
|
teamEnvironment.name,
|
||||||
JSON.stringify([{ key: '123' }]),
|
JSON.stringify([{ key: '123' }]),
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
expect(result).toEqualRight(<TeamEnvironment>{
|
||||||
...teamEnvironment,
|
...teamEnvironment,
|
||||||
@@ -269,14 +214,24 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
|
||||||
|
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
||||||
|
teamEnvironment.id,
|
||||||
|
'12',
|
||||||
|
JSON.stringify([{ key: 'value' }]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
||||||
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
|
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
||||||
'invalidid',
|
'invalidid',
|
||||||
teamEnvironment.name,
|
teamEnvironment.name,
|
||||||
JSON.stringify(teamEnvironment.variables),
|
JSON.stringify(teamEnvironment.variables),
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
});
|
});
|
||||||
@@ -288,7 +243,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
teamEnvironment.name,
|
teamEnvironment.name,
|
||||||
JSON.stringify([{ key: 'value' }]),
|
JSON.stringify([{ key: 'value' }]),
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||||
`team_environment/${teamEnvironment.teamID}/updated`,
|
`team_environment/${teamEnvironment.teamID}/updated`,
|
||||||
@@ -301,13 +256,13 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteAllVariablesFromTeamEnvironment', () => {
|
describe('deleteAllVariablesFromTeamEnvironment', () => {
|
||||||
test('should delete all variables in a team environment', async () => {
|
test('should successfully delete all variables in a team environment', async () => {
|
||||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
|
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
expect(result).toEqualRight(<TeamEnvironment>{
|
||||||
...teamEnvironment,
|
...teamEnvironment,
|
||||||
@@ -315,13 +270,13 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
||||||
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
|
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
|
||||||
|
|
||||||
const result =
|
const result =
|
||||||
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
||||||
'invalidid',
|
'invalidid',
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
});
|
});
|
||||||
@@ -332,7 +287,7 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
const result =
|
const result =
|
||||||
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||||
`team_environment/${teamEnvironment.teamID}/updated`,
|
`team_environment/${teamEnvironment.teamID}/updated`,
|
||||||
@@ -345,33 +300,33 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('createDuplicateEnvironment', () => {
|
describe('createDuplicateEnvironment', () => {
|
||||||
test('should duplicate an existing team environment', async () => {
|
test('should successfully duplicate an existing team environment', async () => {
|
||||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
|
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
|
||||||
teamEnvironment,
|
teamEnvironment,
|
||||||
);
|
);
|
||||||
|
|
||||||
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
|
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
|
||||||
...teamEnvironment,
|
|
||||||
id: 'newid',
|
id: 'newid',
|
||||||
|
...teamEnvironment,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualRight(<TeamEnvironment>{
|
expect(result).toEqualRight(<TeamEnvironment>{
|
||||||
...teamEnvironment,
|
|
||||||
id: 'newid',
|
id: 'newid',
|
||||||
|
...teamEnvironment,
|
||||||
variables: JSON.stringify(teamEnvironment.variables),
|
variables: JSON.stringify(teamEnvironment.variables),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
||||||
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
|
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
});
|
});
|
||||||
@@ -382,19 +337,19 @@ describe('TeamEnvironmentsService', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
|
mockPrisma.teamEnvironment.create.mockResolvedValueOnce({
|
||||||
...teamEnvironment,
|
|
||||||
id: 'newid',
|
id: 'newid',
|
||||||
|
...teamEnvironment,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
||||||
teamEnvironment.id,
|
teamEnvironment.id,
|
||||||
)();
|
);
|
||||||
|
|
||||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||||
`team_environment/${teamEnvironment.teamID}/created`,
|
`team_environment/${teamEnvironment.teamID}/created`,
|
||||||
{
|
{
|
||||||
...teamEnvironment,
|
|
||||||
id: 'newid',
|
id: 'newid',
|
||||||
|
...teamEnvironment,
|
||||||
variables: JSON.stringify([{}]),
|
variables: JSON.stringify([{}]),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { pipe } from 'fp-ts/function';
|
import { TeamEnvironment as DBTeamEnvironment, Prisma } from '@prisma/client';
|
||||||
import * as T from 'fp-ts/Task';
|
|
||||||
import * as TO from 'fp-ts/TaskOption';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import * as A from 'fp-ts/Array';
|
|
||||||
import { Prisma } from '@prisma/client';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||||
import { TeamEnvironment } from './team-environments.model';
|
import { TeamEnvironment } from './team-environments.model';
|
||||||
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
|
import {
|
||||||
|
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||||
|
TEAM_ENVIRONMENT_SHORT_NAME,
|
||||||
|
} from 'src/errors';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
|
import { isValidLength } from 'src/utils';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamEnvironmentsService {
|
export class TeamEnvironmentsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -17,219 +16,218 @@ export class TeamEnvironmentsService {
|
|||||||
private readonly pubsub: PubSubService,
|
private readonly pubsub: PubSubService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
getTeamEnvironment(id: string) {
|
TITLE_LENGTH = 3;
|
||||||
return TO.tryCatch(() =>
|
|
||||||
this.prisma.teamEnvironment.findFirst({
|
/**
|
||||||
where: { id },
|
* TeamEnvironments are saved in the DB in the following way
|
||||||
|
* [{ key: value }, { key: value },....]
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typecast a database TeamEnvironment to a TeamEnvironment model
|
||||||
|
* @param teamEnvironment database TeamEnvironment
|
||||||
|
* @returns TeamEnvironment model
|
||||||
|
*/
|
||||||
|
private cast(teamEnvironment: DBTeamEnvironment): TeamEnvironment {
|
||||||
|
return {
|
||||||
|
id: teamEnvironment.id,
|
||||||
|
name: teamEnvironment.name,
|
||||||
|
teamID: teamEnvironment.teamID,
|
||||||
|
variables: JSON.stringify(teamEnvironment.variables),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get details of a TeamEnvironment.
|
||||||
|
*
|
||||||
|
* @param id TeamEnvironment ID
|
||||||
|
* @returns Either of a TeamEnvironment or error message
|
||||||
|
*/
|
||||||
|
async getTeamEnvironment(id: string) {
|
||||||
|
try {
|
||||||
|
const teamEnvironment =
|
||||||
|
await this.prisma.teamEnvironment.findFirstOrThrow({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
return E.right(teamEnvironment);
|
||||||
|
} catch (error) {
|
||||||
|
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new TeamEnvironment.
|
||||||
|
*
|
||||||
|
* @param name name of new TeamEnvironment
|
||||||
|
* @param teamID teamID of new TeamEnvironment
|
||||||
|
* @param variables JSONified string of contents of new TeamEnvironment
|
||||||
|
* @returns Either of a TeamEnvironment or error message
|
||||||
|
*/
|
||||||
|
async createTeamEnvironment(name: string, teamID: string, variables: string) {
|
||||||
|
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
|
||||||
|
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
|
||||||
|
|
||||||
|
const result = await this.prisma.teamEnvironment.create({
|
||||||
|
data: {
|
||||||
|
name: name,
|
||||||
|
teamID: teamID,
|
||||||
|
variables: JSON.parse(variables),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdTeamEnvironment = this.cast(result);
|
||||||
|
|
||||||
|
this.pubsub.publish(
|
||||||
|
`team_environment/${createdTeamEnvironment.teamID}/created`,
|
||||||
|
createdTeamEnvironment,
|
||||||
|
);
|
||||||
|
|
||||||
|
return E.right(createdTeamEnvironment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a TeamEnvironment.
|
||||||
|
*
|
||||||
|
* @param id TeamEnvironment ID
|
||||||
|
* @returns Either of boolean or error message
|
||||||
|
*/
|
||||||
|
async deleteTeamEnvironment(id: string) {
|
||||||
|
try {
|
||||||
|
const result = await this.prisma.teamEnvironment.delete({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletedTeamEnvironment = this.cast(result);
|
||||||
|
|
||||||
|
this.pubsub.publish(
|
||||||
|
`team_environment/${deletedTeamEnvironment.teamID}/deleted`,
|
||||||
|
deletedTeamEnvironment,
|
||||||
|
);
|
||||||
|
|
||||||
|
return E.right(true);
|
||||||
|
} catch (error) {
|
||||||
|
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a TeamEnvironment.
|
||||||
|
*
|
||||||
|
* @param id TeamEnvironment ID
|
||||||
|
* @param name TeamEnvironment name
|
||||||
|
* @param variables JSONified string of contents of new TeamEnvironment
|
||||||
|
* @returns Either of a TeamEnvironment or error message
|
||||||
|
*/
|
||||||
|
async updateTeamEnvironment(id: string, name: string, variables: string) {
|
||||||
|
try {
|
||||||
|
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
|
||||||
|
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
|
||||||
|
|
||||||
|
const result = await this.prisma.teamEnvironment.update({
|
||||||
|
where: { id: id },
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
variables: JSON.parse(variables),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedTeamEnvironment = this.cast(result);
|
||||||
|
|
||||||
|
this.pubsub.publish(
|
||||||
|
`team_environment/${updatedTeamEnvironment.teamID}/updated`,
|
||||||
|
updatedTeamEnvironment,
|
||||||
|
);
|
||||||
|
|
||||||
|
return E.right(updatedTeamEnvironment);
|
||||||
|
} catch (error) {
|
||||||
|
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear contents of a TeamEnvironment.
|
||||||
|
*
|
||||||
|
* @param id TeamEnvironment ID
|
||||||
|
* @returns Either of a TeamEnvironment or error message
|
||||||
|
*/
|
||||||
|
async deleteAllVariablesFromTeamEnvironment(id: string) {
|
||||||
|
try {
|
||||||
|
const result = await this.prisma.teamEnvironment.update({
|
||||||
|
where: { id: id },
|
||||||
|
data: {
|
||||||
|
variables: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamEnvironment = this.cast(result);
|
||||||
|
|
||||||
|
this.pubsub.publish(
|
||||||
|
`team_environment/${teamEnvironment.teamID}/updated`,
|
||||||
|
teamEnvironment,
|
||||||
|
);
|
||||||
|
|
||||||
|
return E.right(teamEnvironment);
|
||||||
|
} catch (error) {
|
||||||
|
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a duplicate of a existing TeamEnvironment.
|
||||||
|
*
|
||||||
|
* @param id TeamEnvironment ID
|
||||||
|
* @returns Either of a TeamEnvironment or error message
|
||||||
|
*/
|
||||||
|
async createDuplicateEnvironment(id: string) {
|
||||||
|
try {
|
||||||
|
const environment = await this.prisma.teamEnvironment.findFirst({
|
||||||
|
where: {
|
||||||
|
id: id,
|
||||||
|
},
|
||||||
rejectOnNotFound: true,
|
rejectOnNotFound: true,
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
const result = await this.prisma.teamEnvironment.create({
|
||||||
|
data: {
|
||||||
|
name: environment.name,
|
||||||
|
teamID: environment.teamID,
|
||||||
|
variables: environment.variables as Prisma.JsonArray,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const duplicatedTeamEnvironment = this.cast(result);
|
||||||
|
|
||||||
|
this.pubsub.publish(
|
||||||
|
`team_environment/${duplicatedTeamEnvironment.teamID}/created`,
|
||||||
|
duplicatedTeamEnvironment,
|
||||||
|
);
|
||||||
|
|
||||||
|
return E.right(duplicatedTeamEnvironment);
|
||||||
|
} catch (error) {
|
||||||
|
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createTeamEnvironment(name: string, teamID: string, variables: string) {
|
/**
|
||||||
return pipe(
|
* Fetch all TeamEnvironments of a team.
|
||||||
() =>
|
*
|
||||||
this.prisma.teamEnvironment.create({
|
* @param teamID teamID of new TeamEnvironment
|
||||||
data: {
|
* @returns List of TeamEnvironments
|
||||||
name: name,
|
*/
|
||||||
teamID: teamID,
|
async fetchAllTeamEnvironments(teamID: string) {
|
||||||
variables: JSON.parse(variables),
|
const result = await this.prisma.teamEnvironment.findMany({
|
||||||
},
|
where: {
|
||||||
}),
|
teamID: teamID,
|
||||||
T.chainFirst(
|
},
|
||||||
(environment) => () =>
|
});
|
||||||
this.pubsub.publish(
|
const teamEnvironments = result.map((item) => {
|
||||||
`team_environment/${environment.teamID}/created`,
|
return this.cast(item);
|
||||||
<TeamEnvironment>{
|
});
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
T.map((data) => {
|
|
||||||
return <TeamEnvironment>{
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
teamID: data.teamID,
|
|
||||||
variables: JSON.stringify(data.variables),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteTeamEnvironment(id: string) {
|
return teamEnvironments;
|
||||||
return pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.delete({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
() => TEAM_ENVIRONMENT_NOT_FOUND,
|
|
||||||
),
|
|
||||||
TE.chainFirst((environment) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_environment/${environment.teamID}/deleted`,
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.map((data) => true),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTeamEnvironment(id: string, name: string, variables: string) {
|
|
||||||
return pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.update({
|
|
||||||
where: { id: id },
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
variables: JSON.parse(variables),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
() => TEAM_ENVIRONMENT_NOT_FOUND,
|
|
||||||
),
|
|
||||||
TE.chainFirst((environment) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_environment/${environment.teamID}/updated`,
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.map(
|
|
||||||
(environment) =>
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteAllVariablesFromTeamEnvironment(id: string) {
|
|
||||||
return pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.update({
|
|
||||||
where: { id: id },
|
|
||||||
data: {
|
|
||||||
variables: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
() => TEAM_ENVIRONMENT_NOT_FOUND,
|
|
||||||
),
|
|
||||||
TE.chainFirst((environment) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_environment/${environment.teamID}/updated`,
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.map(
|
|
||||||
(environment) =>
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
createDuplicateEnvironment(id: string) {
|
|
||||||
return pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.findFirst({
|
|
||||||
where: {
|
|
||||||
id: id,
|
|
||||||
},
|
|
||||||
rejectOnNotFound: true,
|
|
||||||
}),
|
|
||||||
() => TEAM_ENVIRONMENT_NOT_FOUND,
|
|
||||||
),
|
|
||||||
TE.chain((environment) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.prisma.teamEnvironment.create({
|
|
||||||
data: {
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: environment.variables as Prisma.JsonArray,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.chainFirst((environment) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team_environment/${environment.teamID}/created`,
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.map(
|
|
||||||
(environment) =>
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchAllTeamEnvironments(teamID: string) {
|
|
||||||
return pipe(
|
|
||||||
() =>
|
|
||||||
this.prisma.teamEnvironment.findMany({
|
|
||||||
where: {
|
|
||||||
teamID: teamID,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
T.map(
|
|
||||||
A.map(
|
|
||||||
(environment) =>
|
|
||||||
<TeamEnvironment>{
|
|
||||||
id: environment.id,
|
|
||||||
name: environment.name,
|
|
||||||
teamID: environment.teamID,
|
|
||||||
variables: JSON.stringify(environment.variables),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ export class TeamEnvsTeamResolver {
|
|||||||
description: 'Returns all Team Environments for the given Team',
|
description: 'Returns all Team Environments for the given Team',
|
||||||
})
|
})
|
||||||
teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> {
|
teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> {
|
||||||
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)();
|
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { ArgsType, Field, ID } from '@nestjs/graphql';
|
||||||
|
import { TeamMemberRole } from 'src/team/team.model';
|
||||||
|
|
||||||
|
@ArgsType()
|
||||||
|
export class CreateTeamInvitationArgs {
|
||||||
|
@Field(() => ID, {
|
||||||
|
name: 'teamID',
|
||||||
|
description: 'ID of the Team ID to invite from',
|
||||||
|
})
|
||||||
|
teamID: string;
|
||||||
|
|
||||||
|
@Field({ name: 'inviteeEmail', description: 'Email of the user to invite' })
|
||||||
|
inviteeEmail: string;
|
||||||
|
|
||||||
|
@Field(() => TeamMemberRole, {
|
||||||
|
name: 'inviteeRole',
|
||||||
|
description: 'Role to be given to the user',
|
||||||
|
})
|
||||||
|
inviteeRole: TeamMemberRole;
|
||||||
|
}
|
||||||
@@ -12,15 +12,10 @@ import { TeamInvitation } from './team-invitation.model';
|
|||||||
import { TeamInvitationService } from './team-invitation.service';
|
import { TeamInvitationService } from './team-invitation.service';
|
||||||
import { pipe } from 'fp-ts/function';
|
import { pipe } from 'fp-ts/function';
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
import * as TE from 'fp-ts/TaskEither';
|
||||||
|
import * as E from 'fp-ts/Either';
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
|
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
|
||||||
import { EmailCodec } from 'src/types/Email';
|
import { TEAM_INVITE_NO_INVITE_FOUND, USER_NOT_FOUND } from 'src/errors';
|
||||||
import {
|
|
||||||
INVALID_EMAIL,
|
|
||||||
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
|
||||||
TEAM_INVITE_NO_INVITE_FOUND,
|
|
||||||
USER_NOT_FOUND,
|
|
||||||
} from 'src/errors';
|
|
||||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||||
import { User } from 'src/user/user.model';
|
import { User } from 'src/user/user.model';
|
||||||
import { UseGuards } from '@nestjs/common';
|
import { UseGuards } from '@nestjs/common';
|
||||||
@@ -36,6 +31,8 @@ import { UserService } from 'src/user/user.service';
|
|||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||||
import { SkipThrottle } from '@nestjs/throttler';
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
|
import { CreateTeamInvitationArgs } from './input-type.args';
|
||||||
|
|
||||||
@UseGuards(GqlThrottlerGuard)
|
@UseGuards(GqlThrottlerGuard)
|
||||||
@Resolver(() => TeamInvitation)
|
@Resolver(() => TeamInvitation)
|
||||||
@@ -79,8 +76,8 @@ export class TeamInvitationResolver {
|
|||||||
'Gets the Team Invitation with the given ID, or null if not exists',
|
'Gets the Team Invitation with the given ID, or null if not exists',
|
||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
|
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
|
||||||
teamInvitation(
|
async teamInvitation(
|
||||||
@GqlUser() user: User,
|
@GqlUser() user: AuthUser,
|
||||||
@Args({
|
@Args({
|
||||||
name: 'inviteID',
|
name: 'inviteID',
|
||||||
description: 'ID of the Team Invitation to lookup',
|
description: 'ID of the Team Invitation to lookup',
|
||||||
@@ -88,17 +85,11 @@ export class TeamInvitationResolver {
|
|||||||
})
|
})
|
||||||
inviteID: string,
|
inviteID: string,
|
||||||
): Promise<TeamInvitation> {
|
): Promise<TeamInvitation> {
|
||||||
return pipe(
|
const teamInvitation = await this.teamInvitationService.getInvitation(
|
||||||
this.teamInvitationService.getInvitation(inviteID),
|
inviteID,
|
||||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
);
|
||||||
TE.chainW(
|
if (O.isNone(teamInvitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
|
||||||
TE.fromPredicate(
|
return teamInvitation.value;
|
||||||
(a) => a.inviteeEmail.toLowerCase() === user.email?.toLowerCase(),
|
|
||||||
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => TeamInvitation, {
|
@Mutation(() => TeamInvitation, {
|
||||||
@@ -106,56 +97,19 @@ export class TeamInvitationResolver {
|
|||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER)
|
@RequiresTeamRole(TeamMemberRole.OWNER)
|
||||||
createTeamInvitation(
|
async createTeamInvitation(
|
||||||
@GqlUser()
|
@GqlUser() user: AuthUser,
|
||||||
user: User,
|
@Args() args: CreateTeamInvitationArgs,
|
||||||
|
|
||||||
@Args({
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the Team ID to invite from',
|
|
||||||
type: () => ID,
|
|
||||||
})
|
|
||||||
teamID: string,
|
|
||||||
@Args({
|
|
||||||
name: 'inviteeEmail',
|
|
||||||
description: 'Email of the user to invite',
|
|
||||||
})
|
|
||||||
inviteeEmail: string,
|
|
||||||
@Args({
|
|
||||||
name: 'inviteeRole',
|
|
||||||
type: () => TeamMemberRole,
|
|
||||||
description: 'Role to be given to the user',
|
|
||||||
})
|
|
||||||
inviteeRole: TeamMemberRole,
|
|
||||||
): Promise<TeamInvitation> {
|
): Promise<TeamInvitation> {
|
||||||
return pipe(
|
const teamInvitation = await this.teamInvitationService.createInvitation(
|
||||||
TE.Do,
|
user,
|
||||||
|
args.teamID,
|
||||||
|
args.inviteeEmail,
|
||||||
|
args.inviteeRole,
|
||||||
|
);
|
||||||
|
|
||||||
// Validate email
|
if (E.isLeft(teamInvitation)) throwErr(teamInvitation.left);
|
||||||
TE.bindW('email', () =>
|
return teamInvitation.right;
|
||||||
pipe(
|
|
||||||
EmailCodec.decode(inviteeEmail),
|
|
||||||
TE.fromEither,
|
|
||||||
TE.mapLeft(() => INVALID_EMAIL),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Validate and get Team
|
|
||||||
TE.bindW('team', () => this.teamService.getTeamWithIDTE(teamID)),
|
|
||||||
|
|
||||||
// Create team
|
|
||||||
TE.chainW(({ email, team }) =>
|
|
||||||
this.teamInvitationService.createInvitation(
|
|
||||||
user,
|
|
||||||
team,
|
|
||||||
email,
|
|
||||||
inviteeRole,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// If failed, throw err (so the message is passed) else return value
|
|
||||||
TE.getOrElse(throwErr),
|
|
||||||
)();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => Boolean, {
|
@Mutation(() => Boolean, {
|
||||||
@@ -163,7 +117,7 @@ export class TeamInvitationResolver {
|
|||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
|
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
|
||||||
@RequiresTeamRole(TeamMemberRole.OWNER)
|
@RequiresTeamRole(TeamMemberRole.OWNER)
|
||||||
revokeTeamInvitation(
|
async revokeTeamInvitation(
|
||||||
@Args({
|
@Args({
|
||||||
name: 'inviteID',
|
name: 'inviteID',
|
||||||
type: () => ID,
|
type: () => ID,
|
||||||
@@ -171,19 +125,19 @@ export class TeamInvitationResolver {
|
|||||||
})
|
})
|
||||||
inviteID: string,
|
inviteID: string,
|
||||||
): Promise<true> {
|
): Promise<true> {
|
||||||
return pipe(
|
const isRevoked = await this.teamInvitationService.revokeInvitation(
|
||||||
this.teamInvitationService.revokeInvitation(inviteID),
|
inviteID,
|
||||||
TE.map(() => true as const),
|
);
|
||||||
TE.getOrElse(throwErr),
|
if (E.isLeft(isRevoked)) throwErr(isRevoked.left);
|
||||||
)();
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Mutation(() => TeamMember, {
|
@Mutation(() => TeamMember, {
|
||||||
description: 'Accept an Invitation',
|
description: 'Accept an Invitation',
|
||||||
})
|
})
|
||||||
@UseGuards(GqlAuthGuard, TeamInviteeGuard)
|
@UseGuards(GqlAuthGuard, TeamInviteeGuard)
|
||||||
acceptTeamInvitation(
|
async acceptTeamInvitation(
|
||||||
@GqlUser() user: User,
|
@GqlUser() user: AuthUser,
|
||||||
@Args({
|
@Args({
|
||||||
name: 'inviteID',
|
name: 'inviteID',
|
||||||
type: () => ID,
|
type: () => ID,
|
||||||
@@ -191,10 +145,12 @@ export class TeamInvitationResolver {
|
|||||||
})
|
})
|
||||||
inviteID: string,
|
inviteID: string,
|
||||||
): Promise<TeamMember> {
|
): Promise<TeamMember> {
|
||||||
return pipe(
|
const teamMember = await this.teamInvitationService.acceptInvitation(
|
||||||
this.teamInvitationService.acceptInvitation(inviteID, user),
|
inviteID,
|
||||||
TE.getOrElse(throwErr),
|
user,
|
||||||
)();
|
);
|
||||||
|
if (E.isLeft(teamMember)) throwErr(teamMember.left);
|
||||||
|
return teamMember.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscriptions
|
// Subscriptions
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import * as T from 'fp-ts/Task';
|
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import * as TO from 'fp-ts/TaskOption';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import * as E from 'fp-ts/Either';
|
import * as E from 'fp-ts/Either';
|
||||||
import { pipe, flow, constVoid } from 'fp-ts/function';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { Team, TeamMemberRole } from 'src/team/team.model';
|
import { TeamInvitation as DBTeamInvitation } from '@prisma/client';
|
||||||
import { Email } from 'src/types/Email';
|
import { TeamMember, TeamMemberRole } from 'src/team/team.model';
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { TeamService } from 'src/team/team.service';
|
import { TeamService } from 'src/team/team.service';
|
||||||
import {
|
import {
|
||||||
INVALID_EMAIL,
|
INVALID_EMAIL,
|
||||||
|
TEAM_INVALID_ID,
|
||||||
TEAM_INVITE_ALREADY_MEMBER,
|
TEAM_INVITE_ALREADY_MEMBER,
|
||||||
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
||||||
TEAM_INVITE_MEMBER_HAS_INVITE,
|
TEAM_INVITE_MEMBER_HAS_INVITE,
|
||||||
TEAM_INVITE_NO_INVITE_FOUND,
|
TEAM_INVITE_NO_INVITE_FOUND,
|
||||||
|
TEAM_MEMBER_NOT_FOUND,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { TeamInvitation } from './team-invitation.model';
|
import { TeamInvitation } from './team-invitation.model';
|
||||||
import { MailerService } from 'src/mailer/mailer.service';
|
import { MailerService } from 'src/mailer/mailer.service';
|
||||||
import { UserService } from 'src/user/user.service';
|
import { UserService } from 'src/user/user.service';
|
||||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||||
import { validateEmail } from '../utils';
|
import { validateEmail } from '../utils';
|
||||||
|
import { AuthUser } from 'src/types/AuthUser';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamInvitationService {
|
export class TeamInvitationService {
|
||||||
@@ -32,38 +30,37 @@ export class TeamInvitationService {
|
|||||||
private readonly mailerService: MailerService,
|
private readonly mailerService: MailerService,
|
||||||
|
|
||||||
private readonly pubsub: PubSubService,
|
private readonly pubsub: PubSubService,
|
||||||
) {
|
) {}
|
||||||
this.getInvitation = this.getInvitation.bind(this);
|
|
||||||
|
/**
|
||||||
|
* Cast a DBTeamInvitation to a TeamInvitation
|
||||||
|
* @param dbTeamInvitation database TeamInvitation
|
||||||
|
* @returns TeamInvitation model
|
||||||
|
*/
|
||||||
|
cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation {
|
||||||
|
return {
|
||||||
|
...dbTeamInvitation,
|
||||||
|
inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> {
|
/**
|
||||||
return pipe(
|
* Get the team invite
|
||||||
() =>
|
* @param inviteID invite id
|
||||||
this.prisma.teamInvitation.findUnique({
|
* @returns an Option of team invitation or none
|
||||||
where: {
|
*/
|
||||||
id: inviteID,
|
async getInvitation(inviteID: string) {
|
||||||
},
|
try {
|
||||||
}),
|
const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({
|
||||||
TO.fromTask,
|
where: {
|
||||||
TO.chain(flow(O.fromNullable, TO.fromOption)),
|
id: inviteID,
|
||||||
TO.map((x) => x as TeamInvitation),
|
},
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
getInvitationWithEmail(email: Email, team: Team) {
|
return O.some(this.cast(dbInvitation));
|
||||||
return pipe(
|
} catch (e) {
|
||||||
() =>
|
return O.none;
|
||||||
this.prisma.teamInvitation.findUnique({
|
}
|
||||||
where: {
|
|
||||||
teamID_inviteeEmail: {
|
|
||||||
inviteeEmail: email,
|
|
||||||
teamID: team.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
TO.fromTask,
|
|
||||||
TO.chain(flow(O.fromNullable, TO.fromOption)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,211 +89,162 @@ export class TeamInvitationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createInvitation(
|
/**
|
||||||
creator: User,
|
* Create a team invitation
|
||||||
team: Team,
|
* @param creator creator of the invitation
|
||||||
inviteeEmail: Email,
|
* @param teamID team id
|
||||||
|
* @param inviteeEmail invitee email
|
||||||
|
* @param inviteeRole invitee role
|
||||||
|
* @returns an Either of team invitation or error message
|
||||||
|
*/
|
||||||
|
async createInvitation(
|
||||||
|
creator: AuthUser,
|
||||||
|
teamID: string,
|
||||||
|
inviteeEmail: string,
|
||||||
inviteeRole: TeamMemberRole,
|
inviteeRole: TeamMemberRole,
|
||||||
) {
|
) {
|
||||||
return pipe(
|
// validate email
|
||||||
// Perform all validation checks
|
const isEmailValid = validateEmail(inviteeEmail);
|
||||||
TE.sequenceArray([
|
if (!isEmailValid) return E.left(INVALID_EMAIL);
|
||||||
// creator should be a TeamMember
|
|
||||||
pipe(
|
|
||||||
this.teamService.getTeamMemberTE(team.id, creator.uid),
|
|
||||||
TE.map(constVoid),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Invitee should not be a team member
|
// team ID should valid
|
||||||
pipe(
|
const team = await this.teamService.getTeamWithID(teamID);
|
||||||
async () => await this.userService.findUserByEmail(inviteeEmail),
|
if (!team) return E.left(TEAM_INVALID_ID);
|
||||||
TO.foldW(
|
|
||||||
() => TE.right(undefined), // If no user, short circuit to completion
|
|
||||||
(user) =>
|
|
||||||
pipe(
|
|
||||||
// If user is found, check if team member
|
|
||||||
this.teamService.getTeamMemberTE(team.id, user.uid),
|
|
||||||
TE.foldW(
|
|
||||||
() => TE.right(undefined), // Not team-member, this is good
|
|
||||||
() => TE.left(TEAM_INVITE_ALREADY_MEMBER), // Is team member, not good
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TE.map(constVoid),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Should not have an existing invite
|
// invitation creator should be a TeamMember
|
||||||
pipe(
|
const isTeamMember = await this.teamService.getTeamMember(
|
||||||
this.getInvitationWithEmail(inviteeEmail, team),
|
team.id,
|
||||||
TE.fromTaskOption(() => null),
|
creator.uid,
|
||||||
TE.swap,
|
|
||||||
TE.map(constVoid),
|
|
||||||
TE.mapLeft(() => TEAM_INVITE_MEMBER_HAS_INVITE),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
|
|
||||||
// Create the invitation
|
|
||||||
TE.chainTaskK(
|
|
||||||
() => () =>
|
|
||||||
this.prisma.teamInvitation.create({
|
|
||||||
data: {
|
|
||||||
teamID: team.id,
|
|
||||||
inviteeEmail,
|
|
||||||
inviteeRole,
|
|
||||||
creatorUid: creator.uid,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Send email, this is a side effect
|
|
||||||
TE.chainFirstTaskK((invitation) =>
|
|
||||||
pipe(
|
|
||||||
this.mailerService.sendMail(inviteeEmail, {
|
|
||||||
template: 'team-invitation',
|
|
||||||
variables: {
|
|
||||||
invitee: creator.displayName ?? 'A Hoppscotch User',
|
|
||||||
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${invitation.id}`,
|
|
||||||
invite_team_name: team.name,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
TE.getOrElseW(() => T.of(undefined)), // This value doesn't matter as we don't mind the return value (chainFirst) as long as the task completes
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Send PubSub topic
|
|
||||||
TE.chainFirstTaskK((invitation) =>
|
|
||||||
TE.fromTask(async () => {
|
|
||||||
const inv: TeamInvitation = {
|
|
||||||
id: invitation.id,
|
|
||||||
teamID: invitation.teamID,
|
|
||||||
creatorUid: invitation.creatorUid,
|
|
||||||
inviteeEmail: invitation.inviteeEmail,
|
|
||||||
inviteeRole: TeamMemberRole[invitation.inviteeRole],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.pubsub.publish(`team/${inv.teamID}/invite_added`, inv);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Map to model type
|
|
||||||
TE.map((x) => x as TeamInvitation),
|
|
||||||
);
|
);
|
||||||
}
|
if (!isTeamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
|
||||||
|
|
||||||
revokeInvitation(inviteID: string) {
|
// Checking to see if the invitee is already part of the team or not
|
||||||
return pipe(
|
const inviteeUser = await this.userService.findUserByEmail(inviteeEmail);
|
||||||
// Make sure invite exists
|
if (O.isSome(inviteeUser)) {
|
||||||
this.getInvitation(inviteID),
|
// invitee should not already a member
|
||||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
const isTeamMember = await this.teamService.getTeamMember(
|
||||||
|
team.id,
|
||||||
|
inviteeUser.value.uid,
|
||||||
|
);
|
||||||
|
if (isTeamMember) return E.left(TEAM_INVITE_ALREADY_MEMBER);
|
||||||
|
}
|
||||||
|
|
||||||
// Delete team invitation
|
// check invitee already invited earlier or not
|
||||||
TE.chainTaskK(
|
const teamInvitation = await this.getTeamInviteByEmailAndTeamID(
|
||||||
() => () =>
|
inviteeEmail,
|
||||||
this.prisma.teamInvitation.delete({
|
team.id,
|
||||||
where: {
|
|
||||||
id: inviteID,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Emit Pubsub Event
|
|
||||||
TE.chainFirst((invitation) =>
|
|
||||||
TE.fromTask(() =>
|
|
||||||
this.pubsub.publish(
|
|
||||||
`team/${invitation.teamID}/invite_removed`,
|
|
||||||
invitation.id,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// We are not returning anything
|
|
||||||
TE.map(constVoid),
|
|
||||||
);
|
);
|
||||||
}
|
if (E.isRight(teamInvitation)) return E.left(TEAM_INVITE_MEMBER_HAS_INVITE);
|
||||||
|
|
||||||
getAllInvitationsInTeam(team: Team) {
|
// create the invitation
|
||||||
return pipe(
|
const dbInvitation = await this.prisma.teamInvitation.create({
|
||||||
() =>
|
data: {
|
||||||
this.prisma.teamInvitation.findMany({
|
teamID: team.id,
|
||||||
where: {
|
inviteeEmail,
|
||||||
teamID: team.id,
|
inviteeRole,
|
||||||
},
|
creatorUid: creator.uid,
|
||||||
}),
|
},
|
||||||
T.map((x) => x as TeamInvitation[]),
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
acceptInvitation(inviteID: string, acceptedBy: User) {
|
await this.mailerService.sendEmail(inviteeEmail, {
|
||||||
return pipe(
|
template: 'team-invitation',
|
||||||
TE.Do,
|
variables: {
|
||||||
|
invitee: creator.displayName ?? 'A Hoppscotch User',
|
||||||
|
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
|
||||||
|
invite_team_name: team.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// First get the invitation
|
const invitation = this.cast(dbInvitation);
|
||||||
TE.bindW('invitation', () =>
|
this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation);
|
||||||
pipe(
|
|
||||||
this.getInvitation(inviteID),
|
|
||||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Validation checks
|
return E.right(invitation);
|
||||||
TE.chainFirstW(({ invitation }) =>
|
|
||||||
TE.sequenceArray([
|
|
||||||
// Make sure the invited user is not part of the team
|
|
||||||
pipe(
|
|
||||||
this.teamService.getTeamMemberTE(invitation.teamID, acceptedBy.uid),
|
|
||||||
TE.swap,
|
|
||||||
TE.bimap(
|
|
||||||
() => TEAM_INVITE_ALREADY_MEMBER,
|
|
||||||
constVoid, // The return type is ignored
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Make sure the invited user and accepting user has the same email
|
|
||||||
pipe(
|
|
||||||
undefined,
|
|
||||||
TE.fromPredicate(
|
|
||||||
(a) => acceptedBy.email === invitation.inviteeEmail,
|
|
||||||
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Add the team member
|
|
||||||
// TODO: Somehow bring subscriptions to this ?
|
|
||||||
TE.bindW('teamMember', ({ invitation }) =>
|
|
||||||
pipe(
|
|
||||||
TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.teamService.addMemberToTeam(
|
|
||||||
invitation.teamID,
|
|
||||||
acceptedBy.uid,
|
|
||||||
invitation.inviteeRole,
|
|
||||||
),
|
|
||||||
() => TEAM_INVITE_ALREADY_MEMBER, // Can only fail if Team Member already exists, which we checked, but due to async lets assert that here too
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.chainFirstW(({ invitation }) => this.revokeInvitation(invitation.id)),
|
|
||||||
|
|
||||||
TE.map(({ teamMember }) => teamMember),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the count invitations for a given team.
|
* Revoke a team invitation
|
||||||
* @param teamID team id
|
* @param inviteID invite id
|
||||||
* @returns a count team invitations for a team
|
* @returns an Either of true or error message
|
||||||
*/
|
*/
|
||||||
async getAllTeamInvitations(teamID: string) {
|
async revokeInvitation(inviteID: string) {
|
||||||
const invitations = await this.prisma.teamInvitation.findMany({
|
// check if the invite exists
|
||||||
|
const invitation = await this.getInvitation(inviteID);
|
||||||
|
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
|
||||||
|
|
||||||
|
// delete the invite
|
||||||
|
await this.prisma.teamInvitation.delete({
|
||||||
|
where: {
|
||||||
|
id: inviteID,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pubsub.publish(
|
||||||
|
`team/${invitation.value.teamID}/invite_removed`,
|
||||||
|
invitation.value.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return E.right(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept a team invitation
|
||||||
|
* @param inviteID invite id
|
||||||
|
* @param acceptedBy user who accepted the invitation
|
||||||
|
* @returns an Either of team member or error message
|
||||||
|
*/
|
||||||
|
async acceptInvitation(inviteID: string, acceptedBy: AuthUser) {
|
||||||
|
// check if the invite exists
|
||||||
|
const invitation = await this.getInvitation(inviteID);
|
||||||
|
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
|
||||||
|
|
||||||
|
// make sure the user is not already a member of the team
|
||||||
|
const teamMemberInvitee = await this.teamService.getTeamMember(
|
||||||
|
invitation.value.teamID,
|
||||||
|
acceptedBy.uid,
|
||||||
|
);
|
||||||
|
if (teamMemberInvitee) return E.left(TEAM_INVITE_ALREADY_MEMBER);
|
||||||
|
|
||||||
|
// make sure the user is the same as the invitee
|
||||||
|
if (
|
||||||
|
acceptedBy.email.toLowerCase() !==
|
||||||
|
invitation.value.inviteeEmail.toLowerCase()
|
||||||
|
)
|
||||||
|
return E.left(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
|
||||||
|
|
||||||
|
// add the user to the team
|
||||||
|
let teamMember: TeamMember;
|
||||||
|
try {
|
||||||
|
teamMember = await this.teamService.addMemberToTeam(
|
||||||
|
invitation.value.teamID,
|
||||||
|
acceptedBy.uid,
|
||||||
|
invitation.value.inviteeRole,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
return E.left(TEAM_INVITE_ALREADY_MEMBER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete the invite
|
||||||
|
await this.revokeInvitation(inviteID);
|
||||||
|
|
||||||
|
return E.right(teamMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all team invitations for a given team.
|
||||||
|
* @param teamID team id
|
||||||
|
* @returns array of team invitations for a team
|
||||||
|
*/
|
||||||
|
async getTeamInvitations(teamID: string) {
|
||||||
|
const dbInvitations = await this.prisma.teamInvitation.findMany({
|
||||||
where: {
|
where: {
|
||||||
teamID: teamID,
|
teamID: teamID,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) =>
|
||||||
|
this.cast(dbInvitation),
|
||||||
|
);
|
||||||
|
|
||||||
return invitations;
|
return invitations;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { pipe } from 'fp-ts/function';
|
|
||||||
import { TeamService } from 'src/team/team.service';
|
import { TeamService } from 'src/team/team.service';
|
||||||
import { TeamInvitationService } from './team-invitation.service';
|
import { TeamInvitationService } from './team-invitation.service';
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import * as T from 'fp-ts/Task';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||||
import {
|
import {
|
||||||
BUG_AUTH_NO_USER_CTX,
|
BUG_AUTH_NO_USER_CTX,
|
||||||
BUG_TEAM_INVITE_NO_INVITE_ID,
|
BUG_TEAM_INVITE_NO_INVITE_ID,
|
||||||
TEAM_INVITE_NO_INVITE_FOUND,
|
TEAM_INVITE_NO_INVITE_FOUND,
|
||||||
|
TEAM_MEMBER_NOT_FOUND,
|
||||||
TEAM_NOT_REQUIRED_ROLE,
|
TEAM_NOT_REQUIRED_ROLE,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { throwErr } from 'src/utils';
|
import { throwErr } from 'src/utils';
|
||||||
import { TeamMemberRole } from 'src/team/team.model';
|
import { TeamMemberRole } from 'src/team/team.model';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This guard only allows team owner to execute the resolver
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TeamInviteTeamOwnerGuard implements CanActivate {
|
export class TeamInviteTeamOwnerGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -24,48 +24,30 @@ export class TeamInviteTeamOwnerGuard implements CanActivate {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
return pipe(
|
// Get GQL context
|
||||||
TE.Do,
|
const gqlExecCtx = GqlExecutionContext.create(context);
|
||||||
|
|
||||||
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
|
// Get user
|
||||||
|
const { user } = gqlExecCtx.getContext().req;
|
||||||
|
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
|
||||||
|
|
||||||
// Get the invite
|
// Get the invite
|
||||||
TE.bindW('invite', ({ gqlCtx }) =>
|
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
|
||||||
pipe(
|
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
|
||||||
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
|
|
||||||
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
|
|
||||||
TE.chainW((inviteID) =>
|
|
||||||
pipe(
|
|
||||||
this.teamInviteService.getInvitation(inviteID),
|
|
||||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.bindW('user', ({ gqlCtx }) =>
|
const invitation = await this.teamInviteService.getInvitation(inviteID);
|
||||||
pipe(
|
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
|
||||||
gqlCtx.getContext().req.user,
|
|
||||||
O.fromNullable,
|
|
||||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.bindW('userMember', ({ invite, user }) =>
|
// Fetch team member details of this user
|
||||||
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
|
const teamMember = await this.teamService.getTeamMember(
|
||||||
),
|
invitation.value.teamID,
|
||||||
|
user.uid,
|
||||||
|
);
|
||||||
|
|
||||||
TE.chainW(
|
if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
|
||||||
TE.fromPredicate(
|
if (teamMember.role !== TeamMemberRole.OWNER)
|
||||||
({ userMember }) => userMember.role === TeamMemberRole.OWNER,
|
throwErr(TEAM_NOT_REQUIRED_ROLE);
|
||||||
() => TEAM_NOT_REQUIRED_ROLE,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.fold(
|
return true;
|
||||||
(err) => throwErr(err),
|
|
||||||
() => T.of(true),
|
|
||||||
),
|
|
||||||
)();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { TeamInvitationService } from './team-invitation.service';
|
import { TeamInvitationService } from './team-invitation.service';
|
||||||
import { pipe, flow } from 'fp-ts/function';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import * as T from 'fp-ts/Task';
|
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||||
import {
|
import {
|
||||||
BUG_AUTH_NO_USER_CTX,
|
BUG_AUTH_NO_USER_CTX,
|
||||||
BUG_TEAM_INVITE_NO_INVITE_ID,
|
BUG_TEAM_INVITE_NO_INVITE_ID,
|
||||||
TEAM_INVITE_NOT_VALID_VIEWER,
|
|
||||||
TEAM_INVITE_NO_INVITE_FOUND,
|
TEAM_INVITE_NO_INVITE_FOUND,
|
||||||
|
TEAM_MEMBER_NOT_FOUND,
|
||||||
} from 'src/errors';
|
} from 'src/errors';
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import { throwErr } from 'src/utils';
|
import { throwErr } from 'src/utils';
|
||||||
import { TeamService } from 'src/team/team.service';
|
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()
|
@Injectable()
|
||||||
export class TeamInviteViewerGuard implements CanActivate {
|
export class TeamInviteViewerGuard implements CanActivate {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -23,50 +26,32 @@ export class TeamInviteViewerGuard implements CanActivate {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
return pipe(
|
// Get GQL context
|
||||||
TE.Do,
|
const gqlExecCtx = GqlExecutionContext.create(context);
|
||||||
|
|
||||||
// Get GQL Context
|
// Get user
|
||||||
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
|
const { user } = gqlExecCtx.getContext().req;
|
||||||
|
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
|
||||||
|
|
||||||
// Get user
|
// Get the invite
|
||||||
TE.bindW('user', ({ gqlCtx }) =>
|
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
|
||||||
pipe(
|
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
|
||||||
O.fromNullable(gqlCtx.getContext().req.user),
|
|
||||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Get the invite
|
const invitation = await this.teamInviteService.getInvitation(inviteID);
|
||||||
TE.bindW('invite', ({ gqlCtx }) =>
|
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
|
||||||
pipe(
|
|
||||||
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
|
|
||||||
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
|
|
||||||
TE.chainW(
|
|
||||||
flow(
|
|
||||||
this.teamInviteService.getInvitation,
|
|
||||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Check if the user and the invite email match, else if we can resolver the user as a team member
|
// Check if the user and the invite email match, else if user is a team member
|
||||||
// any better solution ?
|
if (
|
||||||
TE.chainW(({ user, invite }) =>
|
user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
|
||||||
user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase()
|
) {
|
||||||
? TE.of(true)
|
const teamMember = await this.teamService.getTeamMember(
|
||||||
: pipe(
|
invitation.value.teamID,
|
||||||
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
|
user.uid,
|
||||||
TE.map(() => true),
|
);
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
TE.mapLeft((e) =>
|
if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
|
||||||
e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e,
|
}
|
||||||
),
|
|
||||||
|
|
||||||
TE.fold(throwErr, () => T.of(true)),
|
return true;
|
||||||
)();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { TeamInvitationService } from './team-invitation.service';
|
import { TeamInvitationService } from './team-invitation.service';
|
||||||
import { pipe, flow } from 'fp-ts/function';
|
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import * as T from 'fp-ts/Task';
|
|
||||||
import * as TE from 'fp-ts/TaskEither';
|
|
||||||
import { GqlExecutionContext } from '@nestjs/graphql';
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
||||||
import { User } from 'src/user/user.model';
|
|
||||||
import {
|
import {
|
||||||
BUG_AUTH_NO_USER_CTX,
|
BUG_AUTH_NO_USER_CTX,
|
||||||
BUG_TEAM_INVITE_NO_INVITE_ID,
|
BUG_TEAM_INVITE_NO_INVITE_ID,
|
||||||
@@ -24,44 +20,26 @@ export class TeamInviteeGuard implements CanActivate {
|
|||||||
constructor(private readonly teamInviteService: TeamInvitationService) {}
|
constructor(private readonly teamInviteService: TeamInvitationService) {}
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
return pipe(
|
// Get GQL Context
|
||||||
TE.Do,
|
const gqlExecCtx = GqlExecutionContext.create(context);
|
||||||
|
|
||||||
// Get execution context
|
// Get user
|
||||||
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
|
const { user } = gqlExecCtx.getContext().req;
|
||||||
|
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
|
||||||
|
|
||||||
// Get user
|
// Get the invite
|
||||||
TE.bindW('user', ({ gqlCtx }) =>
|
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
|
||||||
pipe(
|
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
|
||||||
O.fromNullable(gqlCtx.getContext().req.user),
|
|
||||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Get invite
|
const invitation = await this.teamInviteService.getInvitation(inviteID);
|
||||||
TE.bindW('invite', ({ gqlCtx }) =>
|
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
|
||||||
pipe(
|
|
||||||
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
|
|
||||||
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
|
|
||||||
TE.chainW(
|
|
||||||
flow(
|
|
||||||
this.teamInviteService.getInvitation,
|
|
||||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Check if the emails match
|
if (
|
||||||
TE.chainW(
|
user.email.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
|
||||||
TE.fromPredicate(
|
) {
|
||||||
({ user, invite }) => user.email === invite.inviteeEmail,
|
throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
|
||||||
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
}
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Fold it to a promise
|
return true;
|
||||||
TE.fold(throwErr, () => T.of(true)),
|
|
||||||
)();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ export class TeamTeamInviteExtResolver {
|
|||||||
complexity: 10,
|
complexity: 10,
|
||||||
})
|
})
|
||||||
teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> {
|
teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> {
|
||||||
return this.teamInviteService.getAllInvitationsInTeam(team)();
|
return this.teamInviteService.getTeamInvitations(team.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import * as E from 'fp-ts/Either';
|
|||||||
import * as A from 'fp-ts/Array';
|
import * as A from 'fp-ts/Array';
|
||||||
import { TeamMemberRole } from './team/team.model';
|
import { TeamMemberRole } from './team/team.model';
|
||||||
import { User } from './user/user.model';
|
import { User } from './user/user.model';
|
||||||
import { JSON_INVALID } from './errors';
|
import {
|
||||||
|
ENV_EMPTY_AUTH_PROVIDERS,
|
||||||
|
ENV_NOT_FOUND_KEY_AUTH_PROVIDERS,
|
||||||
|
ENV_NOT_SUPPORT_AUTH_PROVIDERS,
|
||||||
|
JSON_INVALID,
|
||||||
|
} from './errors';
|
||||||
|
import { AuthProvider } from './auth/helper';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A workaround to throw an exception in an expression.
|
* A workaround to throw an exception in an expression.
|
||||||
@@ -152,3 +158,31 @@ export function isValidLength(title: string, length: number) {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called by bootstrap() in main.ts
|
||||||
|
* It checks if the "VITE_ALLOWED_AUTH_PROVIDERS" environment variable is properly set or not.
|
||||||
|
* If not, it throws an error.
|
||||||
|
*/
|
||||||
|
export function checkEnvironmentAuthProvider() {
|
||||||
|
if (!process.env.hasOwnProperty('VITE_ALLOWED_AUTH_PROVIDERS')) {
|
||||||
|
throw new Error(ENV_NOT_FOUND_KEY_AUTH_PROVIDERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.VITE_ALLOWED_AUTH_PROVIDERS === '') {
|
||||||
|
throw new Error(ENV_EMPTY_AUTH_PROVIDERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const givenAuthProviders = process.env.VITE_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1
packages/hoppscotch-common/assets/icons/star-off.svg
Normal file
1
packages/hoppscotch-common/assets/icons/star-off.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
|
||||||
|
After Width: | Height: | Size: 337 B |
@@ -4,6 +4,7 @@
|
|||||||
@apply after:backface-hidden;
|
@apply after:backface-hidden;
|
||||||
@apply selection:bg-accentDark;
|
@apply selection:bg-accentDark;
|
||||||
@apply selection:text-accentContrast;
|
@apply selection:text-accentContrast;
|
||||||
|
@apply overscroll-none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
@mixin base-theme {
|
@mixin base-theme {
|
||||||
--font-sans: "Inter", sans-serif;
|
--font-sans: "Inter Variable", sans-serif;
|
||||||
--font-mono: "Roboto Mono", monospace;
|
--font-icon: "Material Symbols Rounded Variable";
|
||||||
--font-icon: "Material Icons";
|
--font-mono: "Roboto Mono Variable", monospace;
|
||||||
--font-size-tiny: calc(var(--font-size-body) - 0.062rem);
|
--font-size-tiny: calc(var(--font-size-body) - 0.062rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"choose_file": "Choose a file",
|
"choose_file": "Choose a file",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
|
"clear_history": "Clear All History",
|
||||||
"clear_all": "Clear all",
|
"clear_all": "Clear all",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"connect": "Connect",
|
"connect": "Connect",
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
"open_workspace": "Open workspace",
|
"open_workspace": "Open workspace",
|
||||||
"paste": "Paste",
|
"paste": "Paste",
|
||||||
"prettify": "Prettify",
|
"prettify": "Prettify",
|
||||||
|
"rename": "Rename",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"restore": "Restore",
|
"restore": "Restore",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -131,6 +133,7 @@
|
|||||||
"renamed": "Collection renamed",
|
"renamed": "Collection renamed",
|
||||||
"request_in_use": "Request in use",
|
"request_in_use": "Request in use",
|
||||||
"save_as": "Save as",
|
"save_as": "Save as",
|
||||||
|
"save_to_collection": "Save to Collection",
|
||||||
"select": "Select a Collection",
|
"select": "Select a Collection",
|
||||||
"select_location": "Select location",
|
"select_location": "Select location",
|
||||||
"select_team": "Select a team",
|
"select_team": "Select a team",
|
||||||
@@ -148,8 +151,14 @@
|
|||||||
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?",
|
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?",
|
||||||
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
|
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
|
||||||
"save_unsaved_tab": "Do you want to save changes made in this tab?",
|
"save_unsaved_tab": "Do you want to save changes made in this tab?",
|
||||||
|
"close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.",
|
||||||
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
|
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
|
||||||
},
|
},
|
||||||
|
"context_menu": {
|
||||||
|
"set_environment_variable": "Set as variable",
|
||||||
|
"add_parameter": "Add to parameter",
|
||||||
|
"open_link_in_new_tab": "Open link in new tab"
|
||||||
|
},
|
||||||
"count": {
|
"count": {
|
||||||
"header": "Header {count}",
|
"header": "Header {count}",
|
||||||
"message": "Message {count}",
|
"message": "Message {count}",
|
||||||
@@ -173,6 +182,7 @@
|
|||||||
"folder": "Folder is empty",
|
"folder": "Folder is empty",
|
||||||
"headers": "This request does not have any headers",
|
"headers": "This request does not have any headers",
|
||||||
"history": "History is empty",
|
"history": "History is empty",
|
||||||
|
"history_suggestions": "History does not have any matching entries",
|
||||||
"invites": "Invite list is empty",
|
"invites": "Invite list is empty",
|
||||||
"members": "Team is empty",
|
"members": "Team is empty",
|
||||||
"parameters": "This request does not have any parameters",
|
"parameters": "This request does not have any parameters",
|
||||||
@@ -193,16 +203,23 @@
|
|||||||
"created": "Environment created",
|
"created": "Environment created",
|
||||||
"deleted": "Environment deletion",
|
"deleted": "Environment deletion",
|
||||||
"edit": "Edit Environment",
|
"edit": "Edit Environment",
|
||||||
|
"global": "Global",
|
||||||
"invalid_name": "Please provide a name for the environment",
|
"invalid_name": "Please provide a name for the environment",
|
||||||
"my_environments": "My Environments",
|
"my_environments": "My Environments",
|
||||||
|
"name": "Name",
|
||||||
"nested_overflow": "nested environment variables are limited to 10 levels",
|
"nested_overflow": "nested environment variables are limited to 10 levels",
|
||||||
"new": "New Environment",
|
"new": "New Environment",
|
||||||
"no_environment": "No environment",
|
"no_environment": "No environment",
|
||||||
"no_environment_description": "No environments were selected. Choose what to do with the following variables.",
|
"no_environment_description": "No environments were selected. Choose what to do with the following variables.",
|
||||||
|
"replace_with_variable": "Replace with variable",
|
||||||
|
"scope": "Scope",
|
||||||
"select": "Select environment",
|
"select": "Select environment",
|
||||||
|
"set_as_environment": "Set as environment",
|
||||||
"team_environments": "Team Environments",
|
"team_environments": "Team Environments",
|
||||||
"title": "Environments",
|
"title": "Environments",
|
||||||
"updated": "Environment updated",
|
"updated": "Environment updated",
|
||||||
|
"value": "Value",
|
||||||
|
"variable": "Variable",
|
||||||
"variable_list": "Variable List"
|
"variable_list": "Variable List"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
@@ -418,6 +435,7 @@
|
|||||||
"payload": "Payload",
|
"payload": "Payload",
|
||||||
"query": "Query",
|
"query": "Query",
|
||||||
"raw_body": "Raw Request Body",
|
"raw_body": "Raw Request Body",
|
||||||
|
"rename": "Rename Request",
|
||||||
"renamed": "Request renamed",
|
"renamed": "Request renamed",
|
||||||
"run": "Run",
|
"run": "Run",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -582,6 +600,11 @@
|
|||||||
"log": "Log",
|
"log": "Log",
|
||||||
"url": "URL"
|
"url": "URL"
|
||||||
},
|
},
|
||||||
|
"spotlight": {
|
||||||
|
"section": {
|
||||||
|
"user": "User"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sse": {
|
"sse": {
|
||||||
"event_type": "Event type",
|
"event_type": "Event type",
|
||||||
"log": "Log",
|
"log": "Log",
|
||||||
@@ -639,8 +662,11 @@
|
|||||||
"tab": {
|
"tab": {
|
||||||
"authorization": "Authorization",
|
"authorization": "Authorization",
|
||||||
"body": "Body",
|
"body": "Body",
|
||||||
|
"close": "Close Tab",
|
||||||
|
"close_others": "Close other Tabs",
|
||||||
"collections": "Collections",
|
"collections": "Collections",
|
||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
|
"duplicate": "Duplicate Tab",
|
||||||
"environments": "Environments",
|
"environments": "Environments",
|
||||||
"headers": "Headers",
|
"headers": "Headers",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
"edit": "編輯",
|
"edit": "編輯",
|
||||||
"filter": "篩選回應",
|
"filter": "篩選回應",
|
||||||
"go_back": "返回",
|
"go_back": "返回",
|
||||||
"go_forward": "Go forward",
|
"go_forward": "向前",
|
||||||
"group_by": "分組方式",
|
"group_by": "分組方式",
|
||||||
"label": "標籤",
|
"label": "標籤",
|
||||||
"learn_more": "瞭解更多",
|
"learn_more": "瞭解更多",
|
||||||
@@ -117,37 +117,37 @@
|
|||||||
"username": "使用者名稱"
|
"username": "使用者名稱"
|
||||||
},
|
},
|
||||||
"collection": {
|
"collection": {
|
||||||
"created": "組合已建立",
|
"created": "集合已建立",
|
||||||
"different_parent": "Cannot reorder collection with different parent",
|
"different_parent": "無法為父集合不同的集合重新排序",
|
||||||
"edit": "編輯組合",
|
"edit": "編輯集合",
|
||||||
"invalid_name": "請提供有效的組合名稱",
|
"invalid_name": "請提供有效的集合名稱",
|
||||||
"invalid_root_move": "Collection already in the root",
|
"invalid_root_move": "集合已在根目錄",
|
||||||
"moved": "Moved Successfully",
|
"moved": "移動成功",
|
||||||
"my_collections": "我的組合",
|
"my_collections": "我的集合",
|
||||||
"name": "我的新組合",
|
"name": "我的新集合",
|
||||||
"name_length_insufficient": "組合名稱至少要有 3 個字元。",
|
"name_length_insufficient": "集合名稱至少要有 3 個字元。",
|
||||||
"new": "建立組合",
|
"new": "建立集合",
|
||||||
"order_changed": "Collection Order Updated",
|
"order_changed": "集合順序已更新",
|
||||||
"renamed": "組合已重新命名",
|
"renamed": "集合已重新命名",
|
||||||
"request_in_use": "請求正在使用中",
|
"request_in_use": "請求正在使用中",
|
||||||
"save_as": "另存為",
|
"save_as": "另存為",
|
||||||
"select": "選擇一個組合",
|
"select": "選擇一個集合",
|
||||||
"select_location": "選擇位置",
|
"select_location": "選擇位置",
|
||||||
"select_team": "選擇一個團隊",
|
"select_team": "選擇一個團隊",
|
||||||
"team_collections": "團隊組合"
|
"team_collections": "團隊集合"
|
||||||
},
|
},
|
||||||
"confirm": {
|
"confirm": {
|
||||||
"exit_team": "您確定要離開此團隊嗎?",
|
"exit_team": "您確定要離開此團隊嗎?",
|
||||||
"logout": "您確定要登出嗎?",
|
"logout": "您確定要登出嗎?",
|
||||||
"remove_collection": "您確定要永久刪除該組合嗎?",
|
"remove_collection": "您確定要永久刪除該集合嗎?",
|
||||||
"remove_environment": "您確定要永久刪除該環境嗎?",
|
"remove_environment": "您確定要永久刪除該環境嗎?",
|
||||||
"remove_folder": "您確定要永久刪除該資料夾嗎?",
|
"remove_folder": "您確定要永久刪除該資料夾嗎?",
|
||||||
"remove_history": "您確定要永久刪除全部歷史記錄嗎?",
|
"remove_history": "您確定要永久刪除全部歷史記錄嗎?",
|
||||||
"remove_request": "您確定要永久刪除該請求嗎?",
|
"remove_request": "您確定要永久刪除該請求嗎?",
|
||||||
"remove_team": "您確定要刪除該團隊嗎?",
|
"remove_team": "您確定要刪除該團隊嗎?",
|
||||||
"remove_telemetry": "您確定要退出遙測服務嗎?",
|
"remove_telemetry": "您確定要退出遙測服務嗎?",
|
||||||
"request_change": "您確定要捨棄當前請求嗎?未儲存的變更將遺失。",
|
"request_change": "您確定要捨棄目前的請求嗎?未儲存的變更將遺失。",
|
||||||
"save_unsaved_tab": "Do you want to save changes made in this tab?",
|
"save_unsaved_tab": "您要儲存在此分頁做出的改動嗎?",
|
||||||
"sync": "您想從雲端恢復您的工作區嗎?這將丟棄您的本地進度。"
|
"sync": "您想從雲端恢復您的工作區嗎?這將丟棄您的本地進度。"
|
||||||
},
|
},
|
||||||
"count": {
|
"count": {
|
||||||
@@ -160,13 +160,13 @@
|
|||||||
},
|
},
|
||||||
"documentation": {
|
"documentation": {
|
||||||
"generate": "產生文件",
|
"generate": "產生文件",
|
||||||
"generate_message": "匯入 Hoppscotch 組合以隨時隨地產生 API 文件。"
|
"generate_message": "匯入 Hoppscotch 集合以隨時隨地產生 API 文件。"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"authorization": "該請求沒有使用任何授權",
|
"authorization": "該請求沒有使用任何授權",
|
||||||
"body": "該請求沒有任何請求主體",
|
"body": "該請求沒有任何請求主體",
|
||||||
"collection": "組合為空",
|
"collection": "集合為空",
|
||||||
"collections": "組合為空",
|
"collections": "集合為空",
|
||||||
"documentation": "連線到 GraphQL 端點以檢視文件",
|
"documentation": "連線到 GraphQL 端點以檢視文件",
|
||||||
"endpoint": "端點不能留空",
|
"endpoint": "端點不能留空",
|
||||||
"environments": "環境為空",
|
"environments": "環境為空",
|
||||||
@@ -209,7 +209,7 @@
|
|||||||
"browser_support_sse": "此瀏覽器似乎不支援 SSE。",
|
"browser_support_sse": "此瀏覽器似乎不支援 SSE。",
|
||||||
"check_console_details": "檢查控制台日誌以獲悉詳情",
|
"check_console_details": "檢查控制台日誌以獲悉詳情",
|
||||||
"curl_invalid_format": "cURL 格式不正確",
|
"curl_invalid_format": "cURL 格式不正確",
|
||||||
"danger_zone": "Danger zone",
|
"danger_zone": "危險地帶",
|
||||||
"delete_account": "您的帳號目前為這些團隊的擁有者:",
|
"delete_account": "您的帳號目前為這些團隊的擁有者:",
|
||||||
"delete_account_description": "您在刪除帳號前必須先將您自己從團隊中移除、轉移擁有權,或是刪除團隊。",
|
"delete_account_description": "您在刪除帳號前必須先將您自己從團隊中移除、轉移擁有權,或是刪除團隊。",
|
||||||
"empty_req_name": "空請求名稱",
|
"empty_req_name": "空請求名稱",
|
||||||
@@ -277,38 +277,38 @@
|
|||||||
"tests": "編寫測試指令碼以自動除錯。"
|
"tests": "編寫測試指令碼以自動除錯。"
|
||||||
},
|
},
|
||||||
"hide": {
|
"hide": {
|
||||||
"collection": "隱藏組合面板",
|
"collection": "隱藏集合面板",
|
||||||
"more": "隱藏更多",
|
"more": "隱藏更多",
|
||||||
"preview": "隱藏預覽",
|
"preview": "隱藏預覽",
|
||||||
"sidebar": "隱藏側邊欄"
|
"sidebar": "隱藏側邊欄"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"collections": "匯入組合",
|
"collections": "匯入集合",
|
||||||
"curl": "匯入 cURL",
|
"curl": "匯入 cURL",
|
||||||
"failed": "匯入失敗",
|
"failed": "匯入失敗",
|
||||||
"from_gist": "從 Gist 匯入",
|
"from_gist": "從 Gist 匯入",
|
||||||
"from_gist_description": "從 Gist 網址匯入",
|
"from_gist_description": "從 Gist 網址匯入",
|
||||||
"from_insomnia": "從 Insomnia 匯入",
|
"from_insomnia": "從 Insomnia 匯入",
|
||||||
"from_insomnia_description": "從 Insomnia 組合匯入",
|
"from_insomnia_description": "從 Insomnia 集合匯入",
|
||||||
"from_json": "從 Hoppscotch 匯入",
|
"from_json": "從 Hoppscotch 匯入",
|
||||||
"from_json_description": "從 Hoppscotch 組合檔匯入",
|
"from_json_description": "從 Hoppscotch 集合檔匯入",
|
||||||
"from_my_collections": "從我的組合匯入",
|
"from_my_collections": "從我的集合匯入",
|
||||||
"from_my_collections_description": "從我的組合檔匯入",
|
"from_my_collections_description": "從我的集合檔匯入",
|
||||||
"from_openapi": "從 OpenAPI 匯入",
|
"from_openapi": "從 OpenAPI 匯入",
|
||||||
"from_openapi_description": "從 OpenAPI 規格檔 (YML/JSON) 匯入",
|
"from_openapi_description": "從 OpenAPI 規格檔 (YML/JSON) 匯入",
|
||||||
"from_postman": "從 Postman 匯入",
|
"from_postman": "從 Postman 匯入",
|
||||||
"from_postman_description": "從 Postman 組合匯入",
|
"from_postman_description": "從 Postman 集合匯入",
|
||||||
"from_url": "從網址匯入",
|
"from_url": "從網址匯入",
|
||||||
"gist_url": "輸入 Gist 網址",
|
"gist_url": "輸入 Gist 網址",
|
||||||
"import_from_url_invalid_fetch": "無法從網址取得資料",
|
"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_invalid_type": "不支援此類型。可接受的值為 'hoppscotch'、'openapi'、'postman'、'insomnia'",
|
||||||
"import_from_url_success": "已匯入組合",
|
"import_from_url_success": "已匯入集合",
|
||||||
"json_description": "從 Hoppscotch 組合 JSON 檔匯入組合",
|
"json_description": "從 Hoppscotch 集合 JSON 檔匯入集合",
|
||||||
"title": "匯入"
|
"title": "匯入"
|
||||||
},
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
"collapse_collection": "隱藏或顯示組合",
|
"collapse_collection": "隱藏或顯示集合",
|
||||||
"collapse_sidebar": "隱藏或顯示側邊欄",
|
"collapse_sidebar": "隱藏或顯示側邊欄",
|
||||||
"column": "垂直版面",
|
"column": "垂直版面",
|
||||||
"name": "配置",
|
"name": "配置",
|
||||||
@@ -316,8 +316,8 @@
|
|||||||
"zen_mode": "專注模式"
|
"zen_mode": "專注模式"
|
||||||
},
|
},
|
||||||
"modal": {
|
"modal": {
|
||||||
"close_unsaved_tab": "You have unsaved changes",
|
"close_unsaved_tab": "您有未儲存的改動",
|
||||||
"collections": "組合",
|
"collections": "集合",
|
||||||
"confirm": "確認",
|
"confirm": "確認",
|
||||||
"edit_request": "編輯請求",
|
"edit_request": "編輯請求",
|
||||||
"import_export": "匯入/匯出"
|
"import_export": "匯入/匯出"
|
||||||
@@ -374,9 +374,9 @@
|
|||||||
"email_verification_mail": "已將驗證信寄送至您的電子郵件地址。請點擊信中連結以驗證您的電子郵件地址。",
|
"email_verification_mail": "已將驗證信寄送至您的電子郵件地址。請點擊信中連結以驗證您的電子郵件地址。",
|
||||||
"no_permission": "您沒有權限執行此操作。",
|
"no_permission": "您沒有權限執行此操作。",
|
||||||
"owner": "擁有者",
|
"owner": "擁有者",
|
||||||
"owner_description": "擁有者可以新增、編輯和刪除請求、組合和團隊成員。",
|
"owner_description": "擁有者可以新增、編輯和刪除請求、集合和團隊成員。",
|
||||||
"roles": "角色",
|
"roles": "角色",
|
||||||
"roles_description": "角色用來控制對共用組合的存取權。",
|
"roles_description": "角色用來控制對共用集合的存取權。",
|
||||||
"updated": "已更新個人檔案",
|
"updated": "已更新個人檔案",
|
||||||
"viewer": "檢視者",
|
"viewer": "檢視者",
|
||||||
"viewer_description": "檢視者只能檢視和使用請求。"
|
"viewer_description": "檢視者只能檢視和使用請求。"
|
||||||
@@ -396,8 +396,8 @@
|
|||||||
"text": "文字"
|
"text": "文字"
|
||||||
},
|
},
|
||||||
"copy_link": "複製連結",
|
"copy_link": "複製連結",
|
||||||
"different_collection": "Cannot reorder requests from different collections",
|
"different_collection": "無法重新排列來自不同集合的請求",
|
||||||
"duplicated": "Request duplicated",
|
"duplicated": "已複製請求",
|
||||||
"duration": "持續時間",
|
"duration": "持續時間",
|
||||||
"enter_curl": "輸入 cURL",
|
"enter_curl": "輸入 cURL",
|
||||||
"generate_code": "產生程式碼",
|
"generate_code": "產生程式碼",
|
||||||
@@ -405,10 +405,10 @@
|
|||||||
"header_list": "請求標頭列表",
|
"header_list": "請求標頭列表",
|
||||||
"invalid_name": "請提供請求名稱",
|
"invalid_name": "請提供請求名稱",
|
||||||
"method": "方法",
|
"method": "方法",
|
||||||
"moved": "Request moved",
|
"moved": "已移動請求",
|
||||||
"name": "請求名稱",
|
"name": "請求名稱",
|
||||||
"new": "新請求",
|
"new": "新請求",
|
||||||
"order_changed": "Request Order Updated",
|
"order_changed": "已更新請求順序",
|
||||||
"override": "覆寫",
|
"override": "覆寫",
|
||||||
"override_help": "在標頭設置 <kbd>Content-Type</kbd>",
|
"override_help": "在標頭設置 <kbd>Content-Type</kbd>",
|
||||||
"overriden": "已覆寫",
|
"overriden": "已覆寫",
|
||||||
@@ -432,7 +432,7 @@
|
|||||||
"view_my_links": "檢視我的連結"
|
"view_my_links": "檢視我的連結"
|
||||||
},
|
},
|
||||||
"response": {
|
"response": {
|
||||||
"audio": "Audio",
|
"audio": "音訊",
|
||||||
"body": "回應本體",
|
"body": "回應本體",
|
||||||
"filter_response_body": "篩選 JSON 回應本體 (使用 JSONPath 語法)",
|
"filter_response_body": "篩選 JSON 回應本體 (使用 JSONPath 語法)",
|
||||||
"headers": "回應標頭",
|
"headers": "回應標頭",
|
||||||
@@ -446,7 +446,7 @@
|
|||||||
"status": "狀態",
|
"status": "狀態",
|
||||||
"time": "時間",
|
"time": "時間",
|
||||||
"title": "回應",
|
"title": "回應",
|
||||||
"video": "Video",
|
"video": "視訊",
|
||||||
"waiting_for_connection": "等待連線",
|
"waiting_for_connection": "等待連線",
|
||||||
"xml": "XML"
|
"xml": "XML"
|
||||||
},
|
},
|
||||||
@@ -494,7 +494,7 @@
|
|||||||
"short_codes_description": "我們為您打造的快捷碼。",
|
"short_codes_description": "我們為您打造的快捷碼。",
|
||||||
"sidebar_on_left": "左側邊欄",
|
"sidebar_on_left": "左側邊欄",
|
||||||
"sync": "同步",
|
"sync": "同步",
|
||||||
"sync_collections": "組合",
|
"sync_collections": "集合",
|
||||||
"sync_description": "這些設定會同步到雲端。",
|
"sync_description": "這些設定會同步到雲端。",
|
||||||
"sync_environments": "環境",
|
"sync_environments": "環境",
|
||||||
"sync_history": "歷史",
|
"sync_history": "歷史",
|
||||||
@@ -551,7 +551,7 @@
|
|||||||
"previous_method": "選擇上一個方法",
|
"previous_method": "選擇上一個方法",
|
||||||
"put_method": "選擇 PUT 方法",
|
"put_method": "選擇 PUT 方法",
|
||||||
"reset_request": "重置請求",
|
"reset_request": "重置請求",
|
||||||
"save_to_collections": "儲存到組合",
|
"save_to_collections": "儲存到集合",
|
||||||
"send_request": "傳送請求",
|
"send_request": "傳送請求",
|
||||||
"title": "請求"
|
"title": "請求"
|
||||||
},
|
},
|
||||||
@@ -570,7 +570,7 @@
|
|||||||
},
|
},
|
||||||
"show": {
|
"show": {
|
||||||
"code": "顯示程式碼",
|
"code": "顯示程式碼",
|
||||||
"collection": "顯示組合面板",
|
"collection": "顯示集合面板",
|
||||||
"more": "顯示更多",
|
"more": "顯示更多",
|
||||||
"sidebar": "顯示側邊欄"
|
"sidebar": "顯示側邊欄"
|
||||||
},
|
},
|
||||||
@@ -639,9 +639,9 @@
|
|||||||
"tab": {
|
"tab": {
|
||||||
"authorization": "授權",
|
"authorization": "授權",
|
||||||
"body": "請求本體",
|
"body": "請求本體",
|
||||||
"collections": "組合",
|
"collections": "集合",
|
||||||
"documentation": "幫助文件",
|
"documentation": "幫助文件",
|
||||||
"environments": "Environments",
|
"environments": "環境",
|
||||||
"headers": "請求標頭",
|
"headers": "請求標頭",
|
||||||
"history": "歷史記錄",
|
"history": "歷史記錄",
|
||||||
"mqtt": "MQTT",
|
"mqtt": "MQTT",
|
||||||
@@ -666,7 +666,7 @@
|
|||||||
"email_do_not_match": "電子信箱與您的帳號資料不一致。請聯絡您的團隊擁有者。",
|
"email_do_not_match": "電子信箱與您的帳號資料不一致。請聯絡您的團隊擁有者。",
|
||||||
"exit": "退出團隊",
|
"exit": "退出團隊",
|
||||||
"exit_disabled": "團隊擁有者無法退出團隊",
|
"exit_disabled": "團隊擁有者無法退出團隊",
|
||||||
"invalid_coll_id": "Invalid collection ID",
|
"invalid_coll_id": "集合 ID 無效",
|
||||||
"invalid_email_format": "電子信箱格式無效",
|
"invalid_email_format": "電子信箱格式無效",
|
||||||
"invalid_id": "團隊 ID 無效。請聯絡您的團隊擁有者。",
|
"invalid_id": "團隊 ID 無效。請聯絡您的團隊擁有者。",
|
||||||
"invalid_invite_link": "邀請連結無效",
|
"invalid_invite_link": "邀請連結無效",
|
||||||
@@ -690,21 +690,21 @@
|
|||||||
"member_removed": "使用者已移除",
|
"member_removed": "使用者已移除",
|
||||||
"member_role_updated": "使用者角色已更新",
|
"member_role_updated": "使用者角色已更新",
|
||||||
"members": "成員",
|
"members": "成員",
|
||||||
"more_members": "+{count} more",
|
"more_members": "還有 {count} 位",
|
||||||
"name_length_insufficient": "團隊名稱至少為 6 個字元",
|
"name_length_insufficient": "團隊名稱至少為 6 個字元",
|
||||||
"name_updated": "團隊名稱已更新",
|
"name_updated": "團隊名稱已更新",
|
||||||
"new": "新團隊",
|
"new": "新團隊",
|
||||||
"new_created": "已建立新團隊",
|
"new_created": "已建立新團隊",
|
||||||
"new_name": "我的新團隊",
|
"new_name": "我的新團隊",
|
||||||
"no_access": "您沒有編輯組合的許可權",
|
"no_access": "您沒有編輯集合的許可權",
|
||||||
"no_invite_found": "未找到邀請。請聯絡您的團隊擁有者。",
|
"no_invite_found": "未找到邀請。請聯絡您的團隊擁有者。",
|
||||||
"no_request_found": "Request not found.",
|
"no_request_found": "找不到請求。",
|
||||||
"not_found": "找不到團隊。請聯絡您的團隊擁有者。",
|
"not_found": "找不到團隊。請聯絡您的團隊擁有者。",
|
||||||
"not_valid_viewer": "您不是一個有效的檢視者。請聯絡您的團隊擁有者。",
|
"not_valid_viewer": "您不是一個有效的檢視者。請聯絡您的團隊擁有者。",
|
||||||
"parent_coll_move": "Cannot move collection to a child collection",
|
"parent_coll_move": "無法將集合移動至子集合",
|
||||||
"pending_invites": "待定邀請",
|
"pending_invites": "待定邀請",
|
||||||
"permissions": "許可權",
|
"permissions": "許可權",
|
||||||
"same_target_destination": "Same target and destination",
|
"same_target_destination": "目標和目的地相同",
|
||||||
"saved": "團隊已儲存",
|
"saved": "團隊已儲存",
|
||||||
"select_a_team": "選擇團隊",
|
"select_a_team": "選擇團隊",
|
||||||
"title": "團隊",
|
"title": "團隊",
|
||||||
@@ -734,9 +734,9 @@
|
|||||||
"url": "網址"
|
"url": "網址"
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"change": "Change workspace",
|
"change": "切換工作區",
|
||||||
"personal": "My Workspace",
|
"personal": "我的工作區",
|
||||||
"team": "Team Workspace",
|
"team": "團隊工作區",
|
||||||
"title": "Workspaces"
|
"title": "工作區"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@hoppscotch/common",
|
"name": "@hoppscotch/common",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2023.4.7",
|
"version": "2023.4.8",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm exec npm-run-all -p -l dev:*",
|
"dev": "pnpm exec npm-run-all -p -l dev:*",
|
||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
@@ -33,6 +33,9 @@
|
|||||||
"@codemirror/search": "^6.0.0",
|
"@codemirror/search": "^6.0.0",
|
||||||
"@codemirror/state": "^6.1.0",
|
"@codemirror/state": "^6.1.0",
|
||||||
"@codemirror/view": "^6.0.2",
|
"@codemirror/view": "^6.0.2",
|
||||||
|
"@fontsource-variable/inter": "^5.0.5",
|
||||||
|
"@fontsource-variable/material-symbols-rounded": "^5.0.5",
|
||||||
|
"@fontsource-variable/roboto-mono": "^5.0.6",
|
||||||
"@hoppscotch/codemirror-lang-graphql": "workspace:^",
|
"@hoppscotch/codemirror-lang-graphql": "workspace:^",
|
||||||
"@hoppscotch/data": "workspace:^",
|
"@hoppscotch/data": "workspace:^",
|
||||||
"@hoppscotch/js-sandbox": "workspace:^",
|
"@hoppscotch/js-sandbox": "workspace:^",
|
||||||
@@ -46,7 +49,7 @@
|
|||||||
"@urql/exchange-auth": "^0.1.7",
|
"@urql/exchange-auth": "^0.1.7",
|
||||||
"@urql/exchange-graphcache": "^4.4.3",
|
"@urql/exchange-graphcache": "^4.4.3",
|
||||||
"@vitejs/plugin-legacy": "^2.3.0",
|
"@vitejs/plugin-legacy": "^2.3.0",
|
||||||
"@vueuse/core": "^8.7.5",
|
"@vueuse/core": "^8.9.4",
|
||||||
"@vueuse/head": "^0.7.9",
|
"@vueuse/head": "^0.7.9",
|
||||||
"acorn-walk": "^8.2.0",
|
"acorn-walk": "^8.2.0",
|
||||||
"axios": "^0.21.4",
|
"axios": "^0.21.4",
|
||||||
@@ -67,6 +70,7 @@
|
|||||||
"jsonpath-plus": "^7.0.0",
|
"jsonpath-plus": "^7.0.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lossless-json": "^2.0.8",
|
"lossless-json": "^2.0.8",
|
||||||
|
"minisearch": "^6.1.0",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"paho-mqtt": "^1.1.0",
|
"paho-mqtt": "^1.1.0",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
@@ -88,7 +92,6 @@
|
|||||||
"util": "^0.12.4",
|
"util": "^0.12.4",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"vue": "^3.2.25",
|
"vue": "^3.2.25",
|
||||||
"vue-github-button": "^3.0.3",
|
|
||||||
"vue-i18n": "^9.2.2",
|
"vue-i18n": "^9.2.2",
|
||||||
"vue-pdf-embed": "^1.1.4",
|
"vue-pdf-embed": "^1.1.4",
|
||||||
"vue-router": "^4.0.16",
|
"vue-router": "^4.0.16",
|
||||||
@@ -110,7 +113,7 @@
|
|||||||
"@graphql-codegen/typescript-urql-graphcache": "^2.3.1",
|
"@graphql-codegen/typescript-urql-graphcache": "^2.3.1",
|
||||||
"@graphql-codegen/urql-introspection": "^2.2.0",
|
"@graphql-codegen/urql-introspection": "^2.2.0",
|
||||||
"@graphql-typed-document-node/core": "^3.1.1",
|
"@graphql-typed-document-node/core": "^3.1.1",
|
||||||
"@iconify-json/lucide": "^1.1.40",
|
"@iconify-json/lucide": "^1.1.109",
|
||||||
"@intlify/vite-plugin-vue-i18n": "^7.0.0",
|
"@intlify/vite-plugin-vue-i18n": "^7.0.0",
|
||||||
"@relmify/jest-fp-ts": "^2.1.1",
|
"@relmify/jest-fp-ts": "^2.1.1",
|
||||||
"@rushstack/eslint-patch": "^1.1.4",
|
"@rushstack/eslint-patch": "^1.1.4",
|
||||||
@@ -139,15 +142,15 @@
|
|||||||
"rollup-plugin-polyfill-node": "^0.10.1",
|
"rollup-plugin-polyfill-node": "^0.10.1",
|
||||||
"sass": "^1.53.0",
|
"sass": "^1.53.0",
|
||||||
"typescript": "^4.5.4",
|
"typescript": "^4.5.4",
|
||||||
|
"unplugin-fonts": "^1.0.3",
|
||||||
"unplugin-icons": "^0.14.9",
|
"unplugin-icons": "^0.14.9",
|
||||||
"unplugin-vue-components": "^0.21.0",
|
"unplugin-vue-components": "^0.21.0",
|
||||||
"vite": "^3.1.4",
|
"vite": "^3.1.4",
|
||||||
"vite-plugin-checker": "^0.5.1",
|
"vite-plugin-checker": "^0.5.1",
|
||||||
"vite-plugin-fonts": "^0.6.0",
|
|
||||||
"vite-plugin-html-config": "^1.0.10",
|
"vite-plugin-html-config": "^1.0.10",
|
||||||
"vite-plugin-inspect": "^0.7.4",
|
"vite-plugin-inspect": "^0.7.4",
|
||||||
"vite-plugin-pages": "^0.26.0",
|
"vite-plugin-pages": "^0.26.0",
|
||||||
"vite-plugin-pages-sitemap": "^1.4.0",
|
"vite-plugin-pages-sitemap": "^1.4.5",
|
||||||
"vite-plugin-pwa": "^0.13.1",
|
"vite-plugin-pwa": "^0.13.1",
|
||||||
"vite-plugin-vue-layouts": "^0.7.0",
|
"vite-plugin-vue-layouts": "^0.7.0",
|
||||||
"vite-plugin-windicss": "^1.8.8",
|
"vite-plugin-windicss": "^1.8.8",
|
||||||
|
|||||||
1
packages/hoppscotch-common/public/badge.svg
Normal file
1
packages/hoppscotch-common/public/badge.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="156" height="32" fill="none"><rect width="156" height="32" fill="#6366f1" rx="4"/><text xmlns="http://www.w3.org/2000/svg" x="50%" y="50%" fill="#fff" dominant-baseline="central" font-family="Helvetica,sans-serif" font-size="12" font-weight="bold" text-anchor="middle" text-rendering="geometricPrecision">▶ Run in Hoppscotch</text></svg>
|
||||||
|
After Width: | Height: | Size: 389 B |
202
packages/hoppscotch-common/src/components.d.ts
vendored
202
packages/hoppscotch-common/src/components.d.ts
vendored
@@ -1,12 +1,204 @@
|
|||||||
// generated by unplugin-vue-components
|
// generated by unplugin-vue-components
|
||||||
// We suggest you to commit this file into source control
|
// We suggest you to commit this file into source control
|
||||||
// Read more: https://github.com/vuejs/core/pull/3399
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
import '@vue/runtime-core'
|
import "@vue/runtime-core"
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|
||||||
declare module '@vue/runtime-core' {
|
declare module "@vue/runtime-core" {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
AppActionHandler: typeof import("./components/app/ActionHandler.vue")["default"]
|
||||||
|
AppAnnouncement: typeof import("./components/app/Announcement.vue")["default"]
|
||||||
|
AppDeveloperOptions: typeof import("./components/app/DeveloperOptions.vue")["default"]
|
||||||
|
AppFooter: typeof import("./components/app/Footer.vue")["default"]
|
||||||
|
AppGitHubStarButton: typeof import("./components/app/GitHubStarButton.vue")["default"]
|
||||||
|
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"]
|
||||||
|
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"]
|
||||||
|
Collections: typeof import("./components/collections/index.vue")["default"]
|
||||||
|
CollectionsAdd: typeof import("./components/collections/Add.vue")["default"]
|
||||||
|
CollectionsAddFolder: typeof import("./components/collections/AddFolder.vue")["default"]
|
||||||
|
CollectionsAddRequest: typeof import("./components/collections/AddRequest.vue")["default"]
|
||||||
|
CollectionsCollection: typeof import("./components/collections/Collection.vue")["default"]
|
||||||
|
CollectionsEdit: typeof import("./components/collections/Edit.vue")["default"]
|
||||||
|
CollectionsEditFolder: typeof import("./components/collections/EditFolder.vue")["default"]
|
||||||
|
CollectionsEditRequest: typeof import("./components/collections/EditRequest.vue")["default"]
|
||||||
|
CollectionsGraphql: typeof import("./components/collections/graphql/index.vue")["default"]
|
||||||
|
CollectionsGraphqlAdd: typeof import("./components/collections/graphql/Add.vue")["default"]
|
||||||
|
CollectionsGraphqlAddFolder: typeof import("./components/collections/graphql/AddFolder.vue")["default"]
|
||||||
|
CollectionsGraphqlAddRequest: typeof import("./components/collections/graphql/AddRequest.vue")["default"]
|
||||||
|
CollectionsGraphqlCollection: typeof import("./components/collections/graphql/Collection.vue")["default"]
|
||||||
|
CollectionsGraphqlEdit: typeof import("./components/collections/graphql/Edit.vue")["default"]
|
||||||
|
CollectionsGraphqlEditFolder: typeof import("./components/collections/graphql/EditFolder.vue")["default"]
|
||||||
|
CollectionsGraphqlEditRequest: typeof import("./components/collections/graphql/EditRequest.vue")["default"]
|
||||||
|
CollectionsGraphqlFolder: typeof import("./components/collections/graphql/Folder.vue")["default"]
|
||||||
|
CollectionsGraphqlImportExport: typeof import("./components/collections/graphql/ImportExport.vue")["default"]
|
||||||
|
CollectionsGraphqlRequest: typeof import("./components/collections/graphql/Request.vue")["default"]
|
||||||
|
CollectionsImportExport: typeof import("./components/collections/ImportExport.vue")["default"]
|
||||||
|
CollectionsMyCollections: typeof import("./components/collections/MyCollections.vue")["default"]
|
||||||
|
CollectionsRequest: typeof import("./components/collections/Request.vue")["default"]
|
||||||
|
CollectionsSaveRequest: typeof import("./components/collections/SaveRequest.vue")["default"]
|
||||||
|
CollectionsTeamCollections: typeof import("./components/collections/TeamCollections.vue")["default"]
|
||||||
|
Environments: typeof import("./components/environments/index.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"]
|
||||||
|
EnvironmentsMyEnvironment: typeof import("./components/environments/my/Environment.vue")["default"]
|
||||||
|
EnvironmentsSelector: typeof import("./components/environments/Selector.vue")["default"]
|
||||||
|
EnvironmentsTeams: typeof import("./components/environments/teams/index.vue")["default"]
|
||||||
|
EnvironmentsTeamsDetails: typeof import("./components/environments/teams/Details.vue")["default"]
|
||||||
|
EnvironmentsTeamsEnvironment: typeof import("./components/environments/teams/Environment.vue")["default"]
|
||||||
|
FirebaseLogin: typeof import("./components/firebase/Login.vue")["default"]
|
||||||
|
FirebaseLogout: typeof import("./components/firebase/Logout.vue")["default"]
|
||||||
|
GraphqlAuthorization: typeof import("./components/graphql/Authorization.vue")["default"]
|
||||||
|
GraphqlField: typeof import("./components/graphql/Field.vue")["default"]
|
||||||
|
GraphqlRequest: typeof import("./components/graphql/Request.vue")["default"]
|
||||||
|
GraphqlRequestOptions: typeof import("./components/graphql/RequestOptions.vue")["default"]
|
||||||
|
GraphqlResponse: typeof import("./components/graphql/Response.vue")["default"]
|
||||||
|
GraphqlSidebar: typeof import("./components/graphql/Sidebar.vue")["default"]
|
||||||
|
GraphqlType: typeof import("./components/graphql/Type.vue")["default"]
|
||||||
|
GraphqlTypeLink: typeof import("./components/graphql/TypeLink.vue")["default"]
|
||||||
|
History: typeof import("./components/history/index.vue")["default"]
|
||||||
|
HistoryGraphqlCard: typeof import("./components/history/graphql/Card.vue")["default"]
|
||||||
|
HistoryRestCard: typeof import("./components/history/rest/Card.vue")["default"]
|
||||||
|
HoppButtonPrimary: typeof import("@hoppscotch/ui")["HoppButtonPrimary"]
|
||||||
|
HoppButtonSecondary: typeof import("@hoppscotch/ui")["HoppButtonSecondary"]
|
||||||
|
HoppSmartAnchor: typeof import("@hoppscotch/ui")["HoppSmartAnchor"]
|
||||||
|
HoppSmartAutoComplete: typeof import("@hoppscotch/ui")["HoppSmartAutoComplete"]
|
||||||
|
HoppSmartCheckbox: typeof import("@hoppscotch/ui")["HoppSmartCheckbox"]
|
||||||
|
HoppSmartConfirmModal: typeof import("@hoppscotch/ui")["HoppSmartConfirmModal"]
|
||||||
|
HoppSmartExpand: typeof import("@hoppscotch/ui")["HoppSmartExpand"]
|
||||||
|
HoppSmartFileChip: typeof import("@hoppscotch/ui")["HoppSmartFileChip"]
|
||||||
|
HoppSmartInput: typeof import("@hoppscotch/ui")["HoppSmartInput"]
|
||||||
|
HoppSmartIntersection: typeof import("@hoppscotch/ui")["HoppSmartIntersection"]
|
||||||
|
HoppSmartItem: typeof import("@hoppscotch/ui")["HoppSmartItem"]
|
||||||
|
HoppSmartLink: typeof import("@hoppscotch/ui")["HoppSmartLink"]
|
||||||
|
HoppSmartModal: typeof import("@hoppscotch/ui")["HoppSmartModal"]
|
||||||
|
HoppSmartPicture: typeof import("@hoppscotch/ui")["HoppSmartPicture"]
|
||||||
|
HoppSmartPlaceholder: typeof import("@hoppscotch/ui")["HoppSmartPlaceholder"]
|
||||||
|
HoppSmartProgressRing: typeof import("@hoppscotch/ui")["HoppSmartProgressRing"]
|
||||||
|
HoppSmartRadioGroup: typeof import("@hoppscotch/ui")["HoppSmartRadioGroup"]
|
||||||
|
HoppSmartSlideOver: typeof import("@hoppscotch/ui")["HoppSmartSlideOver"]
|
||||||
|
HoppSmartSpinner: typeof import("@hoppscotch/ui")["HoppSmartSpinner"]
|
||||||
|
HoppSmartTab: typeof import("@hoppscotch/ui")["HoppSmartTab"]
|
||||||
|
HoppSmartTabs: typeof import("@hoppscotch/ui")["HoppSmartTabs"]
|
||||||
|
HoppSmartToggle: typeof import("@hoppscotch/ui")["HoppSmartToggle"]
|
||||||
|
HoppSmartWindow: typeof import("@hoppscotch/ui")["HoppSmartWindow"]
|
||||||
|
HoppSmartWindows: typeof import("@hoppscotch/ui")["HoppSmartWindows"]
|
||||||
|
HttpAuthorization: typeof import("./components/http/Authorization.vue")["default"]
|
||||||
|
HttpAuthorizationApiKey: typeof import("./components/http/authorization/ApiKey.vue")["default"]
|
||||||
|
HttpAuthorizationBasic: typeof import("./components/http/authorization/Basic.vue")["default"]
|
||||||
|
HttpBody: typeof import("./components/http/Body.vue")["default"]
|
||||||
|
HttpBodyParameters: typeof import("./components/http/BodyParameters.vue")["default"]
|
||||||
|
HttpCodegenModal: typeof import("./components/http/CodegenModal.vue")["default"]
|
||||||
|
HttpHeaders: typeof import("./components/http/Headers.vue")["default"]
|
||||||
|
HttpImportCurl: typeof import("./components/http/ImportCurl.vue")["default"]
|
||||||
|
HttpOAuth2Authorization: typeof import("./components/http/OAuth2Authorization.vue")["default"]
|
||||||
|
HttpParameters: typeof import("./components/http/Parameters.vue")["default"]
|
||||||
|
HttpPreRequestScript: typeof import("./components/http/PreRequestScript.vue")["default"]
|
||||||
|
HttpRawBody: typeof import("./components/http/RawBody.vue")["default"]
|
||||||
|
HttpReqChangeConfirmModal: typeof import("./components/http/ReqChangeConfirmModal.vue")["default"]
|
||||||
|
HttpRequest: typeof import("./components/http/Request.vue")["default"]
|
||||||
|
HttpRequestOptions: typeof import("./components/http/RequestOptions.vue")["default"]
|
||||||
|
HttpRequestTab: typeof import("./components/http/RequestTab.vue")["default"]
|
||||||
|
HttpResponse: typeof import("./components/http/Response.vue")["default"]
|
||||||
|
HttpResponseMeta: typeof import("./components/http/ResponseMeta.vue")["default"]
|
||||||
|
HttpSidebar: typeof import("./components/http/Sidebar.vue")["default"]
|
||||||
|
HttpTestResult: typeof import("./components/http/TestResult.vue")["default"]
|
||||||
|
HttpTestResultEntry: typeof import("./components/http/TestResultEntry.vue")["default"]
|
||||||
|
HttpTestResultEnv: typeof import("./components/http/TestResultEnv.vue")["default"]
|
||||||
|
HttpTestResultReport: typeof import("./components/http/TestResultReport.vue")["default"]
|
||||||
|
HttpTests: typeof import("./components/http/Tests.vue")["default"]
|
||||||
|
HttpURLEncodedParams: typeof import("./components/http/URLEncodedParams.vue")["default"]
|
||||||
|
IconLucideAlertTriangle: typeof import("~icons/lucide/alert-triangle")["default"]
|
||||||
|
IconLucideArrowLeft: typeof import("~icons/lucide/arrow-left")["default"]
|
||||||
|
IconLucideCheckCircle: typeof import("~icons/lucide/check-circle")["default"]
|
||||||
|
IconLucideChevronRight: typeof import("~icons/lucide/chevron-right")["default"]
|
||||||
|
IconLucideGlobe: typeof import("~icons/lucide/globe")["default"]
|
||||||
|
IconLucideHelpCircle: typeof import("~icons/lucide/help-circle")["default"]
|
||||||
|
IconLucideInbox: typeof import("~icons/lucide/inbox")["default"]
|
||||||
|
IconLucideInfo: typeof import("~icons/lucide/info")["default"]
|
||||||
|
IconLucideLayers: typeof import("~icons/lucide/layers")["default"]
|
||||||
|
IconLucideListEnd: typeof import("~icons/lucide/list-end")["default"]
|
||||||
|
IconLucideMinus: typeof import("~icons/lucide/minus")["default"]
|
||||||
|
IconLucideSearch: typeof import("~icons/lucide/search")["default"]
|
||||||
|
IconLucideUsers: typeof import("~icons/lucide/users")["default"]
|
||||||
|
IconLucideVerified: typeof import("~icons/lucide/verified")["default"]
|
||||||
|
LensesHeadersRenderer: typeof import("./components/lenses/HeadersRenderer.vue")["default"]
|
||||||
|
LensesHeadersRendererEntry: typeof import("./components/lenses/HeadersRendererEntry.vue")["default"]
|
||||||
|
LensesRenderersAudioLensRenderer: typeof import("./components/lenses/renderers/AudioLensRenderer.vue")["default"]
|
||||||
|
LensesRenderersHTMLLensRenderer: typeof import("./components/lenses/renderers/HTMLLensRenderer.vue")["default"]
|
||||||
|
LensesRenderersImageLensRenderer: typeof import("./components/lenses/renderers/ImageLensRenderer.vue")["default"]
|
||||||
|
LensesRenderersJSONLensRenderer: typeof import("./components/lenses/renderers/JSONLensRenderer.vue")["default"]
|
||||||
|
LensesRenderersPDFLensRenderer: typeof import("./components/lenses/renderers/PDFLensRenderer.vue")["default"]
|
||||||
|
LensesRenderersRawLensRenderer: typeof import("./components/lenses/renderers/RawLensRenderer.vue")["default"]
|
||||||
|
LensesRenderersVideoLensRenderer: typeof import("./components/lenses/renderers/VideoLensRenderer.vue")["default"]
|
||||||
|
LensesRenderersXMLLensRenderer: typeof import("./components/lenses/renderers/XMLLensRenderer.vue")["default"]
|
||||||
|
LensesResponseBodyRenderer: typeof import("./components/lenses/ResponseBodyRenderer.vue")["default"]
|
||||||
|
ProfileShortcode: typeof import("./components/profile/Shortcode.vue")["default"]
|
||||||
|
ProfileShortcodes: typeof import("./components/profile/Shortcodes.vue")["default"]
|
||||||
|
ProfileUserDelete: typeof import("./components/profile/UserDelete.vue")["default"]
|
||||||
|
RealtimeCommunication: typeof import("./components/realtime/Communication.vue")["default"]
|
||||||
|
RealtimeConnectionConfig: typeof import("./components/realtime/ConnectionConfig.vue")["default"]
|
||||||
|
RealtimeLog: typeof import("./components/realtime/Log.vue")["default"]
|
||||||
|
RealtimeLogEntry: typeof import("./components/realtime/LogEntry.vue")["default"]
|
||||||
|
RealtimeSubscription: typeof import("./components/realtime/Subscription.vue")["default"]
|
||||||
|
SmartAccentModePicker: typeof import("./components/smart/AccentModePicker.vue")["default"]
|
||||||
|
SmartAnchor: typeof import("./../../hoppscotch-ui/src/components/smart/Anchor.vue")["default"]
|
||||||
|
SmartAutoComplete: typeof import("./../../hoppscotch-ui/src/components/smart/AutoComplete.vue")["default"]
|
||||||
|
SmartChangeLanguage: typeof import("./components/smart/ChangeLanguage.vue")["default"]
|
||||||
|
SmartCheckbox: typeof import("./../../hoppscotch-ui/src/components/smart/Checkbox.vue")["default"]
|
||||||
|
SmartColorModePicker: typeof import("./components/smart/ColorModePicker.vue")["default"]
|
||||||
|
SmartConfirmModal: typeof import("./../../hoppscotch-ui/src/components/smart/ConfirmModal.vue")["default"]
|
||||||
|
SmartEnvInput: typeof import("./components/smart/EnvInput.vue")["default"]
|
||||||
|
SmartExpand: typeof import("./../../hoppscotch-ui/src/components/smart/Expand.vue")["default"]
|
||||||
|
SmartFileChip: typeof import("./../../hoppscotch-ui/src/components/smart/FileChip.vue")["default"]
|
||||||
|
SmartFontSizePicker: typeof import("./components/smart/FontSizePicker.vue")["default"]
|
||||||
|
SmartInput: typeof import("./../../hoppscotch-ui/src/components/smart/Input.vue")["default"]
|
||||||
|
SmartIntersection: typeof import("./../../hoppscotch-ui/src/components/smart/Intersection.vue")["default"]
|
||||||
|
SmartItem: typeof import("./../../hoppscotch-ui/src/components/smart/Item.vue")["default"]
|
||||||
|
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"]
|
||||||
|
SmartSlideOver: typeof import("./../../hoppscotch-ui/src/components/smart/SlideOver.vue")["default"]
|
||||||
|
SmartSpinner: typeof import("./../../hoppscotch-ui/src/components/smart/Spinner.vue")["default"]
|
||||||
|
SmartTab: typeof import("./../../hoppscotch-ui/src/components/smart/Tab.vue")["default"]
|
||||||
|
SmartTabs: typeof import("./../../hoppscotch-ui/src/components/smart/Tabs.vue")["default"]
|
||||||
|
SmartToggle: typeof import("./../../hoppscotch-ui/src/components/smart/Toggle.vue")["default"]
|
||||||
|
SmartTree: typeof import("./components/smart/Tree.vue")["default"]
|
||||||
|
SmartTreeBranch: typeof import("./components/smart/TreeBranch.vue")["default"]
|
||||||
|
SmartWindow: typeof import("./../../hoppscotch-ui/src/components/smart/Window.vue")["default"]
|
||||||
|
SmartWindows: typeof import("./../../hoppscotch-ui/src/components/smart/Windows.vue")["default"]
|
||||||
|
TabPrimary: typeof import("./components/tab/Primary.vue")["default"]
|
||||||
|
TabSecondary: typeof import("./components/tab/Secondary.vue")["default"]
|
||||||
|
Teams: typeof import("./components/teams/index.vue")["default"]
|
||||||
|
TeamsAdd: typeof import("./components/teams/Add.vue")["default"]
|
||||||
|
TeamsEdit: typeof import("./components/teams/Edit.vue")["default"]
|
||||||
|
TeamsInvite: typeof import("./components/teams/Invite.vue")["default"]
|
||||||
|
TeamsMemberStack: typeof import("./components/teams/MemberStack.vue")["default"]
|
||||||
|
TeamsModal: typeof import("./components/teams/Modal.vue")["default"]
|
||||||
|
TeamsTeam: typeof import("./components/teams/Team.vue")["default"]
|
||||||
|
Tippy: typeof import("vue-tippy")["Tippy"]
|
||||||
|
WorkspaceCurrent: typeof import("./components/workspace/Current.vue")["default"]
|
||||||
|
WorkspaceSelector: typeof import("./components/workspace/Selector.vue")["default"]
|
||||||
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
|
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
|
||||||
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
|
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
|
||||||
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
|
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
|
||||||
@@ -82,6 +274,7 @@ declare module '@vue/runtime-core' {
|
|||||||
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
|
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
|
||||||
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
|
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
|
||||||
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
|
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
|
||||||
|
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
|
||||||
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
|
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
|
||||||
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
|
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
|
||||||
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
|
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
|
||||||
@@ -93,6 +286,7 @@ declare module '@vue/runtime-core' {
|
|||||||
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
|
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
|
||||||
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
|
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
|
||||||
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
|
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
|
||||||
|
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
|
||||||
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
|
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
|
||||||
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
|
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
|
||||||
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
|
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
|
||||||
@@ -114,6 +308,7 @@ declare module '@vue/runtime-core' {
|
|||||||
HttpResponse: typeof import('./components/http/Response.vue')['default']
|
HttpResponse: typeof import('./components/http/Response.vue')['default']
|
||||||
HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default']
|
HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default']
|
||||||
HttpSidebar: typeof import('./components/http/Sidebar.vue')['default']
|
HttpSidebar: typeof import('./components/http/Sidebar.vue')['default']
|
||||||
|
HttpTabHead: typeof import('./components/http/TabHead.vue')['default']
|
||||||
HttpTestResult: typeof import('./components/http/TestResult.vue')['default']
|
HttpTestResult: typeof import('./components/http/TestResult.vue')['default']
|
||||||
HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default']
|
HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default']
|
||||||
HttpTestResultEnv: typeof import('./components/http/TestResultEnv.vue')['default']
|
HttpTestResultEnv: typeof import('./components/http/TestResultEnv.vue')['default']
|
||||||
@@ -133,6 +328,7 @@ declare module '@vue/runtime-core' {
|
|||||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||||
|
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
|
||||||
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
||||||
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
|
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
|
||||||
LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default']
|
LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default']
|
||||||
@@ -168,7 +364,6 @@ declare module '@vue/runtime-core' {
|
|||||||
SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default']
|
SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default']
|
||||||
SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default']
|
SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default']
|
||||||
SmartPicture: typeof import('./../../hoppscotch-ui/src/components/smart/Picture.vue')['default']
|
SmartPicture: typeof import('./../../hoppscotch-ui/src/components/smart/Picture.vue')['default']
|
||||||
SmartPlaceholder: typeof import('./../../hoppscotch-ui/src/components/smart/Placeholder.vue')['default']
|
|
||||||
SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default']
|
SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default']
|
||||||
SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default']
|
SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default']
|
||||||
SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default']
|
SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default']
|
||||||
@@ -194,5 +389,4 @@ declare module '@vue/runtime-core' {
|
|||||||
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
|
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
|
||||||
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
|
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="contextMenuRef"
|
||||||
|
class="fixed bg-popover shadow-lg transform translate-y-8 border border-dividerDark p-2 rounded"
|
||||||
|
:style="`top: ${position.top}px; left: ${position.left}px; z-index: 1000;`"
|
||||||
|
>
|
||||||
|
<div v-if="contextMenuOptions" class="flex flex-col">
|
||||||
|
<div
|
||||||
|
v-for="option in contextMenuOptions"
|
||||||
|
:key="option.id"
|
||||||
|
class="flex flex-col space-y-2"
|
||||||
|
>
|
||||||
|
<HoppSmartItem
|
||||||
|
v-if="option.text.type === 'text' && option.text"
|
||||||
|
:icon="option.icon"
|
||||||
|
:label="option.text.text"
|
||||||
|
@click="handleClick(option)"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
:is="option.text.component"
|
||||||
|
v-else-if="option.text.type === 'custom'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { onClickOutside } from "@vueuse/core"
|
||||||
|
import { useService } from "dioc/vue"
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { ContextMenuResult, ContextMenuService } from "~/services/context-menu"
|
||||||
|
import { EnvironmentMenuService } from "~/services/context-menu/menu/environment.menu"
|
||||||
|
import { ParameterMenuService } from "~/services/context-menu/menu/parameter.menu"
|
||||||
|
import { URLMenuService } from "~/services/context-menu/menu/url.menu"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
position: { top: number; left: number }
|
||||||
|
text: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "hide-modal"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const contextMenuRef = ref<any | null>(null)
|
||||||
|
|
||||||
|
const contextMenuOptions = ref<ContextMenuResult[]>([])
|
||||||
|
|
||||||
|
onClickOutside(contextMenuRef, () => {
|
||||||
|
emit("hide-modal")
|
||||||
|
})
|
||||||
|
|
||||||
|
const contextMenuService = useService(ContextMenuService)
|
||||||
|
|
||||||
|
useService(EnvironmentMenuService)
|
||||||
|
useService(ParameterMenuService)
|
||||||
|
useService(URLMenuService)
|
||||||
|
|
||||||
|
const handleClick = (option: { action: () => void }) => {
|
||||||
|
option.action()
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.show, props.text],
|
||||||
|
(val) => {
|
||||||
|
if (val && props.text) {
|
||||||
|
const options = contextMenuService.getMenuFor(props.text)
|
||||||
|
contextMenuOptions.value = options
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||||
:title="`${t(
|
:title="`${t(
|
||||||
'app.shortcuts'
|
'app.shortcuts'
|
||||||
)} <kbd>${getSpecialKey()}</kbd><kbd>K</kbd>`"
|
)} <kbd>${getSpecialKey()}</kbd><kbd>/</kbd>`"
|
||||||
:icon="IconZap"
|
:icon="IconZap"
|
||||||
@click="invokeAction('flyouts.keybinds.toggle')"
|
@click="invokeAction('flyouts.keybinds.toggle')"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div key="outputHash" class="flex flex-col flex-1 overflow-auto">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<AppPowerSearchEntry
|
|
||||||
v-for="(shortcut, shortcutIndex) in searchResults"
|
|
||||||
:key="`shortcut-${shortcutIndex}`"
|
|
||||||
:active="shortcutIndex === selectedEntry"
|
|
||||||
:shortcut="shortcut.item"
|
|
||||||
@action="emit('action', shortcut.item.action)"
|
|
||||||
@mouseover="selectedEntry = shortcutIndex"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<HoppSmartPlaceholder
|
|
||||||
v-if="searchResults.length === 0"
|
|
||||||
:text="`${t('state.nothing_found')} ‟${search}”`"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
|
||||||
</template>
|
|
||||||
</HoppSmartPlaceholder>
|
|
||||||
</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,16 +15,21 @@
|
|||||||
:label="t('app.name')"
|
:label="t('app.name')"
|
||||||
to="/"
|
to="/"
|
||||||
/>
|
/>
|
||||||
<!-- <AppGitHubStarButton class="mt-1.5 transition" /> -->
|
|
||||||
</div>
|
</div>
|
||||||
<div class="inline-flex items-center space-x-2">
|
<div class="inline-flex items-center justify-center flex-1 space-x-2">
|
||||||
<HoppButtonSecondary
|
<button
|
||||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
class="flex flex-1 items-center justify-between px-2 py-1 bg-primaryDark transition text-secondaryLight cursor-text rounded border border-dividerDark max-w-xs hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
|
||||||
:title="`${t('app.search')} <kbd>/</kbd>`"
|
|
||||||
:icon="IconSearch"
|
|
||||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
|
||||||
@click="invokeAction('modals.search.toggle')"
|
@click="invokeAction('modals.search.toggle')"
|
||||||
/>
|
>
|
||||||
|
<span class="inline-flex flex-1 items-center">
|
||||||
|
<icon-lucide-search class="mr-2 svg-icons" />
|
||||||
|
{{ t("app.search") }}
|
||||||
|
</span>
|
||||||
|
<span class="flex">
|
||||||
|
<kbd class="shortcut-key">{{ getPlatformSpecialKey() }}</kbd>
|
||||||
|
<kbd class="shortcut-key">K</kbd>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<HoppButtonSecondary
|
<HoppButtonSecondary
|
||||||
v-if="showInstallButton"
|
v-if="showInstallButton"
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
@@ -42,6 +47,8 @@
|
|||||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||||
@click="invokeAction('modals.support.toggle')"
|
@click="invokeAction('modals.support.toggle')"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center justify-end flex-1 space-x-2">
|
||||||
<div
|
<div
|
||||||
v-if="currentUser === null"
|
v-if="currentUser === null"
|
||||||
class="inline-flex items-center space-x-2"
|
class="inline-flex items-center space-x-2"
|
||||||
@@ -236,17 +243,17 @@ import IconDownload from "~icons/lucide/download"
|
|||||||
import IconUploadCloud from "~icons/lucide/upload-cloud"
|
import IconUploadCloud from "~icons/lucide/upload-cloud"
|
||||||
import IconUserPlus from "~icons/lucide/user-plus"
|
import IconUserPlus from "~icons/lucide/user-plus"
|
||||||
import IconLifeBuoy from "~icons/lucide/life-buoy"
|
import IconLifeBuoy from "~icons/lucide/life-buoy"
|
||||||
import IconSearch from "~icons/lucide/search"
|
|
||||||
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
|
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
|
||||||
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
|
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { invokeAction } from "@helpers/actions"
|
import { defineActionHandler, invokeAction } from "@helpers/actions"
|
||||||
import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
|
import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
|
||||||
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||||
import { onLoggedIn } from "~/composables/auth"
|
import { onLoggedIn } from "~/composables/auth"
|
||||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||||
|
import { getPlatformSpecialKey } from "~/helpers/platformutils"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
@@ -374,4 +381,12 @@ const profile = ref<any | null>(null)
|
|||||||
const settings = ref<any | null>(null)
|
const settings = ref<any | null>(null)
|
||||||
const logout = ref<any | null>(null)
|
const logout = ref<any | null>(null)
|
||||||
const accountActions = ref<any | null>(null)
|
const accountActions = ref<any | null>(null)
|
||||||
|
|
||||||
|
defineActionHandler(
|
||||||
|
"user.login",
|
||||||
|
() => {
|
||||||
|
invokeAction("modals.login.toggle")
|
||||||
|
},
|
||||||
|
computed(() => !currentUser.value)
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
<template>
|
|
||||||
<HoppSmartModal
|
|
||||||
v-if="show"
|
|
||||||
styles="sm:max-w-lg"
|
|
||||||
full-width
|
|
||||||
@close="emit('hide-modal')"
|
|
||||||
>
|
|
||||||
<template #body>
|
|
||||||
<div class="flex flex-col border-b transition border-dividerLight">
|
|
||||||
<input
|
|
||||||
id="command"
|
|
||||||
v-model="search"
|
|
||||||
v-focus
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
name="command"
|
|
||||||
:placeholder="`${t('app.type_a_command_search')}`"
|
|
||||||
class="flex flex-shrink-0 p-6 text-base bg-transparent text-secondaryDark"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="flex flex-shrink-0 text-tiny text-secondaryLight px-4 pb-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
|
|
||||||
>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<kbd class="shortcut-key">↑</kbd>
|
|
||||||
<kbd class="shortcut-key">↓</kbd>
|
|
||||||
<span class="ml-2 truncate">
|
|
||||||
{{ t("action.to_navigate") }}
|
|
||||||
</span>
|
|
||||||
<kbd class="shortcut-key">↩</kbd>
|
|
||||||
<span class="ml-2 truncate">
|
|
||||||
{{ t("action.to_select") }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
<kbd class="shortcut-key">ESC</kbd>
|
|
||||||
<span class="ml-2 truncate">
|
|
||||||
{{ t("action.to_close") }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AppFuse
|
|
||||||
v-if="search && show"
|
|
||||||
:input="fuse"
|
|
||||||
:search="search"
|
|
||||||
@action="runAction"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="flex flex-col flex-1 overflow-auto space-y-4 divide-y divide-dividerLight"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(map, mapIndex) in mappings"
|
|
||||||
:key="`map-${mapIndex}`"
|
|
||||||
class="flex flex-col"
|
|
||||||
>
|
|
||||||
<h5 class="px-6 py-2 my-2 text-secondaryLight">
|
|
||||||
{{ t(map.section) }}
|
|
||||||
</h5>
|
|
||||||
<AppPowerSearchEntry
|
|
||||||
v-for="(shortcut, shortcutIndex) in map.shortcuts"
|
|
||||||
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
|
|
||||||
:shortcut="shortcut"
|
|
||||||
:active="shortcutsItems.indexOf(shortcut) === selectedEntry"
|
|
||||||
@action="runAction"
|
|
||||||
@mouseover="selectedEntry = shortcutsItems.indexOf(shortcut)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</HoppSmartModal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, watch } from "vue"
|
|
||||||
import { HoppAction, invokeAction } from "~/helpers/actions"
|
|
||||||
import { spotlight as mappings, fuse } from "@helpers/shortcuts"
|
|
||||||
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
|
|
||||||
const t = useI18n()
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
show: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "hide-modal"): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const search = ref("")
|
|
||||||
|
|
||||||
const hideModal = () => {
|
|
||||||
search.value = ""
|
|
||||||
emit("hide-modal")
|
|
||||||
}
|
|
||||||
|
|
||||||
const runAction = (command: HoppAction) => {
|
|
||||||
invokeAction(command)
|
|
||||||
hideModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
const shortcutsItems = computed(() =>
|
|
||||||
mappings.reduce(
|
|
||||||
(shortcuts, section) => [...shortcuts, ...section.shortcuts],
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
|
|
||||||
useArrowKeysNavigation(shortcutsItems, {
|
|
||||||
onEnter: runAction,
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.show,
|
|
||||||
(show) => {
|
|
||||||
if (show) bindArrowKeysListeners()
|
|
||||||
else unbindArrowKeysListeners()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
<template>
|
|
||||||
<button
|
|
||||||
class="flex items-center flex-1 px-6 py-3 font-medium transition cursor-pointer search-entry focus:outline-none"
|
|
||||||
:class="{ active: active }"
|
|
||||||
tabindex="-1"
|
|
||||||
@click="emit('action', shortcut.action)"
|
|
||||||
@keydown.enter="emit('action', shortcut.action)"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="shortcut.icon"
|
|
||||||
class="mr-4 transition opacity-50 svg-icons"
|
|
||||||
:class="{ 'opacity-100 text-secondaryDark': active }"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="flex flex-1 mr-4 transition"
|
|
||||||
:class="{ 'text-secondaryDark': active }"
|
|
||||||
>
|
|
||||||
{{ t(shortcut.label) }}
|
|
||||||
</span>
|
|
||||||
<kbd
|
|
||||||
v-for="(key, keyIndex) in shortcut.keys"
|
|
||||||
:key="`key-${String(keyIndex)}`"
|
|
||||||
class="shortcut-key"
|
|
||||||
>
|
|
||||||
{{ key }}
|
|
||||||
</kbd>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Component } from "vue"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
|
|
||||||
const t = useI18n()
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
shortcut: {
|
|
||||||
label: string
|
|
||||||
keys: string[]
|
|
||||||
action: string
|
|
||||||
icon: object | Component
|
|
||||||
}
|
|
||||||
active: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "action", action: string): void
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.search-entry {
|
|
||||||
@apply relative;
|
|
||||||
@apply after:absolute;
|
|
||||||
@apply after:top-0;
|
|
||||||
@apply after:left-0;
|
|
||||||
@apply after:bottom-0;
|
|
||||||
@apply after:bg-transparent;
|
|
||||||
@apply after:z-2;
|
|
||||||
@apply after:w-0.5;
|
|
||||||
@apply after:content-DEFAULT;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
@apply bg-primaryLight;
|
|
||||||
@apply after:bg-accentLight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -4,56 +4,26 @@
|
|||||||
<div
|
<div
|
||||||
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto bg-primary"
|
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto bg-primary"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col px-6 py-4 border-b border-dividerLight">
|
<HoppSmartInput
|
||||||
<input
|
v-model="filterText"
|
||||||
v-model="filterText"
|
type="search"
|
||||||
type="search"
|
styles="px-6 py-4 border-b border-dividerLight"
|
||||||
autocomplete="off"
|
:placeholder="`${t('action.search')}`"
|
||||||
class="flex px-4 py-2 border rounded bg-primaryContrast border-divider hover:border-dividerDark focus-visible:border-dividerDark"
|
input-styles="flex px-4 py-2 border rounded bg-primaryContrast border-divider hover:border-dividerDark focus-visible:border-dividerDark"
|
||||||
:placeholder="`${t('action.search')}`"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filterText" class="flex flex-col divide-y divide-dividerLight">
|
<div class="flex flex-col divide-y divide-dividerLight">
|
||||||
<details
|
|
||||||
v-for="(map, mapIndex) in searchResults"
|
|
||||||
: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.item.section) }}
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<div class="flex flex-col px-6 pb-4 space-y-2">
|
|
||||||
<AppShortcutsEntry
|
|
||||||
v-for="(shortcut, index) in map.item.shortcuts"
|
|
||||||
:key="`shortcut-${index}`"
|
|
||||||
:shortcut="shortcut"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<HoppSmartPlaceholder
|
<HoppSmartPlaceholder
|
||||||
v-if="searchResults.length === 0"
|
v-if="isEmpty(shortcutsResults)"
|
||||||
:text="`${t('state.nothing_found')} ‟${filterText}”`"
|
:text="`${t('state.nothing_found')} ‟${filterText}”`"
|
||||||
>
|
>
|
||||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||||
<span class="my-2 text-center flex flex-col">
|
</HoppSmartPlaceholder>
|
||||||
{{ t("state.nothing_found") }}
|
|
||||||
<span class="break-all">"{{ filterText }}"</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex flex-col divide-y divide-dividerLight">
|
|
||||||
<details
|
<details
|
||||||
v-for="(map, mapIndex) in mappings"
|
v-for="(sectionResults, sectionTitle) in shortcutsResults"
|
||||||
:key="`map-${mapIndex}`"
|
v-else
|
||||||
|
:key="`section-${sectionTitle}`"
|
||||||
class="flex flex-col"
|
class="flex flex-col"
|
||||||
open
|
open
|
||||||
>
|
>
|
||||||
@@ -64,13 +34,13 @@
|
|||||||
<span
|
<span
|
||||||
class="font-semibold truncate capitalize-first text-secondaryDark"
|
class="font-semibold truncate capitalize-first text-secondaryDark"
|
||||||
>
|
>
|
||||||
{{ t(map.section) }}
|
{{ sectionTitle }}
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="flex flex-col px-6 pb-4 space-y-2">
|
<div class="flex flex-col px-6 pb-4 space-y-2">
|
||||||
<AppShortcutsEntry
|
<AppShortcutsEntry
|
||||||
v-for="(shortcut, shortcutIndex) in map.shortcuts"
|
v-for="(shortcut, index) in sectionResults"
|
||||||
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
|
:key="`shortcut-${index}`"
|
||||||
:shortcut="shortcut"
|
:shortcut="shortcut"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,10 +51,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue"
|
import { computed, onBeforeMount, ref } from "vue"
|
||||||
import Fuse from "fuse.js"
|
import { ShortcutDef, getShortcuts } from "~/helpers/shortcuts"
|
||||||
import mappings from "~/helpers/shortcuts"
|
import MiniSearch from "minisearch"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import { groupBy, isEmpty } from "lodash-es"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
@@ -92,15 +63,33 @@ defineProps<{
|
|||||||
show: boolean
|
show: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const options = {
|
const minisearch = new MiniSearch({
|
||||||
keys: ["shortcuts.label"],
|
fields: ["label", "keys", "section"],
|
||||||
}
|
idField: "label",
|
||||||
|
storeFields: ["label", "keys", "section"],
|
||||||
|
searchOptions: {
|
||||||
|
fuzzy: true,
|
||||||
|
prefix: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const fuse = new Fuse(mappings, options)
|
const shortcuts = getShortcuts(t)
|
||||||
|
|
||||||
|
onBeforeMount(() => {
|
||||||
|
minisearch.addAllAsync(shortcuts)
|
||||||
|
})
|
||||||
|
|
||||||
const filterText = ref("")
|
const filterText = ref("")
|
||||||
|
|
||||||
const searchResults = computed(() => fuse.search(filterText.value))
|
const shortcutsResults = computed(() => {
|
||||||
|
// If there are no search text, return all the shortcuts
|
||||||
|
const results =
|
||||||
|
filterText.value.length > 0
|
||||||
|
? minisearch.search(filterText.value)
|
||||||
|
: shortcuts
|
||||||
|
|
||||||
|
return groupBy(results, "section") as Record<string, ShortcutDef[]>
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "close"): void
|
(e: "close"): void
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center py-1">
|
<div class="flex items-center py-1">
|
||||||
<span class="flex flex-1 mr-4">
|
<span class="flex flex-1 mr-4">
|
||||||
{{ t(shortcut.label) }}
|
{{ shortcut.label }}
|
||||||
</span>
|
</span>
|
||||||
<kbd
|
<kbd
|
||||||
v-for="(key, index) in shortcut.keys"
|
v-for="(key, index) in shortcut.keys"
|
||||||
@@ -14,14 +14,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "@composables/i18n"
|
import { ShortcutDef } from "~/helpers/shortcuts"
|
||||||
|
|
||||||
const t = useI18n()
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
shortcut: {
|
shortcut: ShortcutDef
|
||||||
label: string
|
|
||||||
keys: string[]
|
|
||||||
}
|
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -22,10 +22,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||||
<kbd class="shortcut-key">K</kbd>
|
<kbd class="shortcut-key">/</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<kbd class="shortcut-key">/</kbd>
|
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||||
|
<kbd class="shortcut-key">K</kbd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<kbd class="shortcut-key">?</kbd>
|
<kbd class="shortcut-key">?</kbd>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
ref="el"
|
||||||
|
class="flex items-center flex-1 px-6 py-4 font-medium space-x-4 transition cursor-pointer relative search-entry focus:outline-none"
|
||||||
|
:class="{ 'active bg-primaryLight text-secondaryDark': active }"
|
||||||
|
tabindex="-1"
|
||||||
|
@click="emit('action')"
|
||||||
|
@keydown.enter="emit('action')"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="entry.icon"
|
||||||
|
class="opacity-50 svg-icons"
|
||||||
|
:class="{ 'opacity-100': active }"
|
||||||
|
/>
|
||||||
|
<template
|
||||||
|
v-if="entry.text.type === 'text' && typeof entry.text.text === 'string'"
|
||||||
|
>
|
||||||
|
<span class="block truncate">
|
||||||
|
{{ entry.text.text }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-else-if="entry.text.type === 'text' && Array.isArray(entry.text.text)"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="(labelPart, labelPartIndex) in entry.text.text"
|
||||||
|
:key="`label-${labelPart}-${labelPartIndex}`"
|
||||||
|
>
|
||||||
|
<span class="block truncate">
|
||||||
|
{{ labelPart }}
|
||||||
|
</span>
|
||||||
|
<icon-lucide-chevron-right
|
||||||
|
v-if="labelPartIndex < entry.text.text.length - 1"
|
||||||
|
class="flex flex-shrink-0"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="entry.text.type === 'custom'">
|
||||||
|
<span class="block truncate">
|
||||||
|
<component
|
||||||
|
:is="entry.text.component"
|
||||||
|
v-bind="entry.text.componentProps"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<span v-if="formattedShortcutKeys" class="block truncate">
|
||||||
|
<kbd
|
||||||
|
v-for="(key, keyIndex) in formattedShortcutKeys"
|
||||||
|
:key="`key-${String(keyIndex)}`"
|
||||||
|
class="shortcut-key"
|
||||||
|
>
|
||||||
|
{{ key }}
|
||||||
|
</kbd>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { getPlatformSpecialKey } from "~/helpers/platformutils"
|
||||||
|
import { SpotlightSearcherResult } from "~/services/spotlight"
|
||||||
|
|
||||||
|
const SPECIAL_KEY_CHARS: Record<string, string> = {
|
||||||
|
ctrl: getPlatformSpecialKey(),
|
||||||
|
alt: getPlatformAlternateKey(),
|
||||||
|
up: "↑",
|
||||||
|
down: "↓",
|
||||||
|
enter: "↩",
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch, ref } from "vue"
|
||||||
|
import { capitalize } from "lodash-es"
|
||||||
|
import { getPlatformAlternateKey } from "~/helpers/platformutils"
|
||||||
|
|
||||||
|
const el = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entry: SpotlightSearcherResult
|
||||||
|
active: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formattedShortcutKeys = computed(() =>
|
||||||
|
props.entry.meta?.keyboardShortcut?.map((key) => {
|
||||||
|
return SPECIAL_KEY_CHARS[key] ?? capitalize(key)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "action"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.active,
|
||||||
|
(active) => {
|
||||||
|
if (active) {
|
||||||
|
el.value?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
inline: "nearest",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.search-entry {
|
||||||
|
@apply after:absolute;
|
||||||
|
@apply after:top-0;
|
||||||
|
@apply after:left-0;
|
||||||
|
@apply after:bottom-0;
|
||||||
|
@apply after:bg-transparent;
|
||||||
|
@apply after:z-2;
|
||||||
|
@apply after:w-0.5;
|
||||||
|
@apply after:content-DEFAULT;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
@apply after:bg-accentLight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<span class="flex flex-1 items-center space-x-2">
|
||||||
|
<span class="block truncate">
|
||||||
|
{{ dateTimeText }}
|
||||||
|
</span>
|
||||||
|
<icon-lucide-chevron-right class="flex flex-shrink-0" />
|
||||||
|
<span class="block truncate">
|
||||||
|
{{ historyEntry.request.url }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1"
|
||||||
|
>
|
||||||
|
{{ historyEntry.request.query.split("\n")[0] }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { shortDateTime } from "~/helpers/utils/date"
|
||||||
|
import { GQLHistoryEntry } from "~/newstore/history"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
historyEntry: GQLHistoryEntry
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dateTimeText = computed(() =>
|
||||||
|
shortDateTime(props.historyEntry.updatedOn!)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<template>
|
||||||
|
<span class="flex flex-1 items-center space-x-2">
|
||||||
|
<span class="block truncate">
|
||||||
|
{{ dateTimeText }}
|
||||||
|
</span>
|
||||||
|
<icon-lucide-chevron-right class="flex flex-shrink-0" />
|
||||||
|
<span
|
||||||
|
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1"
|
||||||
|
:class="entryStatus.className"
|
||||||
|
>
|
||||||
|
{{ historyEntry.request.method }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate">
|
||||||
|
{{ historyEntry.request.endpoint }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import findStatusGroup from "~/helpers/findStatusGroup"
|
||||||
|
import { shortDateTime } from "~/helpers/utils/date"
|
||||||
|
import { RESTHistoryEntry } from "~/newstore/history"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
historyEntry: RESTHistoryEntry
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dateTimeText = computed(() =>
|
||||||
|
shortDateTime(props.historyEntry.updatedOn!)
|
||||||
|
)
|
||||||
|
|
||||||
|
const entryStatus = computed(() => {
|
||||||
|
const foundStatusGroup = findStatusGroup(
|
||||||
|
props.historyEntry.responseMeta.statusCode
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
foundStatusGroup || {
|
||||||
|
className: "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
<template>
|
||||||
|
<HoppSmartModal
|
||||||
|
v-if="show"
|
||||||
|
styles="sm:max-w-lg"
|
||||||
|
full-width
|
||||||
|
@close="emit('hide-modal')"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="flex flex-col border-b transition border-divider">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
id="command"
|
||||||
|
v-model="search"
|
||||||
|
v-focus
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
name="command"
|
||||||
|
:placeholder="`${t('app.type_a_command_search')}`"
|
||||||
|
class="flex flex-1 text-base bg-transparent text-secondaryDark px-6 py-5"
|
||||||
|
/>
|
||||||
|
<HoppSmartSpinner v-if="searchSession?.loading" class="mr-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="searchSession && search.length > 0"
|
||||||
|
class="flex flex-col flex-1 overflow-y-auto border-b border-divider divide-y divide-dividerLight"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="([sectionID, sectionResult], sectionIndex) in scoredResults"
|
||||||
|
:key="`section-${sectionID}`"
|
||||||
|
class="flex flex-col"
|
||||||
|
>
|
||||||
|
<h5
|
||||||
|
class="px-6 py-2 bg-primaryContrast z-10 text-secondaryLight sticky top-0"
|
||||||
|
>
|
||||||
|
{{ sectionResult.title }}
|
||||||
|
</h5>
|
||||||
|
<AppSpotlightEntry
|
||||||
|
v-for="(result, entryIndex) in sectionResult.results"
|
||||||
|
:key="`result-${result.id}`"
|
||||||
|
:entry="result"
|
||||||
|
:active="isEqual(selectedEntry, [sectionIndex, entryIndex])"
|
||||||
|
@mouseover="selectedEntry = [sectionIndex, entryIndex]"
|
||||||
|
@action="runAction(sectionID, result)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<HoppSmartPlaceholder
|
||||||
|
v-if="search.length > 0 && scoredResults.length === 0"
|
||||||
|
:text="`${t('state.nothing_found')} ‟${search}”`"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||||
|
</template>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:label="t('action.clear')"
|
||||||
|
outline
|
||||||
|
@click="search = ''"
|
||||||
|
/>
|
||||||
|
</HoppSmartPlaceholder>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-shrink-0 text-tiny text-secondaryLight p-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<kbd class="shortcut-key">↑</kbd>
|
||||||
|
<kbd class="shortcut-key">↓</kbd>
|
||||||
|
<span class="mx-2 truncate">
|
||||||
|
{{ t("action.to_navigate") }}
|
||||||
|
</span>
|
||||||
|
<kbd class="shortcut-key">↩</kbd>
|
||||||
|
<span class="ml-2 truncate">
|
||||||
|
{{ t("action.to_select") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<kbd class="shortcut-key">ESC</kbd>
|
||||||
|
<span class="ml-2 truncate">
|
||||||
|
{{ t("action.to_close") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</HoppSmartModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from "vue"
|
||||||
|
import { useService } from "dioc/vue"
|
||||||
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import {
|
||||||
|
SpotlightService,
|
||||||
|
SpotlightSearchState,
|
||||||
|
SpotlightSearcherResult,
|
||||||
|
} from "~/services/spotlight"
|
||||||
|
import { isEqual } from "lodash-es"
|
||||||
|
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
|
||||||
|
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "hide-modal"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const spotlightService = useService(SpotlightService)
|
||||||
|
|
||||||
|
useService(HistorySpotlightSearcherService)
|
||||||
|
useService(UserSpotlightSearcherService)
|
||||||
|
|
||||||
|
const search = ref("")
|
||||||
|
|
||||||
|
const searchSession = ref<SpotlightSearchState>()
|
||||||
|
const stopSearchSession = ref<() => void>()
|
||||||
|
|
||||||
|
const scoredResults = computed(() =>
|
||||||
|
Object.entries(searchSession.value?.results ?? {}).sort(
|
||||||
|
([, sectionA], [, sectionB]) => sectionB.avgScore - sectionA.avgScore
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const { selectedEntry } = newUseArrowKeysForNavigation()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
search.value = ""
|
||||||
|
|
||||||
|
if (show) {
|
||||||
|
const [session, onSessionEnd] =
|
||||||
|
spotlightService.createSearchSession(search)
|
||||||
|
|
||||||
|
searchSession.value = session.value
|
||||||
|
stopSearchSession.value = onSessionEnd
|
||||||
|
} else {
|
||||||
|
stopSearchSession.value?.()
|
||||||
|
stopSearchSession.value = undefined
|
||||||
|
searchSession.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function runAction(searcherID: string, result: SpotlightSearcherResult) {
|
||||||
|
spotlightService.selectSearchResult(searcherID, result)
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
|
|
||||||
|
function newUseArrowKeysForNavigation() {
|
||||||
|
const selectedEntry = ref<[number, number]>([0, 0]) // [sectionIndex, entryIndex]
|
||||||
|
|
||||||
|
watch(search, () => {
|
||||||
|
selectedEntry.value = [0, 0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const onArrowDown = () => {
|
||||||
|
// If no entries, do nothing
|
||||||
|
if (scoredResults.value.length === 0) return
|
||||||
|
|
||||||
|
const [sectionIndex, entryIndex] = selectedEntry.value
|
||||||
|
|
||||||
|
const [, section] = scoredResults.value[sectionIndex]
|
||||||
|
|
||||||
|
if (entryIndex < section.results.length - 1) {
|
||||||
|
selectedEntry.value = [sectionIndex, entryIndex + 1]
|
||||||
|
} else if (sectionIndex < scoredResults.value.length - 1) {
|
||||||
|
selectedEntry.value = [sectionIndex + 1, 0]
|
||||||
|
} else {
|
||||||
|
selectedEntry.value = [0, 0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onArrowUp = () => {
|
||||||
|
// If no entries, do nothing
|
||||||
|
if (scoredResults.value.length === 0) return
|
||||||
|
|
||||||
|
const [sectionIndex, entryIndex] = selectedEntry.value
|
||||||
|
|
||||||
|
if (entryIndex > 0) {
|
||||||
|
selectedEntry.value = [sectionIndex, entryIndex - 1]
|
||||||
|
} else if (sectionIndex > 0) {
|
||||||
|
const [, section] = scoredResults.value[sectionIndex - 1]
|
||||||
|
selectedEntry.value = [sectionIndex - 1, section.results.length - 1]
|
||||||
|
} else {
|
||||||
|
selectedEntry.value = [
|
||||||
|
scoredResults.value.length - 1,
|
||||||
|
scoredResults.value[scoredResults.value.length - 1][1].results.length -
|
||||||
|
1,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnter = () => {
|
||||||
|
// If no entries, do nothing
|
||||||
|
if (scoredResults.value.length === 0) return
|
||||||
|
|
||||||
|
const [sectionIndex, entryIndex] = selectedEntry.value
|
||||||
|
const [sectionID, section] = scoredResults.value[sectionIndex]
|
||||||
|
const result = section.results[entryIndex]
|
||||||
|
|
||||||
|
runAction(sectionID, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyPress(e: KeyboardEvent) {
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
onArrowUp()
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
onArrowDown()
|
||||||
|
} else if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
onEnter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (show) {
|
||||||
|
window.addEventListener("keydown", handleKeyPress)
|
||||||
|
} else {
|
||||||
|
window.removeEventListener("keydown", handleKeyPress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return { selectedEntry }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="hideModal"
|
@close="hideModal"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelAdd"
|
placeholder=" "
|
||||||
v-model="name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="addNewCollection"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="addNewCollection"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelAdd">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="emit('hide-modal')"
|
@close="emit('hide-modal')"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelAddFolder"
|
placeholder=" "
|
||||||
v-model="name"
|
input-styles="floating-input"
|
||||||
v-focus
|
:label="t('action.label')"
|
||||||
class="input floating-input"
|
@submit="addFolder"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="addFolder"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelAddFolder">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -6,19 +6,13 @@
|
|||||||
@close="$emit('hide-modal')"
|
@close="$emit('hide-modal')"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelAddRequest"
|
placeholder=" "
|
||||||
v-model="name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="addRequest"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="addRequest"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelAddRequest">{{ t("action.label") }}</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ import IconTrash2 from "~icons/lucide/trash-2"
|
|||||||
import IconEdit from "~icons/lucide/edit"
|
import IconEdit from "~icons/lucide/edit"
|
||||||
import IconFolder from "~icons/lucide/folder"
|
import IconFolder from "~icons/lucide/folder"
|
||||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||||
import { PropType, ref, computed, watch } from "vue"
|
import { ref, computed, watch } from "vue"
|
||||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { TippyComponent } from "vue-tippy"
|
import { TippyComponent } from "vue-tippy"
|
||||||
@@ -209,67 +209,36 @@ type FolderType = "collection" | "folder"
|
|||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = withDefaults(
|
||||||
id: {
|
defineProps<{
|
||||||
type: String,
|
id: string
|
||||||
default: "",
|
parentID?: string | null
|
||||||
required: true,
|
data: HoppCollection<HoppRESTRequest> | TeamCollection
|
||||||
},
|
/**
|
||||||
parentID: {
|
* Collection component can be used for both collections and folders.
|
||||||
type: String as PropType<string | null>,
|
* folderType is used to determine which one it is.
|
||||||
default: null,
|
*/
|
||||||
required: false,
|
collectionsType: CollectionType
|
||||||
},
|
folderType: FolderType
|
||||||
data: {
|
isOpen: boolean
|
||||||
type: Object as PropType<HoppCollection<HoppRESTRequest> | TeamCollection>,
|
isSelected?: boolean | null
|
||||||
default: () => ({}),
|
exportLoading?: boolean
|
||||||
required: true,
|
hasNoTeamAccess?: boolean
|
||||||
},
|
collectionMoveLoading?: string[]
|
||||||
collectionsType: {
|
isLastItem?: boolean
|
||||||
type: String as PropType<CollectionType>,
|
}>(),
|
||||||
default: "my-collections",
|
{
|
||||||
required: true,
|
id: "",
|
||||||
},
|
parentID: null,
|
||||||
/**
|
collectionsType: "my-collections",
|
||||||
* Collection component can be used for both collections and folders.
|
folderType: "collection",
|
||||||
* folderType is used to determine which one it is.
|
isOpen: false,
|
||||||
*/
|
isSelected: false,
|
||||||
folderType: {
|
exportLoading: false,
|
||||||
type: String as PropType<FolderType>,
|
hasNoTeamAccess: false,
|
||||||
default: "collection",
|
isLastItem: false,
|
||||||
required: true,
|
}
|
||||||
},
|
)
|
||||||
isOpen: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
isSelected: {
|
|
||||||
type: Boolean as PropType<boolean | null>,
|
|
||||||
default: false,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
exportLoading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
hasNoTeamAccess: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
collectionMoveLoading: {
|
|
||||||
type: Array as PropType<string[]>,
|
|
||||||
default: () => [],
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
isLastItem: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: "toggle-children"): void
|
(event: "toggle-children"): void
|
||||||
@@ -448,8 +417,13 @@ const notSameDestination = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isCollLoading = computed(() => {
|
const isCollLoading = computed(() => {
|
||||||
if (props.collectionMoveLoading.length > 0 && props.data.id) {
|
const { collectionMoveLoading } = props
|
||||||
return props.collectionMoveLoading.includes(props.data.id)
|
if (
|
||||||
|
collectionMoveLoading &&
|
||||||
|
collectionMoveLoading.length > 0 &&
|
||||||
|
props.data.id
|
||||||
|
) {
|
||||||
|
return collectionMoveLoading.includes(props.data.id)
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="hideModal"
|
@close="hideModal"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelEdit"
|
placeholder=" "
|
||||||
v-model="name"
|
input-styles="floating-input"
|
||||||
v-focus
|
:label="t('action.label')"
|
||||||
class="input floating-input"
|
@submit="saveCollection"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="saveCollection"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelEdit">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="emit('hide-modal')"
|
@close="emit('hide-modal')"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelEditFolder"
|
placeholder=" "
|
||||||
v-model="name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="editFolder"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="editFolder"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelEditFolder">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="hideModal"
|
@close="hideModal"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelEditReq"
|
placeholder=" "
|
||||||
v-model="name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="editRequest"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="editRequest"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelEditReq">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -8,21 +8,15 @@
|
|||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="relative flex">
|
<HoppSmartInput
|
||||||
<input
|
v-model="requestName"
|
||||||
id="selectLabelSaveReq"
|
styles="relative flex"
|
||||||
v-model="requestName"
|
placeholder=" "
|
||||||
v-focus
|
:label="t('request.name')"
|
||||||
class="input floating-input"
|
input-styles="floating-input"
|
||||||
placeholder=" "
|
@submit="saveRequestAs"
|
||||||
type="text"
|
/>
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="saveRequestAs"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelSaveReq">
|
|
||||||
{{ t("request.name") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label class="p-4">
|
<label class="p-4">
|
||||||
{{ t("collection.select_location") }}
|
{{ t("collection.select_location") }}
|
||||||
</label>
|
</label>
|
||||||
@@ -62,7 +56,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, reactive, ref, watch } from "vue"
|
import { computed, nextTick, reactive, ref, watch } from "vue"
|
||||||
import { cloneDeep } from "lodash-es"
|
import { cloneDeep } from "lodash-es"
|
||||||
import {
|
import {
|
||||||
HoppGQLRequest,
|
HoppGQLRequest,
|
||||||
@@ -107,10 +101,12 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
mode: "rest" | "graphql"
|
mode: "rest" | "graphql"
|
||||||
|
request?: HoppRESTRequest | HoppGQLRequest | null
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
show: false,
|
show: false,
|
||||||
mode: "rest",
|
mode: "rest",
|
||||||
|
request: null,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,9 +128,17 @@ const restRequestName = computedWithControl(
|
|||||||
() => currentActiveTab.value.document.request.name
|
() => currentActiveTab.value.document.request.name
|
||||||
)
|
)
|
||||||
|
|
||||||
const requestName = ref(
|
const reqName = computed(() => {
|
||||||
props.mode === "rest" ? restRequestName.value : gqlRequestName.value
|
if (props.request) {
|
||||||
)
|
return props.request.name
|
||||||
|
} else if (props.mode === "rest") {
|
||||||
|
return restRequestName.value
|
||||||
|
} else {
|
||||||
|
return gqlRequestName.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const requestName = ref(reqName.value)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [currentActiveTab.value, gqlRequestName.value],
|
() => [currentActiveTab.value, gqlRequestName.value],
|
||||||
@@ -198,10 +202,15 @@ const saveRequestAs = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestUpdated =
|
let requestUpdated
|
||||||
props.mode === "rest"
|
|
||||||
? cloneDeep(currentActiveTab.value.document.request)
|
if (props.request) {
|
||||||
: cloneDeep(getGQLSession().request)
|
requestUpdated = cloneDeep(props.request)
|
||||||
|
} else if (props.mode === "rest") {
|
||||||
|
requestUpdated = cloneDeep(currentActiveTab.value.document.request)
|
||||||
|
} else {
|
||||||
|
requestUpdated = cloneDeep(getGQLSession().request)
|
||||||
|
}
|
||||||
|
|
||||||
requestUpdated.name = requestName.value
|
requestUpdated.name = requestName.value
|
||||||
|
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="hideModal"
|
@close="hideModal"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelGqlAdd"
|
placeholder=" "
|
||||||
v-model="name"
|
input-styles="floating-input"
|
||||||
v-focus
|
:label="t('action.label')"
|
||||||
class="input floating-input"
|
@submit="addNewCollection"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="addNewCollection"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelGqlAdd">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="$emit('hide-modal')"
|
@close="$emit('hide-modal')"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelGqlAddFolder"
|
placeholder=" "
|
||||||
v-model="name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="addFolder"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="addFolder"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelGqlAddFolder">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="emit('hide-modal')"
|
@close="emit('hide-modal')"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelGqlAddRequest"
|
placeholder=" "
|
||||||
v-model="name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="addRequest"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="addRequest"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelGqlAddRequest">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="hideModal"
|
@close="hideModal"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelGqlEdit"
|
placeholder=" "
|
||||||
v-model="name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="saveCollection"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="saveCollection"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelGqlEdit">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="$emit('hide-modal')"
|
@close="$emit('hide-modal')"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelGqlEditFolder"
|
placeholder=" "
|
||||||
v-model="name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="editFolder"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="editFolder"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelGqlEditFolder">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -6,21 +6,13 @@
|
|||||||
@close="hideModal"
|
@close="hideModal"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="requestUpdateData.name"
|
||||||
id="selectLabelGqlEditReq"
|
placeholder=" "
|
||||||
v-model="requestUpdateData.name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="saveRequest"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="saveRequest"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelGqlEditReq">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
type="search"
|
type="search"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:placeholder="t('action.search')"
|
:placeholder="t('action.search')"
|
||||||
class="py-2 pl-4 pr-2 bg-transparent"
|
class="py-2 pl-4 pr-2 bg-transparent !border-0"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class="flex justify-between flex-1 flex-shrink-0 border-y bg-primary border-dividerLight"
|
class="flex justify-between flex-1 flex-shrink-0 border-y bg-primary border-dividerLight"
|
||||||
|
|||||||
@@ -18,12 +18,12 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<WorkspaceCurrent :section="t('tab.collections')" />
|
<WorkspaceCurrent :section="t('tab.collections')" />
|
||||||
<input
|
|
||||||
|
<HoppSmartInput
|
||||||
v-model="filterTexts"
|
v-model="filterTexts"
|
||||||
type="search"
|
|
||||||
autocomplete="off"
|
|
||||||
:placeholder="t('action.search')"
|
:placeholder="t('action.search')"
|
||||||
class="py-2 pl-4 pr-2 bg-transparent"
|
input-styles="py-2 pl-4 pr-2 bg-transparent !border-0"
|
||||||
|
type="search"
|
||||||
:disabled="collectionsType.type === 'team-collections'"
|
:disabled="collectionsType.type === 'team-collections'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
208
packages/hoppscotch-common/src/components/environments/Add.vue
Normal file
208
packages/hoppscotch-common/src/components/environments/Add.vue
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<template>
|
||||||
|
<HoppSmartModal
|
||||||
|
v-if="show"
|
||||||
|
:title="t('environment.set_as_environment')"
|
||||||
|
@close="hideModal"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="flex space-y-4 flex-1 flex-col">
|
||||||
|
<div class="flex items-center space-x-8 ml-2">
|
||||||
|
<label for="name" class="font-semibold min-w-10">{{
|
||||||
|
t("environment.name")
|
||||||
|
}}</label>
|
||||||
|
<input
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
:placeholder="t('environment.variable')"
|
||||||
|
class="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-8 ml-2">
|
||||||
|
<label for="value" class="font-semibold min-w-10">{{
|
||||||
|
t("environment.value")
|
||||||
|
}}</label>
|
||||||
|
<input type="text" :value="value" class="input" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-8 ml-2">
|
||||||
|
<label for="scope" class="font-semibold min-w-10">
|
||||||
|
{{ t("environment.scope") }}
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
class="relative flex flex-1 flex-col border border-divider rounded focus-visible:border-dividerDark"
|
||||||
|
>
|
||||||
|
<EnvironmentsSelector v-model="scope" :is-scope-selector="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="replaceWithVariable" class="flex space-x-2 mt-3">
|
||||||
|
<div class="min-w-18" />
|
||||||
|
<HoppSmartCheckbox
|
||||||
|
:on="replaceWithVariable"
|
||||||
|
title="t('environment.replace_with_variable'))"
|
||||||
|
@change="replaceWithVariable = !replaceWithVariable"
|
||||||
|
/>
|
||||||
|
<label for="replaceWithVariable">
|
||||||
|
{{ t("environment.replace_with_variable") }}</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<span class="flex space-x-2">
|
||||||
|
<HoppButtonPrimary
|
||||||
|
:label="t('action.save')"
|
||||||
|
outline
|
||||||
|
@click="addEnvironment"
|
||||||
|
/>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
:label="t('action.cancel')"
|
||||||
|
outline
|
||||||
|
filled
|
||||||
|
@click="hideModal"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</HoppSmartModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { Environment } from "@hoppscotch/data"
|
||||||
|
import { ref, watch } from "vue"
|
||||||
|
import { useI18n } from "~/composables/i18n"
|
||||||
|
import { useToast } from "~/composables/toast"
|
||||||
|
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||||
|
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
|
||||||
|
import {
|
||||||
|
addEnvironmentVariable,
|
||||||
|
addGlobalEnvVariable,
|
||||||
|
} from "~/newstore/environments"
|
||||||
|
import * as TE from "fp-ts/TaskEither"
|
||||||
|
import { pipe } from "fp-ts/function"
|
||||||
|
import { updateTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
|
||||||
|
import { currentActiveTab } from "~/helpers/rest/tab"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
position: { top: number; left: number }
|
||||||
|
name: string
|
||||||
|
value: string
|
||||||
|
replaceWithVariable: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "hide-modal"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const hideModal = () => {
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
scope.value = {
|
||||||
|
type: "global",
|
||||||
|
}
|
||||||
|
name.value = ""
|
||||||
|
replaceWithVariable.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scope =
|
||||||
|
| {
|
||||||
|
type: "global"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "my-environment"
|
||||||
|
environment: Environment
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "team-environment"
|
||||||
|
environment: TeamEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = ref<Scope>({
|
||||||
|
type: "global",
|
||||||
|
})
|
||||||
|
|
||||||
|
const replaceWithVariable = ref(false)
|
||||||
|
|
||||||
|
const name = ref("")
|
||||||
|
|
||||||
|
const addEnvironment = async () => {
|
||||||
|
if (!name.value) {
|
||||||
|
toast.error(`${t("environment.invalid_name")}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (scope.value.type === "global") {
|
||||||
|
addGlobalEnvVariable({
|
||||||
|
key: name.value,
|
||||||
|
value: props.value,
|
||||||
|
})
|
||||||
|
toast.success(`${t("environment.updated")}`)
|
||||||
|
} else if (scope.value.type === "my-environment") {
|
||||||
|
addEnvironmentVariable(scope.value.index, {
|
||||||
|
key: name.value,
|
||||||
|
value: props.value,
|
||||||
|
})
|
||||||
|
toast.success(`${t("environment.updated")}`)
|
||||||
|
} else {
|
||||||
|
const newVariables = [
|
||||||
|
...scope.value.environment.environment.variables,
|
||||||
|
{
|
||||||
|
key: name.value,
|
||||||
|
value: props.value,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
await pipe(
|
||||||
|
updateTeamEnvironment(
|
||||||
|
JSON.stringify(newVariables),
|
||||||
|
scope.value.environment.id,
|
||||||
|
scope.value.environment.environment.name
|
||||||
|
),
|
||||||
|
TE.match(
|
||||||
|
(err: GQLError<string>) => {
|
||||||
|
console.error(err)
|
||||||
|
toast.error(`${getErrorMessage(err)}`)
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
hideModal()
|
||||||
|
toast.success(`${t("environment.updated")}`)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
if (replaceWithVariable.value) {
|
||||||
|
//replace the current tab endpoint with the variable name with << and >>
|
||||||
|
const variableName = `<<${name.value}>>`
|
||||||
|
//replace the currenttab endpoint containing the value in the text with variablename
|
||||||
|
currentActiveTab.value.document.request.endpoint =
|
||||||
|
currentActiveTab.value.document.request.endpoint.replace(
|
||||||
|
props.value,
|
||||||
|
variableName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hideModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
const getErrorMessage = (err: GQLError<string>) => {
|
||||||
|
if (err.type === "network_error") {
|
||||||
|
return t("error.network_error")
|
||||||
|
} else {
|
||||||
|
switch (err.error) {
|
||||||
|
case "team_environment/not_found":
|
||||||
|
return t("team_environment.not_found")
|
||||||
|
case "Forbidden resource":
|
||||||
|
return t("profile.no_permission")
|
||||||
|
default:
|
||||||
|
return t("error.something_went_wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<span
|
<span
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
:title="`${t('environment.select')}`"
|
:title="`${t('environment.select')}`"
|
||||||
class="bg-transparent border-b border-dividerLight select-wrapper"
|
class="bg-transparent select-wrapper"
|
||||||
>
|
>
|
||||||
<HoppButtonSecondary
|
<HoppButtonSecondary
|
||||||
:icon="IconLayers"
|
:icon="IconLayers"
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
class="flex-1 !justify-start pr-8 rounded-none"
|
class="flex-1 !justify-start pr-8 rounded-none"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<template #content="{ hide }">
|
<template #content="{ hide }">
|
||||||
<div
|
<div
|
||||||
ref="tippyActions"
|
ref="tippyActions"
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
@keyup.escape="hide()"
|
@keyup.escape="hide()"
|
||||||
>
|
>
|
||||||
<HoppSmartItem
|
<HoppSmartItem
|
||||||
|
v-if="!isScopeSelector"
|
||||||
:label="`${t('environment.no_environment')}`"
|
:label="`${t('environment.no_environment')}`"
|
||||||
:info-icon="
|
:info-icon="
|
||||||
selectedEnvironmentIndex.type === 'NO_ENV_SELECTED'
|
selectedEnvironmentIndex.type === 'NO_ENV_SELECTED'
|
||||||
@@ -47,6 +49,21 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
<HoppSmartItem
|
||||||
|
v-else-if="isScopeSelector && modelValue"
|
||||||
|
:label="t('environment.global')"
|
||||||
|
:icon="IconGlobe"
|
||||||
|
:info-icon="modelValue.type === 'global' ? IconCheck : undefined"
|
||||||
|
:active-info-icon="modelValue.type === 'global'"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
$emit('update:modelValue', {
|
||||||
|
type: 'global',
|
||||||
|
})
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
<HoppSmartTabs
|
<HoppSmartTabs
|
||||||
v-model="selectedEnvTab"
|
v-model="selectedEnvTab"
|
||||||
styles="sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary"
|
styles="sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary"
|
||||||
@@ -61,11 +78,14 @@
|
|||||||
:key="`gen-${index}`"
|
:key="`gen-${index}`"
|
||||||
:icon="IconLayers"
|
:icon="IconLayers"
|
||||||
:label="gen.name"
|
:label="gen.name"
|
||||||
:info-icon="index === selectedEnv.index ? IconCheck : undefined"
|
:info-icon="isEnvActive(index) ? IconCheck : undefined"
|
||||||
:active-info-icon="index === selectedEnv.index"
|
:active-info-icon="isEnvActive(index)"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
selectedEnvironmentIndex = { type: 'MY_ENV', index: index }
|
handleEnvironmentChange(index, {
|
||||||
|
type: 'my-environment',
|
||||||
|
environment: gen,
|
||||||
|
})
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
@@ -96,18 +116,14 @@
|
|||||||
:key="`gen-team-${index}`"
|
:key="`gen-team-${index}`"
|
||||||
:icon="IconLayers"
|
:icon="IconLayers"
|
||||||
:label="gen.environment.name"
|
:label="gen.environment.name"
|
||||||
:info-icon="
|
:info-icon="isEnvActive(gen.id) ? IconCheck : undefined"
|
||||||
gen.id === selectedEnv.teamEnvID ? IconCheck : undefined
|
:active-info-icon="isEnvActive(gen.id)"
|
||||||
"
|
|
||||||
:active-info-icon="gen.id === selectedEnv.teamEnvID"
|
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
selectedEnvironmentIndex = {
|
handleEnvironmentChange(index, {
|
||||||
type: 'TEAM_ENV',
|
type: 'team-environment',
|
||||||
teamEnvID: gen.id,
|
environment: gen,
|
||||||
teamID: gen.teamID,
|
})
|
||||||
environment: gen.environment,
|
|
||||||
}
|
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
@@ -136,9 +152,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, onMounted, ref, watch } from "vue"
|
||||||
import IconCheck from "~icons/lucide/check"
|
import IconCheck from "~icons/lucide/check"
|
||||||
import IconLayers from "~icons/lucide/layers"
|
import IconLayers from "~icons/lucide/layers"
|
||||||
|
import IconGlobe from "~icons/lucide/globe"
|
||||||
import { TippyComponent } from "vue-tippy"
|
import { TippyComponent } from "vue-tippy"
|
||||||
import { useI18n } from "~/composables/i18n"
|
import { useI18n } from "~/composables/i18n"
|
||||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||||
@@ -156,6 +173,31 @@ import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
|||||||
import { useLocalState } from "~/newstore/localstate"
|
import { useLocalState } from "~/newstore/localstate"
|
||||||
import { onLoggedIn } from "~/composables/auth"
|
import { onLoggedIn } from "~/composables/auth"
|
||||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||||
|
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
|
||||||
|
import { Environment } from "@hoppscotch/data"
|
||||||
|
|
||||||
|
type Scope =
|
||||||
|
| {
|
||||||
|
type: "global"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "my-environment"
|
||||||
|
environment: Environment
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "team-environment"
|
||||||
|
environment: TeamEnvironment
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isScopeSelector?: boolean
|
||||||
|
modelValue?: Scope
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", data: Scope): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||||
const mdAndLarger = breakpoints.greater("md")
|
const mdAndLarger = breakpoints.greater("md")
|
||||||
@@ -170,6 +212,39 @@ const myEnvironments = useReadonlyStream(environments$, [])
|
|||||||
|
|
||||||
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
|
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
|
||||||
|
|
||||||
|
// TeamList-Adapter
|
||||||
|
const teamListAdapter = new TeamListAdapter(true)
|
||||||
|
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
|
||||||
|
const teamListFetched = ref(false)
|
||||||
|
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
||||||
|
|
||||||
|
onLoggedIn(() => {
|
||||||
|
!teamListAdapter.isInitialized && teamListAdapter.initialize()
|
||||||
|
})
|
||||||
|
|
||||||
|
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
|
||||||
|
REMEMBERED_TEAM_ID.value = team.id
|
||||||
|
changeWorkspace({
|
||||||
|
teamID: team.id,
|
||||||
|
teamName: team.name,
|
||||||
|
type: "team",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => myTeams.value,
|
||||||
|
(newTeams) => {
|
||||||
|
if (newTeams && !teamListFetched.value) {
|
||||||
|
teamListFetched.value = true
|
||||||
|
if (REMEMBERED_TEAM_ID.value) {
|
||||||
|
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
|
||||||
|
if (team) switchToTeamWorkspace(team)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// TeamEnv List Adapter
|
||||||
const teamEnvListAdapter = new TeamEnvironmentAdapter(undefined)
|
const teamEnvListAdapter = new TeamEnvironmentAdapter(undefined)
|
||||||
const teamListLoading = useReadonlyStream(teamEnvListAdapter.loading$, false)
|
const teamListLoading = useReadonlyStream(teamEnvListAdapter.loading$, false)
|
||||||
const teamAdapterError = useReadonlyStream(teamEnvListAdapter.error$, null)
|
const teamAdapterError = useReadonlyStream(teamEnvListAdapter.error$, null)
|
||||||
@@ -204,63 +279,152 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// TeamList-Adapter
|
const handleEnvironmentChange = (
|
||||||
const teamListAdapter = new TeamListAdapter(true)
|
index: number,
|
||||||
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
|
env?:
|
||||||
const teamListFetched = ref(false)
|
| {
|
||||||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
type: "my-environment"
|
||||||
|
environment: Environment
|
||||||
onLoggedIn(() => {
|
}
|
||||||
!teamListAdapter.isInitialized && teamListAdapter.initialize()
|
| {
|
||||||
})
|
type: "team-environment"
|
||||||
|
environment: TeamEnvironment
|
||||||
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
|
}
|
||||||
REMEMBERED_TEAM_ID.value = team.id
|
) => {
|
||||||
changeWorkspace({
|
if (props.isScopeSelector && env) {
|
||||||
teamID: team.id,
|
if (env.type === "my-environment") {
|
||||||
teamName: team.name,
|
emit("update:modelValue", {
|
||||||
type: "team",
|
type: "my-environment",
|
||||||
})
|
environment: env.environment,
|
||||||
}
|
index,
|
||||||
|
})
|
||||||
watch(
|
} else if (env.type === "team-environment") {
|
||||||
() => myTeams.value,
|
emit("update:modelValue", {
|
||||||
(newTeams) => {
|
type: "team-environment",
|
||||||
if (newTeams && !teamListFetched.value) {
|
environment: env.environment,
|
||||||
teamListFetched.value = true
|
})
|
||||||
if (REMEMBERED_TEAM_ID.value) {
|
}
|
||||||
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
|
} else {
|
||||||
if (team) switchToTeamWorkspace(team)
|
if (env && env.type === "my-environment") {
|
||||||
|
selectedEnvironmentIndex.value = {
|
||||||
|
type: "MY_ENV",
|
||||||
|
index,
|
||||||
|
}
|
||||||
|
} else if (env && env.type === "team-environment") {
|
||||||
|
selectedEnvironmentIndex.value = {
|
||||||
|
type: "TEAM_ENV",
|
||||||
|
teamEnvID: env.environment.id,
|
||||||
|
teamID: env.environment.teamID,
|
||||||
|
environment: env.environment.environment,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
|
|
||||||
|
const isEnvActive = (id: string | number) => {
|
||||||
|
if (props.isScopeSelector) {
|
||||||
|
if (props.modelValue?.type === "my-environment") {
|
||||||
|
return props.modelValue.index === id
|
||||||
|
} else if (props.modelValue?.type === "team-environment") {
|
||||||
|
return (
|
||||||
|
props.modelValue?.type === "team-environment" &&
|
||||||
|
props.modelValue.environment &&
|
||||||
|
props.modelValue.environment.id === id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
|
||||||
|
return selectedEnv.value.index === id
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||||
|
selectedEnv.value.teamEnvID === id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const selectedEnv = computed(() => {
|
const selectedEnv = computed(() => {
|
||||||
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
|
if (props.isScopeSelector) {
|
||||||
return {
|
if (props.modelValue?.type === "my-environment") {
|
||||||
type: "MY_ENV",
|
return {
|
||||||
index: selectedEnvironmentIndex.value.index,
|
type: "MY_ENV",
|
||||||
name: myEnvironments.value[selectedEnvironmentIndex.value.index].name,
|
index: props.modelValue.index,
|
||||||
}
|
name: props.modelValue.environment?.name,
|
||||||
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
|
}
|
||||||
const teamEnv = teamEnvironmentList.value.find(
|
} else if (props.modelValue?.type === "team-environment") {
|
||||||
(env) =>
|
|
||||||
env.id ===
|
|
||||||
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
|
||||||
selectedEnvironmentIndex.value.teamEnvID)
|
|
||||||
)
|
|
||||||
if (teamEnv) {
|
|
||||||
return {
|
return {
|
||||||
type: "TEAM_ENV",
|
type: "TEAM_ENV",
|
||||||
name: teamEnv.environment.name,
|
name: props.modelValue.environment.environment.name,
|
||||||
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
|
teamEnvID: props.modelValue.environment.id,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { type: "global", name: "Global" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
|
||||||
|
return {
|
||||||
|
type: "MY_ENV",
|
||||||
|
index: selectedEnvironmentIndex.value.index,
|
||||||
|
name: myEnvironments.value[selectedEnvironmentIndex.value.index].name,
|
||||||
|
}
|
||||||
|
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
|
||||||
|
const teamEnv = teamEnvironmentList.value.find(
|
||||||
|
(env) =>
|
||||||
|
env.id ===
|
||||||
|
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||||
|
selectedEnvironmentIndex.value.teamEnvID)
|
||||||
|
)
|
||||||
|
if (teamEnv) {
|
||||||
|
return {
|
||||||
|
type: "TEAM_ENV",
|
||||||
|
name: teamEnv.environment.name,
|
||||||
|
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return { type: "NO_ENV_SELECTED" }
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return { type: "NO_ENV_SELECTED" }
|
return { type: "NO_ENV_SELECTED" }
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
return { type: "NO_ENV_SELECTED" }
|
})
|
||||||
|
|
||||||
|
// Set the selected environment as initial scope value
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.isScopeSelector) {
|
||||||
|
if (
|
||||||
|
selectedEnvironmentIndex.value.type === "MY_ENV" &&
|
||||||
|
selectedEnvironmentIndex.value.index !== undefined
|
||||||
|
) {
|
||||||
|
emit("update:modelValue", {
|
||||||
|
type: "my-environment",
|
||||||
|
environment: myEnvironments.value[selectedEnvironmentIndex.value.index],
|
||||||
|
index: selectedEnvironmentIndex.value.index,
|
||||||
|
})
|
||||||
|
} else if (
|
||||||
|
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||||
|
selectedEnvironmentIndex.value.teamEnvID &&
|
||||||
|
teamEnvironmentList.value &&
|
||||||
|
teamEnvironmentList.value.length > 0
|
||||||
|
) {
|
||||||
|
const teamEnv = teamEnvironmentList.value.find(
|
||||||
|
(env) =>
|
||||||
|
env.id ===
|
||||||
|
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||||
|
selectedEnvironmentIndex.value.teamEnvID)
|
||||||
|
)
|
||||||
|
if (teamEnv) {
|
||||||
|
emit("update:modelValue", {
|
||||||
|
type: "team-environment",
|
||||||
|
environment: teamEnv,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
emit("update:modelValue", {
|
||||||
|
type: "global",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
:editing-variable-name="editingVariableName"
|
:editing-variable-name="editingVariableName"
|
||||||
@hide-modal="displayModalEdit(false)"
|
@hide-modal="displayModalEdit(false)"
|
||||||
/>
|
/>
|
||||||
|
<EnvironmentsAdd
|
||||||
|
:show="showModalNew"
|
||||||
|
:name="editingVariableName"
|
||||||
|
:value="editingVariableValue"
|
||||||
|
:position="position"
|
||||||
|
@hide-modal="displayModalNew(false)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -161,10 +168,18 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const showModalNew = ref(false)
|
||||||
const showModalDetails = ref(false)
|
const showModalDetails = ref(false)
|
||||||
const action = ref<"new" | "edit">("edit")
|
const action = ref<"new" | "edit">("edit")
|
||||||
const editingEnvironmentIndex = ref<"Global" | null>(null)
|
const editingEnvironmentIndex = ref<"Global" | null>(null)
|
||||||
const editingVariableName = ref("")
|
const editingVariableName = ref("")
|
||||||
|
const editingVariableValue = ref("")
|
||||||
|
|
||||||
|
const position = ref({ top: 0, left: 0 })
|
||||||
|
|
||||||
|
const displayModalNew = (shouldDisplay: boolean) => {
|
||||||
|
showModalNew.value = shouldDisplay
|
||||||
|
}
|
||||||
|
|
||||||
const displayModalEdit = (shouldDisplay: boolean) => {
|
const displayModalEdit = (shouldDisplay: boolean) => {
|
||||||
action.value = "edit"
|
action.value = "edit"
|
||||||
@@ -233,4 +248,10 @@ watch(
|
|||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
defineActionHandler("modals.environment.add", ({ envName, variableName }) => {
|
||||||
|
editingVariableName.value = envName
|
||||||
|
editingVariableValue.value = variableName
|
||||||
|
displayModalNew(true)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,22 +7,15 @@
|
|||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="relative flex">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelEnvEdit"
|
placeholder=" "
|
||||||
v-model="name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
:disabled="editingEnvironmentIndex === 'Global'"
|
||||||
placeholder=" "
|
@submit="saveEnvironment"
|
||||||
type="text"
|
/>
|
||||||
autocomplete="off"
|
|
||||||
:disabled="editingEnvironmentIndex === 'Global'"
|
|
||||||
@keyup.enter="saveEnvironment"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelEnvEdit">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between flex-1">
|
<div class="flex items-center justify-between flex-1">
|
||||||
<label for="variableList" class="p-4">
|
<label for="variableList" class="p-4">
|
||||||
{{ t("environment.variable_list") }}
|
{{ t("environment.variable_list") }}
|
||||||
|
|||||||
@@ -7,23 +7,15 @@
|
|||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col px-2">
|
<div class="flex flex-col px-2">
|
||||||
<div class="relative flex">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelEnvEdit"
|
placeholder=" "
|
||||||
v-model="name"
|
:input-styles="['floating-input', isViewer && 'opacity-25']"
|
||||||
v-focus
|
:label="t('action.label')"
|
||||||
class="input floating-input"
|
:disabled="isViewer"
|
||||||
:class="isViewer && 'opacity-25'"
|
@submit="saveEnvironment"
|
||||||
placeholder=""
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
:disabled="isViewer"
|
|
||||||
@keyup.enter="saveEnvironment"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelEnvEdit">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between flex-1">
|
<div class="flex items-center justify-between flex-1">
|
||||||
<label for="variableList" class="p-4">
|
<label for="variableList" class="p-4">
|
||||||
{{ t("environment.variable_list") }}
|
{{ t("environment.variable_list") }}
|
||||||
|
|||||||
@@ -37,24 +37,14 @@
|
|||||||
class="flex flex-col space-y-2"
|
class="flex flex-col space-y-2"
|
||||||
@submit.prevent="signInWithEmail"
|
@submit.prevent="signInWithEmail"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="form.email"
|
||||||
id="email"
|
type="email"
|
||||||
v-model="form.email"
|
placeholder=" "
|
||||||
v-focus
|
:label="t('auth.email')"
|
||||||
class="input floating-input"
|
input-styles="floating-input"
|
||||||
placeholder=" "
|
/>
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
spellcheck="false"
|
|
||||||
autofocus
|
|
||||||
/>
|
|
||||||
<label for="email">
|
|
||||||
{{ t("auth.email") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<HoppButtonPrimary
|
<HoppButtonPrimary
|
||||||
:loading="signingInWithEmail"
|
:loading="signingInWithEmail"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex" @click="OpenLogoutModal()">
|
<div class="flex" @click="openLogoutModal()">
|
||||||
<HoppSmartItem
|
<HoppSmartItem
|
||||||
ref="logoutItem"
|
ref="logoutItem"
|
||||||
:icon="IconLogOut"
|
:icon="IconLogOut"
|
||||||
:label="`${t('auth.logout')}`"
|
:label="`${t('auth.logout')}`"
|
||||||
:outline="outline"
|
:outline="outline"
|
||||||
:shortcut="shortcut"
|
:shortcut="shortcut"
|
||||||
@click="OpenLogoutModal()"
|
@click="openLogoutModal()"
|
||||||
/>
|
/>
|
||||||
<HoppSmartConfirmModal
|
<HoppSmartConfirmModal
|
||||||
:show="confirmLogout"
|
:show="confirmLogout"
|
||||||
@@ -23,6 +23,7 @@ import IconLogOut from "~icons/lucide/log-out"
|
|||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
|
import { defineActionHandler } from "~/helpers/actions"
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
outline: {
|
outline: {
|
||||||
@@ -55,8 +56,12 @@ const logout = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const OpenLogoutModal = () => {
|
const openLogoutModal = () => {
|
||||||
emit("confirm-logout")
|
emit("confirm-logout")
|
||||||
confirmLogout.value = true
|
confirmLogout.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineActionHandler("user.logout", () => {
|
||||||
|
openLogoutModal()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ import { GQLHistoryEntry } from "~/newstore/history"
|
|||||||
import { shortDateTime } from "~/helpers/utils/date"
|
import { shortDateTime } from "~/helpers/utils/date"
|
||||||
|
|
||||||
import IconStar from "~icons/lucide/star"
|
import IconStar from "~icons/lucide/star"
|
||||||
import IconStarOff from "~icons/lucide/star-off"
|
import IconStarOff from "~icons/hopp/star-off"
|
||||||
import IconTrash from "~icons/lucide/trash"
|
import IconTrash from "~icons/lucide/trash"
|
||||||
import IconMinimize2 from "~icons/lucide/minimize-2"
|
import IconMinimize2 from "~icons/lucide/minimize-2"
|
||||||
import IconMaximize2 from "~icons/lucide/maximize-2"
|
import IconMaximize2 from "~icons/lucide/maximize-2"
|
||||||
|
|||||||
@@ -105,6 +105,7 @@
|
|||||||
@toggle-star="toggleStar(entry.entry)"
|
@toggle-star="toggleStar(entry.entry)"
|
||||||
@delete-entry="deleteHistory(entry.entry)"
|
@delete-entry="deleteHistory(entry.entry)"
|
||||||
@use-entry="useHistory(toRaw(entry.entry))"
|
@use-entry="useHistory(toRaw(entry.entry))"
|
||||||
|
@add-to-collection="addToCollection(entry.entry)"
|
||||||
/>
|
/>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,6 +177,7 @@ import {
|
|||||||
import HistoryRestCard from "./rest/Card.vue"
|
import HistoryRestCard from "./rest/Card.vue"
|
||||||
import HistoryGraphqlCard from "./graphql/Card.vue"
|
import HistoryGraphqlCard from "./graphql/Card.vue"
|
||||||
import { createNewTab } from "~/helpers/rest/tab"
|
import { createNewTab } from "~/helpers/rest/tab"
|
||||||
|
import { defineActionHandler, invokeAction } from "~/helpers/actions"
|
||||||
|
|
||||||
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
|
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
|
||||||
|
|
||||||
@@ -323,10 +325,22 @@ const deleteHistory = (entry: HistoryEntry) => {
|
|||||||
toast.success(`${t("state.deleted")}`)
|
toast.success(`${t("state.deleted")}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addToCollection = (entry: HistoryEntry) => {
|
||||||
|
if (props.page === "rest") {
|
||||||
|
invokeAction("request.save-as", {
|
||||||
|
request: entry.request,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleStar = (entry: HistoryEntry) => {
|
const toggleStar = (entry: HistoryEntry) => {
|
||||||
// History entry type specified because function does not know the type
|
// History entry type specified because function does not know the type
|
||||||
if (props.page === "rest")
|
if (props.page === "rest")
|
||||||
toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
|
toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
|
||||||
else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
|
else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineActionHandler("history.clear", () => {
|
||||||
|
confirmRemove.value = true
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-stretch group">
|
<div
|
||||||
|
class="flex items-stretch group"
|
||||||
|
@contextmenu.prevent="options!.tippy.show()"
|
||||||
|
>
|
||||||
<span
|
<span
|
||||||
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
|
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
|
||||||
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
||||||
@@ -26,6 +29,39 @@
|
|||||||
{{ entry.request.endpoint }}
|
{{ entry.request.endpoint }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
<tippy
|
||||||
|
ref="options"
|
||||||
|
interactive
|
||||||
|
trigger="click"
|
||||||
|
theme="popover"
|
||||||
|
:on-shown="() => tippyActions!.focus()"
|
||||||
|
>
|
||||||
|
<template #content="{ hide }">
|
||||||
|
<div
|
||||||
|
ref="tippyActions"
|
||||||
|
class="flex flex-col focus:outline-none"
|
||||||
|
tabindex="0"
|
||||||
|
role="menu"
|
||||||
|
@keyup.s="addToCollectionAction?.$el.click()"
|
||||||
|
@keyup.escape="hide()"
|
||||||
|
>
|
||||||
|
<HoppSmartItem
|
||||||
|
ref="addToCollectionAction"
|
||||||
|
:icon="IconSave"
|
||||||
|
:label="`${t('collection.save_to_collection')}`"
|
||||||
|
:shortcut="['S']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('add-to-collection')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</tippy>
|
||||||
|
</span>
|
||||||
<HoppButtonSecondary
|
<HoppButtonSecondary
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
:icon="IconTrash"
|
:icon="IconTrash"
|
||||||
@@ -48,15 +84,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue"
|
import { computed, ref } from "vue"
|
||||||
import findStatusGroup from "~/helpers/findStatusGroup"
|
import findStatusGroup from "~/helpers/findStatusGroup"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { RESTHistoryEntry } from "~/newstore/history"
|
import { RESTHistoryEntry } from "~/newstore/history"
|
||||||
import { shortDateTime } from "~/helpers/utils/date"
|
import { shortDateTime } from "~/helpers/utils/date"
|
||||||
|
import IconSave from "~icons/lucide/save"
|
||||||
import IconStar from "~icons/lucide/star"
|
import IconStar from "~icons/lucide/star"
|
||||||
import IconStarOff from "~icons/lucide/star-off"
|
import IconStarOff from "~icons/hopp/star-off"
|
||||||
import IconTrash from "~icons/lucide/trash"
|
import IconTrash from "~icons/lucide/trash"
|
||||||
|
import { TippyComponent } from "vue-tippy"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
entry: RESTHistoryEntry
|
entry: RESTHistoryEntry
|
||||||
@@ -67,8 +104,13 @@ const emit = defineEmits<{
|
|||||||
(e: "use-entry"): void
|
(e: "use-entry"): void
|
||||||
(e: "delete-entry"): void
|
(e: "delete-entry"): void
|
||||||
(e: "toggle-star"): void
|
(e: "toggle-star"): void
|
||||||
|
(e: "add-to-collection"): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const tippyActions = ref<TippyComponent | null>(null)
|
||||||
|
const options = ref<TippyComponent | null>(null)
|
||||||
|
const addToCollectionAction = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
const duration = computed(() => {
|
const duration = computed(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="sticky top-0 z-20 flex-none flex-shrink-0 p-4 overflow-x-auto sm:flex sm:flex-shrink-0 sm:space-x-2 bg-primary"
|
class="sticky top-0 z-20 flex-none flex-shrink-0 p-4 sm:flex sm:flex-shrink-0 sm:space-x-2 bg-primary"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap"
|
class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap"
|
||||||
@@ -47,13 +47,14 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex flex-1 overflow-auto transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
|
class="flex flex-1 transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<SmartEnvInput
|
<SmartEnvInput
|
||||||
v-model="tab.document.request.endpoint"
|
v-model="tab.document.request.endpoint"
|
||||||
:placeholder="`${t('request.url')}`"
|
:placeholder="`${t('request.url')}`"
|
||||||
@enter="newSendRequest()"
|
:auto-complete-source="userHistories"
|
||||||
@paste="onPasteUrl($event)"
|
@paste="onPasteUrl($event)"
|
||||||
|
@enter="newSendRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -220,6 +221,7 @@
|
|||||||
v-if="showSaveRequestModal"
|
v-if="showSaveRequestModal"
|
||||||
mode="rest"
|
mode="rest"
|
||||||
:show="showSaveRequestModal"
|
:show="showSaveRequestModal"
|
||||||
|
:request="request"
|
||||||
@hide-modal="showSaveRequestModal = false"
|
@hide-modal="showSaveRequestModal = false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +230,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useSetting } from "@composables/settings"
|
import { useSetting } from "@composables/settings"
|
||||||
import { useStreamSubscriber } from "@composables/stream"
|
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
import { refAutoReset, useVModel } from "@vueuse/core"
|
import { refAutoReset, useVModel } from "@vueuse/core"
|
||||||
import * as E from "fp-ts/Either"
|
import * as E from "fp-ts/Either"
|
||||||
@@ -259,8 +261,10 @@ import IconSave from "~icons/lucide/save"
|
|||||||
import IconShare2 from "~icons/lucide/share-2"
|
import IconShare2 from "~icons/lucide/share-2"
|
||||||
import { HoppRESTTab } from "~/helpers/rest/tab"
|
import { HoppRESTTab } from "~/helpers/rest/tab"
|
||||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||||
|
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
import { getCurrentStrategyID } from "~/helpers/network"
|
import { getCurrentStrategyID } from "~/helpers/network"
|
||||||
|
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
@@ -313,6 +317,12 @@ const clearAll = ref<any | null>(null)
|
|||||||
const copyRequestAction = ref<any | null>(null)
|
const copyRequestAction = ref<any | null>(null)
|
||||||
const saveRequestAction = ref<any | null>(null)
|
const saveRequestAction = ref<any | null>(null)
|
||||||
|
|
||||||
|
const history = useReadonlyStream<RESTHistoryEntry[]>(restHistory$, [])
|
||||||
|
|
||||||
|
const userHistories = computed(() => {
|
||||||
|
return history.value.map((history) => history.request.endpoint).slice(0, 10)
|
||||||
|
})
|
||||||
|
|
||||||
const newSendRequest = async () => {
|
const newSendRequest = async () => {
|
||||||
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
|
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
|
||||||
toast.error(`${t("empty.endpoint")}`)
|
toast.error(`${t("empty.endpoint")}`)
|
||||||
@@ -570,6 +580,8 @@ const saveRequest = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const request = ref<HoppRESTRequest | null>(null)
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (loading.value) cancelRequest()
|
if (loading.value) cancelRequest()
|
||||||
})
|
})
|
||||||
@@ -585,7 +597,22 @@ defineActionHandler("request.method.prev", cycleUpMethod)
|
|||||||
defineActionHandler("request.save", saveRequest)
|
defineActionHandler("request.save", saveRequest)
|
||||||
defineActionHandler(
|
defineActionHandler(
|
||||||
"request.save-as",
|
"request.save-as",
|
||||||
() => (showSaveRequestModal.value = true)
|
(
|
||||||
|
req:
|
||||||
|
| {
|
||||||
|
requestType: "rest"
|
||||||
|
request: HoppRESTRequest
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
requestType: "gql"
|
||||||
|
request: HoppGQLRequest
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
showSaveRequestModal.value = true
|
||||||
|
if (req && req.requestType === "rest") {
|
||||||
|
request.value = req.request
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
defineActionHandler("request.method.get", () => updateMethod("GET"))
|
defineActionHandler("request.method.get", () => updateMethod("GET"))
|
||||||
defineActionHandler("request.method.post", () => updateMethod("POST"))
|
defineActionHandler("request.method.post", () => updateMethod("POST"))
|
||||||
|
|||||||
126
packages/hoppscotch-common/src/components/http/TabHead.vue
Normal file
126
packages/hoppscotch-common/src/components/http/TabHead.vue
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
|
||||||
|
:title="tab.document.request.name"
|
||||||
|
class="truncate px-2 flex items-center"
|
||||||
|
@dblclick="emit('open-rename-modal')"
|
||||||
|
@contextmenu.prevent="options?.tippy.show()"
|
||||||
|
@click.middle="emit('close-tab')"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="font-semibold text-tiny"
|
||||||
|
:class="getMethodLabelColorClassOf(tab.document.request)"
|
||||||
|
>
|
||||||
|
{{ tab.document.request.method }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<tippy
|
||||||
|
ref="options"
|
||||||
|
trigger="manual"
|
||||||
|
interactive
|
||||||
|
theme="popover"
|
||||||
|
:on-shown="() => tippyActions!.focus()"
|
||||||
|
>
|
||||||
|
<span class="leading-8 px-2">
|
||||||
|
{{ tab.document.request.name }}
|
||||||
|
</span>
|
||||||
|
<template #content="{ hide }">
|
||||||
|
<div
|
||||||
|
ref="tippyActions"
|
||||||
|
class="flex flex-col focus:outline-none"
|
||||||
|
tabindex="0"
|
||||||
|
@keyup.r="renameAction?.$el.click()"
|
||||||
|
@keyup.d="duplicateAction?.$el.click()"
|
||||||
|
@keyup.w="closeAction?.$el.click()"
|
||||||
|
@keyup.x="closeOthersAction?.$el.click()"
|
||||||
|
@keyup.escape="hide()"
|
||||||
|
>
|
||||||
|
<HoppSmartItem
|
||||||
|
ref="renameAction"
|
||||||
|
:icon="IconFileEdit"
|
||||||
|
:label="t('request.rename')"
|
||||||
|
:shortcut="['R']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('open-rename-modal')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<HoppSmartItem
|
||||||
|
ref="duplicateAction"
|
||||||
|
:icon="IconCopy"
|
||||||
|
:label="t('tab.duplicate')"
|
||||||
|
:shortcut="['D']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('duplicate-tab')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<HoppSmartItem
|
||||||
|
v-if="isRemovable"
|
||||||
|
ref="closeAction"
|
||||||
|
:icon="IconXCircle"
|
||||||
|
:label="t('tab.close')"
|
||||||
|
:shortcut="['W']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('close-tab')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<HoppSmartItem
|
||||||
|
v-if="isRemovable"
|
||||||
|
ref="closeOthersAction"
|
||||||
|
:icon="IconXSquare"
|
||||||
|
:label="t('tab.close_others')"
|
||||||
|
:shortcut="['X']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('close-other-tabs')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</tippy>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
|
import { TippyComponent } from "vue-tippy"
|
||||||
|
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
|
||||||
|
import { useI18n } from "~/composables/i18n"
|
||||||
|
import { HoppRESTTab } from "~/helpers/rest/tab"
|
||||||
|
import IconXCircle from "~icons/lucide/x-circle"
|
||||||
|
import IconXSquare from "~icons/lucide/x-square"
|
||||||
|
import IconFileEdit from "~icons/lucide/file-edit"
|
||||||
|
import IconCopy from "~icons/lucide/copy"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
tab: HoppRESTTab
|
||||||
|
isRemovable: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "open-rename-modal"): void
|
||||||
|
(event: "close-tab"): void
|
||||||
|
(event: "close-other-tabs"): void
|
||||||
|
(event: "duplicate-tab"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tippyActions = ref<TippyComponent | null>(null)
|
||||||
|
const options = ref<TippyComponent | null>(null)
|
||||||
|
|
||||||
|
const renameAction = ref<HTMLButtonElement | null>(null)
|
||||||
|
const closeAction = ref<HTMLButtonElement | null>(null)
|
||||||
|
const closeOthersAction = ref<HTMLButtonElement | null>(null)
|
||||||
|
const duplicateAction = ref<HTMLButtonElement | null>(null)
|
||||||
|
</script>
|
||||||
@@ -18,15 +18,13 @@
|
|||||||
</span>
|
</span>
|
||||||
<template #content="{ hide }">
|
<template #content="{ hide }">
|
||||||
<div class="flex flex-col space-y-2">
|
<div class="flex flex-col space-y-2">
|
||||||
<div class="sticky z-10 top-0 flex-shrink-0 overflow-x-auto">
|
<HoppSmartInput
|
||||||
<input
|
v-model="searchQuery"
|
||||||
v-model="searchQuery"
|
styles="ticky z-10 top-0 flex-shrink-0 overflow-x-auto"
|
||||||
type="search"
|
:placeholder="`${t('action.search')}`"
|
||||||
autocomplete="off"
|
type="search"
|
||||||
class="flex w-full p-4 py-2 input !bg-primaryContrast"
|
input-styles="flex w-full p-4 py-2 input !bg-primaryContrast"
|
||||||
:placeholder="`${t('action.search')}`"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
ref="tippyActions"
|
ref="tippyActions"
|
||||||
class="flex flex-col focus:outline-none"
|
class="flex flex-col focus:outline-none"
|
||||||
|
|||||||
@@ -1,19 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="autocomplete-wrapper">
|
||||||
class="relative flex items-center flex-1 flex-shrink-0 py-4 overflow-auto whitespace-nowrap"
|
<div class="absolute inset-0 flex flex-1 overflow-x-auto">
|
||||||
>
|
|
||||||
<div class="absolute inset-0 flex flex-1">
|
|
||||||
<div
|
<div
|
||||||
ref="editor"
|
ref="editor"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
class="flex flex-1"
|
class="flex flex-1"
|
||||||
:class="styles"
|
:class="styles"
|
||||||
@keydown.enter.prevent="emit('enter', $event)"
|
|
||||||
@keyup="emit('keyup', $event)"
|
|
||||||
@click="emit('click', $event)"
|
@click="emit('click', $event)"
|
||||||
@keydown="emit('keydown', $event)"
|
@keydown="handleKeystroke"
|
||||||
|
@focusin="showSuggestionPopover = true"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
<ul
|
||||||
|
v-if="showSuggestionPopover && autoCompleteSource"
|
||||||
|
ref="suggestionsMenu"
|
||||||
|
class="suggestions"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(suggestion, index) in suggestions"
|
||||||
|
:key="`suggestion-${index}`"
|
||||||
|
:class="{ active: currentSuggestionIndex === index }"
|
||||||
|
@click="updateModelValue(suggestion)"
|
||||||
|
>
|
||||||
|
<span class="truncate py-0.5">
|
||||||
|
{{ suggestion }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="currentSuggestionIndex === index"
|
||||||
|
class="hidden md:flex text-secondary items-center"
|
||||||
|
>
|
||||||
|
<kbd class="shortcut-key">TAB</kbd>
|
||||||
|
<span class="ml-2 truncate">to select</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li v-if="suggestions.length === 0" class="pointer-events-none">
|
||||||
|
<span class="truncate py-0.5">
|
||||||
|
{{ t("empty.history_suggestions") }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -35,6 +60,9 @@ import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironme
|
|||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
|
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
|
import { useI18n } from "~/composables/i18n"
|
||||||
|
import { onClickOutside, useDebounceFn } from "@vueuse/core"
|
||||||
|
import { invokeAction } from "~/helpers/actions"
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -46,6 +74,7 @@ const props = withDefaults(
|
|||||||
selectTextOnMount?: boolean
|
selectTextOnMount?: boolean
|
||||||
environmentHighlights?: boolean
|
environmentHighlights?: boolean
|
||||||
readonly?: boolean
|
readonly?: boolean
|
||||||
|
autoCompleteSource?: string[]
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: "",
|
modelValue: "",
|
||||||
@@ -55,6 +84,7 @@ const props = withDefaults(
|
|||||||
focus: false,
|
focus: false,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
environmentHighlights: true,
|
environmentHighlights: true,
|
||||||
|
autoCompleteSource: undefined,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,12 +98,165 @@ const emit = defineEmits<{
|
|||||||
(e: "click", ev: any): void
|
(e: "click", ev: any): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
const cachedValue = ref(props.modelValue)
|
const cachedValue = ref(props.modelValue)
|
||||||
|
|
||||||
const view = ref<EditorView>()
|
const view = ref<EditorView>()
|
||||||
|
|
||||||
const editor = ref<any | null>(null)
|
const editor = ref<any | null>(null)
|
||||||
|
|
||||||
|
const currentSuggestionIndex = ref(-1)
|
||||||
|
const showSuggestionPopover = ref(false)
|
||||||
|
|
||||||
|
const suggestionsMenu = ref<any | null>(null)
|
||||||
|
|
||||||
|
onClickOutside(suggestionsMenu, () => {
|
||||||
|
showSuggestionPopover.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
//filter autocompleteSource with unique values
|
||||||
|
const uniqueAutoCompleteSource = computed(() => {
|
||||||
|
if (props.autoCompleteSource) {
|
||||||
|
return [...new Set(props.autoCompleteSource)]
|
||||||
|
} else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const suggestions = computed(() => {
|
||||||
|
if (
|
||||||
|
props.modelValue &&
|
||||||
|
props.modelValue.length > 0 &&
|
||||||
|
uniqueAutoCompleteSource.value &&
|
||||||
|
uniqueAutoCompleteSource.value.length > 0
|
||||||
|
) {
|
||||||
|
return uniqueAutoCompleteSource.value.filter((suggestion) =>
|
||||||
|
suggestion.toLowerCase().includes(props.modelValue.toLowerCase())
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return uniqueAutoCompleteSource.value ?? []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateModelValue = (value: string) => {
|
||||||
|
emit("update:modelValue", value)
|
||||||
|
emit("change", value)
|
||||||
|
showSuggestionPopover.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeystroke = (ev: KeyboardEvent) => {
|
||||||
|
if (["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].includes(ev.key)) {
|
||||||
|
ev.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.shiftKey) {
|
||||||
|
showSuggestionPopover.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuggestionPopover.value = true
|
||||||
|
|
||||||
|
if (
|
||||||
|
["Enter", "Tab"].includes(ev.key) &&
|
||||||
|
suggestions.value.length > 0 &&
|
||||||
|
currentSuggestionIndex.value > -1
|
||||||
|
) {
|
||||||
|
updateModelValue(suggestions.value[currentSuggestionIndex.value])
|
||||||
|
currentSuggestionIndex.value = -1
|
||||||
|
|
||||||
|
//used to set codemirror cursor at the end of the line after selecting a suggestion
|
||||||
|
nextTick(() => {
|
||||||
|
view.value?.dispatch({
|
||||||
|
selection: EditorSelection.create([
|
||||||
|
EditorSelection.range(
|
||||||
|
props.modelValue.length,
|
||||||
|
props.modelValue.length
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "ArrowDown") {
|
||||||
|
scrollActiveElIntoView()
|
||||||
|
|
||||||
|
currentSuggestionIndex.value =
|
||||||
|
currentSuggestionIndex.value < suggestions.value.length - 1
|
||||||
|
? currentSuggestionIndex.value + 1
|
||||||
|
: suggestions.value.length - 1
|
||||||
|
|
||||||
|
emit("keydown", ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "ArrowUp") {
|
||||||
|
scrollActiveElIntoView()
|
||||||
|
|
||||||
|
currentSuggestionIndex.value =
|
||||||
|
currentSuggestionIndex.value - 1 >= 0
|
||||||
|
? currentSuggestionIndex.value - 1
|
||||||
|
: 0
|
||||||
|
|
||||||
|
emit("keyup", ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "Enter") {
|
||||||
|
emit("enter", ev)
|
||||||
|
showSuggestionPopover.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ev.key === "Escape") {
|
||||||
|
showSuggestionPopover.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// used to scroll to the first suggestion when left arrow is pressed
|
||||||
|
if (ev.key === "ArrowLeft") {
|
||||||
|
if (suggestions.value.length > 0) {
|
||||||
|
currentSuggestionIndex.value = 0
|
||||||
|
nextTick(() => {
|
||||||
|
scrollActiveElIntoView()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// used to scroll to the last suggestion when right arrow is pressed
|
||||||
|
if (ev.key === "ArrowRight") {
|
||||||
|
if (suggestions.value.length > 0) {
|
||||||
|
currentSuggestionIndex.value = suggestions.value.length - 1
|
||||||
|
nextTick(() => {
|
||||||
|
scrollActiveElIntoView()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset currentSuggestionIndex showSuggestionPopover is false
|
||||||
|
watch(
|
||||||
|
() => showSuggestionPopover.value,
|
||||||
|
(newVal) => {
|
||||||
|
if (!newVal) {
|
||||||
|
currentSuggestionIndex.value = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to scroll the active suggestion into view
|
||||||
|
*/
|
||||||
|
const scrollActiveElIntoView = () => {
|
||||||
|
const suggestionsMenuEl = suggestionsMenu.value
|
||||||
|
if (suggestionsMenuEl) {
|
||||||
|
const activeSuggestionEl = suggestionsMenuEl.querySelector(".active")
|
||||||
|
if (activeSuggestionEl) {
|
||||||
|
activeSuggestionEl.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "center",
|
||||||
|
inline: "start",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
@@ -122,8 +305,45 @@ const envVars = computed(() =>
|
|||||||
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
|
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
|
||||||
|
|
||||||
const initView = (el: any) => {
|
const initView = (el: any) => {
|
||||||
|
function handleTextSelection() {
|
||||||
|
const selection = view.value?.state.selection.main
|
||||||
|
if (selection) {
|
||||||
|
const from = selection.from
|
||||||
|
const to = selection.to
|
||||||
|
const text = view.value?.state.doc.sliceString(from, to)
|
||||||
|
const { top, left } = view.value?.coordsAtPos(from)
|
||||||
|
if (text) {
|
||||||
|
invokeAction("contextmenu.open", {
|
||||||
|
position: {
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
showSuggestionPopover.value = false
|
||||||
|
} else {
|
||||||
|
invokeAction("contextmenu.open", {
|
||||||
|
position: {
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
},
|
||||||
|
text: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce to prevent double click from selecting the word
|
||||||
|
const debounceFn = useDebounceFn(() => {
|
||||||
|
handleTextSelection()
|
||||||
|
}, 140)
|
||||||
|
|
||||||
|
el.addEventListener("mouseup", debounceFn)
|
||||||
|
el.addEventListener("keyup", debounceFn)
|
||||||
|
|
||||||
const extensions: Extension = [
|
const extensions: Extension = [
|
||||||
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
|
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
|
||||||
|
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (props.readonly) {
|
if (props.readonly) {
|
||||||
update.view.contentDOM.inputMode = "none"
|
update.view.contentDOM.inputMode = "none"
|
||||||
@@ -236,3 +456,49 @@ watch(editor, () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.autocomplete-wrapper {
|
||||||
|
@apply relative;
|
||||||
|
@apply flex;
|
||||||
|
@apply flex-1;
|
||||||
|
@apply flex-shrink-0;
|
||||||
|
@apply whitespace-nowrap;
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
@apply absolute;
|
||||||
|
@apply bg-popover;
|
||||||
|
@apply z-50;
|
||||||
|
@apply shadow-lg;
|
||||||
|
@apply max-h-46;
|
||||||
|
@apply border-b border-x border-divider;
|
||||||
|
@apply overflow-y-auto;
|
||||||
|
@apply -left-[1px];
|
||||||
|
@apply -right-[1px];
|
||||||
|
|
||||||
|
top: calc(100% + 1px);
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
@apply flex;
|
||||||
|
@apply items-center;
|
||||||
|
@apply justify-between;
|
||||||
|
@apply w-full;
|
||||||
|
@apply py-2 px-4;
|
||||||
|
@apply text-secondary;
|
||||||
|
@apply cursor-pointer;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-radius: 0 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&.active {
|
||||||
|
@apply bg-primaryDark;
|
||||||
|
@apply text-secondaryDark;
|
||||||
|
@apply cursor-pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
class="flex flex-col flex-1"
|
class="flex flex-col flex-1"
|
||||||
>
|
>
|
||||||
<SmartTreeBranch
|
<SmartTreeBranch
|
||||||
|
:root-nodes-length="rootNodes.data.length"
|
||||||
:node-item="rootNode"
|
:node-item="rootNode"
|
||||||
:adapter="adapter as SmartTreeAdapter<T>"
|
:adapter="adapter as SmartTreeAdapter<T>"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -85,19 +85,25 @@ const props = defineProps<{
|
|||||||
* The node item that will be used to render the tree branch content
|
* The node item that will be used to render the tree branch content
|
||||||
*/
|
*/
|
||||||
nodeItem: TreeNode<T>
|
nodeItem: TreeNode<T>
|
||||||
|
/**
|
||||||
|
* Total number of rootNode
|
||||||
|
*/
|
||||||
|
rootNodesLength?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const CHILD_SLOT_NAME = "default"
|
const CHILD_SLOT_NAME = "default"
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
|
const isOnlyRootChild = computed(() => props.rootNodesLength === 1)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks whether the children on this branch were ever rendered
|
* Marks whether the children on this branch were ever rendered
|
||||||
* See the usage inside '<template>' for more info
|
* See the usage inside '<template>' for more info
|
||||||
*/
|
*/
|
||||||
const childrenRendered = ref(false)
|
const childrenRendered = ref(isOnlyRootChild.value)
|
||||||
|
|
||||||
const showChildren = ref(false)
|
const showChildren = ref(isOnlyRootChild.value)
|
||||||
const isNodeOpen = ref(false)
|
const isNodeOpen = ref(isOnlyRootChild.value)
|
||||||
|
|
||||||
const highlightNode = ref(false)
|
const highlightNode = ref(false)
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<HoppSmartModal v-if="show" dialog :title="t('team.new')" @close="hideModal">
|
<HoppSmartModal v-if="show" dialog :title="t('team.new')" @close="hideModal">
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelTeamAdd"
|
:label="t('action.label')"
|
||||||
v-model="name"
|
placeholder=" "
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="addNewTeam"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="addNewTeam"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelTeamAdd">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
|
|||||||
@@ -2,21 +2,13 @@
|
|||||||
<HoppSmartModal v-if="show" dialog :title="t('team.edit')" @close="hideModal">
|
<HoppSmartModal v-if="show" dialog :title="t('team.edit')" @close="hideModal">
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="relative flex">
|
<HoppSmartInput
|
||||||
<input
|
v-model="name"
|
||||||
id="selectLabelTeamEdit"
|
placeholder=" "
|
||||||
v-model="name"
|
:label="t('action.label')"
|
||||||
v-focus
|
input-styles="floating-input"
|
||||||
class="input floating-input"
|
@submit="saveTeam"
|
||||||
placeholder=" "
|
/>
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="saveTeam"
|
|
||||||
/>
|
|
||||||
<label for="selectLabelTeamEdit">
|
|
||||||
{{ t("action.label") }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between flex-1 pt-4">
|
<div class="flex items-center justify-between flex-1 pt-4">
|
||||||
<label for="memberList" class="p-4">
|
<label for="memberList" class="p-4">
|
||||||
{{ t("team.members") }}
|
{{ t("team.members") }}
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ import {
|
|||||||
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
|
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
|
||||||
import xmlFormat from "xml-formatter"
|
import xmlFormat from "xml-formatter"
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
|
import { invokeAction } from "~/helpers/actions"
|
||||||
|
import { useDebounceFn } from "@vueuse/core"
|
||||||
// TODO: Migrate from legacy mode
|
// TODO: Migrate from legacy mode
|
||||||
|
|
||||||
type ExtendedEditorConfig = {
|
type ExtendedEditorConfig = {
|
||||||
@@ -218,6 +220,40 @@ export function useCodemirror(
|
|||||||
ViewPlugin.fromClass(
|
ViewPlugin.fromClass(
|
||||||
class {
|
class {
|
||||||
update(update: ViewUpdate) {
|
update(update: ViewUpdate) {
|
||||||
|
function handleTextSelection() {
|
||||||
|
const selection = view.value?.state.selection.main
|
||||||
|
if (selection) {
|
||||||
|
const from = selection.from
|
||||||
|
const to = selection.to
|
||||||
|
const text = view.value?.state.doc.sliceString(from, to)
|
||||||
|
const { top, left } = view.value?.coordsAtPos(from)
|
||||||
|
if (text) {
|
||||||
|
invokeAction("contextmenu.open", {
|
||||||
|
position: {
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
invokeAction("contextmenu.open", {
|
||||||
|
position: {
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
},
|
||||||
|
text: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce to prevent double click from selecting the word
|
||||||
|
const debounceFn = useDebounceFn(() => {
|
||||||
|
handleTextSelection()
|
||||||
|
}, 140)
|
||||||
|
|
||||||
|
el.addEventListener("mouseup", debounceFn)
|
||||||
|
el.addEventListener("keyup", debounceFn)
|
||||||
const cursorPos = update.state.selection.main.head
|
const cursorPos = update.state.selection.main.head
|
||||||
const line = update.state.doc.lineAt(cursorPos)
|
const line = update.state.doc.lineAt(cursorPos)
|
||||||
|
|
||||||
@@ -276,6 +312,7 @@ export function useCodemirror(
|
|||||||
run: indentLess,
|
run: indentLess,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
|
||||||
]
|
]
|
||||||
|
|
||||||
if (environmentTooltip) extensions.push(environmentTooltip.extension)
|
if (environmentTooltip) extensions.push(environmentTooltip.extension)
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
* For example, sending a request.
|
* For example, sending a request.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { onBeforeUnmount, onMounted } from "vue"
|
import { Ref, onBeforeUnmount, onMounted, watch } from "vue"
|
||||||
import { BehaviorSubject } from "rxjs"
|
import { BehaviorSubject } from "rxjs"
|
||||||
|
import { HoppRESTDocument } from "./rest/document"
|
||||||
|
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
|
||||||
|
|
||||||
export type HoppAction =
|
export type HoppAction =
|
||||||
|
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
|
||||||
| "request.send-cancel" // Send/Cancel a Hoppscotch Request
|
| "request.send-cancel" // Send/Cancel a Hoppscotch Request
|
||||||
| "request.reset" // Clear request data
|
| "request.reset" // Clear request data
|
||||||
| "request.copy-link" // Copy Request Link
|
| "request.copy-link" // Copy Request Link
|
||||||
@@ -22,6 +25,7 @@ export type HoppAction =
|
|||||||
| "modals.search.toggle" // Shows the search modal
|
| "modals.search.toggle" // Shows the search modal
|
||||||
| "modals.support.toggle" // Shows the support modal
|
| "modals.support.toggle" // Shows the support modal
|
||||||
| "modals.share.toggle" // Shows the share modal
|
| "modals.share.toggle" // Shows the share modal
|
||||||
|
| "modals.environment.add" // Show add environment modal via context menu
|
||||||
| "modals.my.environment.edit" // Edit current personal environment
|
| "modals.my.environment.edit" // Edit current personal environment
|
||||||
| "modals.team.environment.edit" // Edit current team environment
|
| "modals.team.environment.edit" // Edit current team environment
|
||||||
| "navigation.jump.rest" // Jump to REST page
|
| "navigation.jump.rest" // Jump to REST page
|
||||||
@@ -38,6 +42,9 @@ export type HoppAction =
|
|||||||
| "response.file.download" // Download response as file
|
| "response.file.download" // Download response as file
|
||||||
| "response.copy" // Copy response to clipboard
|
| "response.copy" // Copy response to clipboard
|
||||||
| "modals.login.toggle" // Login to Hoppscotch
|
| "modals.login.toggle" // Login to Hoppscotch
|
||||||
|
| "history.clear" // Clear REST History
|
||||||
|
| "user.login" // Login to Hoppscotch
|
||||||
|
| "user.logout" // Log out of Hoppscotch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defines the arguments, if present for a given type that is required to be passed on
|
* Defines the arguments, if present for a given type that is required to be passed on
|
||||||
@@ -50,7 +57,14 @@ export type HoppAction =
|
|||||||
* NOTE: We can't enforce type checks to make sure the key is Action, you
|
* NOTE: We can't enforce type checks to make sure the key is Action, you
|
||||||
* will know if you got something wrong if there is a type error in this file
|
* will know if you got something wrong if there is a type error in this file
|
||||||
*/
|
*/
|
||||||
type HoppActionArgs = {
|
type HoppActionArgsMap = {
|
||||||
|
"contextmenu.open": {
|
||||||
|
position: {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
text: string | null
|
||||||
|
}
|
||||||
"modals.my.environment.edit": {
|
"modals.my.environment.edit": {
|
||||||
envName: string
|
envName: string
|
||||||
variableName: string
|
variableName: string
|
||||||
@@ -59,12 +73,31 @@ type HoppActionArgs = {
|
|||||||
envName: string
|
envName: string
|
||||||
variableName: string
|
variableName: string
|
||||||
}
|
}
|
||||||
|
"rest.request.open": {
|
||||||
|
doc: HoppRESTDocument
|
||||||
|
}
|
||||||
|
"request.save-as":
|
||||||
|
| {
|
||||||
|
requestType: "rest"
|
||||||
|
request: HoppRESTRequest
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
requestType: "gql"
|
||||||
|
request: HoppGQLRequest
|
||||||
|
}
|
||||||
|
"gql.request.open": {
|
||||||
|
request: HoppGQLRequest
|
||||||
|
}
|
||||||
|
"modals.environment.add": {
|
||||||
|
envName: string
|
||||||
|
variableName: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HoppActions which require arguments for their invocation
|
* HoppActions which require arguments for their invocation
|
||||||
*/
|
*/
|
||||||
type HoppActionWithArgs = keyof HoppActionArgs
|
export type HoppActionWithArgs = keyof HoppActionArgsMap
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HoppActions which do not require arguments for their invocation
|
* HoppActions which do not require arguments for their invocation
|
||||||
@@ -74,27 +107,27 @@ export type HoppActionWithNoArgs = Exclude<HoppAction, HoppActionWithArgs>
|
|||||||
/**
|
/**
|
||||||
* Resolves the argument type for a given HoppAction
|
* Resolves the argument type for a given HoppAction
|
||||||
*/
|
*/
|
||||||
type ArgOfHoppAction<A extends HoppAction> = A extends HoppActionWithArgs
|
type ArgOfHoppAction<A extends HoppAction | HoppActionWithArgs> =
|
||||||
? HoppActionArgs[A]
|
A extends HoppActionWithArgs ? HoppActionArgsMap[A] : undefined
|
||||||
: undefined
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the action function for a given HoppAction, used by action handler function defs
|
* Resolves the action function for a given HoppAction, used by action handler function defs
|
||||||
*/
|
*/
|
||||||
type ActionFunc<A extends HoppAction> = A extends HoppActionWithArgs
|
type ActionFunc<A extends HoppAction | HoppActionWithArgs> =
|
||||||
? (arg: ArgOfHoppAction<A>) => void
|
A extends HoppActionWithArgs ? (arg: ArgOfHoppAction<A>) => void : () => void
|
||||||
: () => void
|
|
||||||
|
|
||||||
type BoundActionList = {
|
type BoundActionList = {
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
[A in HoppAction]?: Array<ActionFunc<A>>
|
[A in HoppAction | HoppActionWithArgs]?: Array<ActionFunc<A>>
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundActions: BoundActionList = {}
|
const boundActions: BoundActionList = {}
|
||||||
|
|
||||||
export const activeActions$ = new BehaviorSubject<HoppAction[]>([])
|
export const activeActions$ = new BehaviorSubject<
|
||||||
|
(HoppAction | HoppActionWithArgs)[]
|
||||||
|
>([])
|
||||||
|
|
||||||
export function bindAction<A extends HoppAction>(
|
export function bindAction<A extends HoppAction | HoppActionWithArgs>(
|
||||||
action: A,
|
action: A,
|
||||||
handler: ActionFunc<A>
|
handler: ActionFunc<A>
|
||||||
) {
|
) {
|
||||||
@@ -110,7 +143,7 @@ export function bindAction<A extends HoppAction>(
|
|||||||
|
|
||||||
type InvokeActionFunc = {
|
type InvokeActionFunc = {
|
||||||
(action: HoppActionWithNoArgs, args?: undefined): void
|
(action: HoppActionWithNoArgs, args?: undefined): void
|
||||||
<A extends HoppActionWithArgs>(action: A, args: ArgOfHoppAction<A>): void
|
<A extends HoppActionWithArgs>(action: A, args: HoppActionArgsMap[A]): void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,14 +152,16 @@ type InvokeActionFunc = {
|
|||||||
* @param action The action to fire
|
* @param action The action to fire
|
||||||
* @param args The argument passed to the action handler. Optional if action has no args required
|
* @param args The argument passed to the action handler. Optional if action has no args required
|
||||||
*/
|
*/
|
||||||
export const invokeAction: InvokeActionFunc = <A extends HoppAction>(
|
export const invokeAction: InvokeActionFunc = <
|
||||||
|
A extends HoppAction | HoppActionWithArgs
|
||||||
|
>(
|
||||||
action: A,
|
action: A,
|
||||||
args: ArgOfHoppAction<A>
|
args: ArgOfHoppAction<A>
|
||||||
) => {
|
) => {
|
||||||
boundActions[action]?.forEach((handler) => handler(args!))
|
boundActions[action]?.forEach((handler) => handler(args! as any))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function unbindAction<A extends HoppAction>(
|
export function unbindAction<A extends HoppAction | HoppActionWithArgs>(
|
||||||
action: A,
|
action: A,
|
||||||
handler: ActionFunc<A>
|
handler: ActionFunc<A>
|
||||||
) {
|
) {
|
||||||
@@ -142,15 +177,57 @@ export function unbindAction<A extends HoppAction>(
|
|||||||
activeActions$.next(Object.keys(boundActions) as HoppAction[])
|
activeActions$.next(Object.keys(boundActions) as HoppAction[])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defineActionHandler<A extends HoppAction>(
|
/**
|
||||||
|
* A composable function that defines a component can handle a given
|
||||||
|
* HoppAction. The handler will be bound when the component is mounted
|
||||||
|
* and unbound when the component is unmounted.
|
||||||
|
* @param action The action to be bound
|
||||||
|
* @param handler The function to be called when the action is invoked
|
||||||
|
* @param isActive A ref that indicates whether the action is active
|
||||||
|
*/
|
||||||
|
export function defineActionHandler<A extends HoppAction | HoppActionWithArgs>(
|
||||||
action: A,
|
action: A,
|
||||||
handler: ActionFunc<A>
|
handler: ActionFunc<A>,
|
||||||
|
isActive: Ref<boolean> | undefined = undefined
|
||||||
) {
|
) {
|
||||||
|
let mounted = false
|
||||||
|
let bound = false
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
bindAction(action, handler)
|
mounted = true
|
||||||
|
|
||||||
|
// Only bind if isActive is undefined or true
|
||||||
|
if (isActive === undefined || isActive.value === true) {
|
||||||
|
bound = true
|
||||||
|
bindAction(action, handler)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
mounted = false
|
||||||
|
bound = false
|
||||||
|
|
||||||
unbindAction(action, handler)
|
unbindAction(action, handler)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
watch(
|
||||||
|
isActive,
|
||||||
|
(active) => {
|
||||||
|
if (mounted) {
|
||||||
|
if (active) {
|
||||||
|
if (!bound) {
|
||||||
|
bound = true
|
||||||
|
bindAction(action, handler)
|
||||||
|
}
|
||||||
|
} else if (bound) {
|
||||||
|
bound = false
|
||||||
|
|
||||||
|
unbindAction(action, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ let keybindingsEnabled = true
|
|||||||
* Alt is also regarded as macOS OPTION (⌥) key
|
* Alt is also regarded as macOS OPTION (⌥) key
|
||||||
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
|
* Ctrl is also regarded as macOS COMMAND (⌘) key (NOTE: this differs from HTML Keyboard spec where COMMAND is Meta key!)
|
||||||
*/
|
*/
|
||||||
type ModifierKeys = "ctrl" | "alt" | "ctrl-shift" | "alt-shift"
|
type ModifierKeys =
|
||||||
|
| "ctrl"
|
||||||
|
| "alt"
|
||||||
|
| "ctrl-shift"
|
||||||
|
| "alt-shift"
|
||||||
|
| "ctrl-alt"
|
||||||
|
| "ctrl-alt-shift"
|
||||||
|
|
||||||
/* eslint-disable prettier/prettier */
|
/* eslint-disable prettier/prettier */
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
@@ -48,8 +54,8 @@ export const bindings: {
|
|||||||
"alt-p": "request.method.post",
|
"alt-p": "request.method.post",
|
||||||
"alt-u": "request.method.put",
|
"alt-u": "request.method.put",
|
||||||
"alt-x": "request.method.delete",
|
"alt-x": "request.method.delete",
|
||||||
"ctrl-k": "flyouts.keybinds.toggle",
|
"ctrl-k": "modals.search.toggle",
|
||||||
"/": "modals.search.toggle",
|
"ctrl-/": "flyouts.keybinds.toggle",
|
||||||
"?": "modals.support.toggle",
|
"?": "modals.support.toggle",
|
||||||
"ctrl-m": "modals.share.toggle",
|
"ctrl-m": "modals.share.toggle",
|
||||||
"alt-r": "navigation.jump.rest",
|
"alt-r": "navigation.jump.rest",
|
||||||
@@ -143,18 +149,19 @@ function getPressedKey(ev: KeyboardEvent): Key | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null {
|
function getActiveModifier(ev: KeyboardEvent): ModifierKeys | null {
|
||||||
const isShiftKey = ev.shiftKey
|
const modifierKeys = {
|
||||||
|
ctrl: isAppleDevice() ? ev.metaKey : ev.ctrlKey,
|
||||||
|
alt: ev.altKey,
|
||||||
|
shift: ev.shiftKey,
|
||||||
|
}
|
||||||
|
|
||||||
// We only allow one modifier key to be pressed (for now)
|
// active modifier: ctrl | alt | ctrl-alt | ctrl-shift | ctrl-alt-shift | alt-shift
|
||||||
// Control key (+ Command) gets priority and if Alt is also pressed, it is ignored
|
// modiferKeys object's keys are sorted to match the above order
|
||||||
if (isAppleDevice() && ev.metaKey) return isShiftKey ? "ctrl-shift" : "ctrl"
|
const activeModifier = Object.keys(modifierKeys)
|
||||||
else if (!isAppleDevice() && ev.ctrlKey)
|
.filter((key) => modifierKeys[key as keyof typeof modifierKeys])
|
||||||
return isShiftKey ? "ctrl-shift" : "ctrl"
|
.join("-")
|
||||||
|
|
||||||
// Test for Alt key
|
return activeModifier === "" ? null : (activeModifier as ModifierKeys)
|
||||||
if (ev.altKey) return isShiftKey ? "alt-shift" : "alt"
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import { ref } from "vue"
|
|
||||||
|
|
||||||
const NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Enter"]
|
|
||||||
|
|
||||||
export function useArrowKeysNavigation(searchItems: any, options: any = {}) {
|
|
||||||
function handleArrowKeysNavigation(
|
|
||||||
event: any,
|
|
||||||
itemIndex: any,
|
|
||||||
preventPropagation: boolean
|
|
||||||
) {
|
|
||||||
if (!NAVIGATION_KEYS.includes(event.key)) return
|
|
||||||
|
|
||||||
if (preventPropagation) event.stopImmediatePropagation()
|
|
||||||
|
|
||||||
const itemsLength = searchItems.value.length
|
|
||||||
const lastItemIndex = itemsLength - 1
|
|
||||||
const itemIndexValue = itemIndex.value
|
|
||||||
const action = searchItems.value[itemIndexValue]?.action
|
|
||||||
|
|
||||||
if (action && event.key === "Enter" && options.onEnter) {
|
|
||||||
options.onEnter(action)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemsLength && event.key === "ArrowDown") {
|
|
||||||
itemIndex.value = itemIndexValue < lastItemIndex ? itemIndexValue + 1 : 0
|
|
||||||
} else if (itemIndexValue === 0) itemIndex.value = lastItemIndex
|
|
||||||
else if (itemsLength && event.key === "ArrowUp")
|
|
||||||
itemIndex.value = itemIndexValue - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const preventPropagation = options && options.stopPropagation
|
|
||||||
|
|
||||||
const selectedEntry = ref(0)
|
|
||||||
|
|
||||||
const onKeyUp = (event: any) => {
|
|
||||||
handleArrowKeysNavigation(event, selectedEntry, preventPropagation)
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindArrowKeysListeners() {
|
|
||||||
window.addEventListener("keydown", onKeyUp, { capture: preventPropagation })
|
|
||||||
}
|
|
||||||
|
|
||||||
function unbindArrowKeysListeners() {
|
|
||||||
window.removeEventListener("keydown", onKeyUp, {
|
|
||||||
capture: preventPropagation,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
bindArrowKeysListeners,
|
|
||||||
unbindArrowKeysListeners,
|
|
||||||
selectedEntry,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -181,6 +181,33 @@ export function closeTab(tabID: string) {
|
|||||||
tabMap.delete(tabID)
|
tabMap.delete(tabID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function closeOtherTabs(tabID: string) {
|
||||||
|
if (!tabMap.has(tabID)) {
|
||||||
|
console.warn(
|
||||||
|
`The tab to close other tabs does not exist (tab id: ${tabID})`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tabOrdering.value = [tabID]
|
||||||
|
|
||||||
|
tabMap.forEach((_, id) => {
|
||||||
|
if (id !== tabID) tabMap.delete(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
currentTabID.value = tabID
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDirtyTabsCount() {
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
for (const tab of tabMap.values()) {
|
||||||
|
if (tab.document.isDirty) count++
|
||||||
|
}
|
||||||
|
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
export function getTabRefWithSaveContext(ctx: HoppRESTSaveContext) {
|
export function getTabRefWithSaveContext(ctx: HoppRESTSaveContext) {
|
||||||
for (const tab of tabMap.values()) {
|
for (const tab of tabMap.values()) {
|
||||||
// For `team-collection` request id can be considered unique
|
// For `team-collection` request id can be considered unique
|
||||||
|
|||||||
@@ -1,315 +1,146 @@
|
|||||||
import IconLifeBuoy from "~icons/lucide/life-buoy"
|
|
||||||
import IconZap from "~icons/lucide/zap"
|
|
||||||
import IconArrowRight from "~icons/lucide/arrow-right"
|
|
||||||
import IconGift from "~icons/lucide/gift"
|
|
||||||
import IconMonitor from "~icons/lucide/monitor"
|
|
||||||
import IconSun from "~icons/lucide/sun"
|
|
||||||
import IconCloud from "~icons/lucide/cloud"
|
|
||||||
import IconMoon from "~icons/lucide/moon"
|
|
||||||
import { getPlatformAlternateKey, getPlatformSpecialKey } from "./platformutils"
|
import { getPlatformAlternateKey, getPlatformSpecialKey } from "./platformutils"
|
||||||
|
|
||||||
export default [
|
export type ShortcutDef = {
|
||||||
{
|
label: string
|
||||||
section: "shortcut.general.title",
|
keys: string[]
|
||||||
shortcuts: [
|
section: string
|
||||||
{
|
}
|
||||||
keys: ["?"],
|
|
||||||
label: "shortcut.general.help_menu",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: ["/"],
|
|
||||||
label: "shortcut.general.command_menu",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "K"],
|
|
||||||
label: "shortcut.general.show_all",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: ["ESC"],
|
|
||||||
label: "shortcut.general.close_current_menu",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
section: "shortcut.request.title",
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "↩"],
|
|
||||||
label: "shortcut.request.send_request",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "S"],
|
|
||||||
label: "shortcut.request.save_to_collections",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "U"],
|
|
||||||
label: "shortcut.request.copy_request_link",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "I"],
|
|
||||||
label: "shortcut.request.reset_request",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "↑"],
|
|
||||||
label: "shortcut.request.next_method",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "↓"],
|
|
||||||
label: "shortcut.request.previous_method",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "G"],
|
|
||||||
label: "shortcut.request.get_method",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "H"],
|
|
||||||
label: "shortcut.request.head_method",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "P"],
|
|
||||||
label: "shortcut.request.post_method",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "U"],
|
|
||||||
label: "shortcut.request.put_method",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "X"],
|
|
||||||
label: "shortcut.request.delete_method",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
section: "shortcut.response.title",
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "J"],
|
|
||||||
label: "shortcut.response.download",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "."],
|
|
||||||
label: "shortcut.response.copy",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
section: "shortcut.navigation.title",
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "←"],
|
|
||||||
label: "shortcut.navigation.back",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "→"],
|
|
||||||
label: "shortcut.navigation.forward",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "R"],
|
|
||||||
label: "shortcut.navigation.rest",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "Q"],
|
|
||||||
label: "shortcut.navigation.graphql",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "W"],
|
|
||||||
label: "shortcut.navigation.realtime",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "S"],
|
|
||||||
label: "shortcut.navigation.settings",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "M"],
|
|
||||||
label: "shortcut.navigation.profile",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
section: "shortcut.miscellaneous.title",
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "M"],
|
|
||||||
label: "shortcut.miscellaneous.invite",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const spotlight = [
|
export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
|
||||||
{
|
// General
|
||||||
section: "app.spotlight",
|
return [
|
||||||
shortcuts: [
|
{
|
||||||
{
|
label: t("shortcut.general.help_menu"),
|
||||||
keys: ["?"],
|
keys: ["?"],
|
||||||
label: "shortcut.general.help_menu",
|
section: t("shortcut.general.title"),
|
||||||
action: "modals.support.toggle",
|
},
|
||||||
icon: IconLifeBuoy,
|
{
|
||||||
},
|
label: t("shortcut.general.command_menu"),
|
||||||
{
|
keys: [getPlatformSpecialKey(), "K"],
|
||||||
keys: [getPlatformSpecialKey(), "K"],
|
section: t("shortcut.general.title"),
|
||||||
label: "shortcut.general.show_all",
|
},
|
||||||
action: "flyouts.keybinds.toggle",
|
{
|
||||||
icon: IconZap,
|
label: t("shortcut.general.show_all"),
|
||||||
},
|
keys: [getPlatformSpecialKey(), "/"],
|
||||||
],
|
section: t("shortcut.general.title"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
section: "shortcut.navigation.title",
|
label: t("shortcut.general.close_current_menu"),
|
||||||
shortcuts: [
|
keys: ["ESC"],
|
||||||
{
|
section: t("shortcut.general.title"),
|
||||||
keys: [getPlatformAlternateKey(), "R"],
|
},
|
||||||
label: "shortcut.navigation.rest",
|
|
||||||
action: "navigation.jump.rest",
|
|
||||||
icon: IconArrowRight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "Q"],
|
|
||||||
label: "shortcut.navigation.graphql",
|
|
||||||
action: "navigation.jump.graphql",
|
|
||||||
icon: IconArrowRight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "W"],
|
|
||||||
label: "shortcut.navigation.realtime",
|
|
||||||
action: "navigation.jump.realtime",
|
|
||||||
icon: IconArrowRight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "S"],
|
|
||||||
label: "shortcut.navigation.settings",
|
|
||||||
action: "navigation.jump.settings",
|
|
||||||
icon: IconArrowRight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "M"],
|
|
||||||
label: "shortcut.navigation.profile",
|
|
||||||
action: "navigation.jump.profile",
|
|
||||||
icon: IconArrowRight,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
section: "shortcut.miscellaneous.title",
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
keys: [getPlatformSpecialKey(), "M"],
|
|
||||||
label: "shortcut.miscellaneous.invite",
|
|
||||||
action: "modals.share.toggle",
|
|
||||||
icon: IconGift,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const fuse = [
|
// Request
|
||||||
{
|
{
|
||||||
keys: ["?"],
|
label: t("shortcut.request.send_request"),
|
||||||
label: "shortcut.general.help_menu",
|
keys: [getPlatformSpecialKey(), "↩"],
|
||||||
action: "modals.support.toggle",
|
section: t("shortcut.request.title"),
|
||||||
icon: IconLifeBuoy,
|
},
|
||||||
tags: [
|
{
|
||||||
"help",
|
keys: [getPlatformSpecialKey(), "S"],
|
||||||
"support",
|
label: t("shortcut.request.save_to_collections"),
|
||||||
"menu",
|
section: t("shortcut.request.title"),
|
||||||
"discord",
|
},
|
||||||
"twitter",
|
{
|
||||||
"documentation",
|
keys: [getPlatformSpecialKey(), "U"],
|
||||||
"troubleshooting",
|
label: t("shortcut.request.copy_request_link"),
|
||||||
"chat",
|
section: t("shortcut.request.title"),
|
||||||
"community",
|
},
|
||||||
"feedback",
|
{
|
||||||
"report",
|
keys: [getPlatformSpecialKey(), "I"],
|
||||||
"bug",
|
label: t("shortcut.request.reset_request"),
|
||||||
"issue",
|
section: t("shortcut.request.title"),
|
||||||
"ticket",
|
},
|
||||||
],
|
{
|
||||||
},
|
keys: [getPlatformAlternateKey(), "↑"],
|
||||||
{
|
label: t("shortcut.request.next_method"),
|
||||||
keys: [getPlatformSpecialKey(), "K"],
|
section: t("shortcut.request.title"),
|
||||||
label: "shortcut.general.show_all",
|
},
|
||||||
action: "flyouts.keybinds.toggle",
|
{
|
||||||
icon: IconZap,
|
keys: [getPlatformAlternateKey(), "↓"],
|
||||||
tags: ["keyboard", "shortcuts"],
|
label: t("shortcut.request.previous_method"),
|
||||||
},
|
section: t("shortcut.request.title"),
|
||||||
{
|
},
|
||||||
keys: [getPlatformAlternateKey(), "R"],
|
{
|
||||||
label: "shortcut.navigation.rest",
|
keys: [getPlatformAlternateKey(), "G"],
|
||||||
action: "navigation.jump.rest",
|
label: t("shortcut.request.get_method"),
|
||||||
icon: IconArrowRight,
|
section: t("shortcut.request.title"),
|
||||||
tags: ["rest", "jump", "page", "navigation", "go"],
|
},
|
||||||
},
|
{
|
||||||
{
|
keys: [getPlatformAlternateKey(), "H"],
|
||||||
keys: [getPlatformAlternateKey(), "Q"],
|
label: t("shortcut.request.head_method"),
|
||||||
label: "shortcut.navigation.graphql",
|
section: t("shortcut.request.title"),
|
||||||
action: "navigation.jump.graphql",
|
},
|
||||||
icon: IconArrowRight,
|
{
|
||||||
tags: ["graphql", "jump", "page", "navigation", "go"],
|
keys: [getPlatformAlternateKey(), "P"],
|
||||||
},
|
label: t("shortcut.request.post_method"),
|
||||||
{
|
section: t("shortcut.request.title"),
|
||||||
keys: [getPlatformAlternateKey(), "W"],
|
},
|
||||||
label: "shortcut.navigation.realtime",
|
{
|
||||||
action: "navigation.jump.realtime",
|
keys: [getPlatformAlternateKey(), "U"],
|
||||||
icon: IconArrowRight,
|
label: t("shortcut.request.put_method"),
|
||||||
tags: [
|
section: t("shortcut.request.title"),
|
||||||
"realtime",
|
},
|
||||||
"jump",
|
{
|
||||||
"page",
|
keys: [getPlatformAlternateKey(), "X"],
|
||||||
"navigation",
|
label: t("shortcut.request.delete_method"),
|
||||||
"websocket",
|
section: t("shortcut.request.title"),
|
||||||
"socket",
|
},
|
||||||
"mqtt",
|
|
||||||
"sse",
|
// Response
|
||||||
"go",
|
{
|
||||||
],
|
keys: [getPlatformSpecialKey(), "J"],
|
||||||
},
|
label: t("shortcut.response.download"),
|
||||||
{
|
section: t("shortcut.response.title"),
|
||||||
keys: [getPlatformAlternateKey(), "S"],
|
},
|
||||||
label: "shortcut.navigation.settings",
|
{
|
||||||
action: "navigation.jump.settings",
|
keys: [getPlatformSpecialKey(), "."],
|
||||||
icon: IconArrowRight,
|
label: t("shortcut.response.copy"),
|
||||||
tags: ["settings", "jump", "page", "navigation", "account", "theme", "go"],
|
section: t("shortcut.response.title"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "M"],
|
// Navigation
|
||||||
label: "shortcut.navigation.profile",
|
{
|
||||||
action: "navigation.jump.profile",
|
keys: [getPlatformSpecialKey(), "←"],
|
||||||
icon: IconArrowRight,
|
label: t("shortcut.navigation.back"),
|
||||||
tags: ["profile", "jump", "page", "navigation", "account", "theme", "go"],
|
section: t("shortcut.navigation.title"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [getPlatformSpecialKey(), "M"],
|
keys: [getPlatformSpecialKey(), "→"],
|
||||||
label: "shortcut.miscellaneous.invite",
|
label: t("shortcut.navigation.forward"),
|
||||||
action: "modals.share.toggle",
|
section: t("shortcut.navigation.title"),
|
||||||
icon: IconGift,
|
},
|
||||||
tags: ["invite", "share", "app", "friends", "people", "social"],
|
{
|
||||||
},
|
keys: [getPlatformAlternateKey(), "R"],
|
||||||
{
|
label: t("shortcut.navigation.rest"),
|
||||||
keys: [getPlatformAlternateKey(), "0"],
|
section: t("shortcut.navigation.title"),
|
||||||
label: "shortcut.theme.system",
|
},
|
||||||
action: "settings.theme.system",
|
{
|
||||||
icon: IconMonitor,
|
keys: [getPlatformAlternateKey(), "Q"],
|
||||||
tags: ["theme", "system"],
|
label: t("shortcut.navigation.graphql"),
|
||||||
},
|
section: t("shortcut.navigation.title"),
|
||||||
{
|
},
|
||||||
keys: [getPlatformAlternateKey(), "1"],
|
{
|
||||||
label: "shortcut.theme.light",
|
keys: [getPlatformAlternateKey(), "W"],
|
||||||
action: "settings.theme.light",
|
label: t("shortcut.navigation.realtime"),
|
||||||
icon: IconSun,
|
section: t("shortcut.navigation.title"),
|
||||||
tags: ["theme", "light"],
|
},
|
||||||
},
|
{
|
||||||
{
|
keys: [getPlatformAlternateKey(), "S"],
|
||||||
keys: [getPlatformAlternateKey(), "2"],
|
label: t("shortcut.navigation.settings"),
|
||||||
label: "shortcut.theme.dark",
|
section: t("shortcut.navigation.title"),
|
||||||
action: "settings.theme.dark",
|
},
|
||||||
icon: IconCloud,
|
{
|
||||||
tags: ["theme", "dark"],
|
keys: [getPlatformAlternateKey(), "M"],
|
||||||
},
|
label: t("shortcut.navigation.profile"),
|
||||||
{
|
section: t("shortcut.navigation.title"),
|
||||||
keys: [getPlatformAlternateKey(), "3"],
|
},
|
||||||
label: "shortcut.theme.black",
|
|
||||||
action: "settings.theme.black",
|
// Miscellaneous
|
||||||
icon: IconMoon,
|
{
|
||||||
tags: ["theme", "black"],
|
keys: [getPlatformSpecialKey(), "M"],
|
||||||
},
|
label: t("shortcut.miscellaneous.invite"),
|
||||||
]
|
section: t("shortcut.miscellaneous.title"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import "virtual:windi.css"
|
|||||||
import "../assets/scss/themes.scss"
|
import "../assets/scss/themes.scss"
|
||||||
import "../assets/scss/styles.scss"
|
import "../assets/scss/styles.scss"
|
||||||
import "nprogress/nprogress.css"
|
import "nprogress/nprogress.css"
|
||||||
|
import "@fontsource-variable/inter"
|
||||||
|
import "@fontsource-variable/material-symbols-rounded"
|
||||||
|
import "@fontsource-variable/roboto-mono"
|
||||||
|
|
||||||
import App from "./App.vue"
|
import App from "./App.vue"
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
</Pane>
|
</Pane>
|
||||||
</Splitpanes>
|
</Splitpanes>
|
||||||
<AppActionHandler />
|
<AppActionHandler />
|
||||||
<AppPowerSearch :show="showSearch" @hide-modal="showSearch = false" />
|
<AppSpotlight :show="showSearch" @hide-modal="showSearch = false" />
|
||||||
<AppSupport
|
<AppSupport
|
||||||
v-if="mdAndLarger"
|
v-if="mdAndLarger"
|
||||||
:show="showSupport"
|
:show="showSupport"
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
import { HoppModule } from "."
|
import { HoppModule } from "."
|
||||||
import { Container } from "dioc"
|
import { Container, Service } from "dioc"
|
||||||
import { diocPlugin } from "dioc/vue"
|
import { diocPlugin } from "dioc/vue"
|
||||||
|
import { DebugService } from "~/services/debug.service"
|
||||||
|
|
||||||
|
const serviceContainer = new Container()
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
serviceContainer.bind(DebugService)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a service from the app service container. You can use this function
|
||||||
|
* to get a service if you have no access to the container or if you are not
|
||||||
|
* in a component (if you are, you can use `useService`) or if you are not in a
|
||||||
|
* service.
|
||||||
|
* @param service The class of the service to get
|
||||||
|
* @returns The service instance
|
||||||
|
*
|
||||||
|
* @deprecated This is a temporary escape hatch for legacy code to access
|
||||||
|
* services. Please use `useService` if within components or try to convert your
|
||||||
|
* legacy subsystem into a service if possible.
|
||||||
|
*/
|
||||||
|
export function getService<T extends typeof Service<any> & { ID: string }>(
|
||||||
|
service: T
|
||||||
|
): InstanceType<T> {
|
||||||
|
return serviceContainer.bind(service)
|
||||||
|
}
|
||||||
|
|
||||||
export default <HoppModule>{
|
export default <HoppModule>{
|
||||||
onVueAppInit(app) {
|
onVueAppInit(app) {
|
||||||
// TODO: look into this
|
// TODO: look into this
|
||||||
// @ts-expect-error Something weird with Vue versions
|
// @ts-expect-error Something weird with Vue versions
|
||||||
app.use(diocPlugin, {
|
app.use(diocPlugin, {
|
||||||
container: new Container(),
|
container: serviceContainer,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ export const changeAppLanguage = async (locale: string) => {
|
|||||||
setLocalConfig("locale", locale)
|
setLocalConfig("locale", locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the i18n instance
|
||||||
|
*/
|
||||||
|
export function getI18n() {
|
||||||
|
// @ts-expect-error Something weird with the i18n errors
|
||||||
|
return i18nInstance!.global.t
|
||||||
|
}
|
||||||
|
|
||||||
export default <HoppModule>{
|
export default <HoppModule>{
|
||||||
onVueAppInit(app) {
|
onVueAppInit(app) {
|
||||||
const i18n = createI18n(<I18nOptions>{
|
const i18n = createI18n(<I18nOptions>{
|
||||||
|
|||||||
@@ -17,7 +17,10 @@
|
|||||||
import { usePageHead } from "@composables/head"
|
import { usePageHead } from "@composables/head"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { GQLConnection } from "@helpers/GQLConnection"
|
import { GQLConnection } from "@helpers/GQLConnection"
|
||||||
|
import { cloneDeep } from "lodash-es"
|
||||||
import { computed, onBeforeUnmount } from "vue"
|
import { computed, onBeforeUnmount } from "vue"
|
||||||
|
import { defineActionHandler } from "~/helpers/actions"
|
||||||
|
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
@@ -32,4 +35,14 @@ onBeforeUnmount(() => {
|
|||||||
gqlConn.disconnect()
|
gqlConn.disconnect()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineActionHandler("gql.request.open", ({ request }) => {
|
||||||
|
const session = getGQLSession()
|
||||||
|
|
||||||
|
setGQLSession({
|
||||||
|
request: cloneDeep(request),
|
||||||
|
schema: session.schema,
|
||||||
|
response: session.response,
|
||||||
|
})
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,22 +19,14 @@
|
|||||||
:close-visibility="'hover'"
|
:close-visibility="'hover'"
|
||||||
>
|
>
|
||||||
<template #tabhead>
|
<template #tabhead>
|
||||||
<div
|
<HttpTabHead
|
||||||
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
|
:tab="tab"
|
||||||
:title="tab.document.request.name"
|
:is-removable="tabs.length > 1"
|
||||||
class="truncate px-2"
|
@open-rename-modal="openReqRenameModal(tab.id)"
|
||||||
@dblclick="openReqRenameModal()"
|
@close-tab="removeTab(tab.id)"
|
||||||
>
|
@close-other-tabs="closeOtherTabsAction(tab.id)"
|
||||||
<span
|
@duplicate-tab="duplicateTab(tab.id)"
|
||||||
class="font-semibold text-tiny"
|
/>
|
||||||
:class="getMethodLabelColorClassOf(tab.document.request)"
|
|
||||||
>
|
|
||||||
{{ tab.document.request.method }}
|
|
||||||
</span>
|
|
||||||
<span class="leading-8 px-2">
|
|
||||||
{{ tab.document.request.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<span
|
<span
|
||||||
@@ -78,12 +70,26 @@
|
|||||||
@hide-modal="onCloseConfirmSaveTab"
|
@hide-modal="onCloseConfirmSaveTab"
|
||||||
@resolve="onResolveConfirmSaveTab"
|
@resolve="onResolveConfirmSaveTab"
|
||||||
/>
|
/>
|
||||||
|
<HoppSmartConfirmModal
|
||||||
|
:show="confirmingCloseAllTabs"
|
||||||
|
:confirm="t('modal.close_unsaved_tab')"
|
||||||
|
:title="t('confirm.close_unsaved_tabs', { count: unsavedTabsCount })"
|
||||||
|
@hide-modal="confirmingCloseAllTabs = false"
|
||||||
|
@resolve="onResolveConfirmCloseAllTabs"
|
||||||
|
/>
|
||||||
<CollectionsSaveRequest
|
<CollectionsSaveRequest
|
||||||
v-if="savingRequest"
|
v-if="savingRequest"
|
||||||
mode="rest"
|
mode="rest"
|
||||||
:show="savingRequest"
|
:show="savingRequest"
|
||||||
@hide-modal="onSaveModalClose"
|
@hide-modal="onSaveModalClose"
|
||||||
/>
|
/>
|
||||||
|
<AppContextMenu
|
||||||
|
v-if="contextMenu.show"
|
||||||
|
:show="contextMenu.show"
|
||||||
|
:position="contextMenu.position"
|
||||||
|
:text="contextMenu.text"
|
||||||
|
@hide-modal="contextMenu.show = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -92,10 +98,10 @@ import { ref, onMounted, onBeforeUnmount, onBeforeMount } from "vue"
|
|||||||
import { safelyExtractRESTRequest } from "@hoppscotch/data"
|
import { safelyExtractRESTRequest } from "@hoppscotch/data"
|
||||||
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
|
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
|
||||||
import { useRoute } from "vue-router"
|
import { useRoute } from "vue-router"
|
||||||
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import {
|
import {
|
||||||
closeTab,
|
closeTab,
|
||||||
|
closeOtherTabs,
|
||||||
createNewTab,
|
createNewTab,
|
||||||
currentActiveTab,
|
currentActiveTab,
|
||||||
currentTabID,
|
currentTabID,
|
||||||
@@ -106,9 +112,10 @@ import {
|
|||||||
persistableTabState,
|
persistableTabState,
|
||||||
updateTab,
|
updateTab,
|
||||||
updateTabOrdering,
|
updateTabOrdering,
|
||||||
|
getDirtyTabsCount,
|
||||||
} from "~/helpers/rest/tab"
|
} from "~/helpers/rest/tab"
|
||||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||||
import { invokeAction } from "~/helpers/actions"
|
import { defineActionHandler, invokeAction } from "~/helpers/actions"
|
||||||
import { onLoggedIn } from "~/composables/auth"
|
import { onLoggedIn } from "~/composables/auth"
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
import {
|
import {
|
||||||
@@ -132,12 +139,33 @@ import {
|
|||||||
|
|
||||||
const savingRequest = ref(false)
|
const savingRequest = ref(false)
|
||||||
const confirmingCloseForTabID = ref<string | null>(null)
|
const confirmingCloseForTabID = ref<string | null>(null)
|
||||||
|
const confirmingCloseAllTabs = ref(false)
|
||||||
const showRenamingReqNameModal = ref(false)
|
const showRenamingReqNameModal = ref(false)
|
||||||
const reqName = ref<string>("")
|
const reqName = ref<string>("")
|
||||||
|
const unsavedTabsCount = ref(0)
|
||||||
|
const exceptedTabID = ref<string | null>(null)
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
type PopupDetails = {
|
||||||
|
show: boolean
|
||||||
|
position: {
|
||||||
|
top: number
|
||||||
|
left: number
|
||||||
|
}
|
||||||
|
text: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextMenu = ref<PopupDetails>({
|
||||||
|
show: false,
|
||||||
|
position: {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
text: null,
|
||||||
|
})
|
||||||
|
|
||||||
const tabs = getActiveTabs()
|
const tabs = getActiveTabs()
|
||||||
|
|
||||||
const confirmSync = useReadonlyStream(currentSyncingStatus$, {
|
const confirmSync = useReadonlyStream(currentSyncingStatus$, {
|
||||||
@@ -190,9 +218,42 @@ const removeTab = (tabID: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const openReqRenameModal = () => {
|
const closeOtherTabsAction = (tabID: string) => {
|
||||||
|
const dirtyTabCount = getDirtyTabsCount()
|
||||||
|
// If there are dirty tabs, show the confirm modal
|
||||||
|
if (dirtyTabCount > 0) {
|
||||||
|
confirmingCloseAllTabs.value = true
|
||||||
|
unsavedTabsCount.value = dirtyTabCount
|
||||||
|
exceptedTabID.value = tabID
|
||||||
|
} else {
|
||||||
|
closeOtherTabs(tabID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateTab = (tabID: string) => {
|
||||||
|
const tab = getTabRef(tabID)
|
||||||
|
if (tab.value) {
|
||||||
|
const newTab = createNewTab({
|
||||||
|
request: tab.value.document.request,
|
||||||
|
isDirty: true,
|
||||||
|
})
|
||||||
|
currentTabID.value = newTab.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onResolveConfirmCloseAllTabs = () => {
|
||||||
|
if (exceptedTabID.value) closeOtherTabs(exceptedTabID.value)
|
||||||
|
confirmingCloseAllTabs.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const openReqRenameModal = (tabID?: string) => {
|
||||||
|
if (tabID) {
|
||||||
|
const tab = getTabRef(tabID)
|
||||||
|
reqName.value = tab.value.document.request.name
|
||||||
|
} else {
|
||||||
|
reqName.value = currentActiveTab.value.document.request.name
|
||||||
|
}
|
||||||
showRenamingReqNameModal.value = true
|
showRenamingReqNameModal.value = true
|
||||||
reqName.value = currentActiveTab.value.document.request.name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renameReqName = () => {
|
const renameReqName = () => {
|
||||||
@@ -365,7 +426,27 @@ function oAuthURL() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineActionHandler("contextmenu.open", ({ position, text }) => {
|
||||||
|
if (text) {
|
||||||
|
contextMenu.value = {
|
||||||
|
show: true,
|
||||||
|
position,
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
contextMenu.value = {
|
||||||
|
show: false,
|
||||||
|
position,
|
||||||
|
text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
setupTabStateSync()
|
setupTabStateSync()
|
||||||
bindRequestToURLParams()
|
bindRequestToURLParams()
|
||||||
oAuthURL()
|
oAuthURL()
|
||||||
|
|
||||||
|
defineActionHandler("rest.request.open", ({ doc }) => {
|
||||||
|
createNewTab(doc)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -100,55 +100,45 @@
|
|||||||
<label for="displayName">
|
<label for="displayName">
|
||||||
{{ t("settings.profile_name") }}
|
{{ t("settings.profile_name") }}
|
||||||
</label>
|
</label>
|
||||||
<form
|
<HoppSmartInput
|
||||||
class="flex mt-2 md:max-w-sm"
|
v-model="displayName"
|
||||||
@submit.prevent="updateDisplayName"
|
styles="mt-2 md:max-w-sm"
|
||||||
|
:placeholder="`${t('settings.profile_name')}`"
|
||||||
>
|
>
|
||||||
<input
|
<template #button>
|
||||||
id="displayName"
|
<HoppButtonSecondary
|
||||||
v-model="displayName"
|
filled
|
||||||
class="input"
|
outline
|
||||||
:placeholder="`${t('settings.profile_name')}`"
|
:label="t('action.save')"
|
||||||
type="text"
|
class="ml-2 min-w-16"
|
||||||
autocomplete="off"
|
type="submit"
|
||||||
required
|
:loading="updatingDisplayName"
|
||||||
/>
|
@click="updateDisplayName"
|
||||||
<HoppButtonSecondary
|
/>
|
||||||
filled
|
</template>
|
||||||
outline
|
</HoppSmartInput>
|
||||||
:label="t('action.save')"
|
|
||||||
class="ml-2 min-w-16"
|
|
||||||
type="submit"
|
|
||||||
:loading="updatingDisplayName"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="py-4">
|
<div class="py-4">
|
||||||
<label for="emailAddress">
|
<label for="emailAddress">
|
||||||
{{ t("settings.profile_email") }}
|
{{ t("settings.profile_email") }}
|
||||||
</label>
|
</label>
|
||||||
<form
|
<HoppSmartInput
|
||||||
class="flex mt-2 md:max-w-sm"
|
v-model="emailAddress"
|
||||||
@submit.prevent="updateEmailAddress"
|
styles="flex mt-2 md:max-w-sm"
|
||||||
|
:placeholder="`${t('settings.profile_name')}`"
|
||||||
>
|
>
|
||||||
<input
|
<template #button>
|
||||||
id="emailAddress"
|
<HoppButtonSecondary
|
||||||
v-model="emailAddress"
|
filled
|
||||||
class="input"
|
outline
|
||||||
:placeholder="`${t('settings.profile_name')}`"
|
:label="t('action.save')"
|
||||||
type="email"
|
class="ml-2 min-w-16"
|
||||||
autocomplete="off"
|
type="submit"
|
||||||
required
|
:loading="updatingEmailAddress"
|
||||||
/>
|
@click="updateEmailAddress"
|
||||||
<HoppButtonSecondary
|
/>
|
||||||
filled
|
</template>
|
||||||
outline
|
</HoppSmartInput>
|
||||||
:label="t('action.save')"
|
|
||||||
class="ml-2 min-w-16"
|
|
||||||
type="submit"
|
|
||||||
:loading="updatingEmailAddress"
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -208,7 +198,6 @@ import { ref, watchEffect, computed } from "vue"
|
|||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
|
|
||||||
import { invokeAction } from "~/helpers/actions"
|
import { invokeAction } from "~/helpers/actions"
|
||||||
|
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
@@ -251,9 +240,9 @@ const loadingCurrentUser = computed(() => {
|
|||||||
else return false
|
else return false
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayName = ref(currentUser.value?.displayName)
|
const displayName = ref(currentUser.value?.displayName || "")
|
||||||
const updatingDisplayName = ref(false)
|
const updatingDisplayName = ref(false)
|
||||||
watchEffect(() => (displayName.value = currentUser.value?.displayName))
|
watchEffect(() => (displayName.value = currentUser.value?.displayName || ""))
|
||||||
|
|
||||||
const updateDisplayName = () => {
|
const updateDisplayName = () => {
|
||||||
updatingDisplayName.value = true
|
updatingDisplayName.value = true
|
||||||
@@ -270,9 +259,9 @@ const updateDisplayName = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const emailAddress = ref(currentUser.value?.email)
|
const emailAddress = ref(currentUser.value?.email || "")
|
||||||
const updatingEmailAddress = ref(false)
|
const updatingEmailAddress = ref(false)
|
||||||
watchEffect(() => (emailAddress.value = currentUser.value?.email))
|
watchEffect(() => (emailAddress.value = currentUser.value?.email || ""))
|
||||||
|
|
||||||
const updateEmailAddress = () => {
|
const updateEmailAddress = () => {
|
||||||
updatingEmailAddress.value = true
|
updatingEmailAddress.value = true
|
||||||
|
|||||||
@@ -4,38 +4,35 @@
|
|||||||
<div
|
<div
|
||||||
class="sticky top-0 z-10 flex flex-shrink-0 p-4 space-x-2 overflow-x-auto bg-primary"
|
class="sticky top-0 z-10 flex flex-shrink-0 p-4 space-x-2 overflow-x-auto bg-primary"
|
||||||
>
|
>
|
||||||
<div class="inline-flex flex-1 space-x-2">
|
<HoppSmartInput
|
||||||
<input
|
v-model="url"
|
||||||
id="websocket-url"
|
type="url"
|
||||||
v-model="url"
|
styles="!inline-flex flex-1 space-x-2"
|
||||||
class="w-full px-4 py-2 border rounded bg-primaryLight border-divider text-secondaryDark"
|
input-styles="w-full px-4 py-2 border rounded !bg-primaryLight border-divider text-secondaryDark"
|
||||||
type="url"
|
:placeholder="`${t('websocket.url')}`"
|
||||||
autocomplete="off"
|
:disabled="
|
||||||
spellcheck="false"
|
connectionState === 'CONNECTED' || connectionState === 'CONNECTING'
|
||||||
:class="{ error: !isUrlValid }"
|
"
|
||||||
:placeholder="`${t('websocket.url')}`"
|
@submit="isUrlValid ? toggleConnection() : null"
|
||||||
:disabled="
|
>
|
||||||
connectionState === 'CONNECTED' ||
|
<template #button>
|
||||||
connectionState === 'CONNECTING'
|
<HoppButtonPrimary
|
||||||
"
|
id="connect"
|
||||||
@keyup.enter="isUrlValid ? toggleConnection() : null"
|
:disabled="!isUrlValid"
|
||||||
/>
|
class="w-32"
|
||||||
<HoppButtonPrimary
|
name="connect"
|
||||||
id="connect"
|
:label="
|
||||||
:disabled="!isUrlValid"
|
connectionState === 'CONNECTING'
|
||||||
class="w-32"
|
? t('action.connecting')
|
||||||
name="connect"
|
: connectionState === 'DISCONNECTED'
|
||||||
:label="
|
? t('action.connect')
|
||||||
connectionState === 'CONNECTING'
|
: t('action.disconnect')
|
||||||
? t('action.connecting')
|
"
|
||||||
: connectionState === 'DISCONNECTED'
|
:loading="connectionState === 'CONNECTING'"
|
||||||
? t('action.connect')
|
@click="toggleConnection"
|
||||||
: t('action.disconnect')
|
/>
|
||||||
"
|
</template>
|
||||||
:loading="connectionState === 'CONNECTING'"
|
</HoppSmartInput>
|
||||||
@click="toggleConnection"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<HoppSmartTabs
|
<HoppSmartTabs
|
||||||
v-model="selectedTab"
|
v-model="selectedTab"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user