Compare commits
18 Commits
feat-cherr
...
pr/AndrewB
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60303ef7c | ||
|
|
53e4863a80 | ||
|
|
3340d0813c | ||
|
|
7c5722586c | ||
|
|
6b38363fab | ||
|
|
14202a3eed | ||
|
|
ea03223b8e | ||
|
|
235deb113c | ||
|
|
833e11ab0b | ||
|
|
0d101673d2 | ||
|
|
6fe565c30f | ||
|
|
4164de5a9e | ||
|
|
1ff35f45ee | ||
|
|
3bf8288de3 | ||
|
|
8c48d41eed | ||
|
|
38215be3bd | ||
|
|
edf57da9be | ||
|
|
be61b62825 |
@@ -13,7 +13,6 @@ SESSION_SECRET='add some secret here'
|
||||
# Hoppscotch App Domain Config
|
||||
REDIRECT_URL="http://localhost:3000"
|
||||
WHITELISTED_ORIGINS = "http://localhost:3170,http://localhost:3000,http://localhost:3100"
|
||||
ALLOWED_AUTH_PROVIDERS = GOOGLE,GITHUB,MICROSOFT,EMAIL
|
||||
|
||||
# Google Auth Config
|
||||
GOOGLE_CLIENT_ID="************************************************"
|
||||
|
||||
@@ -411,23 +411,6 @@ export class AdminResolver {
|
||||
return deletedTeam.right;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
description: 'Revoke a team Invite by Invite ID',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlAdminGuard)
|
||||
async revokeTeamInviteByAdmin(
|
||||
@Args({
|
||||
name: 'inviteID',
|
||||
description: 'Team Invite ID',
|
||||
type: () => ID,
|
||||
})
|
||||
inviteID: string,
|
||||
): Promise<boolean> {
|
||||
const invite = await this.adminService.revokeTeamInviteByID(inviteID);
|
||||
if (E.isLeft(invite)) throwErr(invite.left);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* Subscriptions */
|
||||
|
||||
@Subscription(() => InvitedUser, {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
INVALID_EMAIL,
|
||||
ONLY_ONE_ADMIN_ACCOUNT,
|
||||
TEAM_INVITE_ALREADY_MEMBER,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
USER_ALREADY_INVITED,
|
||||
USER_IS_ADMIN,
|
||||
USER_NOT_FOUND,
|
||||
@@ -182,7 +181,7 @@ export class AdminService {
|
||||
* @returns an array team invitations
|
||||
*/
|
||||
async pendingInvitationCountInTeam(teamID: string) {
|
||||
const invitations = await this.teamInvitationService.getTeamInvitations(
|
||||
const invitations = await this.teamInvitationService.getAllTeamInvitations(
|
||||
teamID,
|
||||
);
|
||||
|
||||
@@ -258,7 +257,7 @@ export class AdminService {
|
||||
if (E.isRight(userInvitation)) {
|
||||
await this.teamInvitationService.revokeInvitation(
|
||||
userInvitation.right.id,
|
||||
);
|
||||
)();
|
||||
}
|
||||
|
||||
return E.right(addedUser.right);
|
||||
@@ -417,19 +416,4 @@ export class AdminService {
|
||||
if (E.isLeft(team)) return E.left(team.left);
|
||||
return E.right(team.right);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a team invite by ID
|
||||
* @param inviteID Team Invite ID
|
||||
* @returns an Either of boolean or error
|
||||
*/
|
||||
async revokeTeamInviteByID(inviteID: string) {
|
||||
const teamInvite = await this.teamInvitationService.revokeInvitation(
|
||||
inviteID,
|
||||
);
|
||||
|
||||
if (E.isLeft(teamInvite)) return E.left(teamInvite.left);
|
||||
|
||||
return E.right(teamInvite.right);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
InternalServerErrorException,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Request,
|
||||
Res,
|
||||
UseGuards,
|
||||
@@ -19,18 +19,12 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { GqlUser } from 'src/decorators/gql-user.decorator';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
|
||||
import {
|
||||
AuthProvider,
|
||||
authCookieHandler,
|
||||
authProviderCheck,
|
||||
throwHTTPErr,
|
||||
} from './helper';
|
||||
import { authCookieHandler, throwHTTPErr } from './helper';
|
||||
import { GoogleSSOGuard } from './guards/google-sso.guard';
|
||||
import { GithubSSOGuard } from './guards/github-sso.guard';
|
||||
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
|
||||
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
|
||||
@UseGuards(ThrottlerBehindProxyGuard)
|
||||
@Controller({ path: 'auth', version: '1' })
|
||||
@@ -45,9 +39,6 @@ export class AuthController {
|
||||
@Body() authData: SignInMagicDto,
|
||||
@Query('origin') origin: string,
|
||||
) {
|
||||
if (!authProviderCheck(AuthProvider.EMAIL))
|
||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||
|
||||
const deviceIdToken = await this.authService.signInMagicLink(
|
||||
authData.email,
|
||||
origin,
|
||||
|
||||
@@ -11,7 +11,6 @@ import { RTJwtStrategy } from './strategies/rt-jwt.strategy';
|
||||
import { GoogleStrategy } from './strategies/google.strategy';
|
||||
import { GithubStrategy } from './strategies/github.strategy';
|
||||
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
||||
import { AuthProvider, authProviderCheck } from './helper';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -27,9 +26,9 @@ import { AuthProvider, authProviderCheck } from './helper';
|
||||
AuthService,
|
||||
JwtStrategy,
|
||||
RTJwtStrategy,
|
||||
...(authProviderCheck(AuthProvider.GOOGLE) ? [GoogleStrategy] : []),
|
||||
...(authProviderCheck(AuthProvider.GITHUB) ? [GithubStrategy] : []),
|
||||
...(authProviderCheck(AuthProvider.MICROSOFT) ? [MicrosoftStrategy] : []),
|
||||
GoogleStrategy,
|
||||
GithubStrategy,
|
||||
MicrosoftStrategy,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
|
||||
@@ -228,7 +228,7 @@ export class AuthService {
|
||||
url = process.env.VITE_BASE_URL;
|
||||
}
|
||||
|
||||
await this.mailerService.sendEmail(email, {
|
||||
await this.mailerService.sendAuthEmail(email, {
|
||||
template: 'code-your-own',
|
||||
variables: {
|
||||
inviteeEmail: email,
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
if (!authProviderCheck(AuthProvider.GITHUB))
|
||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
export class GithubSSOGuard extends AuthGuard('github') {
|
||||
getAuthenticateOptions(context: ExecutionContext) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
if (!authProviderCheck(AuthProvider.GOOGLE))
|
||||
throwHTTPErr({ message: AUTH_PROVIDER_NOT_SPECIFIED, statusCode: 404 });
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
export class GoogleSSOGuard extends AuthGuard('google') {
|
||||
getAuthenticateOptions(context: ExecutionContext) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
|
||||
import { Observable } from 'rxjs';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
export class MicrosoftSSOGuard
|
||||
extends AuthGuard('microsoft')
|
||||
implements CanActivate
|
||||
{
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
if (!authProviderCheck(AuthProvider.MICROSOFT))
|
||||
throwHTTPErr({
|
||||
message: AUTH_PROVIDER_NOT_SPECIFIED,
|
||||
statusCode: 404,
|
||||
});
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
|
||||
export class MicrosoftSSOGuard extends AuthGuard('microsoft') {
|
||||
getAuthenticateOptions(context: ExecutionContext) {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { ForbiddenException, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AuthError } from 'src/types/AuthError';
|
||||
import { AuthTokens } from 'src/types/AuthTokens';
|
||||
import { Response } from 'express';
|
||||
import * as cookie from 'cookie';
|
||||
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { COOKIES_NOT_FOUND } from 'src/errors';
|
||||
|
||||
enum AuthTokenType {
|
||||
ACCESS_TOKEN = 'access_token',
|
||||
@@ -17,13 +16,6 @@ export enum Origin {
|
||||
APP = 'app',
|
||||
}
|
||||
|
||||
export enum AuthProvider {
|
||||
GOOGLE = 'GOOGLE',
|
||||
GITHUB = 'GITHUB',
|
||||
MICROSOFT = 'MICROSOFT',
|
||||
EMAIL = 'EMAIL',
|
||||
}
|
||||
|
||||
/**
|
||||
* This function allows throw to be used as an expression
|
||||
* @param errMessage Message present in the error message
|
||||
@@ -105,25 +97,3 @@ export const subscriptionContextCookieParser = (rawCookies: string) => {
|
||||
refresh_token: cookies[AuthTokenType.REFRESH_TOKEN],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check to see if given auth provider is present in the ALLOWED_AUTH_PROVIDERS env variable
|
||||
*
|
||||
* @param provider Provider we want to check the presence of
|
||||
* @returns Boolean if provider specified is present or not
|
||||
*/
|
||||
export function authProviderCheck(provider: string) {
|
||||
if (!provider) {
|
||||
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
|
||||
}
|
||||
|
||||
const envVariables = process.env.ALLOWED_AUTH_PROVIDERS
|
||||
? process.env.ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
|
||||
provider.trim().toUpperCase(),
|
||||
)
|
||||
: [];
|
||||
|
||||
if (!envVariables.includes(provider.toUpperCase())) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -22,30 +22,6 @@ export const AUTH_FAIL = 'auth/fail';
|
||||
*/
|
||||
export const JSON_INVALID = 'json_invalid';
|
||||
|
||||
/**
|
||||
* Auth Provider not specified
|
||||
* (Auth)
|
||||
*/
|
||||
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
|
||||
|
||||
/**
|
||||
* Environment variable "ALLOWED_AUTH_PROVIDERS" is not present in .env file
|
||||
*/
|
||||
export const ENV_NOT_FOUND_KEY_AUTH_PROVIDERS =
|
||||
'"ALLOWED_AUTH_PROVIDERS" is not present in .env file';
|
||||
|
||||
/**
|
||||
* Environment variable "ALLOWED_AUTH_PROVIDERS" is empty in .env file
|
||||
*/
|
||||
export const ENV_EMPTY_AUTH_PROVIDERS =
|
||||
'"ALLOWED_AUTH_PROVIDERS" is empty in .env file';
|
||||
|
||||
/**
|
||||
* Environment variable "ALLOWED_AUTH_PROVIDERS" contains unsupported provider in .env file
|
||||
*/
|
||||
export const ENV_NOT_SUPPORT_AUTH_PROVIDERS =
|
||||
'"ALLOWED_AUTH_PROVIDERS" contains an unsupported auth provider in .env file';
|
||||
|
||||
/**
|
||||
* Tried to delete a user data document from fb firestore but failed.
|
||||
* (FirebaseService)
|
||||
@@ -336,13 +312,6 @@ export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' 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
|
||||
* (GqlTeamEnvTeamGuard)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
UserMagicLinkMailDescription,
|
||||
} from './MailDescriptions';
|
||||
import { throwErr } from 'src/utils';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import { EMAIL_FAILED } from 'src/errors';
|
||||
import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
|
||||
|
||||
@@ -34,14 +35,33 @@ export class MailerService {
|
||||
|
||||
/**
|
||||
* Sends an email to the given email address given a mail description
|
||||
* @param to Receiver's email id
|
||||
* @param to The email address to be sent to (NOTE: this is not validated)
|
||||
* @param mailDesc Definition of what email to be sent
|
||||
* @returns Response if email was send successfully or not
|
||||
*/
|
||||
async sendEmail(
|
||||
sendMail(
|
||||
to: string,
|
||||
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 {
|
||||
await this.nestMailerService.sendMail({
|
||||
to,
|
||||
|
||||
@@ -5,14 +5,11 @@ import * as cookieParser from 'cookie-parser';
|
||||
import { VersioningType } from '@nestjs/common';
|
||||
import * as session from 'express-session';
|
||||
import { emitGQLSchemaFile } from './gql-schema';
|
||||
import { checkEnvironmentAuthProvider } from './utils';
|
||||
|
||||
async function bootstrap() {
|
||||
console.log(`Running in production: ${process.env.PRODUCTION}`);
|
||||
console.log(`Port: ${process.env.PORT}`);
|
||||
|
||||
checkEnvironmentAuthProvider();
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.use(
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
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 {
|
||||
BUG_AUTH_NO_USER_CTX,
|
||||
@@ -9,10 +19,6 @@ import {
|
||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
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
|
||||
@@ -27,31 +33,50 @@ export class GqlTeamEnvTeamGuard implements CanActivate {
|
||||
private readonly teamService: TeamService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const requireRoles = this.reflector.get<TeamMemberRole[]>(
|
||||
'requiresTeamRole',
|
||||
context.getHandler(),
|
||||
);
|
||||
if (!requireRoles) throw new Error(BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES);
|
||||
canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
const gqlExecCtx = GqlExecutionContext.create(context);
|
||||
TE.bindW('requiredRoles', () =>
|
||||
pipe(
|
||||
getAnnotatedRequiredRoles(this.reflector, context),
|
||||
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES),
|
||||
),
|
||||
),
|
||||
|
||||
const { user } = gqlExecCtx.getContext().req;
|
||||
if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX);
|
||||
TE.bindW('user', () =>
|
||||
pipe(
|
||||
getUserFromGQLContext(context),
|
||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
||||
),
|
||||
),
|
||||
|
||||
const { id } = gqlExecCtx.getArgs<{ id: string }>();
|
||||
if (!id) throwErr(BUG_TEAM_ENV_GUARD_NO_ENV_ID);
|
||||
TE.bindW('envID', () =>
|
||||
pipe(
|
||||
getGqlArg('id', context),
|
||||
O.fromPredicate(S.isString),
|
||||
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID),
|
||||
),
|
||||
),
|
||||
|
||||
const teamEnvironment =
|
||||
await this.teamEnvironmentService.getTeamEnvironment(id);
|
||||
if (E.isLeft(teamEnvironment)) throwErr(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||
TE.bindW('membership', ({ envID, user }) =>
|
||||
pipe(
|
||||
this.teamEnvironmentService.getTeamEnvironment(envID),
|
||||
TE.fromTaskOption(() => TEAM_ENVIRONMENT_NOT_FOUND),
|
||||
TE.chainW((env) =>
|
||||
pipe(
|
||||
this.teamService.getTeamMemberTE(env.teamID, user.uid),
|
||||
TE.mapLeft(() => TEAM_ENVIRONMENT_NOT_TEAM_MEMBER),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const member = await this.teamService.getTeamMember(
|
||||
teamEnvironment.right.teamID,
|
||||
user.uid,
|
||||
);
|
||||
if (!member) throwErr(TEAM_ENVIRONMENT_NOT_TEAM_MEMBER);
|
||||
TE.map(({ membership, requiredRoles }) =>
|
||||
requiredRoles.includes(membership.role),
|
||||
),
|
||||
|
||||
return requireRoles.includes(member.role);
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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,11 +13,6 @@ import { throwErr } from 'src/utils';
|
||||
import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
|
||||
import { TeamEnvironment } from './team-environments.model';
|
||||
import { TeamEnvironmentsService } from './team-environments.service';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import {
|
||||
CreateTeamEnvironmentArgs,
|
||||
UpdateTeamEnvironmentArgs,
|
||||
} from './input-type.args';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => 'TeamEnvironment')
|
||||
@@ -34,18 +29,29 @@ export class TeamEnvironmentsResolver {
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||
async createTeamEnvironment(
|
||||
@Args() args: CreateTeamEnvironmentArgs,
|
||||
createTeamEnvironment(
|
||||
@Args({
|
||||
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> {
|
||||
const teamEnvironment =
|
||||
await this.teamEnvironmentsService.createTeamEnvironment(
|
||||
args.name,
|
||||
args.teamID,
|
||||
args.variables,
|
||||
);
|
||||
|
||||
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
|
||||
return teamEnvironment.right;
|
||||
return this.teamEnvironmentsService.createTeamEnvironment(
|
||||
name,
|
||||
teamID,
|
||||
variables,
|
||||
)();
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
@@ -53,7 +59,7 @@ export class TeamEnvironmentsResolver {
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||
async deleteTeamEnvironment(
|
||||
deleteTeamEnvironment(
|
||||
@Args({
|
||||
name: 'id',
|
||||
description: 'ID of the Team Environment',
|
||||
@@ -61,12 +67,10 @@ export class TeamEnvironmentsResolver {
|
||||
})
|
||||
id: string,
|
||||
): Promise<boolean> {
|
||||
const isDeleted = await this.teamEnvironmentsService.deleteTeamEnvironment(
|
||||
id,
|
||||
);
|
||||
|
||||
if (E.isLeft(isDeleted)) throwErr(isDeleted.left);
|
||||
return isDeleted.right;
|
||||
return pipe(
|
||||
this.teamEnvironmentsService.deleteTeamEnvironment(id),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
@Mutation(() => TeamEnvironment, {
|
||||
@@ -75,19 +79,28 @@ export class TeamEnvironmentsResolver {
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||
async updateTeamEnvironment(
|
||||
@Args()
|
||||
args: UpdateTeamEnvironmentArgs,
|
||||
updateTeamEnvironment(
|
||||
@Args({
|
||||
name: 'id',
|
||||
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> {
|
||||
const updatedTeamEnvironment =
|
||||
await this.teamEnvironmentsService.updateTeamEnvironment(
|
||||
args.id,
|
||||
args.name,
|
||||
args.variables,
|
||||
);
|
||||
|
||||
if (E.isLeft(updatedTeamEnvironment)) throwErr(updatedTeamEnvironment.left);
|
||||
return updatedTeamEnvironment.right;
|
||||
return pipe(
|
||||
this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
@Mutation(() => TeamEnvironment, {
|
||||
@@ -95,7 +108,7 @@ export class TeamEnvironmentsResolver {
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||
async deleteAllVariablesFromTeamEnvironment(
|
||||
deleteAllVariablesFromTeamEnvironment(
|
||||
@Args({
|
||||
name: 'id',
|
||||
description: 'ID of the Team Environment',
|
||||
@@ -103,13 +116,10 @@ export class TeamEnvironmentsResolver {
|
||||
})
|
||||
id: string,
|
||||
): Promise<TeamEnvironment> {
|
||||
const teamEnvironment =
|
||||
await this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
||||
id,
|
||||
);
|
||||
|
||||
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
|
||||
return teamEnvironment.right;
|
||||
return pipe(
|
||||
this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
@Mutation(() => TeamEnvironment, {
|
||||
@@ -117,7 +127,7 @@ export class TeamEnvironmentsResolver {
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
|
||||
async createDuplicateEnvironment(
|
||||
createDuplicateEnvironment(
|
||||
@Args({
|
||||
name: 'id',
|
||||
description: 'ID of the Team Environment',
|
||||
@@ -125,12 +135,10 @@ export class TeamEnvironmentsResolver {
|
||||
})
|
||||
id: string,
|
||||
): Promise<TeamEnvironment> {
|
||||
const res = await this.teamEnvironmentsService.createDuplicateEnvironment(
|
||||
id,
|
||||
);
|
||||
|
||||
if (E.isLeft(res)) throwErr(res.left);
|
||||
return res.right;
|
||||
return pipe(
|
||||
this.teamEnvironmentsService.createDuplicateEnvironment(id),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
/* Subscriptions */
|
||||
|
||||
@@ -2,11 +2,7 @@ import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { TeamEnvironment } from './team-environments.model';
|
||||
import { TeamEnvironmentsService } from './team-environments.service';
|
||||
import {
|
||||
JSON_INVALID,
|
||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||
TEAM_ENVIRONMENT_SHORT_NAME,
|
||||
} from 'src/errors';
|
||||
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
|
||||
@@ -35,81 +31,125 @@ beforeEach(() => {
|
||||
|
||||
describe('TeamEnvironmentsService', () => {
|
||||
describe('getTeamEnvironment', () => {
|
||||
test('should successfully return a TeamEnvironment with valid ID', async () => {
|
||||
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
|
||||
teamEnvironment,
|
||||
);
|
||||
test('queries the db with the id', async () => {
|
||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
|
||||
|
||||
const result = await teamEnvironmentsService.getTeamEnvironment(
|
||||
teamEnvironment.id,
|
||||
await teamEnvironmentsService.getTeamEnvironment('123')();
|
||||
|
||||
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
id: '123',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result).toEqualRight(teamEnvironment);
|
||||
});
|
||||
|
||||
test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
|
||||
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
|
||||
'RejectOnNotFound',
|
||||
);
|
||||
test('requests prisma to reject the query promise if not found', async () => {
|
||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
|
||||
|
||||
const result = await teamEnvironmentsService.getTeamEnvironment(
|
||||
teamEnvironment.id,
|
||||
await teamEnvironmentsService.getTeamEnvironment('123')();
|
||||
|
||||
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rejectOnNotFound: true,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||
});
|
||||
|
||||
test('should return a Some of the correct environment if exists', async () => {
|
||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
|
||||
|
||||
const result = await teamEnvironmentsService.getTeamEnvironment('123')();
|
||||
|
||||
expect(result).toEqualSome(teamEnvironment);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
test('should successfully create and return a new team environment given valid inputs', async () => {
|
||||
test('should create and return a new team environment given a valid name,variable and team ID', async () => {
|
||||
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
|
||||
|
||||
const result = await teamEnvironmentsService.createTeamEnvironment(
|
||||
teamEnvironment.name,
|
||||
teamEnvironment.teamID,
|
||||
JSON.stringify(teamEnvironment.variables),
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualRight({
|
||||
...teamEnvironment,
|
||||
expect(result).toEqual(<TeamEnvironment>{
|
||||
id: teamEnvironment.id,
|
||||
name: teamEnvironment.name,
|
||||
teamID: teamEnvironment.teamID,
|
||||
variables: JSON.stringify(teamEnvironment.variables),
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
|
||||
const result = await teamEnvironmentsService.createTeamEnvironment(
|
||||
'12',
|
||||
teamEnvironment.teamID,
|
||||
JSON.stringify(teamEnvironment.variables),
|
||||
);
|
||||
test('should reject if given team ID is invalid', async () => {
|
||||
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
|
||||
|
||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
|
||||
await expect(
|
||||
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 () => {
|
||||
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
|
||||
mockPrisma.teamEnvironment.create.mockResolvedValueOnce(teamEnvironment);
|
||||
|
||||
const result = await teamEnvironmentsService.createTeamEnvironment(
|
||||
teamEnvironment.name,
|
||||
teamEnvironment.teamID,
|
||||
JSON.stringify(teamEnvironment.variables),
|
||||
);
|
||||
)();
|
||||
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_environment/${teamEnvironment.teamID}/created`,
|
||||
{
|
||||
...teamEnvironment,
|
||||
variables: JSON.stringify(teamEnvironment.variables),
|
||||
},
|
||||
result,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTeamEnvironment', () => {
|
||||
test('should successfully delete a TeamEnvironment with a valid ID', async () => {
|
||||
test('should resolve to true given a valid team environment ID', async () => {
|
||||
mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment);
|
||||
|
||||
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
||||
teamEnvironment.id,
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualRight(true);
|
||||
});
|
||||
@@ -119,7 +159,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
|
||||
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
||||
'invalidid',
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||
});
|
||||
@@ -129,7 +169,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
|
||||
const result = await teamEnvironmentsService.deleteTeamEnvironment(
|
||||
teamEnvironment.id,
|
||||
);
|
||||
)();
|
||||
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_environment/${teamEnvironment.teamID}/deleted`,
|
||||
@@ -142,7 +182,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
});
|
||||
|
||||
describe('updateVariablesInTeamEnvironment', () => {
|
||||
test('should successfully add new variable to a team environment', async () => {
|
||||
test('should add new variable to a team environment', async () => {
|
||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
||||
...teamEnvironment,
|
||||
variables: [{ key: 'value' }],
|
||||
@@ -152,7 +192,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
teamEnvironment.id,
|
||||
teamEnvironment.name,
|
||||
JSON.stringify([{ key: 'value' }]),
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualRight(<TeamEnvironment>{
|
||||
...teamEnvironment,
|
||||
@@ -160,7 +200,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should successfully add new variable to already existing list of variables in a team environment', async () => {
|
||||
test('should add new variable to already existing list of variables in a team environment', async () => {
|
||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
||||
...teamEnvironment,
|
||||
variables: [{ key: 'value' }, { key_2: 'value_2' }],
|
||||
@@ -170,7 +210,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
teamEnvironment.id,
|
||||
teamEnvironment.name,
|
||||
JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualRight(<TeamEnvironment>{
|
||||
...teamEnvironment,
|
||||
@@ -178,7 +218,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should successfully edit existing variables in a team environment', async () => {
|
||||
test('should edit existing variables in a team environment', async () => {
|
||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
|
||||
...teamEnvironment,
|
||||
variables: [{ key: '1234' }],
|
||||
@@ -188,7 +228,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
teamEnvironment.id,
|
||||
teamEnvironment.name,
|
||||
JSON.stringify([{ key: '1234' }]),
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualRight(<TeamEnvironment>{
|
||||
...teamEnvironment,
|
||||
@@ -196,7 +236,22 @@ describe('TeamEnvironmentsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should successfully edit name of an existing team environment', async () => {
|
||||
test('should delete existing variable in a 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({
|
||||
...teamEnvironment,
|
||||
variables: [{ key: '123' }],
|
||||
@@ -206,7 +261,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
teamEnvironment.id,
|
||||
teamEnvironment.name,
|
||||
JSON.stringify([{ key: '123' }]),
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualRight(<TeamEnvironment>{
|
||||
...teamEnvironment,
|
||||
@@ -214,24 +269,14 @@ describe('TeamEnvironmentsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
||||
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
|
||||
|
||||
const result = await teamEnvironmentsService.updateTeamEnvironment(
|
||||
'invalidid',
|
||||
teamEnvironment.name,
|
||||
JSON.stringify(teamEnvironment.variables),
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||
});
|
||||
@@ -243,7 +288,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
teamEnvironment.id,
|
||||
teamEnvironment.name,
|
||||
JSON.stringify([{ key: 'value' }]),
|
||||
);
|
||||
)();
|
||||
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_environment/${teamEnvironment.teamID}/updated`,
|
||||
@@ -256,13 +301,13 @@ describe('TeamEnvironmentsService', () => {
|
||||
});
|
||||
|
||||
describe('deleteAllVariablesFromTeamEnvironment', () => {
|
||||
test('should successfully delete all variables in a team environment', async () => {
|
||||
test('should delete all variables in a team environment', async () => {
|
||||
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
|
||||
|
||||
const result =
|
||||
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
||||
teamEnvironment.id,
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualRight(<TeamEnvironment>{
|
||||
...teamEnvironment,
|
||||
@@ -270,13 +315,13 @@ describe('TeamEnvironmentsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
||||
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
||||
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
|
||||
|
||||
const result =
|
||||
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
||||
'invalidid',
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||
});
|
||||
@@ -287,7 +332,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
const result =
|
||||
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
|
||||
teamEnvironment.id,
|
||||
);
|
||||
)();
|
||||
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_environment/${teamEnvironment.teamID}/updated`,
|
||||
@@ -300,7 +345,7 @@ describe('TeamEnvironmentsService', () => {
|
||||
});
|
||||
|
||||
describe('createDuplicateEnvironment', () => {
|
||||
test('should successfully duplicate an existing team environment', async () => {
|
||||
test('should duplicate an existing team environment', async () => {
|
||||
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
|
||||
teamEnvironment,
|
||||
);
|
||||
@@ -312,21 +357,21 @@ describe('TeamEnvironmentsService', () => {
|
||||
|
||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
||||
teamEnvironment.id,
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualRight(<TeamEnvironment>{
|
||||
id: 'newid',
|
||||
...teamEnvironment,
|
||||
id: 'newid',
|
||||
variables: JSON.stringify(teamEnvironment.variables),
|
||||
});
|
||||
});
|
||||
|
||||
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
||||
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
|
||||
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
|
||||
|
||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
||||
teamEnvironment.id,
|
||||
);
|
||||
)();
|
||||
|
||||
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
|
||||
});
|
||||
@@ -343,13 +388,13 @@ describe('TeamEnvironmentsService', () => {
|
||||
|
||||
const result = await teamEnvironmentsService.createDuplicateEnvironment(
|
||||
teamEnvironment.id,
|
||||
);
|
||||
)();
|
||||
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`team_environment/${teamEnvironment.teamID}/created`,
|
||||
{
|
||||
id: 'newid',
|
||||
...teamEnvironment,
|
||||
id: 'newid',
|
||||
variables: JSON.stringify([{}]),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { TeamEnvironment as DBTeamEnvironment, Prisma } from '@prisma/client';
|
||||
import { pipe } from 'fp-ts/function';
|
||||
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 { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { TeamEnvironment } from './team-environments.model';
|
||||
import {
|
||||
TEAM_ENVIRONMENT_NOT_FOUND,
|
||||
TEAM_ENVIRONMENT_SHORT_NAME,
|
||||
} from 'src/errors';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { isValidLength } from 'src/utils';
|
||||
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
|
||||
|
||||
@Injectable()
|
||||
export class TeamEnvironmentsService {
|
||||
constructor(
|
||||
@@ -16,218 +17,219 @@ export class TeamEnvironmentsService {
|
||||
private readonly pubsub: PubSubService,
|
||||
) {}
|
||||
|
||||
TITLE_LENGTH = 3;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
getTeamEnvironment(id: string) {
|
||||
return TO.tryCatch(() =>
|
||||
this.prisma.teamEnvironment.findFirst({
|
||||
where: { id },
|
||||
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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all TeamEnvironments of a team.
|
||||
*
|
||||
* @param teamID teamID of new TeamEnvironment
|
||||
* @returns List of TeamEnvironments
|
||||
*/
|
||||
async fetchAllTeamEnvironments(teamID: string) {
|
||||
const result = await this.prisma.teamEnvironment.findMany({
|
||||
where: {
|
||||
teamID: teamID,
|
||||
},
|
||||
});
|
||||
const teamEnvironments = result.map((item) => {
|
||||
return this.cast(item);
|
||||
});
|
||||
createTeamEnvironment(name: string, teamID: string, variables: string) {
|
||||
return pipe(
|
||||
() =>
|
||||
this.prisma.teamEnvironment.create({
|
||||
data: {
|
||||
name: name,
|
||||
teamID: teamID,
|
||||
variables: JSON.parse(variables),
|
||||
},
|
||||
}),
|
||||
T.chainFirst(
|
||||
(environment) => () =>
|
||||
this.pubsub.publish(
|
||||
`team_environment/${environment.teamID}/created`,
|
||||
<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),
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return teamEnvironments;
|
||||
deleteTeamEnvironment(id: string) {
|
||||
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',
|
||||
})
|
||||
teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> {
|
||||
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id);
|
||||
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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,10 +12,15 @@ import { TeamInvitation } from './team-invitation.model';
|
||||
import { TeamInvitationService } from './team-invitation.service';
|
||||
import { pipe } from 'fp-ts/function';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import * as O from 'fp-ts/Option';
|
||||
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
|
||||
import { TEAM_INVITE_NO_INVITE_FOUND, USER_NOT_FOUND } from 'src/errors';
|
||||
import { EmailCodec } from 'src/types/Email';
|
||||
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 { User } from 'src/user/user.model';
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
@@ -31,8 +36,6 @@ import { UserService } from 'src/user/user.service';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
|
||||
import { SkipThrottle } from '@nestjs/throttler';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
import { CreateTeamInvitationArgs } from './input-type.args';
|
||||
|
||||
@UseGuards(GqlThrottlerGuard)
|
||||
@Resolver(() => TeamInvitation)
|
||||
@@ -76,8 +79,8 @@ export class TeamInvitationResolver {
|
||||
'Gets the Team Invitation with the given ID, or null if not exists',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
|
||||
async teamInvitation(
|
||||
@GqlUser() user: AuthUser,
|
||||
teamInvitation(
|
||||
@GqlUser() user: User,
|
||||
@Args({
|
||||
name: 'inviteID',
|
||||
description: 'ID of the Team Invitation to lookup',
|
||||
@@ -85,11 +88,17 @@ export class TeamInvitationResolver {
|
||||
})
|
||||
inviteID: string,
|
||||
): Promise<TeamInvitation> {
|
||||
const teamInvitation = await this.teamInvitationService.getInvitation(
|
||||
inviteID,
|
||||
);
|
||||
if (O.isNone(teamInvitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
|
||||
return teamInvitation.value;
|
||||
return pipe(
|
||||
this.teamInvitationService.getInvitation(inviteID),
|
||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
||||
TE.chainW(
|
||||
TE.fromPredicate(
|
||||
(a) => a.inviteeEmail.toLowerCase() === user.email?.toLowerCase(),
|
||||
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
||||
),
|
||||
),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
@Mutation(() => TeamInvitation, {
|
||||
@@ -97,19 +106,56 @@ export class TeamInvitationResolver {
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER)
|
||||
async createTeamInvitation(
|
||||
@GqlUser() user: AuthUser,
|
||||
@Args() args: CreateTeamInvitationArgs,
|
||||
): Promise<TeamInvitation> {
|
||||
const teamInvitation = await this.teamInvitationService.createInvitation(
|
||||
user,
|
||||
args.teamID,
|
||||
args.inviteeEmail,
|
||||
args.inviteeRole,
|
||||
);
|
||||
createTeamInvitation(
|
||||
@GqlUser()
|
||||
user: User,
|
||||
|
||||
if (E.isLeft(teamInvitation)) throwErr(teamInvitation.left);
|
||||
return teamInvitation.right;
|
||||
@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> {
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
// Validate email
|
||||
TE.bindW('email', () =>
|
||||
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, {
|
||||
@@ -117,7 +163,7 @@ export class TeamInvitationResolver {
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
|
||||
@RequiresTeamRole(TeamMemberRole.OWNER)
|
||||
async revokeTeamInvitation(
|
||||
revokeTeamInvitation(
|
||||
@Args({
|
||||
name: 'inviteID',
|
||||
type: () => ID,
|
||||
@@ -125,19 +171,19 @@ export class TeamInvitationResolver {
|
||||
})
|
||||
inviteID: string,
|
||||
): Promise<true> {
|
||||
const isRevoked = await this.teamInvitationService.revokeInvitation(
|
||||
inviteID,
|
||||
);
|
||||
if (E.isLeft(isRevoked)) throwErr(isRevoked.left);
|
||||
return true;
|
||||
return pipe(
|
||||
this.teamInvitationService.revokeInvitation(inviteID),
|
||||
TE.map(() => true as const),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
@Mutation(() => TeamMember, {
|
||||
description: 'Accept an Invitation',
|
||||
})
|
||||
@UseGuards(GqlAuthGuard, TeamInviteeGuard)
|
||||
async acceptTeamInvitation(
|
||||
@GqlUser() user: AuthUser,
|
||||
acceptTeamInvitation(
|
||||
@GqlUser() user: User,
|
||||
@Args({
|
||||
name: 'inviteID',
|
||||
type: () => ID,
|
||||
@@ -145,12 +191,10 @@ export class TeamInvitationResolver {
|
||||
})
|
||||
inviteID: string,
|
||||
): Promise<TeamMember> {
|
||||
const teamMember = await this.teamInvitationService.acceptInvitation(
|
||||
inviteID,
|
||||
user,
|
||||
);
|
||||
if (E.isLeft(teamMember)) throwErr(teamMember.left);
|
||||
return teamMember.right;
|
||||
return pipe(
|
||||
this.teamInvitationService.acceptInvitation(inviteID, user),
|
||||
TE.getOrElse(throwErr),
|
||||
)();
|
||||
}
|
||||
|
||||
// Subscriptions
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as T from 'fp-ts/Task';
|
||||
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 { pipe, flow, constVoid } from 'fp-ts/function';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { TeamInvitation as DBTeamInvitation } from '@prisma/client';
|
||||
import { TeamMember, TeamMemberRole } from 'src/team/team.model';
|
||||
import { Team, TeamMemberRole } from 'src/team/team.model';
|
||||
import { Email } from 'src/types/Email';
|
||||
import { User } from 'src/user/user.model';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
import {
|
||||
INVALID_EMAIL,
|
||||
TEAM_INVALID_ID,
|
||||
TEAM_INVITE_ALREADY_MEMBER,
|
||||
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
||||
TEAM_INVITE_MEMBER_HAS_INVITE,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { TeamInvitation } from './team-invitation.model';
|
||||
import { MailerService } from 'src/mailer/mailer.service';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { validateEmail } from '../utils';
|
||||
import { AuthUser } from 'src/types/AuthUser';
|
||||
|
||||
@Injectable()
|
||||
export class TeamInvitationService {
|
||||
@@ -30,37 +32,38 @@ export class TeamInvitationService {
|
||||
private readonly mailerService: MailerService,
|
||||
|
||||
private readonly pubsub: PubSubService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Cast a DBTeamInvitation to a TeamInvitation
|
||||
* @param dbTeamInvitation database TeamInvitation
|
||||
* @returns TeamInvitation model
|
||||
*/
|
||||
cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation {
|
||||
return {
|
||||
...dbTeamInvitation,
|
||||
inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole],
|
||||
};
|
||||
) {
|
||||
this.getInvitation = this.getInvitation.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the team invite
|
||||
* @param inviteID invite id
|
||||
* @returns an Option of team invitation or none
|
||||
*/
|
||||
async getInvitation(inviteID: string) {
|
||||
try {
|
||||
const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({
|
||||
where: {
|
||||
id: inviteID,
|
||||
},
|
||||
});
|
||||
getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> {
|
||||
return pipe(
|
||||
() =>
|
||||
this.prisma.teamInvitation.findUnique({
|
||||
where: {
|
||||
id: inviteID,
|
||||
},
|
||||
}),
|
||||
TO.fromTask,
|
||||
TO.chain(flow(O.fromNullable, TO.fromOption)),
|
||||
TO.map((x) => x as TeamInvitation),
|
||||
);
|
||||
}
|
||||
|
||||
return O.some(this.cast(dbInvitation));
|
||||
} catch (e) {
|
||||
return O.none;
|
||||
}
|
||||
getInvitationWithEmail(email: Email, team: Team) {
|
||||
return pipe(
|
||||
() =>
|
||||
this.prisma.teamInvitation.findUnique({
|
||||
where: {
|
||||
teamID_inviteeEmail: {
|
||||
inviteeEmail: email,
|
||||
teamID: team.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
TO.fromTask,
|
||||
TO.chain(flow(O.fromNullable, TO.fromOption)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,162 +92,211 @@ export class TeamInvitationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a team invitation
|
||||
* @param creator creator of the invitation
|
||||
* @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,
|
||||
createInvitation(
|
||||
creator: User,
|
||||
team: Team,
|
||||
inviteeEmail: Email,
|
||||
inviteeRole: TeamMemberRole,
|
||||
) {
|
||||
// validate email
|
||||
const isEmailValid = validateEmail(inviteeEmail);
|
||||
if (!isEmailValid) return E.left(INVALID_EMAIL);
|
||||
return pipe(
|
||||
// Perform all validation checks
|
||||
TE.sequenceArray([
|
||||
// creator should be a TeamMember
|
||||
pipe(
|
||||
this.teamService.getTeamMemberTE(team.id, creator.uid),
|
||||
TE.map(constVoid),
|
||||
),
|
||||
|
||||
// team ID should valid
|
||||
const team = await this.teamService.getTeamWithID(teamID);
|
||||
if (!team) return E.left(TEAM_INVALID_ID);
|
||||
// Invitee should not be a team member
|
||||
pipe(
|
||||
async () => await this.userService.findUserByEmail(inviteeEmail),
|
||||
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),
|
||||
),
|
||||
|
||||
// invitation creator should be a TeamMember
|
||||
const isTeamMember = await this.teamService.getTeamMember(
|
||||
team.id,
|
||||
creator.uid,
|
||||
// Should not have an existing invite
|
||||
pipe(
|
||||
this.getInvitationWithEmail(inviteeEmail, team),
|
||||
TE.fromTaskOption(() => null),
|
||||
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);
|
||||
}
|
||||
|
||||
// Checking to see if the invitee is already part of the team or not
|
||||
const inviteeUser = await this.userService.findUserByEmail(inviteeEmail);
|
||||
if (O.isSome(inviteeUser)) {
|
||||
// invitee should not already a member
|
||||
const isTeamMember = await this.teamService.getTeamMember(
|
||||
team.id,
|
||||
inviteeUser.value.uid,
|
||||
);
|
||||
if (isTeamMember) return E.left(TEAM_INVITE_ALREADY_MEMBER);
|
||||
}
|
||||
revokeInvitation(inviteID: string) {
|
||||
return pipe(
|
||||
// Make sure invite exists
|
||||
this.getInvitation(inviteID),
|
||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
||||
|
||||
// check invitee already invited earlier or not
|
||||
const teamInvitation = await this.getTeamInviteByEmailAndTeamID(
|
||||
inviteeEmail,
|
||||
team.id,
|
||||
// Delete team invitation
|
||||
TE.chainTaskK(
|
||||
() => () =>
|
||||
this.prisma.teamInvitation.delete({
|
||||
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);
|
||||
}
|
||||
|
||||
// create the invitation
|
||||
const dbInvitation = await this.prisma.teamInvitation.create({
|
||||
data: {
|
||||
teamID: team.id,
|
||||
inviteeEmail,
|
||||
inviteeRole,
|
||||
creatorUid: creator.uid,
|
||||
},
|
||||
});
|
||||
getAllInvitationsInTeam(team: Team) {
|
||||
return pipe(
|
||||
() =>
|
||||
this.prisma.teamInvitation.findMany({
|
||||
where: {
|
||||
teamID: team.id,
|
||||
},
|
||||
}),
|
||||
T.map((x) => x as TeamInvitation[]),
|
||||
);
|
||||
}
|
||||
|
||||
await this.mailerService.sendEmail(inviteeEmail, {
|
||||
template: 'team-invitation',
|
||||
variables: {
|
||||
invitee: creator.displayName ?? 'A Hoppscotch User',
|
||||
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
|
||||
invite_team_name: team.name,
|
||||
},
|
||||
});
|
||||
acceptInvitation(inviteID: string, acceptedBy: User) {
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
const invitation = this.cast(dbInvitation);
|
||||
this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation);
|
||||
// First get the invitation
|
||||
TE.bindW('invitation', () =>
|
||||
pipe(
|
||||
this.getInvitation(inviteID),
|
||||
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
|
||||
),
|
||||
),
|
||||
|
||||
return E.right(invitation);
|
||||
// Validation checks
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a team invitation
|
||||
* @param inviteID invite id
|
||||
* @returns an Either of true or error message
|
||||
*/
|
||||
async revokeInvitation(inviteID: string) {
|
||||
// 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.
|
||||
* Fetch the count invitations for a given team.
|
||||
* @param teamID team id
|
||||
* @returns array of team invitations for a team
|
||||
* @returns a count team invitations for a team
|
||||
*/
|
||||
async getTeamInvitations(teamID: string) {
|
||||
const dbInvitations = await this.prisma.teamInvitation.findMany({
|
||||
async getAllTeamInvitations(teamID: string) {
|
||||
const invitations = await this.prisma.teamInvitation.findMany({
|
||||
where: {
|
||||
teamID: teamID,
|
||||
},
|
||||
});
|
||||
|
||||
const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) =>
|
||||
this.cast(dbInvitation),
|
||||
);
|
||||
|
||||
return invitations;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { pipe } from 'fp-ts/function';
|
||||
import { TeamService } from 'src/team/team.service';
|
||||
import { TeamInvitationService } from './team-invitation.service';
|
||||
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 {
|
||||
BUG_AUTH_NO_USER_CTX,
|
||||
BUG_TEAM_INVITE_NO_INVITE_ID,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
TEAM_NOT_REQUIRED_ROLE,
|
||||
} from 'src/errors';
|
||||
import { User } from 'src/user/user.model';
|
||||
import { throwErr } from 'src/utils';
|
||||
import { TeamMemberRole } from 'src/team/team.model';
|
||||
|
||||
/**
|
||||
* This guard only allows team owner to execute the resolver
|
||||
*/
|
||||
@Injectable()
|
||||
export class TeamInviteTeamOwnerGuard implements CanActivate {
|
||||
constructor(
|
||||
@@ -24,30 +24,48 @@ export class TeamInviteTeamOwnerGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Get GQL context
|
||||
const gqlExecCtx = GqlExecutionContext.create(context);
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
// Get user
|
||||
const { user } = gqlExecCtx.getContext().req;
|
||||
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
|
||||
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
|
||||
|
||||
// Get the invite
|
||||
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
|
||||
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
|
||||
// Get the invite
|
||||
TE.bindW('invite', ({ gqlCtx }) =>
|
||||
pipe(
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const invitation = await this.teamInviteService.getInvitation(inviteID);
|
||||
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
|
||||
TE.bindW('user', ({ gqlCtx }) =>
|
||||
pipe(
|
||||
gqlCtx.getContext().req.user,
|
||||
O.fromNullable,
|
||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
||||
),
|
||||
),
|
||||
|
||||
// Fetch team member details of this user
|
||||
const teamMember = await this.teamService.getTeamMember(
|
||||
invitation.value.teamID,
|
||||
user.uid,
|
||||
);
|
||||
TE.bindW('userMember', ({ invite, user }) =>
|
||||
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
|
||||
),
|
||||
|
||||
if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
|
||||
if (teamMember.role !== TeamMemberRole.OWNER)
|
||||
throwErr(TEAM_NOT_REQUIRED_ROLE);
|
||||
TE.chainW(
|
||||
TE.fromPredicate(
|
||||
({ userMember }) => userMember.role === TeamMemberRole.OWNER,
|
||||
() => TEAM_NOT_REQUIRED_ROLE,
|
||||
),
|
||||
),
|
||||
|
||||
return true;
|
||||
TE.fold(
|
||||
(err) => throwErr(err),
|
||||
() => T.of(true),
|
||||
),
|
||||
)();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
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 { GqlExecutionContext } from '@nestjs/graphql';
|
||||
import {
|
||||
BUG_AUTH_NO_USER_CTX,
|
||||
BUG_TEAM_INVITE_NO_INVITE_ID,
|
||||
TEAM_INVITE_NOT_VALID_VIEWER,
|
||||
TEAM_INVITE_NO_INVITE_FOUND,
|
||||
TEAM_MEMBER_NOT_FOUND,
|
||||
} from 'src/errors';
|
||||
import { User } from 'src/user/user.model';
|
||||
import { throwErr } from 'src/utils';
|
||||
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()
|
||||
export class TeamInviteViewerGuard implements CanActivate {
|
||||
constructor(
|
||||
@@ -26,32 +23,50 @@ export class TeamInviteViewerGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Get GQL context
|
||||
const gqlExecCtx = GqlExecutionContext.create(context);
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
// Get user
|
||||
const { user } = gqlExecCtx.getContext().req;
|
||||
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
|
||||
// Get GQL Context
|
||||
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
|
||||
|
||||
// Get the invite
|
||||
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
|
||||
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
|
||||
// Get user
|
||||
TE.bindW('user', ({ gqlCtx }) =>
|
||||
pipe(
|
||||
O.fromNullable(gqlCtx.getContext().req.user),
|
||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
||||
),
|
||||
),
|
||||
|
||||
const invitation = await this.teamInviteService.getInvitation(inviteID);
|
||||
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
|
||||
// Get the invite
|
||||
TE.bindW('invite', ({ gqlCtx }) =>
|
||||
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 user is a team member
|
||||
if (
|
||||
user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
|
||||
) {
|
||||
const teamMember = await this.teamService.getTeamMember(
|
||||
invitation.value.teamID,
|
||||
user.uid,
|
||||
);
|
||||
// Check if the user and the invite email match, else if we can resolver the user as a team member
|
||||
// any better solution ?
|
||||
TE.chainW(({ user, invite }) =>
|
||||
user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase()
|
||||
? TE.of(true)
|
||||
: pipe(
|
||||
this.teamService.getTeamMemberTE(invite.teamID, user.uid),
|
||||
TE.map(() => true),
|
||||
),
|
||||
),
|
||||
|
||||
if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
|
||||
}
|
||||
TE.mapLeft((e) =>
|
||||
e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e,
|
||||
),
|
||||
|
||||
return true;
|
||||
TE.fold(throwErr, () => T.of(true)),
|
||||
)();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { TeamInvitationService } from './team-invitation.service';
|
||||
import { pipe, flow } from 'fp-ts/function';
|
||||
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 { User } from 'src/user/user.model';
|
||||
import {
|
||||
BUG_AUTH_NO_USER_CTX,
|
||||
BUG_TEAM_INVITE_NO_INVITE_ID,
|
||||
@@ -20,26 +24,44 @@ export class TeamInviteeGuard implements CanActivate {
|
||||
constructor(private readonly teamInviteService: TeamInvitationService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Get GQL Context
|
||||
const gqlExecCtx = GqlExecutionContext.create(context);
|
||||
return pipe(
|
||||
TE.Do,
|
||||
|
||||
// Get user
|
||||
const { user } = gqlExecCtx.getContext().req;
|
||||
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
|
||||
// Get execution context
|
||||
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
|
||||
|
||||
// Get the invite
|
||||
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
|
||||
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
|
||||
// Get user
|
||||
TE.bindW('user', ({ gqlCtx }) =>
|
||||
pipe(
|
||||
O.fromNullable(gqlCtx.getContext().req.user),
|
||||
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
|
||||
),
|
||||
),
|
||||
|
||||
const invitation = await this.teamInviteService.getInvitation(inviteID);
|
||||
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
|
||||
// Get invite
|
||||
TE.bindW('invite', ({ gqlCtx }) =>
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (
|
||||
user.email.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
|
||||
) {
|
||||
throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
|
||||
}
|
||||
// Check if the emails match
|
||||
TE.chainW(
|
||||
TE.fromPredicate(
|
||||
({ user, invite }) => user.email === invite.inviteeEmail,
|
||||
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
|
||||
),
|
||||
),
|
||||
|
||||
return true;
|
||||
// Fold it to a promise
|
||||
TE.fold(throwErr, () => T.of(true)),
|
||||
)();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@ export class TeamTeamInviteExtResolver {
|
||||
complexity: 10,
|
||||
})
|
||||
teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> {
|
||||
return this.teamInviteService.getTeamInvitations(team.id);
|
||||
return this.teamInviteService.getAllInvitationsInTeam(team)();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ import * as E from 'fp-ts/Either';
|
||||
import * as A from 'fp-ts/Array';
|
||||
import { TeamMemberRole } from './team/team.model';
|
||||
import { User } from './user/user.model';
|
||||
import { ENV_EMPTY_AUTH_PROVIDERS, ENV_NOT_FOUND_KEY_AUTH_PROVIDERS, ENV_NOT_SUPPORT_AUTH_PROVIDERS, JSON_INVALID } from './errors';
|
||||
import { AuthProvider } from './auth/helper';
|
||||
import { JSON_INVALID } from './errors';
|
||||
|
||||
/**
|
||||
* A workaround to throw an exception in an expression.
|
||||
@@ -153,31 +152,3 @@ export function isValidLength(title: string, length: number) {
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called by bootstrap() in main.ts
|
||||
* It checks if the "ALLOWED_AUTH_PROVIDERS" environment variable is properly set or not.
|
||||
* If not, it throws an error.
|
||||
*/
|
||||
export function checkEnvironmentAuthProvider() {
|
||||
if (!process.env.hasOwnProperty('ALLOWED_AUTH_PROVIDERS')) {
|
||||
throw new Error(ENV_NOT_FOUND_KEY_AUTH_PROVIDERS);
|
||||
}
|
||||
|
||||
if (process.env.ALLOWED_AUTH_PROVIDERS === '') {
|
||||
throw new Error(ENV_EMPTY_AUTH_PROVIDERS);
|
||||
}
|
||||
|
||||
const givenAuthProviders = process.env.ALLOWED_AUTH_PROVIDERS.split(',').map(
|
||||
(provider) => provider.toLocaleUpperCase(),
|
||||
);
|
||||
const supportedAuthProviders = Object.values(AuthProvider).map(
|
||||
(provider: string) => provider.toLocaleUpperCase(),
|
||||
);
|
||||
|
||||
for (const givenAuthProvider of givenAuthProviders) {
|
||||
if (!supportedAuthProviders.includes(givenAuthProvider)) {
|
||||
throw new Error(ENV_NOT_SUPPORT_AUTH_PROVIDERS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 337 B |
@@ -4,7 +4,6 @@
|
||||
@apply after:backface-hidden;
|
||||
@apply selection:bg-accentDark;
|
||||
@apply selection:text-accentContrast;
|
||||
@apply overscroll-none;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -151,11 +151,6 @@
|
||||
"save_unsaved_tab": "Do you want to save changes made in this tab?",
|
||||
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
|
||||
},
|
||||
"context_menu": {
|
||||
"set_environment_variable": "Set as variable",
|
||||
"add_parameter": "Add to parameter",
|
||||
"open_link_in_new_tab": "Open link in new tab"
|
||||
},
|
||||
"count": {
|
||||
"header": "Header {count}",
|
||||
"message": "Message {count}",
|
||||
@@ -179,7 +174,6 @@
|
||||
"folder": "Folder is empty",
|
||||
"headers": "This request does not have any headers",
|
||||
"history": "History is empty",
|
||||
"history_suggestions": "History does not have any matching entries",
|
||||
"invites": "Invite list is empty",
|
||||
"members": "Team is empty",
|
||||
"parameters": "This request does not have any parameters",
|
||||
@@ -200,23 +194,16 @@
|
||||
"created": "Environment created",
|
||||
"deleted": "Environment deletion",
|
||||
"edit": "Edit Environment",
|
||||
"global": "Global",
|
||||
"invalid_name": "Please provide a name for the environment",
|
||||
"my_environments": "My Environments",
|
||||
"name": "Name",
|
||||
"nested_overflow": "nested environment variables are limited to 10 levels",
|
||||
"new": "New Environment",
|
||||
"no_environment": "No environment",
|
||||
"no_environment_description": "No environments were selected. Choose what to do with the following variables.",
|
||||
"replace_with_variable": "Replace with variable",
|
||||
"scope": "Scope",
|
||||
"select": "Select environment",
|
||||
"set_as_environment": "Set as environment",
|
||||
"team_environments": "Team Environments",
|
||||
"title": "Environments",
|
||||
"updated": "Environment updated",
|
||||
"value": "Value",
|
||||
"variable": "Variable",
|
||||
"variable_list": "Variable List"
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"util": "^0.12.4",
|
||||
"uuid": "^8.3.2",
|
||||
"vue": "^3.2.25",
|
||||
"vue-github-button": "^3.0.3",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-pdf-embed": "^1.1.4",
|
||||
"vue-router": "^4.0.16",
|
||||
@@ -110,7 +111,7 @@
|
||||
"@graphql-codegen/typescript-urql-graphcache": "^2.3.1",
|
||||
"@graphql-codegen/urql-introspection": "^2.2.0",
|
||||
"@graphql-typed-document-node/core": "^3.1.1",
|
||||
"@iconify-json/lucide": "^1.1.109",
|
||||
"@iconify-json/lucide": "^1.1.40",
|
||||
"@intlify/vite-plugin-vue-i18n": "^7.0.0",
|
||||
"@relmify/jest-fp-ts": "^2.1.1",
|
||||
"@rushstack/eslint-patch": "^1.1.4",
|
||||
@@ -147,7 +148,7 @@
|
||||
"vite-plugin-html-config": "^1.0.10",
|
||||
"vite-plugin-inspect": "^0.7.4",
|
||||
"vite-plugin-pages": "^0.26.0",
|
||||
"vite-plugin-pages-sitemap": "^1.4.5",
|
||||
"vite-plugin-pages-sitemap": "^1.4.0",
|
||||
"vite-plugin-pwa": "^0.13.1",
|
||||
"vite-plugin-vue-layouts": "^0.7.0",
|
||||
"vite-plugin-windicss": "^1.8.8",
|
||||
|
||||
@@ -9,7 +9,6 @@ declare module '@vue/runtime-core' {
|
||||
export interface GlobalComponents {
|
||||
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
|
||||
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
|
||||
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
|
||||
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
|
||||
AppFooter: typeof import('./components/app/Footer.vue')['default']
|
||||
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
|
||||
@@ -25,8 +24,7 @@ declare module '@vue/runtime-core' {
|
||||
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']
|
||||
AppSpotlightEntryHistory: typeof import('./components/app/spotlight/entry/History.vue')['default']
|
||||
AppSupport: typeof import('./components/app/Support.vue')['default']
|
||||
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
|
||||
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default']
|
||||
@@ -55,7 +53,6 @@ declare module '@vue/runtime-core' {
|
||||
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
|
||||
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
|
||||
Environments: typeof import('./components/environments/index.vue')['default']
|
||||
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
|
||||
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
|
||||
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
|
||||
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
|
||||
@@ -134,7 +131,7 @@ declare module '@vue/runtime-core' {
|
||||
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||
IconLucideRss: typeof import('~icons/lucide/rss')['default']
|
||||
IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default']
|
||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="contextMenuRef"
|
||||
class="fixed bg-popover shadow-lg transform translate-y-8 border border-dividerDark p-2 rounded"
|
||||
:style="`top: ${position.top}px; left: ${position.left}px; z-index: 1000;`"
|
||||
>
|
||||
<div v-if="contextMenuOptions" class="flex flex-col">
|
||||
<div
|
||||
v-for="option in contextMenuOptions"
|
||||
:key="option.id"
|
||||
class="flex flex-col space-y-2"
|
||||
>
|
||||
<HoppSmartItem
|
||||
v-if="option.text.type === 'text' && option.text"
|
||||
:icon="option.icon"
|
||||
:label="option.text.text"
|
||||
@click="handleClick(option)"
|
||||
/>
|
||||
<component
|
||||
:is="option.text.component"
|
||||
v-else-if="option.text.type === 'custom'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onClickOutside } from "@vueuse/core"
|
||||
import { useService } from "dioc/vue"
|
||||
import { ref, watch } from "vue"
|
||||
import { ContextMenuResult, ContextMenuService } from "~/services/context-menu"
|
||||
import { EnvironmentMenuService } from "~/services/context-menu/menu/environment.menu"
|
||||
import { ParameterMenuService } from "~/services/context-menu/menu/parameter.menu"
|
||||
import { URLMenuService } from "~/services/context-menu/menu/url.menu"
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
position: { top: number; left: number }
|
||||
text: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const contextMenuRef = ref<any | null>(null)
|
||||
|
||||
const contextMenuOptions = ref<ContextMenuResult[]>([])
|
||||
|
||||
onClickOutside(contextMenuRef, () => {
|
||||
emit("hide-modal")
|
||||
})
|
||||
|
||||
const contextMenuService = useService(ContextMenuService)
|
||||
|
||||
useService(EnvironmentMenuService)
|
||||
useService(ParameterMenuService)
|
||||
useService(URLMenuService)
|
||||
|
||||
const handleClick = (option: { action: () => void }) => {
|
||||
option.action()
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.show, props.text],
|
||||
(val) => {
|
||||
if (val && props.text) {
|
||||
const options = contextMenuService.getMenuFor(props.text)
|
||||
contextMenuOptions.value = options
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
@@ -152,7 +152,7 @@
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t(
|
||||
'app.shortcuts'
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>/</kbd>`"
|
||||
)} <kbd>${getSpecialKey()}</kbd><kbd>K</kbd>`"
|
||||
:icon="IconZap"
|
||||
@click="invokeAction('flyouts.keybinds.toggle')"
|
||||
/>
|
||||
|
||||
@@ -15,21 +15,16 @@
|
||||
:label="t('app.name')"
|
||||
to="/"
|
||||
/>
|
||||
<!-- <AppGitHubStarButton class="mt-1.5 transition" /> -->
|
||||
</div>
|
||||
<div class="inline-flex items-center justify-center flex-1 space-x-2">
|
||||
<button
|
||||
class="flex flex-1 items-center justify-between px-2 py-1 bg-primaryDark transition text-secondaryLight cursor-text rounded border border-dividerDark max-w-xs hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
|
||||
<div class="inline-flex items-center space-x-2">
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip', allowHTML: true }"
|
||||
:title="`${t('app.search')} <kbd>/</kbd>`"
|
||||
:icon="IconSearch"
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
@click="invokeAction('modals.search.toggle')"
|
||||
>
|
||||
<span class="inline-flex flex-1 items-center">
|
||||
<icon-lucide-search class="mr-2 svg-icons" />
|
||||
{{ t("app.search") }}
|
||||
</span>
|
||||
<span class="flex">
|
||||
<kbd class="shortcut-key">{{ getPlatformSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">K</kbd>
|
||||
</span>
|
||||
</button>
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
v-if="showInstallButton"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
@@ -47,8 +42,6 @@
|
||||
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||
@click="invokeAction('modals.support.toggle')"
|
||||
/>
|
||||
</div>
|
||||
<div class="inline-flex items-center justify-end flex-1 space-x-2">
|
||||
<div
|
||||
v-if="currentUser === null"
|
||||
class="inline-flex items-center space-x-2"
|
||||
@@ -243,6 +236,7 @@ import IconDownload from "~icons/lucide/download"
|
||||
import IconUploadCloud from "~icons/lucide/upload-cloud"
|
||||
import IconUserPlus from "~icons/lucide/user-plus"
|
||||
import IconLifeBuoy from "~icons/lucide/life-buoy"
|
||||
import IconSearch from "~icons/lucide/search"
|
||||
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
|
||||
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
|
||||
import { platform } from "~/platform"
|
||||
@@ -253,7 +247,6 @@ import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
|
||||
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||
import { onLoggedIn } from "~/composables/auth"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { getPlatformSpecialKey } from "~/helpers/platformutils"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col divide-y divide-dividerLight">
|
||||
<HoppSmartPlaceholder
|
||||
v-if="isEmpty(shortcutsResults)"
|
||||
:text="`${t('state.nothing_found')} ‟${filterText}”`"
|
||||
>
|
||||
<HoppSmartPlaceholder v-if="isEmpty(shortcutsResults)">
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
<span class="my-2 text-center flex flex-col">
|
||||
{{ t("state.nothing_found") }}
|
||||
<span class="break-all">"{{ filterText }}"</span>
|
||||
</span>
|
||||
</HoppSmartPlaceholder>
|
||||
<details
|
||||
v-for="(sectionResults, sectionTitle) in shortcutsResults"
|
||||
|
||||
@@ -22,11 +22,10 @@
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">/</kbd>
|
||||
<kbd class="shortcut-key">K</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">{{ getSpecialKey() }}</kbd>
|
||||
<kbd class="shortcut-key">K</kbd>
|
||||
<kbd class="shortcut-key">/</kbd>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<kbd class="shortcut-key">?</kbd>
|
||||
|
||||
@@ -1,49 +1,48 @@
|
||||
<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 }"
|
||||
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')"
|
||||
@keydown.enter="emit('action')"
|
||||
>
|
||||
<component
|
||||
:is="entry.icon"
|
||||
class="opacity-50 svg-icons"
|
||||
:class="{ 'opacity-100': active }"
|
||||
class="mr-4 transition opacity-50 svg-icons"
|
||||
:class="{ 'opacity-100 text-secondaryDark': active }"
|
||||
/>
|
||||
<template
|
||||
<span
|
||||
v-if="entry.text.type === 'text' && typeof entry.text.text === 'string'"
|
||||
class="flex flex-1 mr-4 transition"
|
||||
:class="{ 'text-secondaryDark': active }"
|
||||
>
|
||||
<span class="block truncate">
|
||||
{{ entry.text.text }}
|
||||
</span>
|
||||
</template>
|
||||
<template
|
||||
{{ entry.text.text }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="entry.text.type === 'text' && Array.isArray(entry.text.text)"
|
||||
class="flex flex-1 mr-4 transition"
|
||||
:class="{ 'text-secondaryDark': active }"
|
||||
>
|
||||
<template
|
||||
<span
|
||||
v-for="(labelPart, labelPartIndex) in entry.text.text"
|
||||
:key="`label-${labelPart}-${labelPartIndex}`"
|
||||
>
|
||||
<span class="block truncate">
|
||||
{{ labelPart }}
|
||||
</span>
|
||||
{{ labelPart }}
|
||||
|
||||
<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"
|
||||
class="inline"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="formattedShortcutKeys" class="block truncate">
|
||||
</span>
|
||||
<span v-else-if="entry.text.type === 'custom'">
|
||||
<component
|
||||
:is="entry.text.component"
|
||||
v-bind="entry.text.componentProps"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="formattedShortcutKeys">
|
||||
<kbd
|
||||
v-for="(key, keyIndex) in formattedShortcutKeys"
|
||||
:key="`key-${String(keyIndex)}`"
|
||||
@@ -106,6 +105,7 @@ watch(
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-entry {
|
||||
@apply relative;
|
||||
@apply after:absolute;
|
||||
@apply after:top-0;
|
||||
@apply after:left-0;
|
||||
@@ -116,6 +116,7 @@ watch(
|
||||
@apply after:content-DEFAULT;
|
||||
|
||||
&.active {
|
||||
@apply bg-primaryLight;
|
||||
@apply after:bg-accentLight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<template>
|
||||
<span class="flex flex-1 items-center space-x-2">
|
||||
<span class="block truncate">
|
||||
{{ dateTimeText }}
|
||||
</span>
|
||||
<icon-lucide-chevron-right class="flex flex-shrink-0" />
|
||||
<span class="block truncate">
|
||||
{{ historyEntry.request.url }}
|
||||
</span>
|
||||
<span
|
||||
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1"
|
||||
>
|
||||
{{ historyEntry.request.query.split("\n")[0] }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
import { GQLHistoryEntry } from "~/newstore/history"
|
||||
|
||||
const props = defineProps<{
|
||||
historyEntry: GQLHistoryEntry
|
||||
}>()
|
||||
|
||||
const dateTimeText = computed(() =>
|
||||
shortDateTime(props.historyEntry.updatedOn!)
|
||||
)
|
||||
</script>
|
||||
@@ -1,16 +1,13 @@
|
||||
<template>
|
||||
<span class="flex flex-1 items-center space-x-2">
|
||||
<span class="block truncate">
|
||||
{{ dateTimeText }}
|
||||
<span class="flex flex-row space-x-2">
|
||||
<span>{{ dateTimeText }}</span>
|
||||
<icon-lucide-chevron-right class="inline" />
|
||||
<span class="truncate" :class="entryStatus.className">
|
||||
<span class="font-semibold truncate text-tiny">
|
||||
{{ historyEntry.request.method }}
|
||||
</span>
|
||||
</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">
|
||||
<span>
|
||||
{{ historyEntry.request.endpoint }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -6,8 +6,8 @@
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col border-b transition border-divider">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col border-b transition border-dividerLight">
|
||||
<div class="flex items-center p-6 space-x-2">
|
||||
<input
|
||||
id="command"
|
||||
v-model="search"
|
||||
@@ -16,23 +16,46 @@
|
||||
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"
|
||||
class="flex flex-1 text-base bg-transparent text-secondaryDark"
|
||||
/>
|
||||
<HoppSmartSpinner v-if="searchSession?.loading" class="mr-6" />
|
||||
|
||||
<icon-lucide-refresh-cw
|
||||
v-if="searchSession?.loading"
|
||||
class="animate-spin"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<div
|
||||
v-if="searchSession && search.length > 0"
|
||||
class="flex flex-col flex-1 overflow-y-auto border-b border-divider divide-y divide-dividerLight"
|
||||
v-if="searchSession"
|
||||
class="flex flex-col flex-1 overflow-auto space-y-4 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"
|
||||
>
|
||||
<h5 class="px-6 py-2 my-2 text-secondaryLight">
|
||||
{{ sectionResult.title }}
|
||||
</h5>
|
||||
<AppSpotlightEntry
|
||||
@@ -44,41 +67,15 @@
|
||||
@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"
|
||||
<HoppSmartPlaceholder
|
||||
v-if="scoredResults.length === 0 && search.length > 0"
|
||||
:text="`${t('state.nothing_found')} ‟${search}”`"
|
||||
>
|
||||
<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 #icon>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
@@ -192,17 +189,6 @@ function newUseArrowKeysForNavigation() {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -218,7 +204,11 @@ function newUseArrowKeysForNavigation() {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
onEnter()
|
||||
const [sectionIndex, entryIndex] = selectedEntry.value
|
||||
const [sectionID, section] = scoredResults.value[sectionIndex]
|
||||
const result = section.results[entryIndex]
|
||||
|
||||
runAction(sectionID, result)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -193,7 +193,7 @@ import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import IconEdit from "~icons/lucide/edit"
|
||||
import IconFolder from "~icons/lucide/folder"
|
||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { PropType, ref, computed, watch } from "vue"
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
@@ -209,36 +209,67 @@ type FolderType = "collection" | "folder"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
id: string
|
||||
parentID?: string | null
|
||||
data: HoppCollection<HoppRESTRequest> | TeamCollection
|
||||
/**
|
||||
* Collection component can be used for both collections and folders.
|
||||
* folderType is used to determine which one it is.
|
||||
*/
|
||||
collectionsType: CollectionType
|
||||
folderType: FolderType
|
||||
isOpen: boolean
|
||||
isSelected?: boolean | null
|
||||
exportLoading?: boolean
|
||||
hasNoTeamAccess?: boolean
|
||||
collectionMoveLoading?: string[]
|
||||
isLastItem?: boolean
|
||||
}>(),
|
||||
{
|
||||
id: "",
|
||||
parentID: null,
|
||||
collectionsType: "my-collections",
|
||||
folderType: "collection",
|
||||
isOpen: false,
|
||||
isSelected: false,
|
||||
exportLoading: false,
|
||||
hasNoTeamAccess: false,
|
||||
isLastItem: false,
|
||||
}
|
||||
)
|
||||
const props = defineProps({
|
||||
id: {
|
||||
type: String,
|
||||
default: "",
|
||||
required: true,
|
||||
},
|
||||
parentID: {
|
||||
type: String as PropType<string | null>,
|
||||
default: null,
|
||||
required: false,
|
||||
},
|
||||
data: {
|
||||
type: Object as PropType<HoppCollection<HoppRESTRequest> | TeamCollection>,
|
||||
default: () => ({}),
|
||||
required: true,
|
||||
},
|
||||
collectionsType: {
|
||||
type: String as PropType<CollectionType>,
|
||||
default: "my-collections",
|
||||
required: true,
|
||||
},
|
||||
/**
|
||||
* Collection component can be used for both collections and folders.
|
||||
* folderType is used to determine which one it is.
|
||||
*/
|
||||
folderType: {
|
||||
type: String as PropType<FolderType>,
|
||||
default: "collection",
|
||||
required: true,
|
||||
},
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: true,
|
||||
},
|
||||
isSelected: {
|
||||
type: Boolean as PropType<boolean | null>,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
exportLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
hasNoTeamAccess: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
collectionMoveLoading: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
required: false,
|
||||
},
|
||||
isLastItem: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "toggle-children"): void
|
||||
@@ -417,13 +448,8 @@ const notSameDestination = computed(() => {
|
||||
})
|
||||
|
||||
const isCollLoading = computed(() => {
|
||||
const { collectionMoveLoading } = props
|
||||
if (
|
||||
collectionMoveLoading &&
|
||||
collectionMoveLoading.length > 0 &&
|
||||
props.data.id
|
||||
) {
|
||||
return collectionMoveLoading.includes(props.data.id)
|
||||
if (props.collectionMoveLoading.length > 0 && props.data.id) {
|
||||
return props.collectionMoveLoading.includes(props.data.id)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
:title="t('environment.set_as_environment')"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex space-y-4 flex-1 flex-col">
|
||||
<div class="flex items-center space-x-8 ml-2">
|
||||
<label for="name" class="font-semibold min-w-10">{{
|
||||
t("environment.name")
|
||||
}}</label>
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
:placeholder="t('environment.variable')"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center space-x-8 ml-2">
|
||||
<label for="value" class="font-semibold min-w-10">{{
|
||||
t("environment.value")
|
||||
}}</label>
|
||||
<input type="text" :value="value" class="input" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-8 ml-2">
|
||||
<label for="scope" class="font-semibold min-w-10">
|
||||
{{ t("environment.scope") }}
|
||||
</label>
|
||||
<div
|
||||
class="relative flex flex-1 flex-col border border-divider rounded focus-visible:border-dividerDark"
|
||||
>
|
||||
<EnvironmentsSelector v-model="scope" :is-scope-selector="true" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="replaceWithVariable" class="flex space-x-2 mt-3">
|
||||
<div class="min-w-18" />
|
||||
<HoppSmartCheckbox
|
||||
:on="replaceWithVariable"
|
||||
title="t('environment.replace_with_variable'))"
|
||||
@change="replaceWithVariable = !replaceWithVariable"
|
||||
/>
|
||||
<label for="replaceWithVariable">
|
||||
{{ t("environment.replace_with_variable") }}</label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span class="flex space-x-2">
|
||||
<HoppButtonPrimary
|
||||
:label="t('action.save')"
|
||||
outline
|
||||
@click="addEnvironment"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
outline
|
||||
filled
|
||||
@click="hideModal"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import { ref, watch } from "vue"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
|
||||
import {
|
||||
addEnvironmentVariable,
|
||||
addGlobalEnvVariable,
|
||||
} from "~/newstore/environments"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import { updateTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
|
||||
import { currentActiveTab } from "~/helpers/rest/tab"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
position: { top: number; left: number }
|
||||
name: string
|
||||
value: string
|
||||
replaceWithVariable: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const hideModal = () => {
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
scope.value = {
|
||||
type: "global",
|
||||
}
|
||||
name.value = ""
|
||||
replaceWithVariable.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
type Scope =
|
||||
| {
|
||||
type: "global"
|
||||
}
|
||||
| {
|
||||
type: "my-environment"
|
||||
environment: Environment
|
||||
index: number
|
||||
}
|
||||
| {
|
||||
type: "team-environment"
|
||||
environment: TeamEnvironment
|
||||
}
|
||||
|
||||
const scope = ref<Scope>({
|
||||
type: "global",
|
||||
})
|
||||
|
||||
const replaceWithVariable = ref(false)
|
||||
|
||||
const name = ref("")
|
||||
|
||||
const addEnvironment = async () => {
|
||||
if (!name.value) {
|
||||
toast.error(`${t("environment.invalid_name")}`)
|
||||
return
|
||||
}
|
||||
if (scope.value.type === "global") {
|
||||
addGlobalEnvVariable({
|
||||
key: name.value,
|
||||
value: props.value,
|
||||
})
|
||||
toast.success(`${t("environment.updated")}`)
|
||||
} else if (scope.value.type === "my-environment") {
|
||||
addEnvironmentVariable(scope.value.index, {
|
||||
key: name.value,
|
||||
value: props.value,
|
||||
})
|
||||
toast.success(`${t("environment.updated")}`)
|
||||
} else {
|
||||
const newVariables = [
|
||||
...scope.value.environment.environment.variables,
|
||||
{
|
||||
key: name.value,
|
||||
value: props.value,
|
||||
},
|
||||
]
|
||||
await pipe(
|
||||
updateTeamEnvironment(
|
||||
JSON.stringify(newVariables),
|
||||
scope.value.environment.id,
|
||||
scope.value.environment.environment.name
|
||||
),
|
||||
TE.match(
|
||||
(err: GQLError<string>) => {
|
||||
console.error(err)
|
||||
toast.error(`${getErrorMessage(err)}`)
|
||||
},
|
||||
() => {
|
||||
hideModal()
|
||||
toast.success(`${t("environment.updated")}`)
|
||||
}
|
||||
)
|
||||
)()
|
||||
}
|
||||
if (replaceWithVariable.value) {
|
||||
//replace the current tab endpoint with the variable name with << and >>
|
||||
const variableName = `<<${name.value}>>`
|
||||
//replace the currenttab endpoint containing the value in the text with variablename
|
||||
currentActiveTab.value.document.request.endpoint =
|
||||
currentActiveTab.value.document.request.endpoint.replace(
|
||||
props.value,
|
||||
variableName
|
||||
)
|
||||
}
|
||||
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const getErrorMessage = (err: GQLError<string>) => {
|
||||
if (err.type === "network_error") {
|
||||
return t("error.network_error")
|
||||
} else {
|
||||
switch (err.error) {
|
||||
case "team_environment/not_found":
|
||||
return t("team_environment.not_found")
|
||||
case "Forbidden resource":
|
||||
return t("profile.no_permission")
|
||||
default:
|
||||
return t("error.something_went_wrong")
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -8,7 +8,7 @@
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="`${t('environment.select')}`"
|
||||
class="bg-transparent select-wrapper"
|
||||
class="bg-transparent border-b border-dividerLight select-wrapper"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
:icon="IconLayers"
|
||||
@@ -22,7 +22,6 @@
|
||||
class="flex-1 !justify-start pr-8 rounded-none"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<template #content="{ hide }">
|
||||
<div
|
||||
ref="tippyActions"
|
||||
@@ -32,7 +31,6 @@
|
||||
@keyup.escape="hide()"
|
||||
>
|
||||
<HoppSmartItem
|
||||
v-if="!isScopeSelector"
|
||||
:label="`${t('environment.no_environment')}`"
|
||||
:info-icon="
|
||||
selectedEnvironmentIndex.type === 'NO_ENV_SELECTED'
|
||||
@@ -49,21 +47,6 @@
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartItem
|
||||
v-else-if="isScopeSelector && modelValue"
|
||||
:label="t('environment.global')"
|
||||
:icon="IconGlobe"
|
||||
:info-icon="modelValue.type === 'global' ? IconCheck : undefined"
|
||||
:active-info-icon="modelValue.type === 'global'"
|
||||
@click="
|
||||
() => {
|
||||
$emit('update:modelValue', {
|
||||
type: 'global',
|
||||
})
|
||||
hide()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<HoppSmartTabs
|
||||
v-model="selectedEnvTab"
|
||||
styles="sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary"
|
||||
@@ -78,14 +61,11 @@
|
||||
:key="`gen-${index}`"
|
||||
:icon="IconLayers"
|
||||
:label="gen.name"
|
||||
:info-icon="isEnvActive(index) ? IconCheck : undefined"
|
||||
:active-info-icon="isEnvActive(index)"
|
||||
:info-icon="index === selectedEnv.index ? IconCheck : undefined"
|
||||
:active-info-icon="index === selectedEnv.index"
|
||||
@click="
|
||||
() => {
|
||||
handleEnvironmentChange(index, {
|
||||
type: 'my-environment',
|
||||
environment: gen,
|
||||
})
|
||||
selectedEnvironmentIndex = { type: 'MY_ENV', index: index }
|
||||
hide()
|
||||
}
|
||||
"
|
||||
@@ -116,14 +96,18 @@
|
||||
:key="`gen-team-${index}`"
|
||||
:icon="IconLayers"
|
||||
:label="gen.environment.name"
|
||||
:info-icon="isEnvActive(gen.id) ? IconCheck : undefined"
|
||||
:active-info-icon="isEnvActive(gen.id)"
|
||||
:info-icon="
|
||||
gen.id === selectedEnv.teamEnvID ? IconCheck : undefined
|
||||
"
|
||||
:active-info-icon="gen.id === selectedEnv.teamEnvID"
|
||||
@click="
|
||||
() => {
|
||||
handleEnvironmentChange(index, {
|
||||
type: 'team-environment',
|
||||
environment: gen,
|
||||
})
|
||||
selectedEnvironmentIndex = {
|
||||
type: 'TEAM_ENV',
|
||||
teamEnvID: gen.id,
|
||||
teamID: gen.teamID,
|
||||
environment: gen.environment,
|
||||
}
|
||||
hide()
|
||||
}
|
||||
"
|
||||
@@ -152,10 +136,9 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, watch } from "vue"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import IconCheck from "~icons/lucide/check"
|
||||
import IconLayers from "~icons/lucide/layers"
|
||||
import IconGlobe from "~icons/lucide/globe"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
@@ -173,31 +156,6 @@ import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||
import { useLocalState } from "~/newstore/localstate"
|
||||
import { onLoggedIn } from "~/composables/auth"
|
||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
|
||||
type Scope =
|
||||
| {
|
||||
type: "global"
|
||||
}
|
||||
| {
|
||||
type: "my-environment"
|
||||
environment: Environment
|
||||
index: number
|
||||
}
|
||||
| {
|
||||
type: "team-environment"
|
||||
environment: TeamEnvironment
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
isScopeSelector?: boolean
|
||||
modelValue?: Scope
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", data: Scope): void
|
||||
}>()
|
||||
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const mdAndLarger = breakpoints.greater("md")
|
||||
@@ -212,39 +170,6 @@ const myEnvironments = useReadonlyStream(environments$, [])
|
||||
|
||||
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
|
||||
|
||||
// TeamList-Adapter
|
||||
const teamListAdapter = new TeamListAdapter(true)
|
||||
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
|
||||
const teamListFetched = ref(false)
|
||||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
||||
|
||||
onLoggedIn(() => {
|
||||
!teamListAdapter.isInitialized && teamListAdapter.initialize()
|
||||
})
|
||||
|
||||
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
|
||||
REMEMBERED_TEAM_ID.value = team.id
|
||||
changeWorkspace({
|
||||
teamID: team.id,
|
||||
teamName: team.name,
|
||||
type: "team",
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => myTeams.value,
|
||||
(newTeams) => {
|
||||
if (newTeams && !teamListFetched.value) {
|
||||
teamListFetched.value = true
|
||||
if (REMEMBERED_TEAM_ID.value) {
|
||||
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
|
||||
if (team) switchToTeamWorkspace(team)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// TeamEnv List Adapter
|
||||
const teamEnvListAdapter = new TeamEnvironmentAdapter(undefined)
|
||||
const teamListLoading = useReadonlyStream(teamEnvListAdapter.loading$, false)
|
||||
const teamAdapterError = useReadonlyStream(teamEnvListAdapter.error$, null)
|
||||
@@ -279,152 +204,63 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const handleEnvironmentChange = (
|
||||
index: number,
|
||||
env?:
|
||||
| {
|
||||
type: "my-environment"
|
||||
environment: Environment
|
||||
}
|
||||
| {
|
||||
type: "team-environment"
|
||||
environment: TeamEnvironment
|
||||
}
|
||||
) => {
|
||||
if (props.isScopeSelector && env) {
|
||||
if (env.type === "my-environment") {
|
||||
emit("update:modelValue", {
|
||||
type: "my-environment",
|
||||
environment: env.environment,
|
||||
index,
|
||||
})
|
||||
} else if (env.type === "team-environment") {
|
||||
emit("update:modelValue", {
|
||||
type: "team-environment",
|
||||
environment: env.environment,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (env && env.type === "my-environment") {
|
||||
selectedEnvironmentIndex.value = {
|
||||
type: "MY_ENV",
|
||||
index,
|
||||
}
|
||||
} else if (env && env.type === "team-environment") {
|
||||
selectedEnvironmentIndex.value = {
|
||||
type: "TEAM_ENV",
|
||||
teamEnvID: env.environment.id,
|
||||
teamID: env.environment.teamID,
|
||||
environment: env.environment.environment,
|
||||
}
|
||||
}
|
||||
}
|
||||
// TeamList-Adapter
|
||||
const teamListAdapter = new TeamListAdapter(true)
|
||||
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
|
||||
const teamListFetched = ref(false)
|
||||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
||||
|
||||
onLoggedIn(() => {
|
||||
!teamListAdapter.isInitialized && teamListAdapter.initialize()
|
||||
})
|
||||
|
||||
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
|
||||
REMEMBERED_TEAM_ID.value = team.id
|
||||
changeWorkspace({
|
||||
teamID: team.id,
|
||||
teamName: team.name,
|
||||
type: "team",
|
||||
})
|
||||
}
|
||||
|
||||
const isEnvActive = (id: string | number) => {
|
||||
if (props.isScopeSelector) {
|
||||
if (props.modelValue?.type === "my-environment") {
|
||||
return props.modelValue.index === id
|
||||
} else if (props.modelValue?.type === "team-environment") {
|
||||
return (
|
||||
props.modelValue?.type === "team-environment" &&
|
||||
props.modelValue.environment &&
|
||||
props.modelValue.environment.id === id
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
|
||||
return selectedEnv.value.index === id
|
||||
} else {
|
||||
return (
|
||||
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||
selectedEnv.value.teamEnvID === id
|
||||
)
|
||||
watch(
|
||||
() => myTeams.value,
|
||||
(newTeams) => {
|
||||
if (newTeams && !teamListFetched.value) {
|
||||
teamListFetched.value = true
|
||||
if (REMEMBERED_TEAM_ID.value) {
|
||||
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
|
||||
if (team) switchToTeamWorkspace(team)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const selectedEnv = computed(() => {
|
||||
if (props.isScopeSelector) {
|
||||
if (props.modelValue?.type === "my-environment") {
|
||||
return {
|
||||
type: "MY_ENV",
|
||||
index: props.modelValue.index,
|
||||
name: props.modelValue.environment?.name,
|
||||
}
|
||||
} else if (props.modelValue?.type === "team-environment") {
|
||||
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
|
||||
return {
|
||||
type: "MY_ENV",
|
||||
index: selectedEnvironmentIndex.value.index,
|
||||
name: myEnvironments.value[selectedEnvironmentIndex.value.index].name,
|
||||
}
|
||||
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
|
||||
const teamEnv = teamEnvironmentList.value.find(
|
||||
(env) =>
|
||||
env.id ===
|
||||
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||
selectedEnvironmentIndex.value.teamEnvID)
|
||||
)
|
||||
if (teamEnv) {
|
||||
return {
|
||||
type: "TEAM_ENV",
|
||||
name: props.modelValue.environment.environment.name,
|
||||
teamEnvID: props.modelValue.environment.id,
|
||||
}
|
||||
} else {
|
||||
return { type: "global", name: "Global" }
|
||||
}
|
||||
} else {
|
||||
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
|
||||
return {
|
||||
type: "MY_ENV",
|
||||
index: selectedEnvironmentIndex.value.index,
|
||||
name: myEnvironments.value[selectedEnvironmentIndex.value.index].name,
|
||||
}
|
||||
} else if (selectedEnvironmentIndex.value.type === "TEAM_ENV") {
|
||||
const teamEnv = teamEnvironmentList.value.find(
|
||||
(env) =>
|
||||
env.id ===
|
||||
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||
selectedEnvironmentIndex.value.teamEnvID)
|
||||
)
|
||||
if (teamEnv) {
|
||||
return {
|
||||
type: "TEAM_ENV",
|
||||
name: teamEnv.environment.name,
|
||||
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
|
||||
}
|
||||
} else {
|
||||
return { type: "NO_ENV_SELECTED" }
|
||||
name: teamEnv.environment.name,
|
||||
teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
|
||||
}
|
||||
} else {
|
||||
return { type: "NO_ENV_SELECTED" }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Set the selected environment as initial scope value
|
||||
onMounted(() => {
|
||||
if (props.isScopeSelector) {
|
||||
if (
|
||||
selectedEnvironmentIndex.value.type === "MY_ENV" &&
|
||||
selectedEnvironmentIndex.value.index !== undefined
|
||||
) {
|
||||
emit("update:modelValue", {
|
||||
type: "my-environment",
|
||||
environment: myEnvironments.value[selectedEnvironmentIndex.value.index],
|
||||
index: selectedEnvironmentIndex.value.index,
|
||||
})
|
||||
} else if (
|
||||
selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||
selectedEnvironmentIndex.value.teamEnvID &&
|
||||
teamEnvironmentList.value &&
|
||||
teamEnvironmentList.value.length > 0
|
||||
) {
|
||||
const teamEnv = teamEnvironmentList.value.find(
|
||||
(env) =>
|
||||
env.id ===
|
||||
(selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
|
||||
selectedEnvironmentIndex.value.teamEnvID)
|
||||
)
|
||||
if (teamEnv) {
|
||||
emit("update:modelValue", {
|
||||
type: "team-environment",
|
||||
environment: teamEnv,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
emit("update:modelValue", {
|
||||
type: "global",
|
||||
})
|
||||
}
|
||||
} else {
|
||||
return { type: "NO_ENV_SELECTED" }
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -26,13 +26,6 @@
|
||||
:editing-variable-name="editingVariableName"
|
||||
@hide-modal="displayModalEdit(false)"
|
||||
/>
|
||||
<EnvironmentsAdd
|
||||
:show="showModalNew"
|
||||
:name="editingVariableName"
|
||||
:value="editingVariableValue"
|
||||
:position="position"
|
||||
@hide-modal="displayModalNew(false)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -168,18 +161,10 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const showModalNew = ref(false)
|
||||
const showModalDetails = ref(false)
|
||||
const action = ref<"new" | "edit">("edit")
|
||||
const editingEnvironmentIndex = ref<"Global" | null>(null)
|
||||
const editingVariableName = ref("")
|
||||
const editingVariableValue = ref("")
|
||||
|
||||
const position = ref({ top: 0, left: 0 })
|
||||
|
||||
const displayModalNew = (shouldDisplay: boolean) => {
|
||||
showModalNew.value = shouldDisplay
|
||||
}
|
||||
|
||||
const displayModalEdit = (shouldDisplay: boolean) => {
|
||||
action.value = "edit"
|
||||
@@ -248,10 +233,4 @@ watch(
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
defineActionHandler("modals.environment.add", ({ envName, variableName }) => {
|
||||
editingVariableName.value = envName
|
||||
editingVariableValue.value = variableName
|
||||
displayModalNew(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -63,7 +63,7 @@ import { GQLHistoryEntry } from "~/newstore/history"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
|
||||
import IconStar from "~icons/lucide/star"
|
||||
import IconStarOff from "~icons/hopp/star-off"
|
||||
import IconStarOff from "~icons/lucide/star-off"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import IconMinimize2 from "~icons/lucide/minimize-2"
|
||||
import IconMaximize2 from "~icons/lucide/maximize-2"
|
||||
|
||||
@@ -55,7 +55,7 @@ import { RESTHistoryEntry } from "~/newstore/history"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
|
||||
import IconStar from "~icons/lucide/star"
|
||||
import IconStarOff from "~icons/hopp/star-off"
|
||||
import IconStarOff from "~icons/lucide/star-off"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="sticky top-0 z-20 flex-none flex-shrink-0 p-4 sm:flex sm:flex-shrink-0 sm:space-x-2 bg-primary"
|
||||
class="sticky top-0 z-20 flex-none flex-shrink-0 p-4 overflow-x-auto sm:flex sm:flex-shrink-0 sm:space-x-2 bg-primary"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 border rounded min-w-52 border-divider whitespace-nowrap"
|
||||
@@ -47,14 +47,13 @@
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
|
||||
class="flex flex-1 overflow-auto transition border-l rounded-r border-divider bg-primaryLight whitespace-nowrap"
|
||||
>
|
||||
<SmartEnvInput
|
||||
v-model="tab.document.request.endpoint"
|
||||
:placeholder="`${t('request.url')}`"
|
||||
:auto-complete-source="userHistories"
|
||||
@enter="newSendRequest()"
|
||||
@paste="onPasteUrl($event)"
|
||||
@enter="newSendRequest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,7 +228,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
|
||||
import { useStreamSubscriber } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { refAutoReset, useVModel } from "@vueuse/core"
|
||||
import * as E from "fp-ts/Either"
|
||||
@@ -260,7 +259,6 @@ import IconSave from "~icons/lucide/save"
|
||||
import IconShare2 from "~icons/lucide/share-2"
|
||||
import { HoppRESTTab } from "~/helpers/rest/tab"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
|
||||
import { platform } from "~/platform"
|
||||
import { getCurrentStrategyID } from "~/helpers/network"
|
||||
|
||||
@@ -315,12 +313,6 @@ const clearAll = ref<any | null>(null)
|
||||
const copyRequestAction = ref<any | null>(null)
|
||||
const saveRequestAction = ref<any | null>(null)
|
||||
|
||||
const history = useReadonlyStream<RESTHistoryEntry[]>(restHistory$, [])
|
||||
|
||||
const userHistories = computed(() => {
|
||||
return history.value.map((history) => history.request.endpoint).slice(0, 10)
|
||||
})
|
||||
|
||||
const newSendRequest = async () => {
|
||||
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
|
||||
toast.error(`${t("empty.endpoint")}`)
|
||||
|
||||
@@ -1,44 +1,19 @@
|
||||
<template>
|
||||
<div class="autocomplete-wrapper">
|
||||
<div class="absolute inset-0 flex flex-1 overflow-x-auto">
|
||||
<div
|
||||
class="relative flex items-center flex-1 flex-shrink-0 py-4 overflow-auto whitespace-nowrap"
|
||||
>
|
||||
<div class="absolute inset-0 flex flex-1">
|
||||
<div
|
||||
ref="editor"
|
||||
:placeholder="placeholder"
|
||||
class="flex flex-1"
|
||||
:class="styles"
|
||||
@keydown.enter.prevent="emit('enter', $event)"
|
||||
@keyup="emit('keyup', $event)"
|
||||
@click="emit('click', $event)"
|
||||
@keydown="handleKeystroke"
|
||||
@focusin="showSuggestionPopover = true"
|
||||
@keydown="emit('keydown', $event)"
|
||||
></div>
|
||||
</div>
|
||||
<ul
|
||||
v-if="showSuggestionPopover && autoCompleteSource"
|
||||
ref="suggestionsMenu"
|
||||
class="suggestions"
|
||||
>
|
||||
<li
|
||||
v-for="(suggestion, index) in suggestions"
|
||||
:key="`suggestion-${index}`"
|
||||
:class="{ active: currentSuggestionIndex === index }"
|
||||
@click="updateModelValue(suggestion)"
|
||||
>
|
||||
<span class="truncate py-0.5">
|
||||
{{ suggestion }}
|
||||
</span>
|
||||
<div
|
||||
v-if="currentSuggestionIndex === index"
|
||||
class="hidden md:flex text-secondary items-center"
|
||||
>
|
||||
<kbd class="shortcut-key">TAB</kbd>
|
||||
<span class="ml-2 truncate">to select</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="suggestions.length === 0" class="pointer-events-none">
|
||||
<span class="truncate py-0.5">
|
||||
{{ t("empty.history_suggestions") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -60,9 +35,6 @@ import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironme
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
|
||||
import { platform } from "~/platform"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
import { onClickOutside, useDebounceFn } from "@vueuse/core"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -74,7 +46,6 @@ const props = withDefaults(
|
||||
selectTextOnMount?: boolean
|
||||
environmentHighlights?: boolean
|
||||
readonly?: boolean
|
||||
autoCompleteSource?: string[]
|
||||
}>(),
|
||||
{
|
||||
modelValue: "",
|
||||
@@ -84,7 +55,6 @@ const props = withDefaults(
|
||||
focus: false,
|
||||
readonly: false,
|
||||
environmentHighlights: true,
|
||||
autoCompleteSource: undefined,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -98,165 +68,12 @@ const emit = defineEmits<{
|
||||
(e: "click", ev: any): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const cachedValue = ref(props.modelValue)
|
||||
|
||||
const view = ref<EditorView>()
|
||||
|
||||
const editor = ref<any | null>(null)
|
||||
|
||||
const currentSuggestionIndex = ref(-1)
|
||||
const showSuggestionPopover = ref(false)
|
||||
|
||||
const suggestionsMenu = ref<any | null>(null)
|
||||
|
||||
onClickOutside(suggestionsMenu, () => {
|
||||
showSuggestionPopover.value = false
|
||||
})
|
||||
|
||||
//filter autocompleteSource with unique values
|
||||
const uniqueAutoCompleteSource = computed(() => {
|
||||
if (props.autoCompleteSource) {
|
||||
return [...new Set(props.autoCompleteSource)]
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const suggestions = computed(() => {
|
||||
if (
|
||||
props.modelValue &&
|
||||
props.modelValue.length > 0 &&
|
||||
uniqueAutoCompleteSource.value &&
|
||||
uniqueAutoCompleteSource.value.length > 0
|
||||
) {
|
||||
return uniqueAutoCompleteSource.value.filter((suggestion) =>
|
||||
suggestion.toLowerCase().includes(props.modelValue.toLowerCase())
|
||||
)
|
||||
} else {
|
||||
return uniqueAutoCompleteSource.value ?? []
|
||||
}
|
||||
})
|
||||
|
||||
const updateModelValue = (value: string) => {
|
||||
emit("update:modelValue", value)
|
||||
emit("change", value)
|
||||
showSuggestionPopover.value = false
|
||||
}
|
||||
|
||||
const handleKeystroke = (ev: KeyboardEvent) => {
|
||||
if (["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].includes(ev.key)) {
|
||||
ev.preventDefault()
|
||||
}
|
||||
|
||||
if (ev.shiftKey) {
|
||||
showSuggestionPopover.value = false
|
||||
return
|
||||
}
|
||||
|
||||
showSuggestionPopover.value = true
|
||||
|
||||
if (
|
||||
["Enter", "Tab"].includes(ev.key) &&
|
||||
suggestions.value.length > 0 &&
|
||||
currentSuggestionIndex.value > -1
|
||||
) {
|
||||
updateModelValue(suggestions.value[currentSuggestionIndex.value])
|
||||
currentSuggestionIndex.value = -1
|
||||
|
||||
//used to set codemirror cursor at the end of the line after selecting a suggestion
|
||||
nextTick(() => {
|
||||
view.value?.dispatch({
|
||||
selection: EditorSelection.create([
|
||||
EditorSelection.range(
|
||||
props.modelValue.length,
|
||||
props.modelValue.length
|
||||
),
|
||||
]),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowDown") {
|
||||
scrollActiveElIntoView()
|
||||
|
||||
currentSuggestionIndex.value =
|
||||
currentSuggestionIndex.value < suggestions.value.length - 1
|
||||
? currentSuggestionIndex.value + 1
|
||||
: suggestions.value.length - 1
|
||||
|
||||
emit("keydown", ev)
|
||||
}
|
||||
|
||||
if (ev.key === "ArrowUp") {
|
||||
scrollActiveElIntoView()
|
||||
|
||||
currentSuggestionIndex.value =
|
||||
currentSuggestionIndex.value - 1 >= 0
|
||||
? currentSuggestionIndex.value - 1
|
||||
: 0
|
||||
|
||||
emit("keyup", ev)
|
||||
}
|
||||
|
||||
if (ev.key === "Enter") {
|
||||
emit("enter", ev)
|
||||
showSuggestionPopover.value = false
|
||||
}
|
||||
|
||||
if (ev.key === "Escape") {
|
||||
showSuggestionPopover.value = false
|
||||
}
|
||||
|
||||
// used to scroll to the first suggestion when left arrow is pressed
|
||||
if (ev.key === "ArrowLeft") {
|
||||
if (suggestions.value.length > 0) {
|
||||
currentSuggestionIndex.value = 0
|
||||
nextTick(() => {
|
||||
scrollActiveElIntoView()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// used to scroll to the last suggestion when right arrow is pressed
|
||||
if (ev.key === "ArrowRight") {
|
||||
if (suggestions.value.length > 0) {
|
||||
currentSuggestionIndex.value = suggestions.value.length - 1
|
||||
nextTick(() => {
|
||||
scrollActiveElIntoView()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reset currentSuggestionIndex showSuggestionPopover is false
|
||||
watch(
|
||||
() => showSuggestionPopover.value,
|
||||
(newVal) => {
|
||||
if (!newVal) {
|
||||
currentSuggestionIndex.value = -1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Used to scroll the active suggestion into view
|
||||
*/
|
||||
const scrollActiveElIntoView = () => {
|
||||
const suggestionsMenuEl = suggestionsMenu.value
|
||||
if (suggestionsMenuEl) {
|
||||
const activeSuggestionEl = suggestionsMenuEl.querySelector(".active")
|
||||
if (activeSuggestionEl) {
|
||||
activeSuggestionEl.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
inline: "start",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
@@ -305,46 +122,8 @@ const envVars = computed(() =>
|
||||
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
|
||||
|
||||
const initView = (el: any) => {
|
||||
function handleTextSelection() {
|
||||
const selection = view.value?.state.selection.main
|
||||
if (selection) {
|
||||
const from = selection.from
|
||||
const to = selection.to
|
||||
const text = view.value?.state.doc.sliceString(from, to)
|
||||
const { top, left } = view.value?.coordsAtPos(from)
|
||||
if (text) {
|
||||
invokeAction("contextmenu.open", {
|
||||
position: {
|
||||
top,
|
||||
left,
|
||||
},
|
||||
text,
|
||||
})
|
||||
showSuggestionPopover.value = false
|
||||
} else {
|
||||
invokeAction("contextmenu.open", {
|
||||
position: {
|
||||
top,
|
||||
left,
|
||||
},
|
||||
text: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce to prevent double click from selecting the word
|
||||
const debounceFn = useDebounceFn(() => {
|
||||
handleTextSelection()
|
||||
}, 140)
|
||||
|
||||
el.addEventListener("mouseup", debounceFn)
|
||||
el.addEventListener("keyup", debounceFn)
|
||||
|
||||
const extensions: Extension = [
|
||||
EditorView.lineWrapping,
|
||||
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
|
||||
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (props.readonly) {
|
||||
update.view.contentDOM.inputMode = "none"
|
||||
@@ -457,49 +236,3 @@ watch(editor, () => {
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.autocomplete-wrapper {
|
||||
@apply relative;
|
||||
@apply flex;
|
||||
@apply flex-1;
|
||||
@apply flex-shrink-0;
|
||||
@apply whitespace-nowrap;
|
||||
|
||||
.suggestions {
|
||||
@apply absolute;
|
||||
@apply bg-popover;
|
||||
@apply z-50;
|
||||
@apply shadow-lg;
|
||||
@apply max-h-46;
|
||||
@apply border-b border-x border-divider;
|
||||
@apply overflow-y-auto;
|
||||
@apply -left-[1px];
|
||||
@apply -right-[1px];
|
||||
|
||||
top: calc(100% + 1px);
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
li {
|
||||
@apply flex;
|
||||
@apply items-center;
|
||||
@apply justify-between;
|
||||
@apply w-full;
|
||||
@apply py-2 px-4;
|
||||
@apply text-secondary;
|
||||
@apply cursor-pointer;
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 0 0 8px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
@apply bg-primaryDark;
|
||||
@apply text-secondaryDark;
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
class="flex flex-col flex-1"
|
||||
>
|
||||
<SmartTreeBranch
|
||||
:root-nodes-length="rootNodes.data.length"
|
||||
:node-item="rootNode"
|
||||
:adapter="adapter as SmartTreeAdapter<T>"
|
||||
>
|
||||
|
||||
@@ -85,25 +85,19 @@ const props = defineProps<{
|
||||
* The node item that will be used to render the tree branch content
|
||||
*/
|
||||
nodeItem: TreeNode<T>
|
||||
/**
|
||||
* Total number of rootNode
|
||||
*/
|
||||
rootNodesLength?: number
|
||||
}>()
|
||||
|
||||
const CHILD_SLOT_NAME = "default"
|
||||
const t = useI18n()
|
||||
|
||||
const isOnlyRootChild = computed(() => props.rootNodesLength === 1)
|
||||
|
||||
/**
|
||||
* Marks whether the children on this branch were ever rendered
|
||||
* See the usage inside '<template>' for more info
|
||||
*/
|
||||
const childrenRendered = ref(isOnlyRootChild.value)
|
||||
const childrenRendered = ref(false)
|
||||
|
||||
const showChildren = ref(isOnlyRootChild.value)
|
||||
const isNodeOpen = ref(isOnlyRootChild.value)
|
||||
const showChildren = ref(false)
|
||||
const isNodeOpen = ref(false)
|
||||
|
||||
const highlightNode = ref(false)
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ import {
|
||||
import { HoppEnvironmentPlugin } from "@helpers/editor/extensions/HoppEnvironment"
|
||||
import xmlFormat from "xml-formatter"
|
||||
import { platform } from "~/platform"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import { useDebounceFn } from "@vueuse/core"
|
||||
// TODO: Migrate from legacy mode
|
||||
|
||||
type ExtendedEditorConfig = {
|
||||
@@ -220,40 +218,6 @@ export function useCodemirror(
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
update(update: ViewUpdate) {
|
||||
function handleTextSelection() {
|
||||
const selection = view.value?.state.selection.main
|
||||
if (selection) {
|
||||
const from = selection.from
|
||||
const to = selection.to
|
||||
const text = view.value?.state.doc.sliceString(from, to)
|
||||
const { top, left } = view.value?.coordsAtPos(from)
|
||||
if (text) {
|
||||
invokeAction("contextmenu.open", {
|
||||
position: {
|
||||
top,
|
||||
left,
|
||||
},
|
||||
text,
|
||||
})
|
||||
} else {
|
||||
invokeAction("contextmenu.open", {
|
||||
position: {
|
||||
top,
|
||||
left,
|
||||
},
|
||||
text: null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce to prevent double click from selecting the word
|
||||
const debounceFn = useDebounceFn(() => {
|
||||
handleTextSelection()
|
||||
}, 140)
|
||||
|
||||
el.addEventListener("mouseup", debounceFn)
|
||||
el.addEventListener("keyup", debounceFn)
|
||||
const cursorPos = update.state.selection.main.head
|
||||
const line = update.state.doc.lineAt(cursorPos)
|
||||
|
||||
@@ -312,7 +276,6 @@ export function useCodemirror(
|
||||
run: indentLess,
|
||||
},
|
||||
]),
|
||||
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
|
||||
]
|
||||
|
||||
if (environmentTooltip) extensions.push(environmentTooltip.extension)
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
|
||||
import { Ref, onBeforeUnmount, onMounted, watch } from "vue"
|
||||
import { BehaviorSubject } from "rxjs"
|
||||
import { HoppRESTDocument } from "./rest/document"
|
||||
import { HoppGQLRequest } from "@hoppscotch/data"
|
||||
|
||||
export type HoppAction =
|
||||
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
|
||||
| "request.send-cancel" // Send/Cancel a Hoppscotch Request
|
||||
| "request.reset" // Clear request data
|
||||
| "request.copy-link" // Copy Request Link
|
||||
@@ -25,7 +22,6 @@ export type HoppAction =
|
||||
| "modals.search.toggle" // Shows the search modal
|
||||
| "modals.support.toggle" // Shows the support modal
|
||||
| "modals.share.toggle" // Shows the share modal
|
||||
| "modals.environment.add" // Show add environment modal via context menu
|
||||
| "modals.my.environment.edit" // Edit current personal environment
|
||||
| "modals.team.environment.edit" // Edit current team environment
|
||||
| "navigation.jump.rest" // Jump to REST page
|
||||
@@ -57,14 +53,7 @@ export type HoppAction =
|
||||
* NOTE: We can't enforce type checks to make sure the key is Action, you
|
||||
* will know if you got something wrong if there is a type error in this file
|
||||
*/
|
||||
type HoppActionArgsMap = {
|
||||
"contextmenu.open": {
|
||||
position: {
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
text: string | null
|
||||
}
|
||||
type HoppActionArgs = {
|
||||
"modals.my.environment.edit": {
|
||||
envName: string
|
||||
variableName: string
|
||||
@@ -73,22 +62,12 @@ type HoppActionArgsMap = {
|
||||
envName: string
|
||||
variableName: string
|
||||
}
|
||||
"rest.request.open": {
|
||||
doc: HoppRESTDocument
|
||||
}
|
||||
"gql.request.open": {
|
||||
request: HoppGQLRequest
|
||||
}
|
||||
"modals.environment.add": {
|
||||
envName: string
|
||||
variableName: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HoppActions which require arguments for their invocation
|
||||
*/
|
||||
export type HoppActionWithArgs = keyof HoppActionArgsMap
|
||||
type HoppActionWithArgs = keyof HoppActionArgs
|
||||
|
||||
/**
|
||||
* HoppActions which do not require arguments for their invocation
|
||||
@@ -98,27 +77,27 @@ export type HoppActionWithNoArgs = Exclude<HoppAction, HoppActionWithArgs>
|
||||
/**
|
||||
* Resolves the argument type for a given HoppAction
|
||||
*/
|
||||
type ArgOfHoppAction<A extends HoppAction | HoppActionWithArgs> =
|
||||
A extends HoppActionWithArgs ? HoppActionArgsMap[A] : undefined
|
||||
type ArgOfHoppAction<A extends HoppAction> = A extends HoppActionWithArgs
|
||||
? HoppActionArgs[A]
|
||||
: undefined
|
||||
|
||||
/**
|
||||
* Resolves the action function for a given HoppAction, used by action handler function defs
|
||||
*/
|
||||
type ActionFunc<A extends HoppAction | HoppActionWithArgs> =
|
||||
A extends HoppActionWithArgs ? (arg: ArgOfHoppAction<A>) => void : () => void
|
||||
type ActionFunc<A extends HoppAction> = A extends HoppActionWithArgs
|
||||
? (arg: ArgOfHoppAction<A>) => void
|
||||
: () => void
|
||||
|
||||
type BoundActionList = {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
[A in HoppAction | HoppActionWithArgs]?: Array<ActionFunc<A>>
|
||||
[A in HoppAction]?: Array<ActionFunc<A>>
|
||||
}
|
||||
|
||||
const boundActions: BoundActionList = {}
|
||||
|
||||
export const activeActions$ = new BehaviorSubject<
|
||||
(HoppAction | HoppActionWithArgs)[]
|
||||
>([])
|
||||
export const activeActions$ = new BehaviorSubject<HoppAction[]>([])
|
||||
|
||||
export function bindAction<A extends HoppAction | HoppActionWithArgs>(
|
||||
export function bindAction<A extends HoppAction>(
|
||||
action: A,
|
||||
handler: ActionFunc<A>
|
||||
) {
|
||||
@@ -134,7 +113,7 @@ export function bindAction<A extends HoppAction | HoppActionWithArgs>(
|
||||
|
||||
type InvokeActionFunc = {
|
||||
(action: HoppActionWithNoArgs, args?: undefined): void
|
||||
<A extends HoppActionWithArgs>(action: A, args: HoppActionArgsMap[A]): void
|
||||
<A extends HoppActionWithArgs>(action: A, args: ArgOfHoppAction<A>): void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,16 +122,14 @@ type InvokeActionFunc = {
|
||||
* @param action The action to fire
|
||||
* @param args The argument passed to the action handler. Optional if action has no args required
|
||||
*/
|
||||
export const invokeAction: InvokeActionFunc = <
|
||||
A extends HoppAction | HoppActionWithArgs
|
||||
>(
|
||||
export const invokeAction: InvokeActionFunc = <A extends HoppAction>(
|
||||
action: A,
|
||||
args: ArgOfHoppAction<A>
|
||||
) => {
|
||||
boundActions[action]?.forEach((handler) => handler(args! as any))
|
||||
boundActions[action]?.forEach((handler) => handler(args!))
|
||||
}
|
||||
|
||||
export function unbindAction<A extends HoppAction | HoppActionWithArgs>(
|
||||
export function unbindAction<A extends HoppAction>(
|
||||
action: A,
|
||||
handler: ActionFunc<A>
|
||||
) {
|
||||
@@ -176,22 +153,19 @@ export function unbindAction<A extends HoppAction | HoppActionWithArgs>(
|
||||
* @param handler The function to be called when the action is invoked
|
||||
* @param isActive A ref that indicates whether the action is active
|
||||
*/
|
||||
export function defineActionHandler<A extends HoppAction | HoppActionWithArgs>(
|
||||
export function defineActionHandler<A extends HoppAction>(
|
||||
action: A,
|
||||
handler: ActionFunc<A>,
|
||||
isActive: Ref<boolean> | undefined = undefined
|
||||
) {
|
||||
let mounted = false
|
||||
let bound = false
|
||||
let bound = true
|
||||
|
||||
onMounted(() => {
|
||||
mounted = true
|
||||
bound = true
|
||||
|
||||
// Only bind if isActive is undefined or true
|
||||
if (isActive === undefined || isActive.value === true) {
|
||||
bound = true
|
||||
bindAction(action, handler)
|
||||
}
|
||||
bindAction(action, handler)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -202,23 +176,19 @@ export function defineActionHandler<A extends HoppAction | HoppActionWithArgs>(
|
||||
})
|
||||
|
||||
if (isActive) {
|
||||
watch(
|
||||
isActive,
|
||||
(active) => {
|
||||
if (mounted) {
|
||||
if (active) {
|
||||
if (!bound) {
|
||||
bound = true
|
||||
bindAction(action, handler)
|
||||
}
|
||||
} else if (bound) {
|
||||
bound = false
|
||||
|
||||
unbindAction(action, handler)
|
||||
watch(isActive, (active) => {
|
||||
if (mounted) {
|
||||
if (active) {
|
||||
if (!bound) {
|
||||
bound = true
|
||||
bindAction(action, handler)
|
||||
}
|
||||
} else if (bound) {
|
||||
bound = false
|
||||
|
||||
unbindAction(action, handler)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,8 @@ export const bindings: {
|
||||
"alt-p": "request.method.post",
|
||||
"alt-u": "request.method.put",
|
||||
"alt-x": "request.method.delete",
|
||||
"ctrl-k": "modals.search.toggle",
|
||||
"ctrl-/": "flyouts.keybinds.toggle",
|
||||
"ctrl-k": "flyouts.keybinds.toggle",
|
||||
"/": "modals.search.toggle",
|
||||
"?": "modals.support.toggle",
|
||||
"ctrl-m": "modals.share.toggle",
|
||||
"alt-r": "navigation.jump.rest",
|
||||
|
||||
@@ -16,12 +16,12 @@ export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
|
||||
},
|
||||
{
|
||||
label: t("shortcut.general.command_menu"),
|
||||
keys: [getPlatformSpecialKey(), "K"],
|
||||
keys: ["/"],
|
||||
section: t("shortcut.general.title"),
|
||||
},
|
||||
{
|
||||
label: t("shortcut.general.show_all"),
|
||||
keys: [getPlatformSpecialKey(), "/"],
|
||||
keys: [getPlatformSpecialKey(), "K"],
|
||||
section: t("shortcut.general.title"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
import { usePageHead } from "@composables/head"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { GQLConnection } from "@helpers/GQLConnection"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { computed, onBeforeUnmount } from "vue"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
@@ -35,14 +32,4 @@ onBeforeUnmount(() => {
|
||||
gqlConn.disconnect()
|
||||
}
|
||||
})
|
||||
|
||||
defineActionHandler("gql.request.open", ({ request }) => {
|
||||
const session = getGQLSession()
|
||||
|
||||
setGQLSession({
|
||||
request: cloneDeep(request),
|
||||
schema: session.schema,
|
||||
response: session.response,
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -84,13 +84,6 @@
|
||||
:show="savingRequest"
|
||||
@hide-modal="onSaveModalClose"
|
||||
/>
|
||||
<AppContextMenu
|
||||
v-if="contextMenu.show"
|
||||
:show="contextMenu.show"
|
||||
:position="contextMenu.position"
|
||||
:text="contextMenu.text"
|
||||
@hide-modal="contextMenu.show = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -115,7 +108,7 @@ import {
|
||||
updateTabOrdering,
|
||||
} from "~/helpers/rest/tab"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { defineActionHandler, invokeAction } from "~/helpers/actions"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import { onLoggedIn } from "~/composables/auth"
|
||||
import { platform } from "~/platform"
|
||||
import {
|
||||
@@ -145,24 +138,6 @@ const reqName = ref<string>("")
|
||||
const t = useI18n()
|
||||
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 confirmSync = useReadonlyStream(currentSyncingStatus$, {
|
||||
@@ -390,27 +365,7 @@ function oAuthURL() {
|
||||
})
|
||||
}
|
||||
|
||||
defineActionHandler("contextmenu.open", ({ position, text }) => {
|
||||
if (text) {
|
||||
contextMenu.value = {
|
||||
show: true,
|
||||
position,
|
||||
text,
|
||||
}
|
||||
} else {
|
||||
contextMenu.value = {
|
||||
show: false,
|
||||
position,
|
||||
text,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setupTabStateSync()
|
||||
bindRequestToURLParams()
|
||||
oAuthURL()
|
||||
|
||||
defineActionHandler("rest.request.open", ({ doc }) => {
|
||||
createNewTab(doc)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest"
|
||||
import { ContextMenu, ContextMenuResult, ContextMenuService } from "../"
|
||||
import { TestContainer } from "dioc/testing"
|
||||
|
||||
const contextMenuResult: ContextMenuResult[] = [
|
||||
{
|
||||
id: "result1",
|
||||
text: { type: "text", text: "Sample Text" },
|
||||
icon: {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
action: () => {},
|
||||
},
|
||||
]
|
||||
|
||||
const testMenu: ContextMenu = {
|
||||
menuID: "menu1",
|
||||
getMenuFor: () => {
|
||||
return {
|
||||
results: contextMenuResult,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
describe("ContextMenuService", () => {
|
||||
describe("registerMenu", () => {
|
||||
it("should register a menu", () => {
|
||||
const container = new TestContainer()
|
||||
const service = container.bind(ContextMenuService)
|
||||
|
||||
service.registerMenu(testMenu)
|
||||
|
||||
const result = service.getMenuFor("text")
|
||||
|
||||
expect(result).toContainEqual(expect.objectContaining({ id: "result1" }))
|
||||
})
|
||||
|
||||
it("should not register a menu twice", () => {
|
||||
const container = new TestContainer()
|
||||
const service = container.bind(ContextMenuService)
|
||||
|
||||
service.registerMenu(testMenu)
|
||||
service.registerMenu(testMenu)
|
||||
|
||||
const result = service.getMenuFor("text")
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should register multiple menus", () => {
|
||||
const container = new TestContainer()
|
||||
const service = container.bind(ContextMenuService)
|
||||
|
||||
const testMenu2: ContextMenu = {
|
||||
menuID: "menu2",
|
||||
getMenuFor: () => {
|
||||
return {
|
||||
results: contextMenuResult,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
service.registerMenu(testMenu)
|
||||
service.registerMenu(testMenu2)
|
||||
|
||||
const result = service.getMenuFor("text")
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMenuFor", () => {
|
||||
it("should get the menu", () => {
|
||||
const sampleMenus = {
|
||||
results: contextMenuResult,
|
||||
}
|
||||
|
||||
const container = new TestContainer()
|
||||
const service = container.bind(ContextMenuService)
|
||||
|
||||
service.registerMenu(testMenu)
|
||||
|
||||
const results = service.getMenuFor("sometext")
|
||||
|
||||
expect(results).toEqual(sampleMenus.results)
|
||||
})
|
||||
|
||||
it("calls registered menus with correct value", () => {
|
||||
const container = new TestContainer()
|
||||
const service = container.bind(ContextMenuService)
|
||||
|
||||
const testMenu2: ContextMenu = {
|
||||
menuID: "some-id",
|
||||
getMenuFor: vi.fn(() => ({
|
||||
results: contextMenuResult,
|
||||
})),
|
||||
}
|
||||
|
||||
service.registerMenu(testMenu2)
|
||||
|
||||
service.getMenuFor("sometext")
|
||||
|
||||
expect(testMenu2.getMenuFor).toHaveBeenCalledWith("sometext")
|
||||
})
|
||||
|
||||
it("should return empty array if no menus are registered", () => {
|
||||
const container = new TestContainer()
|
||||
const service = container.bind(ContextMenuService)
|
||||
|
||||
const results = service.getMenuFor("sometext")
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Service } from "dioc"
|
||||
import { Component } from "vue"
|
||||
|
||||
/**
|
||||
* Defines how to render the text in a Context Menu Search Result
|
||||
*/
|
||||
export type ContextMenuTextType<T extends object | Component = never> =
|
||||
| {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: "custom"
|
||||
/**
|
||||
* The component to render in place of the text
|
||||
*/
|
||||
component: T
|
||||
|
||||
/**
|
||||
* The props to pass to the component
|
||||
*/
|
||||
componentProps: T extends Component<infer Props> ? Props : never
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines info about a context menu result so the UI can render it
|
||||
*/
|
||||
export interface ContextMenuResult {
|
||||
/**
|
||||
* The unique ID of the result
|
||||
*/
|
||||
id: string
|
||||
/**
|
||||
* The text to render in the result
|
||||
*/
|
||||
text: ContextMenuTextType<any>
|
||||
/**
|
||||
* The icon to render as the signifier of the result
|
||||
*/
|
||||
icon: object | Component
|
||||
/**
|
||||
* The action to perform when the result is selected
|
||||
*/
|
||||
action: () => void
|
||||
/**
|
||||
* Additional metadata about the result
|
||||
*/
|
||||
meta?: {
|
||||
/**
|
||||
* The keyboard shortcut to trigger the result
|
||||
*/
|
||||
keyboardShortcut?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the state of a context menu
|
||||
*/
|
||||
export type ContextMenuState = {
|
||||
results: ContextMenuResult[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a context menu
|
||||
*/
|
||||
export interface ContextMenu {
|
||||
/**
|
||||
* The unique ID of the context menu
|
||||
* This is used to identify the context menu
|
||||
*/
|
||||
menuID: string
|
||||
/**
|
||||
* Gets the context menu for the given text
|
||||
* @param text The text to get the context menu for
|
||||
* @returns The context menu state
|
||||
*/
|
||||
getMenuFor: (text: string) => ContextMenuState
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the context menu service
|
||||
* This service is used to register context menus and get context menus for text
|
||||
* This service is used by the context menu UI
|
||||
*/
|
||||
export class ContextMenuService extends Service {
|
||||
public static readonly ID = "CONTEXT_MENU_SERVICE"
|
||||
|
||||
private menus: Map<string, ContextMenu> = new Map()
|
||||
|
||||
/**
|
||||
* Registers a menu with the context menu service
|
||||
* @param menu The menu to register
|
||||
*/
|
||||
public registerMenu(menu: ContextMenu) {
|
||||
this.menus.set(menu.menuID, menu)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the context menu for the given text
|
||||
* @param text The text to get the context menu for
|
||||
*/
|
||||
public getMenuFor(text: string): ContextMenuResult[] {
|
||||
const menus = Array.from(this.menus.values()).map((x) => x.getMenuFor(text))
|
||||
|
||||
const result = menus.flatMap((x) => x.results)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { TestContainer } from "dioc/testing"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { EnvironmentMenuService } from "../environment.menu"
|
||||
import { ContextMenuService } from "../.."
|
||||
|
||||
vi.mock("~/modules/i18n", () => ({
|
||||
__esModule: true,
|
||||
getI18n: () => (x: string) => x,
|
||||
}))
|
||||
|
||||
const actionsMock = vi.hoisted(() => ({
|
||||
invokeAction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("~/helpers/actions", async () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
invokeAction: actionsMock.invokeAction,
|
||||
}
|
||||
})
|
||||
|
||||
describe("EnvironmentMenuService", () => {
|
||||
it("registers with the contextmenu service upon initialization", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const registerContextMenuFn = vi.fn()
|
||||
|
||||
container.bindMock(ContextMenuService, {
|
||||
registerMenu: registerContextMenuFn,
|
||||
})
|
||||
|
||||
const environment = container.bind(EnvironmentMenuService)
|
||||
|
||||
expect(registerContextMenuFn).toHaveBeenCalledOnce()
|
||||
expect(registerContextMenuFn).toHaveBeenCalledWith(environment)
|
||||
})
|
||||
|
||||
describe("getMenuFor", () => {
|
||||
it("should return a menu for adding environment", () => {
|
||||
const container = new TestContainer()
|
||||
const environment = container.bind(EnvironmentMenuService)
|
||||
|
||||
const test = "some-text"
|
||||
const result = environment.getMenuFor(test)
|
||||
|
||||
expect(result.results).toContainEqual(
|
||||
expect.objectContaining({ id: "environment" })
|
||||
)
|
||||
})
|
||||
|
||||
it("should invoke the add environment modal", () => {
|
||||
const container = new TestContainer()
|
||||
const environment = container.bind(EnvironmentMenuService)
|
||||
|
||||
const test = "some-text"
|
||||
const result = environment.getMenuFor(test)
|
||||
|
||||
const action = result.results[0].action
|
||||
action()
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledWith(
|
||||
"modals.environment.add",
|
||||
{
|
||||
envName: "test",
|
||||
variableName: test,
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
import { TestContainer } from "dioc/testing"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { ContextMenuService } from "../.."
|
||||
import { ParameterMenuService } from "../parameter.menu"
|
||||
|
||||
//regex containing both url and parameter
|
||||
const urlAndParameterRegex = new RegExp("[^&?]*?=[^&?]*")
|
||||
|
||||
vi.mock("~/modules/i18n", () => ({
|
||||
__esModule: true,
|
||||
getI18n: () => (x: string) => x,
|
||||
}))
|
||||
|
||||
const tabMock = vi.hoisted(() => ({
|
||||
currentActiveTab: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("~/helpers/rest/tab", () => ({
|
||||
__esModule: true,
|
||||
currentActiveTab: tabMock.currentActiveTab,
|
||||
}))
|
||||
|
||||
describe("ParameterMenuService", () => {
|
||||
it("registers with the contextmenu service upon initialization", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const registerContextMenuFn = vi.fn()
|
||||
|
||||
container.bindMock(ContextMenuService, {
|
||||
registerMenu: registerContextMenuFn,
|
||||
})
|
||||
|
||||
const parameter = container.bind(ParameterMenuService)
|
||||
|
||||
expect(registerContextMenuFn).toHaveBeenCalledOnce()
|
||||
expect(registerContextMenuFn).toHaveBeenCalledWith(parameter)
|
||||
|
||||
describe("getMenuFor", () => {
|
||||
it("validating if the text passes the regex and return the menu", () => {
|
||||
const container = new TestContainer()
|
||||
const parameter = container.bind(ParameterMenuService)
|
||||
|
||||
const test = "https://hoppscotch.io?id=some-text"
|
||||
const result = parameter.getMenuFor(test)
|
||||
|
||||
if (test.match(urlAndParameterRegex)) {
|
||||
expect(result.results).toContainEqual(
|
||||
expect.objectContaining({ id: "parameter" })
|
||||
)
|
||||
} else {
|
||||
expect(result.results).not.toContainEqual(
|
||||
expect.objectContaining({ id: "parameter" })
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("should call the addParameter function when action is called", () => {
|
||||
const addParameterFn = vi.fn()
|
||||
|
||||
const container = new TestContainer()
|
||||
const environment = container.bind(ParameterMenuService)
|
||||
|
||||
const test = "https://hoppscotch.io"
|
||||
|
||||
const result = environment.getMenuFor(test)
|
||||
|
||||
const action = result.results[0].action
|
||||
|
||||
action()
|
||||
|
||||
expect(addParameterFn).toHaveBeenCalledOnce()
|
||||
expect(addParameterFn).toHaveBeenCalledWith(action)
|
||||
})
|
||||
|
||||
it("should call the extractParams function when addParameter function is called", () => {
|
||||
const extractParamsFn = vi.fn()
|
||||
|
||||
const container = new TestContainer()
|
||||
const environment = container.bind(ParameterMenuService)
|
||||
|
||||
const test = "https://hoppscotch.io"
|
||||
|
||||
const result = environment.getMenuFor(test)
|
||||
|
||||
const action = result.results[0].action
|
||||
|
||||
action()
|
||||
|
||||
expect(extractParamsFn).toHaveBeenCalledOnce()
|
||||
expect(extractParamsFn).toHaveBeenCalledWith(action)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { TestContainer } from "dioc/testing"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { ContextMenuService } from "../.."
|
||||
import { URLMenuService } from "../url.menu"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
|
||||
vi.mock("~/modules/i18n", () => ({
|
||||
__esModule: true,
|
||||
getI18n: () => (x: string) => x,
|
||||
}))
|
||||
|
||||
const tabMock = vi.hoisted(() => ({
|
||||
createNewTab: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("~/helpers/rest/tab", () => ({
|
||||
__esModule: true,
|
||||
createNewTab: tabMock.createNewTab,
|
||||
}))
|
||||
|
||||
describe("URLMenuService", () => {
|
||||
it("registers with the contextmenu service upon initialization", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const registerContextMenuFn = vi.fn()
|
||||
|
||||
container.bindMock(ContextMenuService, {
|
||||
registerMenu: registerContextMenuFn,
|
||||
})
|
||||
|
||||
const environment = container.bind(URLMenuService)
|
||||
|
||||
expect(registerContextMenuFn).toHaveBeenCalledOnce()
|
||||
expect(registerContextMenuFn).toHaveBeenCalledWith(environment)
|
||||
})
|
||||
|
||||
describe("getMenuFor", () => {
|
||||
it("validating if the text passes the regex and return the menu", () => {
|
||||
function isValidURL(url: string) {
|
||||
try {
|
||||
new URL(url)
|
||||
return true
|
||||
} catch (error) {
|
||||
// Fallback to regular expression check
|
||||
const pattern = /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/
|
||||
return pattern.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
const container = new TestContainer()
|
||||
const url = container.bind(URLMenuService)
|
||||
|
||||
const test = ""
|
||||
const result = url.getMenuFor(test)
|
||||
|
||||
if (isValidURL(test)) {
|
||||
expect(result.results).toContainEqual(
|
||||
expect.objectContaining({ id: "link-tab" })
|
||||
)
|
||||
} else {
|
||||
expect(result).toEqual({ results: [] })
|
||||
}
|
||||
})
|
||||
|
||||
it("should call the openNewTab function when action is called and a new hoppscotch tab is opened", () => {
|
||||
const container = new TestContainer()
|
||||
const url = container.bind(URLMenuService)
|
||||
|
||||
const test = "https://hoppscotch.io"
|
||||
const result = url.getMenuFor(test)
|
||||
|
||||
result.results[0].action()
|
||||
|
||||
const request = {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: test,
|
||||
}
|
||||
|
||||
expect(tabMock.createNewTab).toHaveBeenCalledOnce()
|
||||
expect(tabMock.createNewTab).toHaveBeenCalledWith({
|
||||
request: request,
|
||||
isDirty: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Service } from "dioc"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuResult,
|
||||
ContextMenuService,
|
||||
ContextMenuState,
|
||||
} from "../"
|
||||
import { markRaw, ref } from "vue"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import IconPlusCircle from "~icons/lucide/plus-circle"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
|
||||
/**
|
||||
* This menu returns a single result that allows the user
|
||||
* to add the selected text as an environment variable
|
||||
* This menus is shown on all text selections
|
||||
*/
|
||||
export class EnvironmentMenuService extends Service implements ContextMenu {
|
||||
public static readonly ID = "ENVIRONMENT_CONTEXT_MENU_SERVICE"
|
||||
|
||||
private t = getI18n()
|
||||
|
||||
public readonly menuID = "environment"
|
||||
|
||||
private readonly contextMenu = this.bind(ContextMenuService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.contextMenu.registerMenu(this)
|
||||
}
|
||||
|
||||
getMenuFor(text: Readonly<string>): ContextMenuState {
|
||||
const results = ref<ContextMenuResult[]>([])
|
||||
results.value = [
|
||||
{
|
||||
id: "environment",
|
||||
text: {
|
||||
type: "text",
|
||||
text: this.t("context_menu.set_environment_variable"),
|
||||
},
|
||||
icon: markRaw(IconPlusCircle),
|
||||
action: () => {
|
||||
invokeAction("modals.environment.add", {
|
||||
envName: "test",
|
||||
variableName: text,
|
||||
})
|
||||
},
|
||||
},
|
||||
]
|
||||
const resultObj = <ContextMenuState>{
|
||||
results: results.value,
|
||||
}
|
||||
return resultObj
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Service } from "dioc"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuResult,
|
||||
ContextMenuService,
|
||||
ContextMenuState,
|
||||
} from "../"
|
||||
import { markRaw, ref } from "vue"
|
||||
import IconArrowDownRight from "~icons/lucide/arrow-down-right"
|
||||
import { currentActiveTab } from "~/helpers/rest/tab"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
|
||||
//regex containing both url and parameter
|
||||
const urlAndParameterRegex = new RegExp("[^&?]*?=[^&?]*")
|
||||
|
||||
interface Param {
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The extracted parameters from the input
|
||||
* with the new URL if it was provided
|
||||
*/
|
||||
interface ExtractedParams {
|
||||
params: Param
|
||||
newURL?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* This menu returns a single result that allows the user
|
||||
* to add the selected text as a parameter
|
||||
* if the selected text is a valid URL
|
||||
*/
|
||||
export class ParameterMenuService extends Service implements ContextMenu {
|
||||
public static readonly ID = "PARAMETER_CONTEXT_MENU_SERVICE"
|
||||
|
||||
private t = getI18n()
|
||||
|
||||
public readonly menuID = "parameter"
|
||||
|
||||
private readonly contextMenu = this.bind(ContextMenuService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.contextMenu.registerMenu(this)
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param input The input to extract the parameters from
|
||||
* @returns The extracted parameters and the new URL if it was provided
|
||||
*/
|
||||
private extractParams(input: string): ExtractedParams {
|
||||
let text = input
|
||||
let newURL: string | undefined
|
||||
|
||||
// if the input is a URL, extract the parameters
|
||||
if (text.startsWith("http")) {
|
||||
const url = new URL(text)
|
||||
newURL = url.origin + url.pathname
|
||||
text = url.search.slice(1)
|
||||
}
|
||||
|
||||
const regex = /(\w+)=(\w+)/g
|
||||
const matches = text.matchAll(regex)
|
||||
const params: Param = {}
|
||||
|
||||
// extract the parameters from the input
|
||||
for (const match of matches) {
|
||||
const [, key, value] = match
|
||||
params[key] = value
|
||||
}
|
||||
|
||||
return { params, newURL }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the parameters from the input to the current request
|
||||
* parameters and updates the endpoint if a new URL was provided
|
||||
* @param text The input to extract the parameters from
|
||||
*/
|
||||
private addParameter(text: string) {
|
||||
const { params, newURL } = this.extractParams(text)
|
||||
|
||||
const queryParams = []
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
queryParams.push({ key, value, active: true })
|
||||
}
|
||||
|
||||
// add the parameters to the current request parameters
|
||||
currentActiveTab.value.document.request.params = [
|
||||
...currentActiveTab.value.document.request.params,
|
||||
...queryParams,
|
||||
]
|
||||
|
||||
if (newURL) {
|
||||
currentActiveTab.value.document.request.endpoint = newURL
|
||||
} else {
|
||||
// remove the parameter from the URL
|
||||
const textRegex = new RegExp(`\\b${text.replace(/\?/g, "")}\\b`, "gi")
|
||||
const sanitizedWord = currentActiveTab.value.document.request.endpoint
|
||||
const newURL = sanitizedWord.replace(textRegex, "")
|
||||
currentActiveTab.value.document.request.endpoint = newURL
|
||||
}
|
||||
}
|
||||
|
||||
getMenuFor(text: Readonly<string>): ContextMenuState {
|
||||
const results = ref<ContextMenuResult[]>([])
|
||||
|
||||
if (urlAndParameterRegex.test(text)) {
|
||||
results.value = [
|
||||
{
|
||||
id: "environment",
|
||||
text: {
|
||||
type: "text",
|
||||
text: this.t("context_menu.add_parameter"),
|
||||
},
|
||||
icon: markRaw(IconArrowDownRight),
|
||||
action: () => {
|
||||
this.addParameter(text)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const resultObj = <ContextMenuState>{
|
||||
results: results.value,
|
||||
}
|
||||
|
||||
return resultObj
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Service } from "dioc"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuResult,
|
||||
ContextMenuService,
|
||||
ContextMenuState,
|
||||
} from ".."
|
||||
import { markRaw, ref } from "vue"
|
||||
import IconCopyPlus from "~icons/lucide/copy-plus"
|
||||
import { createNewTab } from "~/helpers/rest/tab"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
|
||||
/**
|
||||
* Used to check if a string is a valid URL
|
||||
* @param url The string to check
|
||||
* @returns Whether the string is a valid URL
|
||||
*/
|
||||
function isValidURL(url: string) {
|
||||
try {
|
||||
// Try to create a URL object
|
||||
// this will fail for endpoints like "localhost:3000", ie without a protocol
|
||||
new URL(url)
|
||||
return true
|
||||
} catch (error) {
|
||||
// Fallback to regular expression check
|
||||
const pattern = /^(https?:\/\/)?([\w.-]+)(\.[\w.-]+)+([/?].*)?$/
|
||||
return pattern.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
export class URLMenuService extends Service implements ContextMenu {
|
||||
public static readonly ID = "URL_CONTEXT_MENU_SERVICE"
|
||||
|
||||
private t = getI18n()
|
||||
|
||||
public readonly menuID = "url"
|
||||
|
||||
private readonly contextMenu = this.bind(ContextMenuService)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.contextMenu.registerMenu(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a new tab with the provided URL
|
||||
* @param url The URL to open
|
||||
*/
|
||||
private openNewTab(url: string) {
|
||||
//create a new request object
|
||||
const request = {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: url,
|
||||
}
|
||||
|
||||
createNewTab({
|
||||
request: request,
|
||||
isDirty: false,
|
||||
})
|
||||
}
|
||||
|
||||
getMenuFor(text: Readonly<string>): ContextMenuState {
|
||||
const results = ref<ContextMenuResult[]>([])
|
||||
|
||||
if (isValidURL(text)) {
|
||||
results.value = [
|
||||
{
|
||||
id: "link-tab",
|
||||
text: {
|
||||
type: "text",
|
||||
text: this.t("context_menu.open_link_in_new_tab"),
|
||||
},
|
||||
icon: markRaw(IconCopyPlus),
|
||||
action: () => {
|
||||
this.openNewTab(text)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const resultObj = <ContextMenuState>{
|
||||
results: results.value,
|
||||
}
|
||||
|
||||
return resultObj
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { HistorySpotlightSearcherService } from "../history.searcher"
|
||||
import { nextTick, ref } from "vue"
|
||||
import { SpotlightService } from "../.."
|
||||
import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history"
|
||||
import { RESTHistoryEntry } from "~/newstore/history"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { HoppAction, HoppActionWithArgs } from "~/helpers/actions"
|
||||
import { defaultGQLSession } from "~/newstore/GQLSession"
|
||||
|
||||
async function flushPromises() {
|
||||
return await new Promise((r) => setTimeout(r))
|
||||
@@ -27,7 +25,7 @@ vi.mock("~/modules/i18n", () => ({
|
||||
}))
|
||||
|
||||
const actionsMock = vi.hoisted(() => ({
|
||||
value: [] as (HoppAction | HoppActionWithArgs)[],
|
||||
value: [] as string[],
|
||||
invokeAction: vi.fn(),
|
||||
}))
|
||||
|
||||
@@ -42,20 +40,14 @@ vi.mock("~/helpers/actions", async () => {
|
||||
})
|
||||
|
||||
const historyMock = vi.hoisted(() => ({
|
||||
restEntries: [] as RESTHistoryEntry[],
|
||||
gqlEntries: [] as GQLHistoryEntry[],
|
||||
entries: [] as RESTHistoryEntry[],
|
||||
}))
|
||||
|
||||
vi.mock("~/newstore/history", () => ({
|
||||
__esModule: true,
|
||||
restHistoryStore: {
|
||||
value: {
|
||||
state: historyMock.restEntries,
|
||||
},
|
||||
},
|
||||
graphqlHistoryStore: {
|
||||
value: {
|
||||
state: historyMock.gqlEntries,
|
||||
state: historyMock.entries,
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -67,9 +59,9 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
x = actionsMock.value.pop()
|
||||
}
|
||||
|
||||
let y = historyMock.restEntries.pop()
|
||||
let y = historyMock.entries.pop()
|
||||
while (y) {
|
||||
y = historyMock.restEntries.pop()
|
||||
y = historyMock.entries.pop()
|
||||
}
|
||||
|
||||
actionsMock.invokeAction.mockReset()
|
||||
@@ -144,10 +136,8 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledWith("history.clear")
|
||||
})
|
||||
|
||||
it("returns all the valid rest history entries for the search term", async () => {
|
||||
actionsMock.value.push("rest.request.open")
|
||||
|
||||
historyMock.restEntries.push({
|
||||
it("returns all the valid history entries for the search term", async () => {
|
||||
historyMock.entries.push({
|
||||
request: {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "bla.com",
|
||||
@@ -172,22 +162,20 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
|
||||
expect(result.value.results).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "rest-0",
|
||||
id: "0",
|
||||
text: {
|
||||
type: "custom",
|
||||
component: expect.anything(),
|
||||
componentProps: expect.objectContaining({
|
||||
historyEntry: historyMock.restEntries[0],
|
||||
historyEntry: historyMock.entries[0],
|
||||
}),
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("selecting a rest history entry invokes action to open the rest request", async () => {
|
||||
actionsMock.value.push("rest.request.open")
|
||||
|
||||
const historyEntry: RESTHistoryEntry = {
|
||||
it("selecting a history entry asks the tab system to open a new tab", async () => {
|
||||
historyMock.entries.push({
|
||||
request: {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "bla.com",
|
||||
@@ -199,9 +187,7 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
}
|
||||
|
||||
historyMock.restEntries.push(historyEntry)
|
||||
})
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
@@ -216,229 +202,10 @@ describe("HistorySpotlightSearcherService", () => {
|
||||
|
||||
history.onResultSelect(doc)
|
||||
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledWith("rest.request.open", {
|
||||
doc: {
|
||||
request: historyEntry.request,
|
||||
isDirty: false,
|
||||
},
|
||||
expect(tabMock.createNewTab).toHaveBeenCalledOnce()
|
||||
expect(tabMock.createNewTab).toHaveBeenCalledWith({
|
||||
request: historyMock.entries[0].request,
|
||||
isDirty: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("returns all the valid graphql history entries for the search term", async () => {
|
||||
actionsMock.value.push("gql.request.open")
|
||||
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "gql-0",
|
||||
text: {
|
||||
type: "custom",
|
||||
component: expect.anything(),
|
||||
componentProps: expect.objectContaining({
|
||||
historyEntry: historyMock.gqlEntries[0],
|
||||
}),
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("selecting a graphql history entry invokes action to open the graphql request", async () => {
|
||||
actionsMock.value.push("gql.request.open")
|
||||
|
||||
const historyEntry: GQLHistoryEntry = {
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
}
|
||||
|
||||
historyMock.gqlEntries.push(historyEntry)
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
const doc = result.value.results[0]
|
||||
|
||||
history.onResultSelect(doc)
|
||||
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
|
||||
expect(actionsMock.invokeAction).toHaveBeenCalledWith("gql.request.open", {
|
||||
request: historyEntry.request,
|
||||
})
|
||||
})
|
||||
|
||||
it("rest history entries are not shown when the rest open action is not registered", async () => {
|
||||
actionsMock.value.push("gql.request.open")
|
||||
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
historyMock.restEntries.push({
|
||||
request: {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "bla.com",
|
||||
},
|
||||
responseMeta: {
|
||||
duration: null,
|
||||
statusCode: null,
|
||||
},
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^gql/),
|
||||
})
|
||||
)
|
||||
expect(result.value.results).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^rest/),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("gql history entries are not shown when the gql open action is not registered", async () => {
|
||||
actionsMock.value.push("rest.request.open")
|
||||
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
historyMock.restEntries.push({
|
||||
request: {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "bla.com",
|
||||
},
|
||||
responseMeta: {
|
||||
duration: null,
|
||||
statusCode: null,
|
||||
},
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^rest/),
|
||||
})
|
||||
)
|
||||
expect(result.value.results).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^gql/),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("none of the history entries are show when neither of the open actions are registered", async () => {
|
||||
historyMock.gqlEntries.push({
|
||||
request: {
|
||||
...defaultGQLSession.request,
|
||||
url: "bla.com",
|
||||
},
|
||||
response: "{}",
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
historyMock.restEntries.push({
|
||||
request: {
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "bla.com",
|
||||
},
|
||||
responseMeta: {
|
||||
duration: null,
|
||||
statusCode: null,
|
||||
},
|
||||
star: false,
|
||||
v: 1,
|
||||
updatedOn: new Date(),
|
||||
})
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const history = container.bind(HistorySpotlightSearcherService)
|
||||
await flushPromises()
|
||||
|
||||
const query = ref("bla")
|
||||
const [result] = history.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(result.value.results).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^rest/),
|
||||
})
|
||||
)
|
||||
expect(result.value.results).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(/^gql/),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,18 +8,17 @@ import {
|
||||
import { Ref, computed, effectScope, markRaw, ref, watch } from "vue"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
import MiniSearch from "minisearch"
|
||||
import { graphqlHistoryStore, restHistoryStore } from "~/newstore/history"
|
||||
import { restHistoryStore } from "~/newstore/history"
|
||||
import { useTimeAgo } from "@vueuse/core"
|
||||
import IconHistory from "~icons/lucide/history"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import SpotlightRESTHistoryEntry from "~/components/app/spotlight/entry/RESTHistory.vue"
|
||||
import SpotlightGQLHistoryEntry from "~/components/app/spotlight/entry/GQLHistory.vue"
|
||||
import SpotlightHistoryEntry from "~/components/app/spotlight/entry/History.vue"
|
||||
import { createNewTab } from "~/helpers/rest/tab"
|
||||
import { capitalize } from "lodash-es"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
import { useStreamStatic } from "~/composables/stream"
|
||||
import { activeActions$, invokeAction } from "~/helpers/actions"
|
||||
import { map } from "rxjs/operators"
|
||||
import { HoppRESTDocument } from "~/helpers/rest/document"
|
||||
|
||||
/**
|
||||
* This searcher is responsible for searching through the history.
|
||||
@@ -48,24 +47,6 @@ export class HistorySpotlightSearcherService
|
||||
}
|
||||
)[0]
|
||||
|
||||
private restHistoryEntryOpenable = useStreamStatic(
|
||||
activeActions$.pipe(
|
||||
map((actions) => actions.includes("rest.request.open"))
|
||||
),
|
||||
activeActions$.value.includes("rest.request.open"),
|
||||
() => {
|
||||
/* noop */
|
||||
}
|
||||
)[0]
|
||||
|
||||
private gqlHistoryEntryOpenable = useStreamStatic(
|
||||
activeActions$.pipe(map((actions) => actions.includes("gql.request.open"))),
|
||||
activeActions$.value.includes("gql.request.open"),
|
||||
() => {
|
||||
/* noop */
|
||||
}
|
||||
)[0]
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
@@ -102,47 +83,24 @@ export class HistorySpotlightSearcherService
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
if (this.restHistoryEntryOpenable.value) {
|
||||
minisearch.addAll(
|
||||
restHistoryStore.value.state
|
||||
.filter((x) => !!x.updatedOn)
|
||||
.map((entry, index) => {
|
||||
const relTimeString = capitalize(
|
||||
useTimeAgo(entry.updatedOn!, {
|
||||
updateInterval: 0,
|
||||
}).value
|
||||
)
|
||||
minisearch.addAll(
|
||||
restHistoryStore.value.state
|
||||
.filter((x) => !!x.updatedOn)
|
||||
.map((entry, index) => {
|
||||
const relTimeString = capitalize(
|
||||
useTimeAgo(entry.updatedOn!, {
|
||||
updateInterval: 0,
|
||||
}).value
|
||||
)
|
||||
|
||||
return {
|
||||
id: `rest-${index}`,
|
||||
url: entry.request.endpoint,
|
||||
reltime: relTimeString,
|
||||
date: shortDateTime(entry.updatedOn!),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (this.gqlHistoryEntryOpenable.value) {
|
||||
minisearch.addAll(
|
||||
graphqlHistoryStore.value.state
|
||||
.filter((x) => !!x.updatedOn)
|
||||
.map((entry, index) => {
|
||||
const relTimeString = capitalize(
|
||||
useTimeAgo(entry.updatedOn!, {
|
||||
updateInterval: 0,
|
||||
}).value
|
||||
)
|
||||
|
||||
return {
|
||||
id: `gql-${index}`,
|
||||
url: entry.request.url,
|
||||
reltime: relTimeString,
|
||||
date: shortDateTime(entry.updatedOn!),
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
return {
|
||||
id: index.toString(),
|
||||
url: entry.request.endpoint,
|
||||
reltime: relTimeString,
|
||||
date: shortDateTime(entry.updatedOn!),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const scopeHandle = effectScope()
|
||||
|
||||
@@ -163,6 +121,8 @@ export class HistorySpotlightSearcherService
|
||||
},
|
||||
})
|
||||
.map((x) => {
|
||||
const entry = restHistoryStore.value.state[parseInt(x.id)]
|
||||
|
||||
if (x.id === "clear-history") {
|
||||
return {
|
||||
id: "clear-history",
|
||||
@@ -174,39 +134,18 @@ export class HistorySpotlightSearcherService
|
||||
},
|
||||
}
|
||||
}
|
||||
if (x.id.startsWith("rest-")) {
|
||||
const entry =
|
||||
restHistoryStore.value.state[parseInt(x.id.split("-")[1])]
|
||||
|
||||
return {
|
||||
id: x.id,
|
||||
icon: markRaw(IconHistory),
|
||||
score: x.score,
|
||||
text: {
|
||||
type: "custom",
|
||||
component: markRaw(SpotlightRESTHistoryEntry),
|
||||
componentProps: {
|
||||
historyEntry: entry,
|
||||
},
|
||||
return {
|
||||
id: x.id,
|
||||
icon: markRaw(IconHistory),
|
||||
score: x.score,
|
||||
text: {
|
||||
type: "custom",
|
||||
component: markRaw(SpotlightHistoryEntry),
|
||||
componentProps: {
|
||||
historyEntry: entry,
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// Assume gql
|
||||
const entry =
|
||||
graphqlHistoryStore.value.state[parseInt(x.id.split("-")[1])]
|
||||
|
||||
return {
|
||||
id: x.id,
|
||||
icon: markRaw(IconHistory),
|
||||
score: x.score,
|
||||
text: {
|
||||
type: "custom",
|
||||
component: markRaw(SpotlightGQLHistoryEntry),
|
||||
componentProps: {
|
||||
historyEntry: entry,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
@@ -231,25 +170,14 @@ export class HistorySpotlightSearcherService
|
||||
onResultSelect(result: SpotlightSearcherResult): void {
|
||||
if (result.id === "clear-history") {
|
||||
invokeAction("history.clear")
|
||||
} else if (result.id.startsWith("rest")) {
|
||||
const req =
|
||||
restHistoryStore.value.state[parseInt(result.id.split("-")[1])].request
|
||||
|
||||
invokeAction("rest.request.open", {
|
||||
doc: <HoppRESTDocument>{
|
||||
request: req,
|
||||
isDirty: false,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Assume gql
|
||||
const req =
|
||||
graphqlHistoryStore.value.state[parseInt(result.id.split("-")[1])]
|
||||
.request
|
||||
|
||||
invokeAction("gql.request.open", {
|
||||
request: req,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const req = restHistoryStore.value.state[parseInt(result.id)].request
|
||||
|
||||
createNewTab({
|
||||
request: req,
|
||||
isDirty: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,13 +48,13 @@ export class UserSpotlightSearcherService extends StaticSpotlightSearcherService
|
||||
login: {
|
||||
text: this.t("auth.login"),
|
||||
excludeFromSearch: computed(() => !this.hasLoginAction.value),
|
||||
alternates: ["sign in", "log in"],
|
||||
alternates: ["sign in"],
|
||||
icon: markRaw(IconLogin),
|
||||
},
|
||||
logout: {
|
||||
text: this.t("auth.logout"),
|
||||
excludeFromSearch: computed(() => !this.hasLogoutAction.value),
|
||||
alternates: ["sign out", "log out"],
|
||||
alternates: ["sign out"],
|
||||
icon: markRaw(IconLogOut),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"vite-plugin-html-config": "^1.0.10",
|
||||
"vite-plugin-inspect": "^0.7.4",
|
||||
"vite-plugin-pages": "^0.26.0",
|
||||
"vite-plugin-pages-sitemap": "^1.4.5",
|
||||
"vite-plugin-pages-sitemap": "^1.4.0",
|
||||
"vite-plugin-pwa": "^0.13.1",
|
||||
"vite-plugin-static-copy": "^0.12.0",
|
||||
"vite-plugin-vue-layouts": "^0.7.0",
|
||||
|
||||
@@ -78,14 +78,16 @@ export default defineConfig({
|
||||
routeStyle: "nuxt",
|
||||
dirs: "../hoppscotch-common/src/pages",
|
||||
importMode: "async",
|
||||
onRoutesGenerated: (routes) =>
|
||||
generateSitemap({
|
||||
onRoutesGenerated(routes) {
|
||||
// HACK: See: https://github.com/jbaubree/vite-plugin-pages-sitemap/issues/173
|
||||
return ((generateSitemap as any).default as typeof generateSitemap)({
|
||||
routes,
|
||||
nuxtStyle: true,
|
||||
allowRobots: true,
|
||||
dest: ".sitemap-gen",
|
||||
hostname: ENV.VITE_BASE_URL,
|
||||
}),
|
||||
})
|
||||
},
|
||||
}),
|
||||
StaticCopy({
|
||||
targets: [
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
@apply after:backface-hidden;
|
||||
@apply selection:bg-accentDark;
|
||||
@apply selection:text-accentContrast;
|
||||
@apply overscroll-none;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
"@esbuild-plugins/node-globals-polyfill": "^0.1.1",
|
||||
"@esbuild-plugins/node-modules-polyfill": "^0.1.4",
|
||||
"@histoire/plugin-vue": "^0.12.4",
|
||||
"@iconify-json/lucide": "^1.1.109",
|
||||
"@iconify-json/lucide": "^1.1.40",
|
||||
"@intlify/vite-plugin-vue-i18n": "^6.0.1",
|
||||
"@rushstack/eslint-patch": "^1.1.4",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
@@ -76,7 +76,7 @@
|
||||
"vite-plugin-html-config": "^1.0.10",
|
||||
"vite-plugin-inspect": "^0.7.4",
|
||||
"vite-plugin-pages": "^0.26.0",
|
||||
"vite-plugin-pages-sitemap": "^1.4.5",
|
||||
"vite-plugin-pages-sitemap": "^1.4.0",
|
||||
"vite-plugin-pwa": "^0.13.1",
|
||||
"vite-plugin-vue-layouts": "^0.7.0",
|
||||
"vite-plugin-windicss": "^1.8.8",
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
@apply after:backface-hidden;
|
||||
@apply selection:bg-accentDark;
|
||||
@apply selection:text-accentContrast;
|
||||
@apply overscroll-none;
|
||||
}
|
||||
|
||||
:root {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
class="relative sticky top-0 z-10 flex-shrink-0 overflow-x-auto divide-x divide-dividerLight bg-primaryLight tabs group-tabs"
|
||||
>
|
||||
<div
|
||||
class="flex flex-1 flex-shrink-0 w-0 overflow-hidden"
|
||||
class="flex flex-1 flex-shrink-0 w-0 overflow-x-auto"
|
||||
ref="scrollContainer"
|
||||
>
|
||||
<div
|
||||
@@ -455,7 +455,6 @@ $slider-height: 4px;
|
||||
@apply min-w-0;
|
||||
@apply bg-dividerDark;
|
||||
@apply hover:bg-secondaryLight;
|
||||
@apply active:bg-secondaryLight;
|
||||
|
||||
width: var(--thumb-width);
|
||||
height: $slider-height;
|
||||
@@ -466,7 +465,6 @@ $slider-height: 4px;
|
||||
@apply min-w-0;
|
||||
@apply bg-dividerDark;
|
||||
@apply hover:bg-secondaryLight;
|
||||
@apply active:bg-secondaryLight;
|
||||
|
||||
width: var(--thumb-width);
|
||||
height: $slider-height;
|
||||
|
||||
55
pnpm-lock.yaml
generated
55
pnpm-lock.yaml
generated
@@ -575,6 +575,9 @@ importers:
|
||||
vue:
|
||||
specifier: ^3.2.25
|
||||
version: 3.2.37
|
||||
vue-github-button:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
vue-i18n:
|
||||
specifier: ^9.2.2
|
||||
version: 9.2.2(vue@3.2.37)
|
||||
@@ -634,8 +637,8 @@ importers:
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1(graphql@15.8.0)
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.1.109
|
||||
version: 1.1.109
|
||||
specifier: ^1.1.40
|
||||
version: 1.1.40
|
||||
'@intlify/vite-plugin-vue-i18n':
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0(vite@3.1.4)(vue-i18n@9.2.2)
|
||||
@@ -745,8 +748,8 @@ importers:
|
||||
specifier: ^0.26.0
|
||||
version: 0.26.0(@vue/compiler-sfc@3.2.39)(vite@3.1.4)
|
||||
vite-plugin-pages-sitemap:
|
||||
specifier: ^1.4.5
|
||||
version: 1.4.5
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
vite-plugin-pwa:
|
||||
specifier: ^0.13.1
|
||||
version: 0.13.1(vite@3.1.4)(workbox-build@6.5.4)(workbox-window@6.5.4)
|
||||
@@ -982,8 +985,8 @@ importers:
|
||||
specifier: ^0.26.0
|
||||
version: 0.26.0(@vue/compiler-sfc@3.2.45)(vite@3.2.4)
|
||||
vite-plugin-pages-sitemap:
|
||||
specifier: ^1.4.5
|
||||
version: 1.4.5
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
vite-plugin-pwa:
|
||||
specifier: ^0.13.1
|
||||
version: 0.13.1(vite@3.2.4)(workbox-build@6.5.4)(workbox-window@6.5.4)
|
||||
@@ -1236,8 +1239,8 @@ importers:
|
||||
specifier: ^0.12.4
|
||||
version: 0.12.4(histoire@0.12.4)(vite@3.2.4)(vue@3.2.45)
|
||||
'@iconify-json/lucide':
|
||||
specifier: ^1.1.109
|
||||
version: 1.1.109
|
||||
specifier: ^1.1.40
|
||||
version: 1.1.40
|
||||
'@intlify/vite-plugin-vue-i18n':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1(vite@3.2.4)
|
||||
@@ -1323,8 +1326,8 @@ importers:
|
||||
specifier: ^0.26.0
|
||||
version: 0.26.0(@vue/compiler-sfc@3.2.45)(vite@3.2.4)
|
||||
vite-plugin-pages-sitemap:
|
||||
specifier: ^1.4.5
|
||||
version: 1.4.5
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
vite-plugin-pwa:
|
||||
specifier: ^0.13.1
|
||||
version: 0.13.1(vite@3.2.4)(workbox-build@6.5.4)(workbox-window@6.5.4)
|
||||
@@ -2939,7 +2942,7 @@ packages:
|
||||
engines: {node: '>=6.9.0'}
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.18.6
|
||||
'@babel/parser': 7.22.5
|
||||
'@babel/parser': 7.20.15
|
||||
'@babel/types': 7.20.7
|
||||
dev: true
|
||||
|
||||
@@ -5783,10 +5786,10 @@ packages:
|
||||
/@iarna/toml@2.2.5:
|
||||
resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==}
|
||||
|
||||
/@iconify-json/lucide@1.1.109:
|
||||
resolution: {integrity: sha512-1+zYieiKUAjN1x66kvcRmmtgBJaDbD7i4To8mhB6+3bEm/i61un76nspJ45LOSGovzBMvYZFIJpqJrGMipWPzw==}
|
||||
/@iconify-json/lucide@1.1.40:
|
||||
resolution: {integrity: sha512-4GeQtaiv3mJ+b0sn/c2KL8Tgf4XQvsX1AHDOseuGRhgoLCWG+ZdNRFxF5sp1I6T/VcQccegLPOp5XHn3NC1mmA==}
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
'@iconify/types': 1.1.0
|
||||
dev: true
|
||||
|
||||
/@iconify/types@1.1.0:
|
||||
@@ -5833,8 +5836,8 @@ packages:
|
||||
vue-i18n:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@intlify/message-compiler': 9.3.0-beta.25
|
||||
'@intlify/shared': 9.3.0-beta.25
|
||||
'@intlify/message-compiler': 9.3.0-beta.24
|
||||
'@intlify/shared': 9.3.0-beta.24
|
||||
jsonc-eslint-parser: 1.4.1
|
||||
source-map: 0.6.1
|
||||
vue-i18n: 9.2.2(vue@3.2.37)
|
||||
@@ -5895,11 +5898,11 @@ packages:
|
||||
source-map-js: 1.0.2
|
||||
dev: true
|
||||
|
||||
/@intlify/message-compiler@9.3.0-beta.25:
|
||||
resolution: {integrity: sha512-uT7ybqKoDEw1XITQYnTYjWgZnpCDmHv9e3D4MmJDqHl2qCm6anzdUXWKHUhqR87Ha9Z8Rl44v40iSI/4NUbppQ==}
|
||||
/@intlify/message-compiler@9.3.0-beta.24:
|
||||
resolution: {integrity: sha512-prhHATkgp0mpPqoVgiAtLmUc1JMvs8fMH6w53AVEBn+VF87dLhzanfmWY5FoZWORG51ag54gBDBOoM/VFv3m3A==}
|
||||
engines: {node: '>= 16'}
|
||||
dependencies:
|
||||
'@intlify/shared': 9.3.0-beta.25
|
||||
'@intlify/shared': 9.3.0-beta.24
|
||||
source-map-js: 1.0.2
|
||||
dev: true
|
||||
|
||||
@@ -5912,8 +5915,8 @@ packages:
|
||||
engines: {node: '>= 16'}
|
||||
dev: true
|
||||
|
||||
/@intlify/shared@9.3.0-beta.25:
|
||||
resolution: {integrity: sha512-Zg+ECV9RPdp227tCJOgvPb+S3i651nf4kKHsMojSyWCppVK/4NFuDrBG2lIQSQL6Iq5LKVr5MkezHCW2NBTQRg==}
|
||||
/@intlify/shared@9.3.0-beta.24:
|
||||
resolution: {integrity: sha512-AKxJ8s7eKIQWkNaf4wyyoLRwf4puCuQgjSChlDJm5JBEt6T8HGgnYTJLRXu6LD/JACn3Qwu6hM/XRX1c9yvjmQ==}
|
||||
engines: {node: '>= 16'}
|
||||
dev: true
|
||||
|
||||
@@ -5933,7 +5936,7 @@ packages:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@intlify/bundle-utils': 7.0.0
|
||||
'@intlify/shared': 9.3.0-beta.25
|
||||
'@intlify/shared': 9.3.0-beta.24
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
debug: 4.3.4(supports-color@9.2.2)
|
||||
fast-glob: 3.2.11
|
||||
@@ -5960,7 +5963,7 @@ packages:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@intlify/bundle-utils': 3.4.0(vue-i18n@9.2.2)
|
||||
'@intlify/shared': 9.3.0-beta.25
|
||||
'@intlify/shared': 9.3.0-beta.24
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
debug: 4.3.4(supports-color@9.2.2)
|
||||
fast-glob: 3.2.12
|
||||
@@ -5988,7 +5991,7 @@ packages:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@intlify/bundle-utils': 3.4.0(vue-i18n@9.2.2)
|
||||
'@intlify/shared': 9.3.0-beta.25
|
||||
'@intlify/shared': 9.3.0-beta.24
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
debug: 4.3.4(supports-color@9.2.2)
|
||||
fast-glob: 3.2.12
|
||||
@@ -21475,8 +21478,8 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/vite-plugin-pages-sitemap@1.4.5:
|
||||
resolution: {integrity: sha512-AcEoJ+0D0P1CwR1LjzBznHs6yNQVP7ha7l6cl/VHrHFcQXbVyKc+QOmLi1a3eONy+aDA3K01pZVK8TzTIufq/w==}
|
||||
/vite-plugin-pages-sitemap@1.4.0:
|
||||
resolution: {integrity: sha512-8GlmNSeObvDVUdmgutfWnGBZGsn3Zb8IXCjRODFB0tOhQqAQPZRwXrFha6ZLjvF4DgVJI3sW2FfiXa1wFxJvmg==}
|
||||
dev: true
|
||||
|
||||
/vite-plugin-pages@0.26.0(@vue/compiler-sfc@3.2.39)(vite@3.1.4):
|
||||
|
||||
Reference in New Issue
Block a user