Compare commits

..

27 Commits

Author SHA1 Message Date
mirarifhasan
70665dae03 fix: feedback resolve 2024-02-21 21:16:09 +05:30
mirarifhasan
efc98588d9 chore: error message updated 2024-02-21 21:16:09 +05:30
mirarifhasan
619bdf85f3 test: improve test case of admin and user services 2024-02-21 21:16:09 +05:30
mirarifhasan
2509545dea chore: feedback updated 2024-02-21 21:16:09 +05:30
mirarifhasan
df2d5995fd fix: invited user deletion added with remove user 2024-02-21 21:16:09 +05:30
mirarifhasan
3c2b48a635 feat: feedback changes added 2024-02-21 21:16:09 +05:30
mirarifhasan
a0d40c8776 feat: added error obj for admin status users on deletion 2024-02-21 21:16:09 +05:30
mirarifhasan
3c7a2401ae Revert "build: pnpm lock file updated"
This reverts commit 852353f24d170726b400fb729ac4caef7acfeb19.
2024-02-21 21:16:09 +05:30
mirarifhasan
9543369ff3 test: test case added for removeUsersAsAdmin findNonAdminUsersByIds removeUsersAsAdmin 2024-02-21 21:16:09 +05:30
mirarifhasan
fd5abd59fb test: updateUser test case added 2024-02-21 21:16:09 +05:30
mirarifhasan
8f6ca169ce test: add test case for fetchAllUsersV2 2024-02-21 21:16:09 +05:30
mirarifhasan
2eab86476e fix: fetchUsers to fetchUsersV2 for backward compatibility 2024-02-21 21:16:09 +05:30
mirarifhasan
b53cbb093c feat: removeUsersByAdmin mutation added 2024-02-21 21:16:09 +05:30
mirarifhasan
2bde3f8b02 feat: removeUsersAsAdmin mutation added 2024-02-21 21:16:09 +05:30
mirarifhasan
da606f5a96 feat: bulk user to admin mutation added 2024-02-21 21:16:09 +05:30
mirarifhasan
2a667a74f0 feat: removed deprecated resolvefields 2024-02-21 21:16:09 +05:30
mirarifhasan
a4c889e38d feat: update user display name mutation added 2024-02-21 21:16:09 +05:30
mirarifhasan
9ceef43c74 feat: change fetchAllUsersV2 to fetchAllUsers 2024-02-21 21:16:09 +05:30
mirarifhasan
abaddd94a5 feat: fetchAllUsersV2 added with search-sort-offset pagination 2024-02-21 21:16:09 +05:30
mirarifhasan
88bca2057a feat: added pagination on fetchInvitedUsers 2024-02-21 21:16:09 +05:30
mirarifhasan
3ff6cc53bb feat: fetchInvitedUsers logic updated 2024-02-21 21:16:09 +05:30
mirarifhasan
1df2520bf0 test: revokeUserInvite test case added 2024-02-21 21:16:09 +05:30
mirarifhasan
5368c52aab feat: user invitation revoke mutation added 2024-02-21 21:16:09 +05:30
Balu Babu
9c6754c70f feat: inital setup info route (#3847) 2024-02-21 21:15:47 +05:30
Joel Jacob Stephen
b359650d96 refactor: updated teams nomenclature in admin dashboard to workspaces (#3770) 2024-02-08 22:17:42 +05:30
James George
3482743782 chore(cli): emit bundle in ESM format (#3777) 2024-02-05 22:55:05 +05:30
Joel Jacob Stephen
3d6adcc39d refactor: consolidated admin dashboard improvements (#3790)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-02-02 15:17:25 +05:30
171 changed files with 6592 additions and 14694 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.4",
"version": "2023.12.3",
"description": "",
"author": "",
"private": true,

View File

@@ -27,9 +27,7 @@ import {
} from './input-types.args';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { User } from 'src/user/user.model';
import { PaginationArgs } from 'src/types/input-types.args';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { UserDeletionResult } from 'src/user/user.model';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Admin)
@@ -49,203 +47,6 @@ 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, {
@@ -269,8 +70,26 @@ 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(
@@ -281,12 +100,33 @@ export class AdminResolver {
})
userUID: string,
): Promise<boolean> {
const invitedUser = await this.adminService.removeUserAccount(userUID);
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
return invitedUser.right;
const removedUser = await this.adminService.removeUserAccount(userUID);
if (E.isLeft(removedUser)) throwErr(removedUser.left);
return removedUser.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(
@@ -302,8 +142,51 @@ 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(
@@ -319,6 +202,23 @@ 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 } from '@prisma/client';
import { InvitedUsers, User as DbUser } from '@prisma/client';
import { UserService } from '../user/user.service';
import { TeamService } from '../team/team.service';
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
@@ -13,10 +13,15 @@ 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>();
@@ -58,20 +63,87 @@ 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 return an array of invited users', async () => {
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 results = await adminService.fetchInvitedUsers();
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);
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();
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(results).toEqual([]);
});
});
@@ -134,6 +206,58 @@ 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,13 +6,16 @@ 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';
@@ -26,6 +29,8 @@ 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 {
@@ -48,12 +53,30 @@ 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
@@ -110,14 +133,68 @@ 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() {
const invitedUsers = await this.prisma.invitedUsers.findMany();
async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) {
const userEmailObjs = await this.prisma.user.findMany({
select: {
email: true,
},
});
const users: InvitedUser[] = invitedUsers.map(
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(
(user) => <InvitedUser>{ ...user },
);
@@ -337,6 +414,7 @@ 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);
@@ -349,10 +427,73 @@ 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);
@@ -360,10 +501,22 @@ 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();
@@ -374,6 +527,26 @@ 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

@@ -0,0 +1,11 @@
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,7 +17,10 @@ import { AuthUser } from 'src/types/AuthUser';
import { throwErr } from 'src/utils';
import * as E from 'fp-ts/Either';
import { Admin } from './admin.model';
import { PaginationArgs } from 'src/types/input-types.args';
import {
OffsetPaginationArgs,
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';
@@ -76,6 +79,7 @@ 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[]> {
@@ -83,11 +87,33 @@ 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(): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers();
async invitedUsers(
@Args() args: OffsetPaginationArgs,
): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers(args);
return users;
}
@@ -306,7 +332,9 @@ 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,6 +10,14 @@ 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)
@@ -99,6 +107,13 @@ 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

@@ -0,0 +1,47 @@
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,10 +1,12 @@
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,7 +34,9 @@ export class InfraConfigService implements OnModuleInit {
await this.initializeInfraConfigTable();
}
getDefaultInfraConfigs(): { name: InfraConfigEnum; value: string }[] {
async getDefaultInfraConfigs(): Promise<
{ name: InfraConfigEnum; value: string }[]
> {
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
{
@@ -73,6 +75,10 @@ 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;
@@ -88,7 +94,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 = this.getDefaultInfraConfigs();
const infraConfigDefaultObjs = await this.getDefaultInfraConfigs();
// Check if all the 'names' are listed in the default values
if (enumValues.length !== infraConfigDefaultObjs.length) {
@@ -147,11 +153,13 @@ 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);
@@ -162,7 +170,7 @@ export class InfraConfigService implements OnModuleInit {
data: { value },
});
stopApp();
if (restartEnabled) stopApp();
return E.right(this.cast(infraConfig));
} catch (e) {
@@ -261,6 +269,7 @@ 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);
@@ -316,13 +325,24 @@ export class InfraConfigService implements OnModuleInit {
*/
async reset() {
try {
const infraConfigDefaultObjs = this.getDefaultInfraConfigs();
const infraConfigDefaultObjs = await 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: infraConfigDefaultObjs,
data: [
...updatedInfraConfigDefaultObjs,
{
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: 'true',
},
],
});
stopApp();

View File

@@ -12,6 +12,8 @@ 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 {
@@ -26,4 +28,6 @@ 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,3 +17,21 @@ 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,3 +56,22 @@ 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, USER_NOT_FOUND } from 'src/errors';
import { JSON_INVALID, USERS_NOT_FOUND, 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,6 +176,26 @@ 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);
@@ -414,6 +434,54 @@ 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);
@@ -435,6 +503,36 @@ 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);
@@ -556,4 +654,17 @@ 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,13 +8,14 @@ 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 { USER_NOT_FOUND } from 'src/errors';
import { USERS_NOT_FOUND, 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 {
@@ -88,6 +89,20 @@ 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
*
@@ -269,6 +284,30 @@ 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
@@ -286,6 +325,7 @@ 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({
@@ -296,6 +336,43 @@ 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
@@ -326,6 +403,23 @@ 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
@@ -444,4 +538,22 @@ 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

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

View File

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

View File

@@ -3,9 +3,10 @@
"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"
"hopp": "bin/hopp.js"
},
"publishConfig": {
"access": "public"
@@ -39,28 +40,27 @@
},
"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.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",
"@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",
"jest": "^29.7.0",
"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"
"prettier": "^3.2.4",
"ts-jest": "^29.1.2",
"tsup": "^8.0.1",
"typescript": "^5.3.3"
}
}

View File

@@ -3,247 +3,138 @@ import { ExecException } from "child_process";
import { HoppErrorCode } from "../../types/errors";
import { runCLI, getErrorCode, getTestJsonFilePath } from "../utils";
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);
describe("Test 'hopp test <file>' command:", () => {
test("No collection file path provided.", 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);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
});
test("Collection file is invalid JSON.", async () => {
const args = `test ${getTestJsonFilePath(
"malformed-collection.json"
)}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
});
test("Malformed collection file.", async () => {
const args = `test ${getTestJsonFilePath(
"malformed-collection2.json"
)}`;
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION");
});
test("Invalid arguement.", async () => {
const args = "invalid-arg";
const { stderr } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Collection file not JSON type.", async () => {
const args = `test ${getTestJsonFilePath("notjson.txt")}`;
const { stderr } = await runCLI(args);
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);
expect(error).not.toBeNull();
expect(error).toMatchObject(<ExecException>{
code: 1,
});
});
test("Errors with the code `INVALID_ARGUMENT` for an invalid command", async () => {
const args = "invalid-arg";
const { stderr } = await runCLI(args);
test("No errors occured (exit code 0).", async () => {
const args = `test ${getTestJsonFilePath("passes.json")}`;
const { error } = await runCLI(args);
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
expect(error).toBeNull();
});
test("Supports inheriting headers and authorization set at the root collection", async () => {
const args = `test ${getTestJsonFilePath("collection-level-headers-auth.json")}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
})
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>("FILE_NOT_FOUND");
});
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>("UNKNOWN_ERROR");
});
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>("MALFORMED_COLLECTION");
});
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");
});
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,
});
});
});
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("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:", () => {
describe("Supplied environment export file validations", () => {
const VALID_TEST_ARGS = `test ${getTestJsonFilePath("passes-coll.json", "collection")}`;
describe("Test 'hopp test <file> --env <file>' command:", () => {
const VALID_TEST_ARGS = `test ${getTestJsonFilePath(
"passes.json"
)}`;
test("Errors with the code `INVALID_ARGUMENT` if no file is supplied", async () => {
const args = `${VALID_TEST_ARGS} --env`;
const { stderr } = await runCLI(args);
test("No env file path provided.", async () => {
const args = `${VALID_TEST_ARGS} --env`;
const { stderr } = await runCLI(args);
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");
});
const out = getErrorCode(stderr);
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
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");
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");
const args = `test ${TESTS_PATH} --env ${ENV_PATH}`;
const { error } = await runCLI(args);
expect(error).toBeNull();
});
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");
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");
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-coll.json", "collection")}`;
describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
const VALID_TEST_ARGS = `test ${getTestJsonFilePath(
"passes.json"
)}`;
test("Errors with the code `INVALID_ARGUMENT` on not supplying a delay value", async () => {
test("No value passed to delay flag.", async () => {
const args = `${VALID_TEST_ARGS} --delay`;
const { stderr } = await runCLI(args);
@@ -251,7 +142,7 @@ describe("Test `hopp test <file> --delay <delay_in_ms>` command:", () => {
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Errors with the code `INVALID_ARGUMENT` on supplying an invalid delay value", async () => {
test("Invalid value passed to delay flag.", async () => {
const args = `${VALID_TEST_ARGS} --delay 'NaN'`;
const { stderr } = await runCLI(args);
@@ -259,17 +150,10 @@ describe("Test `hopp test <file> --delay <delay_in_ms>` command:", () => {
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
});
test("Successfully performs delayed request execution for a valid delay value", async () => {
test("Valid value passed to delay flag.", 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

@@ -1,21 +0,0 @@
{
"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

@@ -1,107 +0,0 @@
{
"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

@@ -1,143 +0,0 @@
{
"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

@@ -1,30 +0,0 @@
{
"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

@@ -1,32 +0,0 @@
[
{
"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

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

View File

@@ -1,27 +0,0 @@
{
"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

@@ -1,40 +0,0 @@
{
"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

@@ -1,46 +0,0 @@
{
"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

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

View File

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

View File

@@ -1,5 +1,5 @@
import chalk from "chalk";
import { program } from "commander";
import { Command } from "commander";
import * as E from "fp-ts/Either";
import { version } from "../package.json";
import { test } from "./commands/test";
@@ -20,6 +20,8 @@ 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,7 +21,6 @@ export interface RequestStack {
*/
export interface RequestConfig extends AxiosRequestConfig {
supported: boolean;
displayUrl?: string
}
export interface EffectiveHoppRESTRequest extends HoppRESTRequest {
@@ -31,7 +30,6 @@ 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,42 +1,34 @@
import { Environment } from "@hoppscotch/data";
import { entityReference } from "verzod";
import { z } from "zod";
import { error } from "../../types/errors";
import {
HoppEnvKeyPairObject,
HoppEnvs,
HoppEnvPair,
HoppEnvs
HoppEnvKeyPairObject,
HoppEnvExportObject,
HoppBulkEnvExportObject,
} 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<Environment["variables"][number] | HoppEnvPair> = [];
// The legacy key-value pair format that is still supported
const envPairs: Array<HoppEnvPair> = [];
const HoppEnvKeyPairResult = HoppEnvKeyPairObject.safeParse(contents);
const HoppEnvExportObjectResult = HoppEnvExportObject.safeParse(contents);
const HoppBulkEnvExportObjectResult =
HoppBulkEnvExportObject.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
// 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.type === "err") {
// 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)) {
throw error({ code: "MALFORMED_ENV_FILE", path, data: error });
}
@@ -44,8 +36,8 @@ export async function parseEnvsData(path: string) {
for (const [key, value] of Object.entries(HoppEnvKeyPairResult.data)) {
envPairs.push({ key, value });
}
} else if (HoppEnvExportObjectResult.type === "ok") {
envPairs.push(...HoppEnvExportObjectResult.value.variables);
} else if (HoppEnvExportObjectResult.success) {
envPairs.push(...HoppEnvExportObjectResult.data.variables);
}
return <HoppEnvs>{ global: [], selected: envPairs };

View File

@@ -1,18 +1,31 @@
import { Environment, HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { z } from "zod";
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { TestReport } from "../interfaces/response";
import { HoppCLIError } from "./errors";
import { z } from "zod";
export type FormDataEntry = {
key: string;
value: string | Blob;
};
export type HoppEnvPair = Environment["variables"][number];
export type HoppEnvPair = { key: string; value: string };
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 { bold } from "chalk";
import chalk from "chalk";
import { log } from "console";
import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/function";
import round from "lodash/round";
import { round } from "lodash-es";
import { CollectionRunnerParam } from "../types/collections";
import {
@@ -68,7 +68,7 @@ export const collectionsRunner = async (
};
// Request processing initiated message.
log(WARN(`\nRunning: ${bold(requestPath)}`));
log(WARN(`\nRunning: ${chalk.bold(requestPath)}`));
// Processing current request.
const result = await processRequest(processRequestParams)();

View File

@@ -1,4 +1,4 @@
import { bold } from "chalk";
import chalk 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${bold(path)} reported errors:`);
const REPORTED_ERRORS_TITLE = FAIL(`\n${chalk.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${bold(path)} failed tests:`);
const FAILED_TESTS_PATH = FAIL(`\n${chalk.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.displayUrl || requestConfig.url;
const ENDPOINT = requestConfig.url;
process.stdout.write(`${METHOD} ${ENDPOINT}`);
},

View File

@@ -1,4 +1,4 @@
import { clone } from "lodash";
import { clone } from "lodash-es";
/**
* 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/round";
import { round } from "lodash-es";
import { DEFAULT_DURATION_PRECISION } from "./constants";
/**

View File

@@ -36,10 +36,7 @@ import { toFormData } from "./mutators";
export const preRequestScriptRunner = (
request: HoppRESTRequest,
envs: HoppEnvs
): TE.TaskEither<
HoppCLIError,
{ effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs }
> =>
): TE.TaskEither<HoppCLIError, EffectiveHoppRESTRequest> =>
pipe(
TE.of(request),
TE.chain(({ preRequestScript }) =>
@@ -71,10 +68,7 @@ export const preRequestScriptRunner = (
export function getEffectiveRESTRequest(
request: HoppRESTRequest,
environment: Environment
): E.Either<
HoppCLIError,
{ effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs }
> {
): E.Either<HoppCLIError, EffectiveHoppRESTRequest> {
const envVariables = environment.variables;
// Parsing final headers with applied ENVs.
@@ -168,30 +162,12 @@ 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({
effectiveRequest: {
...request,
effectiveFinalURL,
effectiveFinalDisplayURL,
effectiveFinalHeaders,
effectiveFinalParams,
effectiveFinalBody,
},
updatedEnvs: { global: [], selected: envVariables },
...request,
effectiveFinalURL,
effectiveFinalHeaders,
effectiveFinalParams,
effectiveFinalBody,
});
}

View File

@@ -1,4 +1,4 @@
import { Environment, HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { 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,38 +29,6 @@ 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.
@@ -70,7 +38,6 @@ const processEnvs = (envs: HoppEnvs) => {
export const createRequest = (req: EffectiveHoppRESTRequest): RequestConfig => {
const config: RequestConfig = {
supported: true,
displayUrl: req.effectiveFinalDisplayURL
};
const { finalBody, finalEndpoint, finalHeaders, finalParams } = getRequest;
const reqParams = finalParams(req);
@@ -254,13 +221,9 @@ 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, processedEnvs)();
const preRequestRes = await preRequestScriptRunner(request, envs)();
if (E.isLeft(preRequestRes)) {
printPreRequestRunner.fail();
@@ -268,8 +231,8 @@ export const processRequest =
report.errors.push(preRequestRes.left);
report.result = report.result && false;
} else {
// Updating effective-request and consuming updated envs after pre-request script execution
({ effectiveRequest, updatedEnvs } = preRequestRes.right);
// Updating effective-request
effectiveRequest = preRequestRes.right;
}
// Creating request-config for request-runner.
@@ -307,7 +270,7 @@ export const processRequest =
const testScriptParams = getTestScriptParams(
_requestRunnerRes,
request,
updatedEnvs
envs
);
// Executing test-runner.

View File

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

View File

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

View File

@@ -24,7 +24,6 @@
"go_back": "Go back",
"go_forward": "Go forward",
"group_by": "Group by",
"hide_secret": "Hide secret",
"label": "Label",
"learn_more": "Learn more",
"less": "Less",
@@ -44,7 +43,6 @@
"search": "Search",
"send": "Send",
"share": "Share",
"show_secret": "Show secret",
"start": "Start",
"starting": "Starting",
"stop": "Stop",
@@ -240,7 +238,6 @@
"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",
@@ -272,8 +269,6 @@
"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",
@@ -282,7 +277,6 @@
"updated": "Environment updated",
"value": "Value",
"variable": "Variable",
"variables":"Variables",
"variable_list": "Variable List"
},
"error": {
@@ -419,8 +413,6 @@
"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": {
@@ -897,7 +889,6 @@
"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.4",
"version": "2023.12.3",
"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', undefined, 'mouseclick')"
@click="invokeAction('modals.search.toggle')"
>
<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="closeSpotlightModal"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col border-b border-divider transition">
@@ -86,36 +86,35 @@
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { ref, computed, watch } from "vue"
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 { useI18n } from "@composables/i18n"
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()
@@ -291,17 +290,4 @@ 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': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'cookie')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
@@ -102,8 +102,6 @@ import {
useCopyResponse,
useDownloadResponse,
} from "~/composables/lens-actions"
import { useNestedSetting } from "~/composables/settings"
import { toggleNestedSetting } from "~/newstore/settings"
// TODO: Build Managed Mode!
@@ -124,7 +122,7 @@ const toast = useToast()
const cookieEditor = ref<HTMLElement>()
const rawCookieString = ref("")
const WRAP_LINES = useNestedSetting("WRAP_LINES", "cookie")
const linewrapEnabled = ref(true)
useCodemirror(
cookieEditor,
@@ -133,7 +131,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/plain",
placeholder: `${t("cookies.modal.enter_cookie_string")}`,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,

View File

@@ -21,7 +21,7 @@
<label for="value" class="min-w-[2.5rem] font-semibold">{{
t("environment.value")
}}</label>
<SmartEnvInput
<input
v-model="editingValue"
type="text"
class="input"
@@ -154,14 +154,12 @@ 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, NonSecretEnvironment } from "@hoppscotch/data"
import { Environment } 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?: NonSecretEnvironment
globalEnv?: Environment
) => {
// if there's a global env, add them to the store
if (globalEnv) {
globalEnv.variables.forEach(({ key, value, secret }) =>
addGlobalEnvVariable({ key, value, secret })
)
globalEnv.variables.forEach(({ key, value }) => {
addGlobalEnvVariable({ key, value })
})
}
if (props.environmentType === "MY_ENV") {

View File

@@ -210,10 +210,7 @@
{{ variable.key }}
</span>
<span class="min-w-[9rem] w-full truncate text-secondaryLight">
<template v-if="variable.secret"> ******** </template>
<template v-else>
{{ variable.value }}
</template>
{{ variable.value }}
</span>
</div>
<div v-if="globalEnvs.length === 0" class="text-secondaryLight">
@@ -268,10 +265,7 @@
{{ variable.key }}
</span>
<span class="min-w-[9rem] w-full truncate text-secondaryLight">
<template v-if="variable.secret"> ******** </template>
<template v-else>
{{ variable.value }}
</template>
{{ variable.value }}
</span>
</div>
<div
@@ -485,20 +479,15 @@ 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 =
@@ -593,7 +582,9 @@ const environmentVariables = computed(() => {
})
const editGlobalEnv = () => {
invokeAction("modals.global.environment.update", {})
invokeAction("modals.my.environment.edit", {
envName: "Global",
})
}
const editEnv = () => {

View File

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

View File

@@ -16,103 +16,76 @@
@submit="saveEnvironment"
/>
<div class="my-4 flex flex-col border border-divider rounded">
<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 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>
<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>
</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
v-for="({ id, env }, index) in vars"
: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"
: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>
</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"
/>
</template>
<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>
</HoppSmartPlaceholder>
</div>
</div>
</template>
@@ -139,8 +112,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 IconHelpCircle from "~icons/lucide/help-circle"
import { ComputedRef, computed, ref, watch } from "vue"
import { clone } from "lodash-es"
import { 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"
@@ -163,16 +136,12 @@ 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: {
value: string
key: string
secret: boolean
value: string
}
}
@@ -186,7 +155,6 @@ const props = withDefaults(
action: "edit" | "new"
editingEnvironmentIndex?: number | "Global" | null
editingVariableName?: string | null
isSecretOptionSelected?: boolean
envVars?: () => Environment["variables"]
}>(),
{
@@ -194,7 +162,6 @@ const props = withDefaults(
action: "edit",
editingEnvironmentIndex: null,
editingVariableName: null,
isSecretOptionSelected: false,
envVars: () => [],
}
)
@@ -205,55 +172,11 @@ 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: "", secret: false } },
{ id: idTicker.value++, env: { key: "", value: "" } },
])
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
@@ -261,23 +184,14 @@ 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: vars,
variables: getGlobalVariables(),
} as Environment
} else if (props.action === "new") {
return {
id: uniqueId(),
name: "",
variables: props.envVars(),
}
@@ -300,7 +214,6 @@ const evnExpandError = computed(() => {
return pipe(
variables,
A.filter(({ secret }) => !secret),
A.exists(({ value }) => E.isLeft(parseTemplateStringE(value, variables)))
)
})
@@ -326,29 +239,11 @@ 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.mapWithIndex((index, e) => ({
A.map((e) => ({
id: idTicker.value++,
env: {
key: e.key,
value: e.secret
? secretEnvironmentService.getSecretEnvironmentVariable(
props.editingEnvironmentIndex === "Global"
? "Global"
: workingEnv.value?.id,
index
)?.value ?? ""
: e.value,
secret: e.secret,
},
env: clone(e),
}))
)
}
@@ -356,10 +251,7 @@ watch(
)
const clearContent = () => {
vars.value = vars.value.filter((e) =>
selectedEnvOption.value === "secret" ? !e.env.secret : e.env.secret
)
vars.value = []
clearIcon.value = IconDone
toast.success(`${t("state.cleared")}`)
}
@@ -370,16 +262,12 @@ const addEnvironmentVariable = () => {
env: {
key: "",
value: "",
secret: selectedEnvOption.value === "secret",
},
})
}
const removeEnvironmentVariable = (id: number) => {
const index = vars.value.findIndex((e) => e.id === id)
if (index !== -1) {
vars.value.splice(index, 1)
}
const removeEnvironmentVariable = (index: number) => {
vars.value.splice(index, 1)
}
const saveEnvironment = () => {
@@ -388,7 +276,7 @@ const saveEnvironment = () => {
return
}
const filteredVariables = pipe(
const filterdVariables = pipe(
vars.value,
A.filterMap(
flow(
@@ -398,43 +286,14 @@ 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,
variables: filterdVariables,
}
if (props.action === "new") {
// Creating a new environment
createEnvironment(
editingName.value,
environmentUpdated.variables,
editingID.value
)
createEnvironment(editingName.value, environmentUpdated.variables)
setSelectedEnvironmentIndex({
type: "MY_ENV",
index: envList.value.length - 1,
@@ -473,7 +332,6 @@ const saveEnvironment = () => {
const hideModal = () => {
editingName.value = null
selectedEnvOption.value = "variables"
emit("hide-modal")
}
</script>

View File

@@ -135,8 +135,6 @@ 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()
@@ -152,8 +150,6 @@ const emit = defineEmits<{
const confirmRemove = ref(false)
const secretEnvironmentService = useService(SecretEnvironmentService)
const exportEnvironmentAsJSON = () => {
const { environment, environmentIndex } = props
exportAsJSON(environment, environmentIndex)
@@ -172,7 +168,6 @@ 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,7 +67,6 @@
:action="action"
:editing-environment-index="editingEnvironmentIndex"
:editing-variable-name="editingVariableName"
:is-secret-option-selected="secretOptionSelected"
@hide-modal="displayModalEdit(false)"
/>
<EnvironmentsImportExport
@@ -100,7 +99,6 @@ 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"
@@ -122,23 +120,18 @@ const editEnvironment = (environmentIndex: number) => {
}
const resetSelectedData = () => {
editingEnvironmentIndex.value = null
editingVariableName.value = ""
secretOptionSelected.value = false
}
defineActionHandler(
"modals.my.environment.edit",
({ envName, variableName, isSecret }) => {
({ envName, variableName }) => {
if (variableName) editingVariableName.value = variableName
const envIndex: number = environments.value.findIndex(
(environment: Environment) => {
return environment.name === envName
}
)
if (envName !== "Global") {
editEnvironment(envIndex)
secretOptionSelected.value = isSecret ?? false
}
if (envName !== "Global") editEnvironment(envIndex)
}
)
</script>

View File

@@ -16,112 +16,90 @@
@submit="saveEnvironment"
/>
<div class="my-4 flex flex-col border border-divider rounded">
<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 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>
<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>
</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
v-for="({ id, env }, index) in vars"
: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"
: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>
</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"
/>
</template>
<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>
</HoppSmartPlaceholder>
</div>
</div>
</template>
<template #footer>
<template v-if="!isViewer" #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
:label="`${t('action.save')}`"
@@ -141,7 +119,7 @@
</template>
<script setup lang="ts">
import { ComputedRef, computed, ref, watch } from "vue"
import { 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"
@@ -163,17 +141,13 @@ 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
}
}
@@ -189,7 +163,6 @@ const props = withDefaults(
editingTeamId: string | undefined
editingVariableName?: string | null
isViewer?: boolean
isSecretOptionSelected?: boolean
envVars?: () => Environment["variables"]
}>(),
{
@@ -199,7 +172,6 @@ const props = withDefaults(
editingTeamId: "",
editingVariableName: null,
isViewer: false,
isSecretOptionSelected: false,
envVars: () => [],
}
)
@@ -210,59 +182,11 @@ 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: "", secret: false } },
{ id: idTicker.value++, env: { key: "", value: "" } },
])
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
@@ -291,34 +215,22 @@ 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) => ({
A.map((e: { key: string; value: string }) => ({
id: idTicker.value++,
env: clone(e),
}))
)
} else if (props.editingEnvironment !== null) {
editingID.value = props.editingEnvironment.id
editingName.value = props.editingEnvironment.environment.name ?? null
vars.value = pipe(
props.editingEnvironment.environment.variables ?? [],
A.mapWithIndex((index, e) => ({
A.map((e: { key: string; value: string }) => ({
id: idTicker.value++,
env: {
key: e.key,
value: e.secret
? secretEnvironmentService.getSecretEnvironmentVariable(
editingID.value ?? "",
index
)?.value ?? ""
: e.value,
secret: e.secret,
},
env: clone(e),
}))
)
}
@@ -338,16 +250,12 @@ const addEnvironmentVariable = () => {
env: {
key: "",
value: "",
secret: selectedEnvOption.value === "secret",
},
})
}
const removeEnvironmentVariable = (id: number) => {
const index = vars.value.findIndex((e) => e.id === id)
if (index !== -1) {
vars.value.splice(index, 1)
}
const removeEnvironmentVariable = (index: number) => {
vars.value.splice(index, 1)
}
const isLoading = ref(false)
@@ -370,102 +278,52 @@ 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",
})
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
}
)
)()
}
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")}`)
}
)
)()
} else {
if (!props.editingEnvironment) {
console.error("No Environment Found")
return
}
if (editingID.value) {
secretEnvironmentService.addSecretEnvironment(
editingID.value,
secretVariables
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 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
@@ -473,7 +331,6 @@ const saveEnvironment = async () => {
const hideModal = () => {
editingName.value = null
selectedEnvOption.value = "variables"
emit("hide-modal")
}

View File

@@ -19,6 +19,7 @@
</span>
<span>
<tippy
v-if="!isViewer"
ref="options"
interactive
trigger="click"
@@ -56,7 +57,6 @@
/>
<HoppSmartItem
v-if="!isViewer"
ref="duplicate"
:icon="IconCopy"
:label="`${t('action.duplicate')}`"
@@ -69,7 +69,6 @@
"
/>
<HoppSmartItem
v-if="!isViewer"
ref="exportAsJsonEl"
:icon="IconEdit"
:label="`${t('export.as_json')}`"
@@ -82,7 +81,6 @@
"
/>
<HoppSmartItem
v-if="!isViewer"
ref="deleteAction"
:icon="IconTrash2"
:label="`${t('action.delete')}`"
@@ -126,8 +124,6 @@ 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()
@@ -141,8 +137,6 @@ const emit = defineEmits<{
(e: "edit-environment"): void
}>()
const secretEnvironmentService = useService(SecretEnvironmentService)
const confirmRemove = ref(false)
const exportEnvironmentAsJSON = () =>
@@ -167,7 +161,6 @@ const removeEnvironment = () => {
},
() => {
toast.success(`${t("team_environment.deleted")}`)
secretEnvironmentService.deleteSecretEnvironment(props.environment.id)
}
)
)()

View File

@@ -105,7 +105,6 @@
: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)"
/>
@@ -149,7 +148,6 @@ 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")
@@ -173,8 +171,6 @@ const editEnvironment = (environment: TeamEnvironment | null) => {
}
const resetSelectedData = () => {
editingEnvironment.value = null
editingVariableName.value = ""
secretOptionSelected.value = false
}
const getErrorMessage = (err: GQLError<string>) => {
@@ -191,15 +187,12 @@ const getErrorMessage = (err: GQLError<string>) => {
defineActionHandler(
"modals.team.environment.edit",
({ envName, variableName, isSecret }) => {
({ envName, variableName }) => {
if (variableName) editingVariableName.value = variableName
const teamEnvToEdit = props.teamEnvironments.find(
(environment) => environment.environment.name === envName
)
if (teamEnvToEdit) {
editEnvironment(teamEnvToEdit)
secretOptionSelected.value = isSecret ?? false
}
if (teamEnvToEdit) editEnvironment(teamEnvToEdit)
}
)
</script>

View File

@@ -27,9 +27,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'graphqlHeaders')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -315,8 +315,6 @@ 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"
@@ -340,7 +338,7 @@ const request = useVModel(props, "modelValue", emit)
const idTicker = ref(0)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlHeaders")
const linewrapEnabled = ref(false)
const bulkMode = ref(false)
const bulkHeaders = ref("")
@@ -355,7 +353,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,

View File

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

View File

@@ -16,11 +16,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="
toggleNestedSetting('WRAP_LINES', 'graphqlResponseBody')
"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
@@ -101,8 +99,6 @@ 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,
@@ -137,8 +133,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,
@@ -147,7 +143,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "application/ld+json",
readOnly: true,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,

View File

@@ -127,9 +127,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'graphqlSchema')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -202,8 +202,6 @@ 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"
@@ -351,7 +349,7 @@ const handleJumpToType = async (type: GraphQLType) => {
}
const schemaEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "graphqlSchema")
const linewrapEnabled = ref(true)
useCodemirror(
schemaEditor,
@@ -360,7 +358,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "graphql",
readOnly: true,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,

View File

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

View File

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

View File

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

View File

@@ -29,9 +29,9 @@
v-if="bulkMode"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpHeaders')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -332,8 +332,6 @@ 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()
@@ -348,7 +346,7 @@ const idTicker = ref(0)
const bulkMode = ref(false)
const bulkHeaders = ref("")
const bulkEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpHeaders")
const linewrapEnabled = ref(true)
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
@@ -373,7 +371,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
linter,
completer: null,
@@ -555,7 +553,7 @@ const clearContent = () => {
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, getAggregateEnvs())
const computedHeaders = computed(() =>
getComputedHeaders(request.value, aggregateEnvs.value, false).map(
getComputedHeaders(request.value, aggregateEnvs.value).map(
(header, index) => ({
id: `header-${index}`,
...header,
@@ -608,8 +606,7 @@ const inheritedProperties = computed(() => {
const computedAuthHeader = getComputedAuthHeaders(
aggregateEnvs.value,
request.value,
props.inheritedProperties.auth.inheritedAuth,
false
props.inheritedProperties.auth.inheritedAuth
)[0]
if (

View File

@@ -22,9 +22,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'importCurl')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
@@ -96,8 +96,6 @@ 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()
@@ -108,7 +106,7 @@ const tabs = useService(RESTTabService)
const curl = ref("")
const curlEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "importCurl")
const linewrapEnabled = ref(true)
const props = defineProps<{ show: boolean; text: string }>()
@@ -119,7 +117,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "application/x-sh",
placeholder: `${t("request.enter_curl")}`,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,

View File

@@ -3,25 +3,14 @@
<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"
:styles="hasOIDCURL ? 'pointer-events-none opacity-70' : ''"
></SmartEnvInput>
<SmartEnvInput v-model="authURL" placeholder="Authorization URL" />
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="accessTokenURL"
placeholder="Access Token URL"
:styles="hasOIDCURL ? 'pointer-events-none opacity-70' : ''"
/>
<SmartEnvInput v-model="accessTokenURL" placeholder="Access Token URL" />
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="clientID" placeholder="Client ID" />
@@ -55,7 +44,6 @@ 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()
@@ -78,16 +66,10 @@ 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")
@@ -106,13 +88,15 @@ 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.variables, ...envs.global]
const envVars = [...envs.selected, ...envs.global]
try {
const tokenReqParams = {

View File

@@ -24,9 +24,9 @@
v-if="bulkMode"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpParams')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -205,8 +205,6 @@ 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()
@@ -219,7 +217,7 @@ const idTicker = ref(0)
const bulkMode = ref(false)
const bulkParams = ref("")
const bulkEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpParams")
const linewrapEnabled = ref(true)
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
@@ -230,7 +228,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
linter,
completer: null,

View File

@@ -23,9 +23,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpPreRequest')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
</div>
</div>
@@ -72,8 +72,6 @@ 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()
@@ -87,7 +85,7 @@ const emit = defineEmits<{
const preRequestScript = useVModel(props, "modelValue", emit)
const preRequestEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpPreRequest")
const linewrapEnabled = ref(true)
useCodemirror(
preRequestEditor,
@@ -95,7 +93,7 @@ useCodemirror(
reactive({
extendedEditorConfig: {
mode: "application/javascript",
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
placeholder: `${t("preRequest.javascript_code")}`,
},
linter,

View File

@@ -23,9 +23,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpRequestBody')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-if="
@@ -85,8 +85,6 @@ 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,
@@ -124,7 +122,7 @@ const langLinter = computed(() =>
isJSONContentType(body.value.contentType) ? jsonLinter : null
)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpRequestBody")
const linewrapEnabled = ref(true)
const rawBodyParameters = ref<any | null>(null)
const codemirrorValue: Ref<string | undefined> =
@@ -150,7 +148,7 @@ useCodemirror(
codemirrorValue,
reactive({
extendedEditorConfig: {
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
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 { HoppRESTRequest } from "@hoppscotch/data"
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { InterceptorService } from "~/services/interceptor.service"
@@ -263,7 +263,6 @@ 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)
@@ -327,8 +326,6 @@ 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")}`)
@@ -344,7 +341,6 @@ const newSendRequest = async () => {
type: "HOPP_REQUEST_RUN",
platform: "rest",
strategy: interceptorService.currentInterceptorID.value!,
workspaceType: workspaceService.currentWorkspace.value.type,
})
const [cancel, streamPromise] = runRESTRequest$(tab)
@@ -399,14 +395,17 @@ const newSendRequest = async () => {
}
const ensureMethodInEndpoint = () => {
const endpoint = newEndpoint.value.trim()
tab.value.document.request.endpoint = endpoint
if (!/^http[s]?:\/\//.test(endpoint) && !endpoint.startsWith("<<")) {
const domain = endpoint.split(/[/:#?]+/)[0]
if (
!/^http[s]?:\/\//.test(newEndpoint.value) &&
!newEndpoint.value.startsWith("<<")
) {
const domain = newEndpoint.value.split(/[/:#?]+/)[0]
if (domain === "localhost" || /([0-9]+\.)*[0-9]/.test(domain)) {
tab.value.document.request.endpoint = "http://" + endpoint
tab.value.document.request.endpoint =
"http://" + tab.value.document.request.endpoint
} else {
tab.value.document.request.endpoint = "https://" + endpoint
tab.value.document.request.endpoint =
"https://" + tab.value.document.request.endpoint
}
}
}
@@ -578,12 +577,25 @@ defineActionHandler("request.share-request", shareRequest)
defineActionHandler("request.method.next", cycleDownMethod)
defineActionHandler("request.method.prev", cycleUpMethod)
defineActionHandler("request.save", saveRequest)
defineActionHandler("request.save-as", (req) => {
showSaveRequestModal.value = true
if (req?.requestType === "rest") {
request.value = req.request
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.method.get", () => updateMethod("GET"))
defineActionHandler("request.method.post", () => updateMethod("POST"))
defineActionHandler("request.method.put", () => updateMethod("PUT"))

View File

@@ -29,7 +29,6 @@
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()"
@@ -59,18 +58,6 @@
}
"
/>
<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"
@@ -112,7 +99,6 @@ 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"
@@ -128,7 +114,6 @@ 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)
@@ -138,5 +123,4 @@ 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,6 +211,7 @@ import { useI18n } from "@composables/i18n"
import {
globalEnv$,
selectedEnvironmentIndex$,
setGlobalEnvVariables,
setSelectedEnvironmentIndex,
} from "~/newstore/environments"
import { HoppTestResult } from "~/helpers/types/HoppTestResult"
@@ -224,7 +225,6 @@ 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,10 +304,9 @@ const globalHasAdditions = computed(() => {
const addEnvToGlobal = () => {
if (!testResults.value?.envDiff.selected.additions) return
invokeAction("modals.global.environment.update", {
variables: testResults.value.envDiff.selected.additions,
isSecret: false,
})
setGlobalEnvVariables([
...globalEnvVars.value,
...testResults.value.envDiff.selected.additions,
])
}
</script>

View File

@@ -23,9 +23,9 @@
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpTest')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
</div>
</div>
@@ -72,8 +72,6 @@ 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()
@@ -83,7 +81,7 @@ const props = defineProps<{
const emit = defineEmits(["update:modelValue"])
const testScript = useVModel(props, "modelValue", emit)
const testScriptEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpTest")
const linewrapEnabled = ref(true)
useCodemirror(
testScriptEditor,
@@ -91,7 +89,7 @@ useCodemirror(
reactive({
extendedEditorConfig: {
mode: "application/javascript",
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
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': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpUrlEncoded')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@@ -196,8 +196,6 @@ 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"
@@ -222,7 +220,7 @@ const idTicker = ref(0)
const bulkMode = ref(false)
const bulkUrlEncodedParams = ref("")
const bulkEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpUrlEncoded")
const linewrapEnabled = ref(true)
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
@@ -233,7 +231,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
linter,
completer: null,

View File

@@ -11,9 +11,9 @@
v-if="response.body"
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-if="response.body"
@@ -76,8 +76,6 @@ 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()
@@ -86,7 +84,7 @@ const props = defineProps<{
}>()
const htmlResponse = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpResponseBody")
const linewrapEnabled = ref(true)
const { responseBodyText } = useResponseBody(props.response)
const { downloadIcon, downloadResponse } = useDownloadResponse(
@@ -106,7 +104,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "htmlmixed",
readOnly: true,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
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': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-if="response.body"
@@ -260,8 +260,6 @@ 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()
@@ -373,8 +371,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,
@@ -383,7 +381,7 @@ const { cursor } = useCodemirror(
extendedEditorConfig: {
mode: "application/ld+json",
readOnly: true,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
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': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-if="response.body"
@@ -58,8 +58,6 @@ 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()
@@ -99,7 +97,7 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
const rawResponse = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpResponseBody")
const linewrapEnabled = ref(true)
useCodemirror(
rawResponse,
@@ -108,7 +106,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "text/plain",
readOnly: true,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
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': WRAP_LINES }"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="toggleNestedSetting('WRAP_LINES', 'httpResponseBody')"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-if="response.body"
@@ -58,8 +58,6 @@ 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()
@@ -93,7 +91,7 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
const { copyIcon, copyResponse } = useCopyResponse(responseBodyText)
const xmlResponse = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpResponseBody")
const linewrapEnabled = ref(true)
useCodemirror(
xmlResponse,
@@ -102,7 +100,7 @@ useCodemirror(
extendedEditorConfig: {
mode: "application/xml",
readOnly: true,
lineWrapping: WRAP_LINES,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,

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="customizeSharedRequest()"
@click="openInNewTab"
>
<span
class="flex items-center justify-center w-16 px-2 truncate pointer-events-none"
@@ -128,6 +128,7 @@ const emit = defineEmits<{
embedProperties?: string | null
): void
(e: "delete-shared-request", codeID: string): void
(e: "open-new-tab", request: HoppRESTRequest): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
@@ -144,6 +145,10 @@ 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,14 +9,8 @@
/>
</div>
<div
class="sticky top-sidebarPrimaryStickyFold z-10 flex flex-1 flex-shrink-0 justify-between overflow-x-auto border-b border-dividerLight bg-primary"
class="sticky top-sidebarPrimaryStickyFold z-10 flex flex-1 flex-shrink-0 justify-end 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"
@@ -53,6 +47,7 @@
:request="request"
@customize-shared-request="customizeSharedRequest"
@delete-shared-request="deleteSharedRequest"
@open-new-tab="openInNewTab"
/>
<HoppSmartIntersection
v-if="hasMoreSharedRequests"
@@ -75,15 +70,7 @@
: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
@@ -108,7 +95,6 @@
<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"
@@ -284,17 +270,6 @@ 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
@@ -459,6 +434,13 @@ 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 {

View File

@@ -3,18 +3,7 @@
<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"
@@ -22,14 +11,7 @@
@click="emit('click', $event)"
@keydown="handleKeystroke"
@focusin="showSuggestionPopover = true"
/>
<HoppButtonSecondary
v-if="secret"
v-tippy="{ theme: 'tooltip' }"
:title="isSecret ? t('action.show_secret') : t('action.hide_secret')"
:icon="isSecret ? IconEyeoff : IconEye"
@click="toggleSecret"
/>
></div>
<AppInspection
:inspection-results="inspectionResults"
class="sticky inset-y-0 right-0 rounded-r bg-primary"
@@ -79,29 +61,18 @@ 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,
aggregateEnvsWithSecrets$,
} from "~/newstore/environments"
import { AggregateEnvironment, aggregateEnvs$ } 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?: Env[] | null
envs?: { key: string; value: string; source: string }[] | null
focus?: boolean
selectTextOnMount?: boolean
environmentHighlights?: boolean
@@ -109,7 +80,6 @@ const props = withDefaults(
autoCompleteSource?: string[]
inspectionResults?: InspectorResult[] | undefined
contextMenuEnabled?: boolean
secret?: boolean
}>(),
{
modelValue: "",
@@ -123,7 +93,6 @@ const props = withDefaults(
inspectionResult: undefined,
inspectionResults: undefined,
contextMenuEnabled: true,
secret: false,
}
)
@@ -149,27 +118,10 @@ 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) {
@@ -217,6 +169,8 @@ watch(
)
const handleKeystroke = (ev: KeyboardEvent) => {
if (!props.autoCompleteSource) return
if (["ArrowDown", "ArrowUp", "Enter", "Escape"].includes(ev.key)) {
ev.preventDefault()
}
@@ -353,28 +307,19 @@ watch(
let clipboardEv: ClipboardEvent | null = null
let pastedValue: string | null = null
const aggregateEnvs = useReadonlyStream(aggregateEnvsWithSecrets$, []) as Ref<
const aggregateEnvs = useReadonlyStream(aggregateEnvs$, []) as Ref<
AggregateEnvironment[]
>
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,
}
})
const envVars = computed(() =>
props.envs
? props.envs.map((x) => ({
key: x.key,
value: x.value,
sourceEnv: x.source,
}))
: aggregateEnvs.value
})
)
const envTooltipPlugin = new HoppReactiveEnvPlugin(envVars, view)
@@ -418,28 +363,17 @@ 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 (readonly) {
if (props.readonly) {
update.view.contentDOM.inputMode = "none"
}
}),
EditorState.changeFilter.of(() => !readonly),
EditorState.changeFilter.of(() => !props.readonly),
inputTheme,
readonly
props.readonly
? EditorView.theme({
".cm-content": {
caretColor: "var(--secondary-dark-color)",
@@ -450,7 +384,6 @@ const getExtensions = (readonly: boolean): Extension => {
})
: EditorView.theme({}),
tooltips({
parent: document.body,
position: "absolute",
}),
props.environmentHighlights ? envTooltipPlugin : [],
@@ -472,8 +405,7 @@ const getExtensions = (readonly: boolean): Extension => {
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
if (readonly) return
if (props.readonly) return
if (update.docChanged) {
const prevValue = clone(cachedValue.value)
@@ -522,7 +454,14 @@ const getExtensions = (readonly: boolean): Extension => {
history(),
keymap.of([...historyKeymap]),
]
return extensions
view.value = new EditorView({
parent: el,
state: EditorState.create({
doc: props.modelValue,
extensions,
}),
})
}
const triggerTextSelection = () => {
@@ -535,11 +474,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,7 +4,6 @@ import {
ViewPlugin,
ViewUpdate,
placeholder,
tooltips,
} from "@codemirror/view"
import {
Extension,
@@ -270,7 +269,6 @@ export function useCodemirror(
basicSetup,
baseTheme,
syntaxHighlighting(baseHighlightStyle, { fallback: true }),
ViewPlugin.fromClass(
class {
update(update: ViewUpdate) {
@@ -320,7 +318,6 @@ export function useCodemirror(
}
}
),
EditorView.domEventHandlers({
scroll(event) {
if (event.target && options.contextMenuEnabled) {
@@ -362,10 +359,6 @@ 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,36 +13,7 @@ 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,
},
})
@@ -64,9 +35,7 @@ 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,13 +30,6 @@ 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" }
@@ -65,63 +58,15 @@ const getTestableBody = (
return x
}
const combineEnvVariables = (envs: {
const combineEnvVariables = (env: {
global: Environment["variables"]
selected: Environment["variables"]
}) => [...envs.selected, ...envs.global]
}) => [...env.selected, ...env.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>>
): [
@@ -209,36 +154,15 @@ 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
const updatedRunResult = {
...runResult.right,
envs: {
global: updatedGlobalEnvVariables,
selected: updatedSelectedEnvVariables,
},
}
tab.value.document.testResults =
translateToSandboxTestResults(updatedRunResult)
setGlobalEnvVariables(
updateEnvironmentsWithSecret(
runResult.right.envs.global,
"global"
)
tab.value.document.testResults = translateToSandboxTestResults(
runResult.right
)
setGlobalEnvVariables(runResult.right.envs.global)
if (
environmentsStore.value.selectedEnvironmentIndex.type === "MY_ENV"
) {
@@ -249,10 +173,8 @@ export function runRESTRequest$(
updateEnvironment(
environmentsStore.value.selectedEnvironmentIndex.index,
{
name: env.name,
v: 1,
id: env.id ?? "",
variables: updatedRunResult.envs.selected,
...env,
variables: runResult.right.envs.selected,
}
)
} else if (
@@ -264,7 +186,7 @@ export function runRESTRequest$(
})
pipe(
updateTeamEnvironment(
JSON.stringify(updatedRunResult.envs.selected),
JSON.stringify(runResult.right.envs.selected),
environmentsStore.value.selectedEnvironmentIndex.teamEnvID,
env.name
)
@@ -353,6 +275,7 @@ 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 { Environment, HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { 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,7 +43,6 @@ 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
@@ -67,13 +66,6 @@ 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
@@ -94,19 +86,13 @@ 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
@@ -126,7 +112,6 @@ type HoppActionArgsMap = {
requestType: "gql"
request: HoppGQLRequest
}
| undefined
"request.open-tab": {
tab: RESTOptionTabs | GQLOptionTabs
}
@@ -136,6 +121,7 @@ type HoppActionArgsMap = {
"tab.duplicate-tab": {
tabID?: string
}
"gql.request.open": {
request: HoppGQLRequest
saveContext?: HoppGQLSaveContext
@@ -146,23 +132,11 @@ 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
*/
@@ -171,26 +145,27 @@ export type HoppActionWithNoArgs = Exclude<HoppAction, HoppActionWithArgs>
/**
* Resolves the argument type for a given HoppAction
*/
type ArgOfHoppAction<A extends HoppAction> = A extends HoppActionWithArgs
? HoppActionArgsMap[A]
: undefined
type ArgOfHoppAction<A extends HoppAction | HoppActionWithArgs> =
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> = A extends HoppActionWithArgs
? (arg: ArgOfHoppAction<A>, trigger?: InvocationTriggers) => void
: (_?: undefined, trigger?: InvocationTriggers) => void
type ActionFunc<A extends HoppAction | HoppActionWithArgs> =
A extends HoppActionWithArgs ? (arg: ArgOfHoppAction<A>) => void : () => void
type BoundActionList = {
[A in HoppAction]?: Array<ActionFunc<A>>
// eslint-disable-next-line no-unused-vars
[A in HoppAction | HoppActionWithArgs]?: Array<ActionFunc<A>>
}
const boundActions: BoundActionList = reactive({})
export const activeActions$ = new BehaviorSubject<HoppAction[]>([])
export const activeActions$ = new BehaviorSubject<
(HoppAction | HoppActionWithArgs)[]
>([])
export function bindAction<A extends HoppAction>(
export function bindAction<A extends HoppAction | HoppActionWithArgs>(
action: A,
handler: ActionFunc<A>
) {
@@ -204,33 +179,27 @@ export function bindAction<A extends HoppAction>(
activeActions$.next(Object.keys(boundActions) as HoppAction[])
}
export type InvocationTriggers = "keypress" | "mouseclick"
type InvokeActionFunc = {
(
action: HoppActionWithOptionalArgs,
args?: undefined,
trigger?: InvocationTriggers
): void
(action: HoppActionWithNoArgs, args?: undefined): void
<A extends HoppActionWithArgs>(action: A, args: HoppActionArgsMap[A]): void
}
/**
* Invokes an action, triggering action handlers if any registered.
* The second and third arguments are optional
* Invokes a action, triggering action handlers if any registered.
* The second argument parameter is optional if your action has no args required
* @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>(
export const invokeAction: InvokeActionFunc = <
A extends HoppAction | HoppActionWithArgs,
>(
action: A,
args?: ArgOfHoppAction<A>,
trigger?: InvocationTriggers
args: ArgOfHoppAction<A>
) => {
boundActions[action]?.forEach((handler) => handler(args! as any, trigger))
boundActions[action]?.forEach((handler) => handler(args! as any))
}
export function unbindAction<A extends HoppAction>(
export function unbindAction<A extends HoppAction | HoppActionWithArgs>(
action: A,
handler: ActionFunc<A>
) {
@@ -263,7 +232,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>(
export function defineActionHandler<A extends HoppAction | HoppActionWithArgs>(
action: A,
handler: ActionFunc<A>,
isActive: Ref<boolean> | undefined = undefined

View File

@@ -1,12 +1,7 @@
mutation CreateTeamEnvironment(
$variables: String!
$teamID: ID!
$name: String!
) {
createTeamEnvironment(variables: $variables, teamID: $teamID, name: $name) {
mutation CreateTeamEnvironment($variables: String!,$teamID: ID!,$name: String!){
createTeamEnvironment( variables: $variables ,teamID: $teamID ,name: $name){
variables
name
teamID
id
}
}
}

View File

@@ -12,17 +12,14 @@ import { parseTemplateStringE } from "@hoppscotch/data"
import { StreamSubscriberFunc } from "@composables/stream"
import {
AggregateEnvironment,
aggregateEnvsWithSecrets$,
getAggregateEnvsWithSecrets,
getCurrentEnvironment,
aggregateEnvs$,
getAggregateEnvs,
getSelectedEnvironmentType,
} from "~/newstore/environments"
import { invokeAction } from "~/helpers/actions"
import IconUser from "~icons/lucide/user?raw"
import IconUsers from "~icons/lucide/users?raw"
import IconEdit from "~icons/lucide/edit?raw"
import { SecretEnvironmentService } from "~/services/secret-environment.service"
import { getService } from "~/modules/dioc"
const HOPP_ENVIRONMENT_REGEX = /(<<[a-zA-Z0-9-_]+>>)/g
@@ -31,8 +28,6 @@ const HOPP_ENV_HIGHLIGHT =
const HOPP_ENV_HIGHLIGHT_FOUND = "env-found"
const HOPP_ENV_HIGHLIGHT_NOT_FOUND = "env-not-found"
const secretEnvironmentService = getService(SecretEnvironmentService)
const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
hoverTooltip(
(view, pos, side) => {
@@ -71,27 +66,7 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
const envName = tooltipEnv?.sourceEnv ?? "Choose an Environment"
let envValue = "Not Found"
const currentSelectedEnvironment = getCurrentEnvironment()
const hasSecretEnv = secretEnvironmentService.hasSecretValue(
tooltipEnv?.sourceEnv !== "Global"
? currentSelectedEnvironment.id
: "Global",
tooltipEnv?.key ?? ""
)
if (!tooltipEnv?.secret && tooltipEnv?.value) envValue = tooltipEnv.value
else if (tooltipEnv?.secret && hasSecretEnv) {
envValue = "******"
} else if (tooltipEnv?.secret && !hasSecretEnv) {
envValue = "Empty"
} else if (!tooltipEnv?.sourceEnv) {
envValue = "Not Found"
} else if (!tooltipEnv?.value) {
envValue = "Empty"
}
const envValue = tooltipEnv?.value ?? "Not found"
const result = parseTemplateStringE(envValue, aggregateEnvs)
@@ -108,25 +83,12 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
editIcon.className =
"ml-2 cursor-pointer text-accent hover:text-accentDark"
editIcon.addEventListener("click", () => {
let invokeActionType:
| "modals.my.environment.edit"
| "modals.team.environment.edit"
| "modals.global.environment.update" = "modals.my.environment.edit"
if (tooltipEnv?.sourceEnv === "Global") {
invokeActionType = "modals.global.environment.update"
} else if (selectedEnvType === "MY_ENV") {
invokeActionType = "modals.my.environment.edit"
} else if (selectedEnvType === "TEAM_ENV") {
invokeActionType = "modals.team.environment.edit"
} else {
invokeActionType = "modals.my.environment.edit"
}
invokeAction(invokeActionType, {
envName: tooltipEnv?.sourceEnv !== "Global" ? envName : "Global",
const isPersonalEnv =
envName === "Global" || selectedEnvType !== "TEAM_ENV"
const action = isPersonalEnv ? "my" : "team"
invokeAction(`modals.${action}.environment.edit`, {
envName,
variableName: parsedEnvKey,
isSecret: tooltipEnv?.secret,
})
})
editIcon.innerHTML = `<span class="inline-flex items-center justify-center my-1">${IconEdit}</span>`
@@ -209,10 +171,11 @@ export class HoppEnvironmentPlugin {
subscribeToStream: StreamSubscriberFunc,
private editorView: Ref<EditorView | undefined>
) {
this.envs = getAggregateEnvsWithSecrets()
this.envs = getAggregateEnvs()
subscribeToStream(aggregateEnvsWithSecrets$, (envs) => {
subscribeToStream(aggregateEnvs$, (envs) => {
this.envs = envs
this.editorView.value?.dispatch({
effects: this.compartment.reconfigure([
cursorTooltipField(this.envs),

View File

@@ -12,6 +12,8 @@ const getEnvironmentJson = (
? cloneDeep(environmentObj.environment)
: cloneDeep(environmentObj)
delete newEnvironment.id
const environmentId =
environmentIndex || environmentIndex === 0
? environmentIndex

View File

@@ -1,13 +1,22 @@
import * as O from "fp-ts/Option"
import * as TE from "fp-ts/TaskEither"
import { entityReference } from "verzod"
import * as O from "fp-ts/Option"
import { safeParseJSON } from "~/helpers/functional/json"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { safeParseJSON } from "~/helpers/functional/json"
import { Environment } from "@hoppscotch/data"
import { z } from "zod"
const hoppEnvSchema = z.object({
id: z.string().optional(),
name: z.string(),
variables: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
})
export const hoppEnvImporter = (content: string) => {
const parsedContent = safeParseJSON(content, true)
@@ -16,9 +25,7 @@ export const hoppEnvImporter = (content: string) => {
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
}
const validationResult = z
.array(entityReference(Environment))
.safeParse(parsedContent.value)
const validationResult = z.array(hoppEnvSchema).safeParse(parsedContent.value)
if (!validationResult.success) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT)

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