Compare commits
18 Commits
fix/genera
...
pr/AndrewB
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60303ef7c | ||
|
|
53e4863a80 | ||
|
|
3340d0813c | ||
|
|
7c5722586c | ||
|
|
6b38363fab | ||
|
|
14202a3eed | ||
|
|
ea03223b8e | ||
|
|
235deb113c | ||
|
|
833e11ab0b | ||
|
|
0d101673d2 | ||
|
|
6fe565c30f | ||
|
|
4164de5a9e | ||
|
|
1ff35f45ee | ||
|
|
3bf8288de3 | ||
|
|
8c48d41eed | ||
|
|
38215be3bd | ||
|
|
edf57da9be | ||
|
|
be61b62825 |
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { ArgsType, Field, ID } from '@nestjs/graphql';
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class CreateTeamEnvironmentArgs {
|
|
||||||
@Field({
|
|
||||||
name: 'name',
|
|
||||||
description: 'Name of the Team Environment',
|
|
||||||
})
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the Team',
|
|
||||||
})
|
|
||||||
teamID: string;
|
|
||||||
|
|
||||||
@Field({
|
|
||||||
name: 'variables',
|
|
||||||
description: 'JSON string of the variables object',
|
|
||||||
})
|
|
||||||
variables: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class UpdateTeamEnvironmentArgs {
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'id',
|
|
||||||
description: 'ID of the Team Environment',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
@Field({
|
|
||||||
name: 'name',
|
|
||||||
description: 'Name of the Team Environment',
|
|
||||||
})
|
|
||||||
name: string;
|
|
||||||
@Field({
|
|
||||||
name: 'variables',
|
|
||||||
description: 'JSON string of the variables object',
|
|
||||||
})
|
|
||||||
variables: string;
|
|
||||||
}
|
|
||||||
@@ -13,11 +13,6 @@ import { throwErr } from 'src/utils';
|
|||||||
import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
|
import { 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 */
|
||||||
|
|||||||
@@ -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([{}]),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { ArgsType, Field, ID } from '@nestjs/graphql';
|
|
||||||
import { TeamMemberRole } from 'src/team/team.model';
|
|
||||||
|
|
||||||
@ArgsType()
|
|
||||||
export class CreateTeamInvitationArgs {
|
|
||||||
@Field(() => ID, {
|
|
||||||
name: 'teamID',
|
|
||||||
description: 'ID of the Team ID to invite from',
|
|
||||||
})
|
|
||||||
teamID: string;
|
|
||||||
|
|
||||||
@Field({ name: 'inviteeEmail', description: 'Email of the user to invite' })
|
|
||||||
inviteeEmail: string;
|
|
||||||
|
|
||||||
@Field(() => TeamMemberRole, {
|
|
||||||
name: 'inviteeRole',
|
|
||||||
description: 'Role to be given to the user',
|
|
||||||
})
|
|
||||||
inviteeRole: TeamMemberRole;
|
|
||||||
}
|
|
||||||
@@ -12,10 +12,15 @@ import { TeamInvitation } from './team-invitation.model';
|
|||||||
import { TeamInvitationService } from './team-invitation.service';
|
import { 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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
)();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
)();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
)();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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')"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<template>
|
|
||||||
<span class="flex flex-1 items-center space-x-2">
|
|
||||||
<span class="block truncate">
|
|
||||||
{{ dateTimeText }}
|
|
||||||
</span>
|
|
||||||
<icon-lucide-chevron-right class="flex flex-shrink-0" />
|
|
||||||
<span class="block truncate">
|
|
||||||
{{ historyEntry.request.url }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1"
|
|
||||||
>
|
|
||||||
{{ historyEntry.request.query.split("\n")[0] }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from "vue"
|
|
||||||
import { shortDateTime } from "~/helpers/utils/date"
|
|
||||||
import { GQLHistoryEntry } from "~/newstore/history"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
historyEntry: GQLHistoryEntry
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const dateTimeText = computed(() =>
|
|
||||||
shortDateTime(props.historyEntry.updatedOn!)
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
<template>
|
<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>
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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")}`)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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 }
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user