Compare commits

..

18 Commits

Author SHA1 Message Date
Nivedin
a60303ef7c chore: add empty result placeholder 2023-07-10 15:29:52 +05:30
Andrew Bastin
53e4863a80 fix: error out when arrow key navigation done on spotlight when no results 2023-07-09 21:56:08 +05:30
Andrew Bastin
3340d0813c chore: remove fuse.js dependency 2023-07-05 11:52:36 +05:30
Andrew Bastin
7c5722586c refactor: move shortcuts to use minisearch 2023-07-05 11:51:15 +05:30
Andrew Bastin
6b38363fab chore: add test case to user searcher to register with spotlight on init 2023-07-04 15:03:50 +05:30
Andrew Bastin
14202a3eed chore: introduce tests for history searcher 2023-07-04 14:55:44 +05:30
Andrew Bastin
ea03223b8e fix: history returning no entries until query update and minisearch conflicts 2023-07-04 14:54:30 +05:30
Andrew Bastin
235deb113c chore: update vitest to be able to parse Vue components 2023-07-04 14:51:31 +05:30
Andrew Bastin
833e11ab0b feat: general spotlight improvements and introducing user searcher 2023-07-04 12:54:57 +05:30
Andrew Bastin
0d101673d2 chore: update vitest config to support loading icons 2023-07-04 12:52:26 +05:30
Andrew Bastin
6fe565c30f feat: add action handler to login and logout components 2023-07-03 23:13:36 +05:30
Andrew Bastin
4164de5a9e refactor: ability for defineActionHandler to be able to control binding 2023-07-03 23:10:27 +05:30
Andrew Bastin
1ff35f45ee feat: introduce debug service 2023-07-03 23:01:46 +05:30
Andrew Bastin
3bf8288de3 feat: initial reworked spotlight implementation 2023-07-03 12:03:17 +05:30
Andrew Bastin
8c48d41eed refactor: expose function to get a service instance from dioc through module 2023-07-03 11:41:05 +05:30
Andrew Bastin
38215be3bd refactor: provide global i18n function through the module 2023-07-03 11:39:00 +05:30
Andrew Bastin
edf57da9be chore: add minisearch and bump @vueuse/core dep 2023-07-03 11:36:55 +05:30
Andrew Bastin
be61b62825 feat: add clear history action 2023-07-03 11:23:02 +05:30
75 changed files with 1281 additions and 3241 deletions

View File

@@ -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="************************************************"

View File

@@ -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, {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),
)();
}
}

View File

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

View File

@@ -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 */

View File

@@ -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([{}]),
},
);

View File

@@ -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),
},
),
),
);
}
/**

View File

@@ -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)();
}
}

View File

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

View File

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

View File

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

View File

@@ -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),
),
)();
}
}

View File

@@ -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)),
)();
}
}

View File

@@ -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)),
)();
}
}

View File

@@ -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)();
}
}

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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']

View File

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

View File

@@ -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')"
/>

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

@@ -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([])
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

@@ -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: [

View File

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

View File

@@ -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",

View File

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

View File

@@ -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
View File

@@ -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):