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
40 changed files with 1091 additions and 1518 deletions

View File

@@ -181,7 +181,7 @@ export class AdminService {
* @returns an array team invitations * @returns an array team invitations
*/ */
async pendingInvitationCountInTeam(teamID: string) { async pendingInvitationCountInTeam(teamID: string) {
const invitations = await this.teamInvitationService.getTeamInvitations( const invitations = await this.teamInvitationService.getAllTeamInvitations(
teamID, teamID,
); );
@@ -257,7 +257,7 @@ export class AdminService {
if (E.isRight(userInvitation)) { if (E.isRight(userInvitation)) {
await this.teamInvitationService.revokeInvitation( await this.teamInvitationService.revokeInvitation(
userInvitation.right.id, userInvitation.right.id,
); )();
} }
return E.right(addedUser.right); return E.right(addedUser.right);

View File

@@ -228,7 +228,7 @@ export class AuthService {
url = process.env.VITE_BASE_URL; url = process.env.VITE_BASE_URL;
} }
await this.mailerService.sendEmail(email, { await this.mailerService.sendAuthEmail(email, {
template: 'code-your-own', template: 'code-your-own',
variables: { variables: {
inviteeEmail: email, inviteeEmail: email,

View File

@@ -312,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; export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
/**
* Invalid TEAM ENVIRONMENT name
* (TeamEnvironmentsService)
*/
export const TEAM_ENVIRONMENT_SHORT_NAME =
'team_environment/short_name' as const;
/** /**
* The user is not a member of the team of the given environment * The user is not a member of the team of the given environment
* (GqlTeamEnvTeamGuard) * (GqlTeamEnvTeamGuard)

View File

@@ -5,6 +5,7 @@ import {
UserMagicLinkMailDescription, UserMagicLinkMailDescription,
} from './MailDescriptions'; } from './MailDescriptions';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import * as TE from 'fp-ts/TaskEither';
import { EMAIL_FAILED } from 'src/errors'; import { EMAIL_FAILED } from 'src/errors';
import { MailerService as NestMailerService } from '@nestjs-modules/mailer'; import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
@@ -34,14 +35,33 @@ export class MailerService {
/** /**
* Sends an email to the given email address given a mail description * Sends an email to the given email address given a mail description
* @param to 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 * @param mailDesc Definition of what email to be sent
* @returns Response if email was send successfully or not
*/ */
async sendEmail( sendMail(
to: string, to: string,
mailDesc: MailDescription | UserMagicLinkMailDescription, mailDesc: MailDescription | UserMagicLinkMailDescription,
) { ) {
return TE.tryCatch(
async () => {
await this.nestMailerService.sendMail({
to,
template: mailDesc.template,
subject: this.resolveSubjectForMailDesc(mailDesc),
context: mailDesc.variables,
});
},
() => EMAIL_FAILED,
);
}
/**
*
* @param to Receiver's email id
* @param mailDesc Details of email to be sent for Magic-Link auth
* @returns Response if email was send successfully or not
*/
async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) {
try { try {
await this.nestMailerService.sendMail({ await this.nestMailerService.sendMail({
to, to,

View File

@@ -1,5 +1,15 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import * as TE from 'fp-ts/TaskEither';
import * as O from 'fp-ts/Option';
import * as S from 'fp-ts/string';
import { pipe } from 'fp-ts/function';
import {
getAnnotatedRequiredRoles,
getGqlArg,
getUserFromGQLContext,
throwErr,
} from 'src/utils';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
@@ -9,10 +19,6 @@ import {
TEAM_ENVIRONMENT_NOT_FOUND, TEAM_ENVIRONMENT_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { GqlExecutionContext } from '@nestjs/graphql';
import * as E from 'fp-ts/Either';
import { TeamMemberRole } from '@prisma/client';
import { throwErr } from 'src/utils';
/** /**
* A guard which checks whether the caller of a GQL Operation * A guard which checks whether the caller of a GQL Operation
@@ -27,31 +33,50 @@ export class GqlTeamEnvTeamGuard implements CanActivate {
private readonly teamService: TeamService, private readonly teamService: TeamService,
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { canActivate(context: ExecutionContext): Promise<boolean> {
const requireRoles = this.reflector.get<TeamMemberRole[]>( return pipe(
'requiresTeamRole', TE.Do,
context.getHandler(),
);
if (!requireRoles) throw new Error(BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES);
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; TE.bindW('user', () =>
if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX); pipe(
getUserFromGQLContext(context),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
const { id } = gqlExecCtx.getArgs<{ id: string }>(); TE.bindW('envID', () =>
if (!id) throwErr(BUG_TEAM_ENV_GUARD_NO_ENV_ID); pipe(
getGqlArg('id', context),
O.fromPredicate(S.isString),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID),
),
),
const teamEnvironment = TE.bindW('membership', ({ envID, user }) =>
await this.teamEnvironmentService.getTeamEnvironment(id); pipe(
if (E.isLeft(teamEnvironment)) throwErr(TEAM_ENVIRONMENT_NOT_FOUND); 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( TE.map(({ membership, requiredRoles }) =>
teamEnvironment.right.teamID, requiredRoles.includes(membership.role),
user.uid, ),
);
if (!member) throwErr(TEAM_ENVIRONMENT_NOT_TEAM_MEMBER);
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 { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import * as E from 'fp-ts/Either';
import {
CreateTeamEnvironmentArgs,
UpdateTeamEnvironmentArgs,
} from './input-type.args';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => 'TeamEnvironment') @Resolver(() => 'TeamEnvironment')
@@ -34,18 +29,29 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async createTeamEnvironment( createTeamEnvironment(
@Args() args: CreateTeamEnvironmentArgs, @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> { ): Promise<TeamEnvironment> {
const teamEnvironment = return this.teamEnvironmentsService.createTeamEnvironment(
await this.teamEnvironmentsService.createTeamEnvironment( name,
args.name, teamID,
args.teamID, variables,
args.variables, )();
);
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
} }
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
@@ -53,7 +59,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async deleteTeamEnvironment( deleteTeamEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -61,12 +67,10 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<boolean> { ): Promise<boolean> {
const isDeleted = await this.teamEnvironmentsService.deleteTeamEnvironment( return pipe(
id, this.teamEnvironmentsService.deleteTeamEnvironment(id),
); TE.getOrElse(throwErr),
)();
if (E.isLeft(isDeleted)) throwErr(isDeleted.left);
return isDeleted.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -75,19 +79,28 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async updateTeamEnvironment( updateTeamEnvironment(
@Args() @Args({
args: UpdateTeamEnvironmentArgs, 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> { ): Promise<TeamEnvironment> {
const updatedTeamEnvironment = return pipe(
await this.teamEnvironmentsService.updateTeamEnvironment( this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables),
args.id, TE.getOrElse(throwErr),
args.name, )();
args.variables,
);
if (E.isLeft(updatedTeamEnvironment)) throwErr(updatedTeamEnvironment.left);
return updatedTeamEnvironment.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -95,7 +108,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async deleteAllVariablesFromTeamEnvironment( deleteAllVariablesFromTeamEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -103,13 +116,10 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
const teamEnvironment = return pipe(
await this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id),
id, TE.getOrElse(throwErr),
); )();
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -117,7 +127,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
async createDuplicateEnvironment( createDuplicateEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -125,12 +135,10 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
const res = await this.teamEnvironmentsService.createDuplicateEnvironment( return pipe(
id, this.teamEnvironmentsService.createDuplicateEnvironment(id),
); TE.getOrElse(throwErr),
)();
if (E.isLeft(res)) throwErr(res.left);
return res.right;
} }
/* Subscriptions */ /* Subscriptions */

View File

@@ -2,11 +2,7 @@ import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import { import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
JSON_INVALID,
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
@@ -35,81 +31,125 @@ beforeEach(() => {
describe('TeamEnvironmentsService', () => { describe('TeamEnvironmentsService', () => {
describe('getTeamEnvironment', () => { describe('getTeamEnvironment', () => {
test('should successfully return a TeamEnvironment with valid ID', async () => { test('queries the db with the id', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce( mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
teamEnvironment,
);
const result = await teamEnvironmentsService.getTeamEnvironment( await teamEnvironmentsService.getTeamEnvironment('123')();
teamEnvironment.id,
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 () => { test('requests prisma to reject the query promise if not found', async () => {
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce( mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment);
'RejectOnNotFound',
);
const result = await teamEnvironmentsService.getTeamEnvironment( await teamEnvironmentsService.getTeamEnvironment('123')();
teamEnvironment.id,
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', () => { 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); mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.createTeamEnvironment( const result = await teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name, teamEnvironment.name,
teamEnvironment.teamID, teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
); )();
expect(result).toEqualRight({ expect(result).toEqual(<TeamEnvironment>{
...teamEnvironment, id: teamEnvironment.id,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables), variables: JSON.stringify(teamEnvironment.variables),
}); });
}); });
test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => { test('should reject if given team ID is invalid', async () => {
const result = await teamEnvironmentsService.createTeamEnvironment( mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
'12',
teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables),
);
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 () => { 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( const result = await teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name, teamEnvironment.name,
teamEnvironment.teamID, teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
); )();
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`, `team_environment/${teamEnvironment.teamID}/created`,
{ result,
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables),
},
); );
}); });
}); });
describe('deleteTeamEnvironment', () => { describe('deleteTeamEnvironment', () => {
test('should 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); mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment);
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
); )();
expect(result).toEqualRight(true); expect(result).toEqualRight(true);
}); });
@@ -119,7 +159,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
'invalidid', 'invalidid',
); )();
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -129,7 +169,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
); )();
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/deleted`, `team_environment/${teamEnvironment.teamID}/deleted`,
@@ -142,7 +182,7 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('updateVariablesInTeamEnvironment', () => { 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({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: 'value' }], variables: [{ key: 'value' }],
@@ -152,7 +192,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }]), JSON.stringify([{ key: 'value' }]),
); )();
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -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({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: 'value' }, { key_2: 'value_2' }], variables: [{ key: 'value' }, { key_2: 'value_2' }],
@@ -170,7 +210,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]), JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
); )();
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -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({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: '1234' }], variables: [{ key: '1234' }],
@@ -188,7 +228,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: '1234' }]), JSON.stringify([{ key: '1234' }]),
); )();
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -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({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: '123' }], variables: [{ key: '123' }],
@@ -206,7 +261,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: '123' }]), JSON.stringify([{ key: '123' }]),
); )();
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -214,24 +269,14 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => { test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
const result = await teamEnvironmentsService.updateTeamEnvironment(
teamEnvironment.id,
'12',
JSON.stringify([{ key: 'value' }]),
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
});
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
const result = await teamEnvironmentsService.updateTeamEnvironment( const result = await teamEnvironmentsService.updateTeamEnvironment(
'invalidid', 'invalidid',
teamEnvironment.name, teamEnvironment.name,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
); )();
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -243,7 +288,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }]), JSON.stringify([{ key: 'value' }]),
); )();
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`, `team_environment/${teamEnvironment.teamID}/updated`,
@@ -256,13 +301,13 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('deleteAllVariablesFromTeamEnvironment', () => { 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); mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
); )();
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -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'); mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
'invalidid', 'invalidid',
); )();
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -287,7 +332,7 @@ describe('TeamEnvironmentsService', () => {
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
); )();
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`, `team_environment/${teamEnvironment.teamID}/updated`,
@@ -300,7 +345,7 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('createDuplicateEnvironment', () => { describe('createDuplicateEnvironment', () => {
test('should successfully duplicate an existing team environment', async () => { test('should duplicate an existing team environment', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce( mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
teamEnvironment, teamEnvironment,
); );
@@ -312,21 +357,21 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
); )();
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
id: 'newid',
...teamEnvironment, ...teamEnvironment,
id: 'newid',
variables: JSON.stringify(teamEnvironment.variables), 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'); mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
); )();
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -343,13 +388,13 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
); )();
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`, `team_environment/${teamEnvironment.teamID}/created`,
{ {
id: 'newid',
...teamEnvironment, ...teamEnvironment,
id: 'newid',
variables: JSON.stringify([{}]), variables: JSON.stringify([{}]),
}, },
); );

View File

@@ -1,14 +1,15 @@
import { Injectable } from '@nestjs/common'; 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 { PrismaService } from 'src/prisma/prisma.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors';
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { isValidLength } from 'src/utils';
@Injectable() @Injectable()
export class TeamEnvironmentsService { export class TeamEnvironmentsService {
constructor( constructor(
@@ -16,218 +17,219 @@ export class TeamEnvironmentsService {
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
) {} ) {}
TITLE_LENGTH = 3; getTeamEnvironment(id: string) {
return TO.tryCatch(() =>
/** this.prisma.teamEnvironment.findFirst({
* TeamEnvironments are saved in the DB in the following way where: { id },
* [{ key: value }, { key: value },....]
*
*/
/**
* Typecast a database TeamEnvironment to a TeamEnvironment model
* @param teamEnvironment database TeamEnvironment
* @returns TeamEnvironment model
*/
private cast(teamEnvironment: DBTeamEnvironment): TeamEnvironment {
return {
id: teamEnvironment.id,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables),
};
}
/**
* Get details of a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async getTeamEnvironment(id: string) {
try {
const teamEnvironment =
await this.prisma.teamEnvironment.findFirstOrThrow({
where: { id },
});
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Create a new TeamEnvironment.
*
* @param name name of new TeamEnvironment
* @param teamID teamID of new TeamEnvironment
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async createTeamEnvironment(name: string, teamID: string, variables: string) {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.create({
data: {
name: name,
teamID: teamID,
variables: JSON.parse(variables),
},
});
const createdTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${createdTeamEnvironment.teamID}/created`,
createdTeamEnvironment,
);
return E.right(createdTeamEnvironment);
}
/**
* Delete a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of boolean or error message
*/
async deleteTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.delete({
where: {
id: id,
},
});
const deletedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${deletedTeamEnvironment.teamID}/deleted`,
deletedTeamEnvironment,
);
return E.right(true);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Update a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @param name TeamEnvironment name
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async updateTeamEnvironment(id: string, name: string, variables: string) {
try {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.update({
where: { id: id },
data: {
name,
variables: JSON.parse(variables),
},
});
const updatedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${updatedTeamEnvironment.teamID}/updated`,
updatedTeamEnvironment,
);
return E.right(updatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Clear contents of a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async deleteAllVariablesFromTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.update({
where: { id: id },
data: {
variables: [],
},
});
const teamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${teamEnvironment.teamID}/updated`,
teamEnvironment,
);
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Create a duplicate of a existing TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async createDuplicateEnvironment(id: string) {
try {
const environment = await this.prisma.teamEnvironment.findFirst({
where: {
id: id,
},
rejectOnNotFound: true, rejectOnNotFound: true,
}); }),
);
const result = await this.prisma.teamEnvironment.create({
data: {
name: environment.name,
teamID: environment.teamID,
variables: environment.variables as Prisma.JsonArray,
},
});
const duplicatedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${duplicatedTeamEnvironment.teamID}/created`,
duplicatedTeamEnvironment,
);
return E.right(duplicatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
} }
/** createTeamEnvironment(name: string, teamID: string, variables: string) {
* Fetch all TeamEnvironments of a team. return pipe(
* () =>
* @param teamID teamID of new TeamEnvironment this.prisma.teamEnvironment.create({
* @returns List of TeamEnvironments data: {
*/ name: name,
async fetchAllTeamEnvironments(teamID: string) { teamID: teamID,
const result = await this.prisma.teamEnvironment.findMany({ variables: JSON.parse(variables),
where: { },
teamID: teamID, }),
}, T.chainFirst(
}); (environment) => () =>
const teamEnvironments = result.map((item) => { this.pubsub.publish(
return this.cast(item); `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', description: 'Returns all Team Environments for the given Team',
}) })
teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> { teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> {
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id); return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)();
} }
} }

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 { TeamInvitationService } from './team-invitation.service';
import { pipe } from 'fp-ts/function'; import { pipe } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither'; import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model'; import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
import { 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 { GqlUser } from 'src/decorators/gql-user.decorator';
import { User } from 'src/user/user.model'; import { User } from 'src/user/user.model';
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
@@ -31,8 +36,6 @@ import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { AuthUser } from 'src/types/AuthUser';
import { CreateTeamInvitationArgs } from './input-type.args';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => TeamInvitation) @Resolver(() => TeamInvitation)
@@ -76,8 +79,8 @@ export class TeamInvitationResolver {
'Gets the Team Invitation with the given ID, or null if not exists', 'Gets the Team Invitation with the given ID, or null if not exists',
}) })
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard) @UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
async teamInvitation( teamInvitation(
@GqlUser() user: AuthUser, @GqlUser() user: User,
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
description: 'ID of the Team Invitation to lookup', description: 'ID of the Team Invitation to lookup',
@@ -85,11 +88,17 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<TeamInvitation> { ): Promise<TeamInvitation> {
const teamInvitation = await this.teamInvitationService.getInvitation( return pipe(
inviteID, this.teamInvitationService.getInvitation(inviteID),
); TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
if (O.isNone(teamInvitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); TE.chainW(
return teamInvitation.value; TE.fromPredicate(
(a) => a.inviteeEmail.toLowerCase() === user.email?.toLowerCase(),
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
TE.getOrElse(throwErr),
)();
} }
@Mutation(() => TeamInvitation, { @Mutation(() => TeamInvitation, {
@@ -97,19 +106,56 @@ export class TeamInvitationResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER) @RequiresTeamRole(TeamMemberRole.OWNER)
async createTeamInvitation( createTeamInvitation(
@GqlUser() user: AuthUser, @GqlUser()
@Args() args: CreateTeamInvitationArgs, user: User,
): Promise<TeamInvitation> {
const teamInvitation = await this.teamInvitationService.createInvitation(
user,
args.teamID,
args.inviteeEmail,
args.inviteeRole,
);
if (E.isLeft(teamInvitation)) throwErr(teamInvitation.left); @Args({
return teamInvitation.right; 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, { @Mutation(() => Boolean, {
@@ -117,7 +163,7 @@ export class TeamInvitationResolver {
}) })
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard) @UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
@RequiresTeamRole(TeamMemberRole.OWNER) @RequiresTeamRole(TeamMemberRole.OWNER)
async revokeTeamInvitation( revokeTeamInvitation(
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
type: () => ID, type: () => ID,
@@ -125,19 +171,19 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<true> { ): Promise<true> {
const isRevoked = await this.teamInvitationService.revokeInvitation( return pipe(
inviteID, this.teamInvitationService.revokeInvitation(inviteID),
); TE.map(() => true as const),
if (E.isLeft(isRevoked)) throwErr(isRevoked.left); TE.getOrElse(throwErr),
return true; )();
} }
@Mutation(() => TeamMember, { @Mutation(() => TeamMember, {
description: 'Accept an Invitation', description: 'Accept an Invitation',
}) })
@UseGuards(GqlAuthGuard, TeamInviteeGuard) @UseGuards(GqlAuthGuard, TeamInviteeGuard)
async acceptTeamInvitation( acceptTeamInvitation(
@GqlUser() user: AuthUser, @GqlUser() user: User,
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
type: () => ID, type: () => ID,
@@ -145,12 +191,10 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<TeamMember> { ): Promise<TeamMember> {
const teamMember = await this.teamInvitationService.acceptInvitation( return pipe(
inviteID, this.teamInvitationService.acceptInvitation(inviteID, user),
user, TE.getOrElse(throwErr),
); )();
if (E.isLeft(teamMember)) throwErr(teamMember.left);
return teamMember.right;
} }
// Subscriptions // Subscriptions

View File

@@ -1,25 +1,27 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as TO from 'fp-ts/TaskOption';
import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { pipe, flow, constVoid } from 'fp-ts/function';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { TeamInvitation as DBTeamInvitation } from '@prisma/client'; import { Team, TeamMemberRole } from 'src/team/team.model';
import { TeamMember, 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 { TeamService } from 'src/team/team.service';
import { import {
INVALID_EMAIL, INVALID_EMAIL,
TEAM_INVALID_ID,
TEAM_INVITE_ALREADY_MEMBER, TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_EMAIL_DO_NOT_MATCH, TEAM_INVITE_EMAIL_DO_NOT_MATCH,
TEAM_INVITE_MEMBER_HAS_INVITE, TEAM_INVITE_MEMBER_HAS_INVITE,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamInvitation } from './team-invitation.model'; import { TeamInvitation } from './team-invitation.model';
import { MailerService } from 'src/mailer/mailer.service'; import { MailerService } from 'src/mailer/mailer.service';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { validateEmail } from '../utils'; import { validateEmail } from '../utils';
import { AuthUser } from 'src/types/AuthUser';
@Injectable() @Injectable()
export class TeamInvitationService { export class TeamInvitationService {
@@ -30,37 +32,38 @@ export class TeamInvitationService {
private readonly mailerService: MailerService, private readonly mailerService: MailerService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
) {} ) {
this.getInvitation = this.getInvitation.bind(this);
/**
* Cast a DBTeamInvitation to a TeamInvitation
* @param dbTeamInvitation database TeamInvitation
* @returns TeamInvitation model
*/
cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation {
return {
...dbTeamInvitation,
inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole],
};
} }
/** getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> {
* Get the team invite return pipe(
* @param inviteID invite id () =>
* @returns an Option of team invitation or none this.prisma.teamInvitation.findUnique({
*/ where: {
async getInvitation(inviteID: string) { id: inviteID,
try { },
const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({ }),
where: { TO.fromTask,
id: inviteID, TO.chain(flow(O.fromNullable, TO.fromOption)),
}, TO.map((x) => x as TeamInvitation),
}); );
}
return O.some(this.cast(dbInvitation)); getInvitationWithEmail(email: Email, team: Team) {
} catch (e) { return pipe(
return O.none; () =>
} 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 {
} }
} }
/** createInvitation(
* Create a team invitation creator: User,
* @param creator creator of the invitation team: Team,
* @param teamID team id inviteeEmail: Email,
* @param inviteeEmail invitee email
* @param inviteeRole invitee role
* @returns an Either of team invitation or error message
*/
async createInvitation(
creator: AuthUser,
teamID: string,
inviteeEmail: string,
inviteeRole: TeamMemberRole, inviteeRole: TeamMemberRole,
) { ) {
// validate email return pipe(
const isEmailValid = validateEmail(inviteeEmail); // Perform all validation checks
if (!isEmailValid) return E.left(INVALID_EMAIL); TE.sequenceArray([
// creator should be a TeamMember
pipe(
this.teamService.getTeamMemberTE(team.id, creator.uid),
TE.map(constVoid),
),
// team ID should valid // Invitee should not be a team member
const team = await this.teamService.getTeamWithID(teamID); pipe(
if (!team) return E.left(TEAM_INVALID_ID); 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 // Should not have an existing invite
const isTeamMember = await this.teamService.getTeamMember( pipe(
team.id, this.getInvitationWithEmail(inviteeEmail, team),
creator.uid, 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 revokeInvitation(inviteID: string) {
const inviteeUser = await this.userService.findUserByEmail(inviteeEmail); return pipe(
if (O.isSome(inviteeUser)) { // Make sure invite exists
// invitee should not already a member this.getInvitation(inviteID),
const isTeamMember = await this.teamService.getTeamMember( TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
team.id,
inviteeUser.value.uid,
);
if (isTeamMember) return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// check invitee already invited earlier or not // Delete team invitation
const teamInvitation = await this.getTeamInviteByEmailAndTeamID( TE.chainTaskK(
inviteeEmail, () => () =>
team.id, 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 getAllInvitationsInTeam(team: Team) {
const dbInvitation = await this.prisma.teamInvitation.create({ return pipe(
data: { () =>
teamID: team.id, this.prisma.teamInvitation.findMany({
inviteeEmail, where: {
inviteeRole, teamID: team.id,
creatorUid: creator.uid, },
}, }),
}); T.map((x) => x as TeamInvitation[]),
);
}
await this.mailerService.sendEmail(inviteeEmail, { acceptInvitation(inviteID: string, acceptedBy: User) {
template: 'team-invitation', return pipe(
variables: { TE.Do,
invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
invite_team_name: team.name,
},
});
const invitation = this.cast(dbInvitation); // First get the invitation
this.pubsub.publish(`team/${invitation.teamID}/invite_added`, 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 * Fetch the count invitations for a given team.
* @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.
* @param teamID team id * @param teamID team id
* @returns array of team invitations for a team * @returns a count team invitations for a team
*/ */
async getTeamInvitations(teamID: string) { async getAllTeamInvitations(teamID: string) {
const dbInvitations = await this.prisma.teamInvitation.findMany({ const invitations = await this.prisma.teamInvitation.findMany({
where: { where: {
teamID: teamID, teamID: teamID,
}, },
}); });
const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) =>
this.cast(dbInvitation),
);
return invitations; return invitations;
} }
} }

View File

@@ -1,21 +1,21 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { pipe } from 'fp-ts/function';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_REQUIRED_ROLE, TEAM_NOT_REQUIRED_ROLE,
} from 'src/errors'; } from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { TeamMemberRole } from 'src/team/team.model'; import { TeamMemberRole } from 'src/team/team.model';
/**
* This guard only allows team owner to execute the resolver
*/
@Injectable() @Injectable()
export class TeamInviteTeamOwnerGuard implements CanActivate { export class TeamInviteTeamOwnerGuard implements CanActivate {
constructor( constructor(
@@ -24,30 +24,48 @@ export class TeamInviteTeamOwnerGuard implements CanActivate {
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
// Get GQL context return pipe(
const gqlExecCtx = GqlExecutionContext.create(context); TE.Do,
// Get user TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get the invite // Get the invite
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>(); TE.bindW('invite', ({ gqlCtx }) =>
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID); 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); TE.bindW('user', ({ gqlCtx }) =>
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); pipe(
gqlCtx.getContext().req.user,
O.fromNullable,
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
// Fetch team member details of this user TE.bindW('userMember', ({ invite, user }) =>
const teamMember = await this.teamService.getTeamMember( this.teamService.getTeamMemberTE(invite.teamID, user.uid),
invitation.value.teamID, ),
user.uid,
);
if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND); TE.chainW(
if (teamMember.role !== TeamMemberRole.OWNER) TE.fromPredicate(
throwErr(TEAM_NOT_REQUIRED_ROLE); ({ 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 { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NOT_VALID_VIEWER,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
/**
* This guard only allows user to execute the resolver
* 1. If user is invitee, allow
* 2. Or else, if user is team member, allow
*
* TLDR: Allow if user is invitee or team member
*/
@Injectable() @Injectable()
export class TeamInviteViewerGuard implements CanActivate { export class TeamInviteViewerGuard implements CanActivate {
constructor( constructor(
@@ -26,32 +23,50 @@ export class TeamInviteViewerGuard implements CanActivate {
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
// Get GQL context return pipe(
const gqlExecCtx = GqlExecutionContext.create(context); TE.Do,
// Get user // Get GQL Context
const { user } = gqlExecCtx.getContext().req; TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get the invite // Get user
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>(); TE.bindW('user', ({ gqlCtx }) =>
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID); pipe(
O.fromNullable(gqlCtx.getContext().req.user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
const invitation = await this.teamInviteService.getInvitation(inviteID); // Get the invite
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); 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 // Check if the user and the invite email match, else if we can resolver the user as a team member
if ( // any better solution ?
user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase() TE.chainW(({ user, invite }) =>
) { user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase()
const teamMember = await this.teamService.getTeamMember( ? TE.of(true)
invitation.value.teamID, : pipe(
user.uid, 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 { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { User } from 'src/user/user.model';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
@@ -20,26 +24,44 @@ export class TeamInviteeGuard implements CanActivate {
constructor(private readonly teamInviteService: TeamInvitationService) {} constructor(private readonly teamInviteService: TeamInvitationService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
// Get GQL Context return pipe(
const gqlExecCtx = GqlExecutionContext.create(context); TE.Do,
// Get user // Get execution context
const { user } = gqlExecCtx.getContext().req; TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))),
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get the invite // Get user
const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>(); TE.bindW('user', ({ gqlCtx }) =>
if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID); pipe(
O.fromNullable(gqlCtx.getContext().req.user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
const invitation = await this.teamInviteService.getInvitation(inviteID); // Get invite
if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND); 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 ( // Check if the emails match
user.email.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase() TE.chainW(
) { TE.fromPredicate(
throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH); ({ 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, complexity: 10,
}) })
teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> { teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> {
return this.teamInviteService.getTeamInvitations(team.id); return this.teamInviteService.getAllInvitationsInTeam(team)();
} }
} }

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

@@ -174,7 +174,6 @@
"folder": "Folder is empty", "folder": "Folder is empty",
"headers": "This request does not have any headers", "headers": "This request does not have any headers",
"history": "History is empty", "history": "History is empty",
"history_suggestions": "History does not have any matching entries",
"invites": "Invite list is empty", "invites": "Invite list is empty",
"members": "Team is empty", "members": "Team is empty",
"parameters": "This request does not have any parameters", "parameters": "This request does not have any parameters",

View File

@@ -148,7 +148,7 @@
"vite-plugin-html-config": "^1.0.10", "vite-plugin-html-config": "^1.0.10",
"vite-plugin-inspect": "^0.7.4", "vite-plugin-inspect": "^0.7.4",
"vite-plugin-pages": "^0.26.0", "vite-plugin-pages": "^0.26.0",
"vite-plugin-pages-sitemap": "^1.4.5", "vite-plugin-pages-sitemap": "^1.4.0",
"vite-plugin-pwa": "^0.13.1", "vite-plugin-pwa": "^0.13.1",
"vite-plugin-vue-layouts": "^0.7.0", "vite-plugin-vue-layouts": "^0.7.0",
"vite-plugin-windicss": "^1.8.8", "vite-plugin-windicss": "^1.8.8",

View File

@@ -24,8 +24,7 @@ declare module '@vue/runtime-core' {
AppSidenav: typeof import('./components/app/Sidenav.vue')['default'] AppSidenav: typeof import('./components/app/Sidenav.vue')['default']
AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default'] AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default']
AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default'] AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default']
AppSpotlightEntryGQLHistory: typeof import('./components/app/spotlight/entry/GQLHistory.vue')['default'] AppSpotlightEntryHistory: typeof import('./components/app/spotlight/entry/History.vue')['default']
AppSpotlightEntryRESTHistory: typeof import('./components/app/spotlight/entry/RESTHistory.vue')['default']
AppSupport: typeof import('./components/app/Support.vue')['default'] AppSupport: typeof import('./components/app/Support.vue')['default']
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default'] ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default'] ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default']
@@ -132,6 +131,7 @@ declare module '@vue/runtime-core' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default'] IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']

View File

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

View File

@@ -20,9 +20,7 @@
<div class="inline-flex items-center space-x-2"> <div class="inline-flex items-center space-x-2">
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }" v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t( :title="`${t('app.search')} <kbd>/</kbd>`"
'app.search'
)} <kbd>${getPlatformSpecialKey()}</kbd> <kbd>K</kbd>`"
:icon="IconSearch" :icon="IconSearch"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark" class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.search.toggle')" @click="invokeAction('modals.search.toggle')"
@@ -249,7 +247,6 @@ import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter" import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { onLoggedIn } from "~/composables/auth" import { onLoggedIn } from "~/composables/auth"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql" import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
const t = useI18n() const t = useI18n()

View File

@@ -17,6 +17,10 @@
<div class="flex flex-col divide-y divide-dividerLight"> <div class="flex flex-col divide-y divide-dividerLight">
<HoppSmartPlaceholder v-if="isEmpty(shortcutsResults)"> <HoppSmartPlaceholder v-if="isEmpty(shortcutsResults)">
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center flex flex-col">
{{ t("state.nothing_found") }}
<span class="break-all">"{{ filterText }}"</span>
</span>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<details <details
v-for="(sectionResults, sectionTitle) in shortcutsResults" v-for="(sectionResults, sectionTitle) in shortcutsResults"

View File

@@ -1,49 +1,48 @@
<template> <template>
<button <button
ref="el" 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="flex items-center flex-1 px-6 py-3 font-medium transition cursor-pointer search-entry focus:outline-none"
:class="{ 'active bg-primaryLight text-secondaryDark': active }" :class="{ active: active }"
tabindex="-1" tabindex="-1"
@click="emit('action')" @click="emit('action')"
@keydown.enter="emit('action')" @keydown.enter="emit('action')"
> >
<component <component
:is="entry.icon" :is="entry.icon"
class="opacity-50 svg-icons" class="mr-4 transition opacity-50 svg-icons"
:class="{ 'opacity-100': active }" :class="{ 'opacity-100 text-secondaryDark': active }"
/> />
<template <span
v-if="entry.text.type === 'text' && typeof entry.text.text === 'string'" 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 }}
{{ entry.text.text }} </span>
</span> <span
</template>
<template
v-else-if="entry.text.type === 'text' && Array.isArray(entry.text.text)" 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" v-for="(labelPart, labelPartIndex) in entry.text.text"
:key="`label-${labelPart}-${labelPartIndex}`" :key="`label-${labelPart}-${labelPartIndex}`"
> >
<span class="block truncate"> {{ labelPart }}
{{ labelPart }}
</span>
<icon-lucide-chevron-right <icon-lucide-chevron-right
v-if="labelPartIndex < entry.text.text.length - 1" v-if="labelPartIndex < entry.text.text.length - 1"
class="flex flex-shrink-0" class="inline"
/>
</template>
</template>
<template v-else-if="entry.text.type === 'custom'">
<span class="block truncate">
<component
:is="entry.text.component"
v-bind="entry.text.componentProps"
/> />
</span> </span>
</template> </span>
<span v-if="formattedShortcutKeys" class="block truncate"> <span v-else-if="entry.text.type === 'custom'">
<component
:is="entry.text.component"
v-bind="entry.text.componentProps"
/>
</span>
<span v-if="formattedShortcutKeys">
<kbd <kbd
v-for="(key, keyIndex) in formattedShortcutKeys" v-for="(key, keyIndex) in formattedShortcutKeys"
:key="`key-${String(keyIndex)}`" :key="`key-${String(keyIndex)}`"
@@ -106,6 +105,7 @@ watch(
<style lang="scss" scoped> <style lang="scss" scoped>
.search-entry { .search-entry {
@apply relative;
@apply after:absolute; @apply after:absolute;
@apply after:top-0; @apply after:top-0;
@apply after:left-0; @apply after:left-0;
@@ -116,6 +116,7 @@ watch(
@apply after:content-DEFAULT; @apply after:content-DEFAULT;
&.active { &.active {
@apply bg-primaryLight;
@apply after:bg-accentLight; @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> <template>
<span class="flex flex-1 items-center space-x-2"> <span class="flex flex-row space-x-2">
<span class="block truncate"> <span>{{ dateTimeText }}</span>
{{ dateTimeText }} <icon-lucide-chevron-right class="inline" />
<span class="truncate" :class="entryStatus.className">
<span class="font-semibold truncate text-tiny">
{{ historyEntry.request.method }}
</span>
</span> </span>
<icon-lucide-chevron-right class="flex flex-shrink-0" /> <span>
<span
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1"
:class="entryStatus.className"
>
{{ historyEntry.request.method }}
</span>
<span class="block truncate">
{{ historyEntry.request.endpoint }} {{ historyEntry.request.endpoint }}
</span> </span>
</span> </span>

View File

@@ -6,8 +6,8 @@
@close="emit('hide-modal')" @close="emit('hide-modal')"
> >
<template #body> <template #body>
<div class="flex flex-col border-b transition border-divider"> <div class="flex flex-col border-b transition border-dividerLight">
<div class="flex items-center"> <div class="flex items-center p-6 space-x-2">
<input <input
id="command" id="command"
v-model="search" v-model="search"
@@ -16,23 +16,46 @@
autocomplete="off" autocomplete="off"
name="command" name="command"
:placeholder="`${t('app.type_a_command_search')}`" :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> </div>
<div <div
v-if="searchSession && search.length > 0" v-if="searchSession"
class="flex flex-col flex-1 overflow-y-auto border-b border-divider divide-y divide-dividerLight" class="flex flex-col flex-1 overflow-auto space-y-4 divide-y divide-dividerLight"
> >
<div <div
v-for="([sectionID, sectionResult], sectionIndex) in scoredResults" v-for="([sectionID, sectionResult], sectionIndex) in scoredResults"
:key="`section-${sectionID}`" :key="`section-${sectionID}`"
class="flex flex-col" class="flex flex-col"
> >
<h5 <h5 class="px-6 py-2 my-2 text-secondaryLight">
class="px-6 py-2 bg-primaryContrast z-10 text-secondaryLight sticky top-0"
>
{{ sectionResult.title }} {{ sectionResult.title }}
</h5> </h5>
<AppSpotlightEntry <AppSpotlightEntry
@@ -44,41 +67,15 @@
@action="runAction(sectionID, result)" @action="runAction(sectionID, result)"
/> />
</div> </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>
<div <HoppSmartPlaceholder
class="flex flex-shrink-0 text-tiny text-secondaryLight p-4 justify-between whitespace-nowrap overflow-auto <sm:hidden" v-if="scoredResults.length === 0 && search.length > 0"
:text="`${t('state.nothing_found')} ‟${search}”`"
> >
<div class="flex items-center"> <template #icon>
<kbd class="shortcut-key"></kbd> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<kbd class="shortcut-key"></kbd> </template>
<span class="mx-2 truncate"> </HoppSmartPlaceholder>
{{ 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> </template>
</HoppSmartModal> </HoppSmartModal>
</template> </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) { function handleKeyPress(e: KeyboardEvent) {
if (e.key === "ArrowUp") { if (e.key === "ArrowUp") {
e.preventDefault() e.preventDefault()
@@ -218,7 +204,11 @@ function newUseArrowKeysForNavigation() {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
onEnter() const [sectionIndex, entryIndex] = selectedEntry.value
const [sectionID, section] = scoredResults.value[sectionIndex]
const result = section.results[entryIndex]
runAction(sectionID, result)
} }
} }

View File

@@ -63,7 +63,7 @@ import { GQLHistoryEntry } from "~/newstore/history"
import { shortDateTime } from "~/helpers/utils/date" import { shortDateTime } from "~/helpers/utils/date"
import IconStar from "~icons/lucide/star" import IconStar from "~icons/lucide/star"
import IconStarOff from "~icons/hopp/star-off" import IconStarOff from "~icons/lucide/star-off"
import IconTrash from "~icons/lucide/trash" import IconTrash from "~icons/lucide/trash"
import IconMinimize2 from "~icons/lucide/minimize-2" import IconMinimize2 from "~icons/lucide/minimize-2"
import IconMaximize2 from "~icons/lucide/maximize-2" import IconMaximize2 from "~icons/lucide/maximize-2"

View File

@@ -55,7 +55,7 @@ import { RESTHistoryEntry } from "~/newstore/history"
import { shortDateTime } from "~/helpers/utils/date" import { shortDateTime } from "~/helpers/utils/date"
import IconStar from "~icons/lucide/star" import IconStar from "~icons/lucide/star"
import IconStarOff from "~icons/hopp/star-off" import IconStarOff from "~icons/lucide/star-off"
import IconTrash from "~icons/lucide/trash" import IconTrash from "~icons/lucide/trash"
const props = defineProps<{ const props = defineProps<{

View File

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

View File

@@ -1,44 +1,19 @@
<template> <template>
<div class="autocomplete-wrapper"> <div
<div class="absolute inset-0 flex flex-1 overflow-x-auto"> 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 <div
ref="editor" ref="editor"
:placeholder="placeholder" :placeholder="placeholder"
class="flex flex-1" class="flex flex-1"
:class="styles" :class="styles"
@keydown.enter.prevent="emit('enter', $event)"
@keyup="emit('keyup', $event)"
@click="emit('click', $event)" @click="emit('click', $event)"
@keydown="handleKeystroke" @keydown="emit('keydown', $event)"
@focusin="showSuggestionPopover = true"
></div> ></div>
</div> </div>
<ul
v-if="showSuggestionPopover && autoCompleteSource"
ref="suggestionsMenu"
class="suggestions"
>
<li
v-for="(suggestion, index) in suggestions"
:key="`suggestion-${index}`"
:class="{ active: currentSuggestionIndex === index }"
@click="updateModelValue(suggestion)"
>
<span class="truncate py-0.5">
{{ suggestion }}
</span>
<div
v-if="currentSuggestionIndex === index"
class="hidden md:flex text-secondary items-center"
>
<kbd class="shortcut-key">TAB</kbd>
<span class="ml-2 truncate">to select</span>
</div>
</li>
<li v-if="suggestions.length === 0" class="pointer-events-none">
<span class="truncate py-0.5">
{{ t("empty.history_suggestions") }}
</span>
</li>
</ul>
</div> </div>
</template> </template>
@@ -60,8 +35,6 @@ import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironme
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments" import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
import { platform } from "~/platform" import { platform } from "~/platform"
import { useI18n } from "~/composables/i18n"
import { onClickOutside } from "@vueuse/core"
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -73,7 +46,6 @@ const props = withDefaults(
selectTextOnMount?: boolean selectTextOnMount?: boolean
environmentHighlights?: boolean environmentHighlights?: boolean
readonly?: boolean readonly?: boolean
autoCompleteSource?: string[]
}>(), }>(),
{ {
modelValue: "", modelValue: "",
@@ -83,7 +55,6 @@ const props = withDefaults(
focus: false, focus: false,
readonly: false, readonly: false,
environmentHighlights: true, environmentHighlights: true,
autoCompleteSource: undefined,
} }
) )
@@ -97,160 +68,12 @@ const emit = defineEmits<{
(e: "click", ev: any): void (e: "click", ev: any): void
}>() }>()
const t = useI18n()
const cachedValue = ref(props.modelValue) const cachedValue = ref(props.modelValue)
const view = ref<EditorView>() const view = ref<EditorView>()
const editor = ref<any | null>(null) const editor = ref<any | null>(null)
const currentSuggestionIndex = ref(-1)
const showSuggestionPopover = ref(false)
const suggestionsMenu = ref<any | null>(null)
onClickOutside(suggestionsMenu, () => {
showSuggestionPopover.value = false
})
//filter autocompleteSource with unique values
const uniqueAutoCompleteSource = computed(() => {
if (props.autoCompleteSource) {
return [...new Set(props.autoCompleteSource)]
} else {
return []
}
})
const suggestions = computed(() => {
if (
props.modelValue &&
props.modelValue.length > 0 &&
uniqueAutoCompleteSource.value &&
uniqueAutoCompleteSource.value.length > 0
) {
return uniqueAutoCompleteSource.value.filter((suggestion) =>
suggestion.toLowerCase().includes(props.modelValue.toLowerCase())
)
} else {
return uniqueAutoCompleteSource.value ?? []
}
})
const updateModelValue = (value: string) => {
emit("update:modelValue", value)
emit("change", value)
showSuggestionPopover.value = false
}
const handleKeystroke = (ev: KeyboardEvent) => {
if (["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"].includes(ev.key)) {
ev.preventDefault()
}
showSuggestionPopover.value = true
if (
["Enter", "Tab"].includes(ev.key) &&
suggestions.value.length > 0 &&
currentSuggestionIndex.value > -1
) {
updateModelValue(suggestions.value[currentSuggestionIndex.value])
currentSuggestionIndex.value = -1
//used to set codemirror cursor at the end of the line after selecting a suggestion
nextTick(() => {
view.value?.dispatch({
selection: EditorSelection.create([
EditorSelection.range(
props.modelValue.length,
props.modelValue.length
),
]),
})
})
}
if (ev.key === "ArrowDown") {
scrollActiveElIntoView()
currentSuggestionIndex.value =
currentSuggestionIndex.value < suggestions.value.length - 1
? currentSuggestionIndex.value + 1
: suggestions.value.length - 1
emit("keydown", ev)
}
if (ev.key === "ArrowUp") {
scrollActiveElIntoView()
currentSuggestionIndex.value =
currentSuggestionIndex.value - 1 >= 0
? currentSuggestionIndex.value - 1
: 0
emit("keyup", ev)
}
if (ev.key === "Enter") {
emit("enter", ev)
showSuggestionPopover.value = false
}
if (ev.key === "Escape") {
showSuggestionPopover.value = false
}
// used to scroll to the first suggestion when left arrow is pressed
if (ev.key === "ArrowLeft") {
if (suggestions.value.length > 0) {
currentSuggestionIndex.value = 0
nextTick(() => {
scrollActiveElIntoView()
})
}
}
// used to scroll to the last suggestion when right arrow is pressed
if (ev.key === "ArrowRight") {
if (suggestions.value.length > 0) {
currentSuggestionIndex.value = suggestions.value.length - 1
nextTick(() => {
scrollActiveElIntoView()
})
}
}
}
// reset currentSuggestionIndex showSuggestionPopover is false
watch(
() => showSuggestionPopover.value,
(newVal) => {
if (!newVal) {
currentSuggestionIndex.value = -1
}
}
)
/**
* Used to scroll the active suggestion into view
*/
const scrollActiveElIntoView = () => {
const suggestionsMenuEl = suggestionsMenu.value
if (suggestionsMenuEl) {
const activeSuggestionEl = suggestionsMenuEl.querySelector(".active")
if (activeSuggestionEl) {
activeSuggestionEl.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "start",
})
}
}
}
watch( watch(
() => props.modelValue, () => props.modelValue,
(newVal) => { (newVal) => {
@@ -413,49 +236,3 @@ watch(editor, () => {
} }
}) })
</script> </script>
<style lang="scss" scoped>
.autocomplete-wrapper {
@apply relative;
@apply flex;
@apply flex-1;
@apply flex-shrink-0;
@apply whitespace-nowrap;
.suggestions {
@apply absolute;
@apply bg-popover;
@apply z-50;
@apply shadow-lg;
@apply max-h-46;
@apply border-b border-x border-divider;
@apply overflow-y-auto;
@apply -left-[1px];
@apply right-0;
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

@@ -4,8 +4,6 @@
import { Ref, onBeforeUnmount, onMounted, watch } from "vue" import { Ref, onBeforeUnmount, onMounted, watch } from "vue"
import { BehaviorSubject } from "rxjs" import { BehaviorSubject } from "rxjs"
import { HoppRESTDocument } from "./rest/document"
import { HoppGQLRequest } from "@hoppscotch/data"
export type HoppAction = export type HoppAction =
| "request.send-cancel" // Send/Cancel a Hoppscotch Request | "request.send-cancel" // Send/Cancel a Hoppscotch Request
@@ -24,6 +22,8 @@ export type HoppAction =
| "modals.search.toggle" // Shows the search modal | "modals.search.toggle" // Shows the search modal
| "modals.support.toggle" // Shows the support modal | "modals.support.toggle" // Shows the support modal
| "modals.share.toggle" // Shows the share modal | "modals.share.toggle" // Shows the share modal
| "modals.my.environment.edit" // Edit current personal environment
| "modals.team.environment.edit" // Edit current team environment
| "navigation.jump.rest" // Jump to REST page | "navigation.jump.rest" // Jump to REST page
| "navigation.jump.graphql" // Jump to GraphQL page | "navigation.jump.graphql" // Jump to GraphQL page
| "navigation.jump.realtime" // Jump to realtime page | "navigation.jump.realtime" // Jump to realtime page
@@ -53,7 +53,7 @@ export type HoppAction =
* NOTE: We can't enforce type checks to make sure the key is Action, you * NOTE: We can't enforce type checks to make sure the key is Action, you
* will know if you got something wrong if there is a type error in this file * will know if you got something wrong if there is a type error in this file
*/ */
type HoppActionArgsMap = { type HoppActionArgs = {
"modals.my.environment.edit": { "modals.my.environment.edit": {
envName: string envName: string
variableName: string variableName: string
@@ -62,18 +62,12 @@ type HoppActionArgsMap = {
envName: string envName: string
variableName: string variableName: string
} }
"rest.request.open": {
doc: HoppRESTDocument
}
"gql.request.open": {
request: HoppGQLRequest
}
} }
/** /**
* HoppActions which require arguments for their invocation * 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 * HoppActions which do not require arguments for their invocation
@@ -83,27 +77,27 @@ export type HoppActionWithNoArgs = Exclude<HoppAction, HoppActionWithArgs>
/** /**
* Resolves the argument type for a given HoppAction * Resolves the argument type for a given HoppAction
*/ */
type ArgOfHoppAction<A extends HoppAction | HoppActionWithArgs> = type ArgOfHoppAction<A extends HoppAction> = A extends HoppActionWithArgs
A extends HoppActionWithArgs ? HoppActionArgsMap[A] : undefined ? HoppActionArgs[A]
: undefined
/** /**
* Resolves the action function for a given HoppAction, used by action handler function defs * Resolves the action function for a given HoppAction, used by action handler function defs
*/ */
type ActionFunc<A extends HoppAction | HoppActionWithArgs> = type ActionFunc<A extends HoppAction> = A extends HoppActionWithArgs
A extends HoppActionWithArgs ? (arg: ArgOfHoppAction<A>) => void : () => void ? (arg: ArgOfHoppAction<A>) => void
: () => void
type BoundActionList = { type BoundActionList = {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
[A in HoppAction | HoppActionWithArgs]?: Array<ActionFunc<A>> [A in HoppAction]?: Array<ActionFunc<A>>
} }
const boundActions: BoundActionList = {} const boundActions: BoundActionList = {}
export const activeActions$ = new BehaviorSubject< export const activeActions$ = new BehaviorSubject<HoppAction[]>([])
(HoppAction | HoppActionWithArgs)[]
>([])
export function bindAction<A extends HoppAction | HoppActionWithArgs>( export function bindAction<A extends HoppAction>(
action: A, action: A,
handler: ActionFunc<A> handler: ActionFunc<A>
) { ) {
@@ -119,7 +113,7 @@ export function bindAction<A extends HoppAction | HoppActionWithArgs>(
type InvokeActionFunc = { type InvokeActionFunc = {
(action: HoppActionWithNoArgs, args?: undefined): void (action: HoppActionWithNoArgs, args?: undefined): void
<A extends HoppActionWithArgs>(action: A, args: HoppActionArgsMap[A]): void <A extends HoppActionWithArgs>(action: A, args: ArgOfHoppAction<A>): void
} }
/** /**
@@ -128,16 +122,14 @@ type InvokeActionFunc = {
* @param action The action to fire * @param action The action to fire
* @param args The argument passed to the action handler. Optional if action has no args required * @param args The argument passed to the action handler. Optional if action has no args required
*/ */
export const invokeAction: InvokeActionFunc = < export const invokeAction: InvokeActionFunc = <A extends HoppAction>(
A extends HoppAction | HoppActionWithArgs
>(
action: A, action: A,
args: ArgOfHoppAction<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, action: A,
handler: ActionFunc<A> handler: ActionFunc<A>
) { ) {
@@ -161,22 +153,19 @@ export function unbindAction<A extends HoppAction | HoppActionWithArgs>(
* @param handler The function to be called when the action is invoked * @param handler The function to be called when the action is invoked
* @param isActive A ref that indicates whether the action is active * @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, action: A,
handler: ActionFunc<A>, handler: ActionFunc<A>,
isActive: Ref<boolean> | undefined = undefined isActive: Ref<boolean> | undefined = undefined
) { ) {
let mounted = false let mounted = false
let bound = false let bound = true
onMounted(() => { onMounted(() => {
mounted = true mounted = true
bound = true
// Only bind if isActive is undefined or true bindAction(action, handler)
if (isActive === undefined || isActive.value === true) {
bound = true
bindAction(action, handler)
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -187,23 +176,19 @@ export function defineActionHandler<A extends HoppAction | HoppActionWithArgs>(
}) })
if (isActive) { if (isActive) {
watch( watch(isActive, (active) => {
isActive, if (mounted) {
(active) => { if (active) {
if (mounted) { if (!bound) {
if (active) { bound = true
if (!bound) { bindAction(action, handler)
bound = true
bindAction(action, handler)
}
} else if (bound) {
bound = false
unbindAction(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-p": "request.method.post",
"alt-u": "request.method.put", "alt-u": "request.method.put",
"alt-x": "request.method.delete", "alt-x": "request.method.delete",
"ctrl-k": "modals.search.toggle", "ctrl-k": "flyouts.keybinds.toggle",
"ctrl-/": "flyouts.keybinds.toggle", "/": "modals.search.toggle",
"?": "modals.support.toggle", "?": "modals.support.toggle",
"ctrl-m": "modals.share.toggle", "ctrl-m": "modals.share.toggle",
"alt-r": "navigation.jump.rest", "alt-r": "navigation.jump.rest",

View File

@@ -17,10 +17,7 @@
import { usePageHead } from "@composables/head" import { usePageHead } from "@composables/head"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { GQLConnection } from "@helpers/GQLConnection" import { GQLConnection } from "@helpers/GQLConnection"
import { cloneDeep } from "lodash-es"
import { computed, onBeforeUnmount } from "vue" import { computed, onBeforeUnmount } from "vue"
import { defineActionHandler } from "~/helpers/actions"
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
const t = useI18n() const t = useI18n()
@@ -35,14 +32,4 @@ onBeforeUnmount(() => {
gqlConn.disconnect() gqlConn.disconnect()
} }
}) })
defineActionHandler("gql.request.open", ({ request }) => {
const session = getGQLSession()
setGQLSession({
request: cloneDeep(request),
schema: session.schema,
response: session.response,
})
})
</script> </script>

View File

@@ -108,7 +108,7 @@ import {
updateTabOrdering, updateTabOrdering,
} from "~/helpers/rest/tab" } from "~/helpers/rest/tab"
import { getDefaultRESTRequest } from "~/helpers/rest/default" import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { defineActionHandler, invokeAction } from "~/helpers/actions" import { invokeAction } from "~/helpers/actions"
import { onLoggedIn } from "~/composables/auth" import { onLoggedIn } from "~/composables/auth"
import { platform } from "~/platform" import { platform } from "~/platform"
import { import {
@@ -368,8 +368,4 @@ function oAuthURL() {
setupTabStateSync() setupTabStateSync()
bindRequestToURLParams() bindRequestToURLParams()
oAuthURL() oAuthURL()
defineActionHandler("rest.request.open", ({ doc }) => {
createNewTab(doc)
})
</script> </script>

View File

@@ -3,10 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"
import { HistorySpotlightSearcherService } from "../history.searcher" import { HistorySpotlightSearcherService } from "../history.searcher"
import { nextTick, ref } from "vue" import { nextTick, ref } from "vue"
import { SpotlightService } from "../.." import { SpotlightService } from "../.."
import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history" import { RESTHistoryEntry } from "~/newstore/history"
import { getDefaultRESTRequest } from "~/helpers/rest/default" import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppAction, HoppActionWithArgs } from "~/helpers/actions"
import { defaultGQLSession } from "~/newstore/GQLSession"
async function flushPromises() { async function flushPromises() {
return await new Promise((r) => setTimeout(r)) return await new Promise((r) => setTimeout(r))
@@ -27,7 +25,7 @@ vi.mock("~/modules/i18n", () => ({
})) }))
const actionsMock = vi.hoisted(() => ({ const actionsMock = vi.hoisted(() => ({
value: [] as (HoppAction | HoppActionWithArgs)[], value: [] as string[],
invokeAction: vi.fn(), invokeAction: vi.fn(),
})) }))
@@ -42,20 +40,14 @@ vi.mock("~/helpers/actions", async () => {
}) })
const historyMock = vi.hoisted(() => ({ const historyMock = vi.hoisted(() => ({
restEntries: [] as RESTHistoryEntry[], entries: [] as RESTHistoryEntry[],
gqlEntries: [] as GQLHistoryEntry[],
})) }))
vi.mock("~/newstore/history", () => ({ vi.mock("~/newstore/history", () => ({
__esModule: true, __esModule: true,
restHistoryStore: { restHistoryStore: {
value: { value: {
state: historyMock.restEntries, state: historyMock.entries,
},
},
graphqlHistoryStore: {
value: {
state: historyMock.gqlEntries,
}, },
}, },
})) }))
@@ -67,9 +59,9 @@ describe("HistorySpotlightSearcherService", () => {
x = actionsMock.value.pop() x = actionsMock.value.pop()
} }
let y = historyMock.restEntries.pop() let y = historyMock.entries.pop()
while (y) { while (y) {
y = historyMock.restEntries.pop() y = historyMock.entries.pop()
} }
actionsMock.invokeAction.mockReset() actionsMock.invokeAction.mockReset()
@@ -144,10 +136,8 @@ describe("HistorySpotlightSearcherService", () => {
expect(actionsMock.invokeAction).toHaveBeenCalledWith("history.clear") expect(actionsMock.invokeAction).toHaveBeenCalledWith("history.clear")
}) })
it("returns all the valid rest history entries for the search term", async () => { it("returns all the valid history entries for the search term", async () => {
actionsMock.value.push("rest.request.open") historyMock.entries.push({
historyMock.restEntries.push({
request: { request: {
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "bla.com", endpoint: "bla.com",
@@ -172,22 +162,20 @@ describe("HistorySpotlightSearcherService", () => {
expect(result.value.results).toContainEqual( expect(result.value.results).toContainEqual(
expect.objectContaining({ expect.objectContaining({
id: "rest-0", id: "0",
text: { text: {
type: "custom", type: "custom",
component: expect.anything(), component: expect.anything(),
componentProps: expect.objectContaining({ 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 () => { it("selecting a history entry asks the tab system to open a new tab", async () => {
actionsMock.value.push("rest.request.open") historyMock.entries.push({
const historyEntry: RESTHistoryEntry = {
request: { request: {
...getDefaultRESTRequest(), ...getDefaultRESTRequest(),
endpoint: "bla.com", endpoint: "bla.com",
@@ -199,9 +187,7 @@ describe("HistorySpotlightSearcherService", () => {
star: false, star: false,
v: 1, v: 1,
updatedOn: new Date(), updatedOn: new Date(),
} })
historyMock.restEntries.push(historyEntry)
const container = new TestContainer() const container = new TestContainer()
@@ -216,229 +202,10 @@ describe("HistorySpotlightSearcherService", () => {
history.onResultSelect(doc) history.onResultSelect(doc)
expect(actionsMock.invokeAction).toHaveBeenCalledOnce() expect(tabMock.createNewTab).toHaveBeenCalledOnce()
expect(actionsMock.invokeAction).toHaveBeenCalledWith("rest.request.open", { expect(tabMock.createNewTab).toHaveBeenCalledWith({
doc: { request: historyMock.entries[0].request,
request: historyEntry.request, isDirty: false,
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 { Ref, computed, effectScope, markRaw, ref, watch } from "vue"
import { getI18n } from "~/modules/i18n" import { getI18n } from "~/modules/i18n"
import MiniSearch from "minisearch" import MiniSearch from "minisearch"
import { graphqlHistoryStore, restHistoryStore } from "~/newstore/history" import { restHistoryStore } from "~/newstore/history"
import { useTimeAgo } from "@vueuse/core" import { useTimeAgo } from "@vueuse/core"
import IconHistory from "~icons/lucide/history" import IconHistory from "~icons/lucide/history"
import IconTrash2 from "~icons/lucide/trash-2" import IconTrash2 from "~icons/lucide/trash-2"
import SpotlightRESTHistoryEntry from "~/components/app/spotlight/entry/RESTHistory.vue" import SpotlightHistoryEntry from "~/components/app/spotlight/entry/History.vue"
import SpotlightGQLHistoryEntry from "~/components/app/spotlight/entry/GQLHistory.vue" import { createNewTab } from "~/helpers/rest/tab"
import { capitalize } from "lodash-es" import { capitalize } from "lodash-es"
import { shortDateTime } from "~/helpers/utils/date" import { shortDateTime } from "~/helpers/utils/date"
import { useStreamStatic } from "~/composables/stream" import { useStreamStatic } from "~/composables/stream"
import { activeActions$, invokeAction } from "~/helpers/actions" import { activeActions$, invokeAction } from "~/helpers/actions"
import { map } from "rxjs/operators" import { map } from "rxjs/operators"
import { HoppRESTDocument } from "~/helpers/rest/document"
/** /**
* This searcher is responsible for searching through the history. * This searcher is responsible for searching through the history.
@@ -48,24 +47,6 @@ export class HistorySpotlightSearcherService
} }
)[0] )[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() { constructor() {
super() super()
@@ -102,47 +83,24 @@ export class HistorySpotlightSearcherService
{ immediate: true } { immediate: true }
) )
if (this.restHistoryEntryOpenable.value) { minisearch.addAll(
minisearch.addAll( restHistoryStore.value.state
restHistoryStore.value.state .filter((x) => !!x.updatedOn)
.filter((x) => !!x.updatedOn) .map((entry, index) => {
.map((entry, index) => { const relTimeString = capitalize(
const relTimeString = capitalize( useTimeAgo(entry.updatedOn!, {
useTimeAgo(entry.updatedOn!, { updateInterval: 0,
updateInterval: 0, }).value
}).value )
)
return { return {
id: `rest-${index}`, id: index.toString(),
url: entry.request.endpoint, url: entry.request.endpoint,
reltime: relTimeString, reltime: relTimeString,
date: shortDateTime(entry.updatedOn!), 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!),
}
})
)
}
const scopeHandle = effectScope() const scopeHandle = effectScope()
@@ -163,6 +121,8 @@ export class HistorySpotlightSearcherService
}, },
}) })
.map((x) => { .map((x) => {
const entry = restHistoryStore.value.state[parseInt(x.id)]
if (x.id === "clear-history") { if (x.id === "clear-history") {
return { return {
id: "clear-history", 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 { return {
id: x.id, id: x.id,
icon: markRaw(IconHistory), icon: markRaw(IconHistory),
score: x.score, score: x.score,
text: { text: {
type: "custom", type: "custom",
component: markRaw(SpotlightRESTHistoryEntry), component: markRaw(SpotlightHistoryEntry),
componentProps: { componentProps: {
historyEntry: entry, 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 { onResultSelect(result: SpotlightSearcherResult): void {
if (result.id === "clear-history") { if (result.id === "clear-history") {
invokeAction("history.clear") invokeAction("history.clear")
} else if (result.id.startsWith("rest")) { return
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,
})
} }
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: { login: {
text: this.t("auth.login"), text: this.t("auth.login"),
excludeFromSearch: computed(() => !this.hasLoginAction.value), excludeFromSearch: computed(() => !this.hasLoginAction.value),
alternates: ["sign in", "log in"], alternates: ["sign in"],
icon: markRaw(IconLogin), icon: markRaw(IconLogin),
}, },
logout: { logout: {
text: this.t("auth.logout"), text: this.t("auth.logout"),
excludeFromSearch: computed(() => !this.hasLogoutAction.value), excludeFromSearch: computed(() => !this.hasLogoutAction.value),
alternates: ["sign out", "log out"], alternates: ["sign out"],
icon: markRaw(IconLogOut), icon: markRaw(IconLogOut),
}, },
}) })

View File

@@ -79,7 +79,8 @@ export default defineConfig({
dirs: "../hoppscotch-common/src/pages", dirs: "../hoppscotch-common/src/pages",
importMode: "async", importMode: "async",
onRoutesGenerated(routes) { onRoutesGenerated(routes) {
return generateSitemap({ // HACK: See: https://github.com/jbaubree/vite-plugin-pages-sitemap/issues/173
return ((generateSitemap as any).default as typeof generateSitemap)({
routes, routes,
nuxtStyle: true, nuxtStyle: true,
allowRobots: true, allowRobots: true,