Compare commits

..

25 Commits

Author SHA1 Message Date
jamesgeorge007
019c2cec46 refactor: remove unnecessary check for slot contents 2023-12-12 14:26:13 +05:30
Liyas Thomas
40b9508361 chore: use template wrapper for slot 2023-12-12 14:26:13 +05:30
Liyas Thomas
85285a5204 fix: search input height 2023-12-12 14:26:13 +05:30
Liyas Thomas
c0c0c37a67 chore: improve placeholder component styles 2023-12-12 14:26:13 +05:30
Liyas Thomas
4ac8a117ef chore: add protocol logos to realtime page (#3637) 2023-12-12 14:25:56 +05:30
Muhammed Ajmal M
c1bc430ee6 fix: overflowing modal fix on small screens (#3643)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-12-12 14:21:19 +05:30
James George
9201aa7d7d fix(common): ensure banner colors are displayed correctly (#3630) 2023-12-12 13:55:18 +05:30
Liyas Thomas
87395a4553 fix: minor ui issues 2023-12-07 17:12:33 +05:30
Andrew Bastin
6063c633ee chore: pin vue workspace-wide to 3.3.9 2023-12-07 17:04:17 +05:30
Akash K
7481feb366 feat: introduce platform defs for adding additional spotlight searchers (#3631) 2023-12-07 16:28:02 +05:30
James George
bdfa14fa54 refactor(scripting-revamp): migrate js-sandbox to web worker/Node vm based implementation (#3619) 2023-12-07 16:10:42 +05:30
Andrew Bastin
0a61ec2bfe fix: useI18n type breaking 2023-12-07 15:17:41 +05:30
Nivedin
2bf0106aa2 feat: embeds (#3627)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-12-07 15:03:49 +05:30
Akash K
ab7c29d228 refactor: revamp the importers & exporters systems to be reused (#3425)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-06 21:24:29 +05:30
Joel Jacob Stephen
d9c75ed79e feat: introducing shared requests to admin dashboard (#3537)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-06 00:21:28 +05:30
Anwarul Islam
6fa722df7b chore: hoppscotch-ui improvements (#3497)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-12-06 00:08:44 +05:30
Balu Babu
18864bfecf feat: addition of data field into User and Team Collections (#3614)
* feat: added new columns into the TeamCollections and UserCollections models

* feat: completed addition of new data field in TeamCollection

* feat: completed addition of new data field in UserCollections

* chore: updated all tests in team-collection module

* chore: added tests for updateTeamCollection method in team-collection module

* chore: refactored all existing testcases in user-collection to reflect new changes

* chore: added new testcases for updateUserCollection method in user-collection module

* chore: made data field optional in team and user collections

* chore: fixed edgecases for data being null

* chore: resolved issue with team-request testcases

* chore: completed changes requested in PR review

* chore: changed target to prod in hoppscotch-old-backend service
2023-12-05 20:12:37 +05:30
Akash K
95754cb2b4 chore: bump deps for hoppscotch-common and hoppscotch-selfhost-web (#3575) 2023-12-05 17:33:25 +05:30
Gaurav K P
ed2a461dc5 feat(common): display status text from the API response if available (#3466)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-04 23:31:49 +05:30
Muhammed Ajmal M
8d5a456dbd fix(common): parentheses and single quotes support to curl imports (#3509) 2023-12-04 23:03:22 +05:30
Nivedin
2528bbb92f feat: shared request (#3486)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-04 22:51:18 +05:30
Liyas Thomas
259cd48dbb feat: init boring avatars (#3615) 2023-12-04 13:20:26 +05:30
Joel Jacob Stephen
b43531f200 feat: add ability to make banners dismissible + new info and warning color schemes added based on themes (#3586)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-12-04 00:57:37 +05:30
Muhammed Ajmal M
26da3e18a9 fix(common): prevented truncating parameters (#3502) 2023-12-04 00:49:45 +05:30
Rajdip Bhattacharya
bb4b640e58 feat: convert json to interfaces (#3566)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2023-12-03 23:14:26 +05:30
274 changed files with 9620 additions and 7505 deletions

View File

@@ -32,6 +32,9 @@
"lint-staged": "12.4.0" "lint-staged": "12.4.0"
}, },
"pnpm": { "pnpm": {
"overrides": {
"vue": "3.3.9"
},
"packageExtensions": { "packageExtensions": {
"httpsnippet@^3.0.1": { "httpsnippet@^3.0.1": {
"peerDependencies": { "peerDependencies": {

View File

@@ -147,7 +147,7 @@ module.exports = {
// The glob patterns Jest uses to detect test files // The glob patterns Jest uses to detect test files
testMatch: [ testMatch: [
// "**/__tests__/**/*.[jt]s?(x)", // "**/__tests__/**/*.[jt]s?(x)",
"**/src/__tests__/**/*.*.ts", "**/src/__tests__/commands/**/*.*.ts",
], ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped

View File

@@ -19,8 +19,9 @@
"debugger": "node debugger.js 9999", "debugger": "node debugger.js 9999",
"prepublish": "pnpm exec tsup", "prepublish": "pnpm exec tsup",
"prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write", "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write",
"test": "pnpm run build && jest && rm -rf dist",
"do-typecheck": "pnpm exec tsc --noEmit", "do-typecheck": "pnpm exec tsc --noEmit",
"test": "pnpm run build && jest && rm -rf dist" "do-test": "pnpm test"
}, },
"keywords": [ "keywords": [
"cli", "cli",

View File

@@ -106,7 +106,7 @@ describe("Test 'hopp test <file> --env <file>' command:", () => {
const TESTS_PATH = getTestJsonFilePath("env-flag-tests.json"); const TESTS_PATH = getTestJsonFilePath("env-flag-tests.json");
const ENV_PATH = getTestJsonFilePath("env-flag-envs.json"); const ENV_PATH = getTestJsonFilePath("env-flag-envs.json");
const cmd = `node ./bin/hopp test ${TESTS_PATH} --env ${ENV_PATH}`; const cmd = `node ./bin/hopp test ${TESTS_PATH} --env ${ENV_PATH}`;
const { error } = await execAsync(cmd); const { error, stdout } = await execAsync(cmd);
expect(error).toBeNull(); expect(error).toBeNull();
}); });
@@ -129,7 +129,6 @@ describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
const cmd = `${VALID_TEST_CMD} --delay 'NaN'`; const cmd = `${VALID_TEST_CMD} --delay 'NaN'`;
const { stderr } = await execAsync(cmd); const { stderr } = await execAsync(cmd);
const out = getErrorCode(stderr); const out = getErrorCode(stderr);
console.log("invalid value thing", out)
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT"); expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
}); });

View File

@@ -1,7 +1,6 @@
{ {
"URL": "https://echo.hoppscotch.io", "URL": "https://echo.hoppscotch.io",
"HOST": "echo.hoppscotch.io", "HOST": "echo.hoppscotch.io",
"X-COUNTRY": "IN",
"BODY_VALUE": "body_value", "BODY_VALUE": "body_value",
"BODY_KEY": "body_key" "BODY_KEY": "body_key"
} }

View File

@@ -12,7 +12,7 @@
"method": "POST", "method": "POST",
"auth": { "authType": "none", "authActive": true }, "auth": { "authType": "none", "authActive": true },
"preRequestScript": "", "preRequestScript": "",
"testScript": "const HOST = pw.env.get(\"HOST\");\nconst UNSET_ENV = pw.env.get(\"UNSET_ENV\");\nconst EXPECTED_URL = \"https://echo.hoppscotch.io\";\nconst URL = pw.env.get(\"URL\");\nconst X_COUNTRY = pw.env.get(\"X-COUNTRY\");\nconst BODY_VALUE = pw.env.get(\"BODY_VALUE\");\n\n// Check JSON response property\npw.test(\"Check headers properties.\", ()=> {\n pw.expect(pw.response.body.headers.host).toBe(HOST);\n\t pw.expect(pw.response.body.headers[\"x-country\"]).toBe(X_COUNTRY); \n});\n\npw.test(\"Check data properties.\", () => {\n\tconst DATA = pw.response.body.data;\n \n pw.expect(DATA).toBeType(\"string\");\n pw.expect(JSON.parse(DATA).body_key).toBe(BODY_VALUE);\n});\n\npw.test(\"Check request URL.\", () => {\n pw.expect(URL).toBe(EXPECTED_URL);\n})\n\npw.test(\"Check unset ENV.\", () => {\n pw.expect(UNSET_ENV).toBeType(\"undefined\");\n})", "testScript": "const HOST = pw.env.get(\"HOST\");\nconst UNSET_ENV = pw.env.get(\"UNSET_ENV\");\nconst EXPECTED_URL = \"https://echo.hoppscotch.io\";\nconst URL = pw.env.get(\"URL\");\nconst BODY_VALUE = pw.env.get(\"BODY_VALUE\");\n\n// Check JSON response property\npw.test(\"Check headers properties.\", ()=> {\n pw.expect(pw.response.body.headers.host).toBe(HOST);\n});\n\npw.test(\"Check data properties.\", () => {\n\tconst DATA = pw.response.body.data;\n \n pw.expect(DATA).toBeType(\"string\");\n pw.expect(JSON.parse(DATA).body_key).toBe(BODY_VALUE);\n});\n\npw.test(\"Check request URL.\", () => {\n pw.expect(URL).toBe(EXPECTED_URL);\n})\n\npw.test(\"Check unset ENV.\", () => {\n pw.expect(UNSET_ENV).toBeType(\"undefined\");\n})",
"body": { "body": {
"contentType": "application/json", "contentType": "application/json",
"body": "{\n \"<<BODY_KEY>>\":\"<<BODY_VALUE>>\"\n}" "body": "{\n \"<<BODY_KEY>>\":\"<<BODY_VALUE>>\"\n}"

View File

@@ -6,23 +6,24 @@ import {
parseTemplateString, parseTemplateString,
parseTemplateStringE, parseTemplateStringE,
} from "@hoppscotch/data"; } from "@hoppscotch/data";
import { runPreRequestScript } from "@hoppscotch/js-sandbox"; import { runPreRequestScript } from "@hoppscotch/js-sandbox/node";
import { flow, pipe } from "fp-ts/function";
import * as TE from "fp-ts/TaskEither";
import * as E from "fp-ts/Either";
import * as RA from "fp-ts/ReadonlyArray";
import * as A from "fp-ts/Array"; import * as A from "fp-ts/Array";
import * as E from "fp-ts/Either";
import * as O from "fp-ts/Option"; import * as O from "fp-ts/Option";
import * as RA from "fp-ts/ReadonlyArray";
import * as TE from "fp-ts/TaskEither";
import { flow, pipe } from "fp-ts/function";
import * as S from "fp-ts/string"; import * as S from "fp-ts/string";
import qs from "qs"; import qs from "qs";
import { EffectiveHoppRESTRequest } from "../interfaces/request"; import { EffectiveHoppRESTRequest } from "../interfaces/request";
import { error, HoppCLIError } from "../types/errors"; import { HoppCLIError, error } from "../types/errors";
import { HoppEnvs } from "../types/request"; import { HoppEnvs } from "../types/request";
import { isHoppCLIError } from "./checks";
import { tupleToRecord, arraySort, arrayFlatMap } from "./functions/array";
import { toFormData } from "./mutators";
import { getEffectiveFinalMetaData } from "./getters";
import { PreRequestMetrics } from "../types/response"; import { PreRequestMetrics } from "../types/response";
import { isHoppCLIError } from "./checks";
import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array";
import { getEffectiveFinalMetaData } from "./getters";
import { toFormData } from "./mutators";
/** /**
* Runs pre-request-script runner over given request which extracts set ENVs and * Runs pre-request-script runner over given request which extracts set ENVs and

View File

@@ -1,17 +1,19 @@
import { HoppRESTRequest } from "@hoppscotch/data"; import { HoppRESTRequest } from "@hoppscotch/data";
import { execTestScript, TestDescriptor } from "@hoppscotch/js-sandbox"; import { TestDescriptor } from "@hoppscotch/js-sandbox";
import { hrtime } from "process"; import { runTestScript } from "@hoppscotch/js-sandbox/node";
import { flow, pipe } from "fp-ts/function";
import * as RA from "fp-ts/ReadonlyArray";
import * as A from "fp-ts/Array"; import * as A from "fp-ts/Array";
import * as TE from "fp-ts/TaskEither"; import * as RA from "fp-ts/ReadonlyArray";
import * as T from "fp-ts/Task"; import * as T from "fp-ts/Task";
import * as TE from "fp-ts/TaskEither";
import { flow, pipe } from "fp-ts/function";
import { hrtime } from "process";
import { import {
RequestRunnerResponse, RequestRunnerResponse,
TestReport, TestReport,
TestScriptParams, TestScriptParams,
} from "../interfaces/response"; } from "../interfaces/response";
import { error, HoppCLIError } from "../types/errors"; import { HoppCLIError, error } from "../types/errors";
import { HoppEnvs } from "../types/request"; import { HoppEnvs } from "../types/request";
import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response"; import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response";
import { getDurationInSeconds } from "./getters"; import { getDurationInSeconds } from "./getters";
@@ -36,7 +38,7 @@ export const testRunner = (
pipe( pipe(
TE.of(testScriptData), TE.of(testScriptData),
TE.chain(({ testScript, response, envs }) => TE.chain(({ testScript, response, envs }) =>
execTestScript(testScript, envs, response) runTestScript(testScript, envs, response)
) )
) )
), ),

View File

@@ -69,5 +69,7 @@ module.exports = {
"Do not use 'localStorage' directly. Please use the PersistenceService", "Do not use 'localStorage' directly. Please use the PersistenceService",
}, },
], ],
eqeqeq: 1,
"no-else-return": 1,
}, },
} }

View File

@@ -5,5 +5,4 @@ module.exports = {
printWidth: 80, printWidth: 80,
useTabs: false, useTabs: false,
tabWidth: 2, tabWidth: 2,
plugins: ["prettier-plugin-tailwindcss"],
} }

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width=".88em" height="1em" viewBox="0 0 21 24" class="iconify iconify--fontisto"><path fill="currentColor" d="M12.731 2.751 17.666 5.6a2.138 2.138 0 1 1 2.07 3.548l-.015.003v5.7a2.14 2.14 0 1 1-2.098 3.502l-.002-.002-4.905 2.832a2.14 2.14 0 1 1-4.079.054l-.004.015-4.941-2.844a2.14 2.14 0 1 1-2.067-3.556l.015-.003V9.15a2.14 2.14 0 1 1 1.58-3.926l-.01-.005c.184.106.342.231.479.376l.001.001 4.938-2.85a2.14 2.14 0 1 1 4.096.021l.004-.015zm-.515.877a.766.766 0 0 1-.057.057l-.001.001 6.461 11.19c.026-.009.056-.016.082-.023V9.146a2.14 2.14 0 0 1-1.555-2.603l-.003.015.019-.072zm-3.015.059-.06-.06-4.946 2.852A2.137 2.137 0 0 1 2.749 9.12l-.015.004-.076.021v5.708l.084.023 6.461-11.19zm2.076.507a2.164 2.164 0 0 1-1.207-.004l.015.004-6.46 11.189c.286.276.496.629.597 1.026l.003.015h12.911c.102-.413.313-.768.599-1.043l.001-.001L11.28 4.194zm.986 16.227 4.917-2.838a1.748 1.748 0 0 1-.038-.142H4.222l-.021.083 4.939 2.852c.39-.403.936-.653 1.54-.653.626 0 1.189.268 1.581.696l.001.002z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width=".88em" height="1em" viewBox="0 0 21 24"><path fill="currentColor" d="M12.731 2.751 17.666 5.6a2.138 2.138 0 1 1 2.07 3.548l-.015.003v5.7a2.14 2.14 0 1 1-2.098 3.502l-.002-.002-4.905 2.832a2.14 2.14 0 1 1-4.079.054l-.004.015-4.941-2.844a2.14 2.14 0 1 1-2.067-3.556l.015-.003V9.15a2.14 2.14 0 1 1 1.58-3.926l-.01-.005c.184.106.342.231.479.376l.001.001 4.938-2.85a2.14 2.14 0 1 1 4.096.021l.004-.015zm-.515.877a.766.766 0 0 1-.057.057l-.001.001 6.461 11.19c.026-.009.056-.016.082-.023V9.146a2.14 2.14 0 0 1-1.555-2.603l-.003.015.019-.072zm-3.015.059-.06-.06-4.946 2.852A2.137 2.137 0 0 1 2.749 9.12l-.015.004-.076.021v5.708l.084.023 6.461-11.19zm2.076.507a2.164 2.164 0 0 1-1.207-.004l.015.004-6.46 11.189c.286.276.496.629.597 1.026l.003.015h12.911c.102-.413.313-.768.599-1.043l.001-.001L11.28 4.194zm.986 16.227 4.917-2.838a1.748 1.748 0 0 1-.038-.142H4.222l-.021.083 4.939 2.852c.39-.403.936-.653 1.54-.653.626 0 1.189.268 1.581.696l.001.002z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1017 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M10.133 1h4.409a.5.5 0 0 1 .5.5v4.422c0 .026-.035.033-.045.01l-.048-.112a9.095 9.095 0 0 0-4.825-4.776c-.023-.01-.016-.044.01-.044Zm-8.588.275h-.5v1h.5c7.027 0 12.229 5.199 12.229 12.226v.5h1v-.5c0-7.58-5.65-13.226-13.229-13.226Zm.034 4.22h-.5v1h.5c2.361 0 4.348.837 5.744 2.238 1.395 1.401 2.227 3.395 2.227 5.758v.5h1v-.5c0-2.604-.921-4.859-2.52-6.463-1.596-1.605-3.845-2.532-6.45-2.532Zm-.528 8.996v-4.423c0-.041.033-.074.074-.074a4.923 4.923 0 0 1 4.923 4.922.074.074 0 0 1-.074.074H1.551a.5.5 0 0 1-.5-.5Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 684 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M9.277 2.084a.5.5 0 0 1 .185.607l-2.269 5.5a.5.5 0 0 1-.462.309H3.5a.5.5 0 0 1-.354-.854l5.5-5.5a.5.5 0 0 1 .631-.062ZM4.707 7.5h1.69l1.186-2.875L4.707 7.5Zm2.016 6.416a.5.5 0 0 1-.185-.607l2.269-5.5a.5.5 0 0 1 .462-.309H12.5a.5.5 0 0 1 .354.854l-5.5 5.5a.5.5 0 0 1-.631.062Zm4.57-5.416h-1.69l-1.186 2.875L11.293 8.5Z" clip-rule="evenodd"/><path fill="currentColor" fill-rule="evenodd" d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1 0A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 633 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 16 16"><path fill="currentColor" d="M1 2h4.257a2.5 2.5 0 0 1 1.768.732L9.293 5 5 9.293 3.732 8.025A2.5 2.5 0 0 1 3 6.257V4H2v2.257a3.5 3.5 0 0 0 1.025 2.475L5 10.707l1.25-1.25 2.396 2.397.708-.708L6.957 8.75 8.75 6.957l2.396 2.397.708-.708L9.457 6.25 10.707 5 7.732 2.025A3.5 3.5 0 0 0 5.257 1H1v1ZM10.646 2.354l2.622 2.62A2.5 2.5 0 0 1 14 6.744V12h1V6.743a3.5 3.5 0 0 0-1.025-2.475l-2.621-2.622-.707.708ZM4.268 13.975l-2.622-2.621.708-.708 2.62 2.622A2.5 2.5 0 0 0 6.744 14H15v1H6.743a3.5 3.5 0 0 1-2.475-1.025Z"/></svg>

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -29,14 +29,6 @@
@apply antialiased; @apply antialiased;
accent-color: var(--accent-color); accent-color: var(--accent-color);
font-variant-ligatures: common-ligatures; font-variant-ligatures: common-ligatures;
// Colors
--info-color: #ec4899;
--success-color: #10b981;
--blue-color: #3b82f6;
--warning-color: #f59e0b;
--cl-error-color: #ef4444;
--sv-error-color: #dc2626;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -65,7 +57,7 @@ input::placeholder,
textarea::placeholder, textarea::placeholder,
.cm-placeholder { .cm-placeholder {
@apply text-secondary; @apply text-secondary;
@apply opacity-50; @apply opacity-50 #{!important};
} }
input, input,
@@ -84,7 +76,7 @@ body {
@apply font-medium; @apply font-medium;
@apply select-none; @apply select-none;
@apply overflow-x-hidden; @apply overflow-x-hidden;
@apply leading-body; @apply leading-body #{!important};
animation: fade 300ms forwards; animation: fade 300ms forwards;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none; -webkit-touch-callout: none;
@@ -182,7 +174,7 @@ a {
@apply font-semibold; @apply font-semibold;
@apply px-2 py-1; @apply px-2 py-1;
@apply truncate; @apply truncate;
@apply leading-normal; @apply leading-body;
@apply items-center; @apply items-center;
kbd { kbd {
@@ -229,7 +221,7 @@ a {
@apply overflow-y-auto; @apply overflow-y-auto;
@apply text-body text-secondary; @apply text-body text-secondary;
@apply p-2; @apply p-2;
@apply leading-normal; @apply leading-body;
@apply focus:outline-none; @apply focus:outline-none;
scroll-behavior: smooth; scroll-behavior: smooth;
@@ -261,7 +253,7 @@ a {
hr { hr {
@apply border-b border-dividerLight; @apply border-b border-dividerLight;
@apply my-2; @apply my-2 #{!important};
} }
.heading { .heading {
@@ -350,44 +342,28 @@ pre.ace_editor {
} }
} }
.select-wrapper {
@apply flex flex-1;
@apply relative;
@apply after:absolute;
@apply after:flex;
@apply after:inset-y-0;
@apply after:items-center;
@apply after:justify-center;
@apply after:pointer-events-none;
@apply after:font-icon;
@apply after:text-current;
@apply after:right-3;
@apply after:content-["\e5cf"];
@apply after:text-lg;
}
.info-response { .info-response {
color: var(--info-color); color: var(--status-info-color);
} }
.success-response { .success-response {
color: var(--success-color); color: var(--status-success-color);
} }
.redir-response { .redirect-response {
color: var(--warning-color); color: var(--status-redirect-color);
} }
.cl-error-response { .critical-error-response {
color: var(--cl-error-color); color: var(--status-critical-error-color);
} }
.sv-error-response { .server-error-response {
color: var(--sv-error-color); color: var(--status-server-error-color);
} }
.missing-data-response { .missing-data-response {
@apply text-secondaryLight; color: var(--status-missing-data-color);
} }
.toasted-container { .toasted-container {
@@ -537,12 +513,12 @@ pre.ace_editor {
@apply inline-flex; @apply inline-flex;
@apply font-sans; @apply font-sans;
@apply text-tiny; @apply text-tiny;
@apply bg-divider; @apply bg-dividerLight;
@apply rounded; @apply rounded;
@apply ml-2; @apply ml-2;
@apply px-1; @apply px-1;
@apply min-w-5; @apply min-w-[1.25rem];
@apply min-h-5; @apply min-h-[1.25rem];
@apply items-center; @apply items-center;
@apply justify-center; @apply justify-center;
@apply border border-dividerDark; @apply border border-dividerDark;

View File

@@ -1,89 +1,89 @@
@mixin green-theme { @mixin green-theme {
--accent-color: #10b981; --accent-color: theme("colors.emerald.500");
--accent-light-color: #34d399; --accent-light-color: theme("colors.emerald.400");
--accent-dark-color: #059669; --accent-dark-color: theme("colors.emerald.600");
--accent-contrast-color: #fff; --accent-contrast-color: theme("colors.white");
--gradient-from-color: #a7f3d0; --gradient-from-color: theme("colors.emerald.400");
--gradient-via-color: #34d399; --gradient-via-color: theme("colors.emerald.500");
--gradient-to-color: #059669; --gradient-to-color: theme("colors.emerald.600");
} }
@mixin teal-theme { @mixin teal-theme {
--accent-color: #14b8a6; --accent-color: theme("colors.teal.500");
--accent-light-color: #2dd4bf; --accent-light-color: theme("colors.teal.400");
--accent-dark-color: #0d9488; --accent-dark-color: theme("colors.teal.600");
--accent-contrast-color: #fff; --accent-contrast-color: theme("colors.white");
--gradient-from-color: #99f6e4; --gradient-from-color: theme("colors.teal.400");
--gradient-via-color: #2dd4bf; --gradient-via-color: theme("colors.teal.500");
--gradient-to-color: #0d9488; --gradient-to-color: theme("colors.teal.600");
} }
@mixin blue-theme { @mixin blue-theme {
--accent-color: #3b82f6; --accent-color: theme("colors.blue.500");
--accent-light-color: #60a5fa; --accent-light-color: theme("colors.blue.400");
--accent-dark-color: #2563eb; --accent-dark-color: theme("colors.blue.600");
--accent-contrast-color: #fff; --accent-contrast-color: theme("colors.white");
--gradient-from-color: #bfdbfe; --gradient-from-color: theme("colors.blue.400");
--gradient-via-color: #60a5fa; --gradient-via-color: theme("colors.blue.500");
--gradient-to-color: #2563eb; --gradient-to-color: theme("colors.blue.600");
} }
@mixin indigo-theme { @mixin indigo-theme {
--accent-color: #6366f1; --accent-color: theme("colors.indigo.500");
--accent-light-color: #818cf8; --accent-light-color: theme("colors.indigo.400");
--accent-dark-color: #4f46e5; --accent-dark-color: theme("colors.indigo.600");
--accent-contrast-color: #fff; --accent-contrast-color: theme("colors.white");
--gradient-from-color: #c7d2fe; --gradient-from-color: theme("colors.indigo.400");
--gradient-via-color: #818cf8; --gradient-via-color: theme("colors.indigo.500");
--gradient-to-color: #4f46e5; --gradient-to-color: theme("colors.indigo.600");
} }
@mixin purple-theme { @mixin purple-theme {
--accent-color: #8b5cf6; --accent-color: theme("colors.purple.500");
--accent-light-color: #a78bfa; --accent-light-color: theme("colors.purple.400");
--accent-dark-color: #7c3aed; --accent-dark-color: theme("colors.purple.600");
--accent-contrast-color: #fff; --accent-contrast-color: theme("colors.white");
--gradient-from-color: #ddd6fe; --gradient-from-color: theme("colors.purple.400");
--gradient-via-color: #a78bfa; --gradient-via-color: theme("colors.purple.500");
--gradient-to-color: #7c3aed; --gradient-to-color: theme("colors.purple.600");
} }
@mixin yellow-theme { @mixin yellow-theme {
--accent-color: #f59e0b; --accent-color: theme("colors.amber.500");
--accent-light-color: #fbbf24; --accent-light-color: theme("colors.amber.400");
--accent-dark-color: #d97706; --accent-dark-color: theme("colors.amber.600");
--accent-contrast-color: #fff; --accent-contrast-color: theme("colors.white");
--gradient-from-color: #fde68a; --gradient-from-color: theme("colors.amber.400");
--gradient-via-color: #fbbf24; --gradient-via-color: theme("colors.amber.500");
--gradient-to-color: #d97706; --gradient-to-color: theme("colors.amber.600");
} }
@mixin orange-theme { @mixin orange-theme {
--accent-color: #f97316; --accent-color: theme("colors.orange.500");
--accent-light-color: #fb923c; --accent-light-color: theme("colors.orange.400");
--accent-dark-color: #ea580c; --accent-dark-color: theme("colors.orange.600");
--accent-contrast-color: #fff; --accent-contrast-color: theme("colors.white");
--gradient-from-color: #fed7aa; --gradient-from-color: theme("colors.orange.400");
--gradient-via-color: #fb923c; --gradient-via-color: theme("colors.orange.500");
--gradient-to-color: #ea580c; --gradient-to-color: theme("colors.orange.600");
} }
@mixin red-theme { @mixin red-theme {
--accent-color: #ef4444; --accent-color: theme("colors.red.500");
--accent-light-color: #f87171; --accent-light-color: theme("colors.red.400");
--accent-dark-color: #dc2626; --accent-dark-color: theme("colors.red.600");
--accent-contrast-color: #fff; --accent-contrast-color: theme("colors.white");
--gradient-from-color: #fecaca; --gradient-from-color: theme("colors.red.400");
--gradient-via-color: #f87171; --gradient-via-color: theme("colors.red.500");
--gradient-to-color: #dc2626; --gradient-to-color: theme("colors.red.600");
} }
@mixin pink-theme { @mixin pink-theme {
--accent-color: #ec4899; --accent-color: theme("colors.pink.500");
--accent-light-color: #f472b6; --accent-light-color: theme("colors.pink.400");
--accent-dark-color: #db2777; --accent-dark-color: theme("colors.pink.600");
--accent-contrast-color: #fff; --accent-contrast-color: theme("colors.white");
--gradient-from-color: #fbcfe8; --gradient-from-color: theme("colors.pink.400");
--gradient-via-color: #f472b6; --gradient-via-color: theme("colors.pink.500");
--gradient-to-color: #db2777; --gradient-to-color: theme("colors.pink.600");
} }

View File

@@ -1,17 +1,16 @@
@mixin base-theme { @mixin base-theme {
--font-sans: "Inter Variable", sans-serif; --font-sans: "Inter Variable", sans-serif;
--font-icon: "Material Symbols Rounded Variable";
--font-mono: "Roboto Mono Variable", monospace; --font-mono: "Roboto Mono Variable", monospace;
--font-size-body: 0.75rem; --font-size-body: 0.75rem;
--font-size-tiny: 0.688rem; --font-size-tiny: 0.625rem;
--line-height-body: 1rem; --line-height-body: 1rem;
--upper-primary-sticky-fold: 4.125rem; --upper-primary-sticky-fold: 4.125rem;
--upper-secondary-sticky-fold: 6.188rem; --upper-secondary-sticky-fold: 6.188rem;
--upper-tertiary-sticky-fold: 8.25rem; --upper-tertiary-sticky-fold: 8.25rem;
--upper-fourth-sticky-fold: 10.2rem; --upper-fourth-sticky-fold: 10.2rem;
--upper-mobile-primary-sticky-fold: 6.625rem; --upper-mobile-primary-sticky-fold: 6.75rem;
--upper-mobile-secondary-sticky-fold: 8.688rem; --upper-mobile-secondary-sticky-fold: 8.813rem;
--upper-mobile-sticky-fold: 10.75rem; --upper-mobile-sticky-fold: 10.875rem;
--upper-mobile-tertiary-sticky-fold: 8.25rem; --upper-mobile-tertiary-sticky-fold: 8.25rem;
--lower-primary-sticky-fold: 3rem; --lower-primary-sticky-fold: 3rem;
--lower-secondary-sticky-fold: 5.063rem; --lower-secondary-sticky-fold: 5.063rem;
@@ -20,62 +19,122 @@
--sidebar-primary-sticky-fold: 2rem; --sidebar-primary-sticky-fold: 2rem;
} }
@mixin light-theme {
--primary-color: theme("colors.white");
--primary-light-color: theme("colors.gray.50");
--primary-dark-color: theme("colors.gray.100");
--primary-contrast-color: #fdfdfd;
--secondary-color: theme("colors.gray.500");
--secondary-light-color: theme("colors.gray.400");
--secondary-dark-color: theme("colors.gray.900");
--divider-color: theme("colors.gray.100");
--divider-light-color: theme("colors.gray.100");
--divider-dark-color: theme("colors.gray.300");
--banner-info-color: theme("colors.stone.100");
--banner-warning-color: theme("colors.yellow.100");
--banner-error-color: theme("colors.red.100");
--tooltip-color: theme("colors.neutral.800");
--popover-color: theme("colors.white");
--method-get-color: theme("colors.green.500");
--method-post-color: theme("colors.amber.500");
--method-put-color: theme("colors.blue.500");
--method-patch-color: theme("colors.purple.500");
--method-delete-color: theme("colors.red.500");
--method-head-color: theme("colors.lime.500");
--method-options-color: theme("colors.pink.500");
--method-default-color: theme("colors.gray.500");
--status-info-color: theme("colors.blue.500");
--status-success-color: theme("colors.green.500");
--status-redirect-color: theme("colors.amber.500");
--status-critical-error-color: theme("colors.red.500");
--status-server-error-color: theme("colors.rose.500");
--status-missing-data-color: theme("colors.slate.500");
--editor-theme: "textmate";
}
@mixin dark-theme { @mixin dark-theme {
--primary-color: #181818; --primary-color: #181818;
--primary-light-color: #1c1c1e; --primary-light-color: #1c1c1e;
--primary-dark-color: #262626; --primary-dark-color: theme("colors.neutral.800");
--primary-contrast-color: #171717; --primary-contrast-color: theme("colors.neutral.900");
--secondary-color: #a3a3a3; --secondary-color: theme("colors.neutral.400");
--secondary-light-color: #737373; --secondary-light-color: theme("colors.neutral.500");
--secondary-dark-color: #fafafa; --secondary-dark-color: theme("colors.zinc.50");
--divider-color: #262626; --divider-color: #1f1f1f;
--divider-light-color: #1f1f1f; --divider-light-color: #1f1f1f;
--divider-dark-color: #2d2d2d; --divider-dark-color: theme("colors.zinc.800");
--error-color: #292524; --banner-info-color: theme("colors.stone.800");
--tooltip-color: #f5f5f5; --banner-warning-color: theme("colors.yellow.800");
--banner-error-color: theme("colors.red.800");
--tooltip-color: theme("colors.neutral.100");
--popover-color: #1b1b1b; --popover-color: #1b1b1b;
--method-get-color: theme("colors.emerald.500");
--method-post-color: theme("colors.yellow.500");
--method-put-color: theme("colors.sky.500");
--method-patch-color: theme("colors.violet.500");
--method-delete-color: theme("colors.rose.500");
--method-head-color: theme("colors.teal.500");
--method-options-color: theme("colors.indigo.500");
--method-default-color: theme("colors.neutral.500");
--status-info-color: theme("colors.blue.500");
--status-success-color: theme("colors.green.500");
--status-redirect-color: theme("colors.amber.500");
--status-critical-error-color: theme("colors.red.500");
--status-server-error-color: theme("colors.rose.500");
--status-missing-data-color: theme("colors.slate.500");
--editor-theme: "merbivore_soft"; --editor-theme: "merbivore_soft";
} }
@mixin light-theme {
--primary-color: #ffffff;
--primary-light-color: #f9fafb;
--primary-dark-color: #f3f4f6;
--primary-contrast-color: #fdfdfd;
--secondary-color: #6b7280;
--secondary-light-color: #9ca3af;
--secondary-dark-color: #111827;
--divider-color: #f3f4f6;
--divider-light-color: #f3f4f6;
--divider-dark-color: #d1d5db;
--error-color: #fef3c7;
--tooltip-color: #262626;
--popover-color: #ffffff;
--editor-theme: "textmate";
}
@mixin black-theme { @mixin black-theme {
--primary-color: #0f0f0f; --primary-color: #0f0f0f;
--primary-light-color: #171717; --primary-light-color: theme("colors.neutral.900");
--primary-dark-color: #181818; --primary-dark-color: #181818;
--primary-contrast-color: #0f0f0f; --primary-contrast-color: #0f0f0f;
--secondary-color: #a3a3a3; --secondary-color: theme("colors.neutral.400");
--secondary-light-color: #737373; --secondary-light-color: theme("colors.neutral.500");
--secondary-dark-color: #f5f5f5; --secondary-dark-color: theme("colors.neutral.50");
--divider-color: #1c1c1e; --divider-color: theme("colors.neutral.900");
--divider-light-color: #181818; --divider-light-color: theme("colors.neutral.900");
--divider-dark-color: #323232; --divider-dark-color: theme("colors.zinc.800");
--banner-info-color: theme("colors.stone.900");
--banner-warning-color: theme("colors.yellow.900");
--banner-error-color: theme("colors.red.900");
--tooltip-color: theme("colors.neutral.100");
--popover-color: theme("colors.stone.950");
--method-get-color: theme("colors.emerald.500");
--method-post-color: theme("colors.yellow.500");
--method-put-color: theme("colors.sky.500");
--method-patch-color: theme("colors.violet.500");
--method-delete-color: theme("colors.rose.500");
--method-head-color: theme("colors.teal.500");
--method-options-color: theme("colors.indigo.500");
--method-default-color: theme("colors.zinc.500");
--status-info-color: theme("colors.blue.500");
--status-success-color: theme("colors.green.500");
--status-redirect-color: theme("colors.amber.500");
--status-critical-error-color: theme("colors.red.500");
--status-server-error-color: theme("colors.rose.500");
--status-missing-data-color: theme("colors.slate.500");
--error-color: #1c1917;
--tooltip-color: #f5f5f5;
--popover-color: #0f0f0f;
--editor-theme: "twilight"; --editor-theme: "twilight";
} }

View File

@@ -1,41 +1,41 @@
@mixin dark-editor-theme { @mixin light-editor-theme {
--editor-type-color: #a78bfa; --editor-type-color: theme("colors.violet.600");
--editor-name-color: #60a5fa; --editor-name-color: theme("colors.red.600");
--editor-operator-color: #818cf8; --editor-operator-color: theme("colors.indigo.600");
--editor-invalid-color: #f87171; --editor-invalid-color: theme("colors.red.600");
--editor-separator-color: #9ca3af; --editor-separator-color: theme("colors.gray.600");
--editor-meta-color: #9ca3af; --editor-meta-color: theme("colors.gray.600");
--editor-variable-color: #34d399; --editor-variable-color: theme("colors.emerald.600");
--editor-link-color: #22d3ee; --editor-link-color: theme("colors.cyan.600");
--editor-process-color: #e879f9; --editor-process-color: theme("colors.blue.600");
--editor-constant-color: #a78bfa; --editor-constant-color: theme("colors.fuchsia.600");
--editor-keyword-color: #f472b6; --editor-keyword-color: theme("colors.pink.600");
} }
@mixin light-editor-theme { @mixin dark-editor-theme {
--editor-type-color: #7c3aed; --editor-type-color: theme("colors.violet.400");
--editor-name-color: #dc2626; --editor-name-color: theme("colors.blue.400");
--editor-operator-color: #4f46e5; --editor-operator-color: theme("colors.indigo.400");
--editor-invalid-color: #dc2626; --editor-invalid-color: theme("colors.red.400");
--editor-separator-color: #4b5563; --editor-separator-color: theme("colors.gray.400");
--editor-meta-color: #4b5563; --editor-meta-color: theme("colors.gray.400");
--editor-variable-color: #059669; --editor-variable-color: theme("colors.emerald.400");
--editor-link-color: #0891b2; --editor-link-color: theme("colors.cyan.400");
--editor-process-color: #2563eb; --editor-process-color: theme("colors.fuchsia.400");
--editor-constant-color: #c026d3; --editor-constant-color: theme("colors.violet.400");
--editor-keyword-color: #db2777; --editor-keyword-color: theme("colors.pink.400");
} }
@mixin black-editor-theme { @mixin black-editor-theme {
--editor-type-color: #a78bfa; --editor-type-color: theme("colors.violet.400");
--editor-name-color: #e879f9; --editor-name-color: theme("colors.fuchsia.400");
--editor-operator-color: #818cf8; --editor-operator-color: theme("colors.indigo.400");
--editor-invalid-color: #f87171; --editor-invalid-color: theme("colors.red.400");
--editor-separator-color: #9ca3af; --editor-separator-color: theme("colors.gray.400");
--editor-meta-color: #9ca3af; --editor-meta-color: theme("colors.gray.400");
--editor-variable-color: #34d399; --editor-variable-color: theme("colors.emerald.400");
--editor-link-color: #22d3ee; --editor-link-color: theme("colors.cyan.400");
--editor-process-color: #a78bfa; --editor-process-color: theme("colors.violet.400");
--editor-constant-color: #60a5fa; --editor-constant-color: theme("colors.blue.400");
--editor-keyword-color: #f472b6; --editor-keyword-color: theme("colors.pink.400");
} }

View File

@@ -11,6 +11,7 @@
"connect": "Connect", "connect": "Connect",
"connecting": "Connecting", "connecting": "Connecting",
"copy": "Copy", "copy": "Copy",
"create": "Create",
"delete": "Delete", "delete": "Delete",
"disconnect": "Disconnect", "disconnect": "Disconnect",
"dismiss": "Dismiss", "dismiss": "Dismiss",
@@ -40,6 +41,7 @@
"scroll_to_top": "Scroll to top", "scroll_to_top": "Scroll to top",
"search": "Search", "search": "Search",
"send": "Send", "send": "Send",
"share": "Share",
"start": "Start", "start": "Start",
"starting": "Starting", "starting": "Starting",
"stop": "Stop", "stop": "Stop",
@@ -78,6 +80,7 @@
"contact_us": "Contact us", "contact_us": "Contact us",
"cookies": "Cookies", "cookies": "Cookies",
"copy": "Copy", "copy": "Copy",
"copy_interface_type": "Copy interface type",
"copy_user_id": "Copy User Auth Token", "copy_user_id": "Copy User Auth Token",
"developer_option": "Developer options", "developer_option": "Developer options",
"developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.", "developer_option_description": "Developer tools which helps in development and maintenance of Hoppscotch.",
@@ -93,6 +96,7 @@
"keyboard_shortcuts": "Keyboard shortcuts", "keyboard_shortcuts": "Keyboard shortcuts",
"name": "Hoppscotch", "name": "Hoppscotch",
"new_version_found": "New version found. Refresh to update.", "new_version_found": "New version found. Refresh to update.",
"open_in_hoppscotch": "Open in Hoppscotch",
"options": "Options", "options": "Options",
"proxy_privacy_policy": "Proxy privacy policy", "proxy_privacy_policy": "Proxy privacy policy",
"reload": "Reload", "reload": "Reload",
@@ -187,6 +191,7 @@
"remove_folder": "Are you sure you want to permanently delete this folder?", "remove_folder": "Are you sure you want to permanently delete this folder?",
"remove_history": "Are you sure you want to permanently delete all history?", "remove_history": "Are you sure you want to permanently delete all history?",
"remove_request": "Are you sure you want to permanently delete this request?", "remove_request": "Are you sure you want to permanently delete this request?",
"remove_shared_request": "Are you sure you want to permanently delete this shared request?",
"remove_team": "Are you sure you want to delete this team?", "remove_team": "Are you sure you want to delete this team?",
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?", "remove_telemetry": "Are you sure you want to opt-out of Telemetry?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
@@ -228,7 +233,8 @@
"profile": "Login to view your profile", "profile": "Login to view your profile",
"protocols": "Protocols are empty", "protocols": "Protocols are empty",
"schema": "Connect to a GraphQL endpoint to view schema", "schema": "Connect to a GraphQL endpoint to view schema",
"shortcodes": "Shortcodes are empty", "shared_requests_logout": "Login to view your shared requests or create a new one",
"shared_requests": "Shared requests are empty",
"subscription": "Subscriptions are empty", "subscription": "Subscriptions are empty",
"team_name": "Team name empty", "team_name": "Team name empty",
"teams": "You don't belong to any teams", "teams": "You don't belong to any teams",
@@ -268,6 +274,9 @@
"variable": "Variable", "variable": "Variable",
"variable_list": "Variable List" "variable_list": "Variable List"
}, },
"graphql_collections": {
"title": "GraphQL Collections"
},
"error": { "error": {
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.", "browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
"check_console_details": "Check console log for details.", "check_console_details": "Check console log for details.",
@@ -303,7 +312,8 @@
"create_secret_gist": "Create secret Gist", "create_secret_gist": "Create secret Gist",
"gist_created": "Gist created", "gist_created": "Gist created",
"require_github": "Login with GitHub to create secret gist", "require_github": "Login with GitHub to create secret gist",
"title": "Export" "title": "Export",
"failed": "Something went wrong while exporting"
}, },
"filter": { "filter": {
"all": "All", "all": "All",
@@ -340,8 +350,8 @@
"authorization": "The authorization header will be automatically generated when you send the request.", "authorization": "The authorization header will be automatically generated when you send the request.",
"generate_documentation_first": "Generate documentation first", "generate_documentation_first": "Generate documentation first",
"network_fail": "Unable to reach the API endpoint. Check your network connection or select a different Interceptor and try again.", "network_fail": "Unable to reach the API endpoint. Check your network connection or select a different Interceptor and try again.",
"offline": "You seem to be offline. Data in this workspace might not be up to date.", "offline": "You're using Hoppscotch offline. Updates will sync when you're online, based on workspace settings.",
"offline_short": "You seem to be offline.", "offline_short": "You're using Hoppscotch offline.",
"post_request_tests": "Test scripts are written in JavaScript, and are run after the response is received.", "post_request_tests": "Test scripts are written in JavaScript, and are run after the response is received.",
"pre_request_script": "Pre-request scripts are written in JavaScript, and are run before the request is sent.", "pre_request_script": "Pre-request scripts are written in JavaScript, and are run before the request is sent.",
"script_fail": "It seems there is a glitch in the pre-request script. Check the error below and fix the script accordingly.", "script_fail": "It seems there is a glitch in the pre-request script. Check the error below and fix the script accordingly.",
@@ -370,6 +380,7 @@
"from_openapi_description": "Import from OpenAPI specification file (YML/JSON)", "from_openapi_description": "Import from OpenAPI specification file (YML/JSON)",
"from_postman": "Import from Postman", "from_postman": "Import from Postman",
"from_postman_description": "Import from Postman collection", "from_postman_description": "Import from Postman collection",
"from_file": "Import from File",
"from_url": "Import from URL", "from_url": "Import from URL",
"gist_url": "Enter Gist URL", "gist_url": "Enter Gist URL",
"import_from_url_invalid_fetch": "Couldn't get data from the url", "import_from_url_invalid_fetch": "Couldn't get data from the url",
@@ -377,7 +388,14 @@
"import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'", "import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'",
"import_from_url_success": "Collections Imported", "import_from_url_success": "Collections Imported",
"json_description": "Import collections from a Hoppscotch Collections JSON file", "json_description": "Import collections from a Hoppscotch Collections JSON file",
"title": "Import" "title": "Import",
"hoppscotch_environment": "Hoppscotch Environment",
"hoppscotch_environment_description": "Import Hoppscotch Environment JSON file",
"postman_environment": "Postman Environment",
"postman_environment_description": "Import Postman Environment JSON file",
"environments_from_gist": "Import From Gist",
"environments_from_gist_description": "Import Hoppscotch Environments From Gist",
"gql_collections_from_gist_description": "Import GraphQL Collections From Gist"
}, },
"inspections": { "inspections": {
"description": "Inspect possible errors", "description": "Inspect possible errors",
@@ -414,7 +432,9 @@
"close_unsaved_tab": "You have unsaved changes", "close_unsaved_tab": "You have unsaved changes",
"collections": "Collections", "collections": "Collections",
"confirm": "Confirm", "confirm": "Confirm",
"customize_request": "Customize Request",
"edit_request": "Edit Request", "edit_request": "Edit Request",
"share_request": "Share Request",
"import_export": "Import / Export" "import_export": "Import / Export"
}, },
"mqtt": { "mqtt": {
@@ -490,7 +510,6 @@
"structured": "Structured", "structured": "Structured",
"text": "Text" "text": "Text"
}, },
"copy_link": "Copy link",
"different_collection": "Cannot reorder requests from different collections", "different_collection": "Cannot reorder requests from different collections",
"duplicated": "Request duplicated", "duplicated": "Request duplicated",
"duration": "Duration", "duration": "Duration",
@@ -523,6 +542,7 @@
"saved": "Request saved", "saved": "Request saved",
"share": "Share", "share": "Share",
"share_description": "Share Hoppscotch with your friends", "share_description": "Share Hoppscotch with your friends",
"share_request": "Share Request",
"stop": "Stop", "stop": "Stop",
"title": "Request", "title": "Request",
"type": "Request type", "type": "Request type",
@@ -603,14 +623,31 @@
"additional": "Additional Settings", "additional": "Additional Settings",
"verify_email": "Verify email" "verify_email": "Verify email"
}, },
"shortcodes": { "shared_requests": {
"actions": "Actions", "button": "Button",
"created_on": "Created on", "button_info": "Create a 'Run in Hoppscotch' button for your website, blog or a README.",
"deleted": "Shortcode deleted", "customize": "Customize",
"method": "Method", "creating_widget": "Creating widget",
"not_found": "Shortcode not found", "copy_html": "Copy HTML",
"short_code": "Short code", "copy_link": "Copy Link",
"url": "URL" "copy_markdown": "Copy Markdown",
"deleted": "Shared request deleted",
"description": "Select a widget, you can change and customize this later",
"embed": "Embed",
"embed_info": "Add a mini 'Hoppscotch API Playground' to your website, blog or documentation.",
"link": "Link",
"link_info": "Create a shareable link to share with anyone on the internet with view access.",
"modified": "Shared request modified",
"not_found": "Shared request not found",
"open_new_tab": "Open in new tab",
"preview": "Preview",
"run_in_hoppscotch": "Run in Hoppscotch",
"theme": {
"dark": "Dark",
"light": "Light",
"system": "System",
"title": "Theme"
}
}, },
"shortcut": { "shortcut": {
"general": { "general": {
@@ -640,7 +677,6 @@
"title": "Others" "title": "Others"
}, },
"request": { "request": {
"copy_request_link": "Copy Request Link",
"delete_method": "Select DELETE method", "delete_method": "Select DELETE method",
"get_method": "Select GET method", "get_method": "Select GET method",
"head_method": "Select HEAD method", "head_method": "Select HEAD method",
@@ -656,6 +692,7 @@
"save_to_collections": "Save to Collections", "save_to_collections": "Save to Collections",
"send_request": "Send Request", "send_request": "Send Request",
"show_code": "Generate code snippet", "show_code": "Generate code snippet",
"share_request": "Share Request",
"title": "Request" "title": "Request"
}, },
"response": { "response": {
@@ -780,6 +817,7 @@
"connection_failed": "Connection failed", "connection_failed": "Connection failed",
"connection_lost": "Connection lost", "connection_lost": "Connection lost",
"copied_to_clipboard": "Copied to clipboard", "copied_to_clipboard": "Copied to clipboard",
"copied_interface_to_clipboard": "Copied {language} interface type to clipboard",
"deleted": "Deleted", "deleted": "Deleted",
"deprecated": "DEPRECATED", "deprecated": "DEPRECATED",
"disabled": "Disabled", "disabled": "Disabled",
@@ -838,6 +876,7 @@
"queries": "Queries", "queries": "Queries",
"query": "Query", "query": "Query",
"schema": "Schema", "schema": "Schema",
"shared_requests": "Shared Requests",
"socketio": "Socket.IO", "socketio": "Socket.IO",
"sse": "SSE", "sse": "SSE",
"tests": "Tests", "tests": "Tests",

View File

@@ -22,45 +22,41 @@
}, },
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^10.1.0", "@apidevtools/swagger-parser": "^10.1.0",
"@codemirror/autocomplete": "^6.10.2", "@codemirror/autocomplete": "^6.11.0",
"@codemirror/commands": "^6.3.0", "@codemirror/commands": "^6.3.0",
"@codemirror/lang-javascript": "^6.2.1", "@codemirror/lang-javascript": "^6.2.1",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2", "@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "6.9.0", "@codemirror/language": "6.9.2",
"@codemirror/legacy-modes": "^6.3.3", "@codemirror/legacy-modes": "^6.3.3",
"@codemirror/lint": "^6.4.2", "@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.5.4", "@codemirror/search": "^6.5.4",
"@codemirror/state": "^6.3.1", "@codemirror/state": "^6.3.1",
"@codemirror/view": "^6.22.0", "@codemirror/view": "^6.22.0",
"@fontsource-variable/inter": "^5.0.8",
"@fontsource-variable/material-symbols-rounded": "^5.0.7",
"@fontsource-variable/roboto-mono": "^5.0.9",
"@hoppscotch/codemirror-lang-graphql": "workspace:^", "@hoppscotch/codemirror-lang-graphql": "workspace:^",
"@hoppscotch/data": "workspace:^", "@hoppscotch/data": "workspace:^",
"@hoppscotch/js-sandbox": "workspace:^", "@hoppscotch/js-sandbox": "workspace:^",
"@hoppscotch/ui": "workspace:^", "@hoppscotch/ui": "workspace:^",
"@hoppscotch/vue-toasted": "^0.1.0", "@hoppscotch/vue-toasted": "^0.1.0",
"@lezer/highlight": "1.1.4", "@lezer/highlight": "1.2.0",
"@urql/core": "^4.1.1", "@unhead/vue": "^1.8.8",
"@urql/core": "^4.2.0",
"@urql/devtools": "^2.0.3", "@urql/devtools": "^2.0.3",
"@urql/exchange-auth": "^2.1.6", "@urql/exchange-auth": "^2.1.6",
"@urql/exchange-graphcache": "^6.3.2", "@urql/exchange-graphcache": "^6.3.3",
"@vitejs/plugin-legacy": "^4.1.1", "@vitejs/plugin-legacy": "^4.1.1",
"@vueuse/core": "^10.3.0", "@vueuse/core": "^10.6.1",
"@vueuse/head": "^1.3.1", "acorn-walk": "^8.3.0",
"acorn-walk": "^8.2.0", "axios": "^1.6.2",
"axios": "^1.4.0",
"buffer": "^6.0.3", "buffer": "^6.0.3",
"cookie-es": "^1.0.0", "cookie-es": "^1.0.0",
"dioc": "workspace:^", "dioc": "workspace:^",
"esprima": "^4.0.1", "esprima": "^4.0.1",
"events": "^3.3.0", "events": "^3.3.0",
"fp-ts": "^2.16.1", "fp-ts": "^2.16.1",
"fuse.js": "^6.6.2",
"globalthis": "^1.0.3", "globalthis": "^1.0.3",
"graphql": "^16.8.0", "graphql": "^16.8.1",
"graphql-language-service-interface": "^2.9.1", "graphql-language-service-interface": "^2.10.2",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"httpsnippet": "^3.0.1", "httpsnippet": "^3.0.1",
"insomnia-importers": "^3.6.0", "insomnia-importers": "^3.6.0",
@@ -68,14 +64,15 @@
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jsonpath-plus": "^7.2.0", "jsonpath-plus": "^7.2.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"lossless-json": "^2.0.11", "lossless-json": "^3.0.2",
"minisearch": "^6.1.0", "minisearch": "^6.3.0",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"paho-mqtt": "^1.1.0", "paho-mqtt": "^1.1.0",
"path": "^0.12.7", "path": "^0.12.7",
"postman-collection": "^4.2.0", "postman-collection": "^4.3.0",
"process": "^0.11.10", "process": "^0.11.10",
"qs": "^6.11.2", "qs": "^6.11.2",
"quicktype-core": "^23.0.79",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"set-cookie-parser": "^2.6.0", "set-cookie-parser": "^2.6.0",
"set-cookie-parser-es": "^1.0.5", "set-cookie-parser-es": "^1.0.5",
@@ -89,19 +86,19 @@
"tern": "^0.24.3", "tern": "^0.24.3",
"timers": "^0.1.1", "timers": "^0.1.1",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"url": "^0.11.1", "url": "^0.11.3",
"util": "^0.12.5", "util": "^0.12.5",
"uuid": "^9.0.0",
"verzod": "^0.2.0", "verzod": "^0.2.0",
"vue": "^3.3.4", "uuid": "^9.0.1",
"vue-i18n": "^9.2.2", "vue": "^3.3.8",
"vue-pdf-embed": "^1.1.6", "vue-i18n": "^9.7.1",
"vue-router": "^4.2.4", "vue-pdf-embed": "^1.2.1",
"vue-router": "^4.2.5",
"vue-tippy": "6.3.1", "vue-tippy": "6.3.1",
"vuedraggable-es": "^4.1.1", "vuedraggable-es": "^4.1.1",
"wonka": "^6.3.4", "wonka": "^6.3.4",
"workbox-window": "^7.0.0", "workbox-window": "^7.0.0",
"xml-formatter": "^3.5.0", "xml-formatter": "^3.6.0",
"yargs-parser": "^21.1.1", "yargs-parser": "^21.1.1",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
@@ -113,57 +110,58 @@
"@graphql-codegen/typed-document-node": "^5.0.1", "@graphql-codegen/typed-document-node": "^5.0.1",
"@graphql-codegen/typescript": "^4.0.1", "@graphql-codegen/typescript": "^4.0.1",
"@graphql-codegen/typescript-operations": "^4.0.1", "@graphql-codegen/typescript-operations": "^4.0.1",
"@graphql-codegen/typescript-urql-graphcache": "^2.4.5", "@graphql-codegen/typescript-urql-graphcache": "^3.0.0",
"@graphql-codegen/urql-introspection": "^2.2.1", "@graphql-codegen/urql-introspection": "^3.0.0",
"@graphql-typed-document-node/core": "^3.2.0", "@graphql-typed-document-node/core": "^3.2.0",
"@iconify-json/lucide": "^1.1.119", "@iconify-json/lucide": "^1.1.141",
"@intlify/vite-plugin-vue-i18n": "^7.0.0", "@intlify/vite-plugin-vue-i18n": "^7.0.0",
"@relmify/jest-fp-ts": "^2.1.1", "@relmify/jest-fp-ts": "^2.1.1",
"@rushstack/eslint-patch": "^1.3.3", "@rushstack/eslint-patch": "^1.6.0",
"@types/har-format": "^1.2.12", "@types/har-format": "^1.2.15",
"@types/js-yaml": "^4.0.5", "@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.8", "@types/lodash-es": "^4.17.12",
"@types/lossless-json": "^1.0.1", "@types/lossless-json": "^1.0.4",
"@types/nprogress": "^0.2.0", "@types/nprogress": "^0.2.3",
"@types/paho-mqtt": "^1.0.7", "@types/paho-mqtt": "^1.0.10",
"@types/postman-collection": "^3.5.7", "@types/postman-collection": "^3.5.10",
"@types/splitpanes": "^2.2.1", "@types/splitpanes": "^2.2.6",
"@types/uuid": "^9.0.2", "@types/uuid": "^9.0.7",
"@types/yargs-parser": "^21.0.0", "@types/yargs-parser": "^21.0.3",
"@typescript-eslint/eslint-plugin": "^6.4.0", "@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.4.0", "@typescript-eslint/parser": "^6.12.0",
"@vitejs/plugin-vue": "^4.3.1", "@vitejs/plugin-vue": "^4.5.0",
"@vue/compiler-sfc": "^3.3.4", "@vue/compiler-sfc": "^3.3.8",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^12.0.0",
"@vue/runtime-core": "^3.3.4", "@vue/runtime-core": "^3.3.8",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"eslint": "^8.47.0", "eslint": "^8.54.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-vue": "^9.17.0", "eslint-plugin-vue": "^9.18.1",
"glob": "^10.3.10", "glob": "^10.3.10",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"postcss": "^8.4.23", "postcss": "^8.4.23",
"prettier-plugin-tailwindcss": "^0.5.6", "prettier": "^3.1.0",
"rollup-plugin-polyfill-node": "^0.12.0", "prettier-plugin-tailwindcss": "^0.5.7",
"sass": "^1.66.0", "rollup-plugin-polyfill-node": "^0.13.0",
"sass": "^1.69.5",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"typescript": "^5.1.6", "typescript": "^5.3.2",
"unplugin-fonts": "^1.0.3", "unplugin-fonts": "^1.1.1",
"unplugin-icons": "^0.16.5", "unplugin-icons": "^0.17.4",
"unplugin-vue-components": "^0.25.1", "unplugin-vue-components": "^0.25.2",
"vite": "^4.4.9", "vite": "^4.5.0",
"vite-plugin-checker": "^0.6.1", "vite-plugin-checker": "^0.6.2",
"vite-plugin-fonts": "^0.6.0", "vite-plugin-fonts": "^0.7.0",
"vite-plugin-html-config": "^1.0.11", "vite-plugin-html-config": "^1.0.11",
"vite-plugin-inspect": "^0.7.38", "vite-plugin-inspect": "^0.7.42",
"vite-plugin-pages": "^0.31.0", "vite-plugin-pages": "^0.31.0",
"vite-plugin-pages-sitemap": "^1.6.1", "vite-plugin-pages-sitemap": "^1.6.1",
"vite-plugin-pwa": "^0.16.4", "vite-plugin-pwa": "^0.17.0",
"vite-plugin-vue-layouts": "^0.8.0", "vite-plugin-vue-layouts": "^0.8.0",
"vitest": "^0.34.2", "vitest": "^0.34.6",
"vue-tsc": "^1.8.8" "vue-tsc": "^1.8.22"
} }
} }

View File

@@ -61,6 +61,7 @@ declare module 'vue' {
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default'] CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default'] CookiesAllModal: typeof import('./components/cookies/AllModal.vue')['default']
CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default'] CookiesEditCookie: typeof import('./components/cookies/EditCookie.vue')['default']
Embeds: typeof import('./components/embeds/index.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default'] Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default'] EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default'] EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
@@ -108,6 +109,7 @@ declare module 'vue' {
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing'] HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio'] HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup'] HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSelectWrapper: typeof import('@hoppscotch/ui')['HoppSmartSelectWrapper']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver'] HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'] HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'] HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
@@ -160,6 +162,14 @@ declare module 'vue' {
IconLucideRss: typeof import('~icons/lucide/rss')['default'] IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
ImportExportImportExportSourcesList: typeof import('./components/importExport/ImportExportSourcesList.vue')['default']
ImportExportImportExportStepsFileImport: typeof import('./components/importExport/ImportExportSteps/FileImport.vue')['default']
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']
InterceptorsErrorPlaceholder: typeof import('./components/interceptors/ErrorPlaceholder.vue')['default'] InterceptorsErrorPlaceholder: typeof import('./components/interceptors/ErrorPlaceholder.vue')['default']
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default'] InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
@@ -183,6 +193,16 @@ declare module 'vue' {
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default'] RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SettingsExtension: typeof import('./components/settings/Extension.vue')['default'] SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default'] SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
Share: typeof import('./components/share/index.vue')['default']
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']
ShareCustomizeModal: typeof import('./components/share/CustomizeModal.vue')['default']
ShareModal: typeof import('./components/share/Modal.vue')['default']
ShareRequest: typeof import('./components/share/Request.vue')['default']
ShareRequestModal: typeof import('./components/share/RequestModal.vue')['default']
ShareShareRequestModal: typeof import('./components/share/ShareRequestModal.vue')['default']
ShareTemplatesButton: typeof import('./components/share/templates/Button.vue')['default']
ShareTemplatesEmbeds: typeof import('./components/share/templates/Embeds.vue')['default']
ShareTemplatesLink: typeof import('./components/share/templates/Link.vue')['default']
SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default'] SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default']
SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default'] SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default'] SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default']
@@ -203,6 +223,7 @@ declare module 'vue' {
SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default'] SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default'] SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default'] SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default']
SmartSelectWrapper: typeof import('./../../hoppscotch-ui/src/components/smart/SelectWrapper.vue')['default']
SmartSlideOver: typeof import('./../../hoppscotch-ui/src/components/smart/SlideOver.vue')['default'] SmartSlideOver: typeof import('./../../hoppscotch-ui/src/components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./../../hoppscotch-ui/src/components/smart/Spinner.vue')['default'] SmartSpinner: typeof import('./../../hoppscotch-ui/src/components/smart/Spinner.vue')['default']
SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default'] SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default']

View File

@@ -1,20 +1,24 @@
<template> <template>
<div <div
:role="bannerRole" :role="bannerRole"
class="flex items-center px-4 py-2 text-tiny" class="flex items-center justify-between px-4 py-2 text-tiny text-secondaryDark"
:class="bannerColor" :class="bannerColor"
> >
<component :is="bannerIcon" class="mr-2 text-white" /> <div class="flex items-center">
<component :is="bannerIcon" class="mr-2" />
<span class="text-white"> <span :class="{ 'hidden sm:inline-flex': banner.alternateText }">
<span v-if="banner.alternateText" class="md:hidden">
{{ banner.alternateText(t) }}
</span>
<span :class="banner.alternateText ? '<md:hidden' : ''">
{{ banner.text(t) }} {{ banner.text(t) }}
</span> </span>
<span v-if="banner.alternateText" class="inline-flex sm:hidden">
{{ banner.alternateText(t) }}
</span> </span>
</div> </div>
<icon-lucide-x
v-if="dismissible"
class="opacity-50 hover:cursor-pointer hover:opacity-100"
@click="emit('dismiss')"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -26,22 +30,32 @@ import IconAlertCircle from "~icons/lucide/alert-circle"
import IconAlertTriangle from "~icons/lucide/alert-triangle" import IconAlertTriangle from "~icons/lucide/alert-triangle"
import IconInfo from "~icons/lucide/info" import IconInfo from "~icons/lucide/info"
const props = defineProps<{ const props = withDefaults(
defineProps<{
banner: BannerContent banner: BannerContent
}>() dismissible?: boolean
}>(),
{
dismissible: false,
}
)
const t = useI18n() const t = useI18n()
const emit = defineEmits<{
(e: "dismiss"): void
}>()
const ariaRoles: Record<BannerType, string> = { const ariaRoles: Record<BannerType, string> = {
error: "alert",
warning: "status",
info: "status", info: "status",
warning: "status",
error: "alert",
} }
const bgColors: Record<BannerType, string> = { const bgColors: Record<BannerType, string> = {
error: "bg-red-700", info: "bg-bannerInfo",
warning: "bg-yellow-700", warning: "bg-bannerWarning",
info: "bg-stone-800", error: "bg-bannerError",
} }
const icons = { const icons = {

View File

@@ -2,25 +2,27 @@
<div> <div>
<header <header
ref="headerRef" ref="headerRef"
class="flex flex-1 flex-shrink-0 items-center justify-between space-x-2 overflow-x-auto overflow-y-hidden px-2 py-2" class="grid grid-cols-5 grid-rows-1 gap-2 overflow-x-auto overflow-y-hidden p-2"
@mousedown.prevent="platform.ui?.appHeader?.onHeaderAreaClick?.()" @mousedown.prevent="platform.ui?.appHeader?.onHeaderAreaClick?.()"
> >
<div <div
class="inline-flex flex-1 items-center justify-start space-x-2" class="col-span-2 flex items-center justify-between space-x-2"
:style="{ :style="{
paddingTop: platform.ui?.appHeader?.paddingTop?.value, paddingTop: platform.ui?.appHeader?.paddingTop?.value,
paddingLeft: platform.ui?.appHeader?.paddingLeft?.value, paddingLeft: platform.ui?.appHeader?.paddingLeft?.value,
}" }"
> >
<div class="flex">
<HoppButtonSecondary <HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark" class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')" :label="t('app.name')"
to="/" to="/"
/> />
</div> </div>
<div class="inline-flex flex-1 items-center justify-center space-x-2"> </div>
<div class="col-span-1 flex items-center justify-between space-x-2">
<button <button
class="flex max-w-[15rem] flex-1 cursor-text items-center justify-between self-stretch rounded border border-dividerDark bg-primaryDark px-2 py-1 text-secondaryLight transition hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary" class="flex h-full flex-1 cursor-text items-center justify-between self-stretch rounded border border-dividerDark bg-primaryDark px-2 text-secondaryLight transition hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
@click="invokeAction('modals.search.toggle')" @click="invokeAction('modals.search.toggle')"
> >
<span class="inline-flex flex-1 items-center"> <span class="inline-flex flex-1 items-center">
@@ -32,6 +34,9 @@
<kbd class="shortcut-key">K</kbd> <kbd class="shortcut-key">K</kbd>
</span> </span>
</button> </button>
</div>
<div class="col-span-2 flex items-center justify-between space-x-2">
<div class="flex">
<HoppButtonSecondary <HoppButtonSecondary
v-if="showInstallButton" v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -50,7 +55,7 @@
@click="invokeAction('modals.support.toggle')" @click="invokeAction('modals.support.toggle')"
/> />
</div> </div>
<div class="inline-flex flex-1 items-center justify-end space-x-2"> <div class="flex">
<div <div
v-if="currentUser === null" v-if="currentUser === null"
class="inline-flex items-center space-x-2" class="inline-flex items-center space-x-2"
@@ -58,11 +63,12 @@
<HoppButtonSecondary <HoppButtonSecondary
:icon="IconUploadCloud" :icon="IconUploadCloud"
:label="t('header.save_workspace')" :label="t('header.save_workspace')"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 hidden border border-green-600/25 bg-green-500/[.15] !text-green-500 hover:border-green-800/50 hover:bg-green-400/10 focus-visible:border-green-800/50 focus-visible:bg-green-400/10 md:flex" class="!focus-visible:text-emerald-600 !hover:text-emerald-600 hidden h-8 border border-emerald-600/25 bg-emerald-500/10 !text-emerald-500 hover:border-emerald-600/20 hover:bg-emerald-600/20 focus-visible:border-emerald-600/20 focus-visible:bg-emerald-600/20 md:flex"
@click="invokeAction('modals.login.toggle')" @click="invokeAction('modals.login.toggle')"
/> />
<HoppButtonPrimary <HoppButtonPrimary
:label="t('header.login')" :label="t('header.login')"
class="h-8"
@click="invokeAction('modals.login.toggle')" @click="invokeAction('modals.login.toggle')"
/> />
</div> </div>
@@ -79,13 +85,13 @@
@handle-click="handleTeamEdit()" @handle-click="handleTeamEdit()"
/> />
<div <div
class="flex divide-x divide-green-600/25 rounded border border-green-600/25 bg-green-500/[.15] focus-within:divide-green-800/50 focus-within:border-green-800/50 focus-within:bg-green-400/10 hover:divide-green-800/50 hover:border-green-800/50 hover:bg-green-400/10" class="flex h-8 divide-x divide-emerald-600/25 rounded border border-emerald-600/25 bg-emerald-500/10 focus-within:divide-emerald-600/20 focus-within:border-emerald-600/20 focus-within:bg-emerald-600/20 hover:divide-emerald-600/20 hover:border-emerald-600/20 hover:bg-emerald-600/20"
> >
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')" :title="t('team.invite_tooltip')"
:icon="IconUserPlus" :icon="IconUserPlus"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500" class="!focus-visible:text-emerald-600 !hover:text-emerald-600 !text-emerald-500"
@click="handleInvite()" @click="handleInvite()"
/> />
<HoppButtonSecondary <HoppButtonSecondary
@@ -97,7 +103,7 @@
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('team.edit')" :title="t('team.edit')"
:icon="IconSettings" :icon="IconSettings"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500" class="!focus-visible:text-emerald-600 !hover:text-emerald-600 !text-emerald-500"
@click="handleTeamEdit()" @click="handleTeamEdit()"
/> />
</div> </div>
@@ -106,14 +112,18 @@
trigger="click" trigger="click"
theme="popover" theme="popover"
:on-shown="() => accountActions.focus()" :on-shown="() => accountActions.focus()"
>
<HoppSmartSelectWrapper
class="!text-blue-500 !focus-visible:text-blue-600 !hover:text-blue-600"
> >
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('workspace.change')" :title="t('workspace.change')"
:label="mdAndLarger ? workspaceName : ``" :label="mdAndLarger ? workspaceName : ``"
:icon="workspace.type === 'personal' ? IconUser : IconUsers" :icon="workspace.type === 'personal' ? IconUser : IconUsers"
class="select-wrapper !focus-visible:text-blue-600 !hover:text-blue-600 rounded border border-blue-600/25 bg-blue-500/[.15] py-[0.4375rem] pr-8 !text-blue-500 hover:border-blue-800/50 hover:bg-blue-400/10 focus-visible:border-blue-800/50 focus-visible:bg-blue-400/10" class="!focus-visible:text-blue-600 !hover:text-blue-600 h-8 rounded border border-blue-600/25 bg-blue-500/10 pr-8 !text-blue-500 hover:border-blue-600/20 hover:bg-blue-600/20 focus-visible:border-blue-600/20 focus-visible:bg-blue-600/20"
/> />
</HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="accountActions" ref="accountActions"
@@ -134,15 +144,10 @@
:on-shown="() => tippyActions.focus()" :on-shown="() => tippyActions.focus()"
> >
<HoppSmartPicture <HoppSmartPicture
v-if="currentUser.photoURL"
v-tippy="{ v-tippy="{
theme: 'tooltip', theme: 'tooltip',
}" }"
:url="currentUser.photoURL" :name="currentUser.uid"
:alt="
currentUser.displayName ||
t('profile.default_hopp_displayname')
"
:title=" :title="
currentUser.displayName || currentUser.displayName ||
currentUser.email || currentUser.email ||
@@ -153,20 +158,6 @@
network.isOnline ? 'bg-green-500' : 'bg-red-500' network.isOnline ? 'bg-green-500' : 'bg-red-500'
" "
/> />
<HoppSmartPicture
v-else
v-tippy="{ theme: 'tooltip' }"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
:initial="currentUser.displayName || currentUser.email"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="tippyActions" ref="tippyActions"
@@ -177,14 +168,16 @@
@keyup.l="logout.$el.click()" @keyup.l="logout.$el.click()"
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<div class="flex flex-col px-2 text-tiny"> <div class="flex flex-col px-2">
<span class="inline-flex truncate font-semibold"> <span class="inline-flex truncate font-semibold">
{{ {{
currentUser.displayName || currentUser.displayName ||
t("profile.default_hopp_displayname") t("profile.default_hopp_displayname")
}} }}
</span> </span>
<span class="inline-flex truncate text-secondaryLight"> <span
class="inline-flex truncate text-secondaryLight text-tiny"
>
{{ currentUser.email }} {{ currentUser.email }}
</span> </span>
</div> </div>
@@ -216,8 +209,14 @@
</span> </span>
</div> </div>
</div> </div>
</div>
</header> </header>
<AppBanner v-if="bannerContent" :banner="bannerContent" /> <AppBanner
v-if="bannerContent"
:banner="bannerContent"
:dismissible="true"
@dismiss="dismissOfflineBanner"
/>
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" /> <TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
<TeamsInvite <TeamsInvite
v-if="workspace.type === 'team' && workspace.teamID" v-if="workspace.type === 'team' && workspace.teamID"
@@ -233,7 +232,6 @@
@invite-team="inviteTeam(editingTeamName, editingTeamID)" @invite-team="inviteTeam(editingTeamName, editingTeamID)"
@refetch-teams="refetchTeams" @refetch-teams="refetchTeams"
/> />
<HoppSmartConfirmModal <HoppSmartConfirmModal
:show="confirmRemove" :show="confirmRemove"
:title="t('confirm.remove_team')" :title="t('confirm.remove_team')"
@@ -293,7 +291,7 @@ const bannerContent = computed(() => banner.content.value?.content)
let bannerID: number | null = null let bannerID: number | null = null
const offlineBanner: BannerContent = { const offlineBanner: BannerContent = {
type: "info", type: "warning",
text: (t) => t("helpers.offline"), text: (t) => t("helpers.offline"),
alternateText: (t) => t("helpers.offline_short"), alternateText: (t) => t("helpers.offline_short"),
score: BANNER_PRIORITY_HIGH, score: BANNER_PRIORITY_HIGH,
@@ -307,13 +305,14 @@ watch(isOnline, () => {
if (!isOnline.value) { if (!isOnline.value) {
bannerID = banner.showBanner(offlineBanner) bannerID = banner.showBanner(offlineBanner)
return return
} else { }
if (banner.content && bannerID) { if (banner.content && bannerID) {
banner.removeBanner(bannerID) banner.removeBanner(bannerID)
} }
}
}) })
const dismissOfflineBanner = () => banner.removeBanner(bannerID!)
const currentUser = useReadonlyStream( const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(), platform.auth.getProbableUserStream(),
platform.auth.getProbableUser() platform.auth.getProbableUser()

View File

@@ -92,9 +92,8 @@ const getHighestSeverity = computed(() => {
}, },
{ severity: 0 } { severity: 0 }
) )
} else {
return { severity: 0 }
} }
return { severity: 0 }
}) })
const severityColor = (severity: number) => { const severityColor = (severity: number) => {

View File

@@ -17,9 +17,10 @@
v-if="isEmpty(shortcutsResults)" v-if="isEmpty(shortcutsResults)"
:text="`${t('state.nothing_found')} ‟${filterText}”`" :text="`${t('state.nothing_found')} ‟${filterText}”`"
> >
<icon-lucide-search class="svg-icons pb-2 opacity-75" /> <template #icon>
<icon-lucide-search class="svg-icons opacity-75" />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<details <details
v-for="(sectionResults, sectionTitle) in shortcutsResults" v-for="(sectionResults, sectionTitle) in shortcutsResults"
v-else v-else

View File

@@ -9,8 +9,8 @@
> >
<component <component
:is="entry.icon" :is="entry.icon"
class="svg-icons opacity-50" class="svg-icons opacity-80"
:class="{ 'opacity-100': active }" :class="{ 'opacity-25': active }"
/> />
<template <template
v-if="entry.text.type === 'text' && typeof entry.text.text === 'string'" v-if="entry.text.type === 'text' && typeof entry.text.text === 'string'"
@@ -82,9 +82,9 @@ const props = defineProps<{
const formattedShortcutKeys = computed( const formattedShortcutKeys = computed(
() => () =>
props.entry.meta?.keyboardShortcut?.map((key) => { props.entry.meta?.keyboardShortcut?.map(
return SPECIAL_KEY_CHARS[key] ?? capitalize(key) (key) => SPECIAL_KEY_CHARS[key] ?? capitalize(key)
}) )
) )
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -16,7 +16,7 @@
autocomplete="off" autocomplete="off"
name="command" name="command"
:placeholder="`${t('app.type_a_command_search')}`" :placeholder="`${t('app.type_a_command_search')}`"
class="flex flex-1 bg-transparent px-6 py-5 text-base text-secondaryDark" class="flex flex-1 bg-transparent px-6 pt-5 pb-3 text-base text-secondaryDark"
/> />
<HoppSmartSpinner v-if="searchSession?.loading" class="mr-6" /> <HoppSmartSpinner v-if="searchSession?.loading" class="mr-6" />
</div> </div>
@@ -49,13 +49,15 @@
:text="`${t('state.nothing_found')} ‟${search}”`" :text="`${t('state.nothing_found')} ‟${search}”`"
> >
<template #icon> <template #icon>
<icon-lucide-search class="svg-icons pb-2 opacity-75" /> <icon-lucide-search class="svg-icons opacity-75" />
</template> </template>
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('action.clear')" :label="t('action.clear')"
outline outline
@click="search = ''" @click="search = ''"
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
<div <div
@@ -112,6 +114,7 @@ import {
WorkspaceSpotlightSearcherService, WorkspaceSpotlightSearcherService,
} from "~/services/spotlight/searchers/workspace.searcher" } from "~/services/spotlight/searchers/workspace.searcher"
import { InterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/interceptor.searcher" import { InterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/interceptor.searcher"
import { platform } from "~/platform"
const t = useI18n() const t = useI18n()
@@ -141,6 +144,10 @@ useService(WorkspaceSpotlightSearcherService)
useService(SwitchWorkspaceSpotlightSearcherService) useService(SwitchWorkspaceSpotlightSearcherService)
useService(InterceptorSpotlightSearcherService) useService(InterceptorSpotlightSearcherService)
platform.spotlight?.additionalSearchers?.forEach((searcher) =>
useService(searcher)
)
const search = ref("") const search = ref("")
const searchSession = ref<SpotlightSearchState>() const searchSession = ref<SpotlightSearchState>()

View File

@@ -14,14 +14,14 @@
></div> ></div>
<div class="relative flex flex-col"> <div class="relative flex flex-col">
<div <div
class="z-1 pointer-events-none absolute inset-0 bg-accent opacity-0 transition" class="z-[1] pointer-events-none absolute inset-0 bg-accent opacity-0 transition"
:class="{ :class="{
'opacity-25': 'opacity-25':
dragging && notSameDestination && notSameParentDestination, dragging && notSameDestination && notSameParentDestination,
}" }"
></div> ></div>
<div <div
class="z-3 group pointer-events-auto relative flex cursor-pointer items-stretch" class="z-[3] group pointer-events-auto relative flex cursor-pointer items-stretch"
:draggable="!hasNoTeamAccess" :draggable="!hasNoTeamAccess"
@dragstart="dragStart" @dragstart="dragStart"
@drop="handelDrop($event)" @drop="handelDrop($event)"
@@ -290,13 +290,13 @@ const collectionIcon = computed(() => {
if (props.isSelected) return IconCheckCircle if (props.isSelected) return IconCheckCircle
else if (!props.isOpen) return IconFolder else if (!props.isOpen) return IconFolder
else if (props.isOpen) return IconFolderOpen else if (props.isOpen) return IconFolderOpen
else return IconFolder return IconFolder
}) })
const collectionName = computed(() => { const collectionName = computed(() => {
if ((props.data as HoppCollection<HoppRESTRequest>).name) if ((props.data as HoppCollection<HoppRESTRequest>).name)
return (props.data as HoppCollection<HoppRESTRequest>).name return (props.data as HoppCollection<HoppRESTRequest>).name
else return (props.data as TeamCollection).title return (props.data as TeamCollection).title
}) })
watch( watch(
@@ -424,9 +424,8 @@ const isCollLoading = computed(() => {
props.data.id props.data.id
) { ) {
return collectionMoveLoading.includes(props.data.id) return collectionMoveLoading.includes(props.data.id)
} else {
return false
} }
return false
}) })
const resetDragState = () => { const resetDragState = () => {

View File

@@ -1,361 +1,568 @@
<template> <template>
<HoppSmartModal <ImportExportBase
v-if="show" ref="collections-import-export"
dialog modal-title="modal.collections"
:title="t('modal.collections')" :importer-modules="importerModules"
styles="sm:max-w-md" :exporter-modules="exporterModules"
@close="hideModal" @hide-modal="emit('hide-modal')"
>
<template #actions>
<HoppButtonSecondary
v-if="importerType !== null"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.go_back')"
:icon="IconArrowLeft"
@click="resetImport"
/> />
</template> </template>
<template #body>
<div v-if="importerType !== null" class="flex flex-col">
<div class="flex flex-col pb-4">
<div
v-for="(step, index) in importerSteps"
:key="`step-${index}`"
class="flex flex-col space-y-8"
>
<div v-if="step.name === 'FILE_IMPORT'" class="space-y-4">
<p class="flex items-center">
<span
class="mr-4 inline-flex flex-shrink-0 items-center justify-center rounded-full border-4 border-primary text-dividerDark"
:class="{
'!text-green-500': hasFile,
}"
>
<icon-lucide-check-circle class="svg-icons" />
</span>
<span>
{{ t(`${step.metadata.caption}`) }}
</span>
</p>
<p
class="ml-10 flex flex-col rounded border border-dashed border-dividerDark"
>
<input
id="inputChooseFileToImportFrom"
ref="inputChooseFileToImportFrom"
name="inputChooseFileToImportFrom"
type="file"
class="cursor-pointer p-4 text-secondary transition file:mr-2 file:cursor-pointer file:rounded file:border-0 file:bg-primaryLight file:px-4 file:py-2 file:text-secondary file:transition hover:text-secondaryDark hover:file:bg-primaryDark hover:file:text-secondaryDark"
:accept="step.metadata.acceptedFileTypes"
@change="onFileChange"
/>
</p>
</div>
<div v-else-if="step.name === 'URL_IMPORT'" class="space-y-4">
<p class="flex items-center">
<span
class="mr-4 inline-flex flex-shrink-0 items-center justify-center rounded-full border-4 border-primary text-dividerDark"
:class="{
'!text-green-500': hasGist,
}"
>
<icon-lucide-check-circle class="svg-icons" />
</span>
<span>
{{ t(`${step.metadata.caption}`) }}
</span>
</p>
<p class="ml-10 flex flex-col">
<input
v-model="inputChooseGistToImportFrom"
type="url"
class="input"
:placeholder="`${t('import.gist_url')}`"
/>
</p>
</div>
<div
v-else-if="step.name === 'TARGET_MY_COLLECTION'"
class="flex flex-col"
>
<div class="select-wrapper">
<select
v-model="mySelectedCollectionID"
autocomplete="off"
class="select"
autofocus
>
<option :key="undefined" :value="undefined" disabled selected>
{{ t("collection.select") }}
</option>
<option
v-for="(collection, collectionIndex) in myCollections"
:key="`collection-${collectionIndex}`"
:value="collectionIndex"
class="bg-primary"
>
{{ collection.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<HoppButtonPrimary
:label="t('import.title')"
:disabled="enableImportButton"
:loading="importingMyCollections"
@click="finishImport"
/>
</div>
<div v-else class="flex flex-col">
<HoppSmartExpand>
<template #body>
<HoppSmartItem
v-for="(importer, index) in importerModules"
:key="`importer-${index}`"
:icon="importer.icon"
:label="t(`${importer.name}`)"
@click="importerType = index"
/>
</template>
</HoppSmartExpand>
<hr />
<div class="flex flex-col space-y-2">
<HoppSmartItem
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:icon="IconDownload"
:loading="exportingTeamCollections"
:label="t('export.as_json')"
@click="emit('export-json-collection')"
/>
<span
v-if="platform.platformFeatureFlags.exportAsGIST"
v-tippy="{ theme: 'tooltip' }"
:title="
!currentUser
? `${t('export.require_github')}`
: currentUser.provider !== 'github.com'
? `${t('export.require_github')}`
: undefined
"
class="flex"
>
<HoppSmartItem
:disabled="
!currentUser
? true
: currentUser.provider !== 'github.com'
? true
: false
"
:icon="IconGithub"
:loading="creatingGistCollection"
:label="t('export.create_secret_gist')"
@click="emit('create-collection-gist')"
/>
</span>
</div>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts"> <script setup lang="ts">
import IconArrowLeft from "~icons/lucide/arrow-left"
import IconDownload from "~icons/lucide/download"
import IconGithub from "~icons/lucide/github"
import { computed, PropType, ref, watch } from "vue"
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { HoppRESTRequest, HoppCollection } from "@hoppscotch/data" import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource"
import { useI18n } from "@composables/i18n" import { UrlSource } from "~/helpers/import-export/import/import-sources/UrlSource"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast" import IconFile from "~icons/lucide/file"
import { platform } from "~/platform"
import {
hoppRESTImporter,
hoppInsomniaImporter,
hoppPostmanImporter,
toTeamsImporter,
hoppOpenAPIImporter,
} from "~/helpers/import-export/import/importers"
import { defineStep } from "~/composables/step-components"
import { PropType, computed, ref } from "vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { HoppCollection } from "@hoppscotch/data"
import { HoppRESTRequest } from "@hoppscotch/data"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections" import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
import { RESTCollectionImporters } from "~/helpers/import-export/import/importers" import MyCollectionImport from "~/components/importExport/ImportExportSteps/MyCollectionImport.vue"
import { StepReturnValue } from "~/helpers/import-export/steps" import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconOpenAPI from "~icons/lucide/file"
import IconPostman from "~icons/hopp/postman"
import IconInsomnia from "~icons/hopp/insomnia"
import IconGithub from "~icons/lucide/github"
import IconLink from "~icons/lucide/link"
import IconUser from "~icons/lucide/user"
import { useReadonlyStream } from "~/composables/stream"
import { getTeamCollectionJSON } from "~/helpers/backend/helpers"
import { platform } from "~/platform"
import { initializeDownloadCollection } from "~/helpers/import-export/export"
import { collectionsGistExporter } from "~/helpers/import-export/export/gistExport"
import { myCollectionsExporter } from "~/helpers/import-export/export/myCollections"
import { teamCollectionsExporter } from "~/helpers/import-export/export/teamCollections"
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
import { ImporterOrExporter } from "~/components/importExport/types"
const toast = useToast()
const t = useI18n() const t = useI18n()
const toast = useToast()
type CollectionType = "team-collections" | "my-collections" type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
| {
type: "team-collections"
selectedTeam: SelectedTeam
}
| { type: "my-collections" }
const props = defineProps({ const props = defineProps({
show: {
type: Boolean,
default: false,
required: true,
},
collectionsType: { collectionsType: {
type: String as PropType<CollectionType>, type: Object as PropType<CollectionType>,
default: "my-collections", default: () => ({
type: "my-collections",
selectedTeam: undefined,
}),
required: true, required: true,
}, },
exportingTeamCollections: {
type: Boolean,
default: false,
required: false,
},
creatingGistCollection: {
type: Boolean,
default: false,
required: false,
},
importingMyCollections: {
type: Boolean,
default: false,
required: false,
},
}) })
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "update-team-collections"): void
(e: "export-json-collection"): void
(e: "create-collection-gist"): void
(e: "import-to-teams", payload: HoppCollection<HoppRESTRequest>[]): void
}>()
const hasFile = ref(false)
const hasGist = ref(false)
const importerType = ref<number | null>(null)
const stepResults = ref<StepReturnValue[]>([])
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
const mySelectedCollectionID = ref<number | undefined>(undefined)
const inputChooseGistToImportFrom = ref<string>("")
const importerModules = computed(() =>
RESTCollectionImporters.filter(
(i) => i.applicableTo?.includes(props.collectionsType) ?? true
)
)
const importerModule = computed(() => {
if (importerType.value === null) return null
return importerModules.value[importerType.value]
})
const importerSteps = computed(() => importerModule.value?.steps ?? null)
const enableImportButton = computed(
() => !(stepResults.value.length === importerSteps.value?.length)
)
watch(mySelectedCollectionID, (newValue) => {
if (newValue === undefined) return
stepResults.value = []
stepResults.value.push(newValue)
})
watch(inputChooseGistToImportFrom, (url) => {
stepResults.value = []
if (url === "") {
hasGist.value = false
} else {
hasGist.value = true
stepResults.value.push(inputChooseGistToImportFrom.value)
}
})
const myCollections = useReadonlyStream(restCollections$, [])
const currentUser = useReadonlyStream( const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(), platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser() platform.auth.getCurrentUser()
) )
const importerAction = async (stepResults: StepReturnValue[]) => { const showImportFailedError = () => {
if (!importerModule.value) return toast.error(t("import.failed"))
}
pipe( const handleImportToStore = async (
await importerModule.value.importer(stepResults as any)(), collections: HoppCollection<HoppRESTRequest>[]
E.match( ) => {
(err) => { const importResult =
failedImport() props.collectionsType.type === "my-collections"
console.error("error", err) ? await importToPersonalWorkspace(collections)
}, : await importToTeamsWorkspace(collections)
(result) => {
if (props.collectionsType === "team-collections") { if (E.isRight(importResult)) {
emit("import-to-teams", result) toast.success(t("state.file_imported"))
emit("hide-modal")
} else { } else {
appendRESTCollections(result) toast.error(t("import.failed"))
}
}
const importToPersonalWorkspace = (
collections: HoppCollection<HoppRESTRequest>[]
) => {
appendRESTCollections(collections)
return E.right({
success: true,
})
}
const importToTeamsWorkspace = async (
collections: HoppCollection<HoppRESTRequest>[]
) => {
if (!hasTeamWriteAccess.value || !selectedTeamID.value) {
return E.left({
success: false,
})
}
const res = await toTeamsImporter(
JSON.stringify(collections),
selectedTeamID.value
)()
return E.isRight(res)
? E.right({ success: true })
: E.left({
success: false,
})
}
const emit = defineEmits<{
(e: "hide-modal"): () => void
}>()
const isHoppMyCollectionExporterInProgress = ref(false)
const isHoppTeamCollectionExporterInProgress = ref(false)
const isHoppGistCollectionExporterInProgress = ref(false)
const isTeamWorkspace = computed(() => {
return props.collectionsType.type === "team-collections"
})
const HoppRESTImporter: ImporterOrExporter = {
metadata: {
id: "hopp_rest",
name: "import.from_json",
title: "import.from_json_description",
icon: IconFolderPlus,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
component: FileSource({
caption: "import.from_file",
acceptedFileTypes: ".json",
onImportFromFile: async (content) => {
const res = await hoppRESTImporter(content)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({ platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION", type: "HOPP_IMPORT_COLLECTION",
importer: importerModule.value!.name, importer: "import.from_json",
platform: "rest", platform: "rest",
workspaceType: "personal", workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
}
const HoppMyCollectionImporter: ImporterOrExporter = {
metadata: {
id: "hopp_my_collection",
name: "import.from_my_collections",
title: "import.from_my_collections_description",
icon: IconUser,
disabled: false,
applicableTo: ["team-workspace"],
},
component: defineStep("my_collection_import", MyCollectionImport, () => ({
async onImportFromMyCollection(content) {
handleImportToStore([content])
// our analytics consider this as an export event, so keeping compatibility with that
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "import_to_teams",
platform: "rest",
})
},
})),
}
const HoppOpenAPIImporter: ImporterOrExporter = {
metadata: {
id: "hopp_openapi",
name: "import.from_openapi",
title: "import.from_openapi_description",
icon: IconOpenAPI,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
supported_sources: [
{
id: "file_import",
name: "import.from_file",
icon: IconFile,
step: FileSource({
caption: "import.from_file",
acceptedFileTypes: ".json, .yaml, .yml",
onImportFromFile: async (content) => {
const res = await hoppOpenAPIImporter(content)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
platform: "rest",
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_openapi",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
},
{
id: "url_import",
name: "import.from_url",
icon: IconLink,
step: UrlSource({
caption: "import.from_url",
onImportFromURL: async (content) => {
const res = await hoppOpenAPIImporter(content)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
platform: "rest",
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_openapi",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
},
],
}
const HoppPostmanImporter: ImporterOrExporter = {
metadata: {
id: "hopp_postman",
name: "import.from_postman",
title: "import.from_postman_description",
icon: IconPostman,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
component: FileSource({
caption: "import.from_file",
acceptedFileTypes: ".json",
onImportFromFile: async (content) => {
const res = await hoppPostmanImporter(content)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
platform: "rest",
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_postman",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
}
const HoppInsomniaImporter: ImporterOrExporter = {
metadata: {
id: "hopp_insomnia",
name: "import.from_insomnia",
title: "import.from_insomnia_description",
icon: IconInsomnia,
disabled: true,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
component: FileSource({
caption: "import.from_file",
acceptedFileTypes: ".json",
onImportFromFile: async (content) => {
const res = await hoppInsomniaImporter(content)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
platform: "rest",
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_insomnia",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
}
const HoppGistImporter: ImporterOrExporter = {
metadata: {
id: "hopp_gist",
name: "import.from_gist",
title: "import.from_gist_description",
icon: IconGithub,
disabled: true,
applicableTo: ["personal-workspace", "team-workspace", "url-import"],
},
component: GistSource({
caption: "import.from_url",
onImportFromGist: async (content) => {
if (E.isLeft(content)) {
showImportFailedError()
return
}
const res = await hoppRESTImporter(content.right)()
if (E.isRight(res)) {
handleImportToStore(res.right)
platform.analytics?.logEvent({
platform: "rest",
type: "HOPP_IMPORT_COLLECTION",
importer: "import.from_gist",
workspaceType: isTeamWorkspace.value ? "team" : "personal",
})
} else {
showImportFailedError()
}
},
}),
}
const HoppMyCollectionsExporter: ImporterOrExporter = {
metadata: {
id: "hopp_my_collections",
name: "export.as_json",
title: "action.download_file",
icon: IconUser,
disabled: false,
applicableTo: ["personal-workspace"],
isLoading: isHoppMyCollectionExporterInProgress,
},
action: () => {
if (!myCollections.value.length) {
return toast.error(t("error.no_collections_to_export"))
}
isHoppMyCollectionExporterInProgress.value = true
const message = initializeDownloadCollection(
myCollectionsExporter(myCollections.value),
"Collections"
)
if (E.isRight(message)) {
toast.success(t(message.right))
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "rest",
})
}
isHoppMyCollectionExporterInProgress.value = false
},
}
const HoppTeamCollectionsExporter: ImporterOrExporter = {
metadata: {
id: "hopp_team_collections",
name: "export.as_json",
title: "export.as_json_description",
icon: IconUser,
disabled: false,
applicableTo: ["team-workspace"],
isLoading: isHoppTeamCollectionExporterInProgress,
},
action: async () => {
isHoppTeamCollectionExporterInProgress.value = true
if (
props.collectionsType.type === "team-collections" &&
props.collectionsType.selectedTeam
) {
const res = await teamCollectionsExporter(
props.collectionsType.selectedTeam.id
)
if (E.isRight(res)) {
const { exportCollectionsToJSON } = res.right
if (!JSON.parse(exportCollectionsToJSON).length) {
isHoppTeamCollectionExporterInProgress.value = false
return toast.error(t("error.no_collections_to_export"))
}
initializeDownloadCollection(
exportCollectionsToJSON,
"team-collections"
)
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "rest",
})
} else {
toast.error(res.left.error.toString())
}
}
isHoppTeamCollectionExporterInProgress.value = false
},
}
const HoppGistCollectionsExporter: ImporterOrExporter = {
metadata: {
id: "create_secret_gist",
name: "export.create_secret_gist",
icon: IconGithub,
disabled: !currentUser.value
? true
: currentUser.value.provider !== "github.com",
title: t("export.create_secret_gist"),
applicableTo: ["personal-workspace", "team-workspace"],
isLoading: isHoppGistCollectionExporterInProgress,
},
action: async () => {
isHoppGistCollectionExporterInProgress.value = true
const collectionJSON = await getCollectionJSON()
const accessToken = currentUser.value?.accessToken
if (!accessToken) {
toast.error(t("error.something_went_wrong"))
isHoppGistCollectionExporterInProgress.value = false
return
}
if (E.isRight(collectionJSON)) {
collectionsGistExporter(collectionJSON.right, accessToken)
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "gist",
platform: "rest",
})
}
isHoppGistCollectionExporterInProgress.value = false
},
}
const importerModules = computed(() => {
const enabledImporters = [
HoppRESTImporter,
HoppMyCollectionImporter,
HoppOpenAPIImporter,
HoppPostmanImporter,
HoppInsomniaImporter,
HoppGistImporter,
]
const isTeams = props.collectionsType.type === "team-collections"
return enabledImporters.filter((importer) => {
return isTeams
? importer.metadata.applicableTo.includes("team-workspace")
: importer.metadata.applicableTo.includes("personal-workspace")
})
}) })
fileImported() const exporterModules = computed(() => {
} const enabledExporters = [
HoppMyCollectionsExporter,
HoppTeamCollectionsExporter,
]
if (platform.platformFeatureFlags.exportAsGIST) {
enabledExporters.push(HoppGistCollectionsExporter)
} }
return enabledExporters.filter((exporter) => {
return exporter.metadata.applicableTo.includes(
props.collectionsType.type === "my-collections"
? "personal-workspace"
: "team-workspace"
) )
})
})
const hasTeamWriteAccess = computed(() => {
const { collectionsType } = props
const isTeamCollection = collectionsType.type === "team-collections"
if (!isTeamCollection || !collectionsType.selectedTeam) {
return false
}
return (
collectionsType.selectedTeam.myRole === "EDITOR" ||
collectionsType.selectedTeam.myRole === "OWNER"
) )
})
const selectedTeamID = computed(() => {
const { collectionsType } = props
return collectionsType.type === "team-collections"
? collectionsType.selectedTeam?.id
: undefined
})
const myCollections = useReadonlyStream(restCollections$, [])
const getCollectionJSON = async () => {
if (
props.collectionsType.type === "team-collections" &&
props.collectionsType.selectedTeam?.id
) {
const res = await getTeamCollectionJSON(
props.collectionsType.selectedTeam?.id
)
return E.isRight(res)
? E.right(res.right.exportCollectionsToJSON)
: E.left(res.left)
} }
const finishImport = async () => { if (props.collectionsType.type === "my-collections") {
await importerAction(stepResults.value) return E.right(JSON.stringify(myCollections.value, null, 2))
} }
const onFileChange = () => { return E.left("INVALID_SELECTED_TEAM_OR_INVALID_COLLECTION_TYPE")
stepResults.value = []
const inputFileToImport = inputChooseFileToImportFrom.value[0]
if (!inputFileToImport) {
hasFile.value = false
return
}
if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
inputChooseFileToImportFrom.value[0].value = ""
hasFile.value = false
toast.show(t("action.choose_file").toString())
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
hasFile.value = false
toast.show(t("action.choose_file").toString())
return
}
stepResults.value.push(content)
hasFile.value = !!content?.length
}
reader.readAsText(inputFileToImport.files[0])
}
const fileImported = () => {
toast.success(t("state.file_imported").toString())
hideModal()
}
const failedImport = () => {
toast.error(t("import.failed").toString())
}
const hideModal = () => {
resetImport()
emit("hide-modal")
}
const resetImport = () => {
importerType.value = null
hasFile.value = false
hasGist.value = false
stepResults.value = []
inputChooseFileToImportFrom.value = ""
inputChooseGistToImportFrom.value = ""
mySelectedCollectionID.value = undefined
} }
</script> </script>

View File

@@ -222,6 +222,12 @@
requestIndex: pathToIndex(node.id), requestIndex: pathToIndex(node.id),
}) })
" "
@share-request="
node.data.type === 'requests' &&
emit('share-request', {
request: node.data.data.data,
})
"
@drag-request=" @drag-request="
dragRequest($event, { dragRequest($event, {
folderPath: node.data.data.parentIndex, folderPath: node.data.data.parentIndex,
@@ -248,7 +254,7 @@
:text="`${t('state.nothing_found')}${filterText}`" :text="`${t('state.nothing_found')}${filterText}`"
> >
<template #icon> <template #icon>
<icon-lucide-search class="svg-icons pb-2 opacity-75" /> <icon-lucide-search class="svg-icons opacity-75" />
</template> </template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
@@ -257,6 +263,7 @@
:alt="`${t('empty.collections')}`" :alt="`${t('empty.collections')}`"
:text="t('empty.collections')" :text="t('empty.collections')"
> >
<template #body>
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight"> <span class="text-center text-secondaryLight">
{{ t("collection.import_or_create") }} {{ t("collection.import_or_create") }}
@@ -278,6 +285,7 @@
/> />
</div> </div>
</div> </div>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-else-if="node.data.type === 'collections'" v-else-if="node.data.type === 'collections'"
@@ -285,6 +293,7 @@
:alt="`${t('empty.collections')}`" :alt="`${t('empty.collections')}`"
:text="t('empty.collections')" :text="t('empty.collections')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('add.new')" :label="t('add.new')"
filled filled
@@ -297,6 +306,7 @@
}) })
" "
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-else-if="node.data.type === 'folders'" v-else-if="node.data.type === 'folders'"
@@ -460,6 +470,12 @@ const emit = defineEmits<{
isActive: boolean isActive: boolean
} }
): void ): void
(
event: "share-request",
payload: {
request: HoppRESTRequest
}
): void
( (
event: "drop-request", event: "drop-request",
payload: { payload: {
@@ -526,14 +542,13 @@ const isSelected = ({
props.picked.folderPath === folderPath && props.picked.folderPath === folderPath &&
props.picked.requestIndex === requestIndex props.picked.requestIndex === requestIndex
) )
} else { }
return ( return (
props.picked && props.picked &&
props.picked.pickedType === "my-folder" && props.picked.pickedType === "my-folder" &&
props.picked.folderPath === folderPath props.picked.folderPath === folderPath
) )
} }
}
const tabs = useService(RESTTabService) const tabs = useService(RESTTabService)
const active = computed(() => tabs.currentActiveTab.value.document.saveContext) const active = computed(() => tabs.currentActiveTab.value.document.saveContext)
@@ -729,12 +744,11 @@ class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {
status: "loaded", status: "loaded",
data: data, data: data,
} as ChildrenResult<Folder | Requests> } as ChildrenResult<Folder | Requests>
} else { }
return { return {
status: "loaded", status: "loaded",
data: [], data: [],
} }
}
}) })
} }
} }

View File

@@ -28,8 +28,7 @@
> >
<span <span
class="pointer-events-none flex w-16 items-center justify-center truncate px-2" class="pointer-events-none flex w-16 items-center justify-center truncate px-2"
:class="requestLabelColor" :style="{ color: getMethodLabelColorClassOf(request) }"
:style="{ color: requestLabelColor }"
> >
<component <component
:is="IconCheckCircle" :is="IconCheckCircle"
@@ -94,6 +93,7 @@
@keyup.e="edit?.$el.click()" @keyup.e="edit?.$el.click()"
@keyup.d="duplicate?.$el.click()" @keyup.d="duplicate?.$el.click()"
@keyup.delete="deleteAction?.$el.click()" @keyup.delete="deleteAction?.$el.click()"
@keyup.s="shareAction?.$el.click()"
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<HoppSmartItem <HoppSmartItem
@@ -133,6 +133,18 @@
} }
" "
/> />
<HoppSmartItem
ref="shareAction"
:icon="IconShare2"
:label="t('action.share')"
:shortcut="['S']"
@click="
() => {
emit('share-request')
hide()
}
"
/>
</div> </div>
</template> </template>
</tippy> </tippy>
@@ -162,6 +174,7 @@ import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy" import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2" import IconTrash2 from "~icons/lucide/trash-2"
import IconRotateCCW from "~icons/lucide/rotate-ccw" import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconShare2 from "~icons/lucide/share-2"
import { ref, PropType, watch, computed } from "vue" import { ref, PropType, watch, computed } from "vue"
import { HoppRESTRequest } from "@hoppscotch/data" import { HoppRESTRequest } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
@@ -240,6 +253,7 @@ const emit = defineEmits<{
(event: "duplicate-request"): void (event: "duplicate-request"): void
(event: "remove-request"): void (event: "remove-request"): void
(event: "select-request"): void (event: "select-request"): void
(event: "share-request"): void
(event: "drag-request", payload: DataTransfer): void (event: "drag-request", payload: DataTransfer): void
(event: "update-request-order", payload: DataTransfer): void (event: "update-request-order", payload: DataTransfer): void
(event: "update-last-request-order", payload: DataTransfer): void (event: "update-last-request-order", payload: DataTransfer): void
@@ -250,6 +264,7 @@ const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null) const deleteAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null) const options = ref<TippyComponent | null>(null)
const duplicate = ref<HTMLButtonElement | null>(null) const duplicate = ref<HTMLButtonElement | null>(null)
const shareAction = ref<HTMLButtonElement | null>(null)
const dragging = ref(false) const dragging = ref(false)
const ordering = ref(false) const ordering = ref(false)
@@ -261,10 +276,6 @@ const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
parentID: "", parentID: "",
}) })
const requestLabelColor = computed(() =>
getMethodLabelColorClassOf(props.request)
)
watch( watch(
() => props.duplicateLoading, () => props.duplicateLoading,
(val) => { (val) => {
@@ -363,9 +374,8 @@ const updateLastItemOrder = (e: DragEvent) => {
const isRequestLoading = computed(() => { const isRequestLoading = computed(() => {
if (props.requestMoveLoading.length > 0 && props.requestID) { if (props.requestMoveLoading.length > 0 && props.requestID) {
return props.requestMoveLoading.includes(props.requestID) return props.requestMoveLoading.includes(props.requestID)
} else {
return false
} }
return false
}) })
const resetDragState = () => { const resetDragState = () => {

View File

@@ -141,9 +141,8 @@ const reqName = computed(() => {
return props.request.name return props.request.name
} else if (props.mode === "rest") { } else if (props.mode === "rest") {
return restRequestName.value return restRequestName.value
} else {
return gqlRequestName.value
} }
return gqlRequestName.value
}) })
const requestName = ref(reqName.value) const requestName = ref(reqName.value)
@@ -480,7 +479,7 @@ const getErrorMessage = (err: GQLError<string>) => {
console.error(err) console.error(err)
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} else { }
switch (err.error) { switch (err.error) {
case "team_coll/short_title": case "team_coll/short_title":
return t("collection.name_length_insufficient") return t("collection.name_length_insufficient")
@@ -496,5 +495,4 @@ const getErrorMessage = (err: GQLError<string>) => {
return t("error.something_went_wrong") return t("error.something_went_wrong")
} }
} }
}
</script> </script>

View File

@@ -15,12 +15,12 @@
class="!rounded-none" class="!rounded-none"
:icon="IconPlus" :icon="IconPlus"
:title="t('team.no_access')" :title="t('team.no_access')"
:label="t('add.new')" :label="t('action.new')"
/> />
<HoppButtonSecondary <HoppButtonSecondary
v-else v-else
:icon="IconPlus" :icon="IconPlus"
:label="t('add.new')" :label="t('action.new')"
class="!rounded-none" class="!rounded-none"
@click="emit('display-modal-add')" @click="emit('display-modal-add')"
/> />
@@ -240,6 +240,12 @@
requestIndex: node.data.data.data.id, requestIndex: node.data.data.data.id,
}) })
" "
@share-request="
node.data.type === 'requests' &&
emit('share-request', {
request: node.data.data.data.request,
})
"
@drag-request=" @drag-request="
dragRequest($event, { dragRequest($event, {
folderPath: node.data.data.parentIndex, folderPath: node.data.data.parentIndex,
@@ -268,6 +274,7 @@
:text="t('empty.collections')" :text="t('empty.collections')"
@drop.stop @drop.stop
> >
<template #body>
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight"> <span class="text-center text-secondaryLight">
{{ t("collection.import_or_create") }} {{ t("collection.import_or_create") }}
@@ -281,7 +288,9 @@
:disabled="hasNoTeamAccess" :disabled="hasNoTeamAccess"
:title="hasNoTeamAccess ? t('team.no_access') : ''" :title="hasNoTeamAccess ? t('team.no_access') : ''"
@click=" @click="
hasNoTeamAccess ? null : emit('display-modal-import-export') hasNoTeamAccess
? null
: emit('display-modal-import-export')
" "
/> />
<HoppButtonSecondary <HoppButtonSecondary
@@ -295,6 +304,7 @@
/> />
</div> </div>
</div> </div>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-else-if="node.data.type === 'collections'" v-else-if="node.data.type === 'collections'"
@@ -303,6 +313,7 @@
:text="t('empty.collections')" :text="t('empty.collections')"
@drop.stop @drop.stop
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('add.new')" :label="t('add.new')"
filled filled
@@ -315,6 +326,7 @@
}) })
" "
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-else-if="node.data.type === 'folders'" v-else-if="node.data.type === 'folders'"
@@ -473,6 +485,12 @@ const emit = defineEmits<{
folderPath?: string | undefined folderPath?: string | undefined
} }
): void ): void
(
event: "share-request",
payload: {
request: HoppRESTRequest
}
): void
( (
event: "drop-request", event: "drop-request",
payload: { payload: {
@@ -542,14 +560,13 @@ const isSelected = ({
props.picked.pickedType === "teams-request" && props.picked.pickedType === "teams-request" &&
props.picked.requestID === requestID props.picked.requestID === requestID
) )
} else { }
return ( return (
props.picked && props.picked &&
props.picked.pickedType === "teams-folder" && props.picked.pickedType === "teams-folder" &&
props.picked.folderID === folderID props.picked.folderID === folderID
) )
} }
}
const active = computed(() => tabs.currentActiveTab.value.document.saveContext) const active = computed(() => tabs.currentActiveTab.value.document.saveContext)
@@ -714,7 +731,7 @@ class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
return { return {
status: "loading", status: "loading",
} }
} else { }
const data = this.data.value.map((item, index) => ({ const data = this.data.value.map((item, index) => ({
id: item.id, id: item.id,
data: { data: {
@@ -731,7 +748,6 @@ class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
data: cloneDeep(data), data: cloneDeep(data),
} as ChildrenResult<TeamCollections> } as ChildrenResult<TeamCollections>
} }
} else {
const parsedID = id.split("/")[id.split("/").length - 1] const parsedID = id.split("/")[id.split("/").length - 1]
!props.teamLoadingCollections.includes(parsedID) && !props.teamLoadingCollections.includes(parsedID) &&
@@ -741,7 +757,7 @@ class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
return { return {
status: "loading", status: "loading",
} }
} else { }
const items = this.findCollInTree(this.data.value, parsedID) const items = this.findCollInTree(this.data.value, parsedID)
if (items) { if (items) {
const data = [ const data = [
@@ -782,14 +798,11 @@ class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
status: "loaded", status: "loaded",
data: cloneDeep(data), data: cloneDeep(data),
} as ChildrenResult<TeamFolder | TeamRequests> } as ChildrenResult<TeamFolder | TeamRequests>
} else { }
return { return {
status: "loaded", status: "loaded",
data: [], data: [],
} }
}
}
}
}) })
} }
} }

View File

@@ -180,6 +180,7 @@
:alt="`${t('empty.collection')}`" :alt="`${t('empty.collection')}`"
:text="t('empty.collection')" :text="t('empty.collection')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('add.new')" :label="t('add.new')"
filled filled
@@ -190,6 +191,7 @@
}) })
" "
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
@@ -271,7 +273,7 @@ const collectionIcon = computed(() => {
if (isSelected.value) return IconCheckCircle if (isSelected.value) return IconCheckCircle
else if (!showChildren.value && !props.isFiltered) return IconFolder else if (!showChildren.value && !props.isFiltered) return IconFolder
else if (!showChildren.value || props.isFiltered) return IconFolderOpen else if (!showChildren.value || props.isFiltered) return IconFolderOpen
else return IconFolder return IconFolder
}) })
const pick = () => { const pick = () => {

View File

@@ -176,8 +176,7 @@
:src="`/images/states/${colorMode.value}/pack.svg`" :src="`/images/states/${colorMode.value}/pack.svg`"
:alt="`${t('empty.folder')}`" :alt="`${t('empty.folder')}`"
:text="t('empty.folder')" :text="t('empty.folder')"
> />
</HoppSmartPlaceholder>
</div> </div>
</div> </div>
<HoppSmartConfirmModal <HoppSmartConfirmModal
@@ -253,7 +252,7 @@ const collectionIcon = computed(() => {
if (isSelected.value) return IconCheckCircle if (isSelected.value) return IconCheckCircle
else if (!showChildren.value && !props.isFiltered) return IconFolder else if (!showChildren.value && !props.isFiltered) return IconFolder
else if (showChildren.value || !props.isFiltered) return IconFolderOpen else if (showChildren.value || !props.isFiltered) return IconFolderOpen
else return IconFolder return IconFolder
}) })
const pick = () => { const pick = () => {

View File

@@ -1,299 +1,227 @@
<template> <template>
<HoppSmartModal <ImportExportBase
v-if="show" ref="collections-import-export"
dialog modal-title="graphql_collections.title"
:title="`${t('modal.collections')}`" :importer-modules="importerModules"
styles="sm:max-w-md" :exporter-modules="exporterModules"
@close="hideModal" @hide-modal="emit('hide-modal')"
>
<template #actions>
<span>
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
:on-shown="() => tippyActions.focus()"
/> />
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="IconGithub"
:label="t('import.from_gist')"
@click="
() => {
readCollectionGist()
hide()
}
"
/>
<span
v-tippy="{ theme: 'tooltip' }"
:title="
!currentUser
? `${t('export.require_github')}`
: currentUser.provider !== 'github.com'
? `${t('export.require_github')}`
: undefined
"
>
<HoppSmartItem
:disabled="
!currentUser
? true
: currentUser.provider !== 'github.com'
? true
: false
"
:icon="IconGithub"
:label="t('export.create_secret_gist')"
@click="
() => {
createCollectionGist()
hide()
}
"
/>
</span>
</div>
</template>
</tippy>
</span>
</template>
<template #body>
<div class="flex flex-col space-y-2">
<HoppSmartItem
:icon="IconFolderPlus"
:label="t('import.from_json')"
@click="openDialogChooseFileToImportFrom"
/>
<input
ref="inputChooseFileToImportFrom"
class="input"
type="file"
accept="application/json"
@change="importFromJSON"
/>
<hr />
<HoppSmartItem
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:icon="IconDownload"
:label="t('export.as_json')"
@click="exportJSON"
/>
</div>
</template>
</HoppSmartModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import axios from "axios" import { useI18n } from "~/composables/i18n"
import IconMoreVertical from "~icons/lucide/more-vertical" import { useToast } from "~/composables/toast"
import { HoppCollection, HoppGQLRequest } from "@hoppscotch/data"
import { ImporterOrExporter } from "~/components/importExport/types"
import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource"
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
import * as E from "fp-ts/Either"
import IconFolderPlus from "~icons/lucide/folder-plus" import IconFolderPlus from "~icons/lucide/folder-plus"
import IconDownload from "~icons/lucide/download" import IconUser from "~icons/lucide/user"
import IconGithub from "~icons/lucide/github" import { initializeDownloadCollection } from "~/helpers/import-export/export"
import { computed, ref } from "vue" import { useReadonlyStream } from "~/composables/stream"
import { platform } from "~/platform" import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { import {
graphqlCollections$, graphqlCollections$,
setGraphqlCollections, setGraphqlCollections,
appendGraphqlCollections,
} from "~/newstore/collections" } from "~/newstore/collections"
import { hoppGqlCollectionsImporter } from "~/helpers/import-export/import/hoppGql"
import { gqlCollectionsExporter } from "~/helpers/import-export/export/gqlCollections"
import { gqlCollectionsGistExporter } from "~/helpers/import-export/export/gqlCollectionsGistExporter"
import { computed } from "vue"
defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const toast = useToast()
const t = useI18n() const t = useI18n()
const collections = useReadonlyStream(graphqlCollections$, []) const toast = useToast()
const currentUser = useReadonlyStream( const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(), platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser() platform.auth.getCurrentUser()
) )
// Template refs const GqlCollectionsHoppImporter: ImporterOrExporter = {
const tippyActions = ref<any | null>(null) metadata: {
const inputChooseFileToImportFrom = ref<HTMLInputElement>() id: "import.from_json",
name: "import.from_json",
const collectionJson = computed(() => { icon: IconFolderPlus,
return JSON.stringify(collections.value, null, 2) title: "import.from_json",
}) applicableTo: ["personal-workspace"],
disabled: false,
const createCollectionGist = async () => { },
if (!currentUser.value) { component: FileSource({
toast.error(t("profile.no_permission").toString()) acceptedFileTypes: "application/json",
caption: "import.from_json_description",
onImportFromFile: async (gqlCollections) => {
const res = await hoppGqlCollectionsImporter(gqlCollections)
if (E.isLeft(res)) {
showImportFailedError()
return return
} }
try { handleImportToStore(res.right)
const res = await axios.post(
"https://api.github.com/gists",
{
files: {
"hoppscotch-collections.json": {
content: collectionJson.value,
},
},
},
{
headers: {
Authorization: `token ${currentUser.value.accessToken}`,
Accept: "application/vnd.github.v3+json",
},
}
)
toast.success(t("export.gist_created").toString())
window.open(res.data.html_url)
} catch (e) {
toast.error(t("error.something_went_wrong").toString())
console.error(e)
}
}
const fileImported = () => {
toast.success(t("state.file_imported").toString())
}
const failedImport = () => {
toast.error(t("import.failed").toString())
}
const readCollectionGist = async () => {
const gist = prompt(t("import.gist_url").toString())
if (!gist) return
try {
const { files } = (await axios.get(
`https://api.github.com/gists/${gist.split("/").pop()}`,
{
headers: {
Accept: "application/vnd.github.v3+json",
},
}
)) as {
files: {
[fileName: string]: {
content: any
}
}
}
const collections = JSON.parse(Object.values(files)[0].content)
setGraphqlCollections(collections)
fileImported()
} catch (e) {
failedImport()
console.error(e)
}
}
const hideModal = () => {
emit("hide-modal")
}
const openDialogChooseFileToImportFrom = () => {
if (inputChooseFileToImportFrom.value)
inputChooseFileToImportFrom.value.click()
}
const importFromJSON = () => {
if (!inputChooseFileToImportFrom.value) return
if (
!inputChooseFileToImportFrom.value.files ||
inputChooseFileToImportFrom.value.files.length === 0
) {
toast.show(t("action.choose_file").toString())
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
toast.show(t("action.choose_file").toString())
return
}
const collections = JSON.parse(content)
if (collections[0]) {
const [name, folders, requests] = Object.keys(collections[0])
if (name === "name" && folders === "folders" && requests === "requests") {
// Do nothing
}
} else {
failedImport()
return
}
appendGraphqlCollections(collections)
platform.analytics?.logEvent({ platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION", type: "HOPP_IMPORT_COLLECTION",
importer: "json",
workspaceType: "personal",
platform: "gql", platform: "gql",
workspaceType: "personal",
importer: "json",
}) })
fileImported() emit("hide-modal")
} },
reader.readAsText(inputChooseFileToImportFrom.value.files[0]) }),
inputChooseFileToImportFrom.value.value = ""
} }
const exportJSON = async () => { const GqlCollectionsGistImporter: ImporterOrExporter = {
const dataToWrite = collectionJson.value metadata: {
id: "import.from_gist",
name: "import.from_gist",
icon: IconFolderPlus,
title: "import.from_gist",
applicableTo: ["personal-workspace", "team-workspace"],
disabled: false,
},
component: GistSource({
caption: "import.gql_collections_from_gist_description",
onImportFromGist: async (gqlCollections) => {
if (E.isLeft(gqlCollections)) {
showImportFailedError()
return
}
const parsedCollections = JSON.parse(dataToWrite) const res = await hoppGqlCollectionsImporter(gqlCollections.right)
if (!parsedCollections.length) { if (E.isLeft(res)) {
showImportFailedError()
return
}
handleImportToStore(res.right)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
platform: "gql",
workspaceType: "personal",
importer: "gist",
})
emit("hide-modal")
},
}),
}
const gqlCollections = useReadonlyStream(graphqlCollections$, [])
const GqlCollectionsHoppExporter: ImporterOrExporter = {
metadata: {
id: "export.as_json",
name: "export.as_json",
title: "action.download_file",
icon: IconUser,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace"],
},
action: () => {
if (!gqlCollections.value.length) {
return toast.error(t("error.no_collections_to_export")) return toast.error(t("error.no_collections_to_export"))
} }
const file = new Blob([dataToWrite], { type: "application/json" }) const message = initializeDownloadCollection(
const url = URL.createObjectURL(file) gqlCollectionsExporter(gqlCollections.value),
"GQLCollections"
)
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json` if (E.isLeft(message)) {
toast.error(t("export.failed"))
return
}
URL.revokeObjectURL(url) toast.success(message.right)
const result = await platform.io.saveFileWithDialog({ platform.analytics?.logEvent({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "Hoppscotch Collection JSON file",
extensions: ["json"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
platform?.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION", type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "gql", platform: "gql",
exporter: "json",
})
},
}
const GqlCollectionsGistExporter: ImporterOrExporter = {
metadata: {
id: "export.as_gist",
name: "export.create_secret_gist",
title: !currentUser
? "export.require_github"
: // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
currentUser.provider !== "github.com"
? `export.require_github`
: "export.create_secret_gist",
icon: IconUser,
disabled: !currentUser.value
? true
: currentUser.value.provider !== "github.com",
applicableTo: ["personal-workspace"],
},
action: async () => {
if (!currentUser.value) {
toast.error(t("profile.no_permission"))
return
}
const accessToken = currentUser.value?.accessToken
if (accessToken) {
const res = await gqlCollectionsGistExporter(
JSON.stringify(gqlCollections.value),
accessToken
)
if (E.isLeft(res)) {
toast.error(t("export.failed"))
return
}
toast.success(t("export.success"))
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
platform: "gql",
exporter: "gist",
}) })
toast.success(t("state.download_started").toString()) window.open(res.right, "_blank")
} }
},
} }
const importerModules = [GqlCollectionsHoppImporter, GqlCollectionsGistImporter]
const exporterModules = computed(() => {
const modules = [GqlCollectionsHoppExporter]
if (platform.platformFeatureFlags.exportAsGIST) {
modules.push(GqlCollectionsGistExporter)
}
return modules
})
const showImportFailedError = () => {
toast.error(t("import.failed"))
}
const handleImportToStore = async (
gqlCollections: HoppCollection<HoppGQLRequest>[]
) => {
setGraphqlCollections(gqlCollections)
toast.success(t("import.success"))
}
const emit = defineEmits<{
(e: "hide-modal"): () => void
}>()
</script> </script>

View File

@@ -11,7 +11,7 @@
type="search" type="search"
autocomplete="off" autocomplete="off"
:placeholder="t('action.search')" :placeholder="t('action.search')"
class="!border-0 bg-transparent py-2 pl-4 pr-2" class="flex w-full bg-transparent px-4 py-2 h-8"
/> />
<div <div
class="flex flex-1 flex-shrink-0 justify-between border-y border-dividerLight bg-primary" class="flex flex-1 flex-shrink-0 justify-between border-y border-dividerLight bg-primary"
@@ -66,6 +66,7 @@
:alt="`${t('empty.collections')}`" :alt="`${t('empty.collections')}`"
:text="t('empty.collections')" :text="t('empty.collections')"
> >
<template #body>
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight"> <span class="text-center text-secondaryLight">
{{ t("collection.import_or_create") }} {{ t("collection.import_or_create") }}
@@ -87,13 +88,14 @@
/> />
</div> </div>
</div> </div>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-if="!(filteredCollections.length !== 0 || collections.length === 0)" v-if="!(filteredCollections.length !== 0 || collections.length === 0)"
:text="`${t('state.nothing_found')}${filterText}`" :text="`${t('state.nothing_found')}${filterText}`"
> >
<template #icon> <template #icon>
<icon-lucide-search class="svg-icons pb-2 opacity-75" /> <icon-lucide-search class="svg-icons opacity-75" />
</template> </template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<CollectionsGraphqlAdd <CollectionsGraphqlAdd
@@ -137,7 +139,7 @@
@hide-modal="displayModalEditRequest(false)" @hide-modal="displayModalEditRequest(false)"
/> />
<CollectionsGraphqlImportExport <CollectionsGraphqlImportExport
:show="showModalImportExport" v-if="showModalImportExport"
@hide-modal="displayModalImportExport(false)" @hide-modal="displayModalImportExport(false)"
/> />
</div> </div>

View File

@@ -11,7 +11,7 @@
@dragend="draggingToRoot = false" @dragend="draggingToRoot = false"
> >
<div <div
class="sticky z-10 flex flex-shrink-0 flex-col overflow-x-auto border-b border-dividerLight bg-primary" class="sticky z-10 flex flex-shrink-0 flex-col overflow-x-auto bg-primary border-b border-dividerLight"
:class="{ 'rounded-t': saveRequest }" :class="{ 'rounded-t': saveRequest }"
:style=" :style="
saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0' saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0'
@@ -22,7 +22,7 @@
v-model="filterTexts" v-model="filterTexts"
type="search" type="search"
autocomplete="off" autocomplete="off"
class="flex h-8 w-full bg-transparent p-4 py-2" class="flex w-full bg-transparent px-4 py-2 h-8"
:placeholder="t('action.search')" :placeholder="t('action.search')"
:disabled="collectionsType.type === 'team-collections'" :disabled="collectionsType.type === 'team-collections'"
/> />
@@ -41,6 +41,7 @@
@export-data="exportData" @export-data="exportData"
@remove-collection="removeCollection" @remove-collection="removeCollection"
@remove-folder="removeFolder" @remove-folder="removeFolder"
@share-request="shareRequest"
@drop-collection="dropCollection" @drop-collection="dropCollection"
@update-request-order="updateRequestOrder" @update-request-order="updateRequestOrder"
@update-collection-order="updateCollectionOrder" @update-collection-order="updateCollectionOrder"
@@ -71,6 +72,7 @@
@export-data="exportData" @export-data="exportData"
@remove-collection="removeCollection" @remove-collection="removeCollection"
@remove-folder="removeFolder" @remove-folder="removeFolder"
@share-request="shareRequest"
@edit-request="editRequest" @edit-request="editRequest"
@duplicate-request="duplicateRequest" @duplicate-request="duplicateRequest"
@remove-request="removeRequest" @remove-request="removeRequest"
@@ -138,17 +140,13 @@
@hide-modal="showConfirmModal = false" @hide-modal="showConfirmModal = false"
@resolve="resolveConfirmModal" @resolve="resolveConfirmModal"
/> />
<CollectionsImportExport <CollectionsImportExport
:show="showModalImportExport" v-if="showModalImportExport"
:collections-type="collectionsType.type" :collections-type="collectionsType"
:exporting-team-collections="exportingTeamCollections"
:creating-gist-collection="creatingGistCollection"
:importing-my-collections="importingMyCollections"
@export-json-collection="exportJSONCollection"
@create-collection-gist="createCollectionGist"
@import-to-teams="importToTeams"
@hide-modal="displayModalImportExport(false)" @hide-modal="displayModalImportExport(false)"
/> />
<TeamsAdd <TeamsAdd
:show="showTeamModalAdd" :show="showTeamModalAdd"
@hide-modal="displayTeamModalAdd(false)" @hide-modal="displayTeamModalAdd(false)"
@@ -197,7 +195,6 @@ import {
createChildCollection, createChildCollection,
renameCollection, renameCollection,
deleteCollection, deleteCollection,
importJSONToTeam,
moveRESTTeamCollection, moveRESTTeamCollection,
updateOrderRESTTeamCollection, updateOrderRESTTeamCollection,
} from "~/helpers/backend/mutations/TeamCollection" } from "~/helpers/backend/mutations/TeamCollection"
@@ -212,12 +209,9 @@ import { TeamCollection } from "~/helpers/teams/TeamCollection"
import { Collection as NodeCollection } from "./MyCollections.vue" import { Collection as NodeCollection } from "./MyCollections.vue"
import { import {
getCompleteCollectionTree, getCompleteCollectionTree,
getTeamCollectionJSON,
teamCollToHoppRESTColl, teamCollToHoppRESTColl,
} from "~/helpers/backend/helpers" } from "~/helpers/backend/helpers"
import * as E from "fp-ts/Either"
import { platform } from "~/platform" import { platform } from "~/platform"
import { createCollectionGists } from "~/helpers/gist"
import { import {
getRequestsByPath, getRequestsByPath,
resolveSaveContextOnRequestReorder, resolveSaveContextOnRequestReorder,
@@ -229,7 +223,7 @@ import {
resetTeamRequestsContext, resetTeamRequestsContext,
} from "~/helpers/collection/collection" } from "~/helpers/collection/collection"
import { currentReorderingStatus$ } from "~/newstore/reordering" import { currentReorderingStatus$ } from "~/newstore/reordering"
import { defineActionHandler } from "~/helpers/actions" import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { WorkspaceService } from "~/services/workspace.service" import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest" import { RESTTabService } from "~/services/tab/rest"
@@ -303,12 +297,6 @@ const draggingToRoot = ref(false)
const collectionMoveLoading = ref<string[]>([]) const collectionMoveLoading = ref<string[]>([])
const requestMoveLoading = ref<string[]>([]) const requestMoveLoading = ref<string[]>([])
// Export - Import refs
const collectionJSON = ref("")
const exportingTeamCollections = ref(false)
const creatingGistCollection = ref(false)
const importingMyCollections = ref(false)
// TeamList-Adapter // TeamList-Adapter
const workspaceService = useService(WorkspaceService) const workspaceService = useService(WorkspaceService)
const teamListAdapter = workspaceService.acquireTeamListAdapter(null) const teamListAdapter = workspaceService.acquireTeamListAdapter(null)
@@ -412,14 +400,12 @@ const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
}) })
const hasTeamWriteAccess = computed(() => { const hasTeamWriteAccess = computed(() => {
if (!collectionsType.value.selectedTeam) return false if (collectionsType.value.type !== "team-collections") {
return false
}
if ( const role = collectionsType.value.selectedTeam?.myRole
collectionsType.value.type === "team-collections" && return role === "OWNER" || role === "EDITOR"
collectionsType.value.selectedTeam.myRole !== "VIEWER"
)
return true
else return false
}) })
const filteredCollections = computed(() => { const filteredCollections = computed(() => {
@@ -1069,7 +1055,7 @@ const onRemoveCollection = () => {
const collectionIndex = editingCollectionIndex.value const collectionIndex = editingCollectionIndex.value
const collectionToRemove = const collectionToRemove =
collectionIndex || collectionIndex == 0 collectionIndex || collectionIndex === 0
? navigateToFolderWithIndexPath(restCollectionStore.value.state, [ ? navigateToFolderWithIndexPath(restCollectionStore.value.state, [
collectionIndex, collectionIndex,
]) ])
@@ -1468,9 +1454,8 @@ const checkIfCollectionIsAParentOfTheChildren = (
) )
if (isEqual(slicedDestinationCollectionPath, collectionDraggedPath)) { if (isEqual(slicedDestinationCollectionPath, collectionDraggedPath)) {
return true return true
} else {
return false
} }
return false
} }
return false return false
@@ -1491,9 +1476,8 @@ const isMoveToSameLocation = (
if (isEqual(draggedItemParentPathArr, destinationPathArr)) { if (isEqual(draggedItemParentPathArr, destinationPathArr)) {
return true return true
} else {
return false
} }
return false
} }
} }
@@ -1673,7 +1657,7 @@ const isSameSameParent = (
const dragedItemParent = draggedItemIndex.slice(0, -1) const dragedItemParent = draggedItemIndex.slice(0, -1)
return dragedItemParent.join("/") === destinationCollectionIndex return dragedItemParent.join("/") === destinationCollectionIndex
} else { }
if (destinationItemPath === null) return false if (destinationItemPath === null) return false
const destinationItemIndex = pathToIndex(destinationItemPath) const destinationItemIndex = pathToIndex(destinationItemPath)
@@ -1685,14 +1669,11 @@ const isSameSameParent = (
const destinationItemParent = destinationItemIndex.slice(0, -1) const destinationItemParent = destinationItemIndex.slice(0, -1)
if (isEqual(dragedItemParent, destinationItemParent)) { if (isEqual(dragedItemParent, destinationItemParent)) {
return true return true
} else { }
return false return false
} }
} else {
return false return false
} }
}
}
/** /**
* This function is called when the user updates the request order in a collection * This function is called when the user updates the request order in a collection
@@ -1833,33 +1814,6 @@ const updateCollectionOrder = (payload: {
} }
} }
// Import - Export Collection functions // Import - Export Collection functions
/**
* Export the whole my collection or specific team collection to JSON
*/
const getJSONCollection = async () => {
if (collectionsType.value.type === "my-collections") {
collectionJSON.value = JSON.stringify(myCollections.value, null, 2)
} else {
if (!collectionsType.value.selectedTeam) return
exportingTeamCollections.value = true
pipe(
await getTeamCollectionJSON(collectionsType.value.selectedTeam.id),
E.match(
(err) => {
toast.error(`${getErrorMessage(err)}`)
exportingTeamCollections.value = false
},
(result) => {
const { exportCollectionsToJSON } = result
collectionJSON.value = exportCollectionsToJSON
exportingTeamCollections.value = false
}
)
)
}
return collectionJSON.value
}
/** /**
* Create a downloadable file from a collection and prompts the user to download it. * Create a downloadable file from a collection and prompts the user to download it.
@@ -1928,88 +1882,15 @@ const exportData = async (
} }
} }
const exportJSONCollection = async () => { const shareRequest = ({ request }: { request: HoppRESTRequest }) => {
platform.analytics?.logEvent({ if (currentUser.value) {
type: "HOPP_EXPORT_COLLECTION", // opens the share request modal
exporter: "json", invokeAction("share.request", {
platform: "rest", request,
}) })
} else {
await getJSONCollection() invokeAction("modals.login.toggle")
const parsedCollections = JSON.parse(collectionJSON.value)
if (!parsedCollections.length) {
return toast.error(t("error.no_collections_to_export"))
} }
initializeDownloadCollection(collectionJSON.value, null)
}
const createCollectionGist = async () => {
if (!currentUser.value || !currentUser.value.accessToken) {
toast.error(t("profile.no_permission").toString())
return
}
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "gist",
platform: "rest",
})
creatingGistCollection.value = true
await getJSONCollection()
pipe(
createCollectionGists(collectionJSON.value, currentUser.value.accessToken),
TE.match(
(err) => {
toast.error(t("error.something_went_wrong").toString())
console.error(err)
creatingGistCollection.value = false
},
(result) => {
toast.success(t("export.gist_created").toString())
creatingGistCollection.value = false
window.open(result.data.html_url)
}
)
)()
}
const importToTeams = async (collection: HoppCollection<HoppRESTRequest>[]) => {
if (!hasTeamWriteAccess.value) {
toast.error(t("team.no_access").toString())
return
}
if (!collectionsType.value.selectedTeam) return
importingMyCollections.value = true
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "import-to-teams",
platform: "rest",
})
pipe(
importJSONToTeam(
JSON.stringify(collection),
collectionsType.value.selectedTeam.id
),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
importingMyCollections.value = false
},
() => {
importingMyCollections.value = false
displayModalImportExport(false)
}
)
)()
} }
const resolveConfirmModal = (title: string | null) => { const resolveConfirmModal = (title: string | null) => {
@@ -2041,7 +1922,7 @@ const getErrorMessage = (err: GQLError<string>) => {
console.error(err) console.error(err)
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} else { }
switch (err.error) { switch (err.error) {
case "team_coll/short_title": case "team_coll/short_title":
return t("collection.name_length_insufficient") return t("collection.name_length_insufficient")
@@ -2073,7 +1954,6 @@ const getErrorMessage = (err: GQLError<string>) => {
return t("error.something_went_wrong") return t("error.something_went_wrong")
} }
} }
}
defineActionHandler("collection.new", () => { defineActionHandler("collection.new", () => {
displayModalAdd(true) displayModalAdd(true)

View File

@@ -11,7 +11,9 @@
v-if="!currentInterceptorSupportsCookies" v-if="!currentInterceptorSupportsCookies"
:text="t('cookies.modal.interceptor_no_support')" :text="t('cookies.modal.interceptor_no_support')"
> >
<template #body>
<AppInterceptor class="rounded border border-dividerLight p-2" /> <AppInterceptor class="rounded border border-dividerLight p-2" />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<div v-else class="flex flex-col"> <div v-else class="flex flex-col">
<div <div
@@ -38,8 +40,7 @@
:alt="`${t('cookies.modal.empty_domains')}`" :alt="`${t('cookies.modal.empty_domains')}`"
:text="t('cookies.modal.empty_domains')" :text="t('cookies.modal.empty_domains')"
class="mt-6" class="mt-6"
> />
</HoppSmartPlaceholder>
<div <div
v-for="[domain, entries] in workingCookieJar.entries()" v-for="[domain, entries] in workingCookieJar.entries()"
v-else v-else

View File

@@ -0,0 +1,206 @@
<template>
<div class="flex flex-col flex-1">
<header
class="flex items-center justify-between flex-1 flex-shrink-0 px-2 py-2 space-x-2 overflow-x-auto overflow-y-hidden"
>
<div class="flex items-center justify-between flex-1 space-x-2">
<HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="https://hoppscotch.io"
blank
/>
<div class="flex">
<HoppButtonSecondary
:label="t('app.open_in_hoppscotch')"
:to="sharedRequestURL"
blank
/>
</div>
</div>
</header>
<div class="sticky top-0 z-10 flex-1">
<div
class="flex-none flex-shrink-0 p-4 bg-primary sm:flex sm:flex-shrink-0 sm:space-x-2"
>
<div
class="flex flex-1 overflow-hidden border divide-x rounded text-secondaryDark divide-divider min-w-[12rem] overflow-x-auto border-divider"
>
<span
class="flex items-center justify-center px-4 py-2 font-semibold transition rounded-l"
>
{{ tab.document.request.method }}
</span>
<div
class="flex items-center flex-1 flex-shrink-0 min-w-0 px-4 py-2 truncate rounded-r"
>
{{ tab.document.request.endpoint }}
</div>
</div>
<div class="flex mt-2 space-x-2 sm:mt-0">
<HoppButtonPrimary
id="send"
:title="`${t(
'action.send'
)} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
:label="`${!loading ? t('action.send') : t('action.cancel')}`"
class="flex-1 min-w-20"
outline
@click="!loading ? newSendRequest() : cancelRequest()"
/>
<div class="flex">
<HoppButtonSecondary
:title="`${t(
'request.save'
)} <kbd>${getSpecialKey()}</kbd><kbd>S</kbd>`"
:label="t('request.save')"
filled
:icon="IconSave"
class="flex-1 rounded"
blank
outline
:to="sharedRequestURL"
/>
</div>
</div>
</div>
</div>
<HttpRequestOptions
v-model="tab.document.request"
v-model:option-tab="selectedOptionTab"
:properties="properties"
/>
<HttpResponse :document="tab.document" :is-embed="true" />
</div>
</template>
<script lang="ts" setup>
import { Ref } from "vue"
import { computed, useModel } from "vue"
import { ref } from "vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import * as E from "fp-ts/Either"
import { useStreamSubscriber } from "~/composables/stream"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { runRESTRequest$ } from "~/helpers/RequestRunner"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
import IconSave from "~icons/lucide/save"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
modelTab: HoppTab<HoppRESTDocument>
properties: string[]
sharedRequestID: string
}>()
const tab = useModel(props, "modelTab")
const selectedOptionTab = ref(props.properties[0])
const requestCancelFunc: Ref<(() => void) | null> = ref(null)
const loading = ref(false)
const baseURL = import.meta.env.VITE_SHORTCODE_BASE_URL ?? "https://hopp.sh"
const sharedRequestURL = computed(() => {
return `${baseURL}/r/${props.sharedRequestID}`
})
const { subscribeToStream } = useStreamSubscriber()
const newSendRequest = async () => {
if (newEndpoint.value === "" || /^\s+$/.test(newEndpoint.value)) {
toast.error(`${t("empty.endpoint")}`)
return
}
ensureMethodInEndpoint()
loading.value = true
const [cancel, streamPromise] = runRESTRequest$(tab)
const streamResult = await streamPromise
requestCancelFunc.value = cancel
if (E.isRight(streamResult)) {
subscribeToStream(
streamResult.right,
(responseState) => {
if (loading.value) {
// Check exists because, loading can be set to false
// when cancelled
updateRESTResponse(responseState)
}
},
() => {
loading.value = false
},
() => {
// TODO: Change this any to a proper type
const result = (streamResult.right as any).value
if (
result.type === "network_fail" &&
result.error?.error === "NO_PW_EXT_HOOK"
) {
const errorResponse: HoppRESTResponse = {
type: "extension_error",
error: result.error.humanMessage.heading,
component: result.error.component,
req: result.req,
}
updateRESTResponse(errorResponse)
}
loading.value = false
}
)
} else {
loading.value = false
toast.error(`${t("error.script_fail")}`)
let error: Error
if (typeof streamResult.left === "string") {
error = { name: "RequestFailure", message: streamResult.left }
} else {
error = streamResult.left
}
updateRESTResponse({
type: "script_fail",
error,
})
}
}
const updateRESTResponse = (response: HoppRESTResponse | null) => {
tab.value.document.response = response
}
const newEndpoint = computed(() => {
return tab.value.document.request.endpoint
})
const ensureMethodInEndpoint = () => {
if (
!/^http[s]?:\/\//.test(newEndpoint.value) &&
!newEndpoint.value.startsWith("<<")
) {
const domain = newEndpoint.value.split(/[/:#?]+/)[0]
if (domain === "localhost" || /([0-9]+\.)*[0-9]/.test(domain)) {
tab.value.document.request.endpoint =
"http://" + tab.value.document.request.endpoint
} else {
tab.value.document.request.endpoint =
"https://" + tab.value.document.request.endpoint
}
}
}
const cancelRequest = () => {
loading.value = false
requestCancelFunc.value?.()
updateRESTResponse(null)
}
</script>

View File

@@ -7,7 +7,7 @@
<template #body> <template #body>
<div class="flex flex-1 flex-col space-y-4"> <div class="flex flex-1 flex-col space-y-4">
<div class="ml-2 flex items-center space-x-8"> <div class="ml-2 flex items-center space-x-8">
<label for="name" class="min-w-10 font-semibold">{{ <label for="name" class="min-w-[2.5rem] font-semibold">{{
t("environment.name") t("environment.name")
}}</label> }}</label>
<input <input
@@ -18,7 +18,7 @@
/> />
</div> </div>
<div class="ml-2 flex items-center space-x-8"> <div class="ml-2 flex items-center space-x-8">
<label for="value" class="min-w-10 font-semibold">{{ <label for="value" class="min-w-[2.5rem] font-semibold">{{
t("environment.value") t("environment.value")
}}</label> }}</label>
<input <input
@@ -29,7 +29,7 @@
/> />
</div> </div>
<div class="ml-2 flex items-center space-x-8"> <div class="ml-2 flex items-center space-x-8">
<label for="scope" class="min-w-10 font-semibold"> <label for="scope" class="min-w-[2.5rem] font-semibold">
{{ t("environment.scope") }} {{ t("environment.scope") }}
</label> </label>
<div <div
@@ -39,10 +39,10 @@
</div> </div>
</div> </div>
<div v-if="replaceWithVariable" class="mt-3 flex space-x-2"> <div v-if="replaceWithVariable" class="mt-3 flex space-x-2">
<div class="min-w-18" /> <div class="min-w-[4rem]" />
<HoppSmartCheckbox <HoppSmartCheckbox
:on="replaceWithVariable" :on="replaceWithVariable"
title="t('environment.replace_with_variable'))" :title="t('environment.replace_with_variable')"
@change="replaceWithVariable = !replaceWithVariable" @change="replaceWithVariable = !replaceWithVariable"
/> />
<label for="replaceWithVariable"> <label for="replaceWithVariable">
@@ -205,7 +205,7 @@ const addEnvironment = async () => {
const getErrorMessage = (err: GQLError<string>) => { const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} else { }
switch (err.error) { switch (err.error) {
case "team_environment/not_found": case "team_environment/not_found":
return t("team_environment.not_found") return t("team_environment.not_found")
@@ -215,5 +215,4 @@ const getErrorMessage = (err: GQLError<string>) => {
return t("error.something_went_wrong") return t("error.something_went_wrong")
} }
} }
}
</script> </script>

View File

@@ -1,154 +1,60 @@
<template> <template>
<HoppSmartModal <ImportExportBase
v-if="show" ref="collections-import-export"
dialog modal-title="environment.title"
:title="`${t('environment.title')}`" :importer-modules="importerModules"
styles="sm:max-w-md" :exporter-modules="exporterModules"
@close="hideModal" @hide-modal="emit('hide-modal')"
>
<template #actions>
<span>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/> />
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="IconGithub"
:label="t('import.from_gist')"
@click="
() => {
readEnvironmentGist()
hide()
}
"
/>
<span
v-tippy="{ theme: 'tooltip' }"
:title="
!currentUser
? `${t('export.require_github')}`
: currentUser.provider !== 'github.com'
? `${t('export.require_github')}`
: undefined
"
>
<HoppSmartItem
:disabled="
!currentUser
? true
: currentUser.provider !== 'github.com'
? true
: false
"
:icon="IconGithub"
:label="t('export.create_secret_gist')"
@click="
() => {
createEnvironmentGist()
hide()
}
"
/>
</span>
</div>
</template>
</tippy>
</span>
</template>
<template #body>
<div v-if="loading" class="flex flex-col items-center justify-center p-4">
<HoppSmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div v-else class="flex flex-col space-y-2">
<HoppSmartItem
:icon="IconFolderPlus"
:label="t('import.from_json')"
@click="openDialogChooseFileToImportFrom"
/>
<input
ref="inputChooseFileToImportFrom"
class="input"
type="file"
accept="application/json"
@change="importFromJSON"
/>
<hr />
<HoppSmartItem
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:icon="IconDownload"
:label="t('export.as_json')"
@click="exportJSON"
/>
</div>
</template>
</HoppSmartModal>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import IconMoreVertical from "~icons/lucide/more-vertical" import { useI18n } from "~/composables/i18n"
import IconFolderPlus from "~icons/lucide/folder-plus" import { useToast } from "~/composables/toast"
import IconDownload from "~icons/lucide/download"
import IconGithub from "~icons/lucide/github"
import { computed, ref } from "vue"
import { Environment } from "@hoppscotch/data" import { Environment } from "@hoppscotch/data"
import { platform } from "~/platform" import { ImporterOrExporter } from "~/components/importExport/types"
import axios from "axios" import { FileSource } from "~/helpers/import-export/import/import-sources/FileSource"
import { useI18n } from "@composables/i18n" import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
import { useReadonlyStream } from "@composables/stream" import { hoppEnvImporter } from "~/helpers/import-export/import/hoppEnv"
import { useToast } from "@composables/toast"
import { import * as E from "fp-ts/Either"
environments$, import { appendEnvironments, environments$ } from "~/newstore/environments"
replaceEnvironments,
appendEnvironments,
} from "~/newstore/environments"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { createTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment" import { createTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
import { TeamEnvironment } from "~/helpers/teams/TeamEnvironment"
import { GQLError } from "~/helpers/backend/GQLClient" import { GQLError } from "~/helpers/backend/GQLClient"
import { TippyComponent } from "vue-tippy" import { CreateTeamEnvironmentMutation } from "~/helpers/backend/graphql"
import { postmanEnvImporter } from "~/helpers/import-export/import/postmanEnv"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconPostman from "~icons/hopp/postman"
import IconUser from "~icons/lucide/user"
import { initializeDownloadCollection } from "~/helpers/import-export/export"
import { computed } from "vue"
import { useReadonlyStream } from "~/composables/stream"
import { environmentsExporter } from "~/helpers/import-export/export/environments"
import { environmentsGistExporter } from "~/helpers/import-export/export/environmentsGistExport"
import { platform } from "~/platform"
const t = useI18n()
const toast = useToast()
const props = defineProps<{ const props = defineProps<{
show: boolean
teamEnvironments?: TeamEnvironment[] teamEnvironments?: TeamEnvironment[]
teamId?: string | undefined teamId?: string | undefined
environmentType: "MY_ENV" | "TEAM_ENV" environmentType: "MY_ENV" | "TEAM_ENV"
}>() }>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const toast = useToast()
const t = useI18n()
const loading = ref(false)
const myEnvironments = useReadonlyStream(environments$, []) const myEnvironments = useReadonlyStream(environments$, [])
const currentUser = useReadonlyStream( const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(), platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser() platform.auth.getCurrentUser()
) )
// Template refs const isTeamEnvironment = computed(() => {
const tippyActions = ref<TippyComponent | null>(null) return props.environmentType === "TEAM_ENV"
const inputChooseFileToImportFrom = ref<HTMLInputElement>() })
const environmentJson = computed(() => { const environmentJson = computed(() => {
if ( if (
@@ -158,266 +64,249 @@ const environmentJson = computed(() => {
const teamEnvironments = props.teamEnvironments.map( const teamEnvironments = props.teamEnvironments.map(
(x) => x.environment as Environment (x) => x.environment as Environment
) )
return JSON.stringify(teamEnvironments, null, 2) return teamEnvironments
} else {
return JSON.stringify(myEnvironments.value, null, 2)
} }
return myEnvironments.value
}) })
const createEnvironmentGist = async () => { const HoppEnvironmentsImport: ImporterOrExporter = {
if (!currentUser.value) { metadata: {
toast.error(t("profile.no_permission").toString()) id: "import.from_json",
name: "import.from_json",
icon: IconFolderPlus,
title: "import.from_json",
applicableTo: ["personal-workspace", "team-workspace"],
disabled: false,
},
component: FileSource({
acceptedFileTypes: "application/json",
caption: "import.hoppscotch_environment_description",
onImportFromFile: async (environments) => {
const res = await hoppEnvImporter(environments)()
if (E.isLeft(res)) {
showImportFailedError()
return return
} }
try { handleImportToStore(res.right)
const res = await axios.post(
"https://api.github.com/gists", platform.analytics?.logEvent({
{ type: "HOPP_IMPORT_ENVIRONMENT",
files: { platform: "rest",
"hoppscotch-environments.json": { workspaceType: isTeamEnvironment.value ? "team" : "personal",
content: environmentJson.value, })
},
}, emit("hide-modal")
},
{
headers: {
Authorization: `token ${currentUser.value.accessToken}`,
Accept: "application/vnd.github.v3+json",
}, },
}),
} }
const PostmanEnvironmentsImport: ImporterOrExporter = {
metadata: {
id: "import.from_postman",
name: "import.from_postman",
icon: IconPostman,
title: "import.from_json",
applicableTo: ["personal-workspace", "team-workspace"],
disabled: false,
},
component: FileSource({
acceptedFileTypes: "application/json",
caption: "import.postman_environment_description",
onImportFromFile: async (environments) => {
const res = await postmanEnvImporter(environments)()
if (E.isLeft(res)) {
showImportFailedError()
return
}
handleImportToStore([res.right])
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: isTeamEnvironment.value ? "team" : "personal",
})
emit("hide-modal")
},
}),
}
const EnvironmentsImportFromGIST: ImporterOrExporter = {
metadata: {
id: "import.environments_from_gist",
name: "import.environments_from_gist",
icon: IconFolderPlus,
title: "import.environments_from_gist",
applicableTo: ["personal-workspace", "team-workspace"],
disabled: false,
},
component: GistSource({
caption: "import.environments_from_gist_description",
onImportFromGist: async (environments) => {
if (E.isLeft(environments)) {
showImportFailedError()
return
}
const res = await hoppEnvImporter(environments.right)()
if (E.isLeft(res)) {
showImportFailedError()
return
}
handleImportToStore(res.right)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: isTeamEnvironment.value ? "team" : "personal",
})
emit("hide-modal")
},
}),
}
const HoppEnvironmentsExport: ImporterOrExporter = {
metadata: {
id: "export.as_json",
name: "export.as_json",
title: "action.download_file",
icon: IconUser,
disabled: false,
applicableTo: ["personal-workspace", "team-workspace"],
},
action: () => {
if (!environmentJson.value.length) {
return toast.error(t("error.no_environments_to_export"))
}
const message = initializeDownloadCollection(
environmentsExporter(environmentJson.value),
"Environments"
) )
toast.success(t("export.gist_created").toString()) if (E.isLeft(message)) {
toast.error(t(message.left))
return
}
toast.success(t(message.right))
platform.analytics?.logEvent({
type: "HOPP_EXPORT_ENVIRONMENT",
platform: "rest",
})
},
}
const HoppEnvironmentsGistExporter: ImporterOrExporter = {
metadata: {
id: "export.as_gist",
name: "export.create_secret_gist",
title:
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
currentUser?.provider === "github.com"
? "export.create_secret_gist"
: "export.require_github",
icon: IconUser,
disabled: !currentUser.value
? true
: currentUser.value.provider !== "github.com",
applicableTo: ["personal-workspace", "team-workspace"],
},
action: async () => {
if (!currentUser.value) {
toast.error(t("profile.no_permission"))
return
}
const accessToken = currentUser.value?.accessToken
if (accessToken) {
const res = await environmentsGistExporter(
JSON.stringify(environmentJson.value),
accessToken
)
if (E.isLeft(res)) {
toast.error(t("export.failed"))
return
}
toast.success(t("export.success"))
platform.analytics?.logEvent({ platform.analytics?.logEvent({
type: "HOPP_EXPORT_ENVIRONMENT", type: "HOPP_EXPORT_ENVIRONMENT",
platform: "rest", platform: "rest",
}) })
window.open(res.data.html_url) window.open(res.right, "_blank")
} catch (e) {
toast.error(t("error.something_went_wrong").toString())
console.error(e)
} }
},
} }
const fileImported = () => { const importerModules = [
toast.success(t("state.file_imported").toString()) HoppEnvironmentsImport,
EnvironmentsImportFromGIST,
PostmanEnvironmentsImport,
]
const exporterModules = computed(() => {
const enabledExporters = [HoppEnvironmentsExport]
if (platform.platformFeatureFlags.exportAsGIST) {
enabledExporters.push(HoppEnvironmentsGistExporter)
} }
const failedImport = () => { return enabledExporters
})
const showImportFailedError = () => {
toast.error(t("import.failed").toString()) toast.error(t("import.failed").toString())
} }
const readEnvironmentGist = async () => { const handleImportToStore = async (environments: Environment[]) => {
const gist = prompt(t("import.gist_url").toString())
if (!gist) return
try {
const { files } = (await axios.get(
`https://api.github.com/gists/${gist.split("/").pop()}`,
{
headers: {
Accept: "application/vnd.github.v3+json",
},
}
)) as {
files: {
[fileName: string]: {
content: any
}
}
}
const environments = JSON.parse(Object.values(files)[0].content)
if (props.environmentType === "MY_ENV") { if (props.environmentType === "MY_ENV") {
replaceEnvironments(environments) appendEnvironments(environments)
fileImported() toast.success(t("state.file_imported"))
} else { } else {
importToTeams(environments) await importToTeams(environments)
} }
} catch (e) {
failedImport()
console.error(e)
}
}
const hideModal = () => {
emit("hide-modal")
}
const openDialogChooseFileToImportFrom = () => {
if (inputChooseFileToImportFrom.value)
inputChooseFileToImportFrom.value.click()
} }
const importToTeams = async (content: Environment[]) => { const importToTeams = async (content: Environment[]) => {
loading.value = true const envImportPromises: Promise<
E.Either<GQLError<"">, CreateTeamEnvironmentMutation>
>[] = []
platform.analytics?.logEvent({ for (const [, env] of content.entries()) {
type: "HOPP_IMPORT_ENVIRONMENT", const res = createTeamEnvironment(
platform: "rest",
workspaceType: "team",
})
for (const [i, env] of content.entries()) {
if (i === content.length - 1) {
await pipe(
createTeamEnvironment(
JSON.stringify(env.variables), JSON.stringify(env.variables),
props.teamId as string, props.teamId as string,
env.name env.name
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
loading.value = false
hideModal()
fileImported()
}
)
)() )()
envImportPromises.push(res)
}
const res = await Promise.all(envImportPromises)
const failedImports = res.some((r) => E.isLeft(r))
if (failedImports) {
toast.error(t("import.failed"))
} else { } else {
await pipe( toast.success(t("import.success"))
createTeamEnvironment(
JSON.stringify(env.variables),
props.teamId as string,
env.name
),
TE.match(
(err: GQLError<string>) => {
console.error(err)
toast.error(`${getErrorMessage(err)}`)
},
() => {
// wait for all the environments to be created then fire the toast
}
)
)()
}
}
}
const importFromJSON = () => {
if (!inputChooseFileToImportFrom.value) return
if (
!inputChooseFileToImportFrom.value.files ||
inputChooseFileToImportFrom.value.files.length === 0
) {
toast.show(t("action.choose_file").toString())
return
}
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: "personal",
})
const reader = new FileReader()
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
toast.show(t("action.choose_file").toString())
return
}
const environments = JSON.parse(content)
if (
environments._postman_variable_scope === "environment" ||
environments._postman_variable_scope === "globals"
) {
importFromPostman(environments)
} else if (environments[0]) {
const [name, variables] = Object.keys(environments[0])
if (name === "name" && variables === "variables") {
// Do nothing
}
importFromHoppscotch(environments)
} else {
failedImport()
} }
} }
reader.readAsText(inputChooseFileToImportFrom.value.files[0]) const emit = defineEmits<{
inputChooseFileToImportFrom.value.value = "" (e: "hide-modal"): () => void
} }>()
const importFromHoppscotch = (environments: Environment[]) => {
if (props.environmentType === "MY_ENV") {
appendEnvironments(environments)
fileImported()
} else {
importToTeams(environments)
}
}
const importFromPostman = ({
name,
values,
}: {
name: string
values: { key: string; value: string }[]
}) => {
const environment: Environment = { name, variables: [] }
values.forEach(({ key, value }) => environment.variables.push({ key, value }))
const environments = [environment]
importFromHoppscotch(environments)
}
const exportJSON = async () => {
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 url = URL.createObjectURL(file)
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "JSON file",
extensions: ["json"],
},
],
})
if (result.type === "unknown" || result.type === "saved") {
toast.success(t("state.download_started").toString())
}
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "team_environment/not_found":
return t("team_environment.not_found")
default:
return t("error.something_went_wrong")
}
}
}
</script> </script>

View File

@@ -6,10 +6,9 @@
theme="popover" theme="popover"
:on-shown="() => envSelectorActions!.focus()" :on-shown="() => envSelectorActions!.focus()"
> >
<span <HoppSmartSelectWrapper
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="`${t('environment.select')}`" :title="`${t('environment.select')}`"
class="select-wrapper"
> >
<HoppButtonSecondary <HoppButtonSecondary
:icon="IconLayers" :icon="IconLayers"
@@ -22,7 +21,7 @@
" "
class="flex-1 !justify-start rounded-none pr-8" class="flex-1 !justify-start rounded-none pr-8"
/> />
</span> </HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="envSelectorActions" ref="envSelectorActions"
@@ -94,20 +93,12 @@
} }
" "
/> />
<div <HoppSmartPlaceholder
v-if="myEnvironments.length === 0" v-if="myEnvironments.length === 0"
class="flex flex-col items-center justify-center text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`" :src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="mb-2 inline-flex h-16 w-16 flex-col object-contain object-center"
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
/> />
<span class="pb-2 text-center">
{{ t("empty.environments") }}
</span>
</div>
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
:id="'team-environments'" :id="'team-environments'"
@@ -141,20 +132,12 @@
} }
" "
/> />
<div <HoppSmartPlaceholder
v-if="teamEnvironmentList.length === 0" v-if="teamEnvironmentList.length === 0"
class="flex flex-col items-center justify-center text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/blockchain.svg`" :src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy"
class="mb-2 inline-flex h-16 w-16 flex-col object-contain object-center"
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
:text="t('empty.environments')"
/> />
<span class="pb-2 text-center">
{{ t("empty.environments") }}
</span>
</div>
</div> </div>
<div <div
v-if="!teamListLoading && teamAdapterError" v-if="!teamListLoading && teamAdapterError"
@@ -207,10 +190,14 @@
</div> </div>
<div class="my-2 flex flex-1 flex-col space-y-2 pl-4 pr-2"> <div class="my-2 flex flex-1 flex-col space-y-2 pl-4 pr-2">
<div class="flex flex-1 space-x-4"> <div class="flex flex-1 space-x-4">
<span class="min-w-32 w-1/4 truncate text-tiny font-semibold"> <span
class="min-w-[9rem] w-1/4 truncate text-tiny font-semibold"
>
{{ t("environment.name") }} {{ t("environment.name") }}
</span> </span>
<span class="min-w-32 w-full truncate text-tiny font-semibold"> <span
class="min-w-[9rem] w-full truncate text-tiny font-semibold"
>
{{ t("environment.value") }} {{ t("environment.value") }}
</span> </span>
</div> </div>
@@ -219,10 +206,10 @@
:key="index" :key="index"
class="flex flex-1 space-x-4" class="flex flex-1 space-x-4"
> >
<span class="min-w-32 w-1/4 truncate text-secondaryLight"> <span class="min-w-[9rem] w-1/4 truncate text-secondaryLight">
{{ variable.key }} {{ variable.key }}
</span> </span>
<span class="min-w-32 w-full truncate text-secondaryLight"> <span class="min-w-[9rem] w-full truncate text-secondaryLight">
{{ variable.value }} {{ variable.value }}
</span> </span>
</div> </div>
@@ -258,10 +245,14 @@
</div> </div>
<div v-else class="my-2 flex flex-1 flex-col space-y-2 pl-4 pr-2"> <div v-else class="my-2 flex flex-1 flex-col space-y-2 pl-4 pr-2">
<div class="flex flex-1 space-x-4"> <div class="flex flex-1 space-x-4">
<span class="min-w-32 w-1/4 truncate text-tiny font-semibold"> <span
class="min-w-[9rem] w-1/4 truncate text-tiny font-semibold"
>
{{ t("environment.name") }} {{ t("environment.name") }}
</span> </span>
<span class="min-w-32 w-full truncate text-tiny font-semibold"> <span
class="min-w-[9rem] w-full truncate text-tiny font-semibold"
>
{{ t("environment.value") }} {{ t("environment.value") }}
</span> </span>
</div> </div>
@@ -270,10 +261,10 @@
:key="index" :key="index"
class="flex flex-1 space-x-4" class="flex flex-1 space-x-4"
> >
<span class="min-w-32 w-1/4 truncate text-secondaryLight"> <span class="min-w-[9rem] w-1/4 truncate text-secondaryLight">
{{ variable.key }} {{ variable.key }}
</span> </span>
<span class="min-w-32 w-full truncate text-secondaryLight"> <span class="min-w-[9rem] w-full truncate text-secondaryLight">
{{ variable.value }} {{ variable.value }}
</span> </span>
</div> </div>
@@ -446,14 +437,13 @@ const isEnvActive = (id: string | number) => {
} else { } else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") { if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return selectedEnv.value.index === id return selectedEnv.value.index === id
} else { }
return ( return (
selectedEnvironmentIndex.value.type === "TEAM_ENV" && selectedEnvironmentIndex.value.type === "TEAM_ENV" &&
selectedEnv.value.teamEnvID === id selectedEnv.value.teamEnvID === id
) )
} }
} }
}
const selectedEnvironmentIndex = useStream( const selectedEnvironmentIndex = useStream(
selectedEnvironmentIndex$, selectedEnvironmentIndex$,
@@ -496,10 +486,9 @@ const selectedEnv = computed(() => {
name: props.modelValue.environment.environment.name, name: props.modelValue.environment.environment.name,
teamEnvID: props.modelValue.environment.id, teamEnvID: props.modelValue.environment.id,
} }
} else { }
return { type: "global", name: "Global" } return { type: "global", name: "Global" }
} }
} else {
if (selectedEnvironmentIndex.value.type === "MY_ENV") { if (selectedEnvironmentIndex.value.type === "MY_ENV") {
const environment = const environment =
myEnvironments.value[selectedEnvironmentIndex.value.index] myEnvironments.value[selectedEnvironmentIndex.value.index]
@@ -523,13 +512,10 @@ const selectedEnv = computed(() => {
teamEnvID: selectedEnvironmentIndex.value.teamEnvID, teamEnvID: selectedEnvironmentIndex.value.teamEnvID,
variables: teamEnv.environment.variables, variables: teamEnv.environment.variables,
} }
} else { }
return { type: "NO_ENV_SELECTED" } return { type: "NO_ENV_SELECTED" }
} }
} else {
return { type: "NO_ENV_SELECTED" } return { type: "NO_ENV_SELECTED" }
}
}
}) })
// Set the selected environment as initial scope value // Set the selected environment as initial scope value
@@ -577,7 +563,7 @@ const envQuickPeekActions = ref<TippyComponent | null>(null)
const getErrorMessage = (err: GQLError<string>) => { const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} else { }
switch (err.error) { switch (err.error) {
case "team_environment/not_found": case "team_environment/not_found":
return t("team_environment.not_found") return t("team_environment.not_found")
@@ -585,16 +571,14 @@ const getErrorMessage = (err: GQLError<string>) => {
return t("error.something_went_wrong") return t("error.something_went_wrong")
} }
} }
}
const globalEnvs = useReadonlyStream(globalEnv$, []) const globalEnvs = useReadonlyStream(globalEnv$, [])
const environmentVariables = computed(() => { const environmentVariables = computed(() => {
if (selectedEnv.value.variables) { if (selectedEnv.value.variables) {
return selectedEnv.value.variables return selectedEnv.value.variables
} else {
return []
} }
return []
}) })
const editGlobalEnv = () => { const editGlobalEnv = () => {

View File

@@ -78,11 +78,13 @@
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
:text="t('empty.environments')" :text="t('empty.environments')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
filled filled
@click="addEnvironmentVariable" @click="addEnvironmentVariable"
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
@@ -198,9 +200,8 @@ const workingEnv = computed(() => {
type: "MY_ENV", type: "MY_ENV",
index: props.editingEnvironmentIndex, index: props.editingEnvironmentIndex,
}) })
} else {
return null
} }
return null
}) })
const envList = useReadonlyStream(environments$, []) || props.envVars() const envList = useReadonlyStream(environments$, []) || props.envVars()
@@ -226,12 +227,11 @@ const liveEnvs = computed(() => {
return [ return [
...vars.value.map((x) => ({ ...x.env, source: editingName.value! })), ...vars.value.map((x) => ({ ...x.env, source: editingName.value! })),
] ]
} else { }
return [ return [
...vars.value.map((x) => ({ ...x.env, source: editingName.value! })), ...vars.value.map((x) => ({ ...x.env, source: editingName.value! })),
...globalVars.value.map((x) => ({ ...x, source: "Global" })), ...globalVars.value.map((x) => ({ ...x, source: "Global" })),
] ]
}
}) })
watch( watch(

View File

@@ -38,6 +38,7 @@
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
:text="t('empty.environments')" :text="t('empty.environments')"
> >
<template #body>
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight"> <span class="text-center text-secondaryLight">
{{ t("environment.import_or_create") }} {{ t("environment.import_or_create") }}
@@ -59,6 +60,7 @@
/> />
</div> </div>
</div> </div>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<EnvironmentsMyDetails <EnvironmentsMyDetails
:show="showModalDetails" :show="showModalDetails"
@@ -68,7 +70,7 @@
@hide-modal="displayModalEdit(false)" @hide-modal="displayModalEdit(false)"
/> />
<EnvironmentsImportExport <EnvironmentsImportExport
:show="showModalImportExport" v-if="showModalImportExport"
environment-type="MY_ENV" environment-type="MY_ENV"
@hide-modal="displayModalImportExport(false)" @hide-modal="displayModalImportExport(false)"
/> />

View File

@@ -81,6 +81,7 @@
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
:text="t('empty.environments')" :text="t('empty.environments')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
v-if="isViewer" v-if="isViewer"
disabled disabled
@@ -93,6 +94,7 @@
filled filled
@click="addEnvironmentVariable" @click="addEnvironmentVariable"
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
@@ -205,11 +207,8 @@ const evnExpandError = computed(() => {
const liveEnvs = computed(() => { const liveEnvs = computed(() => {
if (evnExpandError.value) { if (evnExpandError.value) {
return [] return []
} else {
return [
...vars.value.map((x) => ({ ...x.env, source: editingName.value! })),
]
} }
return [...vars.value.map((x) => ({ ...x.env, source: editingName.value! }))]
}) })
watch( watch(
@@ -338,7 +337,7 @@ const hideModal = () => {
const getErrorMessage = (err: GQLError<string>) => { const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} else { }
switch (err.error) { switch (err.error) {
case "team_environment/not_found": case "team_environment/not_found":
return t("team_environment.not_found") return t("team_environment.not_found")
@@ -346,5 +345,4 @@ const getErrorMessage = (err: GQLError<string>) => {
return t("error.something_went_wrong") return t("error.something_went_wrong")
} }
} }
}
</script> </script>

View File

@@ -184,7 +184,7 @@ const duplicateEnvironments = () => {
const getErrorMessage = (err: GQLError<string>) => { const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} else { }
switch (err.error) { switch (err.error) {
case "team_environment/not_found": case "team_environment/not_found":
return t("team_environment.not_found") return t("team_environment.not_found")
@@ -192,5 +192,4 @@ const getErrorMessage = (err: GQLError<string>) => {
return t("error.something_went_wrong") return t("error.something_went_wrong")
} }
} }
}
</script> </script>

View File

@@ -49,6 +49,7 @@
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
:text="t('empty.environments')" :text="t('empty.environments')"
> >
<template #body>
<div class="flex flex-col items-center space-y-4"> <div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight"> <span class="text-center text-secondaryLight">
{{ t("environment.import_or_create") }} {{ t("environment.import_or_create") }}
@@ -74,6 +75,7 @@
/> />
</div> </div>
</div> </div>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<div v-else-if="!loading"> <div v-else-if="!loading">
<EnvironmentsTeamsEnvironment <EnvironmentsTeamsEnvironment
@@ -107,7 +109,7 @@
@hide-modal="displayModalEdit(false)" @hide-modal="displayModalEdit(false)"
/> />
<EnvironmentsImportExport <EnvironmentsImportExport
:show="showModalImportExport" v-if="showModalImportExport"
:team-environments="teamEnvironments" :team-environments="teamEnvironments"
:team-id="team?.id" :team-id="team?.id"
environment-type="TEAM_ENV" environment-type="TEAM_ENV"
@@ -174,7 +176,7 @@ const resetSelectedData = () => {
const getErrorMessage = (err: GQLError<string>) => { const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} else { }
switch (err.error) { switch (err.error) {
case "team_environment/not_found": case "team_environment/not_found":
return t("team_environment.not_found") return t("team_environment.not_found")
@@ -182,7 +184,6 @@ const getErrorMessage = (err: GQLError<string>) => {
return t("error.something_went_wrong") return t("error.something_went_wrong")
} }
} }
}
defineActionHandler( defineActionHandler(
"modals.team.environment.edit", "modals.team.environment.edit",

View File

@@ -13,12 +13,12 @@
theme="popover" theme="popover"
:on-shown="() => tippyActions.focus()" :on-shown="() => tippyActions.focus()"
> >
<span class="select-wrapper"> <HoppSmartSelectWrapper>
<HoppButtonSecondary <HoppButtonSecondary
class="ml-2 rounded-none pr-8" class="ml-2 rounded-none pr-8"
:label="authName" :label="authName"
/> />
</span> </HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="tippyActions" ref="tippyActions"
@@ -120,6 +120,7 @@
:alt="`${t('empty.authorization')}`" :alt="`${t('empty.authorization')}`"
:text="t('empty.authorization')" :text="t('empty.authorization')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
outline outline
:label="t('app.documentation')" :label="t('app.documentation')"
@@ -128,6 +129,7 @@
:icon="IconExternalLink" :icon="IconExternalLink"
reverse reverse
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<div v-else class="flex flex-1 border-b border-dividerLight"> <div v-else class="flex flex-1 border-b border-dividerLight">
<div class="w-2/3 border-r border-dividerLight"> <div class="w-2/3 border-r border-dividerLight">
@@ -171,7 +173,7 @@
</div> </div>
</div> </div>
<div <div
class="z-9 sticky top-upperTertiaryStickyFold h-full min-w-46 max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4" class="z-[9] sticky top-upperTertiaryStickyFold h-full min-w-[12rem] max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4"
> >
<div class="pb-2 text-secondaryLight"> <div class="pb-2 text-secondaryLight">
{{ t("helpers.authorization") }} {{ t("helpers.authorization") }}

View File

@@ -162,12 +162,14 @@
:alt="`${t('empty.headers')}`" :alt="`${t('empty.headers')}`"
:text="t('empty.headers')" :text="t('empty.headers')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
filled filled
:icon="IconPlus" :icon="IconPlus"
@click="addHeader" @click="addHeader"
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</template> </template>

View File

@@ -25,7 +25,7 @@
:title="`${t( :title="`${t(
'action.download_file' 'action.download_file'
)} <kbd>${getSpecialKey()}</kbd><kbd>J</kbd>`" )} <kbd>${getSpecialKey()}</kbd><kbd>J</kbd>`"
:icon="downloadResponseIcon" :icon="downloadIcon"
@click="downloadResponse" @click="downloadResponse"
/> />
<HoppButtonSecondary <HoppButtonSecondary
@@ -33,9 +33,41 @@
:title="`${t( :title="`${t(
'action.copy' 'action.copy'
)} <kbd>${getSpecialKey()}</kbd><kbd>.</kbd>`" )} <kbd>${getSpecialKey()}</kbd><kbd>.</kbd>`"
:icon="copyResponseIcon" :icon="copyIcon"
@click="copyResponse(response[0].data)" @click="copyResponse"
/> />
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => copyInterfaceTippyActions.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('app.copy_interface_type')"
:icon="IconMore"
/>
<template #content="{ hide }">
<div
ref="copyInterfaceTippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
v-for="(language, index) in interfaceLanguages"
:key="index"
:label="language"
:icon="
copiedInterfaceLanguage === language
? copyInterfaceIcon
: IconCopy
"
@click="runCopyInterface(language)"
/>
</div>
</template>
</tippy>
</div> </div>
</div> </div>
<div ref="schemaEditor" class="flex flex-1 flex-col"></div> <div ref="schemaEditor" class="flex flex-1 flex-col"></div>
@@ -59,22 +91,22 @@
<script setup lang="ts"> <script setup lang="ts">
import IconWrapText from "~icons/lucide/wrap-text" import IconWrapText from "~icons/lucide/wrap-text"
import IconDownload from "~icons/lucide/download"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy" import IconCopy from "~icons/lucide/copy"
import IconMore from "~icons/lucide/more-horizontal"
import { computed, reactive, ref } from "vue" import { computed, reactive, ref } from "vue"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "@composables/codemirror" import { useCodemirror } from "@composables/codemirror"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { defineActionHandler } from "~/helpers/actions" import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils" import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { GQLResponseEvent } from "~/helpers/graphql/connection" import { GQLResponseEvent } from "~/helpers/graphql/connection"
import { platform } from "~/platform" import interfaceLanguages from "~/helpers/utils/interfaceLanguages"
import {
useCopyInterface,
useCopyResponse,
useDownloadResponse,
} from "~/composables/lens-actions"
const t = useI18n() const t = useI18n()
const toast = useToast()
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -101,6 +133,7 @@ const responseString = computed(() => {
}) })
const schemaEditor = ref<any | null>(null) const schemaEditor = ref<any | null>(null)
const copyInterfaceTippyActions = ref<any | null>(null)
const linewrapEnabled = ref(true) const linewrapEnabled = ref(true)
useCodemirror( useCodemirror(
@@ -118,55 +151,29 @@ useCodemirror(
}) })
) )
const downloadResponseIcon = refAutoReset< const { copyIcon, copyResponse } = useCopyResponse(responseString)
typeof IconDownload | typeof IconCheck const { copyInterfaceIcon, copyInterface } = useCopyInterface(responseString)
>(IconDownload, 1000) const { downloadIcon, downloadResponse } = useDownloadResponse(
const copyResponseIcon = refAutoReset<typeof IconCopy | typeof IconCheck>( "application/json",
IconCopy, responseString
1000
) )
const copyResponse = (str: string) => { const copiedInterfaceLanguage = ref("")
copyToClipboard(str)
copyResponseIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
const downloadResponse = async (str: string) => { const runCopyInterface = (language: string) => {
const dataToWrite = str copyInterface(language).then(() => {
const file = new Blob([dataToWrite!], { type: "application/json" }) copiedInterfaceLanguage.value = language
const url = URL.createObjectURL(file)
const filename = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({
data: dataToWrite,
contentType: "application/json",
suggestedFilename: filename,
filters: [
{
name: "JSON file",
extensions: ["json"],
},
],
}) })
if (result.type === "unknown" || result.type === "saved") {
downloadResponseIcon.value = IconCheck
toast.success(`${t("state.download_started")}`)
}
} }
defineActionHandler( defineActionHandler(
"response.file.download", "response.file.download",
() => downloadResponse(responseString.value), () => downloadResponse(),
computed(() => !!props.response && props.response.length > 0) computed(() => !!props.response && props.response.length > 0)
) )
defineActionHandler( defineActionHandler(
"response.copy", "response.copy",
() => copyResponse(responseString.value), () => copyResponse(),
computed(() => !!props.response && props.response.length > 0) computed(() => !!props.response && props.response.length > 0)
) )
</script> </script>

View File

@@ -20,8 +20,7 @@
:src="`/images/states/${colorMode.value}/add_comment.svg`" :src="`/images/states/${colorMode.value}/add_comment.svg`"
:alt="`${t('empty.documentation')}`" :alt="`${t('empty.documentation')}`"
:text="t('empty.documentation')" :text="t('empty.documentation')"
> />
</HoppSmartPlaceholder>
<div v-else> <div v-else>
<div <div
class="sticky top-0 z-10 flex flex-shrink-0 overflow-x-auto bg-primary" class="sticky top-0 z-10 flex flex-shrink-0 overflow-x-auto bg-primary"
@@ -30,7 +29,7 @@
v-model="graphqlFieldsFilterText" v-model="graphqlFieldsFilterText"
type="search" type="search"
autocomplete="off" autocomplete="off"
class="flex h-8 w-full bg-transparent p-4 py-2" class="flex w-full bg-transparent px-4 py-2 h-8"
:placeholder="`${t('action.search')}`" :placeholder="`${t('action.search')}`"
/> />
<div class="flex"> <div class="flex">

View File

@@ -14,7 +14,7 @@
theme="popover" theme="popover"
:on-shown="() => tippyActions!.focus()" :on-shown="() => tippyActions!.focus()"
> >
<span class="truncate px-2 leading-8"> <span class="truncate">
{{ tab.document.request.name }} {{ tab.document.request.name }}
</span> </span>
<template #content="{ hide }"> <template #content="{ hide }">

View File

@@ -26,7 +26,7 @@ const isScalar = computed(() => {
function resolveRootType(type: GraphQLType) { function resolveRootType(type: GraphQLType) {
let t = type as any let t = type as any
while (t.ofType != null) t = t.ofType while (t.ofType !== null) t = t.ofType
return t return t
} }

View File

@@ -9,7 +9,7 @@
v-model="filterText" v-model="filterText"
type="search" type="search"
autocomplete="off" autocomplete="off"
class="flex h-8 w-full bg-transparent p-4 py-2" class="flex w-full bg-transparent px-4 py-2 h-8"
:placeholder="`${t('action.search')}`" :placeholder="`${t('action.search')}`"
/> />
<div class="flex"> <div class="flex">
@@ -114,8 +114,7 @@
:src="`/images/states/${colorMode.value}/history.svg`" :src="`/images/states/${colorMode.value}/history.svg`"
:alt="`${t('empty.history')}`" :alt="`${t('empty.history')}`"
:text="t('empty.history')" :text="t('empty.history')"
> />
</HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-else-if=" v-else-if="
Object.keys(filteredHistoryGroups).length === 0 || Object.keys(filteredHistoryGroups).length === 0 ||
@@ -124,8 +123,9 @@
:text="`${t('state.nothing_found')} ‟${filterText || filterSelection}”`" :text="`${t('state.nothing_found')} ‟${filterText || filterSelection}”`"
> >
<template #icon> <template #icon>
<icon-lucide-search class="svg-icons pb-2 opacity-75" /> <icon-lucide-search class="svg-icons opacity-75" />
</template> </template>
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('action.clear')" :label="t('action.clear')"
outline outline
@@ -136,6 +136,7 @@
} }
" "
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartConfirmModal <HoppSmartConfirmModal
:show="confirmRemove" :show="confirmRemove"

View File

@@ -121,7 +121,8 @@ const duration = computed(() => {
return responseDuration > 0 return responseDuration > 0
? `${t("request.duration")}: ${responseDuration}ms` ? `${t("request.duration")}: ${responseDuration}ms`
: t("error.no_duration") : t("error.no_duration")
} else return t("error.no_duration") }
return t("error.no_duration")
}) })
const entryStatus = computed(() => { const entryStatus = computed(() => {

View File

@@ -13,12 +13,12 @@
theme="popover" theme="popover"
:on-shown="() => tippyActions.focus()" :on-shown="() => tippyActions.focus()"
> >
<span class="select-wrapper"> <HoppSmartSelectWrapper>
<HoppButtonSecondary <HoppButtonSecondary
class="ml-2 rounded-none pr-8" class="ml-2 rounded-none pr-8"
:label="authName" :label="authName"
/> />
</span> </HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="tippyActions" ref="tippyActions"
@@ -119,6 +119,7 @@
:alt="`${t('empty.authorization')}`" :alt="`${t('empty.authorization')}`"
:text="t('empty.authorization')" :text="t('empty.authorization')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
outline outline
:label="t('app.documentation')" :label="t('app.documentation')"
@@ -127,6 +128,7 @@
:icon="IconExternalLink" :icon="IconExternalLink"
reverse reverse
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<div v-else class="flex flex-1 border-b border-dividerLight"> <div v-else class="flex flex-1 border-b border-dividerLight">
<div class="w-2/3 border-r border-dividerLight"> <div class="w-2/3 border-r border-dividerLight">
@@ -149,7 +151,7 @@
</div> </div>
</div> </div>
<div <div
class="z-9 sticky top-upperTertiaryStickyFold h-full min-w-46 max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4" class="z-[9] sticky top-upperTertiaryStickyFold h-full min-w-[12rem] max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4"
> >
<div class="pb-2 text-secondaryLight"> <div class="pb-2 text-secondaryLight">
{{ t("helpers.authorization") }} {{ t("helpers.authorization") }}

View File

@@ -13,12 +13,12 @@
theme="popover" theme="popover"
:on-shown="() => tippyActions.focus()" :on-shown="() => tippyActions.focus()"
> >
<span class="select-wrapper"> <HoppSmartSelectWrapper>
<HoppButtonSecondary <HoppButtonSecondary
:label="body.contentType || t('state.none')" :label="body.contentType || t('state.none')"
class="ml-2 rounded-none pr-8" class="ml-2 rounded-none pr-8"
/> />
</span> </HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="tippyActions" ref="tippyActions"
@@ -112,6 +112,7 @@
:alt="`${t('empty.body')}`" :alt="`${t('empty.body')}`"
:text="t('empty.body')" :text="t('empty.body')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
outline outline
:label="`${t('app.documentation')}`" :label="`${t('app.documentation')}`"
@@ -120,6 +121,7 @@
:icon="IconExternalLink" :icon="IconExternalLink"
reverse reverse
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</template> </template>

View File

@@ -158,12 +158,14 @@
:alt="`${t('empty.body')}`" :alt="`${t('empty.body')}`"
:text="t('empty.body')" :text="t('empty.body')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
filled filled
:icon="IconPlus" :icon="IconPlus"
@click="addBodyParam" @click="addBodyParam"
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</template> </template>
@@ -339,7 +341,7 @@ const deleteBodyParam = (index: number) => {
} }
workingParams.value = workingParams.value.filter( workingParams.value = workingParams.value.filter(
(_, arrIndex) => arrIndex != index (_, arrIndex) => arrIndex !== index
) )
} }

View File

@@ -17,7 +17,7 @@
placement="bottom" placement="bottom"
:on-shown="() => tippyActions.focus()" :on-shown="() => tippyActions.focus()"
> >
<span class="select-wrapper"> <HoppSmartSelectWrapper>
<HoppButtonSecondary <HoppButtonSecondary
:label=" :label="
CodegenDefinitions.find((x) => x.name === codegenType)!.caption CodegenDefinitions.find((x) => x.name === codegenType)!.caption
@@ -25,7 +25,7 @@
outline outline
class="flex-1 pr-8" class="flex-1 pr-8"
/> />
</span> </HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<div class="sticky top-0 z-10 flex-shrink-0 overflow-x-auto"> <div class="sticky top-0 z-10 flex-shrink-0 overflow-x-auto">
@@ -61,7 +61,7 @@
:text="`${t('state.nothing_found')}${searchQuery}`" :text="`${t('state.nothing_found')}${searchQuery}`"
> >
<template #icon> <template #icon>
<icon-lucide-search class="svg-icons pb-2 opacity-75" /> <icon-lucide-search class="svg-icons opacity-75" />
</template> </template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
@@ -214,10 +214,9 @@ const requestCode = computed(() => {
if (O.isSome(result)) { if (O.isSome(result)) {
errorState.value = false errorState.value = false
return result.value return result.value
} else { }
errorState.value = true errorState.value = true
return "" return ""
}
}) })
// Template refs // Template refs

View File

@@ -209,12 +209,14 @@
:alt="`${t('empty.headers')}`" :alt="`${t('empty.headers')}`"
:text="t('empty.headers')" :text="t('empty.headers')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
filled filled
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
:icon="IconPlus" :icon="IconPlus"
@click="addHeader" @click="addHeader"
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</div> </div>

View File

@@ -157,12 +157,14 @@
:alt="`${t('empty.parameters')}`" :alt="`${t('empty.parameters')}`"
:text="t('empty.parameters')" :text="t('empty.parameters')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
:icon="IconPlus" :icon="IconPlus"
filled filled
@click="addParam" @click="addParam"
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</div> </div>

View File

@@ -34,7 +34,7 @@
<div ref="preRequestEditor" class="h-full"></div> <div ref="preRequestEditor" class="h-full"></div>
</div> </div>
<div <div
class="z-9 sticky top-upperTertiaryStickyFold h-full min-w-46 max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4" class="z-[9] sticky top-upperTertiaryStickyFold h-full min-w-[12rem] max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4"
> >
<div class="pb-2 text-secondaryLight"> <div class="pb-2 text-secondaryLight">
{{ t("helpers.pre_request_script") }} {{ t("helpers.pre_request_script") }}

View File

@@ -126,19 +126,19 @@ const linewrapEnabled = ref(true)
const rawBodyParameters = ref<any | null>(null) const rawBodyParameters = ref<any | null>(null)
const codemirrorValue: Ref<string | undefined> = const codemirrorValue: Ref<string | undefined> =
typeof rawParamsBody.value == "string" typeof rawParamsBody.value === "string"
? ref(rawParamsBody.value) ? ref(rawParamsBody.value)
: ref(undefined) : ref(undefined)
watch(rawParamsBody, (newVal) => { watch(rawParamsBody, (newVal) => {
typeof newVal == "string" typeof newVal === "string"
? (codemirrorValue.value = newVal) ? (codemirrorValue.value = newVal)
: (codemirrorValue.value = undefined) : (codemirrorValue.value = undefined)
}) })
// propagate the edits from codemirror back to the body // propagate the edits from codemirror back to the body
watch(codemirrorValue, (updatedValue) => { watch(codemirrorValue, (updatedValue) => {
if (updatedValue && updatedValue != rawParamsBody.value) { if (updatedValue && updatedValue !== rawParamsBody.value) {
rawParamsBody.value = updatedValue rawParamsBody.value = updatedValue
} }
}) })
@@ -185,7 +185,7 @@ const prettifyRequestBody = () => {
if (body.value.contentType.endsWith("json")) { if (body.value.contentType.endsWith("json")) {
const jsonObj = JSON.parse(rawParamsBody.value as string) const jsonObj = JSON.parse(rawParamsBody.value as string)
prettifyBody = JSON.stringify(jsonObj, null, 2) prettifyBody = JSON.stringify(jsonObj, null, 2)
} else if (body.value.contentType == "application/xml") { } else if (body.value.contentType === "application/xml") {
prettifyBody = prettifyXML(rawParamsBody.value as string) prettifyBody = prettifyXML(rawParamsBody.value as string)
} }
rawParamsBody.value = prettifyBody rawParamsBody.value = prettifyBody

View File

@@ -3,7 +3,7 @@
class="sticky top-0 z-20 flex-none flex-shrink-0 bg-primary p-4 sm:flex sm:flex-shrink-0 sm:space-x-2" class="sticky top-0 z-20 flex-none flex-shrink-0 bg-primary p-4 sm:flex sm:flex-shrink-0 sm:space-x-2"
> >
<div <div
class="min-w-52 flex flex-1 whitespace-nowrap rounded border border-divider" class="min-w-[12rem] flex flex-1 whitespace-nowrap rounded border border-divider"
> >
<div class="relative flex"> <div class="relative flex">
<label for="method"> <label for="method">
@@ -13,7 +13,7 @@
theme="popover" theme="popover"
:on-shown="() => methodTippyActions.focus()" :on-shown="() => methodTippyActions.focus()"
> >
<span class="select-wrapper"> <HoppSmartSelectWrapper>
<input <input
id="method" id="method"
class="flex w-26 cursor-pointer rounded-l bg-primaryLight px-4 py-2 font-semibold text-secondaryDark transition" class="flex w-26 cursor-pointer rounded-l bg-primaryLight px-4 py-2 font-semibold text-secondaryDark transition"
@@ -22,7 +22,7 @@
:placeholder="`${t('request.method')}`" :placeholder="`${t('request.method')}`"
@input="onSelectMethod($event)" @input="onSelectMethod($event)"
/> />
</span> </HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="methodTippyActions" ref="methodTippyActions"
@@ -34,6 +34,9 @@
v-for="(method, index) in methods" v-for="(method, index) in methods"
:key="`method-${index}`" :key="`method-${index}`"
:label="method" :label="method"
:style="{
color: getMethodLabelColor(method),
}"
@click=" @click="
() => { () => {
updateMethod(method) updateMethod(method)
@@ -67,7 +70,7 @@
'action.send' 'action.send'
)} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`" )} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
:label="`${!loading ? t('action.send') : t('action.cancel')}`" :label="`${!loading ? t('action.send') : t('action.cancel')}`"
class="min-w-20 flex-1 rounded-r-none" class="min-w-[5rem] flex-1 rounded-r-none"
@click="!loading ? newSendRequest() : cancelRequest()" @click="!loading ? newSendRequest() : cancelRequest()"
/> />
<span class="flex"> <span class="flex">
@@ -179,20 +182,16 @@
/> />
<HoppSmartItem <HoppSmartItem
ref="copyRequestAction" ref="copyRequestAction"
:label="shareButtonText" :label="t('request.share_request')"
:icon="copyLinkIcon" :icon="IconShare2"
:loading="fetchingShareLink" :loading="fetchingShareLink"
@click=" @click="
() => { () => {
copyRequest() shareRequest()
hide()
} }
" "
/> />
<HoppSmartItem
:icon="IconLink2"
:label="`${t('request.view_my_links')}`"
to="/profile"
/>
<hr /> <hr />
<HoppSmartItem <HoppSmartItem
ref="saveRequestAction" ref="saveRequestAction"
@@ -236,25 +235,20 @@ import { useI18n } from "@composables/i18n"
import { useSetting } from "@composables/settings" import { useSetting } from "@composables/settings"
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream" import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
import { useToast } from "@composables/toast" import { useToast } from "@composables/toast"
import { refAutoReset, useVModel } from "@vueuse/core" import { useVModel } from "@vueuse/core"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { Ref, computed, onBeforeUnmount, ref } from "vue" import { Ref, computed, onBeforeUnmount, ref } from "vue"
import { defineActionHandler } from "~/helpers/actions" import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { runMutation } from "~/helpers/backend/GQLClient" import { runMutation } from "~/helpers/backend/GQLClient"
import { UpdateRequestDocument } from "~/helpers/backend/graphql" import { UpdateRequestDocument } from "~/helpers/backend/graphql"
import { createShortcode } from "~/helpers/backend/mutations/Shortcode"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils" import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { runRESTRequest$ } from "~/helpers/RequestRunner" import { runRESTRequest$ } from "~/helpers/RequestRunner"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse" import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { editRESTRequest } from "~/newstore/collections" import { editRESTRequest } from "~/newstore/collections"
import IconCheck from "~icons/lucide/check"
import IconChevronDown from "~icons/lucide/chevron-down" import IconChevronDown from "~icons/lucide/chevron-down"
import IconCode2 from "~icons/lucide/code-2" import IconCode2 from "~icons/lucide/code-2"
import IconCopy from "~icons/lucide/copy"
import IconFileCode from "~icons/lucide/file-code" import IconFileCode from "~icons/lucide/file-code"
import IconFolderPlus from "~icons/lucide/folder-plus" import IconFolderPlus from "~icons/lucide/folder-plus"
import IconLink2 from "~icons/lucide/link-2"
import IconRotateCCW from "~icons/lucide/rotate-ccw" import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconSave from "~icons/lucide/save" import IconSave from "~icons/lucide/save"
import IconShare2 from "~icons/lucide/share-2" import IconShare2 from "~icons/lucide/share-2"
@@ -268,6 +262,7 @@ import { InterceptorService } from "~/services/interceptor.service"
import { HoppTab } from "~/services/tab" import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document" import { HoppRESTDocument } from "~/helpers/rest/document"
import { RESTTabService } from "~/services/tab/rest" import { RESTTabService } from "~/services/tab/rest"
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
const t = useI18n() const t = useI18n()
const interceptorService = useService(InterceptorService) const interceptorService = useService(InterceptorService)
@@ -279,8 +274,8 @@ const methods = [
"PATCH", "PATCH",
"DELETE", "DELETE",
"HEAD", "HEAD",
"CONNECT",
"OPTIONS", "OPTIONS",
"CONNECT",
"TRACE", "TRACE",
"CUSTOM", "CUSTOM",
] ]
@@ -309,8 +304,6 @@ const showCurlImportModal = ref(false)
const showCodegenModal = ref(false) const showCodegenModal = ref(false)
const showSaveRequestModal = ref(false) const showSaveRequestModal = ref(false)
const hasNavigatorShare = !!navigator.share
// Template refs // Template refs
const methodTippyActions = ref<any | null>(null) const methodTippyActions = ref<any | null>(null)
const sendTippyActions = ref<any | null>(null) const sendTippyActions = ref<any | null>(null)
@@ -453,62 +446,20 @@ const updateRESTResponse = (response: HoppRESTResponse | null) => {
tab.value.document.response = response tab.value.document.response = response
} }
const copyLinkIcon = refAutoReset< const currentUser = useReadonlyStream(
typeof IconShare2 | typeof IconCopy | typeof IconCheck platform.auth.getCurrentUserStream(),
>(hasNavigatorShare ? IconShare2 : IconCopy, 1000) platform.auth.getCurrentUser()
)
const shareLink = ref<string | null>("")
const fetchingShareLink = ref(false) const fetchingShareLink = ref(false)
const shareButtonText = computed(() => { const shareRequest = () => {
if (shareLink.value) { if (currentUser.value) {
return shareLink.value invokeAction("share.request", {
} else if (fetchingShareLink.value) { request: tab.value.document.request,
return t("state.loading")
} else {
return t("request.copy_link")
}
})
const copyRequest = async () => {
if (shareLink.value) {
copyShareLink(shareLink.value)
} else {
shareLink.value = ""
fetchingShareLink.value = true
const shortcodeResult = await createShortcode(tab.value.document.request)()
platform.analytics?.logEvent({
type: "HOPP_SHORTCODE_CREATED",
})
if (E.isLeft(shortcodeResult)) {
toast.error(`${shortcodeResult.left.error}`)
shareLink.value = `${t("error.something_went_wrong")}`
} else if (E.isRight(shortcodeResult)) {
shareLink.value = `/${shortcodeResult.right.createShortcode.id}`
copyShareLink(shareLink.value)
}
fetchingShareLink.value = false
}
}
const copyShareLink = (shareLink: string) => {
const link = `${
import.meta.env.VITE_SHORTCODE_BASE_URL ?? "https://hopp.sh"
}/r${shareLink}`
if (navigator.share) {
const time = new Date().toLocaleTimeString()
const date = new Date().toLocaleDateString()
navigator.share({
title: "Hoppscotch",
text: `Hoppscotch • Open source API development ecosystem at ${time} on ${date}`,
url: link,
}) })
} else { } else {
copyLinkIcon.value = IconCheck invokeAction("modals.login.toggle")
copyToClipboard(link)
toast.success(`${t("state.copied_to_clipboard")}`)
} }
} }
@@ -611,7 +562,7 @@ defineActionHandler("request.send-cancel", () => {
else cancelRequest() else cancelRequest()
}) })
defineActionHandler("request.reset", clearContent) defineActionHandler("request.reset", clearContent)
defineActionHandler("request.copy-link", copyRequest) defineActionHandler("request.share-request", shareRequest)
defineActionHandler("request.method.next", cycleDownMethod) defineActionHandler("request.method.next", cycleDownMethod)
defineActionHandler("request.method.prev", cycleUpMethod) defineActionHandler("request.method.prev", cycleUpMethod)
defineActionHandler("request.save", saveRequest) defineActionHandler("request.save", saveRequest)

View File

@@ -5,13 +5,18 @@
render-inactive-tabs render-inactive-tabs
> >
<HoppSmartTab <HoppSmartTab
v-if="properties ? properties.includes('parameters') : true"
:id="'params'" :id="'params'"
:label="`${t('tab.parameters')}`" :label="`${t('tab.parameters')}`"
:info="`${newActiveParamsCount$}`" :info="`${newActiveParamsCount$}`"
> >
<HttpParameters v-model="request.params" /> <HttpParameters v-model="request.params" />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab :id="'bodyParams'" :label="`${t('tab.body')}`"> <HoppSmartTab
v-if="properties ? properties.includes('body') : true"
:id="'bodyParams'"
:label="`${t('tab.body')}`"
>
<HttpBody <HttpBody
v-model:headers="request.headers" v-model:headers="request.headers"
v-model:body="request.body" v-model:body="request.body"
@@ -19,16 +24,22 @@
/> />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
v-if="properties ? properties.includes('headers') : true"
:id="'headers'" :id="'headers'"
:label="`${t('tab.headers')}`" :label="`${t('tab.headers')}`"
:info="`${newActiveHeadersCount$}`" :info="`${newActiveHeadersCount$}`"
> >
<HttpHeaders v-model="request" @change-tab="changeOptionTab" /> <HttpHeaders v-model="request" @change-tab="changeOptionTab" />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab :id="'authorization'" :label="`${t('tab.authorization')}`"> <HoppSmartTab
v-if="properties ? properties.includes('authorization') : true"
:id="'authorization'"
:label="`${t('tab.authorization')}`"
>
<HttpAuthorization v-model="request.auth" /> <HttpAuthorization v-model="request.auth" />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
v-if="properties ? properties.includes('preRequestScript') : true"
:id="'preRequestScript'" :id="'preRequestScript'"
:label="`${t('tab.pre_request_script')}`" :label="`${t('tab.pre_request_script')}`"
:indicator=" :indicator="
@@ -40,6 +51,7 @@
<HttpPreRequestScript v-model="request.preRequestScript" /> <HttpPreRequestScript v-model="request.preRequestScript" />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
v-if="properties ? properties.includes('tests') : true"
:id="'tests'" :id="'tests'"
:label="`${t('tab.tests')}`" :label="`${t('tab.tests')}`"
:indicator=" :indicator="
@@ -76,6 +88,7 @@ const props = withDefaults(
defineProps<{ defineProps<{
modelValue: HoppRESTRequest modelValue: HoppRESTRequest
optionTab: RESTOptionTabs optionTab: RESTOptionTabs
properties?: string[]
}>(), }>(),
{ {
optionTab: "params", optionTab: "params",

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="relative flex flex-1 flex-col"> <div class="relative flex flex-1 flex-col">
<HttpResponseMeta :response="doc.response" /> <HttpResponseMeta :response="doc.response" :is-embed="isEmbed" />
<LensesResponseBodyRenderer <LensesResponseBodyRenderer
v-if="!loading && hasResponse" v-if="!loading && hasResponse"
v-model:document="doc" v-model:document="doc"
@@ -15,6 +15,7 @@ import { HoppRESTDocument } from "~/helpers/rest/document"
const props = defineProps<{ const props = defineProps<{
document: HoppRESTDocument document: HoppRESTDocument
isEmbed: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -2,8 +2,20 @@
<div <div
class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-center overflow-auto overflow-x-auto whitespace-nowrap bg-primary p-4" class="sticky top-0 z-10 flex flex-shrink-0 items-center justify-center overflow-auto overflow-x-auto whitespace-nowrap bg-primary p-4"
> >
<AppShortcutsPrompt v-if="response == null" class="flex-1" /> <AppShortcutsPrompt v-if="response == null && !isEmbed" class="flex-1" />
<div v-else class="flex flex-1 flex-col">
<div v-if="response == null && isEmbed">
<HoppButtonSecondary
:label="`${t('app.documentation')}`"
to="https://docs.hoppscotch.io/documentation/features/rest-api-testing#response"
:icon="IconExternalLink"
blank
outline
reverse
/>
</div>
<div v-else-if="response" class="flex flex-1 flex-col">
<div <div
v-if="response.type === 'loading'" v-if="response.type === 'loading'"
class="flex flex-col items-center justify-center" class="flex flex-col items-center justify-center"
@@ -25,7 +37,9 @@
:text="t('helpers.network_fail')" :text="t('helpers.network_fail')"
large large
> >
<template #body>
<AppInterceptor class="rounded border border-dividerLight p-2" /> <AppInterceptor class="rounded border border-dividerLight p-2" />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-if="response.type === 'script_fail'" v-if="response.type === 'script_fail'"
@@ -35,12 +49,14 @@
:text="t('helpers.script_fail')" :text="t('helpers.script_fail')"
large large
> >
<template #body>
<div <div
class="mt-2 w-full overflow-auto whitespace-normal rounded bg-primaryLight px-4 py-2 font-mono text-red-400" class="mt-2 w-full overflow-auto whitespace-normal rounded bg-primaryLight px-4 py-2 font-mono text-red-400"
> >
{{ response.error.name }}: {{ response.error.message }}<br /> {{ response.error.name }}: {{ response.error.message }}<br />
{{ response.error.stack }} {{ response.error.stack }}
</div> </div>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<div <div
v-if="response.type === 'success' || response.type === 'fail'" v-if="response.type === 'success' || response.type === 'fail'"
@@ -53,7 +69,12 @@
<span v-if="response.statusCode"> <span v-if="response.statusCode">
<span class="text-secondary"> {{ t("response.status") }}: </span> <span class="text-secondary"> {{ t("response.status") }}: </span>
{{ `${response.statusCode}\xA0 • \xA0` {{ `${response.statusCode}\xA0 • \xA0`
}}{{ getStatusCodeReasonPhrase(response.statusCode) }} }}{{
getStatusCodeReasonPhrase(
response.statusCode,
response.statusText
)
}}
</span> </span>
<span v-if="response.meta && response.meta.responseDuration"> <span v-if="response.meta && response.meta.responseDuration">
<span class="text-secondary"> {{ t("response.time") }}: </span> <span class="text-secondary"> {{ t("response.time") }}: </span>
@@ -100,6 +121,7 @@ import { getStatusCodeReasonPhrase } from "~/helpers/utils/statusCodes"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection" import { InspectionService } from "~/services/inspection"
import { RESTTabService } from "~/services/tab/rest" import { RESTTabService } from "~/services/tab/rest"
import IconExternalLink from "~icons/lucide/external-link"
const t = useI18n() const t = useI18n()
const colorMode = useColorMode() const colorMode = useColorMode()
@@ -107,6 +129,7 @@ const tabs = useService(RESTTabService)
const props = defineProps<{ const props = defineProps<{
response: HoppRESTResponse | null | undefined response: HoppRESTResponse | null | undefined
isEmbed?: boolean
}>() }>()
/** /**

View File

@@ -26,6 +26,13 @@
> >
<History :page="'rest'" /> <History :page="'rest'" />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab
:id="'share-request'"
:icon="IconShare2"
:label="`${t('tab.shared_requests')}`"
>
<Share />
</HoppSmartTab>
</HoppSmartTabs> </HoppSmartTabs>
</template> </template>
@@ -33,6 +40,7 @@
import IconClock from "~icons/lucide/clock" import IconClock from "~icons/lucide/clock"
import IconLayers from "~icons/lucide/layers" import IconLayers from "~icons/lucide/layers"
import IconFolder from "~icons/lucide/folder" import IconFolder from "~icons/lucide/folder"
import IconShare2 from "~icons/lucide/share-2"
import { ref } from "vue" import { ref } from "vue"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"

View File

@@ -8,12 +8,11 @@
@click.middle="emit('close-tab')" @click.middle="emit('close-tab')"
> >
<span <span
class="text-tiny font-semibold" class="text-tiny font-semibold mr-2"
:style="{ color: getMethodLabelColorClassOf(tab.document.request) }" :style="{ color: getMethodLabelColorClassOf(tab.document.request) }"
> >
{{ tab.document.request.method }} {{ tab.document.request.method }}
</span> </span>
<tippy <tippy
ref="options" ref="options"
trigger="manual" trigger="manual"
@@ -21,7 +20,7 @@
theme="popover" theme="popover"
:on-shown="() => tippyActions!.focus()" :on-shown="() => tippyActions!.focus()"
> >
<span class="truncate px-2 leading-8"> <span class="truncate">
{{ tab.document.request.name }} {{ tab.document.request.name }}
</span> </span>
<template #content="{ hide }"> <template #content="{ hide }">

View File

@@ -41,7 +41,7 @@
<div class="divide-y divide-dividerLight"> <div class="divide-y divide-dividerLight">
<div <div
v-if="noEnvSelected && !globalHasAdditions" v-if="noEnvSelected && !globalHasAdditions"
class="flex bg-error p-4 text-secondaryDark" class="flex bg-bannerInfo p-4 text-secondaryDark"
role="alert" role="alert"
> >
<icon-lucide-alert-triangle class="svg-icons mr-4" /> <icon-lucide-alert-triangle class="svg-icons mr-4" />
@@ -159,8 +159,7 @@
:alt="`${t('error.test_script_fail')}`" :alt="`${t('error.test_script_fail')}`"
:heading="t('error.test_script_fail')" :heading="t('error.test_script_fail')"
:text="t('helpers.test_script_fail')" :text="t('helpers.test_script_fail')"
> />
</HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-else v-else
:src="`/images/states/${colorMode.value}/validation.svg`" :src="`/images/states/${colorMode.value}/validation.svg`"
@@ -168,6 +167,7 @@
:heading="t('empty.tests')" :heading="t('empty.tests')"
:text="t('helpers.tests')" :text="t('helpers.tests')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
outline outline
:label="`${t('action.learn_more')}`" :label="`${t('action.learn_more')}`"
@@ -175,8 +175,8 @@
blank blank
:icon="IconExternalLink" :icon="IconExternalLink"
reverse reverse
class="my-4"
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<EnvironmentsMyDetails <EnvironmentsMyDetails
:show="showMyEnvironmentDetailsModal" :show="showMyEnvironmentDetailsModal"

View File

@@ -34,7 +34,7 @@
<div ref="testScriptEditor" class="h-full"></div> <div ref="testScriptEditor" class="h-full"></div>
</div> </div>
<div <div
class="z-9 sticky top-upperTertiaryStickyFold h-full min-w-46 max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4" class="z-[9] sticky top-upperTertiaryStickyFold h-full min-w-[12rem] max-w-1/3 flex-shrink-0 overflow-auto overflow-x-auto bg-primary p-4"
> >
<div class="pb-2 text-secondaryLight"> <div class="pb-2 text-secondaryLight">
{{ t("helpers.post_request_tests") }} {{ t("helpers.post_request_tests") }}

View File

@@ -149,12 +149,14 @@
:alt="`${t('empty.body')}`" :alt="`${t('empty.body')}`"
:text="t('empty.body')" :text="t('empty.body')"
> >
<template #body>
<HoppButtonSecondary <HoppButtonSecondary
filled filled
:label="`${t('add.new')}`" :label="`${t('add.new')}`"
:icon="IconPlus" :icon="IconPlus"
@click="addUrlEncodedParam" @click="addUrlEncodedParam"
/> />
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
</div> </div>
@@ -242,7 +244,7 @@ const urlEncodedParamsRaw = pluckRef(body, "body")
const urlEncodedParams = computed<RawKeyValueEntry[]>({ const urlEncodedParams = computed<RawKeyValueEntry[]>({
get() { get() {
return typeof urlEncodedParamsRaw.value == "string" return typeof urlEncodedParamsRaw.value === "string"
? parseRawKeyValueEntries(urlEncodedParamsRaw.value) ? parseRawKeyValueEntries(urlEncodedParamsRaw.value)
: [] : []
}, },

View File

@@ -16,12 +16,12 @@
theme="popover" theme="popover"
:on-shown="() => authTippyActions.focus()" :on-shown="() => authTippyActions.focus()"
> >
<span class="select-wrapper"> <HoppSmartSelectWrapper>
<HoppButtonSecondary <HoppButtonSecondary
:label="auth.addTo || t('state.none')" :label="auth.addTo || t('state.none')"
class="ml-2 rounded-none pr-8" class="ml-2 rounded-none pr-8"
/> />
</span> </HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="authTippyActions" ref="authTippyActions"

View File

@@ -0,0 +1,163 @@
<template>
<HoppSmartModal
dialog
:title="t(modalTitle)"
styles="sm:max-w-md"
@close="hideModal"
>
<template #actions>
<HoppButtonSecondary
v-if="hasPreviousStep"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.go_back')"
:icon="IconArrowLeft"
@click="goToPreviousStep"
/>
</template>
<template #body>
<component :is="currentStep.component" v-bind="currentStep.props()" />
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import IconArrowLeft from "~icons/lucide/arrow-left"
import { useI18n } from "~/composables/i18n"
import { PropType, ref } from "vue"
import { useSteps, defineStep } from "~/composables/step-components"
import ImportExportList from "./ImportExportList.vue"
import ImportExportSourcesList from "./ImportExportSourcesList.vue"
import { ImporterOrExporter } from "~/components/importExport/types"
const t = useI18n()
const props = defineProps({
importerModules: {
// type: Array as PropType<ReturnType<typeof defineImporter>[]>,
type: Array as PropType<ImporterOrExporter[]>,
default: () => [],
required: true,
},
exporterModules: {
type: Array as PropType<ImporterOrExporter[]>,
default: () => [],
required: true,
},
modalTitle: {
type: String,
required: true,
},
})
const {
addStep,
currentStep,
goToStep,
goToNextStep,
goToPreviousStep,
hasPreviousStep,
} = useSteps()
const selectedImporterID = ref<string | null>(null)
const selectedSourceID = ref<string | null>(null)
const chooseImporterOrExporter = defineStep(
"choose_importer_or_exporter",
ImportExportList,
() => ({
importers: props.importerModules.map((importer) => ({
id: importer.metadata.id,
name: importer.metadata.name,
title: importer.metadata.title,
icon: importer.metadata.icon,
disabled: importer.metadata.disabled,
})),
exporters: props.exporterModules.map((exporter) => ({
id: exporter.metadata.id,
name: exporter.metadata.name,
title: exporter.metadata.title,
icon: exporter.metadata.icon,
disabled: exporter.metadata.disabled,
loading: exporter.metadata.isLoading?.value ?? false,
})),
"onImporter-selected": (id: string) => {
selectedImporterID.value = id
const selectedImporter = props.importerModules.find(
(i) => i.metadata.id === id
)
if (selectedImporter?.supported_sources) goToNextStep()
else if (selectedImporter?.component)
goToStep(selectedImporter.component.id)
},
"onExporter-selected": (id: string) => {
const selectedExporter = props.exporterModules.find(
(i) => i.metadata.id === id
)
if (selectedExporter && selectedExporter.action) {
selectedExporter.action()
}
},
})
)
const chooseImportSource = defineStep(
"choose_import_source",
ImportExportSourcesList,
() => {
const currentImporter = props.importerModules.find(
(i) => i.metadata.id === selectedImporterID.value
)
const sources = currentImporter?.supported_sources
if (!sources)
return {
sources: [],
}
sources.forEach((source) => {
addStep(source.step)
})
return {
sources: sources.map((source) => ({
id: source.id,
name: source.name,
icon: source.icon,
})),
"onImport-source-selected": (sourceID) => {
selectedSourceID.value = sourceID
const sourceStep = sources.find((s) => s.id === sourceID)?.step
if (sourceStep) {
goToStep(sourceStep.id)
}
},
}
}
)
addStep(chooseImporterOrExporter)
addStep(chooseImportSource)
props.importerModules.forEach((importer) => {
if (importer.component) {
addStep(importer.component)
}
})
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const hideModal = () => {
// resetImport()
emit("hide-modal")
}
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div class="flex flex-col">
<HoppSmartExpand>
<template #body>
<HoppSmartItem
v-for="importer in importers"
:key="importer.id"
:icon="importer.icon"
:label="t(`${importer.name}`)"
@click="emit('importer-selected', importer.id)"
/>
</template>
</HoppSmartExpand>
<hr />
<div class="flex flex-col space-y-2">
<template v-for="exporter in exporters" :key="exporter.id">
<!-- adding the title to a span if the item is visible, otherwise the title won't be shown -->
<span
v-if="exporter.disabled && exporter.title"
v-tippy="{ theme: 'tooltip' }"
:title="t(`${exporter.title}`)"
class="flex"
>
<HoppSmartItem
v-tippy="{ theme: 'tooltip' }"
:icon="exporter.icon"
:label="t(`${exporter.name}`)"
:disabled="exporter.disabled"
:loading="exporter.loading"
@click="emit('exporter-selected', exporter.id)"
/>
</span>
<HoppSmartItem
v-else
v-tippy="{ theme: 'tooltip' }"
:icon="exporter.icon"
:title="t(`${exporter.title}`)"
:label="t(`${exporter.name}`)"
:loading="exporter.loading"
:disabled="exporter.disabled"
@click="emit('exporter-selected', exporter.id)"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { Component } from "vue"
const t = useI18n()
type ImportExportEntryMeta = {
id: string
name: string
icon: Component
disabled: boolean
title?: string
loading?: boolean
isVisible?: boolean
}
defineProps<{
importers: ImportExportEntryMeta[]
exporters: ImportExportEntryMeta[]
}>()
const emit = defineEmits<{
(e: "importer-selected", importerID: string): void
(e: "exporter-selected", exporterID: string): void
}>()
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex flex-col">
<HoppSmartItem
v-for="source in sources"
:key="source.id"
:icon="source.icon"
:label="t(`${source.name}`)"
@click="emit('import-source-selected', source.id)"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { Component } from "vue"
const t = useI18n()
type ListItemMeta = {
id: string
name: string
icon: Component
title?: string
}
defineProps<{
sources: ListItemMeta[]
}>()
const emit = defineEmits<{
(e: "import-source-selected", sourceID: string): void
}>()
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div class="space-y-4">
<p class="flex items-center">
<span
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary text-dividerDark"
:class="{
'!text-green-500': hasFile,
}"
>
<icon-lucide-check-circle class="svg-icons" />
</span>
<span>
{{ t(`${caption}`) }}
</span>
</p>
<div
class="flex flex-col ml-10 border border-dashed rounded border-dividerDark"
>
<input
id="inputChooseFileToImportFrom"
ref="inputChooseFileToImportFrom"
name="inputChooseFileToImportFrom"
type="file"
class="p-4 cursor-pointer transition file:transition file:cursor-pointer text-secondary hover:text-secondaryDark file:mr-2 file:py-2 file:px-4 file:rounded file:border-0 file:text-secondary hover:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark"
:accept="acceptedFileTypes"
@change="onFileChange"
/>
</div>
<div>
<HoppButtonPrimary
class="w-full"
:label="t('import.title')"
:disabled="!hasFile"
@click="emit('importFromFile', fileContent)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
defineProps<{
caption: string
acceptedFileTypes: string
}>()
const t = useI18n()
const toast = useToast()
const hasFile = ref(false)
const fileContent = ref("")
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
const emit = defineEmits<{
(e: "importFromFile", content: string): void
}>()
const onFileChange = () => {
const inputFileToImport = inputChooseFileToImportFrom.value
if (!inputFileToImport) {
hasFile.value = false
return
}
if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
inputChooseFileToImportFrom.value[0].value = ""
hasFile.value = false
toast.show(t("action.choose_file").toString())
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
hasFile.value = false
toast.show(t("action.choose_file").toString())
return
}
fileContent.value = content
hasFile.value = !!content?.length
}
reader.readAsText(inputFileToImport.files[0])
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div class="select-wrapper">
<select
v-model="mySelectedCollectionID"
autocomplete="off"
class="select"
autofocus
>
<option :key="undefined" :value="undefined" disabled selected>
{{ t("collection.select") }}
</option>
<option
v-for="(collection, collectionIndex) in myCollections"
:key="`collection-${collectionIndex}`"
:value="collectionIndex"
class="bg-primary"
>
{{ collection.name }}
</option>
</select>
</div>
<div class="my-4">
<HoppButtonPrimary
class="w-full"
:label="t('import.title')"
:disabled="!hasSelectedCollectionID"
@click="fetchCollectionFromMyCollections"
/>
</div>
</template>
<script setup lang="ts">
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { computed, ref } from "vue"
import { useI18n } from "~/composables/i18n"
import { useReadonlyStream } from "~/composables/stream"
import { getRESTCollection, restCollections$ } from "~/newstore/collections"
const t = useI18n()
const mySelectedCollectionID = ref<number | undefined>(undefined)
const hasSelectedCollectionID = computed(() => {
return mySelectedCollectionID.value !== undefined
})
const myCollections = useReadonlyStream(restCollections$, [])
const emit = defineEmits<{
(e: "importFromMyCollection", content: HoppCollection<HoppRESTRequest>): void
}>()
const fetchCollectionFromMyCollections = async () => {
if (mySelectedCollectionID.value === undefined) {
return
}
const collection = getRESTCollection(mySelectedCollectionID.value)
if (collection) {
emit("importFromMyCollection", collection)
}
}
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div class="space-y-4">
<p class="flex items-center">
<span
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary text-dividerDark"
:class="{
'!text-green-500': hasURL,
}"
>
<icon-lucide-check-circle class="svg-icons" />
</span>
<span>
{{ t(caption) }}
</span>
</p>
<p class="flex flex-col ml-10">
<input
v-model="inputChooseGistToImportFrom"
type="url"
class="input"
:placeholder="`${t('import.from_url')}`"
/>
</p>
<div>
<HoppButtonPrimary
class="w-full"
:label="t('import.title')"
:disabled="!hasURL"
:loading="isFetchingUrl"
@click="fetchUrlData"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "~/composables/toast"
import axios, { AxiosResponse } from "axios"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
caption: string
fetchLogic?: (url: string) => Promise<AxiosResponse<any>>
}>()
const emit = defineEmits<{
(e: "importFromURL", content: unknown): void
}>()
const inputChooseGistToImportFrom = ref<string>("")
const hasURL = ref(false)
const isFetchingUrl = ref(false)
watch(inputChooseGistToImportFrom, (url) => {
hasURL.value = !!url
})
const urlFetchLogic =
props.fetchLogic ??
async function (url: string) {
const res = await axios.get(url, {
transitional: {
forcedJSONParsing: false,
silentJSONParsing: false,
clarifyTimeoutError: true,
},
})
return res
}
async function fetchUrlData() {
isFetchingUrl.value = true
try {
const res = await urlFetchLogic(inputChooseGistToImportFrom.value)
if (res.status === 200) {
emit("importFromURL", res.data)
}
} catch (e) {
toast.error(t("import.failed"))
console.log(e)
} finally {
isFetchingUrl.value = false
}
}
</script>

View File

@@ -0,0 +1,23 @@
import { Component, Ref } from "vue"
import { defineStep } from "~/composables/step-components"
// TODO: move the metadata except disabled and isLoading to importers.ts
export type ImporterOrExporter = {
metadata: {
id: string
name: string
icon: any
title: string
disabled: boolean
applicableTo: Array<"personal-workspace" | "team-workspace" | "url-import">
isLoading?: Ref<boolean>
}
supported_sources?: {
id: string
name: string
icon: Component
step: ReturnType<typeof defineStep>
}[]
component?: ReturnType<typeof defineStep>
action?: (...args: any[]) => any
}

View File

@@ -5,6 +5,7 @@
:heading="t('error.network_fail')" :heading="t('error.network_fail')"
large large
> >
<template #body>
<div class="my-1 flex flex-col items-center text-secondaryLight"> <div class="my-1 flex flex-col items-center text-secondaryLight">
<span> <span>
{{ t("error.please_install_extension") }} {{ t("error.please_install_extension") }}
@@ -54,6 +55,7 @@
</HoppSmartToggle> </HoppSmartToggle>
</div> </div>
</div> </div>
</template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</template> </template>

View File

@@ -44,6 +44,39 @@
:icon="copyIcon" :icon="copyIcon"
@click="copyResponse" @click="copyResponse"
/> />
<tippy
v-if="response.body"
interactive
trigger="click"
theme="popover"
:on-shown="() => copyInterfaceTippyActions.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('app.copy_interface_type')"
:icon="IconMore"
/>
<template #content="{ hide }">
<div
ref="copyInterfaceTippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
v-for="(language, index) in interfaceLanguages"
:key="index"
:label="language"
:icon="
copiedInterfaceLanguage === language
? copyInterfaceIcon
: IconCopy
"
@click="runCopyInterface(language)"
/>
</div>
</template>
</tippy>
</div> </div>
</div> </div>
<div <div
@@ -201,7 +234,9 @@
<script setup lang="ts"> <script setup lang="ts">
import IconWrapText from "~icons/lucide/wrap-text" import IconWrapText from "~icons/lucide/wrap-text"
import IconFilter from "~icons/lucide/filter" import IconFilter from "~icons/lucide/filter"
import IconMore from "~icons/lucide/more-horizontal"
import IconHelpCircle from "~icons/lucide/help-circle" import IconHelpCircle from "~icons/lucide/help-circle"
import IconCopy from "~icons/lucide/copy"
import * as LJSON from "lossless-json" import * as LJSON from "lossless-json"
import * as O from "fp-ts/Option" import * as O from "fp-ts/Option"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
@@ -221,9 +256,11 @@ import {
useCopyResponse, useCopyResponse,
useResponseBody, useResponseBody,
useDownloadResponse, useDownloadResponse,
useCopyInterface,
} from "@composables/lens-actions" } from "@composables/lens-actions"
import { defineActionHandler } from "~/helpers/actions" import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils" import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import interfaceLanguages from "~/helpers/utils/interfaceLanguages"
const t = useI18n() const t = useI18n()
@@ -235,6 +272,13 @@ const { responseBodyText } = useResponseBody(props.response)
const toggleFilter = ref(false) const toggleFilter = ref(false)
const filterQueryText = ref("") const filterQueryText = ref("")
const copiedInterfaceLanguage = ref("")
const runCopyInterface = (language: string) => {
copyInterface(language).then(() => {
copiedInterfaceLanguage.value = language
})
}
type BodyParseError = type BodyParseError =
| { type: "JSON_PARSE_FAILED" } | { type: "JSON_PARSE_FAILED" }
@@ -269,9 +313,8 @@ const jsonResponseBodyText = computed(() => {
), ),
E.map(JSON.stringify) E.map(JSON.stringify)
) )
} else {
return E.right(responseBodyText.value)
} }
return E.right(responseBodyText.value)
}) })
const jsonBodyText = computed(() => const jsonBodyText = computed(() =>
@@ -319,6 +362,7 @@ const filterResponseError = computed(() =>
) )
const { copyIcon, copyResponse } = useCopyResponse(jsonBodyText) const { copyIcon, copyResponse } = useCopyResponse(jsonBodyText)
const { copyInterfaceIcon, copyInterface } = useCopyInterface(jsonBodyText)
const { downloadIcon, downloadResponse } = useDownloadResponse( const { downloadIcon, downloadResponse } = useDownloadResponse(
"application/json", "application/json",
jsonBodyText jsonBodyText
@@ -327,6 +371,7 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
// Template refs // Template refs
const tippyActions = ref<any | null>(null) const tippyActions = ref<any | null>(null)
const jsonResponse = ref<any | null>(null) const jsonResponse = ref<any | null>(null)
const copyInterfaceTippyActions = ref<any | null>(null)
const linewrapEnabled = ref(true) const linewrapEnabled = ref(true)
const { cursor } = useCodemirror( const { cursor } = useCodemirror(

View File

@@ -5,12 +5,11 @@ export default {
computed: { computed: {
responseBodyText() { responseBodyText() {
if (typeof this.response.body === "string") return this.response.body if (typeof this.response.body === "string") return this.response.body
else {
const res = new TextDecoder("utf-8").decode(this.response.body) const res = new TextDecoder("utf-8").decode(this.response.body)
// HACK: Temporary trailing null character issue from the extension fix // HACK: Temporary trailing null character issue from the extension fix
return res.replace(/\0+$/, "") return res.replace(/\0+$/, "")
}
}, },
}, },
} }

View File

@@ -1,122 +0,0 @@
<template>
<div
class="my-6 block w-full divide-y divide-dividerLight border border-dividerLight lg:my-0 lg:flex lg:divide-x lg:divide-y-0 lg:border-0"
>
<div class="table-box font-mono text-tiny">
{{ shortcode.id }}
</div>
<div class="table-box" :class="requestLabelColor">
{{ parseShortcodeRequest.method }}
</div>
<div class="table-box">
{{ parseShortcodeRequest.endpoint }}
</div>
<div ref="timeStampRef" class="table-box">
{{ dateStamp }}
</div>
<div class="table-box justify-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.open_workspace')"
:to="`${shortcodeBaseURL}/r/${shortcode.id}`"
blank
:icon="IconExternalLink"
class="px-3 text-accent hover:text-accent"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
color="green"
:icon="copyIconRefs"
class="px-3"
@click="copyShortcode(shortcode.id)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
:icon="IconTrash"
color="red"
class="px-3"
@click="deleteShortcode(shortcode.id)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue"
import { pipe } from "fp-ts/function"
import * as RR from "fp-ts/ReadonlyRecord"
import * as O from "fp-ts/Option"
import { translateToNewRequest } from "@hoppscotch/data"
import { refAutoReset } from "@vueuse/core"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { Shortcode } from "~/helpers/shortcodes/Shortcode"
import { shortDateTime } from "~/helpers/utils/date"
import IconTrash from "~icons/lucide/trash"
import IconExternalLink from "~icons/lucide/external-link"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
shortcode: Shortcode
}>()
const emit = defineEmits<{
(e: "delete-shortcode", codeID: string): void
}>()
const deleteShortcode = (codeID: string) => {
emit("delete-shortcode", codeID)
}
const requestMethodLabels = {
get: "text-green-500",
post: "text-yellow-500",
put: "text-blue-500",
delete: "text-red-500",
default: "text-gray-500",
} as const
const timeStampRef = ref()
const copyIconRefs = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const parseShortcodeRequest = computed(() =>
pipe(props.shortcode.request, JSON.parse, translateToNewRequest)
)
const requestLabelColor = computed(() =>
pipe(
requestMethodLabels,
RR.lookup(parseShortcodeRequest.value.method.toLowerCase()),
O.getOrElseW(() => requestMethodLabels.default)
)
)
const dateStamp = computed(() => shortDateTime(props.shortcode.createdOn))
const shortcodeBaseURL =
import.meta.env.VITE_SHORTCODE_BASE_URL ?? "https://hopp.sh"
const copyShortcode = (codeID: string) => {
copyToClipboard(`${shortcodeBaseURL}/r/${codeID}`)
toast.success(`${t("state.copied_to_clipboard")}`)
copyIconRefs.value = IconCheck
}
</script>
<style lang="scss" scoped>
.table-box {
@apply flex flex-1 items-center truncate px-4 py-1;
}
</style>

View File

@@ -1,168 +0,0 @@
<template>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.short_codes") }}
</h4>
<div class="my-1 text-secondaryLight">
{{ t("settings.short_codes_description") }}
</div>
<div class="relative overflow-x-auto py-4">
<div v-if="loading" class="flex flex-col items-center justify-center">
<HoppSmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<HoppSmartPlaceholder
v-if="!loading && myShortcodes.length === 0"
:src="`/images/states/${colorMode.value}/add_files.svg`"
:alt="`${t('empty.shortcodes')}`"
:text="t('empty.shortcodes')"
>
</HoppSmartPlaceholder>
<div v-else-if="!loading">
<div
class="hidden w-full rounded-t border-x border-t border-dividerLight bg-primaryLight lg:flex"
>
<div class="flex w-full overflow-y-scroll">
<div class="table-box">
{{ t("shortcodes.short_code") }}
</div>
<div class="table-box">
{{ t("shortcodes.method") }}
</div>
<div class="table-box">
{{ t("shortcodes.url") }}
</div>
<div class="table-box">
{{ t("shortcodes.created_on") }}
</div>
<div class="table-box justify-center">
{{ t("shortcodes.actions") }}
</div>
</div>
</div>
<div
class="flex max-h-sm w-full flex-col items-center justify-between divide-dividerLight overflow-y-scroll rounded border border-dividerLight lg:divide-y lg:rounded-t-none"
>
<ProfileShortcode
v-for="(shortcode, shortcodeIndex) in myShortcodes"
:key="`shortcode-${shortcodeIndex}`"
:shortcode="shortcode"
@delete-shortcode="deleteShortcode"
/>
<HoppSmartIntersection
v-if="hasMoreShortcodes && myShortcodes.length > 0"
@intersecting="loadMoreShortcodes()"
>
<div v-if="adapterLoading" class="flex flex-col items-center py-3">
<HoppSmartSpinner />
</div>
</HoppSmartIntersection>
</div>
</div>
<div
v-if="!loading && adapterError"
class="flex flex-col items-center py-4"
>
<icon-lucide-help-circle class="svg-icons mb-4" />
{{ getErrorMessage(adapterError) }}
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, watchEffect, computed } from "vue"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { GQLError } from "~/helpers/backend/GQLClient"
import { platform } from "~/platform"
import { onAuthEvent, onLoggedIn } from "@composables/auth"
import { useReadonlyStream } from "@composables/stream"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useColorMode } from "@composables/theming"
import { usePageHead } from "@composables/head"
import ShortcodeListAdapter from "~/helpers/shortcodes/ShortcodeListAdapter"
import { deleteShortcode as backendDeleteShortcode } from "~/helpers/backend/mutations/Shortcode"
const t = useI18n()
const toast = useToast()
const colorMode = useColorMode()
usePageHead({
title: computed(() => t("navigation.profile")),
})
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const displayName = ref(currentUser.value?.displayName)
watchEffect(() => (displayName.value = currentUser.value?.displayName))
const emailAddress = ref(currentUser.value?.email)
watchEffect(() => (emailAddress.value = currentUser.value?.email))
const adapter = new ShortcodeListAdapter(true)
const adapterLoading = useReadonlyStream(adapter.loading$, false)
const adapterError = useReadonlyStream(adapter.error$, null)
const myShortcodes = useReadonlyStream(adapter.shortcodes$, [])
const hasMoreShortcodes = useReadonlyStream(adapter.hasMoreShortcodes$, true)
const loading = computed(
() => adapterLoading.value && myShortcodes.value.length === 0
)
onLoggedIn(() => {
try {
adapter.initialize()
} catch (e) {}
})
onAuthEvent((ev) => {
if (ev.event === "logout" && adapter.isInitialized()) {
adapter.dispose()
return
}
})
const deleteShortcode = (codeID: string) => {
pipe(
backendDeleteShortcode(codeID),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
},
() => {
toast.success(`${t("shortcodes.deleted")}`)
}
)
)()
}
const loadMoreShortcodes = () => {
adapter.loadMore()
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "shortcode/not_found":
return t("shortcodes.not_found")
default:
return t("error.something_went_wrong")
}
}
}
</script>
<style lang="scss" scoped>
.table-box {
@apply flex flex-1 items-center truncate px-4 py-2;
}
</style>

View File

@@ -26,7 +26,7 @@
</div> </div>
<div <div
v-else-if="myTeams.length" v-else-if="myTeams.length"
class="flex flex-col space-y-2 rounded-lg border border-red-500 bg-error p-4 text-secondaryDark" class="bg-bannerInfo flex flex-col space-y-2 rounded-lg border border-red-500 p-4 text-secondaryDark"
> >
<h2 class="font-bold text-red-500"> <h2 class="font-bold text-red-500">
{{ t("error.danger_zone") }} {{ t("error.danger_zone") }}
@@ -45,7 +45,7 @@
</div> </div>
<div v-else> <div v-else>
<div <div
class="mb-4 flex flex-col space-y-2 rounded-lg border border-red-500 bg-error p-4 text-secondaryDark" class="bg-bannerInfo mb-4 flex flex-col space-y-2 rounded-lg border border-red-500 p-4 text-secondaryDark"
> >
<h2 class="font-bold text-red-500"> <h2 class="font-bold text-red-500">
{{ t("error.danger_zone") }} {{ t("error.danger_zone") }}
@@ -173,13 +173,8 @@ const deleteUserAccount = async () => {
const getErrorMessage = (err: GQLError<string>) => { const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") { if (err.type === "network_error") {
return t("error.network_error") return t("error.network_error")
} else { }
switch (err.error) {
case "shortcode/not_found":
return t("shortcodes.not_found")
default:
return t("error.something_went_wrong") return t("error.something_went_wrong")
} }
}
}
</script> </script>

View File

@@ -33,12 +33,12 @@
theme="popover" theme="popover"
:on-shown="() => tippyActions.focus()" :on-shown="() => tippyActions.focus()"
> >
<span class="select-wrapper"> <HoppSmartSelectWrapper>
<HoppButtonSecondary <HoppButtonSecondary
:label="contentType || t('state.none').toLowerCase()" :label="contentType || t('state.none').toLowerCase()"
class="ml-2 rounded-none pr-8" class="ml-2 rounded-none pr-8"
/> />
</span> </HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
ref="tippyActions" ref="tippyActions"
@@ -279,6 +279,7 @@ defineActionHandler("request.send-cancel", sendMessage)
:deep(.cm-panels) { :deep(.cm-panels) {
@apply top-upperSecondaryStickyFold #{!important}; @apply top-upperSecondaryStickyFold #{!important};
} }
.eventFeildShown :deep(.cm-panels), .eventFeildShown :deep(.cm-panels),
.cmResponsePrimaryStickyFold :deep(.cm-panels) { .cmResponsePrimaryStickyFold :deep(.cm-panels) {
@apply top-upperTertiaryStickyFold #{!important}; @apply top-upperTertiaryStickyFold #{!important};

View File

@@ -62,12 +62,12 @@
{{ t("mqtt.lw_qos") }} {{ t("mqtt.lw_qos") }}
</label> </label>
<tippy interactive trigger="click" theme="popover"> <tippy interactive trigger="click" theme="popover">
<span class="select-wrapper"> <HoppSmartSelectWrapper>
<HoppButtonSecondary <HoppButtonSecondary
class="ml-2 rounded-none pr-8" class="ml-2 rounded-none pr-8"
:label="`${config.lwQos}`" :label="`${config.lwQos}`"
/> />
</span> </HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div class="flex flex-col" role="menu"> <div class="flex flex-col" role="menu">
<HoppSmartItem <HoppSmartItem

View File

@@ -35,8 +35,8 @@
autoScrollEnabled ? t('action.turn_off') : t('action.turn_on') autoScrollEnabled ? t('action.turn_off') : t('action.turn_on')
}`" }`"
:icon="IconChevronsDown" :icon="IconChevronsDown"
:class="toggleAutoscrollColor" :color="autoScrollEnabled ? 'green' : 'red'"
@click="toggleAutoscroll()" @click="autoScrollEnabled = !autoScrollEnabled"
/> />
</div> </div>
</div> </div>
@@ -60,7 +60,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, PropType, computed, watch, Ref } from "vue" import { ref, PropType, watch, Ref } from "vue"
import IconTrash from "~icons/lucide/trash" import IconTrash from "~icons/lucide/trash"
import IconArrowUp from "~icons/lucide/arrow-up" import IconArrowUp from "~icons/lucide/arrow-up"
import IconArrowDown from "~icons/lucide/arrow-down" import IconArrowDown from "~icons/lucide/arrow-down"
@@ -123,12 +123,4 @@ watch(
}, 200), }, 200),
{ flush: "post" } { flush: "post" }
) )
const toggleAutoscroll = () => {
autoScrollEnabled.value = !autoScrollEnabled.value
}
const toggleAutoscrollColor = computed(() =>
autoScrollEnabled.value ? "text-green-500" : "text-red-500"
)
</script> </script>

View File

@@ -12,7 +12,7 @@
</div> </div>
<div <div
v-if="entry.ts !== undefined" v-if="entry.ts !== undefined"
class="w-34 hidden items-center px-1 sm:inline-flex" class="w-36 hidden items-center px-1 sm:inline-flex"
> >
<span <span
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@@ -269,12 +269,12 @@ const ast = computed(() =>
const editorText = computed(() => { const editorText = computed(() => {
if (selectedTab.value === "json") return jsonBodyText.value if (selectedTab.value === "json") return jsonBodyText.value
else return logPayload.value return logPayload.value
}) })
const editorMode = computed(() => { const editorMode = computed(() => {
if (selectedTab.value === "json") return "application/ld+json" if (selectedTab.value === "json") return "application/ld+json"
else return "text/plain" return "text/plain"
}) })
const { cursor } = useCodemirror( const { cursor } = useCodemirror(

View File

@@ -9,9 +9,9 @@
{{ t("mqtt.qos") }} {{ t("mqtt.qos") }}
</label> </label>
<tippy interactive trigger="click" theme="popover"> <tippy interactive trigger="click" theme="popover">
<span class="select-wrapper"> <HoppSmartSelectWrapper>
<HoppButtonSecondary class="pr-8" :label="`${QoS}`" /> <HoppButtonSecondary class="pr-8" :label="`${QoS}`" />
</span> </HoppSmartSelectWrapper>
<template #content="{ hide }"> <template #content="{ hide }">
<div class="flex flex-col" role="menu"> <div class="flex flex-col" role="menu">
<HoppSmartItem <HoppSmartItem

View File

@@ -0,0 +1,144 @@
<template>
<div
v-if="selectedWidget"
class="border divide-y rounded divide-divider border-divider"
>
<div v-if="loading" class="px-4 py-2">
{{ t("shared_requests.creating_widget") }}
</div>
<div v-else class="px-4 py-2">
{{ t("shared_requests.description") }}
</div>
<div class="flex flex-col divide-y divide-divider">
<div class="flex flex-col p-4 space-y-4">
<div
v-for="widget in widgets"
:key="widget.value"
class="flex flex-col p-4 border rounded cursor-pointer border-divider hover:bg-dividerLight"
:class="{
'!border-accentLight': selectedWidget.value === widget.value,
}"
@click="selectedWidget = widget"
>
<span class="mb-1 font-bold text-secondaryDark">
{{ widget.label }}
</span>
<span class="text-tiny">
{{ widget.info }}
</span>
</div>
</div>
<div class="flex flex-col items-center justify-center p-4">
<span
class="flex justify-center flex-1 mb-2 text-secondaryLight text-tiny"
>
{{ t("shared_requests.preview") }}
</span>
<div class="w-full">
<ShareTemplatesEmbeds
v-if="selectedWidget.value === 'embed'"
:endpoint="request?.endpoint"
:method="request?.method"
:model-value="embedOption"
/>
<ShareTemplatesButton
v-else-if="selectedWidget.value === 'button'"
img="badge.svg"
/>
<ShareTemplatesLink v-else />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { HoppRESTRequest } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core"
import { PropType, ref } from "vue"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
const props = defineProps({
request: {
type: Object as PropType<HoppRESTRequest | null>,
required: true,
},
modelValue: {
type: Object as PropType<Widget | null>,
default: null,
},
loading: {
type: Boolean,
default: false,
},
})
const selectedWidget = useVModel(props, "modelValue")
type WidgetID = "embed" | "button" | "link"
type Widget = {
value: WidgetID
label: string
info: string
}
const widgets: Widget[] = [
{
value: "embed",
label: t("shared_requests.embed"),
info: t("shared_requests.embed_info"),
},
{
value: "button",
label: t("shared_requests.button"),
info: t("shared_requests.button_info"),
},
{
value: "link",
label: t("shared_requests.link"),
info: t("shared_requests.link_info"),
},
]
type Tabs = "parameters" | "body" | "headers" | "authorization"
type EmbedOption = {
selectedTab: Tabs
tabs: {
value: Tabs
label: string
enabled: boolean
}[]
theme: "light" | "dark" | "system"
}
const embedOption = ref<EmbedOption>({
selectedTab: "parameters",
tabs: [
{
value: "parameters",
label: t("tab.parameters"),
enabled: true,
},
{
value: "body",
label: t("tab.body"),
enabled: true,
},
{
value: "headers",
label: t("tab.headers"),
enabled: true,
},
{
value: "authorization",
label: t("tab.authorization"),
enabled: false,
},
],
theme: "system",
})
</script>

View File

@@ -0,0 +1,432 @@
<template>
<div
v-if="selectedWidget"
class="border divide-y rounded divide-divider border-divider"
>
<div v-if="loading" class="px-4 py-2">
{{ t("shared_requests.creating_widget") }}
</div>
<div v-else class="px-4 py-2">
{{ t("shared_requests.customize") }}
</div>
<div v-if="loading" class="flex flex-col items-center justify-center p-4">
<HoppSmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div v-else class="flex flex-col divide-y divide-divider">
<div class="flex flex-col p-2 space-y-2">
<HoppSmartRadioGroup
v-model="selectedWidget.value"
:radios="widgets"
class="flex !flex-row"
/>
</div>
<div class="flex flex-col divide-y divide-divider">
<div class="flex items-center justify-center px-6 py-4">
<div v-if="selectedWidget.value === 'embed'" class="w-full">
<div class="flex flex-col pb-4">
<div
v-for="option in embedOptions.tabs"
:key="option.value"
class="flex justify-between py-2"
>
<span class="capitalize">
{{ option.label }}
</span>
<HoppSmartCheckbox
:on="option.enabled"
@change="removeEmbedOption(option.value)"
>
</HoppSmartCheckbox>
</div>
<div class="flex items-center justify-between">
<span>
{{ t("shared_requests.theme.title") }}
</span>
<div>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<HoppButtonSecondary
class="!py-2 !px-0 capitalize"
:label="embedOptions.theme"
:icon="embedThemeIcon"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:label="t('shared_requests.theme.system')"
:icon="IconMonitor"
:active="embedOptions.theme === 'system'"
@click="
() => {
embedOptions.theme = 'system'
hide()
}
"
/>
<HoppSmartItem
:label="t('shared_requests.theme.light')"
:icon="IconSun"
:active="embedOptions.theme === 'light'"
@click="
() => {
embedOptions.theme = 'light'
hide()
}
"
/>
<HoppSmartItem
:label="t('shared_requests.theme.dark')"
:icon="IconMoon"
:active="embedOptions.theme === 'dark'"
@click="
() => {
embedOptions.theme = 'dark'
hide()
}
"
/>
</div>
</template>
</tippy>
</div>
</div>
</div>
<span
class="flex justify-center mb-2 text-secondaryLight text-tiny"
>
{{ t("shared_requests.preview") }}
</span>
<ShareTemplatesEmbeds
:endpoint="request?.endpoint"
:method="request?.method"
:model-value="embedOptions"
/>
<div class="flex items-center justify-center">
<HoppButtonSecondary
:label="t('shared_requests.copy_html')"
class="underline text-secondaryDark"
@click="
copyContent({
widget: 'embed',
type: 'html',
})
"
/>
</div>
</div>
<div
v-else-if="selectedWidget.value === 'button'"
class="flex flex-col space-y-8"
>
<div
v-for="variant in buttonVariants"
:key="variant.id"
class="flex flex-col"
>
<span
class="flex justify-center mb-2 text-secondaryLight text-tiny"
>
{{ t("shared_requests.preview") }}
</span>
<ShareTemplatesButton :img="variant.img" />
<div class="flex items-center justify-between">
<HoppButtonSecondary
:label="t('shared_requests.copy_html')"
class="underline text-secondaryDark"
@click="
copyContent({
widget: 'button',
type: 'html',
id: variant.id,
})
"
/>
<HoppButtonSecondary
:label="t('shared_requests.copy_markdown')"
class="underline text-secondaryDark"
@click="
copyContent({
widget: 'button',
type: 'markdown',
id: variant.id,
})
"
/>
</div>
</div>
</div>
<div v-else class="flex flex-col space-y-8">
<div
v-for="variant in linkVariants"
:key="variant.type"
class="flex flex-col items-center justify-center"
>
<span
class="flex justify-center mb-2 text-secondaryLight text-tiny"
>
{{ t("shared_requests.preview") }}
</span>
<ShareTemplatesLink :link="variant.link" :label="variant.label" />
<HoppButtonSecondary
:label="t(`shared_requests.copy_${variant.type}`)"
class="underline text-secondaryDark"
@click="
copyContent({
widget: 'link',
type: variant.type,
id: variant.id,
})
"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useVModel } from "@vueuse/core"
import { computed, ref } from "vue"
import { PropType } from "vue"
import { useI18n } from "~/composables/i18n"
import IconMonitor from "~icons/lucide/monitor"
import IconSun from "~icons/lucide/sun"
import IconMoon from "~icons/lucide/moon"
import { TippyComponent } from "vue-tippy"
import { HoppRESTRequest } from "@hoppscotch/data"
const t = useI18n()
const props = defineProps({
request: {
type: Object as PropType<HoppRESTRequest | null>,
required: true,
},
modelValue: {
type: Object as PropType<Widget | null>,
default: null,
},
loading: {
type: Boolean,
default: false,
},
embedOptions: {
type: Object as PropType<EmbedOption>,
default: () => ({
selectedTab: "parameters",
tabs: [
{
value: "parameters",
label: "shared_requests.parameters",
enabled: true,
},
{
value: "body",
label: "shared_requests.body",
enabled: true,
},
{
value: "headers",
label: "shared_requests.headers",
enabled: true,
},
{
value: "authorization",
label: "shared_requests.authorization",
enabled: false,
},
],
theme: "system",
}),
},
})
const emit = defineEmits<{
(
e: "copy-shared-request",
request: {
sharedRequestID: string | undefined
content: string | undefined
}
): void
(e: "hide-modal"): void
(e: "update:modelValue", value: string): void
}>()
const selectedWidget = useVModel(props, "modelValue")
const embedOptions = useVModel(props, "embedOptions")
type WidgetID = "embed" | "button" | "link"
type Widget = {
value: WidgetID
label: string
}
const widgets: Widget[] = [
{
value: "embed",
label: t("shared_requests.embed"),
},
{
value: "button",
label: t("shared_requests.button"),
},
{
value: "link",
label: t("shared_requests.link"),
},
]
type EmbedTabs = "parameters" | "body" | "headers" | "authorization"
type EmbedOption = {
selectedTab: EmbedTabs
tabs: {
value: EmbedTabs
label: string
enabled: boolean
}[]
theme: "light" | "dark" | "system"
}
const embedThemeIcon = computed(() => {
if (embedOptions.value.theme === "system") {
return IconMonitor
} else if (embedOptions.value.theme === "light") {
return IconSun
}
return IconMoon
})
const removeEmbedOption = (option: EmbedTabs) => {
const index = embedOptions.value.tabs.findIndex((tab) => tab.value === option)
if (index === -1) return
//if removed tab is the selected tab, select the next tab with enabled true
if (embedOptions.value.selectedTab === option) {
const nextTab = embedOptions.value.tabs.find((tab) => tab.enabled)
if (nextTab) {
embedOptions.value.selectedTab = nextTab.value
}
}
embedOptions.value.tabs[index].enabled =
!embedOptions.value.tabs[index].enabled
}
type ButtonVariant = {
id: string
img: string
}
const buttonVariants: ButtonVariant[] = [
{
id: "button1",
img: "badge.svg",
},
{
id: "button2",
img: "badge-light.svg",
},
{
id: "button3",
img: "badge-dark.svg",
},
]
type LinkVariant = {
id: string
link?: string
label?: string
type: "html" | "markdown" | "link"
}
const linkVariants: LinkVariant[] = [
{
id: "link1",
link: props.request?.id,
type: "link",
},
{
id: "link2",
label: "shared_requests.run_in_hoppscotch",
type: "html",
},
{
id: "link3",
label: "shared_requests.run_in_hoppscotch",
type: "markdown",
},
]
const baseURL = import.meta.env.VITE_SHORTCODE_BASE_URL ?? "https://hopp.sh"
const copyEmbed = () => {
return `<iframe src="${baseURL}/e/${props.request?.id}' style='width: 100%; height: 500px; border: 0; border-radius: 4px; overflow: hidden;'></iframe>`
}
const copyButton = (
variationID: string,
type: "html" | "markdown" | "link"
) => {
let badge = ""
if (variationID === "button1") {
badge = "badge.svg"
} else if (variationID === "button2") {
badge = "badge-light.svg"
} else {
badge = "badge-dark.svg"
}
if (type === "markdown") {
return `[![Run in Hoppscotch](${baseURL}/${badge})](${baseURL}/r/${props.request?.id})`
}
return `<a href="${baseURL}/r/${props.request?.id}"><img src="${baseURL}/${badge}" alt="Run in Hoppscotch" /></a>`
}
const copyLink = (variationID: string) => {
if (variationID === "link1") {
return `${baseURL}/r/${props.request?.id}`
} else if (variationID === "link2") {
return `<a href="${baseURL}/r/${props.request?.id}">Run in Hoppscotch</a>`
}
return `[Run in Hoppscotch](${baseURL}/r/${props.request?.id})`
}
const copyContent = ({
id,
widget,
type,
}: {
id?: string | undefined
widget: WidgetID
type: "html" | "markdown" | "link"
}) => {
let content = ""
if (widget === "button") {
content = copyButton(id!, type)
} else if (widget === "link") {
content = copyLink(id!)
} else {
content = copyEmbed()
}
const copyContent = {
sharedRequestID: props.request?.id,
content,
}
emit("copy-shared-request", copyContent)
}
const tippyActions = ref<TippyComponent | null>(null)
</script>

View File

@@ -0,0 +1,168 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="
step === 1 ? t('modal.share_request') : t('modal.customize_request')
"
styles="sm:max-w-md"
@close="hideModal"
>
<template #body>
<div v-if="loading" class="flex flex-col items-center justify-center p-4">
<HoppSmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<ShareCreateModal
v-else-if="step === 1"
v-model="selectedWidget"
:request="request"
:loading="loading"
@create-shared-request="createSharedRequest"
/>
<ShareCustomizeModal
v-else-if="step === 2"
v-model="selectedWidget"
v-model:embed-options="embedOptions"
:request="request"
:loading="loading"
@copy-shared-request="copySharedRequest"
/>
</template>
<template v-if="step === 1" #footer>
<div class="flex justify-start flex-1">
<HoppButtonPrimary
:label="t('action.create')"
:loading="loading"
@click="createSharedRequest"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
class="ml-2"
filled
outline
@click="hideModal"
/>
</div>
</template>
</HoppSmartModal>
</template>
<script lang="ts" setup>
import { HoppRESTRequest } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core"
import { PropType } from "vue"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
type EmbedTabs = "parameters" | "body" | "headers" | "authorization"
type EmbedOption = {
selectedTab: EmbedTabs
tabs: {
value: EmbedTabs
label: string
enabled: boolean
}[]
theme: "light" | "dark" | "system"
}
const props = defineProps({
request: {
type: Object as PropType<HoppRESTRequest | null>,
required: true,
},
show: {
type: Boolean,
default: false,
required: true,
},
modelValue: {
type: Object as PropType<Widget | null>,
default: null,
},
loading: {
type: Boolean,
default: false,
},
step: {
type: Number,
default: 1,
},
embedOptions: {
type: Object as PropType<EmbedOption>,
default: () => ({
selectedTab: "parameters",
tabs: [
{
value: "parameters",
label: "shared_requests.parameters",
enabled: true,
},
{
value: "body",
label: "shared_requests.body",
enabled: true,
},
{
value: "headers",
label: "shared_requests.headers",
enabled: true,
},
{
value: "authorization",
label: "shared_requests.authorization",
enabled: false,
},
],
theme: "system",
}),
},
})
type WidgetID = "embed" | "button" | "link"
type Widget = {
value: WidgetID
label: string
info: string
}
const selectedWidget = useVModel(props, "modelValue")
const embedOptions = useVModel(props, "embedOptions")
const emit = defineEmits<{
(e: "create-shared-request", request: HoppRESTRequest | null): void
(e: "hide-modal"): void
(e: "update:modelValue", value: string): void
(e: "update:step", value: number): void
(
e: "copy-shared-request",
payload: {
sharedRequestID: string | undefined
content: string | undefined
}
): void
}>()
const createSharedRequest = () => {
emit("create-shared-request", props.request as HoppRESTRequest)
}
const copySharedRequest = (payload: {
sharedRequestID: string | undefined
content: string | undefined
}) => {
emit("copy-shared-request", payload)
}
const hideModal = () => {
emit("hide-modal")
selectedWidget.value = {
value: "embed",
label: t("shared_requests.embed"),
info: t("shared_requests.embed_info"),
}
}
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div
class="flex items-stretch group"
@contextmenu.prevent="options!.tippy.show()"
>
<div
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
class="flex items-center justify-center flex-1 min-w-0 py-2 cursor-pointer pointer-events-auto"
:title="`${timeStamp}`"
@click="openInNewTab"
>
<span
class="flex items-center justify-center w-16 px-2 truncate pointer-events-none"
:style="{ color: requestLabelColor }"
>
<span class="font-semibold truncate text-tiny">
{{ parseRequest.method }}
</span>
</span>
<span
class="flex items-center flex-1 min-w-0 pr-2 transition pointer-events-none group-hover:text-secondaryDark"
>
<span class="flex-1 truncate">
{{ parseRequest.endpoint }}
</span>
</span>
<span
class="flex px-2 truncate text-secondaryLight group-hover:text-secondaryDark"
>
{{ parseRequest.name }}
</span>
</div>
<div>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
role="menu"
@keyup.t="openInNewTabAction?.$el.click()"
@keyup.e="customizeAction?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
ref="openInNewTabAction"
:icon="IconArrowUpRight"
:label="`${t('shared_requests.open_new_tab')}`"
:shortcut="['T']"
@click="
() => {
openInNewTab()
hide()
}
"
/>
<HoppSmartItem
ref="customizeAction"
:icon="IconCustomize"
:label="`${t('shared_requests.customize')}`"
:shortcut="['E']"
@click="
() => {
customizeSharedRequest()
hide()
}
"
/>
<HoppSmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="`${t('action.delete')}`"
:shortcut="['⌫']"
@click="
() => {
deleteSharedRequest()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { HoppRESTRequest, translateToNewRequest } from "@hoppscotch/data"
import { pipe } from "fp-ts/lib/function"
import { ref } from "vue"
import { computed } from "vue"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "~/composables/i18n"
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
import { Shortcode } from "~/helpers/shortcode/Shortcode"
import IconArrowUpRight from "~icons/lucide/arrow-up-right-square"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconCustomize from "~icons/lucide/settings-2"
import IconTrash2 from "~icons/lucide/trash-2"
import { shortDateTime } from "~/helpers/utils/date"
const t = useI18n()
const props = defineProps<{
request: Shortcode
}>()
const emit = defineEmits<{
(
e: "customize-shared-request",
request: HoppRESTRequest,
id: string,
embedProperties?: string | null
): void
(e: "delete-shared-request", codeID: string): void
(e: "open-new-tab", request: HoppRESTRequest): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const openInNewTabAction = ref<HTMLButtonElement | null>(null)
const customizeAction = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const options = ref<any | null>(null)
const parseRequest = computed(() =>
pipe(props.request.request, JSON.parse, translateToNewRequest)
)
const requestLabelColor = computed(() =>
getMethodLabelColorClassOf(parseRequest.value)
)
const openInNewTab = () => {
emit("open-new-tab", parseRequest.value)
}
const customizeSharedRequest = () => {
const embedProperties = props.request.properties
emit(
"customize-shared-request",
parseRequest.value,
props.request.id,
embedProperties
)
}
const deleteSharedRequest = () => {
emit("delete-shared-request", props.request.id)
}
const timeStamp = computed(() => shortDateTime(props.request.createdOn))
</script>

View File

@@ -0,0 +1,464 @@
<template>
<div>
<div
class="sticky top-0 z-10 flex flex-shrink-0 flex-col overflow-x-auto bg-primary"
>
<WorkspaceCurrent
:section="t('tab.shared_requests')"
:is-only-personal="true"
/>
</div>
<div
class="sticky top-sidebarPrimaryStickyFold z-10 flex flex-1 flex-shrink-0 justify-end overflow-x-auto border-b border-dividerLight bg-primary"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/shared-request"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
class="py-2"
/>
</div>
<div class="flex flex-col">
<div v-if="loading" class="flex flex-col items-center justify-center">
<HoppSmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<HoppSmartPlaceholder
v-else-if="!currentUser"
:src="`/images/states/${colorMode.value}/add_files.svg`"
:alt="`${t('empty.shared_requests_logout')}`"
:text="`${t('empty.shared_requests_logout')}`"
>
<template #body>
<HoppButtonPrimary
:label="t('auth.login')"
@click="invokeAction('modals.login.toggle')"
/>
</template>
</HoppSmartPlaceholder>
<template v-else-if="sharedRequests.length">
<ShareRequest
v-for="request in sharedRequests"
:key="request.id"
:request="request"
@customize-shared-request="customizeSharedRequest"
@delete-shared-request="deleteSharedRequest"
@open-new-tab="openInNewTab"
/>
<HoppSmartIntersection
v-if="hasMoreSharedRequests"
@intersecting="loadMoreSharedRequests"
>
<div v-if="adapterLoading" class="flex flex-col items-center py-3">
<HoppSmartSpinner />
</div>
</HoppSmartIntersection>
</template>
<div v-else-if="adapterError" class="flex flex-col items-center py-4">
<icon-lucide-help-circle class="svg-icons mb-4" />
{{ getErrorMessage(adapterError) }}
</div>
<HoppSmartPlaceholder
v-else
:src="`/images/states/${colorMode.value}/add_files.svg`"
:alt="`${t('empty.shared_requests')}`"
:text="t('empty.shared_requests')"
@drop.stop
/>
</div>
</div>
<HoppSmartConfirmModal
:show="showConfirmModal"
:title="confirmModalTitle"
:loading-state="modalLoadingState"
@hide-modal="showConfirmModal = false"
@resolve="resolveConfirmModal"
/>
<ShareModal
v-model="selectedWidget"
v-model:embed-options="embedOptions"
:step="step"
:request="requestToShare"
:show="showShareRequestModal"
:loading="shareRequestCreatingLoading"
@hide-modal="displayCustomizeRequestModal(false, null)"
@copy-shared-request="copySharedRequest"
@create-shared-request="createSharedRequest"
/>
</template>
<script lang="ts" setup>
import IconHelpCircle from "~icons/lucide/help-circle"
import { useI18n } from "~/composables/i18n"
import ShortcodeListAdapter from "~/helpers/shortcode/ShortcodeListAdapter"
import { useReadonlyStream } from "~/composables/stream"
import { onAuthEvent, onLoggedIn } from "~/composables/auth"
import { computed } from "vue"
import { useColorMode } from "~/composables/theming"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { platform } from "~/platform"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import {
deleteShortcode as backendDeleteShortcode,
createShortcode,
updateEmbedProperties,
} from "~/helpers/backend/mutations/Shortcode"
import { GQLError } from "~/helpers/backend/GQLClient"
import { useToast } from "~/composables/toast"
import { ref } from "vue"
import { HoppRESTRequest } from "@hoppscotch/data"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import * as E from "fp-ts/Either"
import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
import { watch } from "vue"
const t = useI18n()
const colorMode = useColorMode()
const toast = useToast()
const showConfirmModal = ref(false)
const confirmModalTitle = ref("")
const modalLoadingState = ref(false)
const showShareRequestModal = ref(false)
const sharedRequestID = ref("")
const shareRequestCreatingLoading = ref(false)
const requestToShare = ref<HoppRESTRequest | null>(null)
const embedOptions = ref<EmbedOption>({
selectedTab: "parameters",
tabs: [
{
value: "parameters",
label: t("tab.parameters"),
enabled: false,
},
{
value: "body",
label: t("tab.body"),
enabled: false,
},
{
value: "headers",
label: t("tab.headers"),
enabled: false,
},
{
value: "authorization",
label: t("tab.authorization"),
enabled: false,
},
],
theme: "system",
})
const updateEmbedProperty = async (
shareRequestID: string,
properties: string
) => {
const customizeEmbedResult = await updateEmbedProperties(
shareRequestID,
properties
)()
if (E.isLeft(customizeEmbedResult)) {
toast.error(`${customizeEmbedResult.left.error}`)
toast.error(t("error.something_went_wrong"))
}
}
watch(
() => embedOptions.value,
() => {
if (
requestToShare.value &&
requestToShare.value.id &&
showShareRequestModal.value
) {
if (selectedWidget.value.value === "embed") {
const properties = {
options: embedOptions.value.tabs
.filter((tab) => tab.enabled)
.map((tab) => tab.value),
theme: embedOptions.value.theme,
}
updateEmbedProperty(requestToShare.value.id, JSON.stringify(properties))
}
}
},
{ deep: true }
)
const restTab = useService(RESTTabService)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const step = ref(1)
type EmbedTabs = "parameters" | "body" | "headers" | "authorization"
type EmbedOption = {
selectedTab: EmbedTabs
tabs: {
value: EmbedTabs
label: string
enabled: boolean
}[]
theme: "light" | "dark" | "system"
}
type WidgetID = "embed" | "button" | "link"
type Widget = {
value: WidgetID
label: string
info: string
}
const selectedWidget = ref<Widget>({
value: "embed",
label: t("shared_requests.embed"),
info: t("shared_requests.embed_info"),
})
const adapter = new ShortcodeListAdapter(true)
const adapterLoading = useReadonlyStream(adapter.loading$, false)
const adapterError = useReadonlyStream(adapter.error$, null)
const sharedRequests = useReadonlyStream(adapter.shortcodes$, [])
const hasMoreSharedRequests = useReadonlyStream(
adapter.hasMoreShortcodes$,
true
)
const loading = computed(
() => adapterLoading.value && sharedRequests.value.length === 0
)
onLoggedIn(() => {
try {
adapter.initialize()
} catch (e) {
console.error(e)
}
})
onAuthEvent((ev) => {
if (ev.event === "logout" && adapter.isInitialized()) {
adapter.dispose()
return
}
})
const deleteSharedRequest = (codeID: string) => {
if (currentUser.value) {
sharedRequestID.value = codeID
confirmModalTitle.value = `${t("confirm.remove_shared_request")}`
showConfirmModal.value = true
} else {
invokeAction("modals.login.toggle")
}
}
const onDeleteSharedRequest = () => {
modalLoadingState.value = true
pipe(
backendDeleteShortcode(sharedRequestID.value),
TE.match(
(err: GQLError<string>) => {
toast.error(getErrorMessage(err))
showConfirmModal.value = false
},
() => {
toast.success(t("shared_requests.deleted"))
sharedRequestID.value = ""
modalLoadingState.value = false
showConfirmModal.value = false
}
)
)()
}
const loadMoreSharedRequests = () => {
adapter.loadMore()
}
const displayShareRequestModal = (show: boolean) => {
showShareRequestModal.value = show
step.value = 1
}
const displayCustomizeRequestModal = (
show: boolean,
embedProperties?: string | null
) => {
showShareRequestModal.value = show
step.value = 2
if (!embedProperties) {
selectedWidget.value = {
value: "button",
label: t("shared_requests.button"),
info: t("shared_requests.button_info"),
}
embedOptions.value = {
selectedTab: "parameters",
tabs: [
{
value: "parameters",
label: t("tab.parameters"),
enabled: false,
},
{
value: "body",
label: t("tab.body"),
enabled: false,
},
{
value: "headers",
label: t("tab.headers"),
enabled: false,
},
{
value: "authorization",
label: t("tab.authorization"),
enabled: false,
},
],
theme: "system",
}
} else {
const parsedEmbedProperties = JSON.parse(embedProperties)
embedOptions.value = {
selectedTab: parsedEmbedProperties.options[0],
tabs: embedOptions.value.tabs.map((tab) => {
return {
...tab,
enabled: parsedEmbedProperties.options.includes(tab.value),
}
}),
theme: parsedEmbedProperties.theme,
}
}
}
const createSharedRequest = async (request: HoppRESTRequest | null) => {
if (request && selectedWidget.value) {
const properties = {
options: ["parameters", "body", "headers"],
theme: "system",
}
shareRequestCreatingLoading.value = true
const sharedRequestResult = await createShortcode(
request,
selectedWidget.value.value === "embed"
? JSON.stringify(properties)
: undefined
)()
platform.analytics?.logEvent({
type: "HOPP_SHORTCODE_CREATED",
})
if (E.isLeft(sharedRequestResult)) {
toast.error(`${sharedRequestResult.left.error}`)
toast.error(t("error.something_went_wrong"))
} else if (E.isRight(sharedRequestResult)) {
if (sharedRequestResult.right.createShortcode) {
shareRequestCreatingLoading.value = false
requestToShare.value = {
...JSON.parse(sharedRequestResult.right.createShortcode.request),
id: sharedRequestResult.right.createShortcode.id,
}
step.value = 2
if (sharedRequestResult.right.createShortcode.properties) {
const parsedEmbedProperties = JSON.parse(
sharedRequestResult.right.createShortcode.properties
)
embedOptions.value = {
selectedTab: parsedEmbedProperties.options[0],
tabs: embedOptions.value.tabs.map((tab) => {
return {
...tab,
enabled: parsedEmbedProperties.options.includes(tab.value),
}
}),
theme: parsedEmbedProperties.theme,
}
}
}
}
}
}
const customizeSharedRequest = (
request: HoppRESTRequest,
shredRequestID: string,
embedProperties?: string | null
) => {
requestToShare.value = {
...request,
id: shredRequestID,
}
displayCustomizeRequestModal(true, embedProperties)
}
const copySharedRequest = (payload: {
sharedRequestID: string | undefined
content: string | undefined
}) => {
if (payload.content) {
copyToClipboard(payload.content)
toast.success(t("state.copied_to_clipboard"))
}
}
const openInNewTab = (request: HoppRESTRequest) => {
restTab.createNewTab({
isDirty: false,
request,
})
}
const resolveConfirmModal = (title: string | null) => {
if (title === `${t("confirm.remove_shared_request")}`) onDeleteSharedRequest()
else {
console.error(
`Confirm modal title ${title} is not handled by the component`
)
toast.error(t("error.something_went_wrong"))
showConfirmModal.value = false
sharedRequestID.value = ""
}
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
}
switch (err.error) {
case "shortcode/not_found":
return t("shared_request.not_found")
default:
return t("error.something_went_wrong")
}
}
defineActionHandler("share.request", ({ request }) => {
requestToShare.value = request
displayShareRequestModal(true)
})
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="flex flex-col items-center p-4 border rounded border-dividerDark">
<img :src="img" :alt="t('shared_requests.run_in_hoppscotch')" />
</div>
</template>
<script lang="ts" setup>
import { useI18n } from "~/composables/i18n"
defineProps<{
img: string
}>()
const t = useI18n()
</script>

View File

@@ -0,0 +1,102 @@
<template>
<div
class="flex flex-col p-4 border rounded border-dividerDark"
:class="{
'bg-accentContrast': isEmbedThemeLight,
}"
>
<div
class="flex items-stretch space-x-2"
:class="{
'bg-accentContrast': isEmbedThemeLight,
}"
>
<span
class="flex items-center flex-1 min-w-0 border rounded border-divider"
>
<span
class="flex max-w-[4rem] rounded-l h-full items-center justify-center border-r border-divider text-tiny"
:class="{
'!border-dividerLight bg-accentContrast text-primary':
isEmbedThemeLight,
}"
>
<span class="px-3 truncate">
{{ method }}
</span>
</span>
<span
class="px-3 truncate"
:class="{
'text-primary': isEmbedThemeLight,
}"
>
{{ endpoint }}
</span>
</span>
<button
class="flex items-center justify-center flex-shrink-0 px-3 py-2 font-semibold border rounded border-dividerDark bg-primaryDark text-secondary"
:class="{
'!bg-accentContrast text-primaryLight': isEmbedThemeLight,
}"
>
{{ t("action.send") }}
</button>
</div>
<div
class="flex"
:class="{
'bg-accentContrast text-primary': isEmbedThemeLight,
'border-b border-divider pt-2': !noActiveTab,
}"
>
<span
v-for="option in embedOptions.tabs"
v-show="option.enabled"
:key="option.value"
class="px-2 py-2"
:class="{
'border-b border-dividerDark':
embedOptions.tabs.filter((tab) => tab.enabled)[0]?.value ===
option.value,
}"
>
{{ option.label }}
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { useVModel } from "@vueuse/core"
import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
type Tabs = "parameters" | "body" | "headers" | "authorization"
type EmbedOption = {
selectedTab: Tabs
tabs: {
value: Tabs
label: string
enabled: boolean
}[]
theme: "light" | "dark" | "system"
}
const props = defineProps<{
method: string | undefined
endpoint: string | undefined
modelValue: EmbedOption
}>()
const embedOptions = useVModel(props, "modelValue")
const t = useI18n()
const noActiveTab = computed(() => {
return embedOptions.value.tabs.every((tab) => !tab.enabled)
})
const isEmbedThemeLight = computed(() => embedOptions.value.theme === "light")
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="flex flex-col items-center p-4 border rounded border-dividerDark">
<span
:class="{
'border-b border-secondary': label,
}"
>
{{ text }}
</span>
</div>
</template>
<script lang="ts" setup>
import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
const t = useI18n()
const props = defineProps<{
link?: string | undefined
label?: string | undefined
}>()
const text = computed(() => {
return props.label ? t(props.label) : `hopp.sh/r/${props.link ?? "xxxx"}`
})
</script>

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