Compare commits

..

30 Commits

Author SHA1 Message Date
nivedin
a870725abf chore: enable cm scrollbar 2024-02-22 00:37:35 +05:30
nivedin
cd7429dd24 fix: add parent containers for codemirror instances 2024-02-22 00:37:35 +05:30
nivedin
e6cc235532 fix: add parent containers for codemirror instances 2024-02-22 00:37:35 +05:30
nivedin
6a65eb03e7 fix: add parent container for codemirror instance in gql schema 2024-02-22 00:37:35 +05:30
nivedin
c66ea66689 fix: increase paintfull performance 2024-02-22 00:37:35 +05:30
nivedin
8b0f337280 chore: remove unwanted style 2024-02-22 00:37:35 +05:30
nivedin
2e8a04fbd6 fix: add parent container for codemirror instance in realtime 2024-02-22 00:37:35 +05:30
nivedin
7e1ebd5b43 fix: add parent container for codemirror instance in gql 2024-02-22 00:37:35 +05:30
nivedin
1eb5e02100 fix: add parent container for codemirror instance 2024-02-22 00:37:35 +05:30
nivedin
6198a25bc6 fix: absolute gql response codemirror position 2024-02-22 00:37:35 +05:30
James George
342532c9b1 fix(common): prevent exceptions with open shared requests in new tab action (#3835) 2024-02-22 00:36:45 +05:30
James George
4bd54b12cd fix(persistence-service): add fallbacks for environments related schemas (#3832) 2024-02-15 23:38:56 +05:30
Andrew Bastin
ed6e9b6954 chore: bump version to 2023.12.5 2024-02-15 21:47:58 +05:30
James George
dfdd44b4ed fix(persistence-service): update global environment variables schema (#3829) 2024-02-15 21:40:31 +05:30
Akash K
fc34871dae fix: accessing undefined property variables (#3831) 2024-02-15 21:32:50 +05:30
Nivedin
45b532747e fix: environment tooltip update bug (#3819) 2024-02-13 17:42:02 +05:30
Akash K
de4635df23 chore: add workspace type property in request run analytics event (#3820)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-02-13 17:38:11 +05:30
Nivedin
41bad1f3dc fix: secret environment flow bugs (#3817) 2024-02-10 20:22:10 +05:30
Andrew Bastin
ecca3d2032 chore: correct linting errors 2024-02-09 14:42:12 +05:30
Muhammed Ajmal M
47226be6d0 feat: persist line wrap setting (#3647)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-02-09 14:05:09 +05:30
Andrew Bastin
6a0e73fdec chore: bump versions 2024-02-08 22:41:59 +05:30
Andrew Bastin
672ee69b2c chore: correct linting errors 2024-02-08 22:33:03 +05:30
James George
c0fae79678 fix(sh-admin): persist active selection in the sidebar (#3812) 2024-02-08 22:16:33 +05:30
James George
5bcc38e36b feat: support secret environment variables in CLI (#3815) 2024-02-08 22:08:18 +05:30
Nivedin
00862eb192 feat: secret variables in environments (#3779)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-02-08 21:58:42 +05:30
Akash K
16803acb26 chore: Oauth temporary ux improvements (#3792) 2024-02-06 20:35:29 +05:30
Nivedin
3911c9cd1f refactor: update share request flow (#3805) 2024-02-05 23:50:15 +05:30
Florian Metz
0028f6e878 feat(js-sandbox): expose atob & btoa functions for Node.js (#3724)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-02-05 23:12:55 +05:30
Anwarul Islam
0ba33ec187 fix: request endpoint heading (#3804) 2024-02-05 23:08:16 +05:30
James George
d7cdeb796a chore(common): analytics on spotlight (#3727)
Co-authored-by: amk-dev <akash.k.mohan98@gmail.com>
2024-02-02 15:32:06 +05:30
176 changed files with 14729 additions and 6606 deletions

View File

@@ -118,7 +118,7 @@ services:
restart: always
environment:
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
# - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3000
volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.

View File

@@ -1,6 +1,6 @@
{
"name": "hoppscotch-backend",
"version": "2023.12.3",
"version": "2023.12.5",
"description": "",
"author": "",
"private": true,

View File

@@ -27,7 +27,9 @@ import {
} from './input-types.args';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { UserDeletionResult } from 'src/user/user.model';
import { User } from 'src/user/user.model';
import { PaginationArgs } from 'src/types/input-types.args';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Admin)
@@ -47,6 +49,203 @@ export class AdminResolver {
return admin;
}
@ResolveField(() => [User], {
description: 'Returns a list of all admin users in infra',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async admins() {
const admins = await this.adminService.fetchAdmins();
return admins;
}
@ResolveField(() => User, {
description: 'Returns a user info by UID',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async userInfo(
@Args({
name: 'userUid',
type: () => ID,
description: 'The user UID',
})
userUid: string,
): Promise<AuthUser> {
const user = await this.adminService.fetchUserInfo(userUid);
if (E.isLeft(user)) throwErr(user.left);
return user.right;
}
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(
@Parent() admin: Admin,
@Args() args: PaginationArgs,
): Promise<AuthUser[]> {
const users = await this.adminService.fetchUsers(args.cursor, args.take);
return users;
}
@ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users',
deprecationReason: 'Use `infra` query instead',
})
async invitedUsers(@Parent() admin: Admin): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers();
return users;
}
@ResolveField(() => [Team], {
description: 'Returns a list of all the teams in the infra',
deprecationReason: 'Use `infra` query instead',
})
async allTeams(
@Parent() admin: Admin,
@Args() args: PaginationArgs,
): Promise<Team[]> {
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
return teams;
}
@ResolveField(() => Team, {
description: 'Returns a team info by ID when requested by Admin',
deprecationReason: 'Use `infra` query instead',
})
async teamInfo(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which info to fetch',
})
teamID: string,
): Promise<Team> {
const team = await this.adminService.getTeamInfo(teamID);
if (E.isLeft(team)) throwErr(team.left);
return team.right;
}
@ResolveField(() => Number, {
description: 'Return count of all the members in a team',
deprecationReason: 'Use `infra` query instead',
})
async membersCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
nullable: false,
})
teamID: string,
): Promise<number> {
const teamMembersCount = await this.adminService.membersCountInTeam(teamID);
return teamMembersCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored collections in a team',
deprecationReason: 'Use `infra` query instead',
})
async collectionCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamCollCount = await this.adminService.collectionCountInTeam(teamID);
return teamCollCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored requests in a team',
deprecationReason: 'Use `infra` query instead',
})
async requestCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamReqCount = await this.adminService.requestCountInTeam(teamID);
return teamReqCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored environments in a team',
deprecationReason: 'Use `infra` query instead',
})
async environmentCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const envsCount = await this.adminService.environmentCountInTeam(teamID);
return envsCount;
}
@ResolveField(() => [TeamInvitation], {
description: 'Return all the pending invitations in a team',
deprecationReason: 'Use `infra` query instead',
})
async pendingInvitationCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
) {
const invitations = await this.adminService.pendingInvitationCountInTeam(
teamID,
);
return invitations;
}
@ResolveField(() => Number, {
description: 'Return total number of Users in organization',
deprecationReason: 'Use `infra` query instead',
})
async usersCount() {
return this.adminService.getUsersCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Teams in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamsCount() {
return this.adminService.getTeamsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Collections in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamCollectionsCount() {
return this.adminService.getTeamCollectionsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Requests in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamRequestsCount() {
return this.adminService.getTeamRequestsCount();
}
/* Mutations */
@Mutation(() => InvitedUser, {
@@ -70,26 +269,8 @@ export class AdminResolver {
return invitedUser.right;
}
@Mutation(() => Boolean, {
description: 'Revoke a user invites by invitee emails',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeUserInvitationsByAdmin(
@Args({
name: 'inviteeEmails',
description: 'Invitee Emails',
type: () => [String],
})
inviteeEmails: string[],
): Promise<boolean> {
const invite = await this.adminService.revokeUserInvitations(inviteeEmails);
if (E.isLeft(invite)) throwErr(invite.left);
return invite.right;
}
@Mutation(() => Boolean, {
description: 'Delete an user account from infra',
deprecationReason: 'Use removeUsersByAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserByAdmin(
@@ -100,33 +281,12 @@ export class AdminResolver {
})
userUID: string,
): Promise<boolean> {
const removedUser = await this.adminService.removeUserAccount(userUID);
if (E.isLeft(removedUser)) throwErr(removedUser.left);
return removedUser.right;
const invitedUser = await this.adminService.removeUserAccount(userUID);
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
return invitedUser.right;
}
@Mutation(() => [UserDeletionResult], {
description: 'Delete user accounts from infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUsersByAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<UserDeletionResult[]> {
const deletionResults = await this.adminService.removeUserAccounts(
userUIDs,
);
if (E.isLeft(deletionResults)) throwErr(deletionResults.left);
return deletionResults.right;
}
@Mutation(() => Boolean, {
description: 'Make user an admin',
deprecationReason: 'Use makeUsersAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async makeUserAdmin(
@@ -142,51 +302,8 @@ export class AdminResolver {
return admin.right;
}
@Mutation(() => Boolean, {
description: 'Make users an admin',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async makeUsersAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<boolean> {
const isUpdated = await this.adminService.makeUsersAdmin(userUIDs);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Boolean, {
description: 'Update user display name',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async updateUserDisplayNameByAdmin(
@Args({
name: 'userUID',
description: 'users UID',
type: () => ID,
})
userUID: string,
@Args({
name: 'displayName',
description: 'users display name',
})
displayName: string,
): Promise<boolean> {
const isUpdated = await this.adminService.updateUserDisplayName(
userUID,
displayName,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Boolean, {
description: 'Remove user as admin',
deprecationReason: 'Use demoteUsersByAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserAsAdmin(
@@ -202,23 +319,6 @@ export class AdminResolver {
return admin.right;
}
@Mutation(() => Boolean, {
description: 'Remove users as admin',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async demoteUsersByAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<boolean> {
const isUpdated = await this.adminService.demoteUsersByAdmin(userUIDs);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Team, {
description:
'Create a new team by providing the user uid to nominate as Team owner',

View File

@@ -1,7 +1,7 @@
import { AdminService } from './admin.service';
import { PubSubService } from '../pubsub/pubsub.service';
import { mockDeep } from 'jest-mock-extended';
import { InvitedUsers, User as DbUser } from '@prisma/client';
import { InvitedUsers } from '@prisma/client';
import { UserService } from '../user/user.service';
import { TeamService } from '../team/team.service';
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
@@ -13,15 +13,10 @@ import { PrismaService } from 'src/prisma/prisma.service';
import {
DUPLICATE_EMAIL,
INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
USER_ALREADY_INVITED,
USER_INVITATION_DELETION_FAILED,
USER_NOT_FOUND,
} from '../errors';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import * as E from 'fp-ts/Either';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
@@ -63,87 +58,20 @@ const invitedUsers: InvitedUsers[] = [
invitedOn: new Date(),
},
];
const dbAdminUsers: DbUser[] = [
{
uid: 'uid 1',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: true,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
createdOn: new Date(),
},
{
uid: 'uid 2',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: true,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
createdOn: new Date(),
},
];
const dbNonAminUser: DbUser = {
uid: 'uid 3',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: false,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
createdOn: new Date(),
};
describe('AdminService', () => {
describe('fetchInvitedUsers', () => {
test('should resolve right and apply pagination correctly', async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
// @ts-ignore
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
const paginationArgs: OffsetPaginationArgs = { take: 5, skip: 2 };
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(mockPrisma.invitedUsers.findMany).toHaveBeenCalledWith({
...paginationArgs,
orderBy: {
invitedOn: 'desc',
},
where: {
NOT: {
inviteeEmail: {
in: [dbAdminUsers[0].email],
},
},
},
});
});
test('should resolve right and return an array of invited users', async () => {
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
// @ts-ignore
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
const results = await adminService.fetchInvitedUsers(paginationArgs);
const results = await adminService.fetchInvitedUsers();
expect(results).toEqual(invitedUsers);
});
test('should resolve left and return an empty array if invited users not found', async () => {
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
const results = await adminService.fetchInvitedUsers(paginationArgs);
const results = await adminService.fetchInvitedUsers();
expect(results).toEqual([]);
});
});
@@ -206,58 +134,6 @@ describe('AdminService', () => {
});
});
describe('revokeUserInvitations', () => {
test('should resolve left and return error if email not invited', async () => {
mockPrisma.invitedUsers.deleteMany.mockRejectedValueOnce(
'RecordNotFound',
);
const result = await adminService.revokeUserInvitations([
'test@gmail.com',
]);
expect(result).toEqualLeft(USER_INVITATION_DELETION_FAILED);
});
test('should resolve right and return deleted invitee email', async () => {
const adminUid = 'adminUid';
mockPrisma.invitedUsers.deleteMany.mockResolvedValueOnce({ count: 1 });
const result = await adminService.revokeUserInvitations([
invitedUsers[0].inviteeEmail,
]);
expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({
where: {
inviteeEmail: { in: [invitedUsers[0].inviteeEmail] },
},
});
expect(result).toEqualRight(true);
});
});
describe('removeUsersAsAdmin', () => {
test('should resolve right and make admins to users', async () => {
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
return expect(
await adminService.demoteUsersByAdmin([dbAdminUsers[0].uid]),
).toEqualRight(true);
});
test('should resolve left and return error if only one admin in the infra', async () => {
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
return expect(
await adminService.demoteUsersByAdmin(
dbAdminUsers.map((user) => user.uid),
),
).toEqualLeft(ONLY_ONE_ADMIN_ACCOUNT);
});
});
describe('getUsersCount', () => {
test('should return count of all users in the organization', async () => {
mockUserService.getUsersCount.mockResolvedValueOnce(10);

View File

@@ -6,16 +6,13 @@ import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { validateEmail } from '../utils';
import {
ADMIN_CAN_NOT_BE_DELETED,
DUPLICATE_EMAIL,
EMAIL_FAILED,
INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_NO_INVITE_FOUND,
USERS_NOT_FOUND,
USER_ALREADY_INVITED,
USER_INVITATION_DELETION_FAILED,
USER_IS_ADMIN,
USER_NOT_FOUND,
} from '../errors';
@@ -29,8 +26,6 @@ import { TeamInvitationService } from '../team-invitation/team-invitation.servic
import { TeamMemberRole } from '../team/team.model';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { UserDeletionResult } from 'src/user/user.model';
@Injectable()
export class AdminService {
@@ -53,30 +48,12 @@ export class AdminService {
* @param cursorID Users uid
* @param take number of users to fetch
* @returns an Either of array of user or error
* @deprecated use fetchUsersV2 instead
*/
async fetchUsers(cursorID: string, take: number) {
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
return allUsers;
}
/**
* Fetch all the users in the infra.
* @param searchString search on users displayName or email
* @param paginationOption pagination options
* @returns an Either of array of user or error
*/
async fetchUsersV2(
searchString: string,
paginationOption: OffsetPaginationArgs,
) {
const allUsers = await this.userService.fetchAllUsersV2(
searchString,
paginationOption,
);
return allUsers;
}
/**
* Invite a user to join the infra.
* @param adminUID Admin's UID
@@ -133,68 +110,14 @@ export class AdminService {
return E.right(invitedUser);
}
/**
* Update the display name of a user
* @param userUid Who's display name is being updated
* @param displayName New display name of the user
* @returns an Either of boolean or error
*/
async updateUserDisplayName(userUid: string, displayName: string) {
const updatedUser = await this.userService.updateUserDisplayName(
userUid,
displayName,
);
if (E.isLeft(updatedUser)) return E.left(updatedUser.left);
return E.right(true);
}
/**
* Revoke infra level user invitations
* @param inviteeEmails Invitee's emails
* @param adminUid Admin Uid
* @returns an Either of boolean or error string
*/
async revokeUserInvitations(inviteeEmails: string[]) {
try {
await this.prisma.invitedUsers.deleteMany({
where: {
inviteeEmail: { in: inviteeEmails },
},
});
return E.right(true);
} catch (error) {
return E.left(USER_INVITATION_DELETION_FAILED);
}
}
/**
* Fetch the list of invited users by the admin.
* @returns an Either of array of `InvitedUser` object or error
*/
async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) {
const userEmailObjs = await this.prisma.user.findMany({
select: {
email: true,
},
});
async fetchInvitedUsers() {
const invitedUsers = await this.prisma.invitedUsers.findMany();
const pendingInvitedUsers = await this.prisma.invitedUsers.findMany({
take: paginationOption.take,
skip: paginationOption.skip,
orderBy: {
invitedOn: 'desc',
},
where: {
NOT: {
inviteeEmail: {
in: userEmailObjs.map((user) => user.email),
},
},
},
});
const users: InvitedUser[] = pendingInvitedUsers.map(
const users: InvitedUser[] = invitedUsers.map(
(user) => <InvitedUser>{ ...user },
);
@@ -414,7 +337,6 @@ export class AdminService {
* Remove a user account by UID
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use removeUserAccounts instead
*/
async removeUserAccount(userUid: string) {
const user = await this.userService.findUserById(userUid);
@@ -427,73 +349,10 @@ export class AdminService {
return E.right(delUser.right);
}
/**
* Remove user (not Admin) accounts by UIDs
* @param userUIDs User UIDs
* @returns an Either of boolean or error
*/
async removeUserAccounts(userUIDs: string[]) {
const userDeleteResult: UserDeletionResult[] = [];
// step 1: fetch all users
const allUsersList = await this.userService.findUsersByIds(userUIDs);
if (allUsersList.length === 0) return E.left(USERS_NOT_FOUND);
// step 2: admin user can not be deleted without removing admin status/role
allUsersList.forEach((user) => {
if (user.isAdmin) {
userDeleteResult.push({
userUID: user.uid,
isDeleted: false,
errorMessage: ADMIN_CAN_NOT_BE_DELETED,
});
}
});
const nonAdminUsers = allUsersList.filter((user) => !user.isAdmin);
let deletedUserEmails: string[] = [];
// step 3: delete non-admin users
const deletionPromises = nonAdminUsers.map((user) => {
return this.userService
.deleteUserByUID(user)()
.then((res) => {
if (E.isLeft(res)) {
return {
userUID: user.uid,
isDeleted: false,
errorMessage: res.left,
} as UserDeletionResult;
}
deletedUserEmails.push(user.email);
return {
userUID: user.uid,
isDeleted: true,
errorMessage: null,
} as UserDeletionResult;
});
});
const promiseResult = await Promise.allSettled(deletionPromises);
// step 4: revoke all the invites sent to the deleted users
await this.revokeUserInvitations(deletedUserEmails);
// step 5: return the result
promiseResult.forEach((result) => {
if (result.status === 'fulfilled') {
userDeleteResult.push(result.value);
}
});
return E.right(userDeleteResult);
}
/**
* Make a user an admin
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use makeUsersAdmin instead
*/
async makeUserAdmin(userUID: string) {
const admin = await this.userService.makeAdmin(userUID);
@@ -501,22 +360,10 @@ export class AdminService {
return E.right(true);
}
/**
* Make users to admin
* @param userUid User UIDs
* @returns an Either of boolean or error
*/
async makeUsersAdmin(userUIDs: string[]) {
const isUpdated = await this.userService.makeAdmins(userUIDs);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(true);
}
/**
* Remove user as admin
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use demoteUsersByAdmin instead
*/
async removeUserAsAdmin(userUID: string) {
const adminUsers = await this.userService.fetchAdminUsers();
@@ -527,26 +374,6 @@ export class AdminService {
return E.right(true);
}
/**
* Remove users as admin
* @param userUIDs User UIDs
* @returns an Either of boolean or error
*/
async demoteUsersByAdmin(userUIDs: string[]) {
const adminUsers = await this.userService.fetchAdminUsers();
const remainingAdmins = adminUsers.filter(
(adminUser) => !userUIDs.includes(adminUser.uid),
);
if (remainingAdmins.length < 1) {
return E.left(ONLY_ONE_ADMIN_ACCOUNT);
}
const isUpdated = await this.userService.removeUsersAsAdmin(userUIDs);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(isUpdated.right);
}
/**
* Fetch list of all the Users in org
* @returns number of users in the org

View File

@@ -1,11 +0,0 @@
import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common';
@Injectable()
export class RESTAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
return user.isAdmin;
}
}

View File

@@ -17,10 +17,7 @@ import { AuthUser } from 'src/types/AuthUser';
import { throwErr } from 'src/utils';
import * as E from 'fp-ts/Either';
import { Admin } from './admin.model';
import {
OffsetPaginationArgs,
PaginationArgs,
} from 'src/types/input-types.args';
import { PaginationArgs } from 'src/types/input-types.args';
import { InvitedUser } from './invited-user.model';
import { Team } from 'src/team/team.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
@@ -79,7 +76,6 @@ export class InfraResolver {
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
deprecationReason: 'Use allUsersV2 instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
@@ -87,33 +83,11 @@ export class InfraResolver {
return users;
}
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsersV2(
@Args({
name: 'searchString',
nullable: true,
description: 'Search on users displayName or email',
})
searchString: string,
@Args() paginationOption: OffsetPaginationArgs,
): Promise<AuthUser[]> {
const users = await this.adminService.fetchUsersV2(
searchString,
paginationOption,
);
return users;
}
@ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users',
})
async invitedUsers(
@Args() args: OffsetPaginationArgs,
): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers(args);
async invitedUsers(): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers();
return users;
}
@@ -332,9 +306,7 @@ export class InfraResolver {
})
providerInfo: EnableAndDisableSSOArgs[],
) {
const isUpdated = await this.infraConfigService.enableAndDisableSSO(
providerInfo,
);
const isUpdated = await this.infraConfigService.enableAndDisableSSO(providerInfo);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return true;

View File

@@ -10,14 +10,6 @@ export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
export const ONLY_ONE_ADMIN_ACCOUNT =
'admin/only_one_admin_account_found' as const;
/**
* Admin user can not be deleted
* To delete the admin user, first make the Admin user a normal user
* (AdminService)
*/
export const ADMIN_CAN_NOT_BE_DELETED =
'admin/admin_can_not_be_deleted' as const;
/**
* Token Authorization failed (Check 'Authorization' Header)
* (GqlAuthGuard)
@@ -107,13 +99,6 @@ export const USER_IS_OWNER = 'user/is_owner' as const;
*/
export const USER_IS_ADMIN = 'user/is_admin' as const;
/**
* User invite deletion failure error due to invitation not found
* (AdminService)
*/
export const USER_INVITATION_DELETION_FAILED =
'user/invitation_deletion_failed' as const;
/**
* Teams not found
* (TeamsService)

View File

@@ -1,47 +0,0 @@
import { Controller, Get, HttpStatus, Put, UseGuards } from '@nestjs/common';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { InfraConfigService } from './infra-config.service';
import * as E from 'fp-ts/Either';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard';
import { throwHTTPErr } from 'src/auth/helper';
import { AuthError } from 'src/types/AuthError';
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'site', version: '1' })
export class SiteController {
constructor(private infraConfigService: InfraConfigService) {}
@Get('setup')
@UseGuards(JwtAuthGuard, RESTAdminGuard)
async fetchSetupInfo() {
const status = await this.infraConfigService.get(
InfraConfigEnumForClient.IS_FIRST_TIME_INFRA_SETUP,
);
if (E.isLeft(status))
throwHTTPErr(<AuthError>{
message: status.left,
statusCode: HttpStatus.NOT_FOUND,
});
return status.right;
}
@Put('setup')
@UseGuards(JwtAuthGuard, RESTAdminGuard)
async setSetupAsComplete() {
const res = await this.infraConfigService.update(
InfraConfigEnumForClient.IS_FIRST_TIME_INFRA_SETUP,
false.toString(),
false,
);
if (E.isLeft(res))
throwHTTPErr(<AuthError>{
message: res.left,
statusCode: HttpStatus.FORBIDDEN,
});
return res.right;
}
}

View File

@@ -1,12 +1,10 @@
import { Module } from '@nestjs/common';
import { InfraConfigService } from './infra-config.service';
import { PrismaModule } from 'src/prisma/prisma.module';
import { SiteController } from './infra-config.controller';
@Module({
imports: [PrismaModule],
providers: [InfraConfigService],
exports: [InfraConfigService],
controllers: [SiteController],
})
export class InfraConfigModule {}

View File

@@ -34,9 +34,7 @@ export class InfraConfigService implements OnModuleInit {
await this.initializeInfraConfigTable();
}
async getDefaultInfraConfigs(): Promise<
{ name: InfraConfigEnum; value: string }[]
> {
getDefaultInfraConfigs(): { name: InfraConfigEnum; value: string }[] {
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
{
@@ -75,10 +73,6 @@ export class InfraConfigService implements OnModuleInit {
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: getConfiguredSSOProviders(),
},
{
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: (await this.prisma.infraConfig.count()) === 0 ? 'true' : 'false',
},
];
return infraConfigDefaultObjs;
@@ -94,7 +88,7 @@ export class InfraConfigService implements OnModuleInit {
const enumValues = Object.values(InfraConfigEnum);
// Fetch the default values (value in .env) for configs to be saved in 'infra_config' table
const infraConfigDefaultObjs = await this.getDefaultInfraConfigs();
const infraConfigDefaultObjs = this.getDefaultInfraConfigs();
// Check if all the 'names' are listed in the default values
if (enumValues.length !== infraConfigDefaultObjs.length) {
@@ -153,13 +147,11 @@ export class InfraConfigService implements OnModuleInit {
* Update InfraConfig by name
* @param name Name of the InfraConfig
* @param value Value of the InfraConfig
* @param restartEnabled If true, restart the app after updating the InfraConfig
* @returns InfraConfig model
*/
async update(
name: InfraConfigEnumForClient | InfraConfigEnum,
value: string,
restartEnabled = false,
) {
const isValidate = this.validateEnvValues([{ name, value }]);
if (E.isLeft(isValidate)) return E.left(isValidate.left);
@@ -170,7 +162,7 @@ export class InfraConfigService implements OnModuleInit {
data: { value },
});
if (restartEnabled) stopApp();
stopApp();
return E.right(this.cast(infraConfig));
} catch (e) {
@@ -269,7 +261,6 @@ export class InfraConfigService implements OnModuleInit {
const isUpdated = await this.update(
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
updatedAuthProviders.join(','),
true,
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
@@ -325,24 +316,13 @@ export class InfraConfigService implements OnModuleInit {
*/
async reset() {
try {
const infraConfigDefaultObjs = await this.getDefaultInfraConfigs();
const infraConfigDefaultObjs = this.getDefaultInfraConfigs();
await this.prisma.infraConfig.deleteMany({
where: { name: { in: infraConfigDefaultObjs.map((p) => p.name) } },
});
// Hardcode t
const updatedInfraConfigDefaultObjs = infraConfigDefaultObjs.filter(
(obj) => obj.name !== InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
);
await this.prisma.infraConfig.createMany({
data: [
...updatedInfraConfigDefaultObjs,
{
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: 'true',
},
],
data: infraConfigDefaultObjs,
});
stopApp();

View File

@@ -12,8 +12,6 @@ export enum InfraConfigEnum {
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS',
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
}
export enum InfraConfigEnumForClient {
@@ -28,6 +26,4 @@ export enum InfraConfigEnumForClient {
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
}

View File

@@ -17,21 +17,3 @@ export class PaginationArgs {
})
take: number;
}
@ArgsType()
@InputType()
export class OffsetPaginationArgs {
@Field({
nullable: true,
defaultValue: 0,
description: 'Number of items to skip',
})
skip: number;
@Field({
nullable: true,
defaultValue: 10,
description: 'Number of items to fetch',
})
take: number;
}

View File

@@ -56,22 +56,3 @@ export enum SessionType {
registerEnumType(SessionType, {
name: 'SessionType',
});
@ObjectType()
export class UserDeletionResult {
@Field(() => ID, {
description: 'UID of the user',
})
userUID: string;
@Field(() => Boolean, {
description: 'Flag to determine if user deletion was successful or not',
})
isDeleted: Boolean;
@Field({
nullable: true,
description: 'Error message if user deletion was not successful',
})
errorMessage: String;
}

View File

@@ -1,4 +1,4 @@
import { JSON_INVALID, USERS_NOT_FOUND, USER_NOT_FOUND } from 'src/errors';
import { JSON_INVALID, USER_NOT_FOUND } from 'src/errors';
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service';
import { AuthUser } from 'src/types/AuthUser';
@@ -176,26 +176,6 @@ describe('UserService', () => {
});
});
describe('findUsersByIds', () => {
test('should successfully return users given valid user UIDs', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(users);
const result = await userService.findUsersByIds([
'123344',
'5555',
'6666',
]);
expect(result).toEqual(users);
});
test('should return empty array of users given a invalid user UIDs', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce([]);
const result = await userService.findUsersByIds(['sdcvbdbr']);
expect(result).toEqual([]);
});
});
describe('createUserViaMagicLink', () => {
test('should successfully create user and account for magic-link given valid inputs', async () => {
mockPrisma.user.create.mockResolvedValueOnce(user);
@@ -434,54 +414,6 @@ describe('UserService', () => {
});
});
describe('updateUserDisplayName', () => {
test('should resolve right and update user display name', async () => {
const newDisplayName = 'New Name';
mockPrisma.user.update.mockResolvedValueOnce({
...user,
displayName: newDisplayName,
});
const result = await userService.updateUserDisplayName(
user.uid,
newDisplayName,
);
expect(result).toEqualRight({
...user,
displayName: newDisplayName,
currentGQLSession: JSON.stringify(user.currentGQLSession),
currentRESTSession: JSON.stringify(user.currentRESTSession),
});
});
test('should resolve right and publish user updated subscription', async () => {
const newDisplayName = 'New Name';
mockPrisma.user.update.mockResolvedValueOnce({
...user,
displayName: newDisplayName,
});
await userService.updateUserDisplayName(user.uid, user.displayName);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`user/${user.uid}/updated`,
{
...user,
displayName: newDisplayName,
currentGQLSession: JSON.stringify(user.currentGQLSession),
currentRESTSession: JSON.stringify(user.currentRESTSession),
},
);
});
test('should resolve left and error when invalid user uid is passed', async () => {
mockPrisma.user.update.mockRejectedValueOnce('NotFoundError');
const result = await userService.updateUserDisplayName(
'invalidUserUid',
user.displayName,
);
expect(result).toEqualLeft(USER_NOT_FOUND);
});
});
describe('fetchAllUsers', () => {
test('should resolve right and return 20 users when cursor is null', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(users);
@@ -503,36 +435,6 @@ describe('UserService', () => {
});
});
describe('fetchAllUsersV2', () => {
test('should resolve right and return first 20 users when searchString is null', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(users);
const result = await userService.fetchAllUsersV2(null, {
take: 20,
skip: 0,
});
expect(result).toEqual(users);
});
test('should resolve right and return next 20 users when searchString is provided', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(users);
const result = await userService.fetchAllUsersV2('.com', {
take: 20,
skip: 0,
});
expect(result).toEqual(users);
});
test('should resolve left and return an empty array when users not found', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce([]);
const result = await userService.fetchAllUsersV2('Unknown entry', {
take: 20,
skip: 0,
});
expect(result).toEqual([]);
});
});
describe('fetchAdminUsers', () => {
test('should return a list of admin users', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(adminUsers);
@@ -654,17 +556,4 @@ describe('UserService', () => {
expect(result).toEqual(10);
});
});
describe('removeUsersAsAdmin', () => {
test('should resolve right and return true for valid user UIDs', async () => {
mockPrisma.user.updateMany.mockResolvedValueOnce({ count: 1 });
const result = await userService.removeUsersAsAdmin(['123344']);
expect(result).toEqualRight(true);
});
test('should resolve right and return false for invalid user UIDs', async () => {
mockPrisma.user.updateMany.mockResolvedValueOnce({ count: 0 });
const result = await userService.removeUsersAsAdmin(['123344']);
expect(result).toEqualLeft(USERS_NOT_FOUND);
});
});
});

View File

@@ -8,14 +8,13 @@ import * as T from 'fp-ts/Task';
import * as A from 'fp-ts/Array';
import { pipe, constVoid } from 'fp-ts/function';
import { AuthUser } from 'src/types/AuthUser';
import { USERS_NOT_FOUND, USER_NOT_FOUND } from 'src/errors';
import { USER_NOT_FOUND } from 'src/errors';
import { SessionType, User } from './user.model';
import { USER_UPDATE_FAILED } from 'src/errors';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { stringToJson, taskEitherValidateArraySeq } from 'src/utils';
import { UserDataHandler } from './user.data.handler';
import { User as DbUser } from '@prisma/client';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
@Injectable()
export class UserService {
@@ -89,20 +88,6 @@ export class UserService {
}
}
/**
* Find users with given IDs
* @param userUIDs User IDs
* @returns Array of found Users
*/
async findUsersByIds(userUIDs: string[]): Promise<AuthUser[]> {
const users = await this.prisma.user.findMany({
where: {
uid: { in: userUIDs },
},
});
return users;
}
/**
* Update User with new generated hashed refresh token
*
@@ -284,30 +269,6 @@ export class UserService {
}
}
/**
* Update a user's data
* @param userUID User UID
* @param displayName User's displayName
* @returns a Either of User or error
*/
async updateUserDisplayName(userUID: string, displayName: string) {
try {
const dbUpdatedUser = await this.prisma.user.update({
where: { uid: userUID },
data: { displayName },
});
const updatedUser = this.convertDbUserToUser(dbUpdatedUser);
// Publish subscription for user updates
await this.pubsub.publish(`user/${updatedUser.uid}/updated`, updatedUser);
return E.right(updatedUser);
} catch (error) {
return E.left(USER_NOT_FOUND);
}
}
/**
* Validate and parse currentRESTSession and currentGQLSession
* @param sessionData string of the session
@@ -325,7 +286,6 @@ export class UserService {
* @param cursorID string of userUID or null
* @param take number of users to query
* @returns an array of `User` object
* @deprecated use fetchAllUsersV2 instead
*/
async fetchAllUsers(cursorID: string, take: number) {
const fetchedUsers = await this.prisma.user.findMany({
@@ -336,43 +296,6 @@ export class UserService {
return fetchedUsers;
}
/**
* Fetch all the users in the `User` table based on cursor
* @param searchString search on user's displayName or email
* @param paginationOption pagination options
* @returns an array of `User` object
*/
async fetchAllUsersV2(
searchString: string,
paginationOption: OffsetPaginationArgs,
) {
const fetchedUsers = await this.prisma.user.findMany({
skip: paginationOption.skip,
take: paginationOption.take,
where: searchString
? {
OR: [
{
displayName: {
contains: searchString,
mode: 'insensitive',
},
},
{
email: {
contains: searchString,
mode: 'insensitive',
},
},
],
}
: undefined,
orderBy: [{ isAdmin: 'desc' }, { displayName: 'asc' }],
});
return fetchedUsers;
}
/**
* Fetch the number of users in db
* @returns a count (Int) of user records in DB
@@ -403,23 +326,6 @@ export class UserService {
}
}
/**
* Change users to admins by toggling isAdmin param to true
* @param userUID user UIDs
* @returns a Either of true or error
*/
async makeAdmins(userUIDs: string[]) {
try {
await this.prisma.user.updateMany({
where: { uid: { in: userUIDs } },
data: { isAdmin: true },
});
return E.right(true);
} catch (error) {
return E.left(USER_UPDATE_FAILED);
}
}
/**
* Fetch all the admin users
* @returns an array of admin users
@@ -538,22 +444,4 @@ export class UserService {
return E.left(USER_NOT_FOUND);
}
}
/**
* Change users from an admin by toggling isAdmin param to false
* @param userUIDs user UIDs
* @returns a Either of true or error
*/
async removeUsersAsAdmin(userUIDs: string[]) {
const data = await this.prisma.user.updateMany({
where: { uid: { in: userUIDs } },
data: { isAdmin: false },
});
if (data.count === 0) {
return E.left(USERS_NOT_FOUND);
}
return E.right(true);
}
}

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
// * The entry point of the CLI
require("../dist").cli(process.argv);

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env node
// * The entry point of the CLI
import { cli } from "../dist/index.js";
cli(process.argv);

View File

@@ -3,10 +3,9 @@
"version": "0.6.0",
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
"homepage": "https://hoppscotch.io",
"type": "module",
"main": "dist/index.js",
"bin": {
"hopp": "bin/hopp.js"
"hopp": "bin/hopp"
},
"publishConfig": {
"access": "public"
@@ -40,27 +39,28 @@
},
"license": "MIT",
"private": false,
"dependencies": {
"axios": "^1.6.6",
"chalk": "^5.3.0",
"commander": "^11.1.0",
"lodash-es": "^4.17.21",
"qs": "^6.11.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@hoppscotch/data": "workspace:^",
"@hoppscotch/js-sandbox": "workspace:^",
"@relmify/jest-fp-ts": "^2.1.1",
"@swc/core": "^1.3.105",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/qs": "^6.9.11",
"fp-ts": "^2.16.2",
"@swc/core": "^1.3.92",
"@types/jest": "^29.5.5",
"@types/lodash": "^4.14.199",
"@types/qs": "^6.9.8",
"axios": "^0.21.4",
"chalk": "^4.1.2",
"commander": "^11.0.0",
"esm": "^3.2.25",
"fp-ts": "^2.16.1",
"io-ts": "^2.2.20",
"jest": "^29.7.0",
"prettier": "^3.2.4",
"ts-jest": "^29.1.2",
"tsup": "^8.0.1",
"typescript": "^5.3.3"
"lodash": "^4.17.21",
"prettier": "^3.0.3",
"qs": "^6.11.2",
"ts-jest": "^29.1.1",
"tsup": "^7.2.0",
"typescript": "^5.2.2",
"verzod": "^0.2.2",
"zod": "^3.22.4"
}
}

View File

@@ -3,138 +3,247 @@ import { ExecException } from "child_process";
import { HoppErrorCode } from "../../types/errors";
import { runCLI, getErrorCode, getTestJsonFilePath } from "../utils";
describe("Test 'hopp test <file>' command:", () => {
test("No collection file path provided.", async () => {
const args = "test";
const { stderr } = await runCLI(args);
describe("Test `hopp test <file>` command:", () => {
describe("Argument parsing", () => {
test("Errors with the code `INVALID_ARGUMENT` for not supplying enough arguments", async () => {
const args = "test";
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Collection file not found.", async () => {
const args = "test notfound.json";
const { stderr } = await runCLI(args);
test("Errors with the code `INVALID_ARGUMENT` for an invalid command", async () => {
const args = "invalid-arg";
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
});
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
})
test("Collection file is invalid JSON.", async () => {
const args = `test ${getTestJsonFilePath(
"malformed-collection.json"
)}`;
const { stderr } = await runCLI(args);
describe("Supplied collection export file validations", () => {
test("Errors with the code `FILE_NOT_FOUND` if the supplied collection export file doesn't exist", async () => {
const args = "test notfound.json";
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
});
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
});
test("Malformed collection file.", async () => {
const args = `test ${getTestJsonFilePath(
"malformed-collection2.json"
)}`;
const { stderr } = await runCLI(args);
test("Errors with the code UNKNOWN_ERROR if the supplied collection export file content isn't valid JSON", async () => {
const args = `test ${getTestJsonFilePath("malformed-coll.json", "collection")}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION");
});
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
});
test("Invalid arguement.", async () => {
const args = "invalid-arg";
const { stderr } = await runCLI(args);
test("Errors with the code `MALFORMED_COLLECTION` if the supplied collection export file content is malformed", async () => {
const args = `test ${getTestJsonFilePath("malformed-coll-2.json", "collection")}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION");
});
test("Collection file not JSON type.", async () => {
const args = `test ${getTestJsonFilePath("notjson.txt")}`;
const { stderr } = await runCLI(args);
test("Errors with the code `INVALID_FILE_TYPE` if the supplied collection export file doesn't end with the `.json` extension", async () => {
const args = `test ${getTestJsonFilePath("notjson-coll.txt", "collection")}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
});
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
});
test("Some errors occured (exit code 1).", async () => {
const args = `test ${getTestJsonFilePath("fails.json")}`;
const { error } = await runCLI(args);
test("Fails if the collection file includes scripts with incorrect API usage and failed assertions", async () => {
const args = `test ${getTestJsonFilePath("fails-coll.json", "collection")}`;
const { error } = await runCLI(args);
expect(error).not.toBeNull();
expect(error).toMatchObject(<ExecException>{
code: 1,
expect(error).not.toBeNull();
expect(error).toMatchObject(<ExecException>{
code: 1,
});
});
});
test("No errors occured (exit code 0).", async () => {
const args = `test ${getTestJsonFilePath("passes.json")}`;
test("Successfully processes a supplied collection export file of the expected format", async () => {
const args = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
test("Supports inheriting headers and authorization set at the root collection", async () => {
const args = `test ${getTestJsonFilePath("collection-level-headers-auth.json")}`;
test("Successfully inherits headers and authorization set at the root collection", async () => {
const args = `test ${getTestJsonFilePath(
"collection-level-headers-auth-coll.json", "collection"
)}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
})
});
test("Persists environment variables set in the pre-request script for consumption in the test script", async () => {
const args = `test ${getTestJsonFilePath(
"pre-req-script-env-var-persistence-coll.json", "collection"
)}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
});
describe("Test 'hopp test <file> --env <file>' command:", () => {
const VALID_TEST_ARGS = `test ${getTestJsonFilePath(
"passes.json"
)}`;
describe("Test `hopp test <file> --env <file>` command:", () => {
describe("Supplied environment export file validations", () => {
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
test("No env file path provided.", async () => {
const args = `${VALID_TEST_ARGS} --env`;
const { stderr } = await runCLI(args);
test("Errors with the code `INVALID_ARGUMENT` if no file is supplied", async () => {
const args = `${VALID_TEST_ARGS} --env`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Errors with the code `INVALID_FILE_TYPE` if the supplied environment export file doesn't end with the `.json` extension", async () => {
const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath(
"notjson-coll.txt", "collection"
)}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
});
test("Errors with the code `FILE_NOT_FOUND` if the supplied environment export file doesn't exist", async () => {
const args = `${VALID_TEST_ARGS} --env notfound.json`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
});
test("Errors with the code `MALFORMED_ENV_FILE` on supplying a malformed environment export file", async () => {
const ENV_PATH = getTestJsonFilePath("malformed-envs.json", "environment");
const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("MALFORMED_ENV_FILE");
});
test("Errors with the code `BULK_ENV_FILE` on supplying an environment export file based on the bulk environment export format", async () => {
const ENV_PATH = getTestJsonFilePath("bulk-envs.json", "environment");
const args = `${VALID_TEST_ARGS} --env ${ENV_PATH}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("BULK_ENV_FILE");
});
});
test("ENV file not JSON type.", async () => {
const args = `${VALID_TEST_ARGS} --env ${getTestJsonFilePath("notjson.txt")}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
});
test("ENV file not found.", async () => {
const args = `${VALID_TEST_ARGS} --env notfound.json`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
});
test("No errors occured (exit code 0).", async () => {
const TESTS_PATH = getTestJsonFilePath("env-flag-tests.json");
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json");
test("Successfully resolves values from the supplied environment export file", async () => {
const TESTS_PATH = getTestJsonFilePath("env-flag-tests-coll.json", "collection");
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment");
const args = `test ${TESTS_PATH} --env ${ENV_PATH}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
test("Correctly resolves environment variables referenced in the request body", async () => {
const COLL_PATH = getTestJsonFilePath("req-body-env-vars-coll.json");
const ENVS_PATH = getTestJsonFilePath("req-body-env-vars-envs.json");
test("Successfully resolves environment variables referenced in the request body", async () => {
const COLL_PATH = getTestJsonFilePath("req-body-env-vars-coll.json", "collection");
const ENVS_PATH = getTestJsonFilePath("req-body-env-vars-envs.json", "environment");
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
test("Works with shorth `-e` flag", async () => {
const TESTS_PATH = getTestJsonFilePath("env-flag-tests-coll.json", "collection");
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json", "environment");
const args = `test ${TESTS_PATH} -e ${ENV_PATH}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
describe("Secret environment variables", () => {
jest.setTimeout(10000);
// Reads secret environment values from system environment
test("Successfully picks the values for secret environment variables from `process.env` and persists the variables set from the pre-request script", async () => {
const env = {
...process.env,
secretBearerToken: "test-token",
secretBasicAuthUsername: "test-user",
secretBasicAuthPassword: "test-pass",
secretQueryParamValue: "secret-query-param-value",
secretBodyValue: "secret-body-value",
secretHeaderValue: "secret-header-value",
};
const COLL_PATH = getTestJsonFilePath("secret-envs-coll.json", "collection");
const ENVS_PATH = getTestJsonFilePath("secret-envs.json", "environment");
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
const { error, stdout } = await runCLI(args, { env });
expect(stdout).toContain(
"https://httpbin.org/basic-auth/*********/*********"
);
expect(error).toBeNull();
});
// Prefers values specified in the environment export file over values set in the system environment
test("Successfully picks the values for secret environment variables set directly in the environment export file and persists the environment variables set from the pre-request script", async () => {
const COLL_PATH = getTestJsonFilePath("secret-envs-coll.json", "collection");
const ENVS_PATH = getTestJsonFilePath("secret-supplied-values-envs.json", "environment");
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
const { error, stdout } = await runCLI(args);
expect(stdout).toContain(
"https://httpbin.org/basic-auth/*********/*********"
);
expect(error).toBeNull();
});
// Values set from the scripting context takes the highest precedence
test("Setting values for secret environment variables from the pre-request script overrides values set at the supplied environment export file", async () => {
const COLL_PATH = getTestJsonFilePath(
"secret-envs-persistence-coll.json", "collection"
);
const ENVS_PATH = getTestJsonFilePath("secret-supplied-values-envs.json", "environment");
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
const { error, stdout } = await runCLI(args);
expect(stdout).toContain(
"https://httpbin.org/basic-auth/*********/*********"
);
expect(error).toBeNull();
});
test("Persists secret environment variable values set from the pre-request script for consumption in the request and post-request script context", async () => {
const COLL_PATH = getTestJsonFilePath(
"secret-envs-persistence-scripting-coll.json", "collection"
);
const ENVS_PATH = getTestJsonFilePath(
"secret-envs-persistence-scripting-envs.json", "environment"
);
const args = `test ${COLL_PATH} --env ${ENVS_PATH}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
});
});
describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
const VALID_TEST_ARGS = `test ${getTestJsonFilePath(
"passes.json"
)}`;
describe("Test `hopp test <file> --delay <delay_in_ms>` command:", () => {
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
test("No value passed to delay flag.", async () => {
test("Errors with the code `INVALID_ARGUMENT` on not supplying a delay value", async () => {
const args = `${VALID_TEST_ARGS} --delay`;
const { stderr } = await runCLI(args);
@@ -142,7 +251,7 @@ describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Invalid value passed to delay flag.", async () => {
test("Errors with the code `INVALID_ARGUMENT` on supplying an invalid delay value", async () => {
const args = `${VALID_TEST_ARGS} --delay 'NaN'`;
const { stderr } = await runCLI(args);
@@ -150,10 +259,17 @@ describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Valid value passed to delay flag.", async () => {
test("Successfully performs delayed request execution for a valid delay value", async () => {
const args = `${VALID_TEST_ARGS} --delay 1`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
test("Works with the short `-d` flag", async () => {
const args = `${VALID_TEST_ARGS} -d 1`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
});

View File

@@ -0,0 +1,21 @@
{
"v": 2,
"name": "pre-req-script-env-var-persistence-coll",
"folders": [],
"requests": [
{
"v": "1",
"auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null },
"name": "sample-req",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "https://echo.hoppscotch.io",
"testScript": "pw.expect(pw.env.get(\"variable\")).toBe(\"value\")",
"preRequestScript": "pw.env.set(\"variable\", \"value\");"
}
],
"auth": { "authType": "inherit", "authActive": true },
"headers": []
}

View File

@@ -0,0 +1,107 @@
{
"v": 2,
"name": "secret-envs-coll",
"folders": [],
"requests": [
{
"v": "1",
"auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null },
"name": "test-secret-headers",
"method": "GET",
"params": [],
"headers": [
{
"key": "Secret-Header-Key",
"value": "<<secretHeaderValue>>",
"active": true
}
],
"endpoint": "<<baseURL>>/headers",
"testScript": "pw.test(\"Successfully parses secret variable holding the header value\", () => {\n const secretHeaderValue = pw.env.get(\"secretHeaderValue\")\n pw.expect(secretHeaderValue).toBe(\"secret-header-value\")\n \n if (secretHeaderValue) {\n pw.expect(pw.response.body.headers[\"Secret-Header-Key\"]).toBe(secretHeaderValue)\n }\n\n pw.expect(pw.env.get(\"secretHeaderValueFromPreReqScript\")).toBe(\"secret-header-value\")\n})",
"preRequestScript": "const secretHeaderValueFromPreReqScript = pw.env.get(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
},
{
"v": "1",
"auth": { "authType": "none", "authActive": true },
"body": {
"body": "{\n \"secretBodyKey\": \"<<secretBodyValue>>\"\n}",
"contentType": "application/json"
},
"name": "test-secret-body",
"method": "POST",
"params": [],
"headers": [],
"endpoint": "<<baseURL>>/post",
"testScript": "pw.test(\"Successfully parses secret variable holding the request body value\", () => {\n const secretBodyValue = pw.env.get(\"secretBodyValue\")\n pw.expect(secretBodyValue).toBe(\"secret-body-value\")\n \n if (secretBodyValue) {\n pw.expect(pw.response.body.json.secretBodyKey).toBe(secretBodyValue)\n }\n\n pw.expect(pw.env.get(\"secretBodyValueFromPreReqScript\")).toBe(\"secret-body-value\")\n})",
"preRequestScript": "const secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)"
},
{
"v": "1",
"auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null },
"name": "test-secret-query-params",
"method": "GET",
"params": [
{
"key": "secretQueryParamKey",
"value": "<<secretQueryParamValue>>",
"active": true
}
],
"headers": [],
"endpoint": "<<baseURL>>/get",
"testScript": "pw.test(\"Successfully parses secret variable holding the query param value\", () => {\n const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n pw.expect(secretQueryParamValue).toBe(\"secret-query-param-value\")\n \n if (secretQueryParamValue) {\n pw.expect(pw.response.body.args.secretQueryParamKey).toBe(secretQueryParamValue)\n }\n\n pw.expect(pw.env.get(\"secretQueryParamValueFromPreReqScript\")).toBe(\"secret-query-param-value\")\n})",
"preRequestScript": "const secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)"
},
{
"v": "1",
"auth": {
"authType": "basic",
"password": "<<secretBasicAuthPassword>>",
"username": "<<secretBasicAuthUsername>>",
"authActive": true
},
"body": { "body": null, "contentType": null },
"name": "test-secret-basic-auth",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "<<baseURL>>/basic-auth/<<secretBasicAuthUsername>>/<<secretBasicAuthPassword>>",
"testScript": "pw.test(\"Successfully parses secret variables holding basic auth credentials\", () => {\n\tconst secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n \tconst secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\n pw.expect(secretBasicAuthUsername).toBe(\"test-user\")\n pw.expect(secretBasicAuthPassword).toBe(\"test-pass\")\n\n if (secretBasicAuthUsername && secretBasicAuthPassword) {\n const { authenticated, user } = pw.response.body\n pw.expect(authenticated).toBe(true)\n pw.expect(user).toBe(secretBasicAuthUsername)\n }\n});",
"preRequestScript": ""
},
{
"v": "1",
"auth": {
"token": "<<secretBearerToken>>",
"authType": "bearer",
"password": "testpassword",
"username": "testuser",
"authActive": true
},
"body": { "body": null, "contentType": null },
"name": "test-secret-bearer-auth",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "<<baseURL>>/bearer",
"testScript": "pw.test(\"Successfully parses secret variable holding the bearer token\", () => {\n const secretBearerToken = pw.env.get(\"secretBearerToken\")\n const preReqSecretBearerToken = pw.env.get(\"preReqSecretBearerToken\")\n\n pw.expect(secretBearerToken).toBe(\"test-token\")\n\n if (secretBearerToken) { \n pw.expect(pw.response.body.token).toBe(secretBearerToken)\n pw.expect(preReqSecretBearerToken).toBe(\"test-token\")\n }\n});",
"preRequestScript": "const secretBearerToken = pw.env.get(\"secretBearerToken\")\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)"
},
{
"v": "1",
"auth": { "authType": "none", "authActive": true },
"body": { "body": null, "contentType": null },
"name": "test-secret-fallback",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "<<baseURL>>",
"testScript": "pw.test(\"Returns an empty string if the value for a secret environment variable is not found in the system environment\", () => {\n pw.expect(pw.env.get(\"nonExistentValueInSystemEnv\")).toBe(\"\")\n})",
"preRequestScript": ""
}
],
"auth": { "authType": "inherit", "authActive": false },
"headers": []
}

View File

@@ -0,0 +1,143 @@
{
"v": 2,
"name": "secret-envs-setters-coll",
"folders": [],
"requests": [
{
"v": "1",
"auth": {
"authType": "none",
"authActive": true
},
"body": {
"body": null,
"contentType": null
},
"name": "test-secret-headers",
"method": "GET",
"params": [],
"headers": [
{
"key": "Secret-Header-Key",
"value": "<<secretHeaderValue>>",
"active": true
}
],
"endpoint": "<<baseURL>>/headers",
"testScript": "pw.test(\"Successfully parses secret variable holding the header value\", () => {\n const secretHeaderValue = pw.env.getResolve(\"secretHeaderValue\")\n pw.expect(secretHeaderValue).toBe(\"secret-header-value\")\n \n if (secretHeaderValue) {\n pw.expect(pw.response.body.headers[\"Secret-Header-Key\"]).toBe(secretHeaderValue)\n }\n\n pw.expect(pw.env.getResolve(\"secretHeaderValueFromPreReqScript\")).toBe(\"secret-header-value\")\n})",
"preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
},
{
"v": "1",
"auth": {
"authType": "none",
"authActive": true
},
"body": {
"body": null,
"contentType": null
},
"name": "test-secret-headers-overrides",
"method": "GET",
"params": [],
"headers": [
{
"key": "Secret-Header-Key",
"value": "<<secretHeaderValue>>",
"active": true
}
],
"endpoint": "<<baseURL>>/headers",
"testScript": "pw.test(\"Value set at the pre-request script takes precedence\", () => {\n const secretHeaderValue = pw.env.getResolve(\"secretHeaderValue\")\n pw.expect(secretHeaderValue).toBe(\"secret-header-value-overriden\")\n \n if (secretHeaderValue) {\n pw.expect(pw.response.body.headers[\"Secret-Header-Key\"]).toBe(secretHeaderValue)\n }\n\n pw.expect(pw.env.getResolve(\"secretHeaderValueFromPreReqScript\")).toBe(\"secret-header-value-overriden\")\n})",
"preRequestScript": "pw.env.set(\"secretHeaderValue\", \"secret-header-value-overriden\")\n\nconst secretHeaderValueFromPreReqScript = pw.env.getResolve(\"secretHeaderValue\")\npw.env.set(\"secretHeaderValueFromPreReqScript\", secretHeaderValueFromPreReqScript)"
},
{
"v": "1",
"auth": {
"authType": "none",
"authActive": true
},
"body": {
"body": "{\n \"secretBodyKey\": \"<<secretBodyValue>>\"\n}",
"contentType": "application/json"
},
"name": "test-secret-body",
"method": "POST",
"params": [],
"headers": [],
"endpoint": "<<baseURL>>/post",
"testScript": "pw.test(\"Successfully parses secret variable holding the request body value\", () => {\n const secretBodyValue = pw.env.get(\"secretBodyValue\")\n pw.expect(secretBodyValue).toBe(\"secret-body-value\")\n \n if (secretBodyValue) {\n pw.expect(pw.response.body.json.secretBodyKey).toBe(secretBodyValue)\n }\n\n pw.expect(pw.env.get(\"secretBodyValueFromPreReqScript\")).toBe(\"secret-body-value\")\n})",
"preRequestScript": "const secretBodyValue = pw.env.get(\"secretBodyValue\")\n\nif (!secretBodyValue) { \n pw.env.set(\"secretBodyValue\", \"secret-body-value\")\n}\n\nconst secretBodyValueFromPreReqScript = pw.env.get(\"secretBodyValue\")\npw.env.set(\"secretBodyValueFromPreReqScript\", secretBodyValueFromPreReqScript)"
},
{
"v": "1",
"auth": {
"authType": "none",
"authActive": true
},
"body": {
"body": null,
"contentType": null
},
"name": "test-secret-query-params",
"method": "GET",
"params": [
{
"key": "secretQueryParamKey",
"value": "<<secretQueryParamValue>>",
"active": true
}
],
"headers": [],
"endpoint": "<<baseURL>>/get",
"testScript": "pw.test(\"Successfully parses secret variable holding the query param value\", () => {\n const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n pw.expect(secretQueryParamValue).toBe(\"secret-query-param-value\")\n \n if (secretQueryParamValue) {\n pw.expect(pw.response.body.args.secretQueryParamKey).toBe(secretQueryParamValue)\n }\n\n pw.expect(pw.env.get(\"secretQueryParamValueFromPreReqScript\")).toBe(\"secret-query-param-value\")\n})",
"preRequestScript": "const secretQueryParamValue = pw.env.get(\"secretQueryParamValue\")\n\nif (!secretQueryParamValue) {\n pw.env.set(\"secretQueryParamValue\", \"secret-query-param-value\")\n}\n\nconst secretQueryParamValueFromPreReqScript = pw.env.get(\"secretQueryParamValue\")\npw.env.set(\"secretQueryParamValueFromPreReqScript\", secretQueryParamValueFromPreReqScript)"
},
{
"v": "1",
"auth": {
"authType": "basic",
"password": "<<secretBasicAuthPassword>>",
"username": "<<secretBasicAuthUsername>>",
"authActive": true
},
"body": {
"body": null,
"contentType": null
},
"name": "test-secret-basic-auth",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "<<baseURL>>/basic-auth/<<secretBasicAuthUsername>>/<<secretBasicAuthPassword>>",
"testScript": "pw.test(\"Successfully parses secret variables holding basic auth credentials\", () => {\n\tconst secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n \tconst secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\n pw.expect(secretBasicAuthUsername).toBe(\"test-user\")\n pw.expect(secretBasicAuthPassword).toBe(\"test-pass\")\n\n if (secretBasicAuthUsername && secretBasicAuthPassword) {\n const { authenticated, user } = pw.response.body\n pw.expect(authenticated).toBe(true)\n pw.expect(user).toBe(secretBasicAuthUsername)\n }\n});",
"preRequestScript": "let secretBasicAuthUsername = pw.env.get(\"secretBasicAuthUsername\")\n\nlet secretBasicAuthPassword = pw.env.get(\"secretBasicAuthPassword\")\n\nif (!secretBasicAuthUsername) {\n pw.env.set(\"secretBasicAuthUsername\", \"test-user\")\n}\n\nif (!secretBasicAuthPassword) {\n pw.env.set(\"secretBasicAuthPassword\", \"test-pass\")\n}"
},
{
"v": "1",
"auth": {
"token": "<<secretBearerToken>>",
"authType": "bearer",
"password": "testpassword",
"username": "testuser",
"authActive": true
},
"body": {
"body": null,
"contentType": null
},
"name": "test-secret-bearer-auth",
"method": "GET",
"params": [],
"headers": [],
"endpoint": "<<baseURL>>/bearer",
"testScript": "pw.test(\"Successfully parses secret variable holding the bearer token\", () => {\n const secretBearerToken = pw.env.resolve(\"<<secretBearerToken>>\")\n const preReqSecretBearerToken = pw.env.resolve(\"<<preReqSecretBearerToken>>\")\n\n pw.expect(secretBearerToken).toBe(\"test-token\")\n\n if (secretBearerToken) { \n pw.expect(pw.response.body.token).toBe(secretBearerToken)\n pw.expect(preReqSecretBearerToken).toBe(\"test-token\")\n }\n});",
"preRequestScript": "let secretBearerToken = pw.env.resolve(\"<<secretBearerToken>>\")\n\nif (!secretBearerToken) {\n pw.env.set(\"secretBearerToken\", \"test-token\")\n secretBearerToken = pw.env.resolve(\"<<secretBearerToken>>\")\n}\n\npw.env.set(\"preReqSecretBearerToken\", secretBearerToken)"
}
],
"auth": {
"authType": "inherit",
"authActive": false
},
"headers": []
}

View File

@@ -0,0 +1,30 @@
{
"v": 2,
"name": "secret-envs-persistence-scripting-req",
"folders": [],
"requests": [
{
"v": "1",
"endpoint": "https://httpbin.org/post",
"name": "req",
"params": [],
"headers": [
{
"active": true,
"key": "Custom-Header",
"value": "<<customHeaderValueFromSecretVar>>"
}
],
"method": "POST",
"auth": { "authType": "none", "authActive": true },
"preRequestScript": "pw.env.set(\"preReqVarOne\", \"pre-req-value-one\")\n\npw.env.set(\"preReqVarTwo\", \"pre-req-value-two\")\n\npw.env.set(\"customHeaderValueFromSecretVar\", \"custom-header-secret-value\")\n\npw.env.set(\"customBodyValue\", \"custom-body-value\")",
"testScript": "pw.test(\"Secret environment value set from the pre-request script takes precedence\", () => {\n pw.expect(pw.env.get(\"preReqVarOne\")).toBe(\"pre-req-value-one\")\n})\n\npw.test(\"Successfully sets initial value for the secret variable from the pre-request script\", () => {\n pw.env.set(\"postReqVarTwo\", \"post-req-value-two\")\n pw.expect(pw.env.get(\"postReqVarTwo\")).toBe(\"post-req-value-two\")\n})\n\npw.test(\"Successfully resolves secret variable values referred in request headers that are set in pre-request sccript\", () => {\n pw.expect(pw.response.body.headers[\"Custom-Header\"]).toBe(\"custom-header-secret-value\")\n})\n\npw.test(\"Successfully resolves secret variable values referred in request body that are set in pre-request sccript\", () => {\n pw.expect(pw.response.body.json.key).toBe(\"custom-body-value\")\n})\n\npw.test(\"Secret environment variable set from the post-request script takes precedence\", () => {\n pw.env.set(\"postReqVarOne\", \"post-req-value-one\")\n pw.expect(pw.env.get(\"postReqVarOne\")).toBe(\"post-req-value-one\")\n})\n\npw.test(\"Successfully sets initial value for the secret variable from the post-request script\", () => {\n pw.env.set(\"postReqVarTwo\", \"post-req-value-two\")\n pw.expect(pw.env.get(\"postReqVarTwo\")).toBe(\"post-req-value-two\")\n})\n\npw.test(\"Successfully removes environment variables via the pw.env.unset method\", () => {\n pw.env.unset(\"preReqVarOne\")\n pw.env.unset(\"postReqVarTwo\")\n\n pw.expect(pw.env.get(\"preReqVarOne\")).toBe(undefined)\n pw.expect(pw.env.get(\"postReqVarTwo\")).toBe(undefined)\n})",
"body": {
"contentType": "application/json",
"body": "{\n \"key\": \"<<customBodyValue>>\"\n}"
}
}
],
"auth": { "authType": "inherit", "authActive": false },
"headers": []
}

View File

@@ -0,0 +1,32 @@
[
{
"v": 0,
"name": "Env-I",
"variables": [
{
"key": "firstName",
"value": "John"
},
{
"key": "lastName",
"value": "Doe"
}
]
},
{
"v": 1,
"id": "2",
"name": "Env-II",
"variables": [
{
"key": "baseUrl",
"value": "https://echo.hoppscotch.io",
"secret": false
},
{
"key": "secretVar",
"secret": true
}
]
}
]

View File

@@ -0,0 +1,16 @@
{
"id": 123,
"v": "1",
"name": "secret-envs",
"values": [
{
"key": "secretVar",
"secret": true
},
{
"key": "regularVar",
"secret": false,
"value": "regular-variable"
}
]
}

View File

@@ -1,4 +1,5 @@
{
"v": 0,
"name": "Response body sample",
"variables": [
{
@@ -34,4 +35,4 @@
"value": "<<salutation>> <<fullName>>"
}
]
}
}

View File

@@ -0,0 +1,27 @@
{
"v": 1,
"id": "2",
"name": "secret-envs-persistence-scripting-envs",
"variables": [
{
"key": "preReqVarOne",
"secret": true
},
{
"key": "preReqVarTwo",
"secret": true
},
{
"key": "postReqVarOne",
"secret": true
},
{
"key": "preReqVarTwo",
"secret": true
},
{
"key": "customHeaderValueFromSecretVar",
"secret": true
}
]
}

View File

@@ -0,0 +1,40 @@
{
"id": "2",
"v": 1,
"name": "secret-envs",
"variables": [
{
"key": "secretBearerToken",
"secret": true
},
{
"key": "secretBasicAuthUsername",
"secret": true
},
{
"key": "secretBasicAuthPassword",
"secret": true
},
{
"key": "secretQueryParamValue",
"secret": true
},
{
"key": "secretBodyValue",
"secret": true
},
{
"key": "secretHeaderValue",
"secret": true
},
{
"key": "nonExistentValueInSystemEnv",
"secret": true
},
{
"key": "baseURL",
"value": "https://httpbin.org",
"secret": false
}
]
}

View File

@@ -0,0 +1,46 @@
{
"v": 1,
"id": "2",
"name": "secret-values-envs",
"variables": [
{
"key": "secretBearerToken",
"value": "test-token",
"secret": true
},
{
"key": "secretBasicAuthUsername",
"value": "test-user",
"secret": true
},
{
"key": "secretBasicAuthPassword",
"value": "test-pass",
"secret": true
},
{
"key": "secretQueryParamValue",
"value": "secret-query-param-value",
"secret": true
},
{
"key": "secretBodyValue",
"value": "secret-body-value",
"secret": true
},
{
"key": "secretHeaderValue",
"value": "secret-header-value",
"secret": true
},
{
"key": "nonExistentValueInSystemEnv",
"secret": true
},
{
"key": "baseURL",
"value": "https://httpbin.org",
"secret": false
}
]
}

View File

@@ -3,13 +3,13 @@ import { resolve } from "path";
import { ExecResponse } from "./types";
export const runCLI = (args: string): Promise<ExecResponse> =>
export const runCLI = (args: string, options = {}): Promise<ExecResponse> =>
{
const CLI_PATH = resolve(__dirname, "../../bin/hopp");
const command = `node ${CLI_PATH} ${args}`
return new Promise((resolve) =>
exec(command, (error, stdout, stderr) => resolve({ error, stdout, stderr }))
exec(command, options, (error, stdout, stderr) => resolve({ error, stdout, stderr }))
);
}
@@ -25,7 +25,12 @@ export const getErrorCode = (out: string) => {
return ansiTrimmedStr.split(" ")[0];
};
export const getTestJsonFilePath = (file: string) => {
const filePath = resolve(__dirname, `../../src/__tests__/samples/${file}`);
export const getTestJsonFilePath = (file: string, kind: "collection" | "environment") => {
const kindDir = {
collection: "collections",
environment: "environments",
}[kind];
const filePath = resolve(__dirname, `../../src/__tests__/samples/${kindDir}/${file}`);
return filePath;
};

View File

@@ -1,5 +1,5 @@
import chalk from "chalk";
import { Command } from "commander";
import { program } from "commander";
import * as E from "fp-ts/Either";
import { version } from "../package.json";
import { test } from "./commands/test";
@@ -20,8 +20,6 @@ const CLI_AFTER_ALL_TXT = `\nFor more help, head on to ${accent(
"https://docs.hoppscotch.io/documentation/clients/cli"
)}`;
const program = new Command()
program
.name("hopp")
.version(version, "-v, --ver", "see the current version of hopp-cli")

View File

@@ -21,6 +21,7 @@ export interface RequestStack {
*/
export interface RequestConfig extends AxiosRequestConfig {
supported: boolean;
displayUrl?: string
}
export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
@@ -30,6 +31,7 @@ export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
* This contains path, params and environment variables all applied to it
*/
effectiveFinalURL: string;
effectiveFinalDisplayURL?: string;
effectiveFinalHeaders: { key: string; value: string; active: boolean }[];
effectiveFinalParams: { key: string; value: string; active: boolean }[];
effectiveFinalBody: FormData | string | null;

View File

@@ -1,34 +1,42 @@
import { Environment } from "@hoppscotch/data";
import { entityReference } from "verzod";
import { z } from "zod";
import { error } from "../../types/errors";
import {
HoppEnvs,
HoppEnvPair,
HoppEnvKeyPairObject,
HoppEnvExportObject,
HoppBulkEnvExportObject,
HoppEnvPair,
HoppEnvs
} from "../../types/request";
import { readJsonFile } from "../../utils/mutators";
/**
* Parses env json file for given path and validates the parsed env json object.
* @param path Path of env.json file to be parsed.
* @returns For successful parsing we get HoppEnvs object.
* Parses env json file for given path and validates the parsed env json object
* @param path Path of env.json file to be parsed
* @returns For successful parsing we get HoppEnvs object
*/
export async function parseEnvsData(path: string) {
const contents = await readJsonFile(path);
const envPairs: Array<HoppEnvPair> = [];
const HoppEnvKeyPairResult = HoppEnvKeyPairObject.safeParse(contents);
const HoppEnvExportObjectResult = HoppEnvExportObject.safeParse(contents);
const HoppBulkEnvExportObjectResult =
HoppBulkEnvExportObject.safeParse(contents);
const envPairs: Array<Environment["variables"][number] | HoppEnvPair> = [];
// CLI doesnt support bulk environments export.
// Hence we check for this case and throw an error if it matches the format.
// The legacy key-value pair format that is still supported
const HoppEnvKeyPairResult = HoppEnvKeyPairObject.safeParse(contents);
// Shape of the single environment export object that is exported from the app
const HoppEnvExportObjectResult = Environment.safeParse(contents);
// Shape of the bulk environment export object that is exported from the app
const HoppBulkEnvExportObjectResult = z.array(entityReference(Environment)).safeParse(contents)
// CLI doesnt support bulk environments export
// Hence we check for this case and throw an error if it matches the format
if (HoppBulkEnvExportObjectResult.success) {
throw error({ code: "BULK_ENV_FILE", path, data: error });
}
// Checks if the environment file is of the correct format.
// If it doesnt match either of them, we throw an error.
if (!(HoppEnvKeyPairResult.success || HoppEnvExportObjectResult.success)) {
// Checks if the environment file is of the correct format
// If it doesnt match either of them, we throw an error
if (!HoppEnvKeyPairResult.success && HoppEnvExportObjectResult.type === "err") {
throw error({ code: "MALFORMED_ENV_FILE", path, data: error });
}
@@ -36,8 +44,8 @@ export async function parseEnvsData(path: string) {
for (const [key, value] of Object.entries(HoppEnvKeyPairResult.data)) {
envPairs.push({ key, value });
}
} else if (HoppEnvExportObjectResult.success) {
envPairs.push(...HoppEnvExportObjectResult.data.variables);
} else if (HoppEnvExportObjectResult.type === "ok") {
envPairs.push(...HoppEnvExportObjectResult.value.variables);
}
return <HoppEnvs>{ global: [], selected: envPairs };

View File

@@ -1,31 +1,18 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { Environment, HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { z } from "zod";
import { TestReport } from "../interfaces/response";
import { HoppCLIError } from "./errors";
import { z } from "zod";
export type FormDataEntry = {
key: string;
value: string | Blob;
};
export type HoppEnvPair = { key: string; value: string };
export type HoppEnvPair = Environment["variables"][number];
export const HoppEnvKeyPairObject = z.record(z.string(), z.string());
// Shape of the single environment export object that is exported from the app.
export const HoppEnvExportObject = z.object({
name: z.string(),
variables: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
});
// Shape of the bulk environment export object that is exported from the app.
export const HoppBulkEnvExportObject = z.array(HoppEnvExportObject);
export type HoppEnvs = {
global: HoppEnvPair[];
selected: HoppEnvPair[];

View File

@@ -1,9 +1,9 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import chalk from "chalk";
import { bold } from "chalk";
import { log } from "console";
import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/function";
import { round } from "lodash-es";
import round from "lodash/round";
import { CollectionRunnerParam } from "../types/collections";
import {
@@ -68,7 +68,7 @@ export const collectionsRunner = async (
};
// Request processing initiated message.
log(WARN(`\nRunning: ${chalk.bold(requestPath)}`));
log(WARN(`\nRunning: ${bold(requestPath)}`));
// Processing current request.
const result = await processRequest(processRequestParams)();

View File

@@ -1,4 +1,4 @@
import chalk from "chalk";
import { bold } from "chalk";
import { groupEnd, group, log } from "console";
import { handleError } from "../handlers/error";
import { RequestConfig } from "../interfaces/request";
@@ -120,7 +120,7 @@ export const printErrorsReport = (
errorsReport: HoppCLIError[]
) => {
if (errorsReport.length > 0) {
const REPORTED_ERRORS_TITLE = FAIL(`\n${chalk.bold(path)} reported errors:`);
const REPORTED_ERRORS_TITLE = FAIL(`\n${bold(path)} reported errors:`);
group(REPORTED_ERRORS_TITLE);
for (const errorReport of errorsReport) {
@@ -143,7 +143,7 @@ export const printFailedTestsReport = (
// Only printing test-reports with failed test-cases.
if (failedTestsReport.length > 0) {
const FAILED_TESTS_PATH = FAIL(`\n${chalk.bold(path)} failed tests:`);
const FAILED_TESTS_PATH = FAIL(`\n${bold(path)} failed tests:`);
group(FAILED_TESTS_PATH);
for (const failedTestReport of failedTestsReport) {
@@ -176,7 +176,7 @@ export const printRequestRunner = {
*/
start: (requestConfig: RequestConfig) => {
const METHOD = BG_INFO(` ${requestConfig.method} `);
const ENDPOINT = requestConfig.url;
const ENDPOINT = requestConfig.displayUrl || requestConfig.url;
process.stdout.write(`${METHOD} ${ENDPOINT}`);
},

View File

@@ -1,4 +1,4 @@
import { clone } from "lodash-es";
import { clone } from "lodash";
/**
* Sorts the array based on the sort func.

View File

@@ -11,7 +11,7 @@ import * as E from "fp-ts/Either";
import * as S from "fp-ts/string";
import * as O from "fp-ts/Option";
import { error } from "../types/errors";
import { round } from "lodash-es";
import round from "lodash/round";
import { DEFAULT_DURATION_PRECISION } from "./constants";
/**

View File

@@ -36,7 +36,10 @@ import { toFormData } from "./mutators";
export const preRequestScriptRunner = (
request: HoppRESTRequest,
envs: HoppEnvs
): TE.TaskEither<HoppCLIError, EffectiveHoppRESTRequest> =>
): TE.TaskEither<
HoppCLIError,
{ effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs }
> =>
pipe(
TE.of(request),
TE.chain(({ preRequestScript }) =>
@@ -68,7 +71,10 @@ export const preRequestScriptRunner = (
export function getEffectiveRESTRequest(
request: HoppRESTRequest,
environment: Environment
): E.Either<HoppCLIError, EffectiveHoppRESTRequest> {
): E.Either<
HoppCLIError,
{ effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs }
> {
const envVariables = environment.variables;
// Parsing final headers with applied ENVs.
@@ -162,12 +168,30 @@ export function getEffectiveRESTRequest(
}
const effectiveFinalURL = _effectiveFinalURL.right;
// Secret environment variables referenced in the request endpoint should be masked
let effectiveFinalDisplayURL;
if (envVariables.some(({ secret }) => secret)) {
const _effectiveFinalDisplayURL = parseTemplateStringE(
request.endpoint,
envVariables,
true
);
if (E.isRight(_effectiveFinalDisplayURL)) {
effectiveFinalDisplayURL = _effectiveFinalDisplayURL.right;
}
}
return E.right({
...request,
effectiveFinalURL,
effectiveFinalHeaders,
effectiveFinalParams,
effectiveFinalBody,
effectiveRequest: {
...request,
effectiveFinalURL,
effectiveFinalDisplayURL,
effectiveFinalHeaders,
effectiveFinalParams,
effectiveFinalBody,
},
updatedEnvs: { global: [], selected: envVariables },
});
}

View File

@@ -1,4 +1,4 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { Environment, HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import axios, { Method } from "axios";
import * as A from "fp-ts/Array";
import * as E from "fp-ts/Either";
@@ -29,6 +29,38 @@ import { getTestScriptParams, hasFailedTestCases, testRunner } from "./test";
// !NOTE: The `config.supported` checks are temporary until OAuth2 and Multipart Forms are supported
/**
* Processes given variable, which includes checking for secret variables
* and getting value from system environment
* @param variable Variable to be processed
* @returns Updated variable with value from system environment
*/
const processVariables = (variable: Environment["variables"][number]) => {
if (variable.secret) {
return {
...variable,
value:
"value" in variable ? variable.value : process.env[variable.key] || "",
}
}
return variable
}
/**
* Processes given envs, which includes processing each variable in global
* and selected envs
* @param envs Global + selected envs used by requests with in collection
* @returns Processed envs with each variable processed
*/
const processEnvs = (envs: HoppEnvs) => {
const processedEnvs = {
global: envs.global.map(processVariables),
selected: envs.selected.map(processVariables),
}
return processedEnvs
}
/**
* Transforms given request data to request-config used by request-runner to
* perform HTTP request.
@@ -38,6 +70,7 @@ import { getTestScriptParams, hasFailedTestCases, testRunner } from "./test";
export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => {
const config: RequestConfig = {
supported: true,
displayUrl: req.effectiveFinalDisplayURL
};
const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest;
const reqParams = finalParams(req);
@@ -221,9 +254,13 @@ export const processRequest =
effectiveFinalParams: [],
effectiveFinalURL: "",
};
let updatedEnvs = <HoppEnvs>{};
// Fetch values for secret environment variables from system environment
const processedEnvs = processEnvs(envs)
// Executing pre-request-script
const preRequestRes = await preRequestScriptRunner(request, envs)();
const preRequestRes = await preRequestScriptRunner(request, processedEnvs)();
if (E.isLeft(preRequestRes)) {
printPreRequestRunner.fail();
@@ -231,8 +268,8 @@ export const processRequest =
report.errors.push(preRequestRes.left);
report.result = report.result && false;
} else {
// Updating effective-request
effectiveRequest = preRequestRes.right;
// Updating effective-request and consuming updated envs after pre-request script execution
({ effectiveRequest, updatedEnvs } = preRequestRes.right);
}
// Creating request-config for request-runner.
@@ -270,7 +307,7 @@ export const processRequest =
const testScriptParams = getTestScriptParams(
_requestRunnerRes,
request,
envs
updatedEnvs
);
// Executing test-runner.

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"target": "ES6",
"module": "commonjs",
"outDir": ".",
"rootDir": ".",
"strict": true,

View File

@@ -3,14 +3,17 @@ import { defineConfig } from "tsup";
export default defineConfig({
entry: [ "./src/index.ts" ],
outDir: "./dist/",
format: ["esm"],
format: ["cjs"],
platform: "node",
sourcemap: true,
bundle: true,
target: "esnext",
target: "node12",
skipNodeModulesBundle: false,
esbuildOptions(options) {
options.bundle = true
},
noExternal: [
/\w+/
],
clean: true,
});

View File

@@ -429,6 +429,11 @@ pre.ace_editor {
}
}
.splitpanes__pane {
@apply will-change-auto;
transform: translateZ(0);
}
.smart-splitter .splitpanes__splitter {
@apply relative;
@apply before:absolute;

View File

@@ -24,6 +24,7 @@
"go_back": "Go back",
"go_forward": "Go forward",
"group_by": "Group by",
"hide_secret": "Hide secret",
"label": "Label",
"learn_more": "Learn more",
"less": "Less",
@@ -43,6 +44,7 @@
"search": "Search",
"send": "Send",
"share": "Share",
"show_secret": "Show secret",
"start": "Start",
"starting": "Starting",
"stop": "Stop",
@@ -238,6 +240,7 @@
"profile": "Login to view your profile",
"protocols": "Protocols are empty",
"schema": "Connect to a GraphQL endpoint to view schema",
"secret_environments": "Secrets are not synced to Hoppscotch",
"shared_requests": "Shared requests are empty",
"shared_requests_logout": "Login to view your shared requests or create a new one",
"subscription": "Subscriptions are empty",
@@ -269,6 +272,8 @@
"quick_peek": "Environment Quick Peek",
"replace_with_variable": "Replace with variable",
"scope": "Scope",
"secrets": "Secrets",
"secret_value": "Secret value",
"select": "Select environment",
"set": "Set environment",
"set_as_environment": "Set as environment",
@@ -277,6 +282,7 @@
"updated": "Environment updated",
"value": "Value",
"variable": "Variable",
"variables":"Variables",
"variable_list": "Variable List"
},
"error": {
@@ -413,6 +419,8 @@
"description": "Inspect possible errors",
"environment": {
"add_environment": "Add to Environment",
"add_environment_value": "Add value",
"empty_value": "Environment value is empty for the variable '{variable}' ",
"not_found": "Environment variable “{environment}” not found."
},
"header": {
@@ -889,6 +897,7 @@
"query": "Query",
"schema": "Schema",
"shared_requests": "Shared Requests",
"share_tab_request": "Share tab request",
"socketio": "Socket.IO",
"sse": "SSE",
"tests": "Tests",

View File

@@ -1,7 +1,7 @@
{
"name": "@hoppscotch/common",
"private": true,
"version": "2023.12.3",
"version": "2023.12.5",
"scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*",
"test": "vitest --run",

View File

@@ -23,7 +23,7 @@
<div class="col-span-1 flex items-center justify-between space-x-2">
<button
class="flex h-full flex-1 cursor-text items-center justify-between self-stretch rounded border border-dividerDark bg-primaryDark px-2 text-secondaryLight transition hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
@click="invokeAction('modals.search.toggle')"
@click="invokeAction('modals.search.toggle', undefined, 'mouseclick')"
>
<span class="inline-flex flex-1 items-center">
<icon-lucide-search class="svg-icons mr-2" />

View File

@@ -3,7 +3,7 @@
v-if="show"
styles="sm:max-w-lg"
full-width
@close="emit('hide-modal')"
@close="closeSpotlightModal"
>
<template #body>
<div class="flex flex-col border-b border-divider transition">
@@ -86,35 +86,36 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue"
import { useService } from "dioc/vue"
import { useI18n } from "@composables/i18n"
import { useService } from "dioc/vue"
import { isEqual } from "lodash-es"
import { computed, ref, watch } from "vue"
import { platform } from "~/platform"
import { HoppSpotlightSessionEventData } from "~/platform/analytics"
import {
SpotlightService,
SpotlightSearchState,
SpotlightSearcherResult,
SpotlightService,
} from "~/services/spotlight"
import { isEqual } from "lodash-es"
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
import { NavigationSpotlightSearcherService } from "~/services/spotlight/searchers/navigation.searcher"
import { SettingsSpotlightSearcherService } from "~/services/spotlight/searchers/settings.searcher"
import { CollectionsSpotlightSearcherService } from "~/services/spotlight/searchers/collections.searcher"
import { MiscellaneousSpotlightSearcherService } from "~/services/spotlight/searchers/miscellaneous.searcher"
import { TabSpotlightSearcherService } from "~/services/spotlight/searchers/tab.searcher"
import { GeneralSpotlightSearcherService } from "~/services/spotlight/searchers/general.searcher"
import { ResponseSpotlightSearcherService } from "~/services/spotlight/searchers/response.searcher"
import { RequestSpotlightSearcherService } from "~/services/spotlight/searchers/request.searcher"
import {
EnvironmentsSpotlightSearcherService,
SwitchEnvSpotlightSearcherService,
} from "~/services/spotlight/searchers/environment.searcher"
import { GeneralSpotlightSearcherService } from "~/services/spotlight/searchers/general.searcher"
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
import { InterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/interceptor.searcher"
import { MiscellaneousSpotlightSearcherService } from "~/services/spotlight/searchers/miscellaneous.searcher"
import { NavigationSpotlightSearcherService } from "~/services/spotlight/searchers/navigation.searcher"
import { RequestSpotlightSearcherService } from "~/services/spotlight/searchers/request.searcher"
import { ResponseSpotlightSearcherService } from "~/services/spotlight/searchers/response.searcher"
import { SettingsSpotlightSearcherService } from "~/services/spotlight/searchers/settings.searcher"
import { TabSpotlightSearcherService } from "~/services/spotlight/searchers/tab.searcher"
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
import {
SwitchWorkspaceSpotlightSearcherService,
WorkspaceSpotlightSearcherService,
} from "~/services/spotlight/searchers/workspace.searcher"
import { InterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/interceptor.searcher"
import { platform } from "~/platform"
const t = useI18n()
@@ -290,4 +291,17 @@ function newUseArrowKeysForNavigation() {
return { selectedEntry }
}
function closeSpotlightModal() {
const analyticsData: HoppSpotlightSessionEventData = {
action: "close",
searcherID: null,
rank: null,
}
// Sets the action indicating `close` and rank as `null` in the state for analytics event logging
spotlightService.setAnalyticsData(analyticsData)
emit("hide-modal")
}
</script>

View File

@@ -22,9 +22,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'cookie')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
@@ -102,6 +102,8 @@ import {
useCopyResponse,
useDownloadResponse,
} from "~/composables/lens-actions"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
// TODO: Build Managed Mode!
@@ -122,7 +124,7 @@ const toast = useToast()
const cookieEditor = ref<HTMLElement>()
const rawCookieString = ref("")
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "cookie")
useCodemirror(
cookieEditor,
@@ -131,7 +133,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/plain",
placeholder: `${t("cookies.modal.enter_cookie_string")}`,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,

View File

@@ -21,7 +21,7 @@
<label for="value" class="min-w-[2.5rem] font-semibold">{{
t("environment.value")
}}</label>
<input
<SmartEnvInput
v-model="editingValue"
type="text"
class="input"
@@ -154,12 +154,14 @@ const addEnvironment = async () => {
addGlobalEnvVariable({
key: editingName.value,
value: editingValue.value,
secret: false,
})
toast.success(`${t("environment.updated")}`)
} else if (scope.value.type === "my-environment") {
addEnvironmentVariable(scope.value.index, {
key: editingName.value,
value: editingValue.value,
secret: false,
})
toast.success(`${t("environment.updated")}`)
} else {

View File

@@ -9,7 +9,7 @@
</template>
<script setup lang="ts">
import { Environment } from "@hoppscotch/data"
import { Environment, NonSecretEnvironment } from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { ref } from "vue"
@@ -340,13 +340,13 @@ const showImportFailedError = () => {
const handleImportToStore = async (
environments: Environment[],
globalEnv?: Environment
globalEnv?: NonSecretEnvironment
) => {
// if there's a global env, add them to the store
if (globalEnv) {
globalEnv.variables.forEach(({ key, value }) => {
addGlobalEnvVariable({ key, value })
})
globalEnv.variables.forEach(({ key, value, secret }) =>
addGlobalEnvVariable({ key, value, secret })
)
}
if (props.environmentType === "MY_ENV") {

View File

@@ -210,7 +210,10 @@
{{ variable.key }}
</span>
<span class="min-w-[9rem] w-full truncate text-secondaryLight">
{{ variable.value }}
<template v-if="variable.secret"> ******** </template>
<template v-else>
{{ variable.value }}
</template>
</span>
</div>
<div v-if="globalEnvs.length === 0" class="text-secondaryLight">
@@ -265,7 +268,10 @@
{{ variable.key }}
</span>
<span class="min-w-[9rem] w-full truncate text-secondaryLight">
{{ variable.value }}
<template v-if="variable.secret"> ******** </template>
<template v-else>
{{ variable.value }}
</template>
</span>
</div>
<div
@@ -479,15 +485,20 @@ const selectedEnv = computed(() => {
type: "MY_ENV",
index: props.modelValue.index,
name: props.modelValue.environment?.name,
variables: props.modelValue.environment?.variables,
}
} else if (props.modelValue?.type === "team-environment") {
return {
type: "TEAM_ENV",
name: props.modelValue.environment.environment.name,
teamEnvID: props.modelValue.environment.id,
variables: props.modelValue.environment.environment.variables,
}
}
return { type: "global", name: "Global" }
return {
type: "global",
name: "Global",
}
}
if (selectedEnvironmentIndex.value.type === "MY_ENV") {
const environment =
@@ -582,9 +593,7 @@ const environmentVariables = computed(() => {
})
const editGlobalEnv = () => {
invokeAction("modals.my.environment.edit", {
envName: "Global",
})
invokeAction("modals.global.environment.update", {})
}
const editEnv = () => {

View File

@@ -24,6 +24,8 @@
:action="action"
:editing-environment-index="editingEnvironmentIndex"
:editing-variable-name="editingVariableName"
:env-vars="envVars"
:is-secret-option-selected="secretOptionSelected"
@hide-modal="displayModalEdit(false)"
/>
<EnvironmentsAdd
@@ -37,7 +39,7 @@
<HoppSmartConfirmModal
:show="showConfirmRemoveEnvModal"
:title="t('confirm.remove_team')"
:title="`${t('confirm.remove_environment')}`"
@hide-modal="showConfirmRemoveEnvModal = false"
@resolve="removeSelectedEnvironment()"
/>
@@ -67,6 +69,7 @@ import { deleteTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironme
import { useToast } from "~/composables/toast"
import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { Environment } from "@hoppscotch/data"
const t = useI18n()
const toast = useToast()
@@ -88,6 +91,8 @@ const environmentType = ref<EnvironmentsChooseType>({
const globalEnv = useReadonlyStream(globalEnv$, [])
const globalEnvironment = computed(() => ({
v: 1 as const,
id: "Global",
name: "Global",
variables: globalEnv.value,
}))
@@ -186,6 +191,7 @@ const action = ref<"new" | "edit">("edit")
const editingEnvironmentIndex = ref<"Global" | null>(null)
const editingVariableName = ref("")
const editingVariableValue = ref("")
const secretOptionSelected = ref(false)
const position = ref({ top: 0, left: 0 })
@@ -203,6 +209,7 @@ const displayModalEdit = (shouldDisplay: boolean) => {
const editEnvironment = (environmentIndex: "Global") => {
editingEnvironmentIndex.value = environmentIndex
action.value = "edit"
editingVariableName.value = ""
displayModalEdit(true)
}
@@ -232,6 +239,9 @@ const removeSelectedEnvironment = () => {
const resetSelectedData = () => {
editingEnvironmentIndex.value = null
editingVariableName.value = ""
editingVariableValue.value = ""
secretOptionSelected.value = false
}
defineActionHandler("modals.environment.new", () => {
@@ -243,11 +253,19 @@ defineActionHandler("modals.environment.delete-selected", () => {
showConfirmRemoveEnvModal.value = true
})
const additionalVars = ref<Environment["variables"]>([])
const envVars = () => [...globalEnv.value, ...additionalVars.value]
defineActionHandler(
"modals.my.environment.edit",
({ envName, variableName }) => {
if (variableName) editingVariableName.value = variableName
envName === "Global" && editEnvironment("Global")
"modals.global.environment.update",
({ variables, isSecret }) => {
if (variables) {
additionalVars.value = variables
}
secretOptionSelected.value = isSecret ?? false
editEnvironment("Global")
editingVariableName.value = "Global"
}
)

View File

@@ -16,76 +16,103 @@
@submit="saveEnvironment"
/>
<div class="flex flex-1 items-center justify-between">
<label for="variableList" class="p-4">
{{ t("environment.variable_list") }}
</label>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="clearIcon"
@click="clearContent()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="t('add.new')"
@click="addEnvironmentVariable"
/>
</div>
</div>
<div
v-if="evnExpandError"
class="mb-2 w-full overflow-auto whitespace-normal rounded bg-primaryLight px-4 py-2 font-mono text-red-400"
>
{{ t("environment.nested_overflow") }}
</div>
<div class="divide-y divide-dividerLight rounded border border-divider">
<div class="my-4 flex flex-col border border-divider rounded">
<div
v-for="({ id, env }, index) in vars"
:key="`variable-${id}-${index}`"
class="flex divide-x divide-dividerLight"
v-if="evnExpandError"
class="mb-2 w-full overflow-auto whitespace-normal rounded bg-primaryLight px-4 py-2 font-mono text-red-400"
>
<input
v-model="env.key"
v-focus
class="flex flex-1 bg-transparent px-4 py-2"
:placeholder="`${t('count.variable', { count: index + 1 })}`"
:name="'param' + index"
/>
<SmartEnvInput
v-model="env.value"
:select-text-on-mount="env.key === editingVariableName"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs"
:name="'value' + index"
/>
<div class="flex">
<HoppButtonSecondary
id="variable"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="removeEnvironmentVariable(index)"
/>
</div>
{{ t("environment.nested_overflow") }}
</div>
<HoppSmartPlaceholder
v-if="vars.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<template #body>
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
@click="addEnvironmentVariable"
/>
<HoppSmartTabs v-model="selectedEnvOption" render-inactive-tabs>
<template #actions>
<div class="flex flex-1 items-center justify-between">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/environments"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="clearIcon"
@click="clearContent()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="t('add.new')"
@click="addEnvironmentVariable"
/>
</div>
</template>
</HoppSmartPlaceholder>
<HoppSmartTab
v-for="tab in tabsData"
:id="tab.id"
:key="tab.id"
:label="tab.label"
>
<div
class="divide-y divide-dividerLight rounded border border-divider"
>
<HoppSmartPlaceholder
v-if="tab.variables.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="tab.emptyStateLabel"
:text="tab.emptyStateLabel"
>
<template #body>
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
:icon="IconPlus"
@click="addEnvironmentVariable"
/>
</template>
</HoppSmartPlaceholder>
<template v-else>
<div
v-for="({ id, env }, index) in tab.variables"
:key="`variable-${id}-${index}`"
class="flex divide-x divide-dividerLight"
>
<input
v-model="env.key"
v-focus
class="flex flex-1 bg-transparent px-4 py-2"
:placeholder="`${t('count.variable', {
count: index + 1,
})}`"
:name="'param' + index"
/>
<SmartEnvInput
v-model="env.value"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs"
:name="'value' + index"
:secret="tab.isSecret"
:select-text-on-mount="
env.key ? env.key === editingVariableName : false
"
/>
<div class="flex">
<HoppButtonSecondary
id="variable"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="removeEnvironmentVariable(id)"
/>
</div>
</div>
</template>
</div>
</HoppSmartTab>
</HoppSmartTabs>
</div>
</div>
</template>
@@ -112,8 +139,8 @@ import IconTrash2 from "~icons/lucide/trash-2"
import IconDone from "~icons/lucide/check"
import IconPlus from "~icons/lucide/plus"
import IconTrash from "~icons/lucide/trash"
import { clone } from "lodash-es"
import { computed, ref, watch } from "vue"
import IconHelpCircle from "~icons/lucide/help-circle"
import { ComputedRef, computed, ref, watch } from "vue"
import * as E from "fp-ts/Either"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
@@ -136,12 +163,16 @@ import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming"
import { environmentsStore } from "~/newstore/environments"
import { platform } from "~/platform"
import { useService } from "dioc/vue"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
import { uniqueId } from "lodash-es"
type EnvironmentVariable = {
id: number
env: {
key: string
value: string
key: string
secret: boolean
}
}
@@ -155,6 +186,7 @@ const props = withDefaults(
action: "edit" | "new"
editingEnvironmentIndex?: number | "Global" | null
editingVariableName?: string | null
isSecretOptionSelected?: boolean
envVars?: () => Environment["variables"]
}>(),
{
@@ -162,6 +194,7 @@ const props = withDefaults(
action: "edit",
editingEnvironmentIndex: null,
editingVariableName: null,
isSecretOptionSelected: false,
envVars: () => [],
}
)
@@ -172,11 +205,55 @@ const emit = defineEmits<{
const idTicker = ref(0)
const tabsData: ComputedRef<
{
id: string
label: string
emptyStateLabel: string
isSecret: boolean
variables: EnvironmentVariable[]
}[]
> = computed(() => {
return [
{
id: "variables",
label: t("environment.variables"),
emptyStateLabel: t("empty.environments"),
isSecret: false,
variables: nonSecretVars.value,
},
{
id: "secret",
label: t("environment.secrets"),
emptyStateLabel: t("empty.secret_environments"),
isSecret: true,
variables: secretVars.value,
},
]
})
const editingName = ref<string | null>(null)
const editingID = ref<string>("")
const vars = ref<EnvironmentVariable[]>([
{ id: idTicker.value++, env: { key: "", value: "" } },
{ id: idTicker.value++, env: { key: "", value: "", secret: false } },
])
const secretEnvironmentService = useService(SecretEnvironmentService)
const secretVars = computed(() =>
pipe(
vars.value,
A.filter((e) => e.env.secret)
)
)
const nonSecretVars = computed(() =>
pipe(
vars.value,
A.filter((e) => !e.env.secret)
)
)
const clearIcon = refAutoReset<typeof IconTrash2 | typeof IconDone>(
IconTrash2,
1000
@@ -184,14 +261,23 @@ const clearIcon = refAutoReset<typeof IconTrash2 | typeof IconDone>(
const globalVars = useReadonlyStream(globalEnv$, [])
type SelectedEnv = "variables" | "secret"
const selectedEnvOption = ref<SelectedEnv>("variables")
const workingEnv = computed(() => {
if (props.editingEnvironmentIndex === "Global") {
const vars =
props.editingVariableName === "Global"
? props.envVars()
: getGlobalVariables()
return {
name: "Global",
variables: getGlobalVariables(),
variables: vars,
} as Environment
} else if (props.action === "new") {
return {
id: uniqueId(),
name: "",
variables: props.envVars(),
}
@@ -214,6 +300,7 @@ const evnExpandError = computed(() => {
return pipe(
variables,
A.filter(({ secret }) => !secret),
A.exists(({ value }) => E.isLeft(parseTemplateStringE(value, variables)))
)
})
@@ -239,11 +326,29 @@ watch(
(show) => {
if (show) {
editingName.value = workingEnv.value?.name ?? null
selectedEnvOption.value = props.isSecretOptionSelected
? "secret"
: "variables"
if (props.editingEnvironmentIndex !== "Global") {
editingID.value = workingEnv.value?.id ?? uniqueId()
}
vars.value = pipe(
workingEnv.value?.variables ?? [],
A.map((e) => ({
A.mapWithIndex((index, e) => ({
id: idTicker.value++,
env: clone(e),
env: {
key: e.key,
value: e.secret
? secretEnvironmentService.getSecretEnvironmentVariable(
props.editingEnvironmentIndex === "Global"
? "Global"
: workingEnv.value?.id,
index
)?.value ?? ""
: e.value,
secret: e.secret,
},
}))
)
}
@@ -251,7 +356,10 @@ watch(
)
const clearContent = () => {
vars.value = []
vars.value = vars.value.filter((e) =>
selectedEnvOption.value === "secret" ? !e.env.secret : e.env.secret
)
clearIcon.value = IconDone
toast.success(`${t("state.cleared")}`)
}
@@ -262,12 +370,16 @@ const addEnvironmentVariable = () => {
env: {
key: "",
value: "",
secret: selectedEnvOption.value === "secret",
},
})
}
const removeEnvironmentVariable = (index: number) => {
vars.value.splice(index, 1)
const removeEnvironmentVariable = (id: number) => {
const index = vars.value.findIndex((e) => e.id === id)
if (index !== -1) {
vars.value.splice(index, 1)
}
}
const saveEnvironment = () => {
@@ -276,7 +388,7 @@ const saveEnvironment = () => {
return
}
const filterdVariables = pipe(
const filteredVariables = pipe(
vars.value,
A.filterMap(
flow(
@@ -286,14 +398,43 @@ const saveEnvironment = () => {
)
)
const secretVariables = pipe(
filteredVariables,
A.filterMapWithIndex((i, e) =>
e.secret ? O.some({ key: e.key, value: e.value, varIndex: i }) : O.none
)
)
if (editingID.value) {
secretEnvironmentService.addSecretEnvironment(
editingID.value,
secretVariables
)
} else if (props.editingEnvironmentIndex === "Global") {
secretEnvironmentService.addSecretEnvironment("Global", secretVariables)
}
const variables = pipe(
filteredVariables,
A.map((e) =>
e.secret ? { key: e.key, secret: e.secret, value: undefined } : e
)
)
const environmentUpdated: Environment = {
v: 1,
id: uniqueId(),
name: editingName.value,
variables: filterdVariables,
variables,
}
if (props.action === "new") {
// Creating a new environment
createEnvironment(editingName.value, environmentUpdated.variables)
createEnvironment(
editingName.value,
environmentUpdated.variables,
editingID.value
)
setSelectedEnvironmentIndex({
type: "MY_ENV",
index: envList.value.length - 1,
@@ -332,6 +473,7 @@ const saveEnvironment = () => {
const hideModal = () => {
editingName.value = null
selectedEnvOption.value = "variables"
emit("hide-modal")
}
</script>

View File

@@ -135,6 +135,8 @@ import { useToast } from "@composables/toast"
import { TippyComponent } from "vue-tippy"
import { HoppSmartItem } from "@hoppscotch/ui"
import { exportAsJSON } from "~/helpers/import-export/export/environment"
import { useService } from "dioc/vue"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
const t = useI18n()
const toast = useToast()
@@ -150,6 +152,8 @@ const emit = defineEmits<{
const confirmRemove = ref(false)
const secretEnvironmentService = useService(SecretEnvironmentService)
const exportEnvironmentAsJSON = () => {
const { environment, environmentIndex } = props
exportAsJSON(environment, environmentIndex)
@@ -168,6 +172,7 @@ const removeEnvironment = () => {
if (props.environmentIndex === null) return
if (props.environmentIndex !== "Global") {
deleteEnvironment(props.environmentIndex, props.environment.id)
secretEnvironmentService.deleteSecretEnvironment(props.environment.id)
}
toast.success(`${t("state.deleted")}`)
}

View File

@@ -67,6 +67,7 @@
:action="action"
:editing-environment-index="editingEnvironmentIndex"
:editing-variable-name="editingVariableName"
:is-secret-option-selected="secretOptionSelected"
@hide-modal="displayModalEdit(false)"
/>
<EnvironmentsImportExport
@@ -99,6 +100,7 @@ const showModalDetails = ref(false)
const action = ref<"new" | "edit">("edit")
const editingEnvironmentIndex = ref<number | null>(null)
const editingVariableName = ref("")
const secretOptionSelected = ref(false)
const displayModalAdd = (shouldDisplay: boolean) => {
action.value = "new"
@@ -120,18 +122,23 @@ const editEnvironment = (environmentIndex: number) => {
}
const resetSelectedData = () => {
editingEnvironmentIndex.value = null
editingVariableName.value = ""
secretOptionSelected.value = false
}
defineActionHandler(
"modals.my.environment.edit",
({ envName, variableName }) => {
({ envName, variableName, isSecret }) => {
if (variableName) editingVariableName.value = variableName
const envIndex: number = environments.value.findIndex(
(environment: Environment) => {
return environment.name === envName
}
)
if (envName !== "Global") editEnvironment(envIndex)
if (envName !== "Global") {
editEnvironment(envIndex)
secretOptionSelected.value = isSecret ?? false
}
}
)
</script>

View File

@@ -16,90 +16,112 @@
@submit="saveEnvironment"
/>
<div class="flex flex-1 items-center justify-between">
<label for="variableList" class="p-4">
{{ t("environment.variable_list") }}
</label>
<div v-if="!isViewer" class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="clearIcon"
@click="clearContent()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="t('add.new')"
@click="addEnvironmentVariable"
/>
</div>
</div>
<div
v-if="evnExpandError"
class="mb-2 w-full overflow-auto whitespace-normal rounded bg-primaryLight px-4 py-2 font-mono text-red-400"
>
{{ t("environment.nested_overflow") }}
</div>
<div class="divide-y divide-dividerLight rounded border border-divider">
<div class="my-4 flex flex-col border border-divider rounded">
<div
v-for="({ id, env }, index) in vars"
:key="`variable-${id}-${index}`"
class="flex divide-x divide-dividerLight"
v-if="evnExpandError"
class="mb-2 w-full overflow-auto whitespace-normal rounded bg-primaryLight px-4 py-2 font-mono text-red-400"
>
<input
v-model="env.key"
v-focus
class="flex flex-1 bg-transparent px-4 py-2"
:class="isViewer && 'opacity-25'"
:placeholder="`${t('count.variable', { count: index + 1 })}`"
:name="'param' + index"
:disabled="isViewer"
/>
<SmartEnvInput
v-model="env.value"
:select-text-on-mount="env.key === editingVariableName"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs"
:name="'value' + index"
:readonly="isViewer"
/>
<div v-if="!isViewer" class="flex">
<HoppButtonSecondary
id="variable"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="removeEnvironmentVariable(index)"
/>
</div>
{{ t("environment.nested_overflow") }}
</div>
<HoppSmartPlaceholder
v-if="vars.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<template #body>
<HoppButtonSecondary
v-if="isViewer"
disabled
:label="`${t('add.new')}`"
filled
/>
<HoppButtonSecondary
v-else
:label="`${t('add.new')}`"
filled
@click="addEnvironmentVariable"
/>
<HoppSmartTabs v-model="selectedEnvOption" render-inactive-tabs>
<template #actions>
<div class="flex flex-1 items-center justify-between">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/environments"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-if="!isViewer"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="clearIcon"
@click="clearContent()"
/>
<HoppButtonSecondary
v-if="!isViewer"
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="t('add.new')"
@click="addEnvironmentVariable"
/>
</div>
</template>
</HoppSmartPlaceholder>
<HoppSmartTab
v-for="tab in tabsData"
:id="tab.id"
:key="tab.id"
:label="tab.label"
>
<div
class="divide-y divide-dividerLight rounded border border-divider"
>
<HoppSmartPlaceholder
v-if="tab.variables.length === 0"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="tab.emptyStateLabel"
:text="tab.emptyStateLabel"
>
<template #body>
<HoppButtonSecondary
v-if="!isViewer"
:label="`${t('add.new')}`"
filled
:icon="IconPlus"
@click="addEnvironmentVariable"
/>
</template>
</HoppSmartPlaceholder>
<template v-else>
<div
v-for="({ id, env }, index) in tab.variables"
:key="`variable-${id}-${index}`"
class="flex divide-x divide-dividerLight"
>
<input
v-model="env.key"
v-focus
class="flex flex-1 bg-transparent px-4 py-2"
:placeholder="`${t('count.variable', {
count: index + 1,
})}`"
:name="'param' + index"
:disabled="isViewer"
/>
<SmartEnvInput
v-model="env.value"
:select-text-on-mount="
env.key ? env.key === editingVariableName : false
"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:envs="liveEnvs"
:name="'value' + index"
:secret="tab.isSecret"
:readonly="isViewer && !tab.isSecret"
/>
<div v-if="!isViewer" class="flex">
<HoppButtonSecondary
id="variable"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="removeEnvironmentVariable(id)"
/>
</div>
</div>
</template>
</div>
</HoppSmartTab>
</HoppSmartTabs>
</div>
</div>
</template>
<template v-if="!isViewer" #footer>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
:label="`${t('action.save')}`"
@@ -119,7 +141,7 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { ComputedRef, computed, ref, watch } from "vue"
import * as E from "fp-ts/Either"
import * as A from "fp-ts/Array"
import * as O from "fp-ts/Option"
@@ -141,13 +163,17 @@ import IconTrash from "~icons/lucide/trash"
import IconTrash2 from "~icons/lucide/trash-2"
import IconDone from "~icons/lucide/check"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import { platform } from "~/platform"
import { useService } from "dioc/vue"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
type EnvironmentVariable = {
id: number
env: {
key: string
value: string
secret: boolean
}
}
@@ -163,6 +189,7 @@ const props = withDefaults(
editingTeamId: string | undefined
editingVariableName?: string | null
isViewer?: boolean
isSecretOptionSelected?: boolean
envVars?: () => Environment["variables"]
}>(),
{
@@ -172,6 +199,7 @@ const props = withDefaults(
editingTeamId: "",
editingVariableName: null,
isViewer: false,
isSecretOptionSelected: false,
envVars: () => [],
}
)
@@ -182,11 +210,59 @@ const emit = defineEmits<{
const idTicker = ref(0)
const tabsData: ComputedRef<
{
id: string
label: string
emptyStateLabel: string
isSecret: boolean
variables: EnvironmentVariable[]
}[]
> = computed(() => {
return [
{
id: "variables",
label: t("environment.variables"),
emptyStateLabel: t("empty.environments"),
isSecret: false,
variables: nonSecretVars.value,
},
{
id: "secret",
label: t("environment.secrets"),
emptyStateLabel: t("empty.secret_environments"),
isSecret: true,
variables: secretVars.value,
},
]
})
const editingName = ref<string | null>(null)
const editingID = ref<string | null>(null)
const vars = ref<EnvironmentVariable[]>([
{ id: idTicker.value++, env: { key: "", value: "" } },
{ id: idTicker.value++, env: { key: "", value: "", secret: false } },
])
const secretEnvironmentService = useService(SecretEnvironmentService)
const secretVars = computed(() =>
pipe(
vars.value,
A.filter((e) => e.env.secret)
)
)
const nonSecretVars = computed(() =>
pipe(
vars.value,
A.filter((e) => !e.env.secret)
)
)
type SelectedEnv = "variables" | "secret"
const selectedEnvOption = ref<SelectedEnv>("variables")
const clearIcon = refAutoReset<typeof IconTrash2 | typeof IconDone>(
IconTrash2,
1000
@@ -215,22 +291,34 @@ watch(
() => props.show,
(show) => {
if (show) {
editingName.value = props.editingEnvironment?.environment.name ?? null
selectedEnvOption.value = props.isSecretOptionSelected
? "secret"
: "variables"
if (props.action === "new") {
editingName.value = null
vars.value = pipe(
props.envVars() ?? [],
A.map((e: { key: string; value: string }) => ({
A.map((e) => ({
id: idTicker.value++,
env: clone(e),
}))
)
} else if (props.editingEnvironment !== null) {
editingName.value = props.editingEnvironment.environment.name ?? null
editingID.value = props.editingEnvironment.id
vars.value = pipe(
props.editingEnvironment.environment.variables ?? [],
A.map((e: { key: string; value: string }) => ({
A.mapWithIndex((index, e) => ({
id: idTicker.value++,
env: clone(e),
env: {
key: e.key,
value: e.secret
? secretEnvironmentService.getSecretEnvironmentVariable(
editingID.value ?? "",
index
)?.value ?? ""
: e.value,
secret: e.secret,
},
}))
)
}
@@ -250,12 +338,16 @@ const addEnvironmentVariable = () => {
env: {
key: "",
value: "",
secret: selectedEnvOption.value === "secret",
},
})
}
const removeEnvironmentVariable = (index: number) => {
vars.value.splice(index, 1)
const removeEnvironmentVariable = (id: number) => {
const index = vars.value.findIndex((e) => e.id === id)
if (index !== -1) {
vars.value.splice(index, 1)
}
}
const isLoading = ref(false)
@@ -278,52 +370,102 @@ const saveEnvironment = async () => {
)
)
const secretVariables = pipe(
filterdVariables,
A.filterMapWithIndex((i, e) =>
e.secret ? O.some({ key: e.key, value: e.value, varIndex: i }) : O.none
)
)
const variables = pipe(
filterdVariables,
A.map((e) =>
e.secret ? { key: e.key, secret: e.secret, value: undefined } : e
)
)
const environmentUpdated: Environment = {
v: 1,
id: editingID.value ?? "",
name: editingName.value,
variables,
}
if (props.action === "new") {
platform.analytics?.logEvent({
type: "HOPP_CREATE_ENVIRONMENT",
workspaceType: "team",
})
await pipe(
createTeamEnvironment(
JSON.stringify(filterdVariables),
props.editingTeamId,
editingName.value
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
hideModal()
toast.success(`${t("environment.created")}`)
}
)
)()
if (!props.isViewer) {
await pipe(
createTeamEnvironment(
JSON.stringify(environmentUpdated.variables),
props.editingTeamId,
environmentUpdated.name
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
isLoading.value = false
},
(res) => {
const envID = res.createTeamEnvironment.id
if (envID) {
secretEnvironmentService.addSecretEnvironment(
envID,
secretVariables
)
}
hideModal()
toast.success(`${t("environment.created")}`)
isLoading.value = false
}
)
)()
}
} else {
if (!props.editingEnvironment) {
console.error("No Environment Found")
return
}
await pipe(
updateTeamEnvironment(
JSON.stringify(filterdVariables),
props.editingEnvironment.id,
editingName.value
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
hideModal()
toast.success(`${t("environment.updated")}`)
}
if (editingID.value) {
secretEnvironmentService.addSecretEnvironment(
editingID.value,
secretVariables
)
)()
// If the user is a viewer, we don't need to update the environment in BE
// just update the secret environment in the local storage
if (props.isViewer) {
hideModal()
toast.success(`${t("environment.updated")}`)
}
}
if (!props.isViewer) {
await pipe(
updateTeamEnvironment(
JSON.stringify(environmentUpdated.variables),
props.editingEnvironment.id,
environmentUpdated.name
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
isLoading.value = false
},
() => {
hideModal()
toast.success(`${t("environment.updated")}`)
isLoading.value = false
}
)
)()
}
}
isLoading.value = false
@@ -331,6 +473,7 @@ const saveEnvironment = async () => {
const hideModal = () => {
editingName.value = null
selectedEnvOption.value = "variables"
emit("hide-modal")
}

View File

@@ -19,7 +19,6 @@
</span>
<span>
<tippy
v-if="!isViewer"
ref="options"
interactive
trigger="click"
@@ -57,6 +56,7 @@
/>
<HoppSmartItem
v-if="!isViewer"
ref="duplicate"
:icon="IconCopy"
:label="`${t('action.duplicate')}`"
@@ -69,6 +69,7 @@
"
/>
<HoppSmartItem
v-if="!isViewer"
ref="exportAsJsonEl"
:icon="IconEdit"
:label="`${t('export.as_json')}`"
@@ -81,6 +82,7 @@
"
/>
<HoppSmartItem
v-if="!isViewer"
ref="deleteAction"
:icon="IconTrash2"
:label="`${t('action.delete')}`"
@@ -124,6 +126,8 @@ import IconMoreVertical from "~icons/lucide/more-vertical"
import { TippyComponent } from "vue-tippy"
import { HoppSmartItem } from "@hoppscotch/ui"
import { exportAsJSON } from "~/helpers/import-export/export/environment"
import { useService } from "dioc/vue"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
const t = useI18n()
const toast = useToast()
@@ -137,6 +141,8 @@ const emit = defineEmits<{
(e: "edit-environment"): void
}>()
const secretEnvironmentService = useService(SecretEnvironmentService)
const confirmRemove = ref(false)
const exportEnvironmentAsJSON = () =>
@@ -161,6 +167,7 @@ const removeEnvironment = () => {
},
() => {
toast.success(`${t("team_environment.deleted")}`)
secretEnvironmentService.deleteSecretEnvironment(props.environment.id)
}
)
)()

View File

@@ -105,6 +105,7 @@
:editing-environment="editingEnvironment"
:editing-team-id="team?.id"
:editing-variable-name="editingVariableName"
:is-secret-option-selected="secretOptionSelected"
:is-viewer="team?.myRole === 'VIEWER'"
@hide-modal="displayModalEdit(false)"
/>
@@ -148,6 +149,7 @@ const showModalDetails = ref(false)
const action = ref<"new" | "edit">("edit")
const editingEnvironment = ref<TeamEnvironment | null>(null)
const editingVariableName = ref("")
const secretOptionSelected = ref(false)
const isTeamViewer = computed(() => props.team?.myRole === "VIEWER")
@@ -171,6 +173,8 @@ const editEnvironment = (environment: TeamEnvironment | null) => {
}
const resetSelectedData = () => {
editingEnvironment.value = null
editingVariableName.value = ""
secretOptionSelected.value = false
}
const getErrorMessage = (err: GQLError<string>) => {
@@ -187,12 +191,15 @@ const getErrorMessage = (err: GQLError<string>) => {
defineActionHandler(
"modals.team.environment.edit",
({ envName, variableName }) => {
({ envName, variableName, isSecret }) => {
if (variableName) editingVariableName.value = variableName
const teamEnvToEdit = props.teamEnvironments.find(
(environment) => environment.environment.name === envName
)
if (teamEnvToEdit) editEnvironment(teamEnvToEdit)
if (teamEnvToEdit) {
editEnvironment(teamEnvToEdit)
secretOptionSelected.value = isSecret ?? false
}
}
)
</script>

View File

@@ -27,9 +27,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'graphqlHeaders')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -315,6 +315,8 @@ import { commonHeaders } from "~/helpers/headers"
import { useCodemirror } from "@composables/codemirror"
import { objRemoveKey } from "~/helpers/functional/object"
import { useVModel } from "@vueuse/core"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
import { HoppGQLHeader } from "~/helpers/graphql"
import { throwError } from "~/helpers/functional/error"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
@@ -338,7 +340,7 @@ const request = useVModel(props, "modelValue", emit)
const idTicker = ref(0)
const linewrapEnabled = ref(false)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlHeaders")
const bulkMode = ref(false)
const bulkHeaders = ref("")
@@ -353,7 +355,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,

View File

@@ -61,9 +61,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'graphqlQuery')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -112,6 +112,8 @@ import {
socketDisconnect,
subscriptionState,
} from "~/helpers/graphql/connection"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
// Template refs
const queryEditor = ref<any | null>(null)
@@ -137,7 +139,7 @@ const prettifyQueryIcon = refAutoReset<
typeof IconWand | typeof IconCheck | typeof IconInfo
>(IconWand, 1000)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlQuery")
const selectedOperation = ref<gql.OperationDefinitionNode | null>(null)
@@ -184,7 +186,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "graphql",
placeholder: `${t("request.query")}`,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: createGQLQueryLinter(schema),
completer: queryCompleter(schema),

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex h-full flex-1 flex-col">
<div class="h-full">
<HoppSmartTabs
v-model="selectedOptionTab"
styles="sticky top-0 bg-primary z-10 border-b-0"

View File

@@ -16,9 +16,11 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="
toggleNestedSetting('WRAP_LINES', 'graphqlResponseBody')
"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
@@ -70,7 +72,9 @@
</tippy>
</div>
</div>
<div ref="schemaEditor" class="flex flex-1 flex-col"></div>
<div class="h-full">
<div ref="schemaEditor"></div>
</div>
</div>
<component
:is="response[0].error.component"
@@ -99,6 +103,8 @@ import { useI18n } from "@composables/i18n"
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { GQLResponseEvent } from "~/helpers/graphql/connection"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
import interfaceLanguages from "~/helpers/utils/interfaceLanguages"
import {
useCopyInterface,
@@ -133,8 +139,8 @@ const responseString = computed(() => {
})
const schemaEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlResponseBody")
const copyInterfaceTippyActions = ref<any | null>(null)
const linewrapEnabled = ref(true)
useCodemirror(
schemaEditor,
@@ -143,7 +149,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "application/ld+json",
readOnly: true,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,

View File

@@ -127,9 +127,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'graphqlSchema')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -145,11 +145,9 @@
/>
</div>
</div>
<div
v-if="schemaString"
ref="schemaEditor"
class="flex flex-1 flex-col"
></div>
<div v-if="schemaString" class="h-full relative w-full">
<div ref="schemaEditor" class="absolute inset-0"></div>
</div>
<HoppSmartPlaceholder
v-else
:src="`/images/states/${colorMode.value}/blockchain.svg`"
@@ -202,6 +200,8 @@ import {
subscriptionFields,
} from "~/helpers/graphql/connection"
import { platform } from "~/platform"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
type NavigationTabs = "history" | "collection" | "docs" | "schema"
type GqlTabs = "queries" | "mutations" | "subscriptions" | "types"
@@ -349,7 +349,7 @@ const handleJumpToType = async (type: GraphQLType) => {
}
const schemaEditor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlSchema")
useCodemirror(
schemaEditor,
@@ -358,7 +358,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "graphql",
readOnly: true,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,

View File

@@ -49,9 +49,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'graphqlVariables')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -67,7 +67,9 @@
/>
</div>
</div>
<div ref="variableEditor" class="flex flex-1 flex-col"></div>
<div class="h-full relative">
<div ref="variableEditor" class="flex flex-1 flex-col"></div>
</div>
</template>
<script setup lang="ts">
@@ -93,6 +95,8 @@ import {
socketDisconnect,
subscriptionState,
} from "~/helpers/graphql/connection"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
const t = useI18n()
const toast = useToast()
@@ -114,7 +118,7 @@ const variableString = useVModel(props, "modelValue", emit)
const variableEditor = ref<any | null>(null)
const linewrapEnabled = ref(false)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlVariables")
const copyVariablesIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
@@ -131,7 +135,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "application/ld+json",
placeholder: `${t("request.variables")}`,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: computed(() =>
variableString.value.length > 0 ? jsonLinter : null

View File

@@ -331,7 +331,8 @@ const deleteHistory = (entry: HistoryEntry) => {
const addToCollection = (entry: HistoryEntry) => {
if (props.page === "rest") {
invokeAction("request.save-as", {
request: entry.request,
requestType: "rest",
request: entry.request as HoppRESTRequest,
})
}
}

View File

@@ -86,9 +86,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'codeGen')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
@@ -161,6 +161,8 @@ import cloneDeep from "lodash-es/cloneDeep"
import { platform } from "~/platform"
import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
const t = useI18n()
@@ -187,6 +189,8 @@ const copyCodeIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
const requestCode = computed(() => {
const aggregateEnvs = getAggregateEnvs()
const env: Environment = {
v: 1,
id: "env",
name: "Env",
variables: aggregateEnvs,
}
@@ -222,7 +226,7 @@ const requestCode = computed(() => {
// Template refs
const tippyActions = ref<any | null>(null)
const generatedCode = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "codeGen")
useCodemirror(
generatedCode,
@@ -231,7 +235,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/plain",
readOnly: true,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,

View File

@@ -29,9 +29,9 @@
v-if="bulkMode"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpHeaders')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -49,7 +49,9 @@
/>
</div>
</div>
<div v-if="bulkMode" ref="bulkEditor" class="flex flex-1 flex-col"></div>
<div v-if="bulkMode" class="h-full relative w-full">
<div ref="bulkEditor" class="absolute inset-0"></div>
</div>
<div v-else>
<draggable
v-model="workingHeaders"
@@ -332,6 +334,8 @@ import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
import { InspectionService, InspectorResult } from "~/services/inspection"
import { RESTTabService } from "~/services/tab/rest"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
const t = useI18n()
@@ -346,7 +350,7 @@ const idTicker = ref(0)
const bulkMode = ref(false)
const bulkHeaders = ref("")
const bulkEditor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpHeaders")
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
@@ -371,7 +375,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter,
completer: null,
@@ -553,7 +557,7 @@ const clearContent = () => {
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, getAggregateEnvs())
const computedHeaders = computed(() =>
getComputedHeaders(request.value, aggregateEnvs.value).map(
getComputedHeaders(request.value, aggregateEnvs.value, false).map(
(header, index) => ({
id: `header-${index}`,
...header,
@@ -606,7 +610,8 @@ const inheritedProperties = computed(() => {
const computedAuthHeader = getComputedAuthHeaders(
aggregateEnvs.value,
request.value,
props.inheritedProperties.auth.inheritedAuth
props.inheritedProperties.auth.inheritedAuth,
false
)[0]
if (

View File

@@ -22,9 +22,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'importCurl')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
@@ -96,6 +96,8 @@ import IconTrash2 from "~icons/lucide/trash-2"
import { platform } from "~/platform"
import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
const t = useI18n()
@@ -106,7 +108,7 @@ const tabs = useService(RESTTabService)
const curl = ref("")
const curlEditor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "importCurl")
const props = defineProps<{ show: boolean; text: string }>()
@@ -117,7 +119,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "application/x-sh",
placeholder: `${t("request.enter_curl")}`,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,

View File

@@ -3,14 +3,25 @@
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="oidcDiscoveryURL"
:styles="
hasAccessTokenOrAuthURL ? 'pointer-events-none opacity-70' : ''
"
placeholder="OpenID Connect Discovery URL"
/>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="authURL" placeholder="Authorization URL" />
<SmartEnvInput
v-model="authURL"
placeholder="Authorization URL"
:styles="hasOIDCURL ? 'pointer-events-none opacity-70' : ''"
></SmartEnvInput>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="accessTokenURL" placeholder="Access Token URL" />
<SmartEnvInput
v-model="accessTokenURL"
placeholder="Access Token URL"
:styles="hasOIDCURL ? 'pointer-events-none opacity-70' : ''"
/>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="clientID" placeholder="Client ID" />
@@ -44,6 +55,7 @@ import { useToast } from "@composables/toast"
import { tokenRequest } from "~/helpers/oauth"
import { getCombinedEnvVariables } from "~/helpers/preRequest"
import * as E from "fp-ts/Either"
import { computed } from "vue"
const t = useI18n()
const toast = useToast()
@@ -66,10 +78,16 @@ watch(
)
const oidcDiscoveryURL = pluckRef(auth, "oidcDiscoveryURL")
const hasOIDCURL = computed(() => {
return oidcDiscoveryURL.value
})
const authURL = pluckRef(auth, "authURL")
const accessTokenURL = pluckRef(auth, "accessTokenURL")
const hasAccessTokenOrAuthURL = computed(() => {
return accessTokenURL.value || authURL.value
})
const clientID = pluckRef(auth, "clientID")
@@ -88,13 +106,11 @@ function translateTokenRequestError(error: string) {
}
const handleAccessTokenRequest = async () => {
if (
oidcDiscoveryURL.value === "" &&
(authURL.value === "" || accessTokenURL.value === "")
) {
if (!oidcDiscoveryURL.value && !(authURL.value || accessTokenURL.value)) {
toast.error(`${t("error.incomplete_config_urls")}`)
return
}
const envs = getCombinedEnvVariables()
const envVars = [...envs.selected, ...envs.global]

View File

@@ -24,9 +24,9 @@
v-if="bulkMode"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpParams')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -44,7 +44,9 @@
/>
</div>
</div>
<div v-if="bulkMode" ref="bulkEditor" class="flex flex-1 flex-col"></div>
<div v-if="bulkMode" class="h-full relative">
<div ref="bulkEditor" class="absolute inset-0"></div>
</div>
<div v-else>
<draggable
v-model="workingParams"
@@ -205,6 +207,8 @@ import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
import { InspectionService, InspectorResult } from "~/services/inspection"
import { RESTTabService } from "~/services/tab/rest"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
const colorMode = useColorMode()
@@ -217,7 +221,7 @@ const idTicker = ref(0)
const bulkMode = ref(false)
const bulkParams = ref("")
const bulkEditor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpParams")
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
@@ -228,7 +232,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter,
completer: null,

View File

@@ -23,15 +23,15 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpPreRequest')"
/>
</div>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<div class="w-2/3 border-r border-dividerLight">
<div ref="preRequestEditor" class="h-full"></div>
<div class="w-2/3 border-r border-dividerLight h-full relative">
<div ref="preRequestEditor" class="h-full absolute inset-0"></div>
</div>
<div
class="z-[9] sticky top-upperTertiaryStickyFold h-full min-w-[12rem] max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4"
@@ -72,6 +72,8 @@ import linter from "~/helpers/editor/linting/preRequest"
import completer from "~/helpers/editor/completion/preRequest"
import { useI18n } from "@composables/i18n"
import { useVModel } from "@vueuse/core"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
const t = useI18n()
@@ -85,7 +87,7 @@ const emit = defineEmits<{
const preRequestScript = useVModel(props, "modelValue", emit)
const preRequestEditor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpPreRequest")
useCodemirror(
preRequestEditor,
@@ -93,7 +95,7 @@ useCodemirror(
reactive({
extendedEditorConfig: {
mode: "application/javascript",
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
placeholder: `${t("preRequest.javascript_code")}`,
},
linter,

View File

@@ -23,9 +23,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpRequestBody')"
/>
<HoppButtonSecondary
v-if="
@@ -59,7 +59,9 @@
/>
</div>
</div>
<div ref="rawBodyParameters" class="flex flex-1 flex-col"></div>
<div class="h-full relative">
<div ref="rawBodyParameters" class="absolute inset-0"></div>
</div>
</div>
</template>
@@ -85,6 +87,8 @@ import { isJSONContentType } from "~/helpers/utils/contenttypes"
import jsonLinter from "~/helpers/editor/linting/json"
import { readFileAsText } from "~/helpers/functional/files"
import xmlFormat from "xml-formatter"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
type PossibleContentTypes = Exclude<
ValidContentTypes,
@@ -122,7 +126,7 @@ const langLinter = computed(() =>
isJSONContentType(body.value.contentType) ? jsonLinter : null
)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpRequestBody")
const rawBodyParameters = ref<any | null>(null)
const codemirrorValue: Ref<string | undefined> =
@@ -148,7 +152,7 @@ useCodemirror(
codemirrorValue,
reactive({
extendedEditorConfig: {
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
mode: rawInputEditorLang,
placeholder: t("request.raw_body").toString(),
},

View File

@@ -255,7 +255,7 @@ import IconShare2 from "~icons/lucide/share-2"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
import { platform } from "~/platform"
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { InterceptorService } from "~/services/interceptor.service"
@@ -263,6 +263,7 @@ import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { RESTTabService } from "~/services/tab/rest"
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
import { WorkspaceService } from "~/services/workspace.service"
const t = useI18n()
const interceptorService = useService(InterceptorService)
@@ -326,6 +327,8 @@ const inspectionService = useService(InspectionService)
const tabs = useService(RESTTabService)
const workspaceService = useService(WorkspaceService)
const newSendRequest = async () => {
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
toast.error(`${t("empty.endpoint")}`)
@@ -341,6 +344,7 @@ const newSendRequest = async () => {
type: "HOPP_REQUEST_RUN",
platform: "rest",
strategy: interceptorService.currentInterceptorID.value!,
workspaceType: workspaceService.currentWorkspace.value.type,
})
const [cancel, streamPromise] = runRESTRequest$(tab)
@@ -395,17 +399,14 @@ const newSendRequest = async () => {
}
const ensureMethodInEndpoint = () => {
if (
!/^http[s]?:\/\//.test(newEndpoint.value) &&
!newEndpoint.value.startsWith("<<")
) {
const domain = newEndpoint.value.split(/[/:#?]+/)[0]
const endpoint = newEndpoint.value.trim()
tab.value.document.request.endpoint = endpoint
if (!/^http[s]?:\/\//.test(endpoint) && !endpoint.startsWith("<<")) {
const domain = endpoint.split(/[/:#?]+/)[0]
if (domain === "localhost" || /([0-9]+\.)*[0-9]/.test(domain)) {
tab.value.document.request.endpoint =
"http://" + tab.value.document.request.endpoint
tab.value.document.request.endpoint = "http://" + endpoint
} else {
tab.value.document.request.endpoint =
"https://" + tab.value.document.request.endpoint
tab.value.document.request.endpoint = "https://" + endpoint
}
}
}
@@ -577,25 +578,12 @@ defineActionHandler("request.share-request", shareRequest)
defineActionHandler("request.method.next", cycleDownMethod)
defineActionHandler("request.method.prev", cycleUpMethod)
defineActionHandler("request.save", saveRequest)
defineActionHandler(
"request.save-as",
(
req:
| {
requestType: "rest"
request: HoppRESTRequest
}
| {
requestType: "gql"
request: HoppGQLRequest
}
) => {
showSaveRequestModal.value = true
if (req && req.requestType === "rest") {
request.value = req.request
}
defineActionHandler("request.save-as", (req) => {
showSaveRequestModal.value = true
if (req?.requestType === "rest") {
request.value = req.request
}
)
})
defineActionHandler("request.method.get", () => updateMethod("GET"))
defineActionHandler("request.method.post", () => updateMethod("POST"))
defineActionHandler("request.method.put", () => updateMethod("PUT"))

View File

@@ -9,7 +9,7 @@
/>
</template>
<template #secondary>
<HttpResponse v-model:document="tab.document" />
<HttpResponse v-model:document="tab.document" :is-embed="false" />
</template>
</AppPaneLayout>
</template>

View File

@@ -29,6 +29,7 @@
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="renameAction?.$el.click()"
@keyup.s="shareRequestAction?.$el.click()"
@keyup.d="duplicateAction?.$el.click()"
@keyup.w="closeAction?.$el.click()"
@keyup.x="closeOthersAction?.$el.click()"
@@ -58,6 +59,18 @@
}
"
/>
<HoppSmartItem
ref="shareRequestAction"
:icon="IconShare2"
:label="t('tab.share_tab_request')"
:shortcut="['S']"
@click="
() => {
emit('share-tab-request')
hide()
}
"
/>
<HoppSmartItem
v-if="isRemovable"
ref="closeAction"
@@ -99,6 +112,7 @@ import IconXCircle from "~icons/lucide/x-circle"
import IconXSquare from "~icons/lucide/x-square"
import IconFileEdit from "~icons/lucide/file-edit"
import IconCopy from "~icons/lucide/copy"
import IconShare2 from "~icons/lucide/share-2"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
@@ -114,6 +128,7 @@ const emit = defineEmits<{
(event: "close-tab"): void
(event: "close-other-tabs"): void
(event: "duplicate-tab"): void
(event: "share-tab-request"): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
@@ -123,4 +138,5 @@ const renameAction = ref<HTMLButtonElement | null>(null)
const closeAction = ref<HTMLButtonElement | null>(null)
const closeOthersAction = ref<HTMLButtonElement | null>(null)
const duplicateAction = ref<HTMLButtonElement | null>(null)
const shareRequestAction = ref<HTMLButtonElement | null>(null)
</script>

View File

@@ -211,7 +211,6 @@ import { useI18n } from "@composables/i18n"
import {
globalEnv$,
selectedEnvironmentIndex$,
setGlobalEnvVariables,
setSelectedEnvironmentIndex,
} from "~/newstore/environments"
import { HoppTestResult } from "~/helpers/types/HoppTestResult"
@@ -225,6 +224,7 @@ import { useColorMode } from "~/composables/theming"
import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service"
import { invokeAction } from "~/helpers/actions"
const props = defineProps<{
modelValue: HoppTestResult | null | undefined
@@ -304,9 +304,10 @@ const globalHasAdditions = computed(() => {
const addEnvToGlobal = () => {
if (!testResults.value?.envDiff.selected.additions) return
setGlobalEnvVariables([
...globalEnvVars.value,
...testResults.value.envDiff.selected.additions,
])
invokeAction("modals.global.environment.update", {
variables: testResults.value.envDiff.selected.additions,
isSecret: false,
})
}
</script>

View File

@@ -23,15 +23,15 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpTest')"
/>
</div>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<div class="w-2/3 border-r border-dividerLight">
<div ref="testScriptEditor" class="h-full"></div>
<div class="w-2/3 border-r border-dividerLight h-full relative">
<div ref="testScriptEditor" class="h-full absolute inset-0"></div>
</div>
<div
class="z-[9] sticky top-upperTertiaryStickyFold h-full min-w-[12rem] max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4"
@@ -72,6 +72,8 @@ import linter from "~/helpers/editor/linting/testScript"
import completer from "~/helpers/editor/completion/testScript"
import { useI18n } from "@composables/i18n"
import { useVModel } from "@vueuse/core"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
const t = useI18n()
@@ -81,7 +83,7 @@ const props = defineProps<{
const emit = defineEmits(["update:modelValue"])
const testScript = useVModel(props, "modelValue", emit)
const testScriptEditor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpTest")
useCodemirror(
testScriptEditor,
@@ -89,7 +91,7 @@ useCodemirror(
reactive({
extendedEditorConfig: {
mode: "application/javascript",
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
placeholder: `${t("test.javascript_code")}`,
},
linter,

View File

@@ -24,9 +24,9 @@
v-if="bulkMode"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpUrlEncoded')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -44,7 +44,9 @@
/>
</div>
</div>
<div v-if="bulkMode" ref="bulkEditor" class="flex flex-1 flex-col"></div>
<div v-if="bulkMode" class="h-full relative">
<div ref="bulkEditor" class="absolute inset-0"></div>
</div>
<div v-else>
<draggable
v-model="workingUrlEncodedParams"
@@ -196,6 +198,8 @@ import { useColorMode } from "@composables/theming"
import { objRemoveKey } from "~/helpers/functional/object"
import { throwError } from "~/helpers/functional/error"
import { useVModel } from "@vueuse/core"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
type Body = HoppRESTReqBody & {
contentType: "application/x-www-form-urlencoded"
@@ -220,7 +224,7 @@ const idTicker = ref(0)
const bulkMode = ref(false)
const bulkUrlEncodedParams = ref("")
const bulkEditor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpUrlEncoded")
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
@@ -231,7 +235,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter,
completer: null,

View File

@@ -11,9 +11,9 @@
v-if="response.body"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
/>
<HoppButtonSecondary
v-if="response.body"
@@ -44,11 +44,9 @@
/>
</div>
</div>
<div
v-show="!previewEnabled"
ref="htmlResponse"
class="flex flex-1 flex-col"
></div>
<div v-show="!previewEnabled" class="h-full">
<div ref="htmlResponse" class="flex flex-1 flex-col"></div>
</div>
<iframe
v-show="previewEnabled"
ref="previewFrame"
@@ -76,6 +74,8 @@ import { useI18n } from "@composables/i18n"
import type { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
const t = useI18n()
@@ -84,7 +84,7 @@ const props = defineProps<{
}>()
const htmlResponse = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpResponseBody")
const { responseBodyText } = useResponseBody(props.response)
const { downloadIcon, downloadResponse } = useDownloadResponse(
@@ -104,7 +104,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "htmlmixed",
readOnly: true,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,

View File

@@ -14,9 +14,9 @@
v-if="response.body"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
/>
<HoppButtonSecondary
v-if="response.body"
@@ -119,11 +119,12 @@
/>
</div>
</div>
<div
ref="jsonResponse"
class="flex h-auto h-full flex-1 flex-col"
:class="toggleFilter ? 'responseToggleOn' : 'responseToggleOff'"
></div>
<div class="h-full">
<div
ref="jsonResponse"
:class="toggleFilter ? 'responseToggleOn' : 'responseToggleOff'"
></div>
</div>
<div
v-if="outlinePath"
class="sticky bottom-0 z-10 flex flex-shrink-0 flex-nowrap overflow-auto overflow-x-auto border-t border-dividerLight bg-primaryLight px-2"
@@ -260,6 +261,8 @@ import {
} from "@composables/lens-actions"
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
import interfaceLanguages from "~/helpers/utils/interfaceLanguages"
const t = useI18n()
@@ -371,8 +374,8 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
// Template refs
const tippyActions = ref<any | null>(null)
const jsonResponse = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpResponseBody")
const copyInterfaceTippyActions = ref<any | null>(null)
const linewrapEnabled = ref(true)
const { cursor } = useCodemirror(
jsonResponse,
@@ -381,7 +384,7 @@ const { cursor } = useCodemirror(
extendedEditorConfig: {
mode: "application/ld+json",
readOnly: true,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,

View File

@@ -11,9 +11,9 @@
v-if="response.body"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
/>
<HoppButtonSecondary
v-if="response.body"
@@ -35,7 +35,9 @@
/>
</div>
</div>
<div ref="rawResponse" class="flex flex-1 flex-col"></div>
<div class="h-full">
<div ref="rawResponse" class="flex flex-1 flex-col"></div>
</div>
</div>
</template>
@@ -58,6 +60,8 @@ import {
import { objFieldMatches } from "~/helpers/functional/object"
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
const t = useI18n()
@@ -97,7 +101,7 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
const rawResponse = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpResponseBody")
useCodemirror(
rawResponse,
@@ -106,7 +110,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/plain",
readOnly: true,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,

View File

@@ -11,9 +11,9 @@
v-if="response.body"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:class="{ '!text-accent': WRAP_LINES }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
/>
<HoppButtonSecondary
v-if="response.body"
@@ -35,7 +35,9 @@
/>
</div>
</div>
<div ref="xmlResponse" class="flex flex-1 flex-col"></div>
<div class="h-full">
<div ref="xmlResponse" class="flex flex-1 flex-col"></div>
</div>
</div>
</template>
@@ -58,6 +60,8 @@ import {
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { objFieldMatches } from "~/helpers/functional/object"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
const t = useI18n()
@@ -91,7 +95,7 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
const xmlResponse = ref<any | null>(null)
const linewrapEnabled = ref(true)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpResponseBody")
useCodemirror(
xmlResponse,
@@ -100,7 +104,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "application/xml",
readOnly: true,
lineWrapping: linewrapEnabled,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,

View File

@@ -130,7 +130,9 @@
/>
</div>
</div>
<div ref="wsCommunicationBody" class="flex flex-1 flex-col"></div>
<div class="h-full">
<div ref="wsCommunicationBody" class="flex flex-1 flex-col"></div>
</div>
</div>
</template>
<script setup lang="ts">

View File

@@ -7,7 +7,7 @@
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
class="flex items-center justify-center flex-1 min-w-0 py-2 cursor-pointer pointer-events-auto"
:title="`${timeStamp}`"
@click="openInNewTab"
@click="customizeSharedRequest()"
>
<span
class="flex items-center justify-center w-16 px-2 truncate pointer-events-none"
@@ -62,7 +62,7 @@
:shortcut="['T']"
@click="
() => {
openInNewTab()
emit('open-shared-request', parseRequest)
hide()
}
"
@@ -128,7 +128,7 @@ const emit = defineEmits<{
embedProperties?: string | null
): void
(e: "delete-shared-request", codeID: string): void
(e: "open-new-tab", request: HoppRESTRequest): void
(e: "open-shared-request", request: HoppRESTRequest): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
@@ -145,10 +145,6 @@ const requestLabelColor = computed(() =>
getMethodLabelColorClassOf(parseRequest.value)
)
const openInNewTab = () => {
emit("open-new-tab", parseRequest.value)
}
const customizeSharedRequest = () => {
const embedProperties = props.request.properties
emit(

View File

@@ -9,8 +9,14 @@
/>
</div>
<div
class="sticky top-sidebarPrimaryStickyFold z-10 flex flex-1 flex-shrink-0 justify-end overflow-x-auto border-b border-dividerLight bg-primary"
class="sticky top-sidebarPrimaryStickyFold z-10 flex flex-1 flex-shrink-0 justify-between overflow-x-auto border-b border-dividerLight bg-primary"
>
<HoppButtonSecondary
:label="t('action.new')"
:icon="IconPlus"
class="!rounded-none"
@click="shareRequest()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/widgets"
@@ -47,7 +53,7 @@
:request="request"
@customize-shared-request="customizeSharedRequest"
@delete-shared-request="deleteSharedRequest"
@open-new-tab="openInNewTab"
@open-shared-request="openRequestInNewTab"
/>
<HoppSmartIntersection
v-if="hasMoreSharedRequests"
@@ -70,7 +76,15 @@
:alt="`${t('empty.shared_requests')}`"
:text="t('empty.shared_requests')"
@drop.stop
/>
>
<template #body>
<HoppButtonPrimary
:label="t('add.new')"
:icon="IconPlus"
@click="shareRequest()"
/>
</template>
</HoppSmartPlaceholder>
</div>
</div>
<HoppSmartConfirmModal
@@ -95,6 +109,7 @@
<script lang="ts" setup>
import IconHelpCircle from "~icons/lucide/help-circle"
import IconPlus from "~icons/lucide/plus"
import { useI18n } from "~/composables/i18n"
import ShortcodeListAdapter from "~/helpers/shortcode/ShortcodeListAdapter"
import { useReadonlyStream } from "~/composables/stream"
@@ -270,6 +285,17 @@ onAuthEvent((ev) => {
}
})
const shareRequest = () => {
if (currentUser.value) {
const tab = restTab.currentActiveTab
invokeAction("share.request", {
request: tab.value.document.request,
})
} else {
invokeAction("modals.login.toggle")
}
}
const deleteSharedRequest = (codeID: string) => {
if (currentUser.value) {
sharedRequestID.value = codeID
@@ -434,13 +460,6 @@ const copySharedRequest = (payload: {
}
}
const openInNewTab = (request: HoppRESTRequest) => {
restTab.createNewTab({
isDirty: false,
request,
})
}
const resolveConfirmModal = (title: string | null) => {
if (title === `${t("confirm.remove_shared_request")}`) onDeleteSharedRequest()
else {
@@ -465,6 +484,13 @@ const getErrorMessage = (err: GQLError<string>) => {
}
}
const openRequestInNewTab = (request: HoppRESTRequest) => {
restTab.createNewTab({
isDirty: false,
request,
})
}
defineActionHandler("share.request", ({ request }) => {
requestToShare.value = request
displayShareRequestModal(true)

View File

@@ -3,7 +3,18 @@
<div
class="no-scrollbar absolute inset-0 flex flex-1 divide-x divide-dividerLight overflow-x-auto"
>
<input
v-if="isSecret"
id="secret"
v-model="secretText"
name="secret"
:placeholder="t('environment.secret_value')"
class="flex flex-1 bg-transparent px-4"
:class="styles"
type="password"
/>
<div
v-else
ref="editor"
:placeholder="placeholder"
class="flex flex-1"
@@ -11,7 +22,14 @@
@click="emit('click', $event)"
@keydown="handleKeystroke"
@focusin="showSuggestionPopover = true"
></div>
/>
<HoppButtonSecondary
v-if="secret"
v-tippy="{ theme: 'tooltip' }"
:title="isSecret ? t('action.show_secret') : t('action.hide_secret')"
:icon="isSecret ? IconEyeoff : IconEye"
@click="toggleSecret"
/>
<AppInspection
:inspection-results="inspectionResults"
class="sticky inset-y-0 right-0 rounded-r bg-primary"
@@ -61,18 +79,29 @@ import { history, historyKeymap } from "@codemirror/commands"
import { inputTheme } from "~/helpers/editor/themes/baseTheme"
import { HoppReactiveEnvPlugin } from "~/helpers/editor/extensions/HoppEnvironment"
import { useReadonlyStream } from "@composables/stream"
import { AggregateEnvironment, aggregateEnvs$ } from "~/newstore/environments"
import {
AggregateEnvironment,
aggregateEnvsWithSecrets$,
} from "~/newstore/environments"
import { platform } from "~/platform"
import { onClickOutside, useDebounceFn } from "@vueuse/core"
import { InspectorResult } from "~/services/inspection"
import { invokeAction } from "~/helpers/actions"
import { Environment } from "@hoppscotch/data"
import { useI18n } from "~/composables/i18n"
import IconEye from "~icons/lucide/eye"
import IconEyeoff from "~icons/lucide/eye-off"
const t = useI18n()
type Env = Environment["variables"][number] & { source: string }
const props = withDefaults(
defineProps<{
modelValue?: string
placeholder?: string
styles?: string
envs?: { key: string; value: string; source: string }[] | null
envs?: Env[] | null
focus?: boolean
selectTextOnMount?: boolean
environmentHighlights?: boolean
@@ -80,6 +109,7 @@ const props = withDefaults(
autoCompleteSource?: string[]
inspectionResults?: InspectorResult[] | undefined
contextMenuEnabled?: boolean
secret?: boolean
}>(),
{
modelValue: "",
@@ -93,6 +123,7 @@ const props = withDefaults(
inspectionResult: undefined,
inspectionResults: undefined,
contextMenuEnabled: true,
secret: false,
}
)
@@ -118,10 +149,27 @@ const showSuggestionPopover = ref(false)
const suggestionsMenu = ref<any | null>(null)
const autoCompleteWrapper = ref<any | null>(null)
const isSecret = ref(props.secret)
const secretText = ref(props.modelValue)
watch(
() => secretText.value,
(newVal) => {
if (isSecret.value) {
updateModelValue(newVal)
}
}
)
onClickOutside(autoCompleteWrapper, () => {
showSuggestionPopover.value = false
})
const toggleSecret = () => {
isSecret.value = !isSecret.value
}
//filter autocompleteSource with unique values
const uniqueAutoCompleteSource = computed(() => {
if (props.autoCompleteSource) {
@@ -169,8 +217,6 @@ watch(
)
const handleKeystroke = (ev: KeyboardEvent) => {
if (!props.autoCompleteSource) return
if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(ev.key)) {
ev.preventDefault()
}
@@ -307,19 +353,28 @@ watch(
let clipboardEv: ClipboardEvent | null = null
let pastedValue: string | null = null
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref<
const aggregateEnvs = useReadonlyStream(aggregateEnvsWithSecrets$, []) as Ref<
AggregateEnvironment[]
>
const envVars = computed(() =>
props.envs
? props.envs.map((x) => ({
key: x.key,
value: x.value,
sourceEnv: x.source,
}))
const envVars = computed(() => {
return props.envs
? props.envs.map((x) => {
if (x.secret) {
return {
key: x.key,
sourceEnv: "source" in x ? x.source : null,
value: "********",
}
}
return {
key: x.key,
value: x.value,
sourceEnv: "source" in x ? x.source : null,
}
})
: aggregateEnvs.value
)
})
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
@@ -363,17 +418,28 @@ const initView = (el: any) => {
el.addEventListener("keyup", debounceFn)
}
const extensions: Extension = getExtensions(props.readonly || isSecret.value)
view.value = new EditorView({
parent: el,
state: EditorState.create({
doc: props.modelValue,
extensions,
}),
})
}
const getExtensions = (readonly: boolean): Extension => {
const extensions: Extension = [
EditorView.contentAttributes.of({ "aria-label": props.placeholder }),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
EditorView.updateListener.of((update) => {
if (props.readonly) {
if (readonly) {
update.view.contentDOM.inputMode = "none"
}
}),
EditorState.changeFilter.of(() => !props.readonly),
EditorState.changeFilter.of(() => !readonly),
inputTheme,
props.readonly
readonly
? EditorView.theme({
".cm-content": {
caretColor: "var(--secondary-dark-color)",
@@ -384,6 +450,7 @@ const initView = (el: any) => {
})
: EditorView.theme({}),
tooltips({
parent: document.body,
position: "absolute",
}),
props.environmentHighlights ? envTooltipPlugin : [],
@@ -405,7 +472,8 @@ const initView = (el: any) => {
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
if (props.readonly) return
if (readonly) return
if (update.docChanged) {
const prevValue = clone(cachedValue.value)
@@ -454,14 +522,7 @@ const initView = (el: any) => {
history(),
keymap.of([...historyKeymap]),
]
view.value = new EditorView({
parent: el,
state: EditorState.create({
doc: props.modelValue,
extensions,
}),
})
return extensions
}
const triggerTextSelection = () => {
@@ -474,11 +535,11 @@ const triggerTextSelection = () => {
})
})
}
onMounted(() => {
if (editor.value) {
if (!view.value) initView(editor.value)
if (props.selectTextOnMount) triggerTextSelection()
if (props.focus) view.value?.focus()
platform.ui?.onCodemirrorInstanceMount?.(editor.value)
}
})

View File

@@ -4,6 +4,7 @@ import {
ViewPlugin,
ViewUpdate,
placeholder,
tooltips,
} from "@codemirror/view"
import {
Extension,
@@ -269,6 +270,7 @@ export function useCodemirror(
basicSetup,
baseTheme,
syntaxHighlighting(baseHighlightStyle, { fallback: true }),
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
@@ -318,6 +320,7 @@ export function useCodemirror(
}
}
),
EditorView.domEventHandlers({
scroll(event) {
if (event.target && options.contextMenuEnabled) {
@@ -359,6 +362,10 @@ export function useCodemirror(
run: indentLess,
},
]),
tooltips({
parent: document.body,
position: "absolute",
}),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
additionalExts.of(options.additionalExts ?? []),
]

View File

@@ -13,7 +13,36 @@ export function useSetting<K extends keyof SettingsDef>(
settingsStore.dispatch({
dispatcher: "applySetting",
payload: {
// @ts-expect-error TS is not able to understand the type semantics here
settingKey,
// @ts-expect-error TS is not able to understand the type semantics here
value,
},
})
}
)
}
export function useNestedSetting<
K extends keyof SettingsDef,
P extends keyof SettingsDef[K],
>(settingKey: K, property: P): Ref<SettingsDef[K][P]> {
return useStream(
settingsStore.subject$.pipe(
pluck(settingKey),
pluck(property),
distinctUntilChanged()
),
settingsStore.value[settingKey][property],
(value: SettingsDef[K][P]) => {
settingsStore.dispatch({
dispatcher: "applyNestedSetting",
payload: {
// @ts-expect-error TS is not able to understand the type semantics here
settingKey,
// @ts-expect-error TS is not able to understand the type semantics here
property,
// @ts-expect-error TS is not able to understand the type semantics here
value,
},
})
@@ -35,7 +64,9 @@ export function useSettingStatic<K extends keyof SettingsDef>(
settingsStore.dispatch({
dispatcher: "applySetting",
payload: {
// @ts-expect-error TS is not able to understand the type semantics here
settingKey,
// @ts-expect-error TS is not able to understand the type semantics here
value,
},
})

View File

@@ -30,6 +30,13 @@ import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { HoppTestData, HoppTestResult } from "./types/HoppTestResult"
import { getEffectiveRESTRequest } from "./utils/EffectiveURL"
import { isJSONContentType } from "./utils/contenttypes"
import {
SecretEnvironmentService,
SecretVariable,
} from "~/services/secret-environment.service"
import { getService } from "~/modules/dioc"
const secretEnvironmentService = getService(SecretEnvironmentService)
const getTestableBody = (
res: HoppRESTResponse & { type: "success" | "fail" }
@@ -58,15 +65,63 @@ const getTestableBody = (
return x
}
const combineEnvVariables = (env: {
const combineEnvVariables = (envs: {
global: Environment["variables"]
selected: Environment["variables"]
}) => [...env.selected, ...env.global]
}) => [...envs.selected, ...envs.global]
export const executedResponses$ = new Subject<
HoppRESTResponse & { type: "success" | "fail " }
>()
/**
* Used to update the environment schema with the secret variables
* and store the secret variable values in the secret environment service
* @param envs The environment variables to update
* @param type Whether the environment variables are global or selected
* @returns the updated environment variables
*/
const updateEnvironmentsWithSecret = (
envs: Environment["variables"] &
{
secret: true
value: string | undefined
key: string
}[],
type: "global" | "selected"
) => {
const currentEnvID =
type === "selected" ? getCurrentEnvironment().id : "Global"
const updatedSecretEnvironments: SecretVariable[] = []
const updatedEnv = pipe(
envs,
A.mapWithIndex((index, e) => {
if (e.secret) {
updatedSecretEnvironments.push({
key: e.key,
value: e.value ?? "",
varIndex: index,
})
// delete the value from the environment
// so that it doesn't get saved in the environment
delete e.value
return e
}
return e
})
)
if (currentEnvID) {
secretEnvironmentService.addSecretEnvironment(
currentEnvID,
updatedSecretEnvironments
)
}
return updatedEnv
}
export function runRESTRequest$(
tab: Ref<HoppTab<HoppRESTDocument>>
): [
@@ -154,15 +209,36 @@ export function runRESTRequest$(
)
if (E.isRight(runResult)) {
const updatedGlobalEnvVariables = updateEnvironmentsWithSecret(
cloneDeep(runResult.right.envs.global),
"global"
)
const updatedSelectedEnvVariables = updateEnvironmentsWithSecret(
cloneDeep(runResult.right.envs.selected),
"selected"
)
// set the response in the tab so that multiple tabs can run request simultaneously
tab.value.document.response = res
tab.value.document.testResults = translateToSandboxTestResults(
runResult.right
const updatedRunResult = {
...runResult.right,
envs: {
global: updatedGlobalEnvVariables,
selected: updatedSelectedEnvVariables,
},
}
tab.value.document.testResults =
translateToSandboxTestResults(updatedRunResult)
setGlobalEnvVariables(
updateEnvironmentsWithSecret(
runResult.right.envs.global,
"global"
)
)
setGlobalEnvVariables(runResult.right.envs.global)
if (
environmentsStore.value.selectedEnvironmentIndex.type === "MY_ENV"
) {
@@ -173,8 +249,10 @@ export function runRESTRequest$(
updateEnvironment(
environmentsStore.value.selectedEnvironmentIndex.index,
{
...env,
variables: runResult.right.envs.selected,
name: env.name,
v: 1,
id: env.id ?? "",
variables: updatedRunResult.envs.selected,
}
)
} else if (
@@ -186,7 +264,7 @@ export function runRESTRequest$(
})
pipe(
updateTeamEnvironment(
JSON.stringify(runResult.right.envs.selected),
JSON.stringify(updatedRunResult.envs.selected),
environmentsStore.value.selectedEnvironmentIndex.teamEnvID,
env.name
)
@@ -275,7 +353,6 @@ function translateToSandboxTestResults(
const globals = cloneDeep(getGlobalVariables())
const env = getCurrentEnvironment()
return {
description: "",
expectResults: testDesc.tests.expectResults,

View File

@@ -5,7 +5,7 @@
import { Ref, onBeforeUnmount, onMounted, reactive, watch } from "vue"
import { BehaviorSubject } from "rxjs"
import { HoppRESTDocument } from "./rest/document"
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { Environment, HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { RESTOptionTabs } from "~/components/http/RequestOptions.vue"
import { HoppGQLSaveContext } from "./graphql/document"
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
@@ -43,6 +43,7 @@ export type HoppAction =
| "modals.environment.new" // Add new environment
| "modals.environment.delete-selected" // Delete Selected Environment
| "modals.my.environment.edit" // Edit current personal environment
| "modals.global.environment.update" // Update global environment
| "modals.team.environment.edit" // Edit current team environment
| "modals.team.new" // Add new team
| "modals.team.edit" // Edit selected team
@@ -66,6 +67,13 @@ export type HoppAction =
| "user.login" // Login to Hoppscotch
| "user.logout" // Log out of Hoppscotch
| "editor.format" // Format editor content
| "modals.team.delete" // Delete team
| "workspace.switch" // Switch workspace
| "rest.request.open" // Open REST request
| "request.open-tab" // Open REST request
| "share.request" // Share REST request
| "tab.duplicate-tab" // Duplicate REST request
| "gql.request.open" // Open GraphQL request
/**
* Defines the arguments, if present for a given type that is required to be passed on
@@ -86,13 +94,19 @@ type HoppActionArgsMap = {
}
text: string | null
}
"modals.global.environment.update": {
variables?: Environment["variables"]
isSecret?: boolean
}
"modals.my.environment.edit": {
envName: string
variableName?: string
isSecret?: boolean
}
"modals.team.environment.edit": {
envName: string
variableName?: string
isSecret?: boolean
}
"modals.team.delete": {
teamId: string
@@ -112,6 +126,7 @@ type HoppActionArgsMap = {
requestType: "gql"
request: HoppGQLRequest
}
| undefined
"request.open-tab": {
tab: RESTOptionTabs | GQLOptionTabs
}
@@ -121,7 +136,6 @@ type HoppActionArgsMap = {
"tab.duplicate-tab": {
tabID?: string
}
"gql.request.open": {
request: HoppGQLRequest
saveContext?: HoppGQLSaveContext
@@ -132,11 +146,23 @@ type HoppActionArgsMap = {
}
}
type KeysWithValueUndefined<T> = {
[K in keyof T]: undefined extends T[K] ? K : never
}[keyof T]
/**
* HoppActions which require arguments for their invocation
*/
export type HoppActionWithArgs = keyof HoppActionArgsMap
/**
* HoppActions which optionally takes in arguments for their invocation
*/
export type HoppActionWithOptionalArgs =
| HoppActionWithNoArgs
| KeysWithValueUndefined<HoppActionArgsMap>
/**
* HoppActions which do not require arguments for their invocation
*/
@@ -145,27 +171,26 @@ export type HoppActionWithNoArgs = Exclude<HoppAction, HoppActionWithArgs>
/**
* Resolves the argument type for a given HoppAction
*/
type ArgOfHoppAction<A extends HoppAction | HoppActionWithArgs> =
A extends HoppActionWithArgs ? HoppActionArgsMap[A] : undefined
type ArgOfHoppAction<A extends HoppAction> = A extends HoppActionWithArgs
? HoppActionArgsMap[A]
: undefined
/**
* Resolves the action function for a given HoppAction, used by action handler function defs
*/
type ActionFunc<A extends HoppAction | HoppActionWithArgs> =
A extends HoppActionWithArgs ? (arg: ArgOfHoppAction<A>) => void : () => void
type ActionFunc<A extends HoppAction> = A extends HoppActionWithArgs
? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void
: (_?: undefined, trigger?: InvocationTriggers) => void
type BoundActionList = {
// eslint-disable-next-line no-unused-vars
[A in HoppAction | HoppActionWithArgs]?: Array<ActionFunc<A>>
[A in HoppAction]?: Array<ActionFunc<A>>
}
const boundActions: BoundActionList = reactive({})
export const activeActions$ = new BehaviorSubject<
(HoppAction | HoppActionWithArgs)[]
>([])
export const activeActions$ = new BehaviorSubject<HoppAction[]>([])
export function bindAction<A extends HoppAction | HoppActionWithArgs>(
export function bindAction<A extends HoppAction>(
action: A,
handler: ActionFunc<A>
) {
@@ -179,27 +204,33 @@ export function bindAction<A extends HoppAction | HoppActionWithArgs>(
activeActions$.next(Object.keys(boundActions) as HoppAction[])
}
export type InvocationTriggers = "keypress" | "mouseclick"
type InvokeActionFunc = {
(action: HoppActionWithNoArgs, args?: undefined): void
(
action: HoppActionWithOptionalArgs,
args?: undefined,
trigger?: InvocationTriggers
): void
<A extends HoppActionWithArgs>(action: A, args: HoppActionArgsMap[A]): void
}
/**
* Invokes a action, triggering action handlers if any registered.
* The second argument parameter is optional if your action has no args required
* Invokes an action, triggering action handlers if any registered.
* The second and third arguments are optional
* @param action The action to fire
* @param args The argument passed to the action handler. Optional if action has no args required
* @param trigger Optionally supply the trigger that invoked the action (keypress/mouseclick)
*/
export const invokeAction: InvokeActionFunc = <
A extends HoppAction | HoppActionWithArgs,
>(
export const invokeAction: InvokeActionFunc = <A extends HoppAction>(
action: A,
args: ArgOfHoppAction<A>
args?: ArgOfHoppAction<A>,
trigger?: InvocationTriggers
) => {
boundActions[action]?.forEach((handler) => handler(args! as any))
boundActions[action]?.forEach((handler) => handler(args! as any, trigger))
}
export function unbindAction<A extends HoppAction | HoppActionWithArgs>(
export function unbindAction<A extends HoppAction>(
action: A,
handler: ActionFunc<A>
) {
@@ -232,7 +263,7 @@ export function isActionBound(action: HoppAction): Ref<boolean> {
* @param handler The function to be called when the action is invoked
* @param isActive A ref that indicates whether the action is active
*/
export function defineActionHandler<A extends HoppAction | HoppActionWithArgs>(
export function defineActionHandler<A extends HoppAction>(
action: A,
handler: ActionFunc<A>,
isActive: Ref<boolean> | undefined = undefined

Some files were not shown because too many files have changed in this diff Show More