Compare commits

...

31 Commits

Author SHA1 Message Date
amk-dev
f98687da7b chore: remove @lezer/highlight from hoppscotch-ui 2023-11-02 20:10:42 +05:30
Andrew Bastin
cbe3e14b47 refactor: versioning and migration mechanism for public data structures (#3457)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-11-02 18:54:16 +05:30
Gaurav K P
01df1663ad fix(common): handle false negatives in url validation (#3465) 2023-11-01 22:23:33 +05:30
Nivedin
abd5288da8 refactor: move sentry to platform (#3451) 2023-11-01 18:17:55 +05:30
Michel Tomas
a89bc473f6 fix(self-hosted/web): add "useCredentials: true" to Vite PWA options (#3460) 2023-11-01 09:46:20 +05:30
Andrew Bastin
57cb59027b chore: bump codemirror dependencies 2023-10-19 13:37:07 +05:30
Joel Jacob Stephen
7a9f0c8756 refactor: improvements to the auth implementation in admin dashboard (#3444)
* refactor: abstract axios queries to a separate helper file

* chore: delete unnecessary file

* chore: remove unnecessary console logs

* refactor: updated urls for api and authquery helpers

* refactor: updated auth implementation

* refactor: use default axios instance

* chore: improve code readability

* refactor: separate instances for rest and gql calls

* refactor: removed async await from functions that do not need them

* refactor: removed probable login and probable user from the auth system

* refactor: better error handling in login component

* chore: deleted unnecessary files and restructured some files

* feat: new errors file with typed error message formats

* refactor: removed unwanted usage of async await

* refactor: optimizing the usage and return of promises in auth flow

* refactor: convey boolean return type in a better way

* chore: apply suggestions

* refactor: handle case when mailcatcher is not active

---------

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: James George <jamesgeorge998001@gmail.com>
2023-10-16 18:14:02 +05:30
Balu Babu
46caf9b198 refactor: removed all instances of rejectOnNotFound in prisma queries (#3377)
* chore: removed rejectOnNotFound property from prisma query in team-enviroment method

* chore: fixed issues with test cases in team-environment module

* chore: changed target of hoppscotch-old-backend service back to prod
2023-10-16 14:04:03 +05:30
Mir Arif Hasan
f5db54484c HBE-266 Update NestJS packages (#3389)
* build: update npm nest packages

* build: removed depricated apollo-server-plugin package

* build: pnpm-lock file added

* build: swc integrated

* Revert "build: swc integrated"

This reverts commit 803a01f38f210dfbcd603665893d29af565c8908.

* feat: upgrade graphql* packages version

* feat: upgrade point release

* feat: update pnpm-lock file
2023-10-16 12:23:55 +05:30
Mir Arif Hasan
8deb6471b9 HBE-270 Test-Case timestamp issue fix in backend (#3415)
test: timestamp issue fix in user-history
2023-10-16 12:11:15 +05:30
Liyas Thomas
73b3ff8e41 feat: improve import-export UI (#3452)
* chore: uniform styles across components

* chore: removed absolute wrapper divs

* feat: add import button when graphql collections are empty

* chore: add icon for button

---------

Co-authored-by: nivedin <nivedinp@gmail.com>
2023-10-13 17:57:14 +05:30
James George
016a18d3b2 fix(common): use tab service within helpers (#3448) 2023-10-12 13:15:45 +05:30
Anwarul Islam
ba31cdabea feat: tab service added (#3367) 2023-10-11 18:21:07 +05:30
Nivedin
51510566bc refactor: add import buttons in empty state for collections & environments (#3438) 2023-10-11 11:08:51 +05:30
Anwarul Islam
cabee0ecc8 fix: memory leak issue on TeamInvite modal (#3440)
* fix: memory leak issue

* feat: added rerun ability

* chore: lint fix
2023-10-11 07:59:12 +05:30
Anwarul Islam
2c2b39a236 feat: no permission warning added for users except owner while deleting team (#3328)
* feat: no permission warning added
* chore: changed to function reference
2023-10-09 19:31:48 +05:30
Liyas Thomas
78450c9316 fix: tooltip position in editor instance (#3374) 2023-10-09 11:37:52 +05:30
Joel Jacob Stephen
b18fd90b64 fix: blank screen in admin dashboard on authentication problems (#3385)
* fix: dashboard logs out user when cookie expires or is unauthorized

* fix: handles the 401 error thrown when trying to refresh tokens

* chore: updated wrong logic when returning state in refresh token function

* feat: introduced auth exchange to urql client to check for errors on each backend call

* fix: prevent multiple window reloads

---------

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-10-09 10:08:35 +05:30
Andrew Bastin
0188a8d7db chore: bump version 2023-10-06 22:04:57 +05:30
Joel Jacob Stephen
6c63a8dc28 refactor: updated i18n implementation in the admin dashboard (#3395)
* feat: introduced new unplugin i18n and removed the old vite i18n package

* refactor: updated vite config to support the new plugin

* refactor: removed irrelevant logic from the i18n module
2023-10-06 17:36:19 +05:30
Rakibul Yeasin
17d6ae15a5 fix: Cannot set custom method #3406 (#3408)
* fix: #3406

* chore: remove console log

* fix: an unknown keyboard event issue

---------

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Anwarul Islam <anwaarulislaam@gmail.com>
2023-10-06 11:57:26 +05:30
Andrew Bastin
40f72278a9 fix: team collection resetting on unmount within app lifecycle (#3396)
* fix: team collection resetting on unmount within app lifecycle

* chore: linting

* refactor: eliminate redundancy

* chore: update comment about the watcher purpose

---------

Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-10-06 11:34:44 +05:30
5idereal
f717704731 chore(i18n): update tw.json (#3409) 2023-10-06 11:27:24 +05:30
Joel Jacob Stephen
185c225297 feat: introduces ability to export single environment variables and allow CLI to accept the export format used by the app (#3380)
* feat: add ability to export a single environment

* refactor: export environment without id

* feat: introducing zod for checking json format for environment variables

* refactor: new zod specific type for HoppEnvPair

* feat: add ability to export single environment in team environment

* refactor: moved zod as a dependency to devDependency

* refactor: separated repeating logic to helper file

* refactor: removed unnecessary to string operation

* chore: rearranged smart item placement

* refactor: introduced error type when a bulk environment export is used in cli

* refactor: removed unnecssary type exports and updated logic and variable names across most files

* refactor: better logic for type shapes

* chore: bump hoppscotch-cli package version

---------

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-10-06 11:21:54 +05:30
James George
2694731c36 chore: remove stale type definitions (#3368) 2023-10-05 14:49:04 +05:30
James George
ae89af9978 feat: alert the user on empty collection/environment exports (#3416) 2023-10-05 14:38:38 +05:30
James George
87d617012f fix: environment variables usage in meta tags (#3418) 2023-10-05 13:51:42 +05:30
Liyas Thomas
2420b3fa42 chore: move deps in the root of monorepo into devDependencies (#3375)
chore: move deps in the root of monorepo into devDependencies
2023-09-28 22:25:22 +05:30
Anwarul Islam
175a991ec4 fix: gql teamID not being passed (#3392)
* chore: bump dependencies for path.charCodeAt issue
* fix: gql teamID is not passed issue
2023-09-28 22:04:02 +05:30
SamJakob
0301649aff chore: make devcontainer copy .env.example (#3318) 2023-09-28 21:58:17 +05:30
Joel Jacob Stephen
544b045300 fix: authorisation headers not being sent along with subscriptions when using graphql (#3354)
* fix: send auth headers to the payload

* refactor: alert user that headers are sent to connection_init

* refactor: send headers only when headers are populated

* chore: cleanup code
2023-09-28 21:57:07 +05:30
139 changed files with 6669 additions and 4436 deletions

View File

@@ -5,5 +5,5 @@
"features": {
"ghcr.io/NicoVIII/devcontainer-features/pnpm:1": {}
},
"postCreateCommand": "mv .env.example .env && pnpm i"
"postCreateCommand": "cp .env.example .env && pnpm i"
}

View File

@@ -22,16 +22,14 @@
"workspaces": [
"./packages/*"
],
"dependencies": {
"husky": "^7.0.4",
"lint-staged": "^12.3.8"
},
"devDependencies": {
"@commitlint/cli": "^16.2.3",
"@commitlint/config-conventional": "^16.2.1",
"@types/node": "^17.0.24",
"@types/node": "17.0.27",
"cross-env": "^7.0.3",
"http-server": "^14.1.1"
"http-server": "^14.1.1",
"husky": "^7.0.4",
"lint-staged": "12.4.0"
},
"pnpm": {
"packageExtensions": {

View File

@@ -17,16 +17,16 @@
"types": "dist/index.d.ts",
"sideEffects": false,
"dependencies": {
"@codemirror/language": "^6.9.0",
"@codemirror/language": "^6.9.1",
"@lezer/highlight": "^1.1.6",
"@lezer/lr": "^1.3.10"
"@lezer/lr": "^1.3.13"
},
"devDependencies": {
"@lezer/generator": "^1.5.0",
"@lezer/generator": "^1.5.1",
"mocha": "^9.2.2",
"rollup": "^2.70.2",
"rollup-plugin-dts": "^4.2.1",
"rollup-plugin-ts": "^2.0.7",
"typescript": "^4.6.3"
"rollup": "^3.29.3",
"rollup-plugin-dts": "^6.0.2",
"rollup-plugin-ts": "^3.4.5",
"typescript": "^5.2.2"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "hoppscotch-backend",
"version": "2023.8.1",
"version": "2023.8.2",
"description": "",
"author": "",
"private": true,
@@ -24,18 +24,17 @@
"do-test": "pnpm run test"
},
"dependencies": {
"@nestjs-modules/mailer": "^1.8.1",
"@nestjs/apollo": "^10.1.6",
"@nestjs/common": "^9.2.1",
"@nestjs/core": "^9.2.1",
"@nestjs/graphql": "^10.1.6",
"@nestjs/jwt": "^10.0.1",
"@nestjs/passport": "^9.0.0",
"@nestjs/platform-express": "^9.2.1",
"@nestjs/throttler": "^4.0.0",
"@apollo/server": "^4.9.4",
"@nestjs-modules/mailer": "^1.9.1",
"@nestjs/apollo": "^12.0.9",
"@nestjs/common": "^10.2.6",
"@nestjs/core": "^10.2.6",
"@nestjs/graphql": "^12.0.9",
"@nestjs/jwt": "^10.1.1",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.2.6",
"@nestjs/throttler": "^5.0.0",
"@prisma/client": "^4.16.2",
"apollo-server-express": "^3.11.1",
"apollo-server-plugin-base": "^3.7.1",
"argon2": "^0.30.3",
"bcrypt": "^5.1.0",
"cookie": "^0.5.0",
@@ -43,9 +42,9 @@
"express": "^4.17.1",
"express-session": "^1.17.3",
"fp-ts": "^2.13.1",
"graphql": "^15.5.0",
"graphql": "^16.8.1",
"graphql-query-complexity": "^0.12.0",
"graphql-redis-subscriptions": "^2.5.0",
"graphql-redis-subscriptions": "^2.6.0",
"graphql-subscriptions": "^2.0.0",
"handlebars": "^4.7.7",
"io-ts": "^2.2.16",
@@ -63,9 +62,9 @@
"rxjs": "^7.6.0"
},
"devDependencies": {
"@nestjs/cli": "^9.1.5",
"@nestjs/schematics": "^9.0.3",
"@nestjs/testing": "^9.2.1",
"@nestjs/cli": "^10.1.18",
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.2.6",
"@relmify/jest-fp-ts": "^2.0.2",
"@types/argon2": "^0.15.0",
"@types/bcrypt": "^5.0.0",

View File

@@ -27,12 +27,7 @@ import { AppController } from './app.controller';
buildSchemaOptions: {
numberScalarMode: 'integer',
},
cors: {
origin: process.env.WHITELISTED_ORIGINS.split(','),
credentials: true,
},
playground: process.env.PRODUCTION !== 'true',
debug: process.env.PRODUCTION !== 'true',
autoSchemaFile: true,
installSubscriptionHandlers: true,
subscriptions: {
@@ -62,10 +57,12 @@ import { AppController } from './app.controller';
}),
driver: ApolloDriver,
}),
ThrottlerModule.forRoot({
ttl: +process.env.RATE_LIMIT_TTL,
limit: +process.env.RATE_LIMIT_MAX,
}),
ThrottlerModule.forRoot([
{
ttl: +process.env.RATE_LIMIT_TTL,
limit: +process.env.RATE_LIMIT_MAX,
},
]),
UserModule,
AuthModule,
AdminModule,

View File

@@ -93,9 +93,7 @@ export async function emitGQLSchemaFile() {
numberScalarMode: 'integer',
});
const schemaString = printSchema(schema, {
commentDescriptions: true,
});
const schemaString = printSchema(schema);
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);

View File

@@ -3,8 +3,7 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class ThrottlerBehindProxyGuard extends ThrottlerGuard {
protected getTracker(req: Record<string, any>): string {
protected async getTracker(req: Record<string, any>): Promise<string> {
return req.ips.length ? req.ips[0] : req.ip; // individualize IP extraction to meet your own needs
// learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For#directives
}
}

View File

@@ -1,8 +1,9 @@
import { GraphQLSchemaHost } from '@nestjs/graphql';
import {
ApolloServerPlugin,
BaseContext,
GraphQLRequestListener,
} from 'apollo-server-plugin-base';
} from '@apollo/server';
import { Plugin } from '@nestjs/apollo';
import { GraphQLError } from 'graphql';
import {
@@ -17,7 +18,7 @@ const COMPLEXITY_LIMIT = 50;
export class GQLComplexityPlugin implements ApolloServerPlugin {
constructor(private gqlSchemaHost: GraphQLSchemaHost) {}
async requestDidStart(): Promise<GraphQLRequestListener> {
async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
const { schema } = this.gqlSchemaHost;
return {

View File

@@ -1,5 +1,5 @@
import { Team, TeamCollection as DBTeamCollection } from '@prisma/client';
import { mock, mockDeep, mockReset } from 'jest-mock-extended';
import { mockDeep, mockReset } from 'jest-mock-extended';
import {
TEAM_COLL_DEST_SAME,
TEAM_COLL_INVALID_JSON,
@@ -17,9 +17,6 @@ import { PrismaService } from 'src/prisma/prisma.service';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { AuthUser } from 'src/types/AuthUser';
import { TeamCollectionService } from './team-collection.service';
import { TeamCollection } from './team-collection.model';
import { TeamCollectionModule } from './team-collection.module';
import * as E from 'fp-ts/Either';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();

View File

@@ -301,7 +301,7 @@ describe('TeamEnvironmentsService', () => {
describe('createDuplicateEnvironment', () => {
test('should successfully duplicate an existing team environment', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);
@@ -322,7 +322,9 @@ describe('TeamEnvironmentsService', () => {
});
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValue(
'NotFoundError',
);
const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id,
@@ -332,7 +334,7 @@ describe('TeamEnvironmentsService', () => {
});
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is updated successfully', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
);

View File

@@ -183,11 +183,10 @@ export class TeamEnvironmentsService {
*/
async createDuplicateEnvironment(id: string) {
try {
const environment = await this.prisma.teamEnvironment.findFirst({
const environment = await this.prisma.teamEnvironment.findFirstOrThrow({
where: {
id: id,
},
rejectOnNotFound: true,
});
const result = await this.prisma.teamEnvironment.create({

View File

@@ -142,13 +142,15 @@ describe('UserHistoryService', () => {
});
describe('createUserHistory', () => {
test('Should resolve right and create a REST request to users history and return a `UserHistory` object', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc',
id: '1',
request: [{}],
responseMetadata: [{}],
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: false,
});
@@ -158,7 +160,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]),
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: false,
};
@@ -172,13 +174,15 @@ describe('UserHistoryService', () => {
).toEqualRight(userHistory);
});
test('Should resolve right and create a GQL request to users history and return a `UserHistory` object', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc',
id: '1',
request: [{}],
responseMetadata: [{}],
reqType: ReqType.GQL,
executedOn: new Date(),
executedOn,
isStarred: false,
});
@@ -188,7 +192,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]),
reqType: ReqType.GQL,
executedOn: new Date(),
executedOn,
isStarred: false,
};
@@ -212,13 +216,15 @@ describe('UserHistoryService', () => {
).toEqualLeft(USER_HISTORY_INVALID_REQ_TYPE);
});
test('Should create a GQL request to users history and publish a created subscription', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc',
id: '1',
request: [{}],
responseMetadata: [{}],
reqType: ReqType.GQL,
executedOn: new Date(),
executedOn,
isStarred: false,
});
@@ -228,7 +234,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]),
reqType: ReqType.GQL,
executedOn: new Date(),
executedOn,
isStarred: false,
};
@@ -245,13 +251,15 @@ describe('UserHistoryService', () => {
);
});
test('Should create a REST request to users history and publish a created subscription', async () => {
const executedOn = new Date();
mockPrisma.userHistory.create.mockResolvedValueOnce({
userUid: 'abc',
id: '1',
request: [{}],
responseMetadata: [{}],
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: false,
});
@@ -261,7 +269,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]),
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: false,
};
@@ -323,13 +331,15 @@ describe('UserHistoryService', () => {
).toEqualLeft(USER_HISTORY_NOT_FOUND);
});
test('Should star/unstar a request in the history and publish a updated subscription', async () => {
const executedOn = new Date();
mockPrisma.userHistory.findFirst.mockResolvedValueOnce({
userUid: 'abc',
id: '1',
request: [{}],
responseMetadata: [{}],
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: false,
});
@@ -339,7 +349,7 @@ describe('UserHistoryService', () => {
request: [{}],
responseMetadata: [{}],
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: true,
});
@@ -349,7 +359,7 @@ describe('UserHistoryService', () => {
request: JSON.stringify([{}]),
responseMetadata: JSON.stringify([{}]),
reqType: ReqType.REST,
executedOn: new Date(),
executedOn,
isStarred: true,
};

View File

@@ -1,6 +1,6 @@
{
"name": "@hoppscotch/cli",
"version": "0.3.2",
"version": "0.3.3",
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
"homepage": "https://hoppscotch.io",
"main": "dist/index.js",
@@ -40,9 +40,6 @@
"@hoppscotch/js-sandbox": "workspace:^",
"@relmify/jest-fp-ts": "^2.0.2",
"@swc/core": "^1.2.181",
"@types/axios": "^0.14.0",
"@types/chalk": "^2.2.0",
"@types/commander": "^2.12.2",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.181",
"@types/qs": "^6.9.7",
@@ -58,6 +55,7 @@
"qs": "^6.10.3",
"ts-jest": "^27.1.4",
"tsup": "^5.12.7",
"typescript": "^4.6.4"
"typescript": "^4.6.4",
"zod": "^3.22.2"
}
}

View File

@@ -48,6 +48,11 @@ export const handleError = <T extends HoppErrorCode>(error: HoppError<T>) => {
ERROR_MSG = `Unavailable command: ${error.command}`;
break;
case "MALFORMED_ENV_FILE":
ERROR_MSG = `The environment file is not of the correct format.`;
break;
case "BULK_ENV_FILE":
ERROR_MSG = `CLI doesn't support bulk environments export.`;
break;
case "MALFORMED_COLLECTION":
ERROR_MSG = `${error.path}\n${parseErrorData(error.data)}`;
break;
@@ -82,4 +87,4 @@ export const handleError = <T extends HoppErrorCode>(error: HoppError<T>) => {
if (!S.isEmpty(ERROR_MSG)) {
console.error(ERROR_CODE, ERROR_MSG);
}
};
};

View File

@@ -1,27 +1,45 @@
import { error } from "../../types/errors";
import { HoppEnvs, HoppEnvPair } from "../../types/request";
import {
HoppEnvs,
HoppEnvPair,
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.
*/
export async function parseEnvsData(path: string) {
const contents = await readJsonFile(path)
const contents = await readJsonFile(path);
const envPairs: Array<HoppEnvPair> = [];
const HoppEnvKeyPairResult = HoppEnvKeyPairObject.safeParse(contents);
const HoppEnvExportObjectResult = HoppEnvExportObject.safeParse(contents);
const HoppBulkEnvExportObjectResult =
HoppBulkEnvExportObject.safeParse(contents);
if(!(contents && typeof contents === "object" && !Array.isArray(contents))) {
throw error({ code: "MALFORMED_ENV_FILE", path, data: null })
// 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 });
}
const envPairs: Array<HoppEnvPair> = []
// 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 });
}
for( const [key,value] of Object.entries(contents)) {
if(typeof value !== "string") {
throw error({ code: "MALFORMED_ENV_FILE", path, data: {value: value} })
if (HoppEnvKeyPairResult.success) {
for (const [key, value] of Object.entries(HoppEnvKeyPairResult.data)) {
envPairs.push({ key, value });
}
envPairs.push({key, value})
} else if (HoppEnvExportObjectResult.success) {
const { key, value } = HoppEnvExportObjectResult.data.variables[0];
envPairs.push({ key, value });
}
return <HoppEnvs>{ global: [], selected: envPairs }
return <HoppEnvs>{ global: [], selected: envPairs };
}

View File

@@ -24,6 +24,7 @@ type HoppErrors = {
REQUEST_ERROR: HoppErrorData;
INVALID_ARGUMENT: HoppErrorData;
MALFORMED_ENV_FILE: HoppErrorPath & HoppErrorData;
BULK_ENV_FILE: HoppErrorPath & HoppErrorData;
INVALID_FILE_TYPE: HoppErrorData;
};

View File

@@ -1,6 +1,7 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { TestReport } from "../interfaces/response";
import { HoppCLIError } from "./errors";
import { z } from "zod";
export type FormDataEntry = {
key: string;
@@ -9,6 +10,22 @@ export type FormDataEntry = {
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

@@ -137,10 +137,10 @@ a {
.cm-tooltip {
.tippy-box {
@apply shadow-none;
@apply shadow-none #{!important};
@apply fixed;
@apply inline-flex;
@apply -mt-8;
@apply -mt-7.5;
}
}

View File

@@ -112,6 +112,7 @@
},
"authorization": {
"generate_token": "Generate Token",
"graphql_headers": "Authorization Headers are sent as part of the payload to connection_init",
"include_in_url": "Include in URL",
"learn": "Learn how",
"pass_key_by": "Pass by",
@@ -124,6 +125,7 @@
"created": "Collection created",
"different_parent": "Cannot reorder collection with different parent",
"edit": "Edit Collection",
"import_or_create": "Import or create a collection",
"invalid_name": "Please provide a name for the collection",
"invalid_root_move": "Collection already in the root",
"moved": "Moved Successfully",
@@ -209,6 +211,7 @@
"empty_variables": "No variables",
"global": "Global",
"global_variables": "Global variables",
"import_or_create": "Import or create a environment",
"invalid_name": "Please provide a name for the environment",
"list": "Environment variables",
"my_environments": "My Environments",
@@ -249,7 +252,9 @@
"json_prettify_invalid_body": "Couldn't prettify an invalid body, solve json syntax errors and try again",
"network_error": "There seems to be a network error. Please try again.",
"network_fail": "Could not send request",
"no_collections_to_export": "No collections to export. Please create a collection to get started.",
"no_duration": "No duration",
"no_environments_to_export": "No environments to export. Please create an environment to get started.",
"no_results_found": "No matches found",
"page_not_found": "This page could not be found",
"proxy_error": "Proxy error",
@@ -456,6 +461,7 @@
"enter_curl": "Enter cURL command",
"generate_code": "Generate code",
"generated_code": "Generated code",
"go_to_authorization_tab": "Go to Authorization",
"header_list": "Header List",
"invalid_name": "Please provide a name for the request",
"method": "Method",
@@ -743,9 +749,11 @@
"disconnected_from": "Disconnected from {name}",
"docs_generated": "Documentation generated",
"download_started": "Download started",
"download_failed": "Download failed",
"enabled": "Enabled",
"file_imported": "File imported",
"finished_in": "Finished in {duration} ms",
"hide": "Hide",
"history_deleted": "History deleted",
"linewrap": "Wrap lines",
"loading": "Loading...",
@@ -756,6 +764,7 @@
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}",
"published_message": "Published message: {message} to topic: {topic}",
"reconnection_error": "Failed to reconnect",
"show":"Show",
"subscribed_failed": "Failed to subscribe to topic: {topic}",
"subscribed_success": "Successfully subscribed to topic: {topic}",
"unsubscribed_failed": "Failed to unsubscribe from topic: {topic}",
@@ -837,7 +846,7 @@
"new": "New Team",
"new_created": "New team created",
"new_name": "My New Team",
"no_access": "You do not have edit access to these collections",
"no_access": "You do not have edit access to this team",
"no_invite_found": "Invitation not found. Contact your team owner.",
"no_request_found": "Request not found.",
"not_found": "Team not found. Contact your team owner.",

View File

@@ -5,7 +5,7 @@
"choose_file": "選擇一個檔案",
"clear": "清除",
"clear_all": "全部清除",
"clear_history": "Clear all History",
"clear_history": "清除所有歷史記錄",
"close": "關閉",
"connect": "連線",
"connecting": "正在連接",
@@ -79,8 +79,8 @@
"search": "搜尋",
"share": "分享",
"shortcuts": "快捷方式",
"social_description": "Follow us on social media to stay updated with the latest news, updates and releases.",
"social_links": "Social links",
"social_description": "在社交媒體上追蹤我們即可在第一時間得知新聞、更新、以及新版本的消息。",
"social_links": "社群連結",
"spotlight": "聚光燈",
"status": "狀態",
"status_description": "檢查網站狀態",
@@ -135,15 +135,15 @@
"renamed": "集合已重新命名",
"request_in_use": "請求正在使用中",
"save_as": "另存為",
"save_to_collection": "Save to Collection",
"save_to_collection": "儲存到集合",
"select": "選擇一個集合",
"select_location": "選擇位置",
"select_team": "選擇一個團隊",
"team_collections": "團隊集合"
},
"confirm": {
"close_unsaved_tab": "Are you sure you want to close this tab?",
"close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.",
"close_unsaved_tab": "您確定要關閉此分頁嗎?",
"close_unsaved_tabs": "您確定要關閉所有分頁嗎?{count} 個未儲存的分頁將會遺失。",
"exit_team": "您確定要離開此團隊嗎?",
"logout": "您確定要登出嗎?",
"remove_collection": "您確定要永久刪除該集合嗎?",
@@ -158,9 +158,9 @@
"sync": "您想從雲端恢復您的工作區嗎?這將丟棄您的本地進度。"
},
"context_menu": {
"add_parameters": "Add to parameters",
"open_request_in_new_tab": "Open request in new tab",
"set_environment_variable": "Set as variable"
"add_parameters": "新增至參數",
"open_request_in_new_tab": "在新分頁開啟請求",
"set_environment_variable": "設為變數"
},
"count": {
"header": "請求標頭 {count}",
@@ -204,31 +204,31 @@
"create_new": "建立新環境",
"created": "已建立環境",
"deleted": "刪除環境",
"duplicated": "Environment duplicated",
"duplicated": "已複製環境",
"edit": "編輯環境",
"empty_variables": "No variables",
"global": "Global",
"global_variables": "Global variables",
"empty_variables": "無變數",
"global": "全域",
"global_variables": "全域變數",
"invalid_name": "請提供有效的環境名稱",
"list": "Environment variables",
"list": "環境變數",
"my_environments": "我的環境",
"name": "Name",
"name": "名稱",
"nested_overflow": "巢狀環境變數不得大於 10 層",
"new": "建立環境",
"no_active_environment": "No active environment",
"no_active_environment": "無使用中的環境",
"no_environment": "無環境",
"no_environment_description": "未選取任何環境。請選擇要對以下變數進行的動作。",
"quick_peek": "Environment Quick Peek",
"replace_with_variable": "Replace with variable",
"scope": "Scope",
"quick_peek": "快速預覽環境",
"replace_with_variable": "以變數替代",
"scope": "範圍",
"select": "選擇環境",
"set": "Set environment",
"set_as_environment": "Set as environment",
"set": "設定環境",
"set_as_environment": "設為環境",
"team_environments": "團隊環境",
"title": "環境",
"updated": "更新環境",
"value": "Value",
"variable": "Variable",
"value": "數值",
"variable": "變數",
"variable_list": "變數列表"
},
"error": {
@@ -252,7 +252,7 @@
"no_duration": "無持續時間",
"no_results_found": "找不到結果",
"page_not_found": "找不到此頁面",
"proxy_error": "Proxy error",
"proxy_error": "Proxy 錯誤",
"script_fail": "無法執行預請求指令碼",
"something_went_wrong": "發生了一些錯誤",
"test_script_fail": "無法執行測試指令碼"
@@ -278,13 +278,13 @@
"renamed": "資料夾已重新命名"
},
"graphql": {
"connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?",
"connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is",
"connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is",
"connection_switch_confirm": "您要使用最新的 GraphQL 端點連線嗎?",
"connection_switch_new_url": "切換至分頁將斷開使用中的 GraphQL 連線。新的連線網址為 ",
"connection_switch_url": "您已連接至 GraphQL 端點。連線網址為 ",
"mutations": "變體",
"schema": "綱要",
"subscriptions": "訂閱",
"switch_connection": "Switch connection"
"switch_connection": "切換連線"
},
"group": {
"time": "時間",
@@ -339,27 +339,27 @@
"title": "匯入"
},
"inspections": {
"description": "Inspect possible errors",
"description": "檢查潛在錯誤",
"environment": {
"add_environment": "Add to Environment",
"not_found": "Environment variable “{environment}” not found."
"add_environment": "新增至環境",
"not_found": "找不到環境變數 “{environment}”"
},
"header": {
"cookie": "The browser doesn't allow Hoppscotch to set the Cookie Header. While we're working on the Hoppscotch Desktop App (coming soon), please use the Authorization Header instead."
"cookie": "瀏覽器不允許 Hoppscotch 設定 Cookie 標頭。在我們推出 Hoppscotch 桌面版前,請先使用 Authorization 標頭。"
},
"response": {
"401_error": "Please check your authentication credentials.",
"404_error": "Please check your request URL and method type.",
"cors_error": "Please check your Cross-Origin Resource Sharing configuration.",
"default_error": "Please check your request.",
"network_error": "Please check your network connection."
"401_error": "請檢查您的授權認證。",
"404_error": "請檢查您的請求網址和方式類型。",
"cors_error": "請檢查您的跨來源資源共用設定。",
"default_error": "請檢查您的請求。",
"network_error": "請檢查您的網路連線。"
},
"title": "Inspector",
"title": "檢查工具",
"url": {
"extension_not_installed": "Extension not installed.",
"extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.",
"extention_enable_action": "Enable Browser Extension",
"extention_not_enabled": "Extension not enabled."
"extension_not_installed": "未安裝擴充套件。",
"extension_unknown_origin": "請確認您是否已將 API 端點的來源加入 Hoppscotch 擴充套件的清單。",
"extention_enable_action": "啟用瀏覽器擴充套件",
"extention_not_enabled": "未啟用擴充套件。"
}
},
"layout": {
@@ -472,7 +472,7 @@
"payload": "負載",
"query": "查詢",
"raw_body": "原始請求本體",
"rename": "Rename Request",
"rename": "重新命名請求",
"renamed": "請求已重新命名",
"run": "執行",
"save": "儲存",
@@ -510,7 +510,7 @@
"accent_color": "強調色",
"account": "帳號",
"account_deleted": "已刪除您的帳號",
"account_description": "自定義您的帳號設定。",
"account_description": "自您的帳號設定。",
"account_email_description": "您的主要電子郵件地址。",
"account_name_description": "這是您的顯示名稱。",
"background": "背景",
@@ -542,7 +542,7 @@
"read_the": "閱讀",
"reset_default": "重置為預設",
"short_codes": "快捷碼",
"short_codes_description": "我們為您打造的快捷碼。",
"short_codes_description": "您建立的快捷碼。",
"sidebar_on_left": "左側邊欄",
"sync": "同步",
"sync_collections": "集合",
@@ -551,9 +551,9 @@
"sync_history": "歷史",
"system_mode": "系統",
"telemetry": "遙測服務",
"telemetry_helps_us": "遙測服務幫助我們進行個化操作,為您提供最佳體驗。",
"telemetry_helps_us": "遙測服務能夠幫助我們進行個化操作,為您提供最佳體驗。",
"theme": "主題",
"theme_description": "自定義您的應用程式主題。",
"theme_description": "自您的應用程式主題。",
"use_experimental_url_bar": "使用帶有環境醒目標示的實驗性網址欄",
"user": "使用者",
"verified_email": "已確認電子郵件地址",
@@ -592,26 +592,26 @@
"title": "導航"
},
"others": {
"prettify": "Prettify Editor's Content",
"title": "Others"
"prettify": "美化編輯器的內容",
"title": "其他"
},
"request": {
"copy_request_link": "複製請求連結",
"delete_method": "選擇 DELETE 方法",
"get_method": "選擇 GET 方法",
"head_method": "選擇 HEAD 方法",
"import_curl": "Import cURL",
"import_curl": "匯入 cURL",
"method": "方法",
"next_method": "選擇下一個方法",
"post_method": "選擇 POST 方法",
"previous_method": "選擇上一個方法",
"put_method": "選擇 PUT 方法",
"rename": "Rename Request",
"rename": "重新命名請求",
"reset_request": "重置請求",
"save_request": "Save Request",
"save_request": "儲存請求",
"save_to_collections": "儲存到集合",
"send_request": "傳送請求",
"show_code": "Generate code snippet",
"show_code": "產生程式碼片段",
"title": "請求"
},
"response": {
@@ -642,82 +642,82 @@
"url": "網址"
},
"spotlight": {
"change_language": "Change Language",
"change_language": "變更語言",
"environments": {
"delete": "Delete current environment",
"duplicate": "Duplicate current environment",
"duplicate_global": "Duplicate global environment",
"edit": "Edit current environment",
"edit_global": "Edit global environment",
"new": "Create new environment",
"new_variable": "Create a new environment variable",
"title": "Environments"
"delete": "刪除目前環境",
"duplicate": "複製目前環境",
"duplicate_global": "複製全域環境",
"edit": "編輯目前環境",
"edit_global": "編輯全域環境",
"new": "建立新環境",
"new_variable": "建立新環境變數",
"title": "環境"
},
"general": {
"chat": "Chat with support",
"help_menu": "Help and support",
"open_docs": "Read Documentation",
"open_github": "Open GitHub repository",
"open_keybindings": "Keyboard shortcuts",
"social": "Social",
"title": "General"
"chat": "與客服對話",
"help_menu": "幫助與支援",
"open_docs": "閱讀說明文件",
"open_github": "開啟 GitHub 儲存庫",
"open_keybindings": "鍵盤快捷鍵",
"social": "社交",
"title": "一般"
},
"graphql": {
"connect": "Connect to server",
"disconnect": "Disconnect from server"
"connect": "連接至伺服器",
"disconnect": "斷開與伺服器的連線"
},
"miscellaneous": {
"invite": "Invite your friends to Hoppscotch",
"title": "Miscellaneous"
"invite": "邀請您的朋友使用 Hoppscotch",
"title": "雜項"
},
"request": {
"save_as_new": "Save as new request",
"select_method": "Select method",
"switch_to": "Switch to",
"tab_authorization": "Authorization tab",
"tab_body": "Body tab",
"tab_headers": "Headers tab",
"tab_parameters": "Parameters tab",
"tab_pre_request_script": "Pre-request script tab",
"tab_query": "Query tab",
"tab_tests": "Tests tab",
"tab_variables": "Variables tab"
"save_as_new": "儲存為新請求",
"select_method": "選擇方法",
"switch_to": "切換至",
"tab_authorization": "授權分頁",
"tab_body": "本體分頁",
"tab_headers": "標頭分頁",
"tab_parameters": "參數分頁",
"tab_pre_request_script": "預請求腳本分頁",
"tab_query": "查詢分頁",
"tab_tests": "測試分頁",
"tab_variables": "變數分頁"
},
"response": {
"copy": "Copy response",
"download": "Download response as file",
"title": "Response"
"copy": "複製回應",
"download": "下載回應",
"title": "回應"
},
"section": {
"interceptor": "Interceptor",
"interface": "Interface",
"theme": "Theme",
"user": "User"
"interceptor": "攔截器",
"interface": "介面",
"theme": "主題",
"user": "使用者"
},
"settings": {
"change_interceptor": "Change Interceptor",
"change_language": "Change Language",
"change_interceptor": "變更攔截器",
"change_language": "變更語言",
"theme": {
"black": "Black",
"dark": "Dark",
"light": "Light",
"system": "System preference"
"black": "黑色",
"dark": "暗色",
"light": "亮色",
"system": "跟隨系統"
}
},
"tab": {
"close_current": "Close current tab",
"close_others": "Close all other tabs",
"duplicate": "Duplicate current tab",
"new_tab": "Open a new tab",
"title": "Tabs"
"close_current": "關閉目前分頁",
"close_others": "關閉所有其他分頁",
"duplicate": "複製目前分頁",
"new_tab": "開啟新分頁",
"title": "分頁"
},
"workspace": {
"delete": "Delete current team",
"edit": "Edit current team",
"invite": "Invite people to team",
"new": "Create new team",
"switch_to_personal": "Switch to your personal workspace",
"title": "Teams"
"delete": "刪除目前團隊",
"edit": "編輯目前團隊",
"invite": "邀請他人加入團隊",
"new": "建立新團隊",
"switch_to_personal": "切換至您的個人工作區",
"title": "團隊"
}
},
"sse": {
@@ -777,11 +777,11 @@
"tab": {
"authorization": "授權",
"body": "請求本體",
"close": "Close Tab",
"close_others": "Close other Tabs",
"close": "關閉分頁",
"close_others": "關閉其他分頁",
"collections": "集合",
"documentation": "幫助文件",
"duplicate": "Duplicate Tab",
"duplicate": "複製分頁",
"environments": "環境",
"headers": "請求標頭",
"history": "歷史記錄",

View File

@@ -1,7 +1,7 @@
{
"name": "@hoppscotch/common",
"private": true,
"version": "2023.8.1",
"version": "2023.8.2",
"scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*",
"test": "vitest --run",
@@ -22,17 +22,17 @@
},
"dependencies": {
"@apidevtools/swagger-parser": "^10.1.0",
"@codemirror/autocomplete": "^6.9.0",
"@codemirror/commands": "^6.2.4",
"@codemirror/lang-javascript": "^6.1.9",
"@codemirror/autocomplete": "^6.10.2",
"@codemirror/commands": "^6.3.0",
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.9.0",
"@codemirror/language": "^6.9.1",
"@codemirror/legacy-modes": "^6.3.3",
"@codemirror/lint": "^6.4.0",
"@codemirror/search": "^6.5.1",
"@codemirror/state": "^6.2.1",
"@codemirror/view": "^6.16.0",
"@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.5.4",
"@codemirror/state": "^6.3.1",
"@codemirror/view": "^6.21.3",
"@fontsource-variable/inter": "^5.0.8",
"@fontsource-variable/material-symbols-rounded": "^5.0.7",
"@fontsource-variable/roboto-mono": "^5.0.9",
@@ -42,8 +42,6 @@
"@hoppscotch/ui": "workspace:^",
"@hoppscotch/vue-toasted": "^0.1.0",
"@lezer/highlight": "^1.1.6",
"@sentry/tracing": "^7.64.0",
"@sentry/vue": "^7.64.0",
"@urql/core": "^4.1.1",
"@urql/devtools": "^2.0.3",
"@urql/exchange-auth": "^2.1.6",

View File

@@ -2,53 +2,16 @@
<AppShortcuts :show="showShortcuts" @close="showShortcuts = false" />
<AppShare :show="showShare" @hide-modal="showShare = false" />
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
<HoppSmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_team')"
@hide-modal="confirmRemove = false"
@resolve="deleteTeam()"
/>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { deleteTeam as backendDeleteTeam } from "~/helpers/backend/mutations/Team"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { useToast } from "~/composables/toast"
import { useI18n } from "~/composables/i18n"
const toast = useToast()
const t = useI18n()
import { defineActionHandler } from "~/helpers/actions"
const showShortcuts = ref(false)
const showShare = ref(false)
const showLogin = ref(false)
const confirmRemove = ref(false)
const teamID = ref<string | null>(null)
const deleteTeam = () => {
if (!teamID.value) return
pipe(
backendDeleteTeam(teamID.value),
TE.match(
(err) => {
// TODO: Better errors ? We know the possible errors now
toast.error(`${t("error.something_went_wrong")}`)
console.error(err)
},
() => {
invokeAction("workspace.switch.personal")
toast.success(`${t("team.deleted")}`)
}
)
)() // Tasks (and TEs) are lazy, so call the function returned
}
defineActionHandler("flyouts.keybinds.toggle", () => {
showShortcuts.value = !showShortcuts.value
})
@@ -60,9 +23,4 @@ defineActionHandler("modals.share.toggle", () => {
defineActionHandler("modals.login.toggle", () => {
showLogin.value = !showLogin.value
})
defineActionHandler("modals.team.delete", ({ teamId }) => {
teamID.value = teamId
confirmRemove.value = true
})
</script>

View File

@@ -231,29 +231,39 @@
@invite-team="inviteTeam(editingTeamName, editingTeamID)"
@refetch-teams="refetchTeams"
/>
<HoppSmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_team')"
@hide-modal="confirmRemove = false"
@resolve="deleteTeam"
/>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue"
import IconUser from "~icons/lucide/user"
import IconUsers from "~icons/lucide/users"
import IconSettings from "~icons/lucide/settings"
import IconDownload from "~icons/lucide/download"
import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUserPlus from "~icons/lucide/user-plus"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { defineActionHandler, invokeAction } from "@helpers/actions"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
import { useToast } from "~/composables/toast"
import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { installPWA, pwaDefferedPrompt } from "@modules/pwa"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { computed, reactive, ref, watch } from "vue"
import { useToast } from "~/composables/toast"
import { GetMyTeamsQuery, TeamMemberRole } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
import { platform } from "~/platform"
import IconDownload from "~icons/lucide/download"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSettings from "~icons/lucide/settings"
import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUser from "~icons/lucide/user"
import IconUserPlus from "~icons/lucide/user-plus"
import IconUsers from "~icons/lucide/users"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { deleteTeam as backendDeleteTeam } from "~/helpers/backend/mutations/Team"
const t = useI18n()
const toast = useToast()
@@ -278,6 +288,9 @@ const currentUser = useReadonlyStream(
platform.auth.getProbableUser()
)
const confirmRemove = ref(false)
const teamID = ref<string | null>(null)
const selectedTeam = ref<GetMyTeamsQuery["myTeams"][number] | undefined>()
// TeamList-Adapter
@@ -377,6 +390,24 @@ const handleTeamEdit = () => {
}
}
const deleteTeam = () => {
if (!teamID.value) return
pipe(
backendDeleteTeam(teamID.value),
TE.match(
(err) => {
// TODO: Better errors ? We know the possible errors now
toast.error(`${t("error.something_went_wrong")}`)
console.error(err)
},
() => {
invokeAction("workspace.switch.personal")
toast.success(`${t("team.deleted")}`)
}
)
)() // Tasks (and TEs) are lazy, so call the function returned
}
// Template refs
const tippyActions = ref<any | null>(null)
const profile = ref<any | null>(null)
@@ -405,6 +436,12 @@ defineActionHandler(
computed(() => !currentUser.value)
)
defineActionHandler("modals.team.delete", ({ teamId }) => {
if (selectedTeam.value?.myRole !== TeamMemberRole.Owner) return noPermission()
teamID.value = teamId
confirmRemove.value = true
})
const noPermission = () => {
toast.error(`${t("profile.no_permission")}`)
}

View File

@@ -37,7 +37,8 @@
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { currentActiveTab } from "~/helpers/rest/tab"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
const toast = useToast()
const t = useI18n()
@@ -60,11 +61,12 @@ const emit = defineEmits<{
const editingName = ref("")
const tabs = useService(RESTTabService)
watch(
() => props.show,
(show) => {
if (show) {
editingName.value = currentActiveTab.value.document.request.name
editingName.value = tabs.currentActiveTab.value.document.request.name
}
}
)

View File

@@ -25,7 +25,7 @@
<HoppButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconArchive"
:icon="IconImport"
:title="t('modal.import_export')"
@click="emit('display-modal-import-export')"
/>
@@ -257,12 +257,27 @@
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
/>
<div class="flex flex-col items-center space-y-4">
<span class="text-secondaryLight text-center">
{{ t("collection.import_or_create") }}
</span>
<div class="flex gap-4 flex-col items-stretch">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
@click="emit('display-modal-import-export')"
/>
<HoppButtonSecondary
:icon="IconPlus"
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
/>
</div>
</div>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'collections'"
@@ -288,8 +303,7 @@
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.folder')}`"
:text="t('empty.folder')"
>
</HoppSmartPlaceholder>
/>
</template>
</HoppSmartTree>
</div>
@@ -297,9 +311,9 @@
</template>
<script setup lang="ts">
import IconArchive from "~icons/lucide/archive"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconImport from "~icons/lucide/folder-down"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { computed, PropType, Ref, toRef } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
@@ -312,7 +326,8 @@ import { useColorMode } from "@composables/theming"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import { Picked } from "~/helpers/types/HoppPicked.js"
import { currentActiveTab } from "~/helpers/rest/tab"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
export type Collection = {
type: "collections"
@@ -520,7 +535,8 @@ const isSelected = ({
}
}
const active = computed(() => currentActiveTab.value.document.saveContext)
const tabs = useService(RESTTabService)
const active = computed(() => tabs.currentActiveTab.value.document.saveContext)
const isActiveRequest = (folderPath: string, requestIndex: number) => {
return pipe(

View File

@@ -82,12 +82,16 @@ import {
import { GQLError } from "~/helpers/backend/GQLClient"
import { computedWithControl } from "@vueuse/core"
import { platform } from "~/platform"
import { currentActiveTab as activeRESTTab } from "~/helpers/rest/tab"
import { currentActiveTab as activeGQLTab } from "~/helpers/graphql/tab"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
import { GQLTabService } from "~/services/tab/graphql"
const t = useI18n()
const toast = useToast()
const RESTTabs = useService(RESTTabService)
const GQLTabs = useService(GQLTabService)
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
@@ -123,13 +127,13 @@ const emit = defineEmits<{
}>()
const gqlRequestName = computedWithControl(
() => activeGQLTab.value,
() => activeGQLTab.value.document.request.name
() => GQLTabs.currentActiveTab.value,
() => GQLTabs.currentActiveTab.value.document.request.name
)
const restRequestName = computedWithControl(
() => activeRESTTab.value,
() => activeRESTTab.value.document.request.name
() => RESTTabs.currentActiveTab.value,
() => RESTTabs.currentActiveTab.value.document.request.name
)
const reqName = computed(() => {
@@ -145,12 +149,14 @@ const reqName = computed(() => {
const requestName = ref(reqName.value)
watch(
() => [activeRESTTab.value, activeGQLTab.value],
() => [RESTTabs.currentActiveTab.value, GQLTabs.currentActiveTab.value],
() => {
if (props.mode === "rest") {
requestName.value = activeRESTTab.value?.document.request.name ?? ""
requestName.value =
RESTTabs.currentActiveTab.value?.document.request.name ?? ""
} else {
requestName.value = activeGQLTab.value?.document.request.name ?? ""
requestName.value =
GQLTabs.currentActiveTab.value?.document.request.name ?? ""
}
}
)
@@ -210,8 +216,8 @@ const saveRequestAs = async () => {
const requestUpdated =
props.mode === "rest"
? cloneDeep(activeRESTTab.value.document.request)
: cloneDeep(activeGQLTab.value.document.request)
? cloneDeep(RESTTabs.currentActiveTab.value.document.request)
: cloneDeep(GQLTabs.currentActiveTab.value.document.request)
requestUpdated.name = requestName.value
@@ -224,7 +230,7 @@ const saveRequestAs = async () => {
requestUpdated
)
activeRESTTab.value.document = {
RESTTabs.currentActiveTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
@@ -251,7 +257,7 @@ const saveRequestAs = async () => {
requestUpdated
)
activeRESTTab.value.document = {
RESTTabs.currentActiveTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
@@ -279,7 +285,7 @@ const saveRequestAs = async () => {
requestUpdated
)
activeRESTTab.value.document = {
RESTTabs.currentActiveTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
@@ -439,7 +445,7 @@ const updateTeamCollectionOrFolder = (
(result) => {
const { createRequestInCollection } = result
activeRESTTab.value.document = {
RESTTabs.currentActiveTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
@@ -460,7 +466,7 @@ const updateTeamCollectionOrFolder = (
const requestSaved = () => {
toast.success(`${t("request.added")}`)
nextTick(() => {
activeRESTTab.value.document.isDirty = false
RESTTabs.currentActiveTab.value.document.isDirty = false
})
hideModal()
}

View File

@@ -15,12 +15,12 @@
class="!rounded-none"
:icon="IconPlus"
:title="t('team.no_access')"
:label="t('action.new')"
:label="t('add.new')"
/>
<HoppButtonSecondary
v-else
:icon="IconPlus"
:label="t('action.new')"
:label="t('add.new')"
class="!rounded-none"
@click="emit('display-modal-add')"
/>
@@ -39,7 +39,7 @@
collectionsType.type === 'team-collections' &&
collectionsType.selectedTeam === undefined
"
:icon="IconArchive"
:icon="IconImport"
:title="t('modal.import_export')"
@click="emit('display-modal-import-export')"
/>
@@ -261,55 +261,68 @@
/>
</template>
<template #emptyNode="{ node }">
<div v-if="node === null">
<div @drop="(e) => e.stopPropagation()">
<HoppSmartPlaceholder
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
<HoppButtonSecondary
v-if="hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
disabled
<HoppSmartPlaceholder
v-if="node === null"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
@drop.stop
>
<div class="flex flex-col items-center space-y-4">
<span class="text-secondaryLight text-center">
{{ t("collection.import_or_create") }}
</span>
<div class="flex gap-4 flex-col items-stretch">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
:title="t('team.no_access')"
:label="t('action.new')"
:disabled="hasNoTeamAccess"
:title="hasNoTeamAccess ? t('team.no_access') : ''"
@click="
hasNoTeamAccess ? null : emit('display-modal-import-export')
"
/>
<HoppButtonSecondary
v-else
:icon="IconPlus"
:label="t('action.new')"
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
:disabled="hasNoTeamAccess"
:title="hasNoTeamAccess ? t('team.no_access') : ''"
@click="hasNoTeamAccess ? null : emit('display-modal-add')"
/>
</HoppSmartPlaceholder>
</div>
</div>
</div>
<div
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'collections'"
@drop="(e) => e.stopPropagation()"
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
@drop.stop
>
<HoppSmartPlaceholder
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
</HoppSmartPlaceholder>
</div>
<div
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-else-if="node.data.type === 'folders'"
@drop="(e) => e.stopPropagation()"
>
<HoppSmartPlaceholder
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.folder')}`"
:text="t('empty.folder')"
>
</HoppSmartPlaceholder>
</div>
:src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.folder')}`"
:text="t('empty.folder')"
@drop.stop
/>
</template>
</HoppSmartTree>
</div>
@@ -317,9 +330,9 @@
</template>
<script setup lang="ts">
import IconArchive from "~icons/lucide/archive"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconImport from "~icons/lucide/folder-down"
import { computed, PropType, Ref, toRef } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { useI18n } from "@composables/i18n"
@@ -335,10 +348,12 @@ import { HoppRESTRequest } from "@hoppscotch/data"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import { Picked } from "~/helpers/types/HoppPicked.js"
import { currentActiveTab } from "~/helpers/rest/tab"
import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
const t = useI18n()
const colorMode = useColorMode()
const tabs = useService(RESTTabService)
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
@@ -536,7 +551,7 @@ const isSelected = ({
}
}
const active = computed(() => currentActiveTab.value.document.saveContext)
const active = computed(() => tabs.currentActiveTab.value.document.saveContext)
const isActiveRequest = (requestID: string) => {
return pipe(

View File

@@ -36,11 +36,14 @@
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { currentActiveTab } from "~/helpers/graphql/tab"
import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
const toast = useToast()
const t = useI18n()
const tabs = useService(GQLTabService)
const props = defineProps<{
show: boolean
folderPath?: string
@@ -63,7 +66,7 @@ watch(
() => props.show,
(show) => {
if (show) {
editingName.value = currentActiveTab.value?.document.request.name
editingName.value = tabs.currentActiveTab.value?.document.request.name
}
}
)

View File

@@ -220,7 +220,8 @@ import {
moveGraphqlRequest,
} from "~/newstore/collections"
import { Picked } from "~/helpers/types/HoppPicked"
import { getTabsRefTo } from "~/helpers/graphql/tab"
import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
const props = defineProps({
picked: { type: Object, default: null },
@@ -235,6 +236,8 @@ const colorMode = useColorMode()
const toast = useToast()
const t = useI18n()
const tabs = useService(GQLTabService)
// TODO: improve types plz
const emit = defineEmits<{
(e: "select", i: Picked | null): void
@@ -295,7 +298,7 @@ const removeCollection = () => {
emit("select", null)
}
const possibleTabs = getTabsRefTo((tab) => {
const possibleTabs = tabs.getTabsRefTo((tab) => {
const ctx = tab.document.saveContext
if (!ctx) return false

View File

@@ -203,12 +203,15 @@ import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { removeGraphqlFolder, moveGraphqlRequest } from "~/newstore/collections"
import { computed, ref } from "vue"
import { getTabsRefTo } from "~/helpers/graphql/tab"
import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
const toast = useToast()
const t = useI18n()
const colorMode = useColorMode()
const tabs = useService(GQLTabService)
const props = defineProps({
picked: { type: Object, default: null },
// Whether the request is in a selectable mode (activates 'select' event)
@@ -277,7 +280,7 @@ const removeFolder = () => {
emit("select", { picked: null })
}
const possibleTabs = getTabsRefTo((tab) => {
const possibleTabs = tabs.getTabsRefTo((tab) => {
const ctx = tab.document.saveContext
if (!ctx) return false

View File

@@ -260,6 +260,13 @@ const importFromJSON = () => {
const exportJSON = () => {
const dataToWrite = collectionJson.value
const parsedCollections = JSON.parse(dataToWrite)
if (!parsedCollections.length) {
return toast.error(t("error.no_collections_to_export"))
}
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)

View File

@@ -137,12 +137,8 @@ import { useToast } from "@composables/toast"
import { HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import { removeGraphqlRequest } from "~/newstore/collections"
import {
createNewTab,
getTabRefWithSaveContext,
currentTabID,
currentActiveTab,
} from "~/helpers/graphql/tab"
import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
// Template refs
const tippyActions = ref<any | null>(null)
@@ -154,6 +150,8 @@ const deleteAction = ref<any | null>(null)
const t = useI18n()
const toast = useToast()
const tabs = useService(GQLTabService)
const props = defineProps({
// Whether the object is selected (show the tick mark)
picked: { type: Object, default: null },
@@ -165,7 +163,7 @@ const props = defineProps({
})
const isActive = computed(() => {
const saveCtx = currentActiveTab.value?.document.saveContext
const saveCtx = tabs.currentActiveTab.value?.document.saveContext
if (!saveCtx) return false
@@ -201,7 +199,7 @@ const selectRequest = () => {
if (props.saveRequest) {
pick()
} else {
const possibleTab = getTabRefWithSaveContext({
const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
@@ -209,11 +207,11 @@ const selectRequest = () => {
// Switch to that request if that request is open
if (possibleTab) {
currentTabID.value = possibleTab.value.id
tabs.setActiveTab(possibleTab.value.id)
return
}
createNewTab({
tabs.createNewTab({
saveContext: {
originLocation: "user-collection",
folderPath: props.folderPath,
@@ -253,7 +251,7 @@ const removeRequest = () => {
}
// Detach the request from any of the tabs
const possibleTab = getTabRefWithSaveContext({
const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection",
folderPath: props.folderPath,
requestIndex: props.requestIndex,

View File

@@ -34,7 +34,7 @@
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:title="t('modal.import_export')"
:icon="IconArchive"
:icon="IconImport"
@click="displayModalImportExport(true)"
/>
</div>
@@ -66,12 +66,27 @@
:alt="`${t('empty.collections')}`"
:text="t('empty.collections')"
>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
@click="displayModalAdd(true)"
/>
<div class="flex flex-col items-center space-y-4">
<span class="text-secondaryLight text-center">
{{ t("collection.import_or_create") }}
</span>
<div class="flex gap-4 flex-col items-stretch">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
@click="displayModalImportExport(true)"
/>
<HoppButtonSecondary
:label="t('add.new')"
filled
outline
:icon="IconPlus"
@click="displayModalAdd(true)"
/>
</div>
</div>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder
v-if="!(filteredCollections.length !== 0 || collections.length === 0)"
@@ -140,12 +155,13 @@ import {
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconArchive from "~icons/lucide/archive"
import IconImport from "~icons/lucide/folder-down"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming"
import { platform } from "~/platform"
import { createNewTab, currentActiveTab } from "~/helpers/graphql/tab"
import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
export default defineComponent({
props: {
@@ -158,14 +174,16 @@ export default defineComponent({
const collections = useReadonlyStream(graphqlCollections$, [], "deep")
const colorMode = useColorMode()
const t = useI18n()
const tabs = useService(GQLTabService)
return {
collections,
colorMode,
t,
tabs,
IconPlus,
IconHelpCircle,
IconArchive,
IconImport,
}
},
data() {
@@ -267,13 +285,13 @@ export default defineComponent({
},
onAddRequest({ name, path, index }) {
const newRequest = {
...currentActiveTab.value.document.request,
...this.tabs.currentActiveTab.value.document.request,
name,
}
saveGraphqlRequestAs(path, newRequest)
createNewTab({
this.tabs.createNewTab({
saveContext: {
originLocation: "user-collection",
folderPath: path,

View File

@@ -219,12 +219,6 @@ import {
import * as E from "fp-ts/Either"
import { platform } from "~/platform"
import { createCollectionGists } from "~/helpers/gist"
import {
createNewTab,
currentActiveTab,
currentTabID,
getTabRefWithSaveContext,
} from "~/helpers/rest/tab"
import {
getRequestsByPath,
resolveSaveContextOnRequestReorder,
@@ -239,9 +233,11 @@ import { currentReorderingStatus$ } from "~/newstore/reordering"
import { defineActionHandler } from "~/helpers/actions"
import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
const t = useI18n()
const toast = useToast()
const tabs = useService(RESTTabService)
const props = defineProps({
saveRequest: {
@@ -377,22 +373,26 @@ const updateSelectedTeam = (team: SelectedTeam) => {
const workspace = workspaceService.currentWorkspace
// Used to switch collection type and team when user switch workspace in the global workspace switcher
// Check if there is a teamID in the workspace, if yes, switch to team collection and select the team
// If there is no teamID, switch to my environment
// Check if there is a teamID in the workspace, if yes, switch to team collections and select the team
// If there is no teamID, switch to my collections
watch(
() => {
const space = workspace.value
if (space.type === "personal") return undefined
else return space.teamID
return space.type === "personal" ? undefined : space.teamID
},
(teamID) => {
if (!teamID) {
switchToMyCollections()
} else if (teamID) {
if (teamID) {
const team = myTeams.value?.find((t) => t.id === teamID)
if (team) updateSelectedTeam(team)
if (team) {
updateSelectedTeam(team)
}
return
}
return switchToMyCollections()
},
{
immediate: true,
}
)
@@ -650,7 +650,7 @@ const addRequest = (payload: {
const onAddRequest = (requestName: string) => {
const newRequest = {
...cloneDeep(currentActiveTab.value.document.request),
...cloneDeep(tabs.currentActiveTab.value.document.request),
name: requestName,
}
@@ -659,7 +659,7 @@ const onAddRequest = (requestName: string) => {
if (!path) return
const insertionIndex = saveRESTRequestAs(path, newRequest)
createNewTab({
tabs.createNewTab({
request: newRequest,
isDirty: false,
saveContext: {
@@ -708,7 +708,7 @@ const onAddRequest = (requestName: string) => {
(result) => {
const { createRequestInCollection } = result
createNewTab({
tabs.createNewTab({
request: newRequest,
isDirty: false,
saveContext: {
@@ -931,7 +931,7 @@ const updateEditingRequest = (newName: string) => {
if (folderPath === null || requestIndex === null) return
const possibleActiveTab = getTabRefWithSaveContext({
const possibleActiveTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection",
requestIndex,
folderPath,
@@ -975,7 +975,7 @@ const updateEditingRequest = (newName: string) => {
)
)()
const possibleTab = getTabRefWithSaveContext({
const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "team-collection",
requestID,
})
@@ -1211,7 +1211,7 @@ const onRemoveRequest = () => {
emit("select", null)
}
const possibleTab = getTabRefWithSaveContext({
const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection",
folderPath,
requestIndex,
@@ -1271,7 +1271,7 @@ const onRemoveRequest = () => {
)()
// If there is a tab attached to this request, dissociate its state and mark it dirty
const possibleTab = getTabRefWithSaveContext({
const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "team-collection",
requestID,
})
@@ -1304,14 +1304,14 @@ const selectRequest = (selectedRequest: {
let possibleTab = null
if (collectionsType.value.type === "team-collections") {
possibleTab = getTabRefWithSaveContext({
possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "team-collection",
requestID: requestIndex,
})
if (possibleTab) {
currentTabID.value = possibleTab.value.id
tabs.setActiveTab(possibleTab.value.id)
} else {
createNewTab({
tabs.createNewTab({
request: cloneDeep(request),
isDirty: false,
saveContext: {
@@ -1321,16 +1321,16 @@ const selectRequest = (selectedRequest: {
})
}
} else {
possibleTab = getTabRefWithSaveContext({
possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection",
requestIndex: parseInt(requestIndex),
folderPath: folderPath!,
})
if (possibleTab) {
currentTabID.value = possibleTab.value.id
tabs.setActiveTab(possibleTab.value.id)
} else {
// If not, open the request in a new tab
createNewTab({
tabs.createNewTab({
request: cloneDeep(request),
isDirty: false,
saveContext: {
@@ -1373,7 +1373,7 @@ const dropRequest = (payload: {
destinationCollectionIndex
)
const possibleTab = getTabRefWithSaveContext({
const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection",
folderPath,
requestIndex: pathToLastIndex(requestIndex),
@@ -1422,7 +1422,7 @@ const dropRequest = (payload: {
1
)
const possibleTab = getTabRefWithSaveContext({
const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "team-collection",
requestID: requestIndex,
})
@@ -1938,6 +1938,12 @@ const exportJSONCollection = async () => {
await getJSONCollection()
const parsedCollections = JSON.parse(collectionJSON.value)
if (!parsedCollections.length) {
return toast.error(t("error.no_collections_to_export"))
}
initializeDownloadCollection(collectionJSON.value, null)
}

View File

@@ -83,11 +83,14 @@ import {
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { updateTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
import { currentActiveTab } from "~/helpers/rest/tab"
import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
const t = useI18n()
const toast = useToast()
const tabs = useService(RESTTabService)
const props = defineProps<{
show: boolean
position: { top: number; left: number }
@@ -189,8 +192,8 @@ const addEnvironment = async () => {
//replace the current tab endpoint with the variable name with << and >>
const variableName = `<<${editingName.value}>>`
//replace the currenttab endpoint containing the value in the text with variablename
currentActiveTab.value.document.request.endpoint =
currentActiveTab.value.document.request.endpoint.replace(
tabs.currentActiveTab.value.document.request.endpoint =
tabs.currentActiveTab.value.document.request.endpoint.replace(
editingValue.value,
variableName
)

View File

@@ -377,6 +377,13 @@ const importFromPostman = ({
const exportJSON = () => {
const dataToWrite = environmentJson.value
const parsedCollections = JSON.parse(dataToWrite)
if (!parsedCollections.length) {
return toast.error(t("error.no_environments_to_export"))
}
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)

View File

@@ -478,7 +478,8 @@ watch(
teamEnvListAdapter.changeTeamID(newVal.teamID)
}
}
}
},
{ immediate: true }
)
const selectedEnv = computed(() => {

View File

@@ -46,6 +46,7 @@
role="menu"
@keyup.e="edit!.$el.click()"
@keyup.d="duplicate!.$el.click()"
@keyup.j="exportAsJsonEl!.$el.click()"
@keyup.delete="
!(environmentIndex === 'Global')
? deleteAction!.$el.click()
@@ -77,6 +78,18 @@
}
"
/>
<HoppSmartItem
ref="exportAsJsonEl"
:icon="IconEdit"
:label="`${t('export.as_json')}`"
:shortcut="['J']"
@click="
() => {
exportEnvironmentAsJSON()
hide()
}
"
/>
<HoppSmartItem
v-if="environmentIndex !== 'Global'"
ref="deleteAction"
@@ -121,6 +134,7 @@ import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { TippyComponent } from "vue-tippy"
import { HoppSmartItem } from "@hoppscotch/ui"
import { exportAsJSON } from "~/helpers/import-export/export/environment"
const t = useI18n()
const toast = useToast()
@@ -136,10 +150,18 @@ const emit = defineEmits<{
const confirmRemove = ref(false)
const exportEnvironmentAsJSON = () => {
const { environment, environmentIndex } = props
exportAsJSON(environment, environmentIndex)
? toast.success(t("state.download_started"))
: toast.error(t("state.download_failed"))
}
const tippyActions = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const edit = ref<typeof HoppSmartItem>()
const duplicate = ref<typeof HoppSmartItem>()
const exportAsJsonEl = ref<typeof HoppSmartItem>()
const deleteAction = ref<typeof HoppSmartItem>()
const removeEnvironment = () => {

View File

@@ -19,7 +19,7 @@
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconArchive"
:icon="IconImport"
:title="t('modal.import_export')"
@click="displayModalImportExport(true)"
/>
@@ -33,17 +33,32 @@
@edit-environment="editEnvironment(index)"
/>
<HoppSmartPlaceholder
v-if="environments.length === 0"
v-if="!environments.length"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
outline
@click="displayModalAdd(true)"
/>
<div class="flex flex-col items-center space-y-4">
<span class="text-secondaryLight text-center">
{{ t("environment.import_or_create") }}
</span>
<div class="flex gap-4 flex-col items-stretch">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
@click="displayModalImportExport(true)"
/>
<HoppButtonSecondary
:icon="IconPlus"
:label="`${t('add.new')}`"
filled
outline
@click="displayModalAdd(true)"
/>
</div>
</div>
</HoppSmartPlaceholder>
<EnvironmentsMyDetails
:show="showModalDetails"
@@ -66,8 +81,8 @@ import { environments$ } from "~/newstore/environments"
import { useColorMode } from "~/composables/theming"
import { useReadonlyStream } from "@composables/stream"
import { useI18n } from "~/composables/i18n"
import IconArchive from "~icons/lucide/archive"
import IconPlus from "~icons/lucide/plus"
import IconImport from "~icons/lucide/folder-down"
import IconHelpCircle from "~icons/lucide/help-circle"
import { Environment } from "@hoppscotch/data"
import { defineActionHandler } from "~/helpers/actions"

View File

@@ -39,6 +39,7 @@
role="menu"
@keyup.e="edit!.$el.click()"
@keyup.d="duplicate!.$el.click()"
@keyup.j="exportAsJsonEl!.$el.click()"
@keyup.delete="deleteAction!.$el.click()"
@keyup.escape="options!.tippy().hide()"
>
@@ -54,6 +55,7 @@
}
"
/>
<HoppSmartItem
ref="duplicate"
:icon="IconCopy"
@@ -66,6 +68,18 @@
}
"
/>
<HoppSmartItem
ref="exportAsJsonEl"
:icon="IconEdit"
:label="`${t('export.as_json')}`"
:shortcut="['J']"
@click="
() => {
exportEnvironmentAsJSON()
hide()
}
"
/>
<HoppSmartItem
ref="deleteAction"
:icon="IconTrash2"
@@ -109,6 +123,7 @@ import IconTrash2 from "~icons/lucide/trash-2"
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"
const t = useI18n()
const toast = useToast()
@@ -124,11 +139,17 @@ const emit = defineEmits<{
const confirmRemove = ref(false)
const exportEnvironmentAsJSON = () =>
exportAsJSON(props.environment)
? toast.success(t("state.download_started"))
: toast.error(t("state.download_failed"))
const tippyActions = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const edit = ref<typeof HoppSmartItem>()
const duplicate = ref<typeof HoppSmartItem>()
const deleteAction = ref<typeof HoppSmartItem>()
const exportAsJsonEl = ref<typeof HoppSmartItem>()
const removeEnvironment = () => {
pipe(

View File

@@ -31,40 +31,49 @@
v-if="team !== undefined && team.myRole === 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
disabled
:icon="IconArchive"
:icon="IconImport"
:title="t('modal.import_export')"
/>
<HoppButtonSecondary
v-else
v-tippy="{ theme: 'tooltip' }"
:icon="IconArchive"
:icon="IconImport"
:title="t('modal.import_export')"
@click="displayModalImportExport(true)"
/>
</div>
</div>
<HoppSmartPlaceholder
v-if="!loading && teamEnvironments.length === 0 && !adapterError"
v-if="!loading && !teamEnvironments.length && !adapterError"
:src="`/images/states/${colorMode.value}/blockchain.svg`"
:alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
>
<HoppButtonSecondary
v-if="team === undefined || team.myRole === 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
disabled
filled
:icon="IconPlus"
:title="t('team.no_access')"
:label="t('action.new')"
/>
<HoppButtonSecondary
v-else
:label="`${t('add.new')}`"
filled
outline
@click="displayModalAdd(true)"
/>
<div class="flex flex-col items-center space-y-4">
<span class="text-secondaryLight text-center">
{{ t("environment.import_or_create") }}
</span>
<div class="flex gap-4 flex-col items-stretch">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
:title="isTeamViewer ? t('team.no_access') : ''"
:disabled="isTeamViewer"
@click="isTeamViewer ? null : displayModalImportExport(true)"
/>
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
outline
:icon="IconPlus"
:title="isTeamViewer ? t('team.no_access') : ''"
:disabled="isTeamViewer"
@click="isTeamViewer ? null : displayModalAdd(true)"
/>
</div>
</div>
</HoppSmartPlaceholder>
<div v-else-if="!loading">
<EnvironmentsTeamsEnvironment
@@ -108,14 +117,14 @@
</template>
<script setup lang="ts">
import { ref } from "vue"
import { computed, ref } from "vue"
import { GQLError } from "~/helpers/backend/GQLClient"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { useI18n } from "~/composables/i18n"
import { useColorMode } from "~/composables/theming"
import IconPlus from "~icons/lucide/plus"
import IconArchive from "~icons/lucide/archive"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconImport from "~icons/lucide/folder-down"
import { defineActionHandler } from "~/helpers/actions"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
@@ -138,6 +147,8 @@ const action = ref<"new" | "edit">("edit")
const editingEnvironment = ref<TeamEnvironment | null>(null)
const editingVariableName = ref("")
const isTeamViewer = computed(() => props.team?.myRole === "VIEWER")
const displayModalAdd = (shouldDisplay: boolean) => {
action.value = "new"
showModalDetails.value = shouldDisplay

View File

@@ -64,7 +64,6 @@
<script setup lang="ts">
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { currentActiveTab } from "~/helpers/graphql/tab"
import { computed, ref, watch } from "vue"
import { connection } from "~/helpers/graphql/connection"
import { connect } from "~/helpers/graphql/connection"
@@ -72,8 +71,10 @@ import { disconnect } from "~/helpers/graphql/connection"
import { InterceptorService } from "~/services/interceptor.service"
import { useService } from "dioc/vue"
import { defineActionHandler } from "~/helpers/actions"
import { GQLTabService } from "~/services/tab/graphql"
const t = useI18n()
const tabs = useService(GQLTabService)
const interceptorService = useService(InterceptorService)
@@ -82,9 +83,9 @@ const connectionSwitchModal = ref(false)
const connected = computed(() => connection.state === "CONNECTED")
const url = computed({
get: () => currentActiveTab.value?.document.request.url ?? "",
get: () => tabs.currentActiveTab.value?.document.request.url ?? "",
set: (value) => {
currentActiveTab.value!.document.request.url = value
tabs.currentActiveTab.value!.document.request.url = value
},
})
@@ -97,7 +98,7 @@ const onConnectClick = () => {
}
const gqlConnect = () => {
connect(url.value, currentActiveTab.value?.document.request.headers)
connect(url.value, tabs.currentActiveTab.value?.document.request.headers)
platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
@@ -114,7 +115,7 @@ const switchConnection = () => {
const lastTwoUrls = ref<string[]>([])
watch(
currentActiveTab,
tabs.currentActiveTab,
(newVal) => {
if (newVal) {
lastTwoUrls.value.push(newVal.document.request.url)

View File

@@ -58,8 +58,7 @@ import { computed, ref, watch } from "vue"
import { defineActionHandler } from "~/helpers/actions"
import { HoppGQLRequest } from "@hoppscotch/data"
import { platform } from "~/platform"
import { currentActiveTab } from "~/helpers/graphql/tab"
import { computedWithControl } from "@vueuse/core"
import { computedWithControl, useVModel } from "@vueuse/core"
import {
GQLResponseEvent,
runGQLOperation,
@@ -68,26 +67,39 @@ import {
import { useService } from "dioc/vue"
import { InterceptorService } from "~/services/interceptor.service"
import { editGraphqlRequest } from "~/newstore/collections"
import { GQLTabService } from "~/services/tab/graphql"
const VALID_GQL_OPERATIONS = [
"query",
"headers",
"variables",
"authorization",
] as const
export type GQLOptionTabs = (typeof VALID_GQL_OPERATIONS)[number]
export type GQLOptionTabs = "query" | "headers" | "variables" | "authorization"
const selectedOptionTab = ref<GQLOptionTabs>("query")
const interceptorService = useService(InterceptorService)
const t = useI18n()
const toast = useToast()
const tabs = useService(GQLTabService)
// v-model integration with props and emit
const props = withDefaults(
defineProps<{
modelValue: HoppGQLRequest
response?: GQLResponseEvent[] | null
optionTab?: GQLOptionTabs
tabId: string
}>(),
{
response: null,
optionTab: "query",
}
)
const emit = defineEmits(["update:modelValue", "update:response"])
const selectedOptionTab = useVModel(props, "optionTab", emit)
const request = ref(props.modelValue)
@@ -100,8 +112,8 @@ watch(
)
const url = computedWithControl(
() => currentActiveTab.value,
() => currentActiveTab.value.document.request.url
() => tabs.currentActiveTab.value,
() => tabs.currentActiveTab.value.document.request.url
)
const activeGQLHeadersCount = computed(
@@ -136,6 +148,9 @@ const runQuery = async (
const duration = Date.now() - startTime
completePageProgress()
toast.success(`${t("state.finished_in", { duration })}`)
if (definition?.operation === "subscription" && request.value.auth) {
toast.success(t("authorization.graphql_headers"))
}
} catch (e: any) {
console.log(e)
// response.value = [`${e}`]
@@ -182,17 +197,17 @@ const hideRequestModal = () => {
}
const saveRequest = () => {
if (
currentActiveTab.value.document.saveContext &&
currentActiveTab.value.document.saveContext.originLocation ===
tabs.currentActiveTab.value.document.saveContext &&
tabs.currentActiveTab.value.document.saveContext.originLocation ===
"user-collection"
) {
editGraphqlRequest(
currentActiveTab.value.document.saveContext.folderPath,
currentActiveTab.value.document.saveContext.requestIndex,
currentActiveTab.value.document.request
tabs.currentActiveTab.value.document.saveContext.folderPath,
tabs.currentActiveTab.value.document.saveContext.requestIndex,
tabs.currentActiveTab.value.document.request
)
currentActiveTab.value.document.isDirty = false
tabs.currentActiveTab.value.document.isDirty = false
} else {
showSaveRequestModal.value = true
}

View File

@@ -3,12 +3,13 @@
<template #primary>
<GraphqlRequestOptions
v-model="tab.document.request"
v-model:response="tab.response"
v-model:response="tab.document.response"
v-model:option-tab="tab.document.optionTabPreference"
:tab-id="tab.id"
/>
</template>
<template #secondary>
<GraphqlResponse :response="tab.response" />
<GraphqlResponse :response="tab.document.response" />
</template>
</AppPaneLayout>
</template>
@@ -18,14 +19,15 @@ import { useVModel } from "@vueuse/core"
import { cloneDeep } from "lodash-es"
import { watch } from "vue"
import { isEqualHoppGQLRequest } from "~/helpers/graphql"
import { HoppGQLTab } from "~/helpers/graphql/tab"
import { HoppGQLDocument } from "~/helpers/graphql/document"
import { HoppTab } from "~/services/tab"
// TODO: Move Response and Request execution code to over here
const props = defineProps<{ modelValue: HoppGQLTab }>()
const props = defineProps<{ modelValue: HoppTab<HoppGQLDocument> }>()
const emit = defineEmits<{
(e: "update:modelValue", val: HoppGQLTab): void
(e: "update:modelValue", val: HoppTab<HoppGQLDocument>): void
}>()
const tab = useVModel(props, "modelValue", emit)

View File

@@ -92,12 +92,13 @@ 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 { HoppGQLTab } from "~/helpers/graphql/tab"
import { HoppTab } from "~/services/tab"
import { HoppGQLDocument } from "~/helpers/graphql/document"
const t = useI18n()
defineProps<{
tab: HoppGQLTab
tab: HoppTab<HoppGQLDocument>
isRemovable: boolean
}>()

View File

@@ -67,9 +67,11 @@ import IconMaximize2 from "~icons/lucide/maximize-2"
import { useI18n } from "@composables/i18n"
import { makeGQLRequest } from "@hoppscotch/data"
import { createNewTab } from "~/helpers/graphql/tab"
import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
const t = useI18n()
const tabs = useService(GQLTabService)
const props = defineProps<{
entry: GQLHistoryEntry
@@ -93,7 +95,7 @@ const query = computed(() =>
)
const useEntry = () => {
createNewTab({
tabs.createNewTab({
request: makeGQLRequest({
name: props.entry.request.name,
url: props.entry.request.url,

View File

@@ -176,8 +176,9 @@ import {
import HistoryRestCard from "./rest/Card.vue"
import HistoryGraphqlCard from "./graphql/Card.vue"
import { createNewTab } from "~/helpers/rest/tab"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
@@ -293,8 +294,9 @@ const clearHistory = () => {
// NOTE: For GQL, the HistoryGraphqlCard component already implements useEntry
// (That is not a really good behaviour tho ¯\_(ツ)_/¯)
const tabs = useService(RESTTabService)
const useHistory = (entry: RESTHistoryEntry) => {
createNewTab({
tabs.createNewTab({
request: entry.request,
isDirty: false,
})

View File

@@ -59,7 +59,9 @@
:key="`contentTypeItem-${contentTypeIndex}`"
:label="contentTypeItem"
:info-icon="
contentTypeItem === body.contentType ? IconDone : null
contentTypeItem === body.contentType
? IconDone
: undefined
"
:active-info-icon="contentTypeItem === body.contentType"
@click="
@@ -136,7 +138,7 @@ import IconDone from "~icons/lucide/check"
import IconExternalLink from "~icons/lucide/external-link"
import IconInfo from "~icons/lucide/info"
import IconRefreshCW from "~icons/lucide/refresh-cw"
import { RequestOptionTabs } from "./RequestOptions.vue"
import { RESTOptionTabs } from "./RequestOptions.vue"
const colorMode = useColorMode()
const t = useI18n()
@@ -147,7 +149,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
(e: "change-tab", value: RequestOptionTabs): void
(e: "change-tab", value: RESTOptionTabs): void
(e: "update:headers", value: HoppRESTHeader[]): void
(e: "update:body", value: HoppRESTReqBody): void
}>()
@@ -164,7 +166,7 @@ const overridenContentType = computed(() =>
)
)
const contentTypeOverride = (tab: RequestOptionTabs) => {
const contentTypeOverride = (tab: RESTOptionTabs) => {
emit("change-tab", tab)
if (!isContentTypeAlreadyExist()) {
// TODO: Fix this

View File

@@ -157,9 +157,10 @@ import {
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import IconWrapText from "~icons/lucide/wrap-text"
import { currentActiveTab } from "~/helpers/rest/tab"
import cloneDeep from "lodash-es/cloneDeep"
import { platform } from "~/platform"
import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
const t = useI18n()
@@ -173,7 +174,8 @@ const emit = defineEmits<{
const toast = useToast()
const request = ref(cloneDeep(currentActiveTab.value.document.request))
const tabs = useService(RESTTabService)
const request = ref(cloneDeep(tabs.currentActiveTab.value.document.request))
const codegenType = ref<CodegenName>("shell-curl")
const errorState = ref(false)
@@ -242,7 +244,7 @@ watch(
() => props.show,
(goingToShow) => {
if (goingToShow) {
request.value = cloneDeep(currentActiveTab.value.document.request)
request.value = cloneDeep(tabs.currentActiveTab.value.document.request)
platform.analytics?.logEvent({
type: "HOPP_REST_CODEGEN_OPENED",

View File

@@ -185,18 +185,24 @@
<span>
<HoppButtonSecondary
v-if="header.source === 'auth'"
v-tippy="{ theme: 'tooltip' }"
:title="t(masking ? 'state.show' : 'state.hide')"
:icon="masking ? IconEye : IconEyeOff"
@click="toggleMask()"
/>
<HoppButtonSecondary
v-else
v-tippy="{ theme: 'tooltip' }"
:icon="IconArrowUpRight"
:title="t('request.go_to_authorization_tab')"
class="cursor-auto text-primary hover:text-primary"
/>
</span>
<span>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconArrowUpRight"
:title="t('request.go_to_authorization_tab')"
@click="changeTab(header.source)"
/>
</span>
@@ -267,10 +273,13 @@ import { aggregateEnvs$, getAggregateEnvs } from "~/newstore/environments"
import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
import { InspectionService, InspectorResult } from "~/services/inspection"
import { currentTabID } from "~/helpers/rest/tab"
import { RESTTabService } from "~/services/tab/rest"
const t = useI18n()
const toast = useToast()
const tabs = useService(RESTTabService)
const colorMode = useColorMode()
const idTicker = ref(0)
@@ -509,13 +518,13 @@ const changeTab = (tab: ComputedHeader["source"]) => {
const inspectionService = useService(InspectionService)
const headerKeyResults = inspectionService.getResultViewFor(
currentTabID.value,
tabs.currentTabID.value,
(result) =>
result.locations.type === "header" && result.locations.position === "key"
)
const headerValueResults = inspectionService.getResultViewFor(
currentTabID.value,
tabs.currentTabID.value,
(result) =>
result.locations.type === "header" && result.locations.position === "value"
)

View File

@@ -93,13 +93,16 @@ import IconWrapText from "~icons/lucide/wrap-text"
import IconClipboard from "~icons/lucide/clipboard"
import IconCheck from "~icons/lucide/check"
import IconTrash2 from "~icons/lucide/trash-2"
import { currentActiveTab } from "~/helpers/rest/tab"
import { platform } from "~/platform"
import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
const t = useI18n()
const toast = useToast()
const tabs = useService(RESTTabService)
const curl = ref("")
const curlEditor = ref<any | null>(null)
@@ -149,7 +152,7 @@ const handleImport = () => {
type: "HOPP_REST_IMPORT_CURL",
})
currentActiveTab.value.document.request = req
tabs.currentActiveTab.value.document.request = req
} catch (e) {
console.error(e)
toast.error(`${t("error.curl_invalid_format")}`)

View File

@@ -202,12 +202,13 @@ import { objRemoveKey } from "@functional/object"
import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
import { InspectionService, InspectorResult } from "~/services/inspection"
import { currentTabID } from "~/helpers/rest/tab"
import { RESTTabService } from "~/services/tab/rest"
const colorMode = useColorMode()
const t = useI18n()
const toast = useToast()
const tabs = useService(RESTTabService)
const idTicker = ref(0)
@@ -410,13 +411,13 @@ const clearContent = () => {
const inspectionService = useService(InspectionService)
const parameterKeyResults = inspectionService.getResultViewFor(
currentTabID.value,
tabs.currentTabID.value,
(result) =>
result.locations.type === "parameter" && result.locations.position === "key"
)
const parameterValueResults = inspectionService.getResultViewFor(
currentTabID.value,
tabs.currentTabID.value,
(result) =>
result.locations.type === "parameter" &&
result.locations.position === "value"

View File

@@ -217,6 +217,7 @@
@hide-modal="showCurlImportModal = false"
/>
<HttpCodegenModal
v-if="showCodegenModal"
:show="showCodegenModal"
@hide-modal="showCodegenModal = false"
/>
@@ -257,7 +258,6 @@ import IconLink2 from "~icons/lucide/link-2"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconSave from "~icons/lucide/save"
import IconShare2 from "~icons/lucide/share-2"
import { HoppRESTTab, currentTabID } from "~/helpers/rest/tab"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
import { platform } from "~/platform"
@@ -265,6 +265,9 @@ import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { InterceptorService } from "~/services/interceptor.service"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { RESTTabService } from "~/services/tab/rest"
const t = useI18n()
const interceptorService = useService(InterceptorService)
@@ -286,7 +289,7 @@ const toast = useToast()
const { subscribeToStream } = useStreamSubscriber()
const props = defineProps<{ modelValue: HoppRESTTab }>()
const props = defineProps<{ modelValue: HoppTab<HoppRESTDocument> }>()
const emit = defineEmits(["update:modelValue"])
const tab = useVModel(props, "modelValue", emit)
@@ -426,7 +429,7 @@ const updateMethod = (method: string) => {
const onSelectMethod = (e: Event | any) => {
// type any because of value property not being recognized by TS in the event.target object. It is a valid property though.
updateMethod(e.value)
updateMethod(e.target.value)
}
const clearContent = () => {
@@ -434,7 +437,7 @@ const clearContent = () => {
}
const updateRESTResponse = (response: HoppRESTResponse | null) => {
tab.value.response = response
tab.value.document.response = response
}
const copyLinkIcon = refAutoReset<
@@ -642,5 +645,6 @@ const COLUMN_LAYOUT = useSetting("COLUMN_LAYOUT")
const inspectionService = useService(InspectionService)
const tabResults = inspectionService.getResultViewFor(currentTabID.value)
const tabs = useService(RESTTabService)
const tabResults = inspectionService.getResultViewFor(tabs.currentTabID.value)
</script>

View File

@@ -1,6 +1,6 @@
<template>
<HoppSmartTabs
v-model="selectedOptionsTab"
v-model="selectedOptionTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-upperMobilePrimaryStickyFold sm:top-upperPrimaryStickyFold z-10"
render-inactive-tabs
>
@@ -15,7 +15,7 @@
<HttpBody
v-model:headers="request.headers"
v-model:body="request.body"
@change-tab="changeTab"
@change-tab="changeOptionTab"
/>
</HoppSmartTab>
<HoppSmartTab
@@ -23,7 +23,7 @@
:label="`${t('tab.headers')}`"
:info="`${newActiveHeadersCount$}`"
>
<HttpHeaders v-model="request" @change-tab="changeTab" />
<HttpHeaders v-model="request" @change-tab="changeOptionTab" />
</HoppSmartTab>
<HoppSmartTab :id="'authorization'" :label="`${t('tab.authorization')}`">
<HttpAuthorization v-model="request.auth" />
@@ -55,31 +55,43 @@
import { useI18n } from "@composables/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core"
import { computed, ref } from "vue"
import { computed } from "vue"
import { defineActionHandler } from "~/helpers/actions"
export type RequestOptionTabs =
| "params"
| "bodyParams"
| "headers"
| "authorization"
| "preRequestScript"
| "tests"
const VALID_OPTION_TABS = [
"params",
"bodyParams",
"headers",
"authorization",
"preRequestScript",
"tests",
] as const
export type RESTOptionTabs = (typeof VALID_OPTION_TABS)[number]
const t = useI18n()
// v-model integration with props and emit
const props = defineProps<{ modelValue: HoppRESTRequest }>()
const props = withDefaults(
defineProps<{
modelValue: HoppRESTRequest
optionTab: RESTOptionTabs
}>(),
{
optionTab: "params",
}
)
const emit = defineEmits<{
(e: "update:modelValue", value: HoppRESTRequest): void
(e: "update:optionTab", value: RESTOptionTabs): void
}>()
const request = useVModel(props, "modelValue", emit)
const selectedOptionTab = useVModel(props, "optionTab", emit)
const selectedOptionsTab = ref<RequestOptionTabs>("params")
const changeTab = (e: RequestOptionTabs) => {
selectedOptionsTab.value = e
const changeOptionTab = (e: RESTOptionTabs) => {
selectedOptionTab.value = e
}
const newActiveParamsCount$ = computed(() => {
@@ -101,6 +113,6 @@ const newActiveHeadersCount$ = computed(() => {
})
defineActionHandler("request.open-tab", ({ tab }) => {
selectedOptionsTab.value = tab as RequestOptionTabs
selectedOptionTab.value = tab as RESTOptionTabs
})
</script>

View File

@@ -2,10 +2,13 @@
<AppPaneLayout layout-id="rest-primary">
<template #primary>
<HttpRequest v-model="tab" />
<HttpRequestOptions v-model="tab.document.request" />
<HttpRequestOptions
v-model="tab.document.request"
v-model:option-tab="tab.document.optionTabPreference"
/>
</template>
<template #secondary>
<HttpResponse v-model:tab="tab" />
<HttpResponse v-model:document="tab.document" />
</template>
</AppPaneLayout>
</template>
@@ -13,16 +16,17 @@
<script setup lang="ts">
import { watch } from "vue"
import { useVModel } from "@vueuse/core"
import { HoppRESTTab } from "~/helpers/rest/tab"
import { cloneDeep } from "lodash-es"
import { isEqualHoppRESTRequest } from "@hoppscotch/data"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
// TODO: Move Response and Request execution code to over here
const props = defineProps<{ modelValue: HoppRESTTab }>()
const props = defineProps<{ modelValue: HoppTab<HoppRESTDocument> }>()
const emit = defineEmits<{
(e: "update:modelValue", val: HoppRESTTab): void
(e: "update:modelValue", val: HoppTab<HoppRESTDocument>): void
}>()
const tab = useVModel(props, "modelValue", emit)

View File

@@ -1,36 +1,33 @@
<template>
<div class="flex flex-col flex-1 relative">
<HttpResponseMeta :response="tab.response" />
<HttpResponseMeta :response="doc.response" />
<LensesResponseBodyRenderer
v-if="!loading && hasResponse"
v-model:selected-tab-preference="selectedTabPreference"
v-model:tab="tab"
v-model:document="doc"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue"
import { HoppRESTTab } from "~/helpers/rest/tab"
import { useVModel } from "@vueuse/core"
import { computed } from "vue"
import { HoppRESTDocument } from "~/helpers/rest/document"
const props = defineProps<{
tab: HoppRESTTab
document: HoppRESTDocument
}>()
const emit = defineEmits<{
(e: "update:tab", val: HoppRESTTab): void
(e: "update:tab", val: HoppRESTDocument): void
}>()
const tab = useVModel(props, "tab", emit)
const selectedTabPreference = ref<string | null>(null)
const doc = useVModel(props, "document", emit)
const hasResponse = computed(
() =>
tab.value.response?.type === "success" ||
tab.value.response?.type === "fail"
doc.value.response?.type === "success" ||
doc.value.response?.type === "fail"
)
const loading = computed(() => tab.value.response?.type === "loading")
const loading = computed(() => doc.value.response?.type === "loading")
</script>

View File

@@ -93,10 +93,11 @@ import { useColorMode } from "@composables/theming"
import { getStatusCodeReasonPhrase } from "~/helpers/utils/statusCodes"
import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { currentTabID } from "~/helpers/rest/tab"
import { RESTTabService } from "~/services/tab/rest"
const t = useI18n()
const colorMode = useColorMode()
const tabs = useService(RESTTabService)
const props = defineProps<{
response: HoppRESTResponse | null | undefined
@@ -146,7 +147,7 @@ const statusCategory = computed(() => {
const inspectionService = useService(InspectionService)
const tabResults = inspectionService.getResultViewFor(
currentTabID.value,
tabs.currentTabID.value,
(result) => result.locations.type === "response"
)
</script>

View File

@@ -96,16 +96,17 @@ import { ref } from "vue"
import { TippyComponent } from "vue-tippy"
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
import { useI18n } from "~/composables/i18n"
import { HoppRESTTab } from "~/helpers/rest/tab"
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 { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
const t = useI18n()
defineProps<{
tab: HoppRESTTab
tab: HoppTab<HoppRESTDocument>
isRemovable: boolean
}>()

View File

@@ -1,6 +1,6 @@
<template>
<HoppSmartTabs
v-if="tab.response"
v-if="doc.response"
v-model="selectedLensTab"
styles="sticky overflow-x-auto flex-shrink-0 z-10 bg-primary top-lowerPrimaryStickyFold"
>
@@ -13,7 +13,7 @@
>
<component
:is="lensRendererFor(lens.renderer)"
:response="tab.response"
:response="doc.response"
/>
</HoppSmartTab>
<HoppSmartTab
@@ -28,19 +28,10 @@
<HoppSmartTab
id="results"
:label="t('test.results')"
:indicator="
tab.testResults &&
(tab.testResults.expectResults.length ||
tab.testResults.tests.length ||
tab.testResults.envDiff.selected.additions.length ||
tab.testResults.envDiff.selected.updations.length ||
tab.testResults.envDiff.global.updations.length)
? true
: false
"
:indicator="showIndicator"
class="flex flex-col flex-1"
>
<HttpTestResult v-model="tab.testResults" />
<HttpTestResult v-model="doc.testResults" />
</HoppSmartTab>
</HoppSmartTabs>
</template>
@@ -54,20 +45,30 @@ import {
} from "~/helpers/lenses/lenses"
import { useI18n } from "@composables/i18n"
import { useVModel } from "@vueuse/core"
import { HoppRESTTab } from "~/helpers/rest/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
const props = defineProps<{
tab: HoppRESTTab
selectedTabPreference: string | null
document: HoppRESTDocument
}>()
const emit = defineEmits<{
(e: "update:tab", val: HoppRESTTab): void
(e: "update:selectedTabPreference", newTab: string): void
(e: "update:document", document: HoppRESTDocument): void
}>()
const tab = useVModel(props, "tab", emit)
const selectedTabPreference = useVModel(props, "selectedTabPreference", emit)
const doc = useVModel(props, "document", emit)
const showIndicator = computed(() => {
if (!doc.value.testResults) return false
const { expectResults, tests, envDiff } = doc.value.testResults
return Boolean(
expectResults.length ||
tests.length ||
envDiff.selected.additions.length ||
envDiff.selected.updations.length ||
envDiff.global.updations.length
)
})
const allLensRenderers = getLensRenderers()
@@ -81,19 +82,19 @@ const selectedLensTab = ref("")
const maybeHeaders = computed(() => {
if (
!tab.value.response ||
!doc.value.response ||
!(
tab.value.response.type === "success" ||
tab.value.response.type === "fail"
doc.value.response.type === "success" ||
doc.value.response.type === "fail"
)
)
return null
return tab.value.response.headers
return doc.value.response.headers
})
const validLenses = computed(() => {
if (!tab.value.response) return []
return getSuitableLenses(tab.value.response)
if (!doc.value.response) return []
return getSuitableLenses(doc.value.response)
})
watch(
@@ -107,11 +108,13 @@ watch(
"results",
]
const { responseTabPreference } = doc.value
if (
selectedTabPreference.value &&
validRenderers.includes(selectedTabPreference.value)
responseTabPreference &&
validRenderers.includes(responseTabPreference)
) {
selectedLensTab.value = selectedTabPreference.value
selectedLensTab.value = responseTabPreference
} else {
selectedLensTab.value = newLenses[0].renderer
}
@@ -120,6 +123,6 @@ watch(
)
watch(selectedLensTab, (newLensID) => {
selectedTabPreference.value = newLensID
doc.value.responseTabPreference = newLensID
})
</script>

View File

@@ -10,6 +10,7 @@ import {
WatchStopHandle,
watchSyncEffect,
watch,
nextTick,
} from "vue"
import {
client,
@@ -101,7 +102,7 @@ export const useGQLQuery = <
const rerunQuery = () => {
source.value = !isPaused.value
? client.value.executeQuery<DocType, DocVarType>(request.value, {
? client.value?.executeQuery<DocType, DocVarType>(request.value, {
requestPolicy: "network-only",
})
: undefined
@@ -126,7 +127,7 @@ export const useGQLQuery = <
const invalidateStops = args.updateSubs!.map((sub) => {
return wonkaPipe(
client.value.executeSubscription(sub),
client.value!.executeSubscription(sub),
onEnd(() => {
if (source.value) execute()
}),
@@ -191,10 +192,12 @@ export const useGQLQuery = <
} else {
args.variables = updatedVars
}
nextTick(rerunQuery)
} else {
rerunQuery()
}
isPaused.value = false
rerunQuery()
}
const pause = () => {

View File

@@ -29,8 +29,9 @@ import {
setGlobalEnvVariables,
updateEnvironment,
} from "~/newstore/environments"
import { HoppRESTTab } from "./rest/tab"
import { Ref } from "vue"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "./rest/document"
const getTestableBody = (
res: HoppRESTResponse & { type: "success" | "fail" }
@@ -69,7 +70,7 @@ export const executedResponses$ = new Subject<
>()
export function runRESTRequest$(
tab: Ref<HoppRESTTab>
tab: Ref<HoppTab<HoppRESTDocument>>
): [
() => void,
Promise<
@@ -127,7 +128,7 @@ export function runRESTRequest$(
)()
if (E.isRight(runResult)) {
tab.value.testResults = translateToSandboxTestResults(
tab.value.document.testResults = translateToSandboxTestResults(
runResult.right
)
@@ -163,7 +164,7 @@ export function runRESTRequest$(
)()
}
} else {
tab.value.testResults = {
tab.value.document.testResults = {
description: "",
expectResults: [],
tests: [],

View File

@@ -1,9 +1,10 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { getTabsRefTo } from "../rest/tab"
import { getAffectedIndexes } from "./affectedIndex"
import { GetSingleRequestDocument } from "../backend/graphql"
import { runGQLQuery } from "../backend/GQLClient"
import * as E from "fp-ts/Either"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
/**
* Resolve save context on reorder
@@ -56,7 +57,9 @@ export function resolveSaveContextOnCollectionReorder(
}
}
const tabs = getTabsRefTo((tab) => {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
affectedPaths.has(tab.document.saveContext.folderPath)
@@ -84,7 +87,8 @@ export function updateSaveContextForAffectedRequests(
oldFolderPath: string,
newFolderPath: string
) {
const tabs = getTabsRefTo((tab) => {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath.startsWith(oldFolderPath)
@@ -105,7 +109,8 @@ export function updateSaveContextForAffectedRequests(
}
function resetSaveContextForAffectedRequests(folderPath: string) {
const tabs = getTabsRefTo((tab) => {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath.startsWith(folderPath)
@@ -124,7 +129,8 @@ function resetSaveContextForAffectedRequests(folderPath: string) {
*/
export async function resetTeamRequestsContext() {
const tabs = getTabsRefTo((tab) => {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return tab.document.saveContext?.originLocation === "team-collection"
})

View File

@@ -1,6 +1,7 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { getTabsRefTo } from "../rest/tab"
import { getAffectedIndexes } from "./affectedIndex"
import { RESTTabService } from "~/services/tab/rest"
import { getService } from "~/modules/dioc"
/**
* Resolve save context on reorder
@@ -32,7 +33,8 @@ export function resolveSaveContextOnRequestReorder(payload: {
// if (newIndex === -1) remove it from the map because it will be deleted
if (newIndex === -1) affectedIndexes.delete(lastIndex)
const tabs = getTabsRefTo((tab) => {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath === folderPath &&

View File

@@ -1,5 +1,6 @@
import { GQLHeader, HoppGQLAuth, makeGQLRequest } from "@hoppscotch/data"
import { OperationType } from "@urql/core"
import * as E from "fp-ts/Either"
import {
GraphQLEnumType,
GraphQLInputObjectType,
@@ -11,11 +12,12 @@ import {
printSchema,
} from "graphql"
import { computed, reactive, ref } from "vue"
import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history"
import { currentTabID } from "./tab"
import { getService } from "~/modules/dioc"
import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history"
import { InterceptorService } from "~/services/interceptor.service"
import * as E from "fp-ts/Either"
import { GQLTabService } from "~/services/tab/graphql"
const GQL_SCHEMA_POLL_INTERVAL = 7000
@@ -61,6 +63,9 @@ type Connection = {
schema: GraphQLSchema | null
}
const tabs = getService(GQLTabService)
const currentTabID = computed(() => tabs.currentTabID.value)
export const connection = reactive<Connection>({
state: "DISCONNECTED",
subscriptionState: new Map<string, SubscriptionState>(),
@@ -268,7 +273,7 @@ export const runGQLOperation = async (options: RunQueryOptions) => {
}
if (operationType === "subscription") {
return runSubscription(options)
return runSubscription(options, finalHeaders)
}
const interceptorService = getService(InterceptorService)
@@ -299,7 +304,10 @@ export const runGQLOperation = async (options: RunQueryOptions) => {
return responseText
}
export const runSubscription = (options: RunQueryOptions) => {
export const runSubscription = (
options: RunQueryOptions,
headers?: Record<string, string>
) => {
const { url, query, operationName } = options
const wsUrl = url.replace(/^http/, "ws")
@@ -309,10 +317,11 @@ export const runSubscription = (options: RunQueryOptions) => {
connection.socket.onopen = (event) => {
console.log("WebSocket is open now.", event)
connection.socket?.send(
JSON.stringify({
type: GQL.CONNECTION_INIT,
payload: {},
payload: headers ?? {},
})
)

View File

@@ -1,4 +1,6 @@
import { HoppGQLRequest } from "@hoppscotch/data"
import { GQLResponseEvent } from "./connection"
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
export type HoppGQLSaveContext =
| {
@@ -55,4 +57,20 @@ export type HoppGQLDocument = {
* This contains where the request is originated from basically.
*/
saveContext?: HoppGQLSaveContext
/**
* The response as it is in the document
* (if any)
*/
response?: GQLResponseEvent[] | null
/**
* Response tab preference for the current tab's document
*/
responseTabPreference?: string
/**
* Options tab preference for the current tab's document
*/
optionTabPreference?: GQLOptionTabs
}

View File

@@ -1,226 +0,0 @@
import { refWithControl } from "@vueuse/core"
import { isEqual } from "lodash-es"
import { v4 as uuidV4 } from "uuid"
import { computed, reactive, ref, shallowReadonly, watch } from "vue"
import { HoppTestResult } from "../types/HoppTestResult"
import { GQLResponseEvent } from "./connection"
import { getDefaultGQLRequest } from "./default"
import { HoppGQLDocument, HoppGQLSaveContext } from "./document"
export type HoppGQLTab = {
id: string
document: HoppGQLDocument
response?: GQLResponseEvent[] | null
testResults?: HoppTestResult | null
}
export type PersistableGQLTabState = {
lastActiveTabID: string
orderedDocs: Array<{
tabID: string
doc: HoppGQLDocument
}>
}
export const currentTabID = refWithControl("test", {
onBeforeChange(newTabID) {
if (!newTabID || !tabMap.has(newTabID)) {
console.warn(
`Tried to set current tab id to an invalid value. (value: ${newTabID})`
)
// Don't allow change
return false
}
},
})
const tabMap = reactive(
new Map<string, HoppGQLTab>([
[
"test",
{
id: "test",
document: {
request: getDefaultGQLRequest(),
isDirty: false,
},
},
],
])
)
const tabOrdering = ref<string[]>(["test"])
watch(
tabOrdering,
(newOrdering) => {
if (!currentTabID.value || !newOrdering.includes(currentTabID.value)) {
currentTabID.value = newOrdering[newOrdering.length - 1] // newOrdering should always be non-empty
}
},
{ deep: true }
)
export const persistableTabState = computed<PersistableGQLTabState>(() => ({
lastActiveTabID: currentTabID.value,
orderedDocs: tabOrdering.value.map((tabID) => {
const tab = tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: tab.document,
}
}),
}))
export const currentActiveTab = computed(() => tabMap.get(currentTabID.value)!) // Guaranteed to not be undefined
// TODO: Mark this unknown and do validations
export function loadTabsFromPersistedState(data: PersistableGQLTabState) {
if (data) {
tabMap.clear()
tabOrdering.value = []
for (const doc of data.orderedDocs) {
tabMap.set(doc.tabID, {
id: doc.tabID,
document: doc.doc,
})
tabOrdering.value.push(doc.tabID)
}
currentTabID.value = data.lastActiveTabID
}
}
/**
* Returns all the active Tab IDs in order
*/
export function getActiveTabs() {
return shallowReadonly(
computed(() => tabOrdering.value.map((x) => tabMap.get(x)!))
)
}
export function getTabRef(tabID: string) {
return computed({
get() {
const result = tabMap.get(tabID)
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
return result
},
set(value) {
return tabMap.set(tabID, value)
},
})
}
function generateNewTabID() {
while (true) {
const id = uuidV4()
if (!tabMap.has(id)) return id
}
}
export function updateTab(tabUpdate: HoppGQLTab) {
if (!tabMap.has(tabUpdate.id)) {
console.warn(
`Cannot update tab as tab with that tab id does not exist (id: ${tabUpdate.id})`
)
}
tabMap.set(tabUpdate.id, tabUpdate)
}
export function createNewTab(document: HoppGQLDocument, switchToIt = true) {
const id = generateNewTabID()
const tab: HoppGQLTab = { id, document }
tabMap.set(id, tab)
tabOrdering.value.push(id)
if (switchToIt) {
currentTabID.value = id
}
return tab
}
export function updateTabOrdering(fromIndex: number, toIndex: number) {
tabOrdering.value.splice(
toIndex,
0,
tabOrdering.value.splice(fromIndex, 1)[0]
)
}
export function closeTab(tabID: string) {
if (!tabMap.has(tabID)) {
console.warn(`Tried to close a tab which does not exist (tab id: ${tabID})`)
return
}
if (tabOrdering.value.length === 1) {
console.warn(
`Tried to close the only tab open, which is not allowed. (tab id: ${tabID})`
)
return
}
tabOrdering.value.splice(tabOrdering.value.indexOf(tabID), 1)
tabMap.delete(tabID)
}
export function getTabRefWithSaveContext(ctx: HoppGQLSaveContext) {
for (const tab of tabMap.values()) {
// For `team-collection` request id can be considered unique
if (ctx && ctx.originLocation === "team-collection") {
if (
tab.document.saveContext?.originLocation === "team-collection" &&
tab.document.saveContext.requestID === ctx.requestID
) {
return getTabRef(tab.id)
}
} else if (isEqual(ctx, tab.document.saveContext)) return getTabRef(tab.id)
}
return null
}
export function getTabsRefTo(func: (tab: HoppGQLTab) => boolean) {
return Array.from(tabMap.values())
.filter(func)
.map((tab) => getTabRef(tab.id))
}
export function closeOtherTabs(tabID: string) {
if (!tabMap.has(tabID)) {
console.warn(
`The tab to close other tabs does not exist (tab id: ${tabID})`
)
return
}
tabOrdering.value = [tabID]
tabMap.forEach((_, id) => {
if (id !== tabID) tabMap.delete(id)
})
currentTabID.value = tabID
}
export function getDirtyTabsCount() {
let count = 0
for (const tab of tabMap.values()) {
if (tab.document.isDirty) count++
}
return count
}

View File

@@ -0,0 +1,48 @@
import { Environment } from "@hoppscotch/data"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { cloneDeep } from "lodash-es"
const getEnvironmentJson = (
environmentObj: TeamEnvironment | Environment,
environmentIndex?: number | "Global" | null
) => {
const newEnvironment =
"environment" in environmentObj
? cloneDeep(environmentObj.environment)
: cloneDeep(environmentObj)
delete newEnvironment.id
const environmentId =
environmentIndex || environmentIndex === 0
? environmentIndex
: environmentObj.id
return environmentId !== null
? JSON.stringify(newEnvironment, null, 2)
: undefined
}
export const exportAsJSON = (
environmentObj: Environment | TeamEnvironment,
environmentIndex?: number | "Global" | null
): boolean => {
const dataToWrite = getEnvironmentJson(environmentObj, environmentIndex)
if (!dataToWrite) return false
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
// Extracts the path from url, removes fragment identifier and query parameters if any, appends the ".json" extension, and assigns it
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
}, 0)
return true
}

View File

@@ -131,7 +131,8 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
}
function getPressedKey(ev: KeyboardEvent): Key | null {
const val = ev.code.toLowerCase()
// Sometimes the property code is not available on the KeyboardEvent object
const val = (ev.code ?? "").toLowerCase()
// Check arrow keys
if (val === "arrowup") return "up"

View File

@@ -1,4 +1,7 @@
import { HoppRESTRequest } from "@hoppscotch/data"
import { HoppRESTResponse } from "../types/HoppRESTResponse"
import { HoppTestResult } from "../types/HoppTestResult"
import { RESTOptionTabs } from "~/components/http/RequestOptions.vue"
export type HoppRESTSaveContext =
| {
@@ -55,4 +58,26 @@ export type HoppRESTDocument = {
* This contains where the request is originated from basically.
*/
saveContext?: HoppRESTSaveContext
/**
* The response as it is in the document
* (if any)
*/
response?: HoppRESTResponse | null
/**
* The test results as it is in the document
* (if any)
*/
testResults?: HoppTestResult | null
/**
* Response tab preference for the current tab's document
*/
responseTabPreference?: string
/**
* Options tab preference for the current tab's document
*/
optionTabPreference?: RESTOptionTabs
}

View File

@@ -1,234 +0,0 @@
import { v4 as uuidV4 } from "uuid"
import { isEqual } from "lodash-es"
import { reactive, watch, computed, ref, shallowReadonly } from "vue"
import { HoppRESTDocument, HoppRESTSaveContext } from "./document"
import { refWithControl } from "@vueuse/core"
import { HoppRESTResponse } from "../types/HoppRESTResponse"
import { getDefaultRESTRequest } from "./default"
import { HoppTestResult } from "../types/HoppTestResult"
import { platform } from "~/platform"
import { nextTick } from "vue"
export type HoppRESTTab = {
id: string
document: HoppRESTDocument
response?: HoppRESTResponse | null
testResults?: HoppTestResult | null
}
export type PersistableRESTTabState = {
lastActiveTabID: string
orderedDocs: Array<{
tabID: string
doc: HoppRESTDocument
}>
}
export const currentTabID = refWithControl("test", {
onBeforeChange(newTabID) {
if (!newTabID || !tabMap.has(newTabID)) {
console.warn(
`Tried to set current tab id to an invalid value. (value: ${newTabID})`
)
// Don't allow change
return false
}
},
})
const tabMap = reactive(
new Map<string, HoppRESTTab>([
[
"test",
{
id: "test",
document: {
request: getDefaultRESTRequest(),
isDirty: false,
},
},
],
])
)
const tabOrdering = ref<string[]>(["test"])
watch(
tabOrdering,
(newOrdering) => {
if (!currentTabID.value || !newOrdering.includes(currentTabID.value)) {
currentTabID.value = newOrdering[newOrdering.length - 1] // newOrdering should always be non-empty
}
},
{ deep: true }
)
export const persistableTabState = computed<PersistableRESTTabState>(() => ({
lastActiveTabID: currentTabID.value,
orderedDocs: tabOrdering.value.map((tabID) => {
const tab = tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: tab.document,
}
}),
}))
export const currentActiveTab = computed(() => tabMap.get(currentTabID.value)!) // Guaranteed to not be undefined
// TODO: Mark this unknown and do validations
export function loadTabsFromPersistedState(data: PersistableRESTTabState) {
if (data) {
tabMap.clear()
tabOrdering.value = []
for (const doc of data.orderedDocs) {
tabMap.set(doc.tabID, {
id: doc.tabID,
document: doc.doc,
})
tabOrdering.value.push(doc.tabID)
}
currentTabID.value = data.lastActiveTabID
}
}
/**
* Returns all the active Tab IDs in order
*/
export function getActiveTabs() {
return shallowReadonly(
computed(() => tabOrdering.value.map((x) => tabMap.get(x)!))
)
}
export function getTabRef(tabID: string) {
return computed({
get() {
const result = tabMap.get(tabID)
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
return result
},
set(value) {
return tabMap.set(tabID, value)
},
})
}
function generateNewTabID() {
while (true) {
const id = uuidV4()
if (!tabMap.has(id)) return id
}
}
export function updateTab(tabUpdate: HoppRESTTab) {
if (!tabMap.has(tabUpdate.id)) {
console.warn(
`Cannot update tab as tab with that tab id does not exist (id: ${tabUpdate.id})`
)
}
tabMap.set(tabUpdate.id, tabUpdate)
}
export function createNewTab(document: HoppRESTDocument, switchToIt = true) {
const id = generateNewTabID()
const tab: HoppRESTTab = { id, document }
tabMap.set(id, tab)
tabOrdering.value.push(id)
if (switchToIt) {
currentTabID.value = id
}
platform.analytics?.logEvent({
type: "HOPP_REST_NEW_TAB_OPENED",
})
return tab
}
export function updateTabOrdering(fromIndex: number, toIndex: number) {
tabOrdering.value.splice(
toIndex,
0,
tabOrdering.value.splice(fromIndex, 1)[0]
)
}
export function closeTab(tabID: string) {
if (!tabMap.has(tabID)) {
console.warn(`Tried to close a tab which does not exist (tab id: ${tabID})`)
return
}
if (tabOrdering.value.length === 1) {
console.warn(
`Tried to close the only tab open, which is not allowed. (tab id: ${tabID})`
)
return
}
tabOrdering.value.splice(tabOrdering.value.indexOf(tabID), 1)
nextTick(() => {
tabMap.delete(tabID)
})
}
export function closeOtherTabs(tabID: string) {
if (!tabMap.has(tabID)) {
console.warn(
`The tab to close other tabs does not exist (tab id: ${tabID})`
)
return
}
tabOrdering.value = [tabID]
tabMap.forEach((_, id) => {
if (id !== tabID) tabMap.delete(id)
})
currentTabID.value = tabID
}
export function getDirtyTabsCount() {
let count = 0
for (const tab of tabMap.values()) {
if (tab.document.isDirty) count++
}
return count
}
export function getTabRefWithSaveContext(ctx: HoppRESTSaveContext) {
for (const tab of tabMap.values()) {
// For `team-collection` request id can be considered unique
if (ctx && ctx.originLocation === "team-collection") {
if (
tab.document.saveContext?.originLocation === "team-collection" &&
tab.document.saveContext.requestID === ctx.requestID
) {
return getTabRef(tab.id)
}
} else if (isEqual(ctx, tab.document.saveContext)) return getTabRef(tab.id)
}
return null
}
export function getTabsRefTo(func: (tab: HoppRESTTab) => boolean) {
return Array.from(tabMap.values())
.filter(func)
.map((tab) => getTabRef(tab.id))
}

View File

@@ -198,11 +198,11 @@ export const resolvesEnvsInBody = (
}
),
}
} else {
return {
contentType: body.contentType,
body: parseTemplateString(body.body, env.variables),
}
}
return {
contentType: body.contentType,
body: parseTemplateString(body.body ?? "", env.variables),
}
}
@@ -210,9 +210,7 @@ function getFinalBodyFromRequest(
request: HoppRESTRequest,
envVariables: Environment["variables"]
): FormData | string | null {
if (request.body.contentType === null) {
return null
}
if (request.body.contentType === null) return null
if (request.body.contentType === "application/x-www-form-urlencoded") {
const parsedBodyRecord = pipe(
@@ -280,7 +278,10 @@ function getFinalBodyFromRequest(
),
toFormData
)
} else return parseBodyEnvVariables(request.body.body, envVariables)
}
// body can be null if the content-type is not set
return parseBodyEnvVariables(request.body.body ?? "", envVariables)
}
/**

View File

@@ -1,200 +0,0 @@
import { HoppModule } from "."
import * as Sentry from "@sentry/vue"
import { BrowserTracing } from "@sentry/tracing"
import { Route } from "@sentry/vue/types/router"
import { RouteLocationNormalized, Router } from "vue-router"
import { settingsStore } from "~/newstore/settings"
import { App } from "vue"
import { APP_IS_IN_DEV_MODE } from "~/helpers/dev"
import { gqlClientError$ } from "~/helpers/backend/GQLClient"
import { platform } from "~/platform"
/**
* The tag names we allow giving to Sentry
*/
type SentryTag = "BACKEND_OPERATIONS"
interface SentryVueRouter {
onError: (fn: (err: Error) => void) => void
beforeEach: (fn: (to: Route, from: Route, next: () => void) => void) => void
}
function normalizedRouteToSentryRoute(route: RouteLocationNormalized): Route {
return {
matched: route.matched,
// route.params' type translates just to a fancy version of this, hence assertion
params: route.params as Route["params"],
path: route.path,
// route.query's type translates just to a fancy version of this, hence assertion
query: route.query as Route["query"],
name: route.name,
}
}
function getInstrumentationVueRouter(router: Router): SentryVueRouter {
return <SentryVueRouter>{
onError: router.onError,
beforeEach(func) {
router.beforeEach((to, from, next) => {
func(
normalizedRouteToSentryRoute(to),
normalizedRouteToSentryRoute(from),
next
)
})
},
}
}
let sentryActive = false
function initSentry(dsn: string, router: Router, app: App) {
Sentry.init({
app,
dsn,
release: import.meta.env.VITE_SENTRY_RELEASE_TAG ?? undefined,
environment: APP_IS_IN_DEV_MODE
? "dev"
: import.meta.env.VITE_SENTRY_ENVIRONMENT,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(
getInstrumentationVueRouter(router)
),
// TODO: We may want to limit this later on
tracingOrigins: [new URL(import.meta.env.VITE_BACKEND_GQL_URL).origin],
}),
],
tracesSampleRate: 0.8,
})
sentryActive = true
}
function deinitSentry() {
Sentry.close()
sentryActive = false
}
/**
* Reports a set of related errors to Sentry
* @param errs The errors to report
* @param tag The tag for the errord
* @param extraTags Additional tag data to add
* @param extras Extra information to attach
*/
function reportErrors(
errs: Error[],
tag: SentryTag,
extraTags: Record<string, string | number | boolean> | null = null,
extras: any = undefined
) {
if (sentryActive) {
Sentry.withScope((scope) => {
scope.setTag("tag", tag)
if (extraTags) {
Object.entries(extraTags).forEach(([key, value]) => {
scope.setTag(key, value)
})
}
if (extras !== null && extras === undefined) scope.setExtras(extras)
scope.addAttachment({
filename: "extras-dump.json",
data: JSON.stringify(extras),
contentType: "application/json",
})
errs.forEach((err) => Sentry.captureException(err))
})
}
}
/**
* Reports a specific error to Sentry
* @param err The error to report
* @param tag The tag for the error
* @param extraTags Additional tag data to add
* @param extras Extra information to attach
*/
function reportError(
err: Error,
tag: SentryTag,
extraTags: Record<string, string | number | boolean> | null = null,
extras: any = undefined
) {
reportErrors([err], tag, extraTags, extras)
}
/**
* Subscribes to events occuring in various subsystems in the app
* for personalized error reporting
*/
function subscribeToAppEventsForReporting() {
gqlClientError$.subscribe((ev) => {
switch (ev.type) {
case "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT":
reportErrors(ev.errors, "BACKEND_OPERATIONS", { from: ev.type })
break
case "CLIENT_REPORTED_ERROR":
reportError(
ev.error,
"BACKEND_OPERATIONS",
{ from: ev.type },
{ op: ev.op }
)
break
case "GQL_CLIENT_REPORTED_ERROR":
reportError(
new Error("Backend Query Failed"),
"BACKEND_OPERATIONS",
{ opType: ev.opType },
{
opResult: ev.opResult,
}
)
break
}
})
}
/**
* Subscribe to app system events for adding
* additional data tags for the error reporting
*/
function subscribeForAppDataTags() {
const currentUser$ = platform.auth.getCurrentUserStream()
currentUser$.subscribe((user) => {
if (sentryActive) {
Sentry.setTag("user_logged_in", !!user)
}
})
}
export default <HoppModule>{
onRouterInit(app, router) {
if (!import.meta.env.VITE_SENTRY_DSN) {
console.log(
"Sentry tracing is not enabled because 'VITE_SENTRY_DSN' env is not defined"
)
return
}
if (settingsStore.value.TELEMETRY_ENABLED) {
initSentry(import.meta.env.VITE_SENTRY_DSN, router, app)
}
settingsStore.subject$.subscribe(({ TELEMETRY_ENABLED }) => {
if (!TELEMETRY_ENABLED && sentryActive) {
deinitSentry()
} else if (TELEMETRY_ENABLED && !sentryActive) {
initSentry(import.meta.env.VITE_SENTRY_DSN!, router, app)
}
})
subscribeToAppEventsForReporting()
subscribeForAppDataTags()
},
}

View File

@@ -7,8 +7,9 @@ import {
} from "@hoppscotch/data"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import { cloneDeep } from "lodash-es"
import { getTabRefWithSaveContext } from "~/helpers/rest/tab"
import { resolveSaveContextOnRequestReorder } from "~/helpers/collection/request"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
const defaultRESTCollectionState = {
state: [
@@ -454,7 +455,10 @@ const restCollectionDispatchers = defineDispatchers({
// Deal with situations where a tab with the given thing is deleted
// We are just going to dissociate the save context of the tab and mark it dirty
const tab = getTabRefWithSaveContext({
const tabService = getService(RESTTabService)
const tab = tabService.getTabRefWithSaveContext({
originLocation: "user-collection",
folderPath: path,
requestIndex: requestIndex,
@@ -512,7 +516,8 @@ const restCollectionDispatchers = defineDispatchers({
destLocation.requests.push(req)
targetLocation.requests.splice(requestIndex, 1)
const possibleTab = getTabRefWithSaveContext({
const tabService = getService(RESTTabService)
const possibleTab = tabService.getTabRefWithSaveContext({
originLocation: "user-collection",
folderPath: path,
requestIndex,

View File

@@ -44,14 +44,9 @@ import { SSERequest$, setSSERequest } from "./SSESession"
import { MQTTRequest$, setMQTTRequest } from "./MQTTSession"
import { bulkApplyLocalState, localStateStore } from "./localstate"
import { StorageLike, watchDebounced } from "@vueuse/core"
import {
loadTabsFromPersistedState,
persistableTabState,
} from "~/helpers/rest/tab"
import {
loadTabsFromPersistedState as loadGQLTabsFromPersistedState,
persistableTabState as persistableGQLTabState,
} from "~/helpers/graphql/tab"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
import { GQLTabService } from "~/services/tab/graphql"
function checkAndMigrateOldSettings() {
if (window.localStorage.getItem("selectedEnvIndex")) {
@@ -320,11 +315,13 @@ function setupGlobalEnvsPersistence() {
// TODO: Graceful error handling ?
export function setupRESTTabsPersistence() {
const tabService = getService(RESTTabService)
try {
const state = window.localStorage.getItem("restTabState")
if (state) {
const data = JSON.parse(state)
loadTabsFromPersistedState(data)
tabService.loadTabsFromPersistedState(data)
}
} catch (e) {
console.error(
@@ -334,7 +331,7 @@ export function setupRESTTabsPersistence() {
}
watchDebounced(
persistableTabState,
tabService.persistableTabState,
(state) => {
window.localStorage.setItem("restTabState", JSON.stringify(state))
},
@@ -343,11 +340,13 @@ export function setupRESTTabsPersistence() {
}
function setupGQLTabsPersistence() {
const tabService = getService(GQLTabService)
try {
const state = window.localStorage.getItem("gqlTabState")
if (state) {
const data = JSON.parse(state)
loadGQLTabsFromPersistedState(data)
tabService.loadTabsFromPersistedState(data)
}
} catch (e) {
console.error(
@@ -357,7 +356,7 @@ function setupGQLTabsPersistence() {
}
watchDebounced(
persistableGQLTabState,
tabService.persistableTabState,
(state) => {
window.localStorage.setItem("gqlTabState", JSON.stringify(state))
},

View File

@@ -7,23 +7,24 @@
<HoppSmartWindows
v-if="currentTabID"
:id="'gql_windows'"
v-model="currentTabID"
:model-value="currentTabID"
@update:model-value="(tabID) => tabs.setActiveTab(tabID)"
@remove-tab="removeTab"
@add-tab="addNewTab"
@sort="sortTabs"
>
<HoppSmartWindow
v-for="tab in tabs"
v-for="tab in activeTabs"
:id="tab.id"
:key="'removable_tab_' + tab.id"
:label="tab.document.request.name"
:is-removable="tabs.length > 1"
:is-removable="activeTabs.length > 1"
:close-visibility="'hover'"
>
<template #tabhead>
<GraphqlTabHead
:tab="tab"
:is-removable="tabs.length > 1"
:is-removable="activeTabs.length > 1"
@open-rename-modal="openReqRenameModal(tab)"
@close-tab="removeTab(tab.id)"
@close-other-tabs="closeOtherTabsAction(tab.id)"
@@ -89,21 +90,15 @@ import { computed, onBeforeUnmount, ref } from "vue"
import { defineActionHandler } from "~/helpers/actions"
import { connection, disconnect } from "~/helpers/graphql/connection"
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
import {
HoppGQLTab,
closeOtherTabs,
closeTab,
createNewTab,
currentTabID,
getActiveTabs,
getDirtyTabsCount,
getTabRef,
updateTab,
updateTabOrdering,
} from "~/helpers/graphql/tab"
import { HoppGQLDocument } from "~/helpers/graphql/document"
import { InspectionService } from "~/services/inspection"
import { HoppTab } from "~/services/tab"
import { GQLTabService } from "~/services/tab/graphql"
const t = useI18n()
const tabs = useService(GQLTabService)
const currentTabID = computed(() => tabs.currentTabID.value)
const inspectionService = useService(InspectionService)
@@ -113,27 +108,27 @@ usePageHead({
title: computed(() => t("navigation.graphql")),
})
const tabs = getActiveTabs()
const activeTabs = tabs.getActiveTabs()
const addNewTab = () => {
const tab = createNewTab({
const tab = tabs.createNewTab({
request: getDefaultGQLRequest(),
isDirty: false,
})
currentTabID.value = tab.id
tabs.setActiveTab(tab.id)
}
const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
updateTabOrdering(e.oldIndex, e.newIndex)
tabs.updateTabOrdering(e.oldIndex, e.newIndex)
}
const removeTab = (tabID: string) => {
const tabState = getTabRef(tabID).value
const tabState = tabs.getTabRef(tabID).value
if (tabState.document.isDirty) {
confirmingCloseForTabID.value = tabID
} else {
closeTab(tabState.id)
tabs.closeTab(tabState.id)
inspectionService.deleteTabInspectorResult(tabState.id)
}
}
@@ -150,7 +145,7 @@ const onCloseConfirm = () => {
*/
const onResolveConfirm = () => {
if (confirmingCloseForTabID.value) {
closeTab(confirmingCloseForTabID.value)
tabs.closeTab(confirmingCloseForTabID.value)
confirmingCloseForTabID.value = null
}
}
@@ -160,24 +155,24 @@ const unsavedTabsCount = ref(0)
const exceptedTabID = ref<string | null>(null)
const closeOtherTabsAction = (tabID: string) => {
const dirtyTabCount = getDirtyTabsCount()
const dirtyTabCount = tabs.getDirtyTabsCount()
// If there are dirty tabs, show the confirm modal
if (dirtyTabCount > 0) {
confirmingCloseAllTabs.value = true
unsavedTabsCount.value = dirtyTabCount
exceptedTabID.value = tabID
} else {
closeOtherTabs(tabID)
tabs.closeOtherTabs(tabID)
}
}
const onResolveConfirmCloseAllTabs = () => {
if (exceptedTabID.value) closeOtherTabs(exceptedTabID.value)
if (exceptedTabID.value) tabs.closeOtherTabs(exceptedTabID.value)
confirmingCloseAllTabs.value = false
}
const onTabUpdate = (tab: HoppGQLTab) => {
updateTab(tab)
const onTabUpdate = (tab: HoppTab<HoppGQLDocument>) => {
tabs.updateTab(tab)
}
onBeforeUnmount(() => {
@@ -189,33 +184,33 @@ onBeforeUnmount(() => {
const editReqModalReqName = ref("")
const showRenamingReqNameModalForTabID = ref<string>()
const openReqRenameModal = (tab: HoppGQLTab) => {
const openReqRenameModal = (tab: HoppTab<HoppGQLDocument>) => {
editReqModalReqName.value = tab.document.request.name
showRenamingReqNameModalForTabID.value = tab.id
}
const renameReqName = () => {
const tab = getTabRef(showRenamingReqNameModalForTabID.value!)
const tab = tabs.getTabRef(showRenamingReqNameModalForTabID.value!)
if (tab.value) {
tab.value.document.request.name = editReqModalReqName.value
updateTab(tab.value)
tabs.updateTab(tab.value)
}
showRenamingReqNameModalForTabID.value = undefined
}
const duplicateTab = (tabID: string) => {
const tab = getTabRef(tabID)
const tab = tabs.getTabRef(tabID)
if (tab.value) {
const newTab = createNewTab({
const newTab = tabs.createNewTab({
request: tab.value.document.request,
isDirty: true,
})
currentTabID.value = newTab.id
tabs.setActiveTab(newTab.id)
}
}
defineActionHandler("gql.request.open", ({ request, saveContext }) => {
createNewTab({
tabs.createNewTab({
saveContext,
request: request,
isDirty: false,
@@ -223,7 +218,7 @@ defineActionHandler("gql.request.open", ({ request, saveContext }) => {
})
defineActionHandler("request.rename", () => {
openReqRenameModal(getTabRef(currentTabID.value).value!)
openReqRenameModal(tabs.getTabRef(currentTabID.value).value!)
})
defineActionHandler("tab.duplicate-tab", ({ tabID }) => {
@@ -233,7 +228,7 @@ defineActionHandler("tab.close-current", () => {
removeTab(currentTabID.value)
})
defineActionHandler("tab.close-other", () => {
closeOtherTabs(currentTabID.value)
tabs.closeOtherTabs(currentTabID.value)
})
defineActionHandler("tab.open-new", addNewTab)
</script>

View File

@@ -11,17 +11,17 @@
@sort="sortTabs"
>
<HoppSmartWindow
v-for="tab in tabs"
v-for="tab in activeTabs"
:id="tab.id"
:key="tab.id"
:label="tab.document.request.name"
:is-removable="tabs.length > 1"
:is-removable="activeTabs.length > 1"
:close-visibility="'hover'"
>
<template #tabhead>
<HttpTabHead
:tab="tab"
:is-removable="tabs.length > 1"
:is-removable="activeTabs.length > 1"
@open-rename-modal="openReqRenameModal(tab.id)"
@close-tab="removeTab(tab.id)"
@close-other-tabs="closeOtherTabsAction(tab.id)"
@@ -99,21 +99,6 @@ import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { useRoute } from "vue-router"
import { useI18n } from "@composables/i18n"
import {
closeTab,
closeOtherTabs,
createNewTab,
currentActiveTab,
currentTabID,
getActiveTabs,
getTabRef,
HoppRESTTab,
loadTabsFromPersistedState,
persistableTabState,
updateTab,
updateTabOrdering,
getDirtyTabsCount,
} from "~/helpers/rest/tab"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { onLoggedIn } from "~/composables/auth"
@@ -128,7 +113,6 @@ import {
Subscription,
} from "rxjs"
import { useToast } from "~/composables/toast"
import { PersistableRESTTabState } from "~/helpers/rest/tab"
import { watchDebounced } from "@vueuse/core"
import { oauthRedirect } from "~/helpers/oauth"
import { useReadonlyStream } from "~/composables/stream"
@@ -142,6 +126,9 @@ import { HeaderInspectorService } from "~/services/inspection/inspectors/header.
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
import { cloneDeep } from "lodash-es"
import { RESTTabService } from "~/services/tab/rest"
import { HoppTab, PersistableTabState } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
const savingRequest = ref(false)
const confirmingCloseForTabID = ref<string | null>(null)
@@ -155,6 +142,10 @@ const renameTabID = ref<string | null>(null)
const t = useI18n()
const toast = useToast()
const tabs = useService(RESTTabService)
const currentTabID = tabs.currentTabID
type PopupDetails = {
show: boolean
position: {
@@ -173,13 +164,13 @@ const contextMenu = ref<PopupDetails>({
text: null,
})
const tabs = getActiveTabs()
const activeTabs = tabs.getActiveTabs()
const confirmSync = useReadonlyStream(currentSyncingStatus$, {
isInitialSync: false,
shouldSync: true,
})
const tabStateForSync = ref<PersistableRESTTabState | null>(null)
const tabStateForSync = ref<PersistableTabState<HoppRESTDocument> | null>(null)
function bindRequestToURLParams() {
const route = useRoute()
@@ -190,91 +181,92 @@ function bindRequestToURLParams() {
// We skip URL params parsing
if (Object.keys(query).length === 0 || query.code || query.error) return
const request = currentActiveTab.value.document.request
const request = tabs.currentActiveTab.value.document.request
currentActiveTab.value.document.request = safelyExtractRESTRequest(
tabs.currentActiveTab.value.document.request = safelyExtractRESTRequest(
translateExtURLParams(query, request),
getDefaultRESTRequest()
)
})
}
const onTabUpdate = (tab: HoppRESTTab) => {
updateTab(tab)
const onTabUpdate = (tab: HoppTab<HoppRESTDocument>) => {
tabs.updateTab(tab)
}
const addNewTab = () => {
const tab = createNewTab({
const tab = tabs.createNewTab({
request: getDefaultRESTRequest(),
isDirty: false,
})
currentTabID.value = tab.id
tabs.setActiveTab(tab.id)
}
const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
updateTabOrdering(e.oldIndex, e.newIndex)
tabs.updateTabOrdering(e.oldIndex, e.newIndex)
}
const inspectionService = useService(InspectionService)
const removeTab = (tabID: string) => {
const tabState = getTabRef(tabID).value
const tabState = tabs.getTabRef(tabID).value
if (tabState.document.isDirty) {
confirmingCloseForTabID.value = tabID
} else {
closeTab(tabState.id)
tabs.closeTab(tabState.id)
inspectionService.deleteTabInspectorResult(tabState.id)
}
}
const closeOtherTabsAction = (tabID: string) => {
const isTabDirty = getTabRef(tabID).value?.document.isDirty
const dirtyTabCount = getDirtyTabsCount()
const isTabDirty = tabs.getTabRef(tabID).value?.document.isDirty
const dirtyTabCount = tabs.getDirtyTabsCount()
// If current tab is dirty, so we need to subtract 1 from the dirty tab count
const balanceDirtyTabCount = isTabDirty ? dirtyTabCount - 1 : dirtyTabCount
// If there are dirty tabs, show the confirm modal
if (balanceDirtyTabCount > 0) {
confirmingCloseAllTabs.value = true
unsavedTabsCount.value = balanceDirtyTabCount
exceptedTabID.value = tabID
} else {
closeOtherTabs(tabID)
tabs.closeOtherTabs(tabID)
}
}
const duplicateTab = (tabID: string) => {
const tab = getTabRef(tabID)
const tab = tabs.getTabRef(tabID)
if (tab.value) {
const newTab = createNewTab({
const newTab = tabs.createNewTab({
request: cloneDeep(tab.value.document.request),
isDirty: true,
})
currentTabID.value = newTab.id
tabs.setActiveTab(newTab.id)
}
}
const onResolveConfirmCloseAllTabs = () => {
if (exceptedTabID.value) closeOtherTabs(exceptedTabID.value)
if (exceptedTabID.value) tabs.closeOtherTabs(exceptedTabID.value)
confirmingCloseAllTabs.value = false
}
const openReqRenameModal = (tabID?: string) => {
if (tabID) {
const tab = getTabRef(tabID)
const tab = tabs.getTabRef(tabID)
reqName.value = tab.value.document.request.name
renameTabID.value = tabID
} else {
reqName.value = currentActiveTab.value.document.request.name
reqName.value = tabs.currentActiveTab.value.document.request.name
}
showRenamingReqNameModal.value = true
}
const renameReqName = () => {
const tab = getTabRef(renameTabID.value ?? currentTabID.value)
const tab = tabs.getTabRef(renameTabID.value ?? currentTabID.value)
if (tab.value) {
tab.value.document.request.name = reqName.value
updateTab(tab.value)
tabs.updateTab(tab.value)
}
showRenamingReqNameModal.value = false
}
@@ -284,7 +276,7 @@ const renameReqName = () => {
*/
const onCloseConfirmSaveTab = () => {
if (!savingRequest.value && confirmingCloseForTabID.value) {
closeTab(confirmingCloseForTabID.value)
tabs.closeTab(confirmingCloseForTabID.value)
inspectionService.deleteTabInspectorResult(confirmingCloseForTabID.value)
confirmingCloseForTabID.value = null
}
@@ -294,11 +286,11 @@ const onCloseConfirmSaveTab = () => {
* Called when the user confirms they want to save the tab
*/
const onResolveConfirmSaveTab = () => {
if (currentActiveTab.value.document.saveContext) {
if (tabs.currentActiveTab.value.document.saveContext) {
invokeAction("request.save")
if (confirmingCloseForTabID.value) {
closeTab(confirmingCloseForTabID.value)
tabs.closeTab(confirmingCloseForTabID.value)
confirmingCloseForTabID.value = null
}
} else {
@@ -312,13 +304,14 @@ const onResolveConfirmSaveTab = () => {
const onSaveModalClose = () => {
savingRequest.value = false
if (confirmingCloseForTabID.value) {
closeTab(confirmingCloseForTabID.value)
tabs.closeTab(confirmingCloseForTabID.value)
confirmingCloseForTabID.value = null
}
}
const syncTabState = () => {
if (tabStateForSync.value) loadTabsFromPersistedState(tabStateForSync.value)
if (tabStateForSync.value)
tabs.loadTabsFromPersistedState(tabStateForSync.value)
}
/**
@@ -329,10 +322,11 @@ const syncTabState = () => {
*/
function startTabStateSync(): Subscription {
const currentUser$ = platform.auth.getCurrentUserStream()
const tabState$ = new BehaviorSubject<PersistableRESTTabState | null>(null)
const tabState$ =
new BehaviorSubject<PersistableTabState<HoppRESTDocument> | null>(null)
watchDebounced(
persistableTabState,
tabs.persistableTabState,
(state) => {
tabState$.next(state)
},
@@ -429,9 +423,10 @@ function oAuthURL() {
tokenInfo.hasOwnProperty("access_token")
) {
if (
currentActiveTab.value.document.request.auth.authType === "oauth-2"
tabs.currentActiveTab.value.document.request.auth.authType ===
"oauth-2"
) {
currentActiveTab.value.document.request.auth.token =
tabs.currentActiveTab.value.document.request.auth.token =
tokenInfo.access_token
}
}
@@ -462,7 +457,7 @@ bindRequestToURLParams()
oAuthURL()
defineActionHandler("rest.request.open", ({ doc }) => {
createNewTab(doc)
tabs.createNewTab(doc)
})
defineActionHandler("request.rename", openReqRenameModal)
@@ -473,7 +468,7 @@ defineActionHandler("tab.close-current", () => {
removeTab(currentTabID.value)
})
defineActionHandler("tab.close-other", () => {
closeOtherTabs(currentTabID.value)
tabs.closeOtherTabs(currentTabID.value)
})
defineActionHandler("tab.open-new", addNewTab)

View File

@@ -82,15 +82,18 @@ import {
import IconHome from "~icons/lucide/home"
import IconRefreshCW from "~icons/lucide/refresh-cw"
import { createNewTab } from "~/helpers/rest/tab"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { platform } from "~/platform"
import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
const route = useRoute()
const router = useRouter()
const t = useI18n()
const tabs = useService(RESTTabService)
const invalidLink = ref(false)
const shortcodeID = ref("")
@@ -127,7 +130,7 @@ const addRequestToTab = () => {
const request: unknown = JSON.parse(data.right.shortcode?.request as string)
createNewTab({
tabs.createNewTab({
request: safelyExtractRESTRequest(request, getDefaultRESTRequest()),
isDirty: false,
})

View File

@@ -60,6 +60,7 @@
<div class="py-4 space-y-4">
<div class="flex items-center">
<HoppSmartToggle
v-if="hasPlatformTelemetry"
:on="TELEMETRY_ENABLED"
@change="showConfirmModal"
>
@@ -134,6 +135,7 @@ import { InterceptorService } from "~/services/interceptor.service"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import { platform } from "~/platform"
const t = useI18n()
const colorMode = useColorMode()
@@ -163,6 +165,8 @@ const TELEMETRY_ENABLED = useSetting("TELEMETRY_ENABLED")
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
const hasPlatformTelemetry = Boolean(platform.platformFeatureFlags.hasTelemetry)
const confirmRemove = ref(false)
const proxySettings = computed(() => ({

View File

@@ -26,6 +26,7 @@ export type PlatformDef = {
additionalInspectors?: InspectorsPlatformDef
platformFeatureFlags: {
exportAsGIST: boolean
hasTelemetry: boolean
}
}

View File

@@ -1,10 +1,11 @@
import { PersistableRESTTabState } from "~/helpers/rest/tab"
import { PersistableTabState } from "~/services/tab"
import { HoppUser } from "./auth"
import { HoppRESTDocument } from "~/helpers/rest/document"
export type TabStatePlatformDef = {
loadTabStateFromSync: () => Promise<PersistableRESTTabState | null>
loadTabStateFromSync: () => Promise<PersistableTabState<HoppRESTDocument> | null>
writeCurrentTabState: (
user: HoppUser,
persistableTabState: PersistableRESTTabState
persistableTabState: PersistableTabState<HoppRESTDocument>
) => Promise<void>
}

View File

@@ -11,15 +11,6 @@ vi.mock("~/modules/i18n", () => ({
getI18n: () => (x: string) => x,
}))
const tabMock = vi.hoisted(() => ({
currentActiveTab: vi.fn(),
}))
vi.mock("~/helpers/rest/tab", () => ({
__esModule: true,
currentActiveTab: tabMock.currentActiveTab,
}))
describe("ParameterMenuService", () => {
it("registers with the contextmenu service upon initialization", () => {
const container = new TestContainer()

View File

@@ -1,5 +1,6 @@
import { TestContainer } from "dioc/testing"
import { describe, expect, it, vi } from "vitest"
import { RESTTabService } from "~/services/tab/rest"
import { ContextMenuService } from "../.."
import { URLMenuService } from "../url.menu"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
@@ -9,15 +10,6 @@ vi.mock("~/modules/i18n", () => ({
getI18n: () => (x: string) => x,
}))
const tabMock = vi.hoisted(() => ({
createNewTab: vi.fn(),
}))
vi.mock("~/helpers/rest/tab", () => ({
__esModule: true,
createNewTab: tabMock.createNewTab,
}))
describe("URLMenuService", () => {
it("registers with the contextmenu service upon initialization", () => {
const container = new TestContainer()
@@ -64,6 +56,10 @@ describe("URLMenuService", () => {
it("should call the openNewTab function when action is called and a new hoppscotch tab is opened", () => {
const container = new TestContainer()
const createNewTabFn = vi.fn()
container.bindMock(RESTTabService, {
createNewTab: createNewTabFn,
})
const url = container.bind(URLMenuService)
const test = "https://hoppscotch.io"
@@ -76,8 +72,8 @@ describe("URLMenuService", () => {
endpoint: test,
}
expect(tabMock.createNewTab).toHaveBeenCalledOnce()
expect(tabMock.createNewTab).toHaveBeenCalledWith({
expect(createNewTabFn).toHaveBeenCalledOnce()
expect(createNewTabFn).toHaveBeenCalledWith({
request: request,
isDirty: false,
})

View File

@@ -7,8 +7,9 @@ import {
} from "../"
import { markRaw, ref } from "vue"
import IconArrowDownRight from "~icons/lucide/arrow-down-right"
import { currentActiveTab } from "~/helpers/rest/tab"
import { getI18n } from "~/modules/i18n"
import { RESTTabService } from "~/services/tab/rest"
import { getService } from "~/modules/dioc"
//regex containing both url and parameter
const urlAndParameterRegex = new RegExp("[^&?]*?=[^&?]*")
@@ -88,20 +89,23 @@ export class ParameterMenuService extends Service implements ContextMenu {
queryParams.push({ key, value, active: true })
}
const tabService = getService(RESTTabService)
// add the parameters to the current request parameters
currentActiveTab.value.document.request.params = [
...currentActiveTab.value.document.request.params,
tabService.currentActiveTab.value.document.request.params = [
...tabService.currentActiveTab.value.document.request.params,
...queryParams,
]
if (newURL) {
currentActiveTab.value.document.request.endpoint = newURL
tabService.currentActiveTab.value.document.request.endpoint = newURL
} else {
// remove the parameter from the URL
const textRegex = new RegExp(`\\b${text.replace(/\?/g, "")}\\b`, "gi")
const sanitizedWord = currentActiveTab.value.document.request.endpoint
const sanitizedWord =
tabService.currentActiveTab.value.document.request.endpoint
const newURL = sanitizedWord.replace(textRegex, "")
currentActiveTab.value.document.request.endpoint = newURL
tabService.currentActiveTab.value.document.request.endpoint = newURL
}
}

View File

@@ -1,15 +1,15 @@
import { Service } from "dioc"
import { markRaw, ref } from "vue"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { getI18n } from "~/modules/i18n"
import { RESTTabService } from "~/services/tab/rest"
import IconCopyPlus from "~icons/lucide/copy-plus"
import {
ContextMenu,
ContextMenuResult,
ContextMenuService,
ContextMenuState,
} from ".."
import { markRaw, ref } from "vue"
import IconCopyPlus from "~icons/lucide/copy-plus"
import { createNewTab } from "~/helpers/rest/tab"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { getI18n } from "~/modules/i18n"
/**
* Used to check if a string is a valid URL
@@ -37,6 +37,7 @@ export class URLMenuService extends Service implements ContextMenu {
public readonly menuID = "url"
private readonly contextMenu = this.bind(ContextMenuService)
private readonly restTab = this.bind(RESTTabService)
constructor() {
super()
@@ -55,7 +56,7 @@ export class URLMenuService extends Service implements ContextMenu {
endpoint: url,
}
createNewTab({
this.restTab.createNewTab({
request: request,
isDirty: false,
})

View File

@@ -3,8 +3,8 @@ import { refDebounced } from "@vueuse/core"
import { Service } from "dioc"
import { computed, markRaw, reactive } from "vue"
import { Component, Ref, ref, watch } from "vue"
import { currentActiveTab } from "~/helpers/rest/tab"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { RESTTabService } from "../tab/rest"
/**
* Defines how to render the text in an Inspector Result
@@ -105,6 +105,8 @@ export class InspectionService extends Service {
private tabs: Ref<Map<string, InspectorResult[]>> = ref(new Map())
private readonly restTab = this.bind(RESTTabService)
constructor() {
super()
@@ -122,10 +124,14 @@ export class InspectionService extends Service {
private initializeListeners() {
watch(
() => [this.inspectors.entries(), currentActiveTab.value.id],
() => [this.inspectors.entries(), this.restTab.currentActiveTab.value.id],
() => {
const reqRef = computed(() => currentActiveTab.value.document.request)
const resRef = computed(() => currentActiveTab.value.response)
const reqRef = computed(
() => this.restTab.currentActiveTab.value.document.request
)
const resRef = computed(
() => this.restTab.currentActiveTab.value.document.response
)
const debouncedReq = refDebounced(reqRef, 1000, { maxWait: 2000 })
const debouncedRes = refDebounced(resRef, 1000, { maxWait: 2000 })
@@ -142,7 +148,7 @@ export class InspectionService extends Service {
() => [...inspectorRefs.flatMap((x) => x!.value)],
() => {
this.tabs.value.set(
currentActiveTab.value.id,
this.restTab.currentActiveTab.value.id,
activeInspections.value
)
},

View File

@@ -7,20 +7,12 @@ import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppAction, HoppActionWithArgs } from "~/helpers/actions"
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
import { RESTTabService } from "~/services/tab/rest"
async function flushPromises() {
return await new Promise((r) => setTimeout(r))
}
const tabMock = vi.hoisted(() => ({
createNewTab: vi.fn(),
}))
vi.mock("~/helpers/rest/tab", () => ({
__esModule: true,
createNewTab: tabMock.createNewTab,
}))
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
@@ -72,8 +64,16 @@ describe("HistorySpotlightSearcherService", () => {
y = historyMock.restEntries.pop()
}
const container = new TestContainer()
const createNewTabFn = vi.fn()
container.bindMock(RESTTabService, {
createNewTab: createNewTabFn,
})
actionsMock.invokeAction.mockReset()
tabMock.createNewTab.mockReset()
createNewTabFn.mockReset()
})
it("registers with the spotlight service upon initialization", async () => {

View File

@@ -16,10 +16,6 @@ import {
import IconFolder from "~icons/lucide/folder"
import RESTRequestSpotlightEntry from "~/components/app/spotlight/entry/RESTRequest.vue"
import GQLRequestSpotlightEntry from "~/components/app/spotlight/entry/GQLRequest.vue"
import { createNewTab } from "~/helpers/rest/tab"
import { createNewTab as createNewGQLTab } from "~/helpers/graphql/tab"
import { getTabRefWithSaveContext } from "~/helpers/rest/tab"
import { currentTabID } from "~/helpers/rest/tab"
import {
HoppCollection,
HoppGQLRequest,
@@ -27,6 +23,8 @@ import {
} from "@hoppscotch/data"
import { WorkspaceService } from "~/services/workspace.service"
import { invokeAction } from "~/helpers/actions"
import { RESTTabService } from "~/services/tab/rest"
import { GQLTabService } from "~/services/tab/graphql"
/**
* A spotlight searcher that searches through the user's collections
@@ -44,6 +42,9 @@ export class CollectionsSpotlightSearcherService
public searcherID = "collections"
public searcherSectionTitle = this.t("collection.my_collections")
private readonly restTab = this.bind(RESTTabService)
private readonly gqlTab = this.bind(GQLTabService)
private readonly spotlight = this.bind(SpotlightService)
private readonly workspaceService = this.bind(WorkspaceService)
@@ -290,21 +291,21 @@ export class CollectionsSpotlightSearcherService
})
}
const possibleTab = getTabRefWithSaveContext({
const possibleTab = this.restTab.getTabRefWithSaveContext({
originLocation: "user-collection",
folderPath: folderPath.join("/"),
requestIndex: reqIndex,
})
if (possibleTab) {
currentTabID.value = possibleTab.value.id
this.restTab.setActiveTab(possibleTab.value.id)
} else {
const req = this.getRESTFolderFromFolderPath(folderPath.join("/"))
?.requests[reqIndex]
if (!req) return
createNewTab(
this.restTab.createNewTab(
{
request: req,
isDirty: false,
@@ -326,7 +327,7 @@ export class CollectionsSpotlightSearcherService
if (!req) return
createNewGQLTab({
this.gqlTab.createNewTab({
saveContext: {
originLocation: "user-collection",
folderPath: folderPath.join("/"),

View File

@@ -8,8 +8,7 @@ import {
} from "./base/static.searcher"
import { useRoute } from "vue-router"
import { RequestOptionTabs } from "~/components/http/RequestOptions.vue"
import { currentActiveTab } from "~/helpers/rest/tab"
import { RESTOptionTabs } from "~/components/http/RequestOptions.vue"
import IconWindow from "~icons/lucide/app-window"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCode2 from "~icons/lucide/code-2"
@@ -20,6 +19,7 @@ import IconPlay from "~icons/lucide/play"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconSave from "~icons/lucide/save"
import { GQLOptionTabs } from "~/components/graphql/RequestOptions.vue"
import { RESTTabService } from "~/services/tab/rest"
type Doc = {
text: string | string[]
@@ -43,6 +43,7 @@ export class RequestSpotlightSearcherService extends StaticSpotlightSearcherServ
public searcherSectionTitle = this.t("shortcut.request.title")
private readonly spotlight = this.bind(SpotlightService)
private readonly restTab = this.bind(RESTTabService)
private route = useRoute()
private isRESTPage = computed(() => this.route.name === "index")
@@ -247,7 +248,7 @@ export class RequestSpotlightSearcherService extends StaticSpotlightSearcherServ
}
}
private openRequestTab(tab: RequestOptionTabs | GQLOptionTabs): void {
private openRequestTab(tab: RESTOptionTabs | GQLOptionTabs): void {
invokeAction("request.open-tab", {
tab,
})
@@ -267,7 +268,7 @@ export class RequestSpotlightSearcherService extends StaticSpotlightSearcherServ
case "save_to_collections":
invokeAction("request.save-as", {
requestType: "rest",
request: currentActiveTab.value?.document.request,
request: this.restTab.currentActiveTab.value?.document.request,
})
break
case "save_request":

View File

@@ -12,8 +12,8 @@ import IconCopyPlus from "~icons/lucide/copy-plus"
import IconXCircle from "~icons/lucide/x-circle"
import IconXSquare from "~icons/lucide/x-square"
import { invokeAction } from "~/helpers/actions"
import { getActiveTabs as getRESTActiveTabs } from "~/helpers/rest/tab"
import { getActiveTabs as getGQLActiveTabs } from "~/helpers/graphql/tab"
import { RESTTabService } from "~/services/tab/rest"
import { GQLTabService } from "~/services/tab/graphql"
type Doc = {
text: string | string[]
@@ -42,12 +42,14 @@ export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<
private showAction = computed(
() => this.route.name === "index" || this.route.name === "graphql"
)
private gqlActiveTabs = getGQLActiveTabs()
private restActiveTabs = getRESTActiveTabs()
private readonly restTab = this.bind(RESTTabService)
private readonly gqlTab = this.bind(GQLTabService)
private isOnlyTab = computed(() =>
this.route.name === "graphql"
? this.gqlActiveTabs.value.length === 1
: this.restActiveTabs.value.length === 1
? this.gqlTab.getActiveTabs().value.length === 1
: this.restTab.getActiveTabs().value.length === 1
)
private documents: Record<string, Doc> = reactive({

View File

@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest"
import { TestContainer } from "dioc/testing"
import { TabService } from "../tab"
import { reactive } from "vue"
class MockTabService extends TabService<{ request: string }> {
public static readonly ID = "MOCK_TAB_SERVICE"
constructor() {
super()
this.tabMap = reactive(
new Map([
[
"test",
{
id: "test",
document: {
request: "test request",
},
},
],
])
)
this.watchCurrentTabID()
}
}
describe("TabService", () => {
it("initially only one tab is defined", () => {
const container = new TestContainer()
const service = container.bind(MockTabService)
expect(service.getActiveTabs().value.length).toEqual(1)
})
it("initially the only tab is the active tab", () => {
const container = new TestContainer()
const service = container.bind(MockTabService)
expect(service.getActiveTab()).not.toBeNull()
})
it("initially active tab id is test", () => {
const container = new TestContainer()
const service = container.bind(MockTabService)
expect(service.getActiveTab()?.id).toEqual("test")
})
it("add new tab", () => {
const container = new TestContainer()
const service = container.bind(MockTabService)
service.createNewTab({
request: "new request",
})
expect(service.getActiveTabs().value.length).toEqual(2)
})
it("get active tab", () => {
const container = new TestContainer()
const service = container.bind(MockTabService)
console.log(service.getActiveTab())
expect(service.getActiveTab()?.id).toEqual("test")
})
it("sort tabs", () => {
const container = new TestContainer()
const service = container.bind(MockTabService)
const currentOrder = service.updateTabOrdering(1, 0)
expect(currentOrder[1]).toEqual("test")
})
it("update tab", () => {
const container = new TestContainer()
const service = container.bind(MockTabService)
service.updateTab({
id: service.currentTabID.value,
document: {
request: "updated request",
},
})
expect(service.getActiveTab()?.document.request).toEqual("updated request")
})
it("set new active tab", () => {
const container = new TestContainer()
const service = container.bind(MockTabService)
service.setActiveTab("test")
expect(service.getActiveTab()?.id).toEqual("test")
})
it("close other tabs", () => {
const container = new TestContainer()
const service = container.bind(MockTabService)
service.closeOtherTabs("test")
expect(service.getActiveTabs().value.length).toEqual(1)
})
it("close tab", () => {
const container = new TestContainer()
const service = container.bind(MockTabService)
service.createNewTab({
request: "new request",
})
expect(service.getActiveTabs().value.length).toEqual(2)
service.closeTab("test")
expect(service.getActiveTabs().value.length).toEqual(1)
})
})

View File

@@ -0,0 +1,66 @@
import { isEqual } from "lodash-es"
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
import { HoppGQLDocument, HoppGQLSaveContext } from "~/helpers/graphql/document"
import { TabService } from "./tab"
import { computed } from "vue"
export class GQLTabService extends TabService<HoppGQLDocument> {
public static readonly ID = "GQL_TAB_SERVICE"
constructor() {
super()
this.tabMap.set("test", {
id: "test",
document: {
request: getDefaultGQLRequest(),
isDirty: false,
optionTabPreference: "query",
},
})
this.watchCurrentTabID()
}
// override persistableTabState to remove response from the document
public override persistableTabState = computed(() => ({
lastActiveTabID: this.currentTabID.value,
orderedDocs: this.tabOrdering.value.map((tabID) => {
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: {
...tab.document,
response: null,
},
}
}),
}))
public getTabRefWithSaveContext(ctx: HoppGQLSaveContext) {
for (const tab of this.tabMap.values()) {
// For `team-collection` request id can be considered unique
if (ctx?.originLocation === "team-collection") {
if (
tab.document.saveContext?.originLocation === "team-collection" &&
tab.document.saveContext.requestID === ctx.requestID
) {
return this.getTabRef(tab.id)
}
} else if (isEqual(ctx, tab.document.saveContext))
return this.getTabRef(tab.id)
}
return null
}
public getDirtyTabsCount() {
let count = 0
for (const tab of this.tabMap.values()) {
if (tab.document.isDirty) count++
}
return count
}
}

View File

@@ -0,0 +1,116 @@
import { ComputedRef, WritableComputedRef } from "vue"
/**
* Represents a tab in HoppScotch.
* @template Doc The type of the document associated with the tab.
*/
export type HoppTab<Doc> = {
/** The unique identifier of the tab. */
id: string
/** The document associated with the tab. */
document: Doc
}
export type PersistableTabState<Doc> = {
lastActiveTabID: string
orderedDocs: Array<{
tabID: string
doc: Doc
}>
}
/**
* Represents a service for managing tabs with documents.
* @template Doc - The type of document associated with each tab.
*/
export interface TabService<Doc> {
/**
* Gets the current active tab.
*/
currentActiveTab: ComputedRef<HoppTab<Doc>>
/**
* Creates a new tab with the given document and sets it as the active tab.
* @param document - The document to associate with the new tab.
* @returns The newly created tab.
*/
createNewTab(document: Doc): HoppTab<Doc>
/**
* Gets an array of all tabs.
* @returns An array of all tabs.
*/
getTabs(): HoppTab<Doc>[]
/**
* Gets the currently active tab.
* @returns The active tab or null if no tab is active.
*/
getActiveTab(): HoppTab<Doc> | null
/**
* Sets the active tab by its ID.
* @param tabID - The ID of the tab to set as active.
*/
setActiveTab(tabID: string): void
/**
* Loads tabs and their ordering from a persisted state.
* @param data - The persisted tab state to load.
*/
loadTabsFromPersistedState(data: PersistableTabState<Doc>): void
/**
* Gets a read-only computed reference to the active tabs.
* @returns A computed reference to the active tabs.
*/
getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>>
/**
* Gets a computed reference to a specific tab by its ID.
* @param tabID - The ID of the tab to retrieve.
* @returns A computed reference to the specified tab.
* @throws An error if the tab with the specified ID does not exist.
*/
getTabRef(tabID: string): WritableComputedRef<HoppTab<Doc>>
/**
* Updates the properties of a tab.
* @param tabUpdate - The updated tab object.
*/
updateTab(tabUpdate: HoppTab<Doc>): void
/**
* Updates the ordering of tabs by moving a tab from one index to another.
* @param fromIndex - The current index of the tab to move.
* @param toIndex - The target index where the tab should be moved to.
*/
updateTabOrdering(fromIndex: number, toIndex: number): void
/**
* Closes the tab with the specified ID.
* @param tabID - The ID of the tab to close.
*/
closeTab(tabID: string): void
/**
* Closes all tabs except the one with the specified ID.
* @param tabID - The ID of the tab to keep open.
*/
closeOtherTabs(tabID: string): void
/**
* Gets a computed reference to a persistable tab state.
* @returns A computed reference to a persistable tab state object.
*/
persistableTabState: ComputedRef<PersistableTabState<Doc>>
/**
* Gets computed references to tabs that match a specified condition.
* @param func - A function that defines the condition for selecting tabs.
* @returns An array of computed references to matching tabs.
*/
getTabsRefTo(
func: (tab: HoppTab<Doc>) => boolean
): WritableComputedRef<HoppTab<Doc>>[]
}

View File

@@ -0,0 +1,67 @@
import { isEqual } from "lodash-es"
import { computed } from "vue"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTDocument, HoppRESTSaveContext } from "~/helpers/rest/document"
import { TabService } from "./tab"
export class RESTTabService extends TabService<HoppRESTDocument> {
public static readonly ID = "REST_TAB_SERVICE"
constructor() {
super()
this.tabMap.set("test", {
id: "test",
document: {
request: getDefaultRESTRequest(),
isDirty: false,
optionTabPreference: "params",
},
})
this.watchCurrentTabID()
}
// override persistableTabState to remove response from the document
public override persistableTabState = computed(() => ({
lastActiveTabID: this.currentTabID.value,
orderedDocs: this.tabOrdering.value.map((tabID) => {
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: {
...tab.document,
response: null,
},
}
}),
}))
public getTabRefWithSaveContext(ctx: HoppRESTSaveContext) {
for (const tab of this.tabMap.values()) {
// For `team-collection` request id can be considered unique
if (ctx?.originLocation === "team-collection") {
if (
tab.document.saveContext?.originLocation === "team-collection" &&
tab.document.saveContext.requestID === ctx.requestID
) {
return this.getTabRef(tab.id)
}
} else if (isEqual(ctx, tab.document.saveContext)) {
return this.getTabRef(tab.id)
}
}
return null
}
public getDirtyTabsCount() {
let count = 0
for (const tab of this.tabMap.values()) {
if (tab.document.isDirty) count++
}
return count
}
}

View File

@@ -0,0 +1,207 @@
import { refWithControl } from "@vueuse/core"
import { Service } from "dioc"
import { v4 as uuidV4 } from "uuid"
import {
ComputedRef,
computed,
nextTick,
reactive,
ref,
shallowReadonly,
watch,
} from "vue"
import {
HoppTab,
PersistableTabState,
TabService as TabServiceInterface,
} from "."
export abstract class TabService<Doc>
extends Service
implements TabServiceInterface<Doc>
{
protected tabMap = reactive(new Map<string, HoppTab<Doc>>())
protected tabOrdering = ref<string[]>(["test"])
public currentTabID = refWithControl("test", {
onBeforeChange: (newTabID) => {
if (!newTabID || !this.tabMap.has(newTabID)) {
console.warn(
`Tried to set current tab id to an invalid value. (value: ${newTabID})`
)
// Don't allow change
return false
}
},
})
public currentActiveTab = computed(
() => this.tabMap.get(this.currentTabID.value)!
) // Guaranteed to not be undefined
protected watchCurrentTabID() {
watch(
this.tabOrdering,
(newOrdering) => {
if (
!this.currentTabID.value ||
!newOrdering.includes(this.currentTabID.value)
) {
this.setActiveTab(newOrdering[newOrdering.length - 1]) // newOrdering should always be non-empty
}
},
{ deep: true }
)
}
public createNewTab(document: Doc, switchToIt = true): HoppTab<Doc> {
const id = this.generateNewTabID()
const tab: HoppTab<Doc> = { id, document }
this.tabMap.set(id, tab)
this.tabOrdering.value.push(id)
if (switchToIt) {
this.setActiveTab(id)
}
return tab
}
public getTabs(): HoppTab<Doc>[] {
return Array.from(this.tabMap.values())
}
public getActiveTab(): HoppTab<Doc> | null {
return this.tabMap.get(this.currentTabID.value) ?? null
}
public setActiveTab(tabID: string): void {
this.currentTabID.value = tabID
}
public loadTabsFromPersistedState(data: PersistableTabState<Doc>): void {
if (data) {
this.tabMap.clear()
this.tabOrdering.value = []
for (const doc of data.orderedDocs) {
this.tabMap.set(doc.tabID, {
id: doc.tabID,
document: doc.doc,
})
this.tabOrdering.value.push(doc.tabID)
}
this.setActiveTab(data.lastActiveTabID)
}
}
public getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>> {
return shallowReadonly(
computed(() => this.tabOrdering.value.map((x) => this.tabMap.get(x)!))
)
}
public getTabRef(tabID: string) {
return computed({
get: () => {
const result = this.tabMap.get(tabID)
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
return result
},
set: (value) => {
return this.tabMap.set(tabID, value)
},
})
}
public updateTab(tabUpdate: HoppTab<Doc>) {
if (!this.tabMap.has(tabUpdate.id)) {
console.warn(
`Cannot update tab as tab with that tab id does not exist (id: ${tabUpdate.id})`
)
}
this.tabMap.set(tabUpdate.id, tabUpdate)
}
public updateTabOrdering(fromIndex: number, toIndex: number) {
this.tabOrdering.value.splice(
toIndex,
0,
this.tabOrdering.value.splice(fromIndex, 1)[0]
)
return this.tabOrdering.value
}
public closeTab(tabID: string) {
if (!this.tabMap.has(tabID)) {
console.warn(
`Tried to close a tab which does not exist (tab id: ${tabID})`
)
return
}
if (this.tabOrdering.value.length === 1) {
console.warn(
`Tried to close the only tab open, which is not allowed. (tab id: ${tabID})`
)
return
}
this.tabOrdering.value.splice(this.tabOrdering.value.indexOf(tabID), 1)
nextTick(() => {
this.tabMap.delete(tabID)
})
}
public closeOtherTabs(tabID: string) {
if (!this.tabMap.has(tabID)) {
console.warn(
`The tab to close other tabs does not exist (tab id: ${tabID})`
)
return
}
this.tabOrdering.value = [tabID]
this.tabMap.forEach((_, id) => {
if (id !== tabID) this.tabMap.delete(id)
})
this.currentTabID.value = tabID
}
public persistableTabState = computed<PersistableTabState<Doc>>(() => ({
lastActiveTabID: this.currentTabID.value,
orderedDocs: this.tabOrdering.value.map((tabID) => {
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: tab.document,
}
}),
}))
public getTabsRefTo(func: (tab: HoppTab<Doc>) => boolean) {
return Array.from(this.tabMap.values())
.filter(func)
.map((tab) => this.getTabRef(tab.id))
}
private generateNewTabID() {
while (true) {
const id = uuidV4()
if (!this.tabMap.has(id)) return id
}
}
}

View File

@@ -1,10 +1,10 @@
function generateREForProtocol(protocol) {
return [
new RegExp(
`${protocol}(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$`
`${protocol}(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]).){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(:[0-9]+)?(\\/[^?#]*)?(\\?[^#]*)?(#.*)?$`
),
new RegExp(
`${protocol}(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]).)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9/])$`
`${protocol}(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]).)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9/])(:[0-9]+)?(\\/[^?#]*)?(\\?[^#]*)?(#.*)?$`
),
]
}

View File

@@ -6,7 +6,9 @@
"main": "dist/hoppscotch-data.cjs",
"module": "dist/hoppscotch-data.js",
"types": "./dist/index.d.ts",
"files": [ "dist/*" ],
"files": [
"dist/*"
],
"scripts": {
"build:code": "vite build",
"build:decl": "tsc --project tsconfig.decl.json",
@@ -33,13 +35,15 @@
"homepage": "https://github.com/hoppscotch/hoppscotch#readme",
"devDependencies": {
"@types/lodash": "^4.14.181",
"typescript": "^4.6.3",
"typescript": "^5.2.2",
"vite": "^3.2.3"
},
"dependencies": {
"fp-ts": "^2.11.10",
"io-ts": "^2.2.16",
"lodash": "^4.17.21",
"parser-ts": "^0.6.16"
"parser-ts": "^0.6.16",
"verzod": "^0.1.1",
"zod": "^3.22.2"
}
}

View File

@@ -1,14 +1,22 @@
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either"
import { pipe } from "fp-ts/function"
import { InferredEntity, createVersionedEntity } from "verzod"
export type Environment = {
id?: string
name: string
variables: {
key: string
value: string
}[]
}
import V0_VERSION from "./v/0"
export const Environment = createVersionedEntity({
latestVersion: 0,
versionMap: {
0: V0_VERSION
},
getVersion(x) {
return V0_VERSION.schema.safeParse(x).success
? 0
: null
}
})
export type Environment = InferredEntity<typeof Environment>
const REGEX_ENV_VAR = /<<([^>]*)>>/g // "<<myVariable>>"

View File

@@ -0,0 +1,18 @@
import { z } from "zod"
import { defineVersion } from "verzod"
export const V0_SCHEMA = z.object({
id: z.optional(z.string()),
name: z.string(),
variables: z.array(
z.object({
key: z.string(),
value: z.string(),
})
)
})
export default defineVersion({
initial: true,
schema: V0_SCHEMA
})

View File

@@ -1,43 +0,0 @@
export type HoppGQLAuthNone = {
authType: "none"
}
export type HoppGQLAuthBasic = {
authType: "basic"
username: string
password: string
}
export type HoppGQLAuthBearer = {
authType: "bearer"
token: string
}
export type HoppGQLAuthOAuth2 = {
authType: "oauth-2"
token: string
oidcDiscoveryURL: string
authURL: string
accessTokenURL: string
clientID: string
scope: string
}
export type HoppGQLAuthAPIKey = {
authType: "api-key"
key: string
value: string
addTo: string
}
export type HoppGQLAuth = { authActive: boolean } & (
| HoppGQLAuthNone
| HoppGQLAuthBasic
| HoppGQLAuthBearer
| HoppGQLAuthOAuth2
| HoppGQLAuthAPIKey
)

View File

@@ -1,51 +1,75 @@
import { HoppGQLAuth } from "./HoppGQLAuth"
import { InferredEntity, createVersionedEntity } from "verzod"
import { z } from "zod"
import V1_VERSION from "./v/1"
import V2_VERSION from "./v/2"
export * from "./HoppGQLAuth"
export { GQLHeader } from "./v/1"
export {
HoppGQLAuth,
HoppGQLAuthAPIKey,
HoppGQLAuthBasic,
HoppGQLAuthBearer,
HoppGQLAuthNone,
HoppGQLAuthOAuth2,
} from "./v/2"
export const GQL_REQ_SCHEMA_VERSION = 2
export type GQLHeader = {
key: string
value: string
active: boolean
}
const versionedObject = z.object({
v: z.number(),
})
export type HoppGQLRequest = {
id?: string
v: number
name: string
url: string
headers: GQLHeader[]
query: string
variables: string
auth: HoppGQLAuth
}
export const HoppGQLRequest = createVersionedEntity({
latestVersion: 2,
versionMap: {
1: V1_VERSION,
2: V2_VERSION,
},
getVersion(x) {
const result = versionedObject.safeParse(x)
export function translateToGQLRequest(x: any): HoppGQLRequest {
if (x.v && x.v === GQL_REQ_SCHEMA_VERSION) return x
return result.success ? result.data.v : null
},
})
// Old request
const name = x.name ?? "Untitled"
const url = x.url ?? ""
const headers = x.headers ?? []
const query = x.query ?? ""
const variables = x.variables ?? []
const auth = x.auth ?? {
authType: "none",
authActive: true,
export type HoppGQLRequest = InferredEntity<typeof HoppGQLRequest>
const DEFAULT_QUERY = `
query Request {
method
url
headers {
key
value
}
}`.trim()
export function getDefaultGQLRequest(): HoppGQLRequest {
return {
v: GQL_REQ_SCHEMA_VERSION,
name,
url,
headers,
query,
variables,
auth
name: "Untitled",
url: "https://echo.hoppscotch.io/graphql",
headers: [],
variables: `
{
"id": "1"
}`.trim(),
query: DEFAULT_QUERY,
auth: {
authType: "none",
authActive: true,
},
}
}
/**
* @deprecated This function is deprecated. Use `HoppGQLRequest` instead.
*/
export function translateToGQLRequest(x: unknown): HoppGQLRequest {
const result = HoppGQLRequest.safeParse(x)
return result.type === "ok" ? result.value : getDefaultGQLRequest()
}
export function makeGQLRequest(x: Omit<HoppGQLRequest, "v">): HoppGQLRequest {
return {
v: GQL_REQ_SCHEMA_VERSION,

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