Merge remote-tracking branch 'hoppscotch/hoppscotch/main'
This commit is contained in:
41
.github/workflows/deploy-netlify-ui.yml
vendored
Normal file
41
.github/workflows/deploy-netlify-ui.yml
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
name: Deploy to Netlify (ui)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
# run this workflow only if an update is made to the ui package
|
||||||
|
paths:
|
||||||
|
- "packages/hoppscotch-ui/**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup environment
|
||||||
|
run: mv .env.example .env
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v2.2.4
|
||||||
|
with:
|
||||||
|
version: 7
|
||||||
|
run_install: true
|
||||||
|
|
||||||
|
- name: Setup node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Build site
|
||||||
|
run: pnpm run generate-ui
|
||||||
|
|
||||||
|
# Deploy the ui site with netlify-cli
|
||||||
|
- name: Deploy to Netlify (ui)
|
||||||
|
run: npx netlify-cli deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
|
||||||
|
env:
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
[[headers]]
|
[[headers]]
|
||||||
for = "/*"
|
for = "/*"
|
||||||
[headers.values]
|
[headers.values]
|
||||||
X-Frame-Options = "DENY"
|
X-Frame-Options = "SAMEORIGIN"
|
||||||
X-XSS-Protection = "1; mode=block"
|
X-XSS-Protection = "1; mode=block"
|
||||||
|
|
||||||
[[redirects]]
|
[[redirects]]
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
"typecheck": "pnpm -r do-typecheck",
|
"typecheck": "pnpm -r do-typecheck",
|
||||||
"lintfix": "pnpm -r do-lintfix",
|
"lintfix": "pnpm -r do-lintfix",
|
||||||
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
|
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
|
||||||
"test": "pnpm -r do-test"
|
"test": "pnpm -r do-test",
|
||||||
|
"generate-ui": "pnpm -r do-build-ui"
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./packages/*"
|
"./packages/*"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@hoppscotch/cli",
|
"name": "@hoppscotch/cli",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
|
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
|
||||||
"homepage": "https://hoppscotch.io",
|
"homepage": "https://hoppscotch.io",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -5,42 +5,52 @@ import { execAsync, getErrorCode, getTestJsonFilePath } from "../utils";
|
|||||||
describe("Test 'hopp test <file>' command:", () => {
|
describe("Test 'hopp test <file>' command:", () => {
|
||||||
test("No collection file path provided.", async () => {
|
test("No collection file path provided.", async () => {
|
||||||
const cmd = `node ./bin/hopp test`;
|
const cmd = `node ./bin/hopp test`;
|
||||||
const { stdout } = await execAsync(cmd);
|
const { stderr } = await execAsync(cmd);
|
||||||
const out = getErrorCode(stdout);
|
const out = getErrorCode(stderr);
|
||||||
|
|
||||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Collection file not found.", async () => {
|
test("Collection file not found.", async () => {
|
||||||
const cmd = `node ./bin/hopp test notfound.json`;
|
const cmd = `node ./bin/hopp test notfound.json`;
|
||||||
const { stdout } = await execAsync(cmd);
|
const { stderr } = await execAsync(cmd);
|
||||||
const out = getErrorCode(stdout);
|
const out = getErrorCode(stderr);
|
||||||
|
|
||||||
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
|
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Malformed collection file.", async () => {
|
test("Collection file is invalid JSON.", async () => {
|
||||||
const cmd = `node ./bin/hopp test ${getTestJsonFilePath(
|
const cmd = `node ./bin/hopp test ${getTestJsonFilePath(
|
||||||
"malformed-collection.json"
|
"malformed-collection.json"
|
||||||
)}`;
|
)}`;
|
||||||
const { stdout } = await execAsync(cmd);
|
const { stderr } = await execAsync(cmd);
|
||||||
const out = getErrorCode(stdout);
|
const out = getErrorCode(stderr);
|
||||||
|
|
||||||
|
expect(out).toBe<HoppErrorCode>("UNKNOWN_ERROR");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Malformed collection file.", async () => {
|
||||||
|
const cmd = `node ./bin/hopp test ${getTestJsonFilePath(
|
||||||
|
"malformed-collection2.json"
|
||||||
|
)}`;
|
||||||
|
const { stderr } = await execAsync(cmd);
|
||||||
|
const out = getErrorCode(stderr);
|
||||||
|
|
||||||
expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION");
|
expect(out).toBe<HoppErrorCode>("MALFORMED_COLLECTION");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Invalid arguement.", async () => {
|
test("Invalid arguement.", async () => {
|
||||||
const cmd = `node ./bin/hopp invalid-arg`;
|
const cmd = `node ./bin/hopp invalid-arg`;
|
||||||
const { stdout } = await execAsync(cmd);
|
const { stderr } = await execAsync(cmd);
|
||||||
const out = getErrorCode(stdout);
|
const out = getErrorCode(stderr);
|
||||||
|
|
||||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Collection file not JSON type.", async () => {
|
test("Collection file not JSON type.", async () => {
|
||||||
const cmd = `node ./bin/hopp test ${getTestJsonFilePath("notjson.txt")}`;
|
const cmd = `node ./bin/hopp test ${getTestJsonFilePath("notjson.txt")}`;
|
||||||
const { stdout } = await execAsync(cmd);
|
const { stderr } = await execAsync(cmd);
|
||||||
const out = getErrorCode(stdout);
|
const out = getErrorCode(stderr);
|
||||||
|
|
||||||
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
|
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
|
||||||
});
|
});
|
||||||
@@ -70,24 +80,24 @@ describe("Test 'hopp test <file> --env <file>' command:", () => {
|
|||||||
|
|
||||||
test("No env file path provided.", async () => {
|
test("No env file path provided.", async () => {
|
||||||
const cmd = `${VALID_TEST_CMD} --env`;
|
const cmd = `${VALID_TEST_CMD} --env`;
|
||||||
const { stdout } = await execAsync(cmd);
|
const { stderr } = await execAsync(cmd);
|
||||||
const out = getErrorCode(stdout);
|
const out = getErrorCode(stderr);
|
||||||
|
|
||||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ENV file not JSON type.", async () => {
|
test("ENV file not JSON type.", async () => {
|
||||||
const cmd = `${VALID_TEST_CMD} --env ${getTestJsonFilePath("notjson.txt")}`;
|
const cmd = `${VALID_TEST_CMD} --env ${getTestJsonFilePath("notjson.txt")}`;
|
||||||
const { stdout } = await execAsync(cmd);
|
const { stderr } = await execAsync(cmd);
|
||||||
const out = getErrorCode(stdout);
|
const out = getErrorCode(stderr);
|
||||||
|
|
||||||
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
|
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ENV file not found.", async () => {
|
test("ENV file not found.", async () => {
|
||||||
const cmd = `${VALID_TEST_CMD} --env notfound.json`;
|
const cmd = `${VALID_TEST_CMD} --env notfound.json`;
|
||||||
const { stdout } = await execAsync(cmd);
|
const { stderr } = await execAsync(cmd);
|
||||||
const out = getErrorCode(stdout);
|
const out = getErrorCode(stderr);
|
||||||
|
|
||||||
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
|
expect(out).toBe<HoppErrorCode>("FILE_NOT_FOUND");
|
||||||
});
|
});
|
||||||
@@ -109,17 +119,17 @@ describe("Test 'hopp test <file> --delay <delay_in_ms>' command:", () => {
|
|||||||
|
|
||||||
test("No value passed to delay flag.", async () => {
|
test("No value passed to delay flag.", async () => {
|
||||||
const cmd = `${VALID_TEST_CMD} --delay`;
|
const cmd = `${VALID_TEST_CMD} --delay`;
|
||||||
const { stdout } = await execAsync(cmd);
|
const { stderr } = await execAsync(cmd);
|
||||||
const out = getErrorCode(stdout);
|
const out = getErrorCode(stderr);
|
||||||
|
|
||||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Invalid value passed to delay flag.", async () => {
|
test("Invalid value passed to delay flag.", async () => {
|
||||||
const cmd = `${VALID_TEST_CMD} --delay 'NaN'`;
|
const cmd = `${VALID_TEST_CMD} --delay 'NaN'`;
|
||||||
const { stdout } = await execAsync(cmd);
|
const { stderr } = await execAsync(cmd);
|
||||||
const out = getErrorCode(stdout);
|
const out = getErrorCode(stderr);
|
||||||
|
console.log("invalid value thing", out)
|
||||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { HoppCLIError } from "../../../types/errors";
|
|
||||||
import { checkFile } from "../../../utils/checks";
|
|
||||||
|
|
||||||
import "@relmify/jest-fp-ts";
|
|
||||||
|
|
||||||
describe("checkFile", () => {
|
|
||||||
test("File doesn't exists.", () => {
|
|
||||||
return expect(
|
|
||||||
checkFile("./src/samples/this-file-not-exists.json")()
|
|
||||||
).resolves.toSubsetEqualLeft(<HoppCLIError>{
|
|
||||||
code: "FILE_NOT_FOUND",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("File not of JSON type.", () => {
|
|
||||||
return expect(
|
|
||||||
checkFile("./src/__tests__/samples/notjson.txt")()
|
|
||||||
).resolves.toSubsetEqualLeft(<HoppCLIError>{
|
|
||||||
code: "INVALID_FILE_TYPE",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("Existing JSON file.", () => {
|
|
||||||
return expect(
|
|
||||||
checkFile("./src/__tests__/samples/passes.json")()
|
|
||||||
).resolves.toBeRight();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -50,7 +50,7 @@ describe("collectionsRunner", () => {
|
|||||||
|
|
||||||
test("Empty HoppCollection.", () => {
|
test("Empty HoppCollection.", () => {
|
||||||
return expect(
|
return expect(
|
||||||
collectionsRunner({ collections: [], envs: SAMPLE_ENVS })()
|
collectionsRunner({ collections: [], envs: SAMPLE_ENVS })
|
||||||
).resolves.toStrictEqual([]);
|
).resolves.toStrictEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ describe("collectionsRunner", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
envs: SAMPLE_ENVS,
|
envs: SAMPLE_ENVS,
|
||||||
})()
|
})
|
||||||
).resolves.toMatchObject([]);
|
).resolves.toMatchObject([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ describe("collectionsRunner", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
envs: SAMPLE_ENVS,
|
envs: SAMPLE_ENVS,
|
||||||
})()
|
})
|
||||||
).resolves.toMatchObject([
|
).resolves.toMatchObject([
|
||||||
{
|
{
|
||||||
path: "collection/request",
|
path: "collection/request",
|
||||||
@@ -116,7 +116,7 @@ describe("collectionsRunner", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
envs: SAMPLE_ENVS,
|
envs: SAMPLE_ENVS,
|
||||||
})()
|
})
|
||||||
).resolves.toMatchObject([
|
).resolves.toMatchObject([
|
||||||
{
|
{
|
||||||
path: "collection/folder/request",
|
path: "collection/folder/request",
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
import { HoppCLIError } from "../../../types/errors";
|
import { HoppCLIError } from "../../../types/errors";
|
||||||
import { parseCollectionData } from "../../../utils/mutators";
|
import { parseCollectionData } from "../../../utils/mutators";
|
||||||
|
|
||||||
import "@relmify/jest-fp-ts";
|
|
||||||
|
|
||||||
describe("parseCollectionData", () => {
|
describe("parseCollectionData", () => {
|
||||||
test("Reading non-existing file.", () => {
|
test("Reading non-existing file.", () => {
|
||||||
return expect(
|
return expect(
|
||||||
parseCollectionData("./src/__tests__/samples/notexist.json")()
|
parseCollectionData("./src/__tests__/samples/notexist.json")
|
||||||
).resolves.toSubsetEqualLeft(<HoppCLIError>{
|
).rejects.toMatchObject(<HoppCLIError>{
|
||||||
code: "FILE_NOT_FOUND",
|
code: "FILE_NOT_FOUND",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Unparseable JSON contents.", () => {
|
test("Unparseable JSON contents.", () => {
|
||||||
return expect(
|
return expect(
|
||||||
parseCollectionData("./src/__tests__/samples/malformed-collection.json")()
|
parseCollectionData("./src/__tests__/samples/malformed-collection.json")
|
||||||
).resolves.toSubsetEqualLeft(<HoppCLIError>{
|
).rejects.toMatchObject(<HoppCLIError>{
|
||||||
code: "MALFORMED_COLLECTION",
|
code: "UNKNOWN_ERROR",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -24,15 +22,15 @@ describe("parseCollectionData", () => {
|
|||||||
return expect(
|
return expect(
|
||||||
parseCollectionData(
|
parseCollectionData(
|
||||||
"./src/__tests__/samples/malformed-collection2.json"
|
"./src/__tests__/samples/malformed-collection2.json"
|
||||||
)()
|
)
|
||||||
).resolves.toSubsetEqualLeft(<HoppCLIError>{
|
).rejects.toMatchObject(<HoppCLIError>{
|
||||||
code: "MALFORMED_COLLECTION",
|
code: "MALFORMED_COLLECTION",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Valid HoppCollection.", () => {
|
test("Valid HoppCollection.", () => {
|
||||||
return expect(
|
return expect(
|
||||||
parseCollectionData("./src/__tests__/samples/passes.json")()
|
parseCollectionData("./src/__tests__/samples/passes.json")
|
||||||
).resolves.toBeRight();
|
).resolves.toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import * as TE from "fp-ts/TaskEither";
|
|
||||||
import { pipe, flow } from "fp-ts/function";
|
|
||||||
import {
|
import {
|
||||||
collectionsRunner,
|
collectionsRunner,
|
||||||
collectionsRunnerExit,
|
collectionsRunnerExit,
|
||||||
@@ -10,18 +8,23 @@ import { parseCollectionData } from "../utils/mutators";
|
|||||||
import { parseEnvsData } from "../options/test/env";
|
import { parseEnvsData } from "../options/test/env";
|
||||||
import { TestCmdOptions } from "../types/commands";
|
import { TestCmdOptions } from "../types/commands";
|
||||||
import { parseDelayOption } from "../options/test/delay";
|
import { parseDelayOption } from "../options/test/delay";
|
||||||
|
import { HoppEnvs } from "../types/request";
|
||||||
|
import { isHoppCLIError } from "../utils/checks";
|
||||||
|
|
||||||
export const test = (path: string, options: TestCmdOptions) => async () => {
|
export const test = (path: string, options: TestCmdOptions) => async () => {
|
||||||
await pipe(
|
try {
|
||||||
TE.Do,
|
const delay = options.delay ? parseDelayOption(options.delay) : 0
|
||||||
TE.bind("envs", () => parseEnvsData(options.env)),
|
const envs = options.env ? await parseEnvsData(options.env) : <HoppEnvs>{ global: [], selected: [] }
|
||||||
TE.bind("collections", () => parseCollectionData(path)),
|
const collections = await parseCollectionData(path)
|
||||||
TE.bind("delay", () => parseDelayOption(options.delay)),
|
|
||||||
TE.chainTaskK(collectionsRunner),
|
const report = await collectionsRunner({collections, envs, delay})
|
||||||
TE.chainW(flow(collectionsRunnerResult, collectionsRunnerExit, TE.of)),
|
const hasSucceeded = collectionsRunnerResult(report)
|
||||||
TE.mapLeft((e) => {
|
collectionsRunnerExit(hasSucceeded)
|
||||||
handleError(e);
|
} catch(e) {
|
||||||
|
if(isHoppCLIError(e)) {
|
||||||
|
handleError(e)
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
})
|
}
|
||||||
)();
|
else throw e
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { log } from "console";
|
|
||||||
import * as S from "fp-ts/string";
|
import * as S from "fp-ts/string";
|
||||||
import { HoppError, HoppErrorCode } from "../types/errors";
|
import { HoppError, HoppErrorCode } from "../types/errors";
|
||||||
import { hasProperty, isSafeCommanderError } from "../utils/checks";
|
import { hasProperty, isSafeCommanderError } from "../utils/checks";
|
||||||
@@ -7,7 +6,7 @@ import { exceptionColors } from "../utils/getters";
|
|||||||
const { BG_FAIL } = exceptionColors;
|
const { BG_FAIL } = exceptionColors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses unknown error data and narrows it to get information realted to
|
* Parses unknown error data and narrows it to get information related to
|
||||||
* error in string format.
|
* error in string format.
|
||||||
* @param e Error data to parse.
|
* @param e Error data to parse.
|
||||||
* @returns Information in string format appropriately parsed, based on error type.
|
* @returns Information in string format appropriately parsed, based on error type.
|
||||||
@@ -81,6 +80,6 @@ export const handleError = <T extends HoppErrorCode>(error: HoppError<T>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!S.isEmpty(ERROR_MSG)) {
|
if (!S.isEmpty(ERROR_MSG)) {
|
||||||
log(ERROR_CODE, ERROR_MSG);
|
console.error(ERROR_CODE, ERROR_MSG);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
import * as TE from "fp-ts/TaskEither";
|
import { error } from "../../types/errors";
|
||||||
import * as S from "fp-ts/string";
|
|
||||||
import { pipe } from "fp-ts/function";
|
|
||||||
import { error, HoppCLIError } from "../../types/errors";
|
|
||||||
|
|
||||||
export const parseDelayOption = (
|
export function parseDelayOption(delay: string): number {
|
||||||
delay: unknown
|
const maybeInt = Number.parseInt(delay)
|
||||||
): TE.TaskEither<HoppCLIError, number> =>
|
|
||||||
!S.isString(delay)
|
if(!Number.isNaN(maybeInt)) {
|
||||||
? TE.right(0)
|
return maybeInt
|
||||||
: pipe(
|
} else {
|
||||||
delay,
|
throw error({
|
||||||
Number,
|
code: "INVALID_ARGUMENT",
|
||||||
TE.fromPredicate(Number.isSafeInteger, () =>
|
data: "Expected '-d, --delay' value to be number",
|
||||||
error({
|
})
|
||||||
code: "INVALID_ARGUMENT",
|
}
|
||||||
data: "Expected '-d, --delay' value to be number",
|
}
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,64 +1,27 @@
|
|||||||
import fs from "fs/promises";
|
import { error } from "../../types/errors";
|
||||||
import { pipe } from "fp-ts/function";
|
|
||||||
import * as TE from "fp-ts/TaskEither";
|
|
||||||
import * as E from "fp-ts/Either";
|
|
||||||
import * as J from "fp-ts/Json";
|
|
||||||
import * as A from "fp-ts/Array";
|
|
||||||
import * as S from "fp-ts/string";
|
|
||||||
import isArray from "lodash/isArray";
|
|
||||||
import { HoppCLIError, error } from "../../types/errors";
|
|
||||||
import { HoppEnvs, HoppEnvPair } from "../../types/request";
|
import { HoppEnvs, HoppEnvPair } from "../../types/request";
|
||||||
import { checkFile } from "../../utils/checks";
|
import { readJsonFile } from "../../utils/mutators";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses env json file for given path and validates the parsed env json object.
|
* Parses env json file for given path and validates the parsed env json object.
|
||||||
* @param path Path of env.json file to be parsed.
|
* @param path Path of env.json file to be parsed.
|
||||||
* @returns For successful parsing we get HoppEnvs object.
|
* @returns For successful parsing we get HoppEnvs object.
|
||||||
*/
|
*/
|
||||||
export const parseEnvsData = (
|
export async function parseEnvsData(path: string) {
|
||||||
path: unknown
|
const contents = await readJsonFile(path)
|
||||||
): TE.TaskEither<HoppCLIError, HoppEnvs> =>
|
|
||||||
!S.isString(path)
|
|
||||||
? TE.right({ global: [], selected: [] })
|
|
||||||
: pipe(
|
|
||||||
// Checking if the env.json file exists or not.
|
|
||||||
checkFile(path),
|
|
||||||
|
|
||||||
// Trying to read given env json file path.
|
if(!(contents && typeof contents === "object" && !Array.isArray(contents))) {
|
||||||
TE.chainW((checkedPath) =>
|
throw error({ code: "MALFORMED_ENV_FILE", path, data: null })
|
||||||
TE.tryCatch(
|
}
|
||||||
() => fs.readFile(checkedPath),
|
|
||||||
(reason) =>
|
|
||||||
error({ code: "UNKNOWN_ERROR", data: E.toError(reason) })
|
|
||||||
)
|
|
||||||
),
|
|
||||||
|
|
||||||
// Trying to JSON parse the read file data and mapping the entries to HoppEnvPairs.
|
const envPairs: Array<HoppEnvPair> = []
|
||||||
TE.chainEitherKW((data) =>
|
|
||||||
pipe(
|
for( const [key,value] of Object.entries(contents)) {
|
||||||
data.toString(),
|
if(typeof value !== "string") {
|
||||||
J.parse,
|
throw error({ code: "MALFORMED_ENV_FILE", path, data: {value: value} })
|
||||||
E.map((jsonData) =>
|
}
|
||||||
jsonData && typeof jsonData === "object" && !isArray(jsonData)
|
|
||||||
? pipe(
|
envPairs.push({key, value})
|
||||||
jsonData,
|
}
|
||||||
Object.entries,
|
return <HoppEnvs>{ global: [], selected: envPairs }
|
||||||
A.map(
|
}
|
||||||
([key, value]) =>
|
|
||||||
<HoppEnvPair>{
|
|
||||||
key,
|
|
||||||
value: S.isString(value)
|
|
||||||
? value
|
|
||||||
: JSON.stringify(value),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: []
|
|
||||||
),
|
|
||||||
E.map((envPairs) => <HoppEnvs>{ global: [], selected: envPairs }),
|
|
||||||
E.mapLeft((e) =>
|
|
||||||
error({ code: "MALFORMED_ENV_FILE", path, data: E.toError(e) })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type TestCmdOptions = {
|
export type TestCmdOptions = {
|
||||||
env: string;
|
env: string | undefined;
|
||||||
delay: number;
|
delay: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HoppEnvFileExt = "json";
|
export type HOPP_ENV_FILE_EXT = "json";
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
import fs from "fs/promises";
|
|
||||||
import { join } from "path";
|
|
||||||
import { pipe } from "fp-ts/function";
|
|
||||||
import {
|
import {
|
||||||
HoppCollection,
|
HoppCollection,
|
||||||
HoppRESTRequest,
|
HoppRESTRequest,
|
||||||
isHoppRESTRequest,
|
isHoppRESTRequest,
|
||||||
} from "@hoppscotch/data";
|
} from "@hoppscotch/data";
|
||||||
import * as A from "fp-ts/Array";
|
import * as A from "fp-ts/Array";
|
||||||
import * as S from "fp-ts/string";
|
|
||||||
import * as TE from "fp-ts/TaskEither";
|
|
||||||
import * as E from "fp-ts/Either";
|
|
||||||
import curryRight from "lodash/curryRight";
|
|
||||||
import { CommanderError } from "commander";
|
import { CommanderError } from "commander";
|
||||||
import { error, HoppCLIError, HoppErrnoException } from "../types/errors";
|
import { HoppCLIError, HoppErrnoException } from "../types/errors";
|
||||||
import { HoppCollectionFileExt } from "../types/collections";
|
|
||||||
import { HoppEnvFileExt } from "../types/commands";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines whether an object has a property with given name.
|
* Determines whether an object has a property with given name.
|
||||||
@@ -71,59 +62,6 @@ export const isRESTCollection = (
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the file path matches the requried file type with of required extension.
|
|
||||||
* @param path The input file path to check.
|
|
||||||
* @param extension The required extension for input file path.
|
|
||||||
* @returns Absolute path for valid file extension OR HoppCLIError in case of error.
|
|
||||||
*/
|
|
||||||
export const checkFileExt = curryRight(
|
|
||||||
(
|
|
||||||
path: unknown,
|
|
||||||
extension: HoppCollectionFileExt | HoppEnvFileExt
|
|
||||||
): E.Either<HoppCLIError, string> =>
|
|
||||||
pipe(
|
|
||||||
path,
|
|
||||||
E.fromPredicate(S.isString, (_) => error({ code: "NO_FILE_PATH" })),
|
|
||||||
E.chainW(
|
|
||||||
E.fromPredicate(S.endsWith(`.${extension}`), (_) =>
|
|
||||||
error({ code: "INVALID_FILE_TYPE", data: extension })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the given file path exists and is of given type.
|
|
||||||
* @param path The input file path to check.
|
|
||||||
* @returns Absolute path for valid file path OR HoppCLIError in case of error.
|
|
||||||
*/
|
|
||||||
export const checkFile = (path: unknown): TE.TaskEither<HoppCLIError, string> =>
|
|
||||||
pipe(
|
|
||||||
path,
|
|
||||||
|
|
||||||
// Checking if path is string.
|
|
||||||
TE.fromPredicate(S.isString, () => error({ code: "NO_FILE_PATH" })),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* After checking file path, we map file path to absolute path and check
|
|
||||||
* if file is of given extension type.
|
|
||||||
*/
|
|
||||||
TE.map(join),
|
|
||||||
TE.chainEitherK(checkFileExt("json")),
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trying to access given file path.
|
|
||||||
* If successfully accessed, we return the path from predicate step.
|
|
||||||
* Else return HoppCLIError with code FILE_NOT_FOUND.
|
|
||||||
*/
|
|
||||||
TE.chainFirstW((checkedPath) =>
|
|
||||||
TE.tryCatchK(
|
|
||||||
() => fs.access(checkedPath),
|
|
||||||
() => error({ code: "FILE_NOT_FOUND", path: checkedPath })
|
|
||||||
)()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if given error data is of type HoppCLIError, based on existence
|
* Checks if given error data is of type HoppCLIError, based on existence
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as T from "fp-ts/Task";
|
|
||||||
import * as A from "fp-ts/Array";
|
import * as A from "fp-ts/Array";
|
||||||
import { pipe } from "fp-ts/function";
|
import { pipe } from "fp-ts/function";
|
||||||
import { bold } from "chalk";
|
import { bold } from "chalk";
|
||||||
@@ -43,8 +42,8 @@ const { WARN, FAIL } = exceptionColors;
|
|||||||
* @returns List of report for each processed request.
|
* @returns List of report for each processed request.
|
||||||
*/
|
*/
|
||||||
export const collectionsRunner =
|
export const collectionsRunner =
|
||||||
(param: CollectionRunnerParam): T.Task<RequestReport[]> =>
|
async (param: CollectionRunnerParam): Promise<RequestReport[]> =>
|
||||||
async () => {
|
{
|
||||||
const envs: HoppEnvs = param.envs;
|
const envs: HoppEnvs = param.envs;
|
||||||
const delay = param.delay ?? 0;
|
const delay = param.delay ?? 0;
|
||||||
const requestsReport: RequestReport[] = [];
|
const requestsReport: RequestReport[] = [];
|
||||||
@@ -213,10 +212,10 @@ export const collectionsRunnerResult = (
|
|||||||
* Else, exit with code 1.
|
* Else, exit with code 1.
|
||||||
* @param result Boolean defining the collections-runner result.
|
* @param result Boolean defining the collections-runner result.
|
||||||
*/
|
*/
|
||||||
export const collectionsRunnerExit = (result: boolean) => {
|
export const collectionsRunnerExit = (result: boolean): never => {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
const EXIT_MSG = FAIL(`\nExited with code 1`);
|
const EXIT_MSG = FAIL(`\nExited with code 1`);
|
||||||
process.stdout.write(EXIT_MSG);
|
process.stderr.write(EXIT_MSG);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import * as E from "fp-ts/Either";
|
|
||||||
import * as TE from "fp-ts/TaskEither";
|
|
||||||
import * as A from "fp-ts/Array";
|
|
||||||
import * as J from "fp-ts/Json";
|
|
||||||
import { pipe } from "fp-ts/function";
|
|
||||||
import { FormDataEntry } from "../types/request";
|
import { FormDataEntry } from "../types/request";
|
||||||
import { error, HoppCLIError } from "../types/errors";
|
import { error } from "../types/errors";
|
||||||
import { isRESTCollection, isHoppErrnoException, checkFile } from "./checks";
|
import { isRESTCollection, isHoppErrnoException } from "./checks";
|
||||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
|
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,49 +34,44 @@ export const parseErrorMessage = (e: unknown) => {
|
|||||||
return msg.replace(/\n+$|\s{2,}/g, "").trim();
|
return msg.replace(/\n+$|\s{2,}/g, "").trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function readJsonFile(path: string): Promise<unknown> {
|
||||||
|
if(!path.endsWith('.json')) {
|
||||||
|
throw error({ code: "INVALID_FILE_TYPE", data: path })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(path)
|
||||||
|
} catch (e) {
|
||||||
|
throw error({ code: "FILE_NOT_FOUND", path: path })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse((await fs.readFile(path)).toString())
|
||||||
|
} catch(e) {
|
||||||
|
throw error({ code: "UNKNOWN_ERROR", data: e })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses collection json file for given path:context.path, and validates
|
* Parses collection json file for given path:context.path, and validates
|
||||||
* the parsed collectiona array.
|
* the parsed collectiona array.
|
||||||
* @param path Collection json file path.
|
* @param path Collection json file path.
|
||||||
* @returns For successful parsing we get array of HoppCollection<HoppRESTRequest>,
|
* @returns For successful parsing we get array of HoppCollection<HoppRESTRequest>,
|
||||||
*/
|
*/
|
||||||
export const parseCollectionData = (
|
export async function parseCollectionData(
|
||||||
path: string
|
path: string
|
||||||
): TE.TaskEither<HoppCLIError, HoppCollection<HoppRESTRequest>[]> =>
|
): Promise<HoppCollection<HoppRESTRequest>[]> {
|
||||||
pipe(
|
let contents = await readJsonFile(path)
|
||||||
TE.of(path),
|
|
||||||
|
|
||||||
// Checking if given file path exists or not.
|
const maybeArrayOfCollections: unknown[] = Array.isArray(contents) ? contents : [contents]
|
||||||
TE.chain(checkFile),
|
|
||||||
|
|
||||||
// Trying to read give collection json path.
|
if(maybeArrayOfCollections.some((x) => !isRESTCollection(x))) {
|
||||||
TE.chainW((checkedPath) =>
|
throw error({
|
||||||
TE.tryCatch(
|
code: "MALFORMED_COLLECTION",
|
||||||
() => fs.readFile(checkedPath),
|
path,
|
||||||
(reason) => error({ code: "UNKNOWN_ERROR", data: E.toError(reason) })
|
data: "Please check the collection data.",
|
||||||
)
|
})
|
||||||
),
|
}
|
||||||
|
|
||||||
// Checking if parsed file data is array.
|
return maybeArrayOfCollections as HoppCollection<HoppRESTRequest>[]
|
||||||
TE.chainEitherKW((data) =>
|
};
|
||||||
pipe(
|
|
||||||
data.toString(),
|
|
||||||
J.parse,
|
|
||||||
E.map((jsonData) => (Array.isArray(jsonData) ? jsonData : [jsonData])),
|
|
||||||
E.mapLeft((e) =>
|
|
||||||
error({ code: "MALFORMED_COLLECTION", path, data: E.toError(e) })
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
|
|
||||||
// Validating collections to be HoppRESTCollection.
|
|
||||||
TE.chainW(
|
|
||||||
TE.fromPredicate(A.every(isRESTCollection), () =>
|
|
||||||
error({
|
|
||||||
code: "MALFORMED_COLLECTION",
|
|
||||||
path,
|
|
||||||
data: "Please check the collection data.",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -391,6 +391,7 @@
|
|||||||
"copy_link": "Copy link",
|
"copy_link": "Copy link",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"enter_curl": "Enter cURL command",
|
"enter_curl": "Enter cURL command",
|
||||||
|
"duplicated": "Request duplicated",
|
||||||
"generate_code": "Generate code",
|
"generate_code": "Generate code",
|
||||||
"generated_code": "Generated code",
|
"generated_code": "Generated code",
|
||||||
"header_list": "Header List",
|
"header_list": "Header List",
|
||||||
|
|||||||
14
packages/hoppscotch-common/src/components.d.ts
vendored
14
packages/hoppscotch-common/src/components.d.ts
vendored
@@ -32,7 +32,7 @@ declare module '@vue/runtime-core' {
|
|||||||
CollectionsAdd: typeof import('./components/collections/Add.vue')['default']
|
CollectionsAdd: typeof import('./components/collections/Add.vue')['default']
|
||||||
CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default']
|
CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default']
|
||||||
CollectionsAddRequest: typeof import('./components/collections/AddRequest.vue')['default']
|
CollectionsAddRequest: typeof import('./components/collections/AddRequest.vue')['default']
|
||||||
CollectionsChooseType: typeof import('./components/collections/ChooseType.vue')['default']
|
CollectionsCollection: typeof import('./components/collections/Collection.vue')['default']
|
||||||
CollectionsEdit: typeof import('./components/collections/Edit.vue')['default']
|
CollectionsEdit: typeof import('./components/collections/Edit.vue')['default']
|
||||||
CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default']
|
CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default']
|
||||||
CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default']
|
CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default']
|
||||||
@@ -48,13 +48,11 @@ declare module '@vue/runtime-core' {
|
|||||||
CollectionsGraphqlImportExport: typeof import('./components/collections/graphql/ImportExport.vue')['default']
|
CollectionsGraphqlImportExport: typeof import('./components/collections/graphql/ImportExport.vue')['default']
|
||||||
CollectionsGraphqlRequest: typeof import('./components/collections/graphql/Request.vue')['default']
|
CollectionsGraphqlRequest: typeof import('./components/collections/graphql/Request.vue')['default']
|
||||||
CollectionsImportExport: typeof import('./components/collections/ImportExport.vue')['default']
|
CollectionsImportExport: typeof import('./components/collections/ImportExport.vue')['default']
|
||||||
CollectionsMyCollection: typeof import('./components/collections/my/Collection.vue')['default']
|
CollectionsMyCollections: typeof import('./components/collections/MyCollections.vue')['default']
|
||||||
CollectionsMyFolder: typeof import('./components/collections/my/Folder.vue')['default']
|
CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
|
||||||
CollectionsMyRequest: typeof import('./components/collections/my/Request.vue')['default']
|
|
||||||
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
|
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
|
||||||
CollectionsTeamsCollection: typeof import('./components/collections/teams/Collection.vue')['default']
|
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
|
||||||
CollectionsTeamsFolder: typeof import('./components/collections/teams/Folder.vue')['default']
|
CollectionsTeamSelect: typeof import('./components/collections/TeamSelect.vue')['default']
|
||||||
CollectionsTeamsRequest: typeof import('./components/collections/teams/Request.vue')['default']
|
|
||||||
Environments: typeof import('./components/environments/index.vue')['default']
|
Environments: typeof import('./components/environments/index.vue')['default']
|
||||||
EnvironmentsChooseType: typeof import('./components/environments/ChooseType.vue')['default']
|
EnvironmentsChooseType: typeof import('./components/environments/ChooseType.vue')['default']
|
||||||
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
|
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
|
||||||
@@ -152,6 +150,8 @@ declare module '@vue/runtime-core' {
|
|||||||
SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default']
|
SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default']
|
||||||
SmartTabs: typeof import('./../../hoppscotch-ui/src/components/smart/Tabs.vue')['default']
|
SmartTabs: typeof import('./../../hoppscotch-ui/src/components/smart/Tabs.vue')['default']
|
||||||
SmartToggle: typeof import('./../../hoppscotch-ui/src/components/smart/Toggle.vue')['default']
|
SmartToggle: typeof import('./../../hoppscotch-ui/src/components/smart/Toggle.vue')['default']
|
||||||
|
SmartTree: typeof import('./components/smart/Tree.vue')['default']
|
||||||
|
SmartTreeBranch: typeof import('./components/smart/TreeBranch.vue')['default']
|
||||||
SmartWindow: typeof import('./../../hoppscotch-ui/src/components/smart/Window.vue')['default']
|
SmartWindow: typeof import('./../../hoppscotch-ui/src/components/smart/Window.vue')['default']
|
||||||
SmartWindows: typeof import('./../../hoppscotch-ui/src/components/smart/Windows.vue')['default']
|
SmartWindows: typeof import('./../../hoppscotch-ui/src/components/smart/Windows.vue')['default']
|
||||||
TabPrimary: typeof import('./components/tab/Primary.vue')['default']
|
TabPrimary: typeof import('./components/tab/Primary.vue')['default']
|
||||||
|
|||||||
@@ -29,10 +29,7 @@ import IconCheck from "~icons/lucide/check"
|
|||||||
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
import { copyToClipboard } from "~/helpers/utils/clipboard"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { platform } from "~/platform"
|
||||||
import { authIdToken$ } from "~/helpers/fb/auth"
|
|
||||||
|
|
||||||
const userAuthToken = useReadonlyStream(authIdToken$, null)
|
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
@@ -53,8 +50,9 @@ const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
|||||||
|
|
||||||
// Copy user auth token to clipboard
|
// Copy user auth token to clipboard
|
||||||
const copyUserAuthToken = () => {
|
const copyUserAuthToken = () => {
|
||||||
if (userAuthToken.value) {
|
const token = platform.auth.getDevOptsBackendIDToken()
|
||||||
copyToClipboard(userAuthToken.value)
|
if (token) {
|
||||||
|
copyToClipboard(token)
|
||||||
copyIcon.value = IconCheck
|
copyIcon.value = IconCheck
|
||||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ import { showChat } from "@modules/crisp"
|
|||||||
import { useSetting } from "@composables/settings"
|
import { useSetting } from "@composables/settings"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { currentUser$ } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
import { TippyComponent } from "vue-tippy"
|
import { TippyComponent } from "vue-tippy"
|
||||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||||
import { invokeAction } from "@helpers/actions"
|
import { invokeAction } from "@helpers/actions"
|
||||||
@@ -236,7 +236,10 @@ const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
|
|||||||
|
|
||||||
const navigatorShare = !!navigator.share
|
const navigatorShare = !!navigator.share
|
||||||
|
|
||||||
const currentUser = useReadonlyStream(currentUser$, null)
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => ZEN_MODE.value,
|
() => ZEN_MODE.value,
|
||||||
|
|||||||
@@ -171,11 +171,10 @@ import IconUploadCloud from "~icons/lucide/upload-cloud"
|
|||||||
import IconUserPlus from "~icons/lucide/user-plus"
|
import IconUserPlus from "~icons/lucide/user-plus"
|
||||||
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
|
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
|
||||||
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
|
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
|
||||||
import { probableUser$ } from "@helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { invokeAction } from "@helpers/actions"
|
import { invokeAction } from "@helpers/actions"
|
||||||
import { platform } from "~/index"
|
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
@@ -194,7 +193,10 @@ const mdAndLarger = breakpoints.greater("md")
|
|||||||
|
|
||||||
const network = reactive(useNetwork())
|
const network = reactive(useNetwork())
|
||||||
|
|
||||||
const currentUser = useReadonlyStream(probableUser$, null)
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getProbableUserStream(),
|
||||||
|
platform.auth.getProbableUser()
|
||||||
|
)
|
||||||
|
|
||||||
// Template refs
|
// Template refs
|
||||||
const tippyActions = ref<any | null>(null)
|
const tippyActions = ref<any | null>(null)
|
||||||
|
|||||||
@@ -41,47 +41,52 @@
|
|||||||
</SmartModal>
|
</SmartModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from "vue"
|
import { watch, ref } from "vue"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
|
|
||||||
export default defineComponent({
|
const toast = useToast()
|
||||||
props: {
|
const t = useI18n()
|
||||||
show: Boolean,
|
|
||||||
loadingState: Boolean,
|
const props = withDefaults(
|
||||||
},
|
defineProps<{
|
||||||
emits: ["submit", "hide-modal"],
|
show: boolean
|
||||||
setup() {
|
loadingState: boolean
|
||||||
return {
|
}>(),
|
||||||
toast: useToast(),
|
{
|
||||||
t: useI18n(),
|
show: false,
|
||||||
|
loadingState: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "submit", name: string): void
|
||||||
|
(e: "hide-modal"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const name = ref("")
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (!show) {
|
||||||
|
name.value = ""
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
data() {
|
)
|
||||||
return {
|
|
||||||
name: null,
|
const addNewCollection = () => {
|
||||||
}
|
if (!name.value) {
|
||||||
},
|
toast.error(t("collection.invalid_name"))
|
||||||
watch: {
|
return
|
||||||
show(isShowing: boolean) {
|
}
|
||||||
if (!isShowing) {
|
|
||||||
this.name = null
|
emit("submit", name.value)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
const hideModal = () => {
|
||||||
methods: {
|
name.value = ""
|
||||||
addNewCollection() {
|
emit("hide-modal")
|
||||||
if (!this.name) {
|
}
|
||||||
this.toast.error(this.t("collection.invalid_name"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$emit("submit", this.name)
|
|
||||||
},
|
|
||||||
hideModal() {
|
|
||||||
this.name = null
|
|
||||||
this.$emit("hide-modal")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
v-if="show"
|
v-if="show"
|
||||||
dialog
|
dialog
|
||||||
:title="t('folder.new')"
|
:title="t('folder.new')"
|
||||||
@close="$emit('hide-modal')"
|
@close="emit('hide-modal')"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
@@ -41,52 +41,51 @@
|
|||||||
</SmartModal>
|
</SmartModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from "vue"
|
import { ref, watch } from "vue"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
|
|
||||||
export default defineComponent({
|
const toast = useToast()
|
||||||
props: {
|
const t = useI18n()
|
||||||
show: Boolean,
|
|
||||||
folder: { type: Object, default: () => ({}) },
|
const props = withDefaults(
|
||||||
folderPath: { type: String, default: null },
|
defineProps<{
|
||||||
collectionIndex: { type: Number, default: null },
|
show: boolean
|
||||||
loadingState: Boolean,
|
loadingState: boolean
|
||||||
},
|
}>(),
|
||||||
emits: ["hide-modal", "add-folder"],
|
{
|
||||||
setup() {
|
show: false,
|
||||||
return {
|
loadingState: false,
|
||||||
toast: useToast(),
|
}
|
||||||
t: useI18n(),
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "hide-modal"): void
|
||||||
|
(e: "add-folder", name: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const name = ref("")
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (!show) {
|
||||||
|
name.value = ""
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
data() {
|
)
|
||||||
return {
|
|
||||||
name: null,
|
const addFolder = () => {
|
||||||
}
|
if (name.value.trim() === "") {
|
||||||
},
|
toast.error(t("folder.invalid_name"))
|
||||||
watch: {
|
return
|
||||||
show(isShowing: boolean) {
|
}
|
||||||
if (!isShowing) this.name = null
|
emit("add-folder", name.value)
|
||||||
},
|
}
|
||||||
},
|
|
||||||
methods: {
|
const hideModal = () => {
|
||||||
addFolder() {
|
name.value = ""
|
||||||
if (!this.name) {
|
emit("hide-modal")
|
||||||
this.toast.error(this.t("folder.invalid_name"))
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
this.$emit("add-folder", {
|
|
||||||
name: this.name,
|
|
||||||
folder: this.folder,
|
|
||||||
path: this.folderPath || `${this.collectionIndex}`,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
hideModal() {
|
|
||||||
this.name = null
|
|
||||||
this.$emit("hide-modal")
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -48,23 +48,20 @@ import { getRESTRequest } from "~/newstore/RESTSession"
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
show: boolean
|
defineProps<{
|
||||||
loadingState: boolean
|
show: boolean
|
||||||
folder?: object
|
loadingState: boolean
|
||||||
folderPath?: string
|
}>(),
|
||||||
}>()
|
{
|
||||||
|
show: false,
|
||||||
|
loadingState: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "hide-modal"): void
|
(event: "hide-modal"): void
|
||||||
(
|
(event: "add-request", name: string): void
|
||||||
e: "add-request",
|
|
||||||
v: {
|
|
||||||
name: string
|
|
||||||
folder: object | undefined
|
|
||||||
path: string | undefined
|
|
||||||
}
|
|
||||||
): void
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const name = ref("")
|
const name = ref("")
|
||||||
@@ -79,15 +76,11 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const addRequest = () => {
|
const addRequest = () => {
|
||||||
if (!name.value) {
|
if (name.value.trim() === "") {
|
||||||
toast.error(`${t("error.empty_req_name")}`)
|
toast.error(`${t("error.empty_req_name")}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
emit("add-request", {
|
emit("add-request", name.value)
|
||||||
name: name.value,
|
|
||||||
folder: props.folder,
|
|
||||||
path: props.folderPath,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideModal = () => {
|
const hideModal = () => {
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<SmartTabs
|
|
||||||
:id="'collections_tab'"
|
|
||||||
v-model="selectedCollectionTab"
|
|
||||||
render-inactive-tabs
|
|
||||||
>
|
|
||||||
<SmartTab
|
|
||||||
:id="'my-collections'"
|
|
||||||
:label="`${t('collection.my_collections')}`"
|
|
||||||
/>
|
|
||||||
<SmartTab
|
|
||||||
:id="'team-collections'"
|
|
||||||
:label="`${t('collection.team_collections')}`"
|
|
||||||
>
|
|
||||||
<SmartIntersection @intersecting="onTeamSelectIntersect">
|
|
||||||
<tippy
|
|
||||||
interactive
|
|
||||||
trigger="click"
|
|
||||||
theme="popover"
|
|
||||||
placement="bottom"
|
|
||||||
:on-shown="() => tippyActions.focus()"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:title="`${t('collection.select_team')}`"
|
|
||||||
class="bg-transparent border-b border-dividerLight select-wrapper"
|
|
||||||
>
|
|
||||||
<ButtonSecondary
|
|
||||||
v-if="collectionsType.selectedTeam"
|
|
||||||
:icon="IconUsers"
|
|
||||||
:label="collectionsType.selectedTeam.name"
|
|
||||||
class="flex-1 !justify-start pr-8 rounded-none"
|
|
||||||
/>
|
|
||||||
<ButtonSecondary
|
|
||||||
v-else
|
|
||||||
:label="`${t('collection.select_team')}`"
|
|
||||||
class="flex-1 !justify-start pr-8 rounded-none"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<template #content="{ hide }">
|
|
||||||
<div
|
|
||||||
ref="tippyActions"
|
|
||||||
class="flex flex-col focus:outline-none"
|
|
||||||
tabindex="0"
|
|
||||||
@keyup.escape="hide()"
|
|
||||||
>
|
|
||||||
<SmartItem
|
|
||||||
v-for="(team, index) in myTeams"
|
|
||||||
:key="`team-${index}`"
|
|
||||||
:label="team.name"
|
|
||||||
:info-icon="
|
|
||||||
team.id === collectionsType.selectedTeam?.id
|
|
||||||
? IconDone
|
|
||||||
: undefined
|
|
||||||
"
|
|
||||||
:active-info-icon="
|
|
||||||
team.id === collectionsType.selectedTeam?.id
|
|
||||||
"
|
|
||||||
:icon="IconUsers"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
updateSelectedTeam(team)
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</tippy>
|
|
||||||
</SmartIntersection>
|
|
||||||
</SmartTab>
|
|
||||||
</SmartTabs>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import IconUsers from "~icons/lucide/users"
|
|
||||||
import IconDone from "~icons/lucide/check"
|
|
||||||
import { nextTick, ref, watch } from "vue"
|
|
||||||
import { GetMyTeamsQuery, Team } from "~/helpers/backend/graphql"
|
|
||||||
import { currentUserInfo$ } from "~/helpers/teams/BackendUserInfo"
|
|
||||||
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
|
||||||
import { onLoggedIn } from "@composables/auth"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
import { useLocalState } from "~/newstore/localstate"
|
|
||||||
import { invokeAction } from "~/helpers/actions"
|
|
||||||
|
|
||||||
type TeamData = GetMyTeamsQuery["myTeams"][number]
|
|
||||||
|
|
||||||
type CollectionTabs = "my-collections" | "team-collections"
|
|
||||||
|
|
||||||
const t = useI18n()
|
|
||||||
|
|
||||||
// Template refs
|
|
||||||
const tippyActions = ref<any | null>(null)
|
|
||||||
const selectedCollectionTab = ref<CollectionTabs>("my-collections")
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
collectionsType: {
|
|
||||||
type: "my-collections" | "team-collections"
|
|
||||||
selectedTeam: Team | undefined
|
|
||||||
}
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: "update-collection-type", tabID: string): void
|
|
||||||
(e: "update-selected-team", team: TeamData | undefined): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const currentUser = useReadonlyStream(currentUserInfo$, null)
|
|
||||||
|
|
||||||
const adapter = new TeamListAdapter(true)
|
|
||||||
const myTeams = useReadonlyStream(adapter.teamList$, null)
|
|
||||||
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
|
|
||||||
let teamListFetched = false
|
|
||||||
|
|
||||||
watch(myTeams, (teams) => {
|
|
||||||
if (teams && !teamListFetched) {
|
|
||||||
teamListFetched = true
|
|
||||||
if (REMEMBERED_TEAM_ID.value && currentUser) {
|
|
||||||
const team = teams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
|
|
||||||
if (team) updateSelectedTeam(team)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onLoggedIn(() => {
|
|
||||||
adapter.initialize()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => currentUser.value,
|
|
||||||
(user) => {
|
|
||||||
if (!user) {
|
|
||||||
selectedCollectionTab.value = "my-collections"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const onTeamSelectIntersect = () => {
|
|
||||||
// Load team data as soon as intersection
|
|
||||||
adapter.fetchList()
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateCollectionsType = (tabID: string) => {
|
|
||||||
emit("update-collection-type", tabID)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSelectedTeam = (team: TeamData | undefined) => {
|
|
||||||
REMEMBERED_TEAM_ID.value = team?.id
|
|
||||||
emit("update-selected-team", team)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(selectedCollectionTab, (newValue: CollectionTabs) => {
|
|
||||||
if (newValue === "team-collections" && !currentUser.value) {
|
|
||||||
invokeAction("modals.login.toggle")
|
|
||||||
nextTick(() => (selectedCollectionTab.value = "my-collections"))
|
|
||||||
} else updateCollectionsType(newValue)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||||
|
<div
|
||||||
|
class="flex items-stretch group"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="dropEvent"
|
||||||
|
@dragover="dragging = true"
|
||||||
|
@drop="dragging = false"
|
||||||
|
@dragleave="dragging = false"
|
||||||
|
@dragend="dragging = false"
|
||||||
|
@contextmenu.prevent="options?.tippy.show()"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex items-center justify-center px-4 cursor-pointer"
|
||||||
|
@click="emit('toggle-children')"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="collectionIcon"
|
||||||
|
class="svg-icons"
|
||||||
|
:class="{ 'text-accent': isSelected }"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex flex-1 min-w-0 py-2 pr-2 transition cursor-pointer group-hover:text-secondaryDark"
|
||||||
|
@click="emit('toggle-children')"
|
||||||
|
>
|
||||||
|
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||||
|
{{ collectionName }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div v-if="!hasNoTeamAccess" class="flex">
|
||||||
|
<ButtonSecondary
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:icon="IconFilePlus"
|
||||||
|
:title="t('request.new')"
|
||||||
|
class="hidden group-hover:inline-flex"
|
||||||
|
@click="emit('add-request')"
|
||||||
|
/>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:icon="IconFolderPlus"
|
||||||
|
:title="t('folder.new')"
|
||||||
|
class="hidden group-hover:inline-flex"
|
||||||
|
@click="emit('add-folder')"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<tippy
|
||||||
|
ref="options"
|
||||||
|
interactive
|
||||||
|
trigger="click"
|
||||||
|
theme="popover"
|
||||||
|
:on-shown="() => tippyActions!.focus()"
|
||||||
|
>
|
||||||
|
<ButtonSecondary
|
||||||
|
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.r="requestAction?.$el.click()"
|
||||||
|
@keyup.n="folderAction?.$el.click()"
|
||||||
|
@keyup.e="edit?.$el.click()"
|
||||||
|
@keyup.delete="deleteAction?.$el.click()"
|
||||||
|
@keyup.x="exportAction?.$el.click()"
|
||||||
|
@keyup.escape="hide()"
|
||||||
|
>
|
||||||
|
<SmartItem
|
||||||
|
ref="requestAction"
|
||||||
|
:icon="IconFilePlus"
|
||||||
|
:label="t('request.new')"
|
||||||
|
:shortcut="['R']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('add-request')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<SmartItem
|
||||||
|
ref="folderAction"
|
||||||
|
:icon="IconFolderPlus"
|
||||||
|
:label="t('folder.new')"
|
||||||
|
:shortcut="['N']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('add-folder')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<SmartItem
|
||||||
|
ref="edit"
|
||||||
|
:icon="IconEdit"
|
||||||
|
:label="t('action.edit')"
|
||||||
|
:shortcut="['E']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('edit-collection')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<SmartItem
|
||||||
|
ref="exportAction"
|
||||||
|
:icon="IconDownload"
|
||||||
|
:label="t('export.title')"
|
||||||
|
:shortcut="['X']"
|
||||||
|
:loading="exportLoading"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('export-data'),
|
||||||
|
collectionsType === 'my-collections' ? hide() : null
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<SmartItem
|
||||||
|
ref="deleteAction"
|
||||||
|
:icon="IconTrash2"
|
||||||
|
:label="t('action.delete')"
|
||||||
|
:shortcut="['⌫']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('remove-collection')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</tippy>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||||
|
import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||||
|
import IconFilePlus from "~icons/lucide/file-plus"
|
||||||
|
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||||
|
import IconDownload from "~icons/lucide/download"
|
||||||
|
import IconTrash2 from "~icons/lucide/trash-2"
|
||||||
|
import IconEdit from "~icons/lucide/edit"
|
||||||
|
import IconFolder from "~icons/lucide/folder"
|
||||||
|
import IconFolderOpen from "~icons/lucide/folder-open"
|
||||||
|
import { PropType, ref, computed, watch } from "vue"
|
||||||
|
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||||
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import { TippyComponent } from "vue-tippy"
|
||||||
|
import { TeamCollection } from "~/helpers/teams/TeamCollection"
|
||||||
|
|
||||||
|
type CollectionType = "my-collections" | "team-collections"
|
||||||
|
type FolderType = "collection" | "folder"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object as PropType<HoppCollection<HoppRESTRequest> | TeamCollection>,
|
||||||
|
default: () => ({}),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
collectionsType: {
|
||||||
|
type: String as PropType<CollectionType>,
|
||||||
|
default: "my-collections",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Collection component can be used for both collections and folders.
|
||||||
|
* folderType is used to determine which one it is.
|
||||||
|
*/
|
||||||
|
folderType: {
|
||||||
|
type: String as PropType<FolderType>,
|
||||||
|
default: "collection",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isOpen: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isSelected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
exportLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
hasNoTeamAccess: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "toggle-children"): void
|
||||||
|
(event: "add-request"): void
|
||||||
|
(event: "add-folder"): void
|
||||||
|
(event: "edit-collection"): void
|
||||||
|
(event: "export-data"): void
|
||||||
|
(event: "remove-collection"): void
|
||||||
|
(event: "drop-event", payload: DataTransfer): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tippyActions = ref<TippyComponent | null>(null)
|
||||||
|
const requestAction = ref<HTMLButtonElement | null>(null)
|
||||||
|
const folderAction = ref<HTMLButtonElement | null>(null)
|
||||||
|
const edit = ref<HTMLButtonElement | null>(null)
|
||||||
|
const deleteAction = ref<HTMLButtonElement | null>(null)
|
||||||
|
const exportAction = ref<HTMLButtonElement | null>(null)
|
||||||
|
const options = ref<TippyComponent | null>(null)
|
||||||
|
|
||||||
|
const dragging = ref(false)
|
||||||
|
|
||||||
|
const collectionIcon = computed(() => {
|
||||||
|
if (props.isSelected) return IconCheckCircle
|
||||||
|
else if (!props.isOpen) return IconFolder
|
||||||
|
else if (props.isOpen) return IconFolderOpen
|
||||||
|
else return IconFolder
|
||||||
|
})
|
||||||
|
|
||||||
|
const collectionName = computed(() => {
|
||||||
|
if ((props.data as HoppCollection<HoppRESTRequest>).name)
|
||||||
|
return (props.data as HoppCollection<HoppRESTRequest>).name
|
||||||
|
else return (props.data as TeamCollection).title
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.exportLoading,
|
||||||
|
(val) => {
|
||||||
|
if (!val) {
|
||||||
|
options.value!.tippy.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const dropEvent = ({ dataTransfer }: DragEvent) => {
|
||||||
|
if (dataTransfer) {
|
||||||
|
dragging.value = !dragging.value
|
||||||
|
emit("drop-event", dataTransfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -41,46 +41,52 @@
|
|||||||
</SmartModal>
|
</SmartModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from "vue"
|
import { ref, watch } from "vue"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
|
|
||||||
export default defineComponent({
|
const t = useI18n()
|
||||||
props: {
|
const toast = useToast()
|
||||||
show: Boolean,
|
|
||||||
editingCollectionName: { type: String, default: null },
|
const props = withDefaults(
|
||||||
loadingState: Boolean,
|
defineProps<{
|
||||||
},
|
show: boolean
|
||||||
emits: ["submit", "hide-modal"],
|
loadingState: boolean
|
||||||
setup() {
|
editingCollectionName: string
|
||||||
return {
|
}>(),
|
||||||
toast: useToast(),
|
{
|
||||||
t: useI18n(),
|
show: false,
|
||||||
}
|
loadingState: false,
|
||||||
},
|
editingCollectionName: "",
|
||||||
data() {
|
}
|
||||||
return {
|
)
|
||||||
name: null,
|
|
||||||
}
|
const emit = defineEmits<{
|
||||||
},
|
(e: "submit", name: string): void
|
||||||
watch: {
|
(e: "hide-modal"): void
|
||||||
editingCollectionName(val) {
|
}>()
|
||||||
this.name = val
|
|
||||||
},
|
const name = ref("")
|
||||||
},
|
|
||||||
methods: {
|
watch(
|
||||||
saveCollection() {
|
() => props.editingCollectionName,
|
||||||
if (!this.name) {
|
(newName) => {
|
||||||
this.toast.error(this.t("collection.invalid_name"))
|
name.value = newName
|
||||||
return
|
}
|
||||||
}
|
)
|
||||||
this.$emit("submit", this.name)
|
|
||||||
},
|
const saveCollection = () => {
|
||||||
hideModal() {
|
if (name.value.trim() === "") {
|
||||||
this.name = null
|
toast.error(t("collection.invalid_name"))
|
||||||
this.$emit("hide-modal")
|
return
|
||||||
},
|
}
|
||||||
},
|
|
||||||
})
|
emit("submit", name.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideModal = () => {
|
||||||
|
name.value = ""
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
v-if="show"
|
v-if="show"
|
||||||
dialog
|
dialog
|
||||||
:title="t('folder.edit')"
|
:title="t('folder.edit')"
|
||||||
@close="$emit('hide-modal')"
|
@close="emit('hide-modal')"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
@@ -41,46 +41,52 @@
|
|||||||
</SmartModal>
|
</SmartModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from "vue"
|
import { ref, watch } from "vue"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
|
|
||||||
export default defineComponent({
|
const t = useI18n()
|
||||||
props: {
|
const toast = useToast()
|
||||||
show: Boolean,
|
|
||||||
editingFolderName: { type: String, default: null },
|
const props = withDefaults(
|
||||||
loadingState: Boolean,
|
defineProps<{
|
||||||
},
|
show: boolean
|
||||||
emits: ["submit", "hide-modal"],
|
loadingState: boolean
|
||||||
setup() {
|
editingFolderName: string
|
||||||
return {
|
}>(),
|
||||||
t: useI18n(),
|
{
|
||||||
toast: useToast(),
|
show: false,
|
||||||
}
|
loadingState: false,
|
||||||
},
|
editingFolderName: "",
|
||||||
data() {
|
}
|
||||||
return {
|
)
|
||||||
name: null,
|
|
||||||
}
|
const emit = defineEmits<{
|
||||||
},
|
(e: "submit", name: string): void
|
||||||
watch: {
|
(e: "hide-modal"): void
|
||||||
editingFolderName(val) {
|
}>()
|
||||||
this.name = val
|
|
||||||
},
|
const name = ref("")
|
||||||
},
|
|
||||||
methods: {
|
watch(
|
||||||
editFolder() {
|
() => props.editingFolderName,
|
||||||
if (!this.name) {
|
(newName) => {
|
||||||
this.toast.error(this.t("folder.invalid_name"))
|
name.value = newName
|
||||||
return
|
}
|
||||||
}
|
)
|
||||||
this.$emit("submit", this.name)
|
|
||||||
},
|
const editFolder = () => {
|
||||||
hideModal() {
|
if (name.value.trim() === "") {
|
||||||
this.name = null
|
toast.error(t("folder.invalid_name"))
|
||||||
this.$emit("hide-modal")
|
return
|
||||||
},
|
}
|
||||||
},
|
|
||||||
})
|
emit("submit", name.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideModal = () => {
|
||||||
|
name.value = ""
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,13 +9,13 @@
|
|||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<input
|
<input
|
||||||
id="selectLabelEditReq"
|
id="selectLabelEditReq"
|
||||||
v-model="requestUpdateData.name"
|
v-model="name"
|
||||||
v-focus
|
v-focus
|
||||||
class="input floating-input"
|
class="input floating-input"
|
||||||
placeholder=" "
|
placeholder=" "
|
||||||
type="text"
|
type="text"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
@keyup.enter="saveRequest"
|
@keyup.enter="editRequest"
|
||||||
/>
|
/>
|
||||||
<label for="selectLabelEditReq">
|
<label for="selectLabelEditReq">
|
||||||
{{ t("action.label") }}
|
{{ t("action.label") }}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
:label="t('action.save')"
|
:label="t('action.save')"
|
||||||
:loading="loadingState"
|
:loading="loadingState"
|
||||||
outline
|
outline
|
||||||
@click="saveRequest"
|
@click="editRequest"
|
||||||
/>
|
/>
|
||||||
<ButtonSecondary
|
<ButtonSecondary
|
||||||
:label="t('action.cancel')"
|
:label="t('action.cancel')"
|
||||||
@@ -41,48 +41,52 @@
|
|||||||
</SmartModal>
|
</SmartModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script setup lang="ts">
|
||||||
import { defineComponent } from "vue"
|
import { ref, watch } from "vue"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
|
|
||||||
export default defineComponent({
|
const toast = useToast()
|
||||||
props: {
|
const t = useI18n()
|
||||||
show: Boolean,
|
|
||||||
editingRequestName: { type: String, default: null },
|
const props = withDefaults(
|
||||||
loadingState: Boolean,
|
defineProps<{
|
||||||
},
|
show: boolean
|
||||||
emits: ["submit", "hide-modal"],
|
loadingState: boolean
|
||||||
setup() {
|
editingRequestName: string
|
||||||
return {
|
}>(),
|
||||||
t: useI18n(),
|
{
|
||||||
toast: useToast(),
|
show: false,
|
||||||
}
|
loadingState: false,
|
||||||
},
|
editingRequestName: "",
|
||||||
data() {
|
}
|
||||||
return {
|
)
|
||||||
requestUpdateData: {
|
|
||||||
name: null,
|
const emit = defineEmits<{
|
||||||
},
|
(e: "submit", name: string): void
|
||||||
}
|
(e: "hide-modal"): void
|
||||||
},
|
}>()
|
||||||
watch: {
|
|
||||||
editingRequestName(val) {
|
const name = ref("")
|
||||||
this.requestUpdateData.name = val
|
|
||||||
},
|
watch(
|
||||||
},
|
() => props.editingRequestName,
|
||||||
methods: {
|
(newName) => {
|
||||||
saveRequest() {
|
name.value = newName
|
||||||
if (!this.requestUpdateData.name) {
|
}
|
||||||
this.toast.error(this.t("request.invalid_name"))
|
)
|
||||||
return
|
|
||||||
}
|
const editRequest = () => {
|
||||||
this.$emit("submit", this.requestUpdateData)
|
if (name.value.trim() === "") {
|
||||||
},
|
toast.error(t("request.invalid_name"))
|
||||||
hideModal() {
|
return
|
||||||
this.requestUpdateData = { name: null }
|
}
|
||||||
this.$emit("hide-modal")
|
|
||||||
},
|
emit("submit", name.value)
|
||||||
},
|
}
|
||||||
})
|
|
||||||
|
const hideModal = () => {
|
||||||
|
name.value = ""
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<SmartModal
|
<SmartModal
|
||||||
v-if="show"
|
v-if="show"
|
||||||
dialog
|
dialog
|
||||||
:title="`${t('modal.collections')}`"
|
:title="t('modal.collections')"
|
||||||
styles="sm:max-w-md"
|
styles="sm:max-w-md"
|
||||||
@close="hideModal"
|
@close="hideModal"
|
||||||
>
|
>
|
||||||
@@ -81,7 +81,6 @@
|
|||||||
<div class="select-wrapper">
|
<div class="select-wrapper">
|
||||||
<select
|
<select
|
||||||
v-model="mySelectedCollectionID"
|
v-model="mySelectedCollectionID"
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
class="select"
|
class="select"
|
||||||
autofocus
|
autofocus
|
||||||
@@ -93,6 +92,7 @@
|
|||||||
v-for="(collection, collectionIndex) in myCollections"
|
v-for="(collection, collectionIndex) in myCollections"
|
||||||
:key="`collection-${collectionIndex}`"
|
:key="`collection-${collectionIndex}`"
|
||||||
:value="collectionIndex"
|
:value="collectionIndex"
|
||||||
|
class="bg-primary"
|
||||||
>
|
>
|
||||||
{{ collection.name }}
|
{{ collection.name }}
|
||||||
</option>
|
</option>
|
||||||
@@ -126,8 +126,9 @@
|
|||||||
v-tippy="{ theme: 'tooltip' }"
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
:title="t('action.download_file')"
|
:title="t('action.download_file')"
|
||||||
:icon="IconDownload"
|
:icon="IconDownload"
|
||||||
|
:loading="exportingTeamCollections"
|
||||||
:label="t('export.as_json')"
|
:label="t('export.as_json')"
|
||||||
@click="exportJSON"
|
@click="emit('export-json-collection')"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
@@ -149,12 +150,9 @@
|
|||||||
: false
|
: false
|
||||||
"
|
"
|
||||||
:icon="IconGithub"
|
:icon="IconGithub"
|
||||||
|
:loading="creatingGistCollection"
|
||||||
:label="t('export.create_secret_gist')"
|
:label="t('export.create_secret_gist')"
|
||||||
@click="
|
@click="emit('create-collection-gist')"
|
||||||
() => {
|
|
||||||
createCollectionGist()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -167,217 +165,96 @@
|
|||||||
import IconArrowLeft from "~icons/lucide/arrow-left"
|
import IconArrowLeft from "~icons/lucide/arrow-left"
|
||||||
import IconDownload from "~icons/lucide/download"
|
import IconDownload from "~icons/lucide/download"
|
||||||
import IconGithub from "~icons/lucide/github"
|
import IconGithub from "~icons/lucide/github"
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, PropType, ref, watch } from "vue"
|
||||||
import { pipe } from "fp-ts/function"
|
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 { HoppRESTRequest, HoppCollection } from "@hoppscotch/data"
|
||||||
import axios from "axios"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
import { currentUser$ } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
|
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
|
||||||
import { RESTCollectionImporters } from "~/helpers/import-export/import/importers"
|
import { RESTCollectionImporters } from "~/helpers/import-export/import/importers"
|
||||||
import { StepReturnValue } from "~/helpers/import-export/steps"
|
import { StepReturnValue } from "~/helpers/import-export/steps"
|
||||||
import { runGQLQuery, runMutation } from "~/helpers/backend/GQLClient"
|
|
||||||
import {
|
|
||||||
ExportAsJsonDocument,
|
|
||||||
ImportFromJsonDocument,
|
|
||||||
} from "~/helpers/backend/graphql"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const toast = useToast()
|
||||||
show: boolean
|
const t = useI18n()
|
||||||
collectionsType:
|
|
||||||
| {
|
type CollectionType = "team-collections" | "my-collections"
|
||||||
type: "team-collections"
|
|
||||||
selectedTeam: {
|
const props = defineProps({
|
||||||
id: string
|
show: {
|
||||||
}
|
type: Boolean,
|
||||||
}
|
default: false,
|
||||||
| { type: "my-collections" }
|
required: true,
|
||||||
}>()
|
},
|
||||||
|
collectionsType: {
|
||||||
|
type: String as PropType<CollectionType>,
|
||||||
|
default: "my-collections",
|
||||||
|
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<{
|
const emit = defineEmits<{
|
||||||
(e: "hide-modal"): void
|
(e: "hide-modal"): void
|
||||||
(e: "update-team-collections"): 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 toast = useToast()
|
const hasFile = ref(false)
|
||||||
const t = useI18n()
|
const hasGist = ref(false)
|
||||||
const myCollections = useReadonlyStream(restCollections$, [])
|
|
||||||
const currentUser = useReadonlyStream(currentUser$, null)
|
|
||||||
|
|
||||||
// Template refs
|
const importerType = ref<number | null>(null)
|
||||||
const mode = ref("import_export")
|
|
||||||
const mySelectedCollectionID = ref<undefined | number>(undefined)
|
|
||||||
const collectionJson = ref("")
|
|
||||||
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
|
|
||||||
const inputChooseGistToImportFrom = ref<string>("")
|
|
||||||
|
|
||||||
const getJSONCollection = async () => {
|
|
||||||
if (props.collectionsType.type === "my-collections") {
|
|
||||||
collectionJson.value = JSON.stringify(myCollections.value, null, 2)
|
|
||||||
} else {
|
|
||||||
collectionJson.value = pipe(
|
|
||||||
await runGQLQuery({
|
|
||||||
query: ExportAsJsonDocument,
|
|
||||||
variables: {
|
|
||||||
teamID: props.collectionsType.selectedTeam.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
E.matchW(
|
|
||||||
// TODO: Handle error case gracefully ?
|
|
||||||
() => {
|
|
||||||
throw new Error("Error exporting collection to JSON")
|
|
||||||
},
|
|
||||||
(x) => x.exportCollectionsToJSON
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return collectionJson.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const createCollectionGist = async () => {
|
|
||||||
if (!currentUser.value) {
|
|
||||||
toast.error(t("profile.no_permission").toString())
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await getJSONCollection()
|
|
||||||
|
|
||||||
try {
|
|
||||||
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.html_url)
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(t("error.something_went_wrong").toString())
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileImported = () => {
|
|
||||||
toast.success(t("state.file_imported").toString())
|
|
||||||
hideModal()
|
|
||||||
}
|
|
||||||
|
|
||||||
const failedImport = () => {
|
|
||||||
toast.error(t("import.failed").toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideModal = () => {
|
|
||||||
mode.value = "import_export"
|
|
||||||
mySelectedCollectionID.value = undefined
|
|
||||||
resetImport()
|
|
||||||
emit("hide-modal")
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepResults = ref<StepReturnValue[]>([])
|
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) => {
|
watch(mySelectedCollectionID, (newValue) => {
|
||||||
if (newValue === undefined) return
|
if (newValue === undefined) return
|
||||||
stepResults.value = []
|
stepResults.value = []
|
||||||
stepResults.value.push(newValue)
|
stepResults.value.push(newValue)
|
||||||
})
|
})
|
||||||
|
|
||||||
const importingMyCollections = ref(false)
|
watch(inputChooseGistToImportFrom, (url) => {
|
||||||
|
|
||||||
const importToTeams = async (content: HoppCollection<HoppRESTRequest>) => {
|
|
||||||
importingMyCollections.value = true
|
|
||||||
if (props.collectionsType.type !== "team-collections") return
|
|
||||||
|
|
||||||
const result = await runMutation(ImportFromJsonDocument, {
|
|
||||||
jsonString: JSON.stringify(content),
|
|
||||||
teamID: props.collectionsType.selectedTeam.id,
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (E.isLeft(result)) {
|
|
||||||
console.error(result.left)
|
|
||||||
} else {
|
|
||||||
emit("update-team-collections")
|
|
||||||
}
|
|
||||||
|
|
||||||
importingMyCollections.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportJSON = async () => {
|
|
||||||
await getJSONCollection()
|
|
||||||
|
|
||||||
const dataToWrite = collectionJson.value
|
|
||||||
const file = new Blob([dataToWrite], { type: "application/json" })
|
|
||||||
const a = document.createElement("a")
|
|
||||||
const url = URL.createObjectURL(file)
|
|
||||||
a.href = url
|
|
||||||
|
|
||||||
// TODO: get uri from meta
|
|
||||||
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
toast.success(t("state.download_started").toString())
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const importerModules = computed(() =>
|
|
||||||
RESTCollectionImporters.filter(
|
|
||||||
(i) => i.applicableTo?.includes(props.collectionsType.type) ?? true
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
const importerType = ref<number | null>(null)
|
|
||||||
|
|
||||||
const importerModule = computed(() =>
|
|
||||||
importerType.value !== null ? importerModules.value[importerType.value] : null
|
|
||||||
)
|
|
||||||
|
|
||||||
const importerSteps = computed(() => importerModule.value?.steps ?? null)
|
|
||||||
|
|
||||||
const finishImport = async () => {
|
|
||||||
await importerAction(stepResults.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const importerAction = async (stepResults: any[]) => {
|
|
||||||
if (!importerModule.value) return
|
|
||||||
const result = await importerModule.value?.importer(stepResults as any)()
|
|
||||||
if (E.isLeft(result)) {
|
|
||||||
failedImport()
|
|
||||||
console.error("error", result.left)
|
|
||||||
} else if (E.isRight(result)) {
|
|
||||||
if (props.collectionsType.type === "team-collections") {
|
|
||||||
importToTeams(result.right)
|
|
||||||
fileImported()
|
|
||||||
} else {
|
|
||||||
appendRESTCollections(result.right)
|
|
||||||
fileImported()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasFile = ref(false)
|
|
||||||
const hasGist = ref(false)
|
|
||||||
|
|
||||||
watch(inputChooseGistToImportFrom, (v) => {
|
|
||||||
stepResults.value = []
|
stepResults.value = []
|
||||||
if (v === "") {
|
if (url === "") {
|
||||||
hasGist.value = false
|
hasGist.value = false
|
||||||
} else {
|
} else {
|
||||||
hasGist.value = true
|
hasGist.value = true
|
||||||
@@ -385,17 +262,49 @@ watch(inputChooseGistToImportFrom, (v) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const myCollections = useReadonlyStream(restCollections$, [])
|
||||||
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
|
const importerAction = async (stepResults: StepReturnValue[]) => {
|
||||||
|
if (!importerModule.value) return
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
await importerModule.value.importer(stepResults as any)(),
|
||||||
|
E.match(
|
||||||
|
(err) => {
|
||||||
|
failedImport()
|
||||||
|
console.error("error", err)
|
||||||
|
},
|
||||||
|
(result) => {
|
||||||
|
if (props.collectionsType === "team-collections") {
|
||||||
|
emit("import-to-teams", result)
|
||||||
|
} else {
|
||||||
|
appendRESTCollections(result)
|
||||||
|
fileImported()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishImport = async () => {
|
||||||
|
await importerAction(stepResults.value)
|
||||||
|
}
|
||||||
|
|
||||||
const onFileChange = () => {
|
const onFileChange = () => {
|
||||||
stepResults.value = []
|
stepResults.value = []
|
||||||
if (!inputChooseFileToImportFrom.value[0]) {
|
|
||||||
|
const inputFileToImport = inputChooseFileToImportFrom.value[0]
|
||||||
|
|
||||||
|
if (!inputFileToImport) {
|
||||||
hasFile.value = false
|
hasFile.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
|
||||||
!inputChooseFileToImportFrom.value[0].files ||
|
|
||||||
inputChooseFileToImportFrom.value[0].files.length === 0
|
|
||||||
) {
|
|
||||||
inputChooseFileToImportFrom.value[0].value = ""
|
inputChooseFileToImportFrom.value[0].value = ""
|
||||||
hasFile.value = false
|
hasFile.value = false
|
||||||
toast.show(t("action.choose_file").toString())
|
toast.show(t("action.choose_file").toString())
|
||||||
@@ -403,6 +312,7 @@ const onFileChange = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
|
|
||||||
reader.onload = ({ target }) => {
|
reader.onload = ({ target }) => {
|
||||||
const content = target!.result as string | null
|
const content = target!.result as string | null
|
||||||
if (!content) {
|
if (!content) {
|
||||||
@@ -414,20 +324,29 @@ const onFileChange = () => {
|
|||||||
stepResults.value.push(content)
|
stepResults.value.push(content)
|
||||||
hasFile.value = !!content?.length
|
hasFile.value = !!content?.length
|
||||||
}
|
}
|
||||||
reader.readAsText(inputChooseFileToImportFrom.value[0].files[0])
|
|
||||||
|
reader.readAsText(inputFileToImport.files[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
const enableImportButton = computed(
|
const fileImported = () => {
|
||||||
() => !(stepResults.value.length === importerSteps.value?.length)
|
toast.success(t("state.file_imported").toString())
|
||||||
)
|
hideModal()
|
||||||
|
}
|
||||||
|
const failedImport = () => {
|
||||||
|
toast.error(t("import.failed").toString())
|
||||||
|
}
|
||||||
|
const hideModal = () => {
|
||||||
|
resetImport()
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
|
|
||||||
const resetImport = () => {
|
const resetImport = () => {
|
||||||
importerType.value = null
|
importerType.value = null
|
||||||
|
hasFile.value = false
|
||||||
|
hasGist.value = false
|
||||||
stepResults.value = []
|
stepResults.value = []
|
||||||
inputChooseFileToImportFrom.value = ""
|
inputChooseFileToImportFrom.value = ""
|
||||||
hasFile.value = false
|
|
||||||
inputChooseGistToImportFrom.value = ""
|
inputChooseGistToImportFrom.value = ""
|
||||||
hasGist.value = false
|
|
||||||
mySelectedCollectionID.value = undefined
|
mySelectedCollectionID.value = undefined
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,613 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col flex-1">
|
||||||
|
<div
|
||||||
|
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
|
||||||
|
:style="
|
||||||
|
saveRequest
|
||||||
|
? 'top: calc(var(--upper-primary-sticky-fold) - var(--line-height-body))'
|
||||||
|
: 'top: var(--upper-primary-sticky-fold)'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ButtonSecondary
|
||||||
|
:icon="IconPlus"
|
||||||
|
:label="t('action.new')"
|
||||||
|
class="!rounded-none"
|
||||||
|
@click="emit('display-modal-add')"
|
||||||
|
/>
|
||||||
|
<span class="flex">
|
||||||
|
<ButtonSecondary
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
to="https://docs.hoppscotch.io/features/collections"
|
||||||
|
blank
|
||||||
|
:title="t('app.wiki')"
|
||||||
|
:icon="IconHelpCircle"
|
||||||
|
/>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-if="!saveRequest"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:icon="IconArchive"
|
||||||
|
:title="t('modal.import_export')"
|
||||||
|
@click="emit('display-modal-import-export')"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col flex-1">
|
||||||
|
<SmartTree :adapter="myAdapter">
|
||||||
|
<template #content="{ node, toggleChildren, isOpen }">
|
||||||
|
<CollectionsCollection
|
||||||
|
v-if="node.data.type === 'collections'"
|
||||||
|
:data="node.data.data.data"
|
||||||
|
:collections-type="collectionsType.type"
|
||||||
|
:is-open="isOpen"
|
||||||
|
:is-selected="
|
||||||
|
isSelected({
|
||||||
|
collectionIndex: parseInt(node.id),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
folder-type="collection"
|
||||||
|
@add-request="
|
||||||
|
node.data.type === 'collections' &&
|
||||||
|
emit('add-request', {
|
||||||
|
path: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@add-folder="
|
||||||
|
node.data.type === 'collections' &&
|
||||||
|
emit('add-folder', {
|
||||||
|
path: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@edit-collection="
|
||||||
|
node.data.type === 'collections' &&
|
||||||
|
emit('edit-collection', {
|
||||||
|
collectionIndex: node.id,
|
||||||
|
collection: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@export-data="
|
||||||
|
node.data.type === 'collections' &&
|
||||||
|
emit('export-data', node.data.data.data)
|
||||||
|
"
|
||||||
|
@remove-collection="emit('remove-collection', node.id)"
|
||||||
|
@drop-event="dropEvent($event, node.id)"
|
||||||
|
@toggle-children="
|
||||||
|
() => {
|
||||||
|
toggleChildren(),
|
||||||
|
saveRequest &&
|
||||||
|
emit('select', {
|
||||||
|
pickedType: 'my-collection',
|
||||||
|
collectionIndex: parseInt(node.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<CollectionsCollection
|
||||||
|
v-if="node.data.type === 'folders'"
|
||||||
|
:data="node.data.data.data"
|
||||||
|
:collections-type="collectionsType.type"
|
||||||
|
:is-open="isOpen"
|
||||||
|
:is-selected="
|
||||||
|
isSelected({
|
||||||
|
folderPath: node.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
folder-type="folder"
|
||||||
|
@add-request="
|
||||||
|
node.data.type === 'folders' &&
|
||||||
|
emit('add-request', {
|
||||||
|
path: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@add-folder="
|
||||||
|
node.data.type === 'folders' &&
|
||||||
|
emit('add-folder', {
|
||||||
|
path: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@edit-collection="
|
||||||
|
node.data.type === 'folders' &&
|
||||||
|
emit('edit-folder', {
|
||||||
|
folderPath: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@export-data="
|
||||||
|
node.data.type === 'folders' &&
|
||||||
|
emit('export-data', node.data.data.data)
|
||||||
|
"
|
||||||
|
@remove-collection="emit('remove-folder', node.id)"
|
||||||
|
@drop-event="dropEvent($event, node.id)"
|
||||||
|
@toggle-children="
|
||||||
|
() => {
|
||||||
|
toggleChildren(),
|
||||||
|
saveRequest &&
|
||||||
|
emit('select', {
|
||||||
|
pickedType: 'my-folder',
|
||||||
|
folderPath: node.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<CollectionsRequest
|
||||||
|
v-if="node.data.type === 'requests'"
|
||||||
|
:request="node.data.data.data"
|
||||||
|
:collections-type="collectionsType.type"
|
||||||
|
:save-request="saveRequest"
|
||||||
|
:is-active="
|
||||||
|
isActiveRequest(
|
||||||
|
node.data.data.parentIndex,
|
||||||
|
parseInt(pathToIndex(node.id))
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:is-selected="
|
||||||
|
isSelected({
|
||||||
|
folderPath: node.data.data.parentIndex,
|
||||||
|
requestIndex: parseInt(pathToIndex(node.id)),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@edit-request="
|
||||||
|
node.data.type === 'requests' &&
|
||||||
|
emit('edit-request', {
|
||||||
|
folderPath: node.data.data.parentIndex,
|
||||||
|
requestIndex: pathToIndex(node.id),
|
||||||
|
request: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@duplicate-request="
|
||||||
|
node.data.type === 'requests' &&
|
||||||
|
emit('duplicate-request', {
|
||||||
|
folderPath: node.data.data.parentIndex,
|
||||||
|
request: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@remove-request="
|
||||||
|
node.data.type === 'requests' &&
|
||||||
|
emit('remove-request', {
|
||||||
|
folderPath: node.data.data.parentIndex,
|
||||||
|
requestIndex: pathToIndex(node.id),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@select-request="
|
||||||
|
node.data.type === 'requests' &&
|
||||||
|
selectRequest({
|
||||||
|
request: node.data.data.data,
|
||||||
|
folderPath: node.data.data.parentIndex,
|
||||||
|
requestIndex: pathToIndex(node.id),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@drag-request="
|
||||||
|
dragRequest($event, {
|
||||||
|
folderPath: node.data.data.parentIndex,
|
||||||
|
requestIndex: pathToIndex(node.id),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #emptyNode="{ node }">
|
||||||
|
<div
|
||||||
|
v-if="filterText.length !== 0 && filteredCollections.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||||
|
>
|
||||||
|
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||||
|
<span class="my-2 text-center">
|
||||||
|
{{ t("state.nothing_found") }} "{{ filterText }}"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="node === null">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||||
|
loading="lazy"
|
||||||
|
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||||
|
:alt="`${t('empty.collections')}`"
|
||||||
|
/>
|
||||||
|
<span class="pb-4 text-center">
|
||||||
|
{{ t("empty.collections") }}
|
||||||
|
</span>
|
||||||
|
<ButtonSecondary
|
||||||
|
:label="t('add.new')"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="emit('display-modal-add')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="node.data.type === 'collections'"
|
||||||
|
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||||
|
loading="lazy"
|
||||||
|
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||||
|
:alt="`${t('empty.collection')}`"
|
||||||
|
/>
|
||||||
|
<span class="pb-4 text-center">
|
||||||
|
{{ t("empty.collection") }}
|
||||||
|
</span>
|
||||||
|
<ButtonSecondary
|
||||||
|
:label="t('add.new')"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="
|
||||||
|
node.data.type === 'collections' &&
|
||||||
|
emit('add-folder', {
|
||||||
|
path: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="node.data.type === 'folders'"
|
||||||
|
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||||
|
loading="lazy"
|
||||||
|
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||||
|
:alt="`${t('empty.folder')}`"
|
||||||
|
/>
|
||||||
|
<span class="text-center">
|
||||||
|
{{ t("empty.folder") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SmartTree>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconArchive from "~icons/lucide/archive"
|
||||||
|
import IconPlus from "~icons/lucide/plus"
|
||||||
|
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||||
|
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||||
|
import { computed, PropType, Ref, toRef } from "vue"
|
||||||
|
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||||
|
import { ChildrenResult, SmartTreeAdapter } from "~/helpers/treeAdapter"
|
||||||
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import { useColorMode } from "@composables/theming"
|
||||||
|
import { useReadonlyStream } from "~/composables/stream"
|
||||||
|
import { restSaveContext$ } from "~/newstore/RESTSession"
|
||||||
|
import { pipe } from "fp-ts/function"
|
||||||
|
import * as O from "fp-ts/Option"
|
||||||
|
import { Picked } from "~/helpers/types/HoppPicked.js"
|
||||||
|
|
||||||
|
export type Collection = {
|
||||||
|
type: "collections"
|
||||||
|
data: {
|
||||||
|
parentIndex: null
|
||||||
|
data: HoppCollection<HoppRESTRequest>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Folder = {
|
||||||
|
type: "folders"
|
||||||
|
data: {
|
||||||
|
parentIndex: string
|
||||||
|
data: HoppCollection<HoppRESTRequest>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Requests = {
|
||||||
|
type: "requests"
|
||||||
|
data: {
|
||||||
|
parentIndex: string
|
||||||
|
data: HoppRESTRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||||
|
|
||||||
|
type CollectionType =
|
||||||
|
| {
|
||||||
|
type: "team-collections"
|
||||||
|
selectedTeam: SelectedTeam
|
||||||
|
}
|
||||||
|
| { type: "my-collections"; selectedTeam: undefined }
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
filteredCollections: {
|
||||||
|
type: Array as PropType<HoppCollection<HoppRESTRequest>[]>,
|
||||||
|
default: () => [],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
collectionsType: {
|
||||||
|
type: Object as PropType<CollectionType>,
|
||||||
|
default: () => ({ type: "my-collections", selectedTeam: undefined }),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
filterText: {
|
||||||
|
type: String as PropType<string>,
|
||||||
|
default: "",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
saveRequest: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
picked: {
|
||||||
|
type: Object as PropType<Picked | null>,
|
||||||
|
default: null,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "display-modal-add"): void
|
||||||
|
(
|
||||||
|
event: "add-request",
|
||||||
|
payload: {
|
||||||
|
path: string
|
||||||
|
folder: HoppCollection<HoppRESTRequest>
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "add-folder",
|
||||||
|
payload: {
|
||||||
|
path: string
|
||||||
|
folder: HoppCollection<HoppRESTRequest>
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "edit-collection",
|
||||||
|
payload: {
|
||||||
|
collectionIndex: string
|
||||||
|
collection: HoppCollection<HoppRESTRequest>
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "edit-folder",
|
||||||
|
payload: {
|
||||||
|
folderPath: string
|
||||||
|
folder: HoppCollection<HoppRESTRequest>
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "edit-request",
|
||||||
|
payload: {
|
||||||
|
folderPath: string
|
||||||
|
requestIndex: string
|
||||||
|
request: HoppRESTRequest
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "duplicate-request",
|
||||||
|
payload: {
|
||||||
|
folderPath: string
|
||||||
|
request: HoppRESTRequest
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(event: "export-data", payload: HoppCollection<HoppRESTRequest>): void
|
||||||
|
(event: "remove-collection", payload: string): void
|
||||||
|
(event: "remove-folder", payload: string): void
|
||||||
|
(
|
||||||
|
event: "remove-request",
|
||||||
|
payload: {
|
||||||
|
folderPath: string | null
|
||||||
|
requestIndex: string
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "select-request",
|
||||||
|
payload: {
|
||||||
|
request: HoppRESTRequest
|
||||||
|
folderPath: string
|
||||||
|
requestIndex: string
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "drop-request",
|
||||||
|
payload: {
|
||||||
|
folderPath: string
|
||||||
|
requestIndex: string
|
||||||
|
collectionIndex: string
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(event: "select", payload: Picked | null): void
|
||||||
|
(event: "display-modal-import-export"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const refFilterCollection = toRef(props, "filteredCollections")
|
||||||
|
|
||||||
|
const pathToIndex = computed(() => {
|
||||||
|
return (path: string) => {
|
||||||
|
const pathArr = path.split("/")
|
||||||
|
return pathArr[pathArr.length - 1]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSelected = computed(() => {
|
||||||
|
return ({
|
||||||
|
collectionIndex,
|
||||||
|
folderPath,
|
||||||
|
requestIndex,
|
||||||
|
}: {
|
||||||
|
collectionIndex?: number | undefined
|
||||||
|
folderPath?: string | undefined
|
||||||
|
requestIndex?: number | undefined
|
||||||
|
}) => {
|
||||||
|
if (collectionIndex !== undefined) {
|
||||||
|
return (
|
||||||
|
props.picked &&
|
||||||
|
props.picked.pickedType === "my-collection" &&
|
||||||
|
props.picked.collectionIndex === collectionIndex
|
||||||
|
)
|
||||||
|
} else if (requestIndex !== undefined && folderPath !== undefined) {
|
||||||
|
return (
|
||||||
|
props.picked &&
|
||||||
|
props.picked.pickedType === "my-request" &&
|
||||||
|
props.picked.folderPath === folderPath &&
|
||||||
|
props.picked.requestIndex === requestIndex
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
props.picked &&
|
||||||
|
props.picked.pickedType === "my-folder" &&
|
||||||
|
props.picked.folderPath === folderPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const active = useReadonlyStream(restSaveContext$, null)
|
||||||
|
|
||||||
|
const isActiveRequest = computed(() => {
|
||||||
|
return (folderPath: string, requestIndex: number) => {
|
||||||
|
return pipe(
|
||||||
|
active.value,
|
||||||
|
O.fromNullable,
|
||||||
|
O.filter(
|
||||||
|
(active) =>
|
||||||
|
active.originLocation === "user-collection" &&
|
||||||
|
active.folderPath === folderPath &&
|
||||||
|
active.requestIndex === requestIndex
|
||||||
|
),
|
||||||
|
O.isSome
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectRequest = (data: {
|
||||||
|
request: HoppRESTRequest
|
||||||
|
folderPath: string
|
||||||
|
requestIndex: string
|
||||||
|
}) => {
|
||||||
|
const { request, folderPath, requestIndex } = data
|
||||||
|
if (props.saveRequest) {
|
||||||
|
emit("select", {
|
||||||
|
pickedType: "my-request",
|
||||||
|
folderPath: folderPath,
|
||||||
|
requestIndex: parseInt(requestIndex),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emit("select-request", {
|
||||||
|
request,
|
||||||
|
folderPath,
|
||||||
|
requestIndex,
|
||||||
|
isActive: isActiveRequest.value(folderPath, parseInt(requestIndex)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragRequest = (
|
||||||
|
dataTransfer: DataTransfer,
|
||||||
|
{
|
||||||
|
folderPath,
|
||||||
|
requestIndex,
|
||||||
|
}: { folderPath: string | null; requestIndex: string }
|
||||||
|
) => {
|
||||||
|
if (!folderPath) return
|
||||||
|
dataTransfer.setData("folderPath", folderPath)
|
||||||
|
dataTransfer.setData("requestIndex", requestIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropEvent = (dataTransfer: DataTransfer, collectionIndex: string) => {
|
||||||
|
const folderPath = dataTransfer.getData("folderPath")
|
||||||
|
const requestIndex = dataTransfer.getData("requestIndex")
|
||||||
|
emit("drop-request", {
|
||||||
|
folderPath,
|
||||||
|
requestIndex,
|
||||||
|
collectionIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type MyCollectionNode = Collection | Folder | Requests
|
||||||
|
|
||||||
|
class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {
|
||||||
|
constructor(public data: Ref<HoppCollection<HoppRESTRequest>[]>) {}
|
||||||
|
|
||||||
|
navigateToFolderWithIndexPath(
|
||||||
|
collections: HoppCollection<HoppRESTRequest>[],
|
||||||
|
indexPaths: number[]
|
||||||
|
) {
|
||||||
|
if (indexPaths.length === 0) return null
|
||||||
|
|
||||||
|
let target = collections[indexPaths.shift() as number]
|
||||||
|
|
||||||
|
while (indexPaths.length > 0)
|
||||||
|
target = target.folders[indexPaths.shift() as number]
|
||||||
|
|
||||||
|
return target !== undefined ? target : null
|
||||||
|
}
|
||||||
|
|
||||||
|
getChildren(id: string | null): Ref<ChildrenResult<MyCollectionNode>> {
|
||||||
|
return computed(() => {
|
||||||
|
if (id === null) {
|
||||||
|
const data = this.data.value.map((item, index) => ({
|
||||||
|
id: index.toString(),
|
||||||
|
data: {
|
||||||
|
type: "collections",
|
||||||
|
data: {
|
||||||
|
parentIndex: null,
|
||||||
|
data: item,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
status: "loaded",
|
||||||
|
data: data,
|
||||||
|
} as ChildrenResult<Collection>
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexPath = id.split("/").map((x) => parseInt(x))
|
||||||
|
|
||||||
|
const item = this.navigateToFolderWithIndexPath(
|
||||||
|
this.data.value,
|
||||||
|
indexPath
|
||||||
|
)
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
const data = [
|
||||||
|
...item.folders.map((item, index) => ({
|
||||||
|
id: `${id}/${index}`,
|
||||||
|
data: {
|
||||||
|
type: "folders",
|
||||||
|
data: {
|
||||||
|
parentIndex: id,
|
||||||
|
data: item,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...item.requests.map((item, index) => ({
|
||||||
|
id: `${id}/${index}`,
|
||||||
|
data: {
|
||||||
|
type: "requests",
|
||||||
|
data: {
|
||||||
|
parentIndex: id,
|
||||||
|
data: item,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "loaded",
|
||||||
|
data: data,
|
||||||
|
} as ChildrenResult<Folder | Requests>
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
status: "loaded",
|
||||||
|
data: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const myAdapter: SmartTreeAdapter<MyCollectionNode> = new MyCollectionsAdapter(
|
||||||
|
refFilterCollection
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
||||||
|
<div
|
||||||
|
class="flex items-stretch group"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart="dragStart"
|
||||||
|
@dragover.stop
|
||||||
|
@dragleave="dragging = false"
|
||||||
|
@dragend="dragging = false"
|
||||||
|
@contextmenu.prevent="options?.tippy.show()"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
||||||
|
:class="requestLabelColor"
|
||||||
|
@click="selectRequest()"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="IconCheckCircle"
|
||||||
|
v-if="isSelected"
|
||||||
|
class="svg-icons"
|
||||||
|
:class="{ 'text-accent': isSelected }"
|
||||||
|
/>
|
||||||
|
<span v-else class="font-semibold truncate text-tiny">
|
||||||
|
{{ request.method }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
||||||
|
@click="selectRequest()"
|
||||||
|
>
|
||||||
|
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
||||||
|
{{ request.name }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="isActive"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
|
||||||
|
:title="`${t('collection.request_in_use')}`"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
|
||||||
|
></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div v-if="!hasNoTeamAccess" class="flex">
|
||||||
|
<ButtonSecondary
|
||||||
|
v-if="!saveRequest"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:icon="IconRotateCCW"
|
||||||
|
:title="t('action.restore')"
|
||||||
|
class="hidden group-hover:inline-flex"
|
||||||
|
@click="selectRequest()"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<tippy
|
||||||
|
ref="options"
|
||||||
|
interactive
|
||||||
|
trigger="click"
|
||||||
|
theme="popover"
|
||||||
|
:on-shown="() => tippyActions!.focus()"
|
||||||
|
>
|
||||||
|
<ButtonSecondary
|
||||||
|
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.e="edit?.$el.click()"
|
||||||
|
@keyup.d="duplicate?.$el.click()"
|
||||||
|
@keyup.delete="deleteAction?.$el.click()"
|
||||||
|
@keyup.escape="hide()"
|
||||||
|
>
|
||||||
|
<SmartItem
|
||||||
|
ref="edit"
|
||||||
|
:icon="IconEdit"
|
||||||
|
:label="t('action.edit')"
|
||||||
|
:shortcut="['E']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('edit-request')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<SmartItem
|
||||||
|
ref="duplicate"
|
||||||
|
:icon="IconCopy"
|
||||||
|
:label="t('action.duplicate')"
|
||||||
|
:loading="duplicateLoading"
|
||||||
|
:shortcut="['D']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('duplicate-request'),
|
||||||
|
collectionsType === 'my-collections' ? hide() : null
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<SmartItem
|
||||||
|
ref="deleteAction"
|
||||||
|
:icon="IconTrash2"
|
||||||
|
:label="t('action.delete')"
|
||||||
|
:shortcut="['⌫']"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
emit('remove-request')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</tippy>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||||
|
import IconMoreVertical from "~icons/lucide/more-vertical"
|
||||||
|
import IconEdit from "~icons/lucide/edit"
|
||||||
|
import IconCopy from "~icons/lucide/copy"
|
||||||
|
import IconTrash2 from "~icons/lucide/trash-2"
|
||||||
|
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
||||||
|
import { ref, PropType, watch, computed } from "vue"
|
||||||
|
import { HoppRESTRequest } from "@hoppscotch/data"
|
||||||
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import { TippyComponent } from "vue-tippy"
|
||||||
|
import { pipe } from "fp-ts/function"
|
||||||
|
import * as RR from "fp-ts/ReadonlyRecord"
|
||||||
|
import * as O from "fp-ts/Option"
|
||||||
|
|
||||||
|
type CollectionType = "my-collections" | "team-collections"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
request: {
|
||||||
|
type: Object as PropType<HoppRESTRequest>,
|
||||||
|
default: () => ({}),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
collectionsType: {
|
||||||
|
type: String as PropType<CollectionType>,
|
||||||
|
default: "my-collections",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
duplicateLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
saveRequest: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
hasNoTeamAccess: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
isSelected: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "edit-request"): void
|
||||||
|
(event: "duplicate-request"): void
|
||||||
|
(event: "remove-request"): void
|
||||||
|
(event: "select-request"): void
|
||||||
|
(event: "drag-request", payload: DataTransfer): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tippyActions = ref<TippyComponent | null>(null)
|
||||||
|
const edit = ref<HTMLButtonElement | null>(null)
|
||||||
|
const deleteAction = ref<HTMLButtonElement | null>(null)
|
||||||
|
const options = ref<TippyComponent | null>(null)
|
||||||
|
const duplicate = ref<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
const dragging = ref(false)
|
||||||
|
|
||||||
|
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 requestLabelColor = computed(() =>
|
||||||
|
pipe(
|
||||||
|
requestMethodLabels,
|
||||||
|
RR.lookup(props.request.method.toLowerCase()),
|
||||||
|
O.getOrElseW(() => requestMethodLabels.default)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.duplicateLoading,
|
||||||
|
(val) => {
|
||||||
|
if (!val) {
|
||||||
|
options.value!.tippy.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectRequest = () => {
|
||||||
|
emit("select-request")
|
||||||
|
}
|
||||||
|
|
||||||
|
const dragStart = ({ dataTransfer }: DragEvent) => {
|
||||||
|
if (dataTransfer) {
|
||||||
|
dragging.value = !dragging.value
|
||||||
|
emit("drag-request", dataTransfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -27,9 +27,8 @@
|
|||||||
</label>
|
</label>
|
||||||
<CollectionsGraphql
|
<CollectionsGraphql
|
||||||
v-if="mode === 'graphql'"
|
v-if="mode === 'graphql'"
|
||||||
:show-coll-actions="false"
|
|
||||||
:picked="picked"
|
:picked="picked"
|
||||||
:saving-mode="true"
|
:save-request="true"
|
||||||
@select="onSelect"
|
@select="onSelect"
|
||||||
/>
|
/>
|
||||||
<Collections
|
<Collections
|
||||||
@@ -37,8 +36,8 @@
|
|||||||
:picked="picked"
|
:picked="picked"
|
||||||
:save-request="true"
|
:save-request="true"
|
||||||
@select="onSelect"
|
@select="onSelect"
|
||||||
@update-collection="updateColl"
|
@update-team="updateTeam"
|
||||||
@update-coll-type="onUpdateCollType"
|
@update-collection-type="updateCollectionType"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -46,6 +45,7 @@
|
|||||||
<span class="flex space-x-2">
|
<span class="flex space-x-2">
|
||||||
<ButtonPrimary
|
<ButtonPrimary
|
||||||
:label="`${t('action.save')}`"
|
:label="`${t('action.save')}`"
|
||||||
|
:loading="modalLoadingState"
|
||||||
outline
|
outline
|
||||||
@click="saveRequestAs"
|
@click="saveRequestAs"
|
||||||
/>
|
/>
|
||||||
@@ -61,99 +61,75 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, watch } from "vue"
|
import { useI18n } from "@composables/i18n"
|
||||||
import * as E from "fp-ts/Either"
|
import { useToast } from "@composables/toast"
|
||||||
import { HoppGQLRequest, isHoppRESTRequest } from "@hoppscotch/data"
|
|
||||||
import { cloneDeep } from "lodash-es"
|
|
||||||
import {
|
import {
|
||||||
editGraphqlRequest,
|
HoppGQLRequest,
|
||||||
editRESTRequest,
|
HoppRESTRequest,
|
||||||
saveGraphqlRequestAs,
|
isHoppRESTRequest,
|
||||||
saveRESTRequestAs,
|
} from "@hoppscotch/data"
|
||||||
} from "~/newstore/collections"
|
import { pipe } from "fp-ts/function"
|
||||||
|
import * as TE from "fp-ts/TaskEither"
|
||||||
|
import { cloneDeep } from "lodash-es"
|
||||||
|
import { reactive, ref, watch } from "vue"
|
||||||
|
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||||
|
import {
|
||||||
|
createRequestInCollection,
|
||||||
|
updateTeamRequest,
|
||||||
|
} from "~/helpers/backend/mutations/TeamRequest"
|
||||||
|
import { Picked } from "~/helpers/types/HoppPicked"
|
||||||
import { getGQLSession, useGQLRequestName } from "~/newstore/GQLSession"
|
import { getGQLSession, useGQLRequestName } from "~/newstore/GQLSession"
|
||||||
import {
|
import {
|
||||||
getRESTRequest,
|
getRESTRequest,
|
||||||
setRESTSaveContext,
|
setRESTSaveContext,
|
||||||
useRESTRequestName,
|
useRESTRequestName,
|
||||||
} from "~/newstore/RESTSession"
|
} from "~/newstore/RESTSession"
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
import { useToast } from "@composables/toast"
|
|
||||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
|
||||||
import {
|
import {
|
||||||
CreateRequestInCollectionDocument,
|
editGraphqlRequest,
|
||||||
UpdateRequestDocument,
|
editRESTRequest,
|
||||||
} from "~/helpers/backend/graphql"
|
saveGraphqlRequestAs,
|
||||||
|
saveRESTRequestAs,
|
||||||
|
} from "~/newstore/collections"
|
||||||
|
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||||
|
|
||||||
type CollectionType =
|
type CollectionType =
|
||||||
| {
|
|
||||||
type: "my-collections"
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: "team-collections"
|
type: "team-collections"
|
||||||
// TODO: Figure this type out
|
selectedTeam: SelectedTeam
|
||||||
selectedTeam: {
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
| { type: "my-collections"; selectedTeam: undefined }
|
||||||
|
|
||||||
type Picked =
|
const props = withDefaults(
|
||||||
| {
|
defineProps<{
|
||||||
pickedType: "my-request"
|
show: boolean
|
||||||
folderPath: string
|
mode: "rest" | "graphql"
|
||||||
requestIndex: number
|
}>(),
|
||||||
}
|
{
|
||||||
| {
|
show: false,
|
||||||
pickedType: "my-folder"
|
mode: "rest",
|
||||||
folderPath: string
|
}
|
||||||
}
|
)
|
||||||
| {
|
|
||||||
pickedType: "my-collection"
|
|
||||||
collectionIndex: number
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
pickedType: "teams-request"
|
|
||||||
requestID: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
pickedType: "teams-folder"
|
|
||||||
folderID: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
pickedType: "teams-collection"
|
|
||||||
collectionID: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
pickedType: "gql-my-request"
|
|
||||||
folderPath: string
|
|
||||||
requestIndex: number
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
pickedType: "gql-my-folder"
|
|
||||||
folderPath: string
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
pickedType: "gql-my-collection"
|
|
||||||
collectionIndex: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
mode: "rest" | "graphql"
|
|
||||||
show: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
(
|
||||||
|
event: "edit-request",
|
||||||
|
payload: {
|
||||||
|
folderPath: string
|
||||||
|
requestIndex: string
|
||||||
|
request: HoppRESTRequest
|
||||||
|
}
|
||||||
|
): void
|
||||||
(e: "hide-modal"): void
|
(e: "hide-modal"): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const toast = useToast()
|
const requestName = ref(
|
||||||
|
|
||||||
// TODO: Use a better implementation with computed ?
|
|
||||||
// This implementation can't work across updates to mode prop (which won't happen tho)
|
|
||||||
const requestName =
|
|
||||||
props.mode === "rest" ? useRESTRequestName() : useGQLRequestName()
|
props.mode === "rest" ? useRESTRequestName() : useGQLRequestName()
|
||||||
|
)
|
||||||
|
|
||||||
const requestData = reactive({
|
const requestData = reactive({
|
||||||
name: requestName,
|
name: requestName,
|
||||||
@@ -164,11 +140,13 @@ const requestData = reactive({
|
|||||||
|
|
||||||
const collectionsType = ref<CollectionType>({
|
const collectionsType = ref<CollectionType>({
|
||||||
type: "my-collections",
|
type: "my-collections",
|
||||||
|
selectedTeam: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO: Figure this type out
|
|
||||||
const picked = ref<Picked | null>(null)
|
const picked = ref<Picked | null>(null)
|
||||||
|
|
||||||
|
const modalLoadingState = ref(false)
|
||||||
|
|
||||||
// Resets
|
// Resets
|
||||||
watch(
|
watch(
|
||||||
() => requestData.collectionIndex,
|
() => requestData.collectionIndex,
|
||||||
@@ -184,20 +162,18 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// All the methods
|
const updateTeam = (newTeam: SelectedTeam) => {
|
||||||
const onUpdateCollType = (newCollType: CollectionType) => {
|
collectionsType.value.selectedTeam = newTeam
|
||||||
collectionsType.value = newCollType
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSelect = ({ picked: pickedVal }: { picked: Picked | null }) => {
|
const updateCollectionType = (type: CollectionType["type"]) => {
|
||||||
|
collectionsType.value.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelect = (pickedVal: Picked | null) => {
|
||||||
picked.value = pickedVal
|
picked.value = pickedVal
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideModal = () => {
|
|
||||||
picked.value = null
|
|
||||||
emit("hide-modal")
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveRequestAs = async () => {
|
const saveRequestAs = async () => {
|
||||||
if (!requestName.value) {
|
if (!requestName.value) {
|
||||||
toast.error(`${t("error.empty_req_name")}`)
|
toast.error(`${t("error.empty_req_name")}`)
|
||||||
@@ -208,35 +184,25 @@ const saveRequestAs = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone Deep because objects are shared by reference so updating
|
|
||||||
// just one bit will update other referenced shared instances
|
|
||||||
const requestUpdated =
|
const requestUpdated =
|
||||||
props.mode === "rest"
|
props.mode === "rest"
|
||||||
? cloneDeep(getRESTRequest())
|
? cloneDeep(getRESTRequest())
|
||||||
: cloneDeep(getGQLSession().request)
|
: cloneDeep(getGQLSession().request)
|
||||||
|
|
||||||
// // Filter out all REST file inputs
|
if (picked.value.pickedType === "my-collection") {
|
||||||
// if (this.mode === "rest" && requestUpdated.bodyParams) {
|
|
||||||
// requestUpdated.bodyParams = requestUpdated.bodyParams.map((param) =>
|
|
||||||
// param?.value?.[0] instanceof File ? { ...param, value: "" } : param
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (picked.value.pickedType === "my-request") {
|
|
||||||
if (!isHoppRESTRequest(requestUpdated))
|
if (!isHoppRESTRequest(requestUpdated))
|
||||||
throw new Error("requestUpdated is not a REST Request")
|
throw new Error("requestUpdated is not a REST Request")
|
||||||
|
|
||||||
editRESTRequest(
|
const insertionIndex = saveRESTRequestAs(
|
||||||
picked.value.folderPath,
|
`${picked.value.collectionIndex}`,
|
||||||
picked.value.requestIndex,
|
|
||||||
requestUpdated
|
requestUpdated
|
||||||
)
|
)
|
||||||
|
|
||||||
setRESTSaveContext({
|
setRESTSaveContext({
|
||||||
originLocation: "user-collection",
|
originLocation: "user-collection",
|
||||||
folderPath: picked.value.folderPath,
|
folderPath: `${picked.value.collectionIndex}`,
|
||||||
requestIndex: picked.value.requestIndex,
|
requestIndex: insertionIndex,
|
||||||
req: cloneDeep(requestUpdated),
|
req: requestUpdated,
|
||||||
})
|
})
|
||||||
|
|
||||||
requestSaved()
|
requestSaved()
|
||||||
@@ -253,114 +219,68 @@ const saveRequestAs = async () => {
|
|||||||
originLocation: "user-collection",
|
originLocation: "user-collection",
|
||||||
folderPath: picked.value.folderPath,
|
folderPath: picked.value.folderPath,
|
||||||
requestIndex: insertionIndex,
|
requestIndex: insertionIndex,
|
||||||
req: cloneDeep(requestUpdated),
|
req: requestUpdated,
|
||||||
})
|
})
|
||||||
|
|
||||||
requestSaved()
|
requestSaved()
|
||||||
} else if (picked.value.pickedType === "my-collection") {
|
} else if (picked.value.pickedType === "my-request") {
|
||||||
if (!isHoppRESTRequest(requestUpdated))
|
if (!isHoppRESTRequest(requestUpdated))
|
||||||
throw new Error("requestUpdated is not a REST Request")
|
throw new Error("requestUpdated is not a REST Request")
|
||||||
|
|
||||||
const insertionIndex = saveRESTRequestAs(
|
editRESTRequest(
|
||||||
`${picked.value.collectionIndex}`,
|
picked.value.folderPath,
|
||||||
|
picked.value.requestIndex,
|
||||||
requestUpdated
|
requestUpdated
|
||||||
)
|
)
|
||||||
|
|
||||||
setRESTSaveContext({
|
setRESTSaveContext({
|
||||||
originLocation: "user-collection",
|
originLocation: "user-collection",
|
||||||
folderPath: `${picked.value.collectionIndex}`,
|
folderPath: picked.value.folderPath,
|
||||||
requestIndex: insertionIndex,
|
requestIndex: picked.value.requestIndex,
|
||||||
req: cloneDeep(requestUpdated),
|
req: requestUpdated,
|
||||||
})
|
})
|
||||||
|
|
||||||
requestSaved()
|
requestSaved()
|
||||||
} else if (picked.value.pickedType === "teams-request") {
|
|
||||||
if (!isHoppRESTRequest(requestUpdated))
|
|
||||||
throw new Error("requestUpdated is not a REST Request")
|
|
||||||
|
|
||||||
if (collectionsType.value.type !== "team-collections")
|
|
||||||
throw new Error("Collections Type mismatch")
|
|
||||||
|
|
||||||
runMutation(UpdateRequestDocument, {
|
|
||||||
requestID: picked.value.requestID,
|
|
||||||
data: {
|
|
||||||
request: JSON.stringify(requestUpdated),
|
|
||||||
title: requestUpdated.name,
|
|
||||||
},
|
|
||||||
})().then((result) => {
|
|
||||||
if (E.isLeft(result)) {
|
|
||||||
toast.error(`${t("profile.no_permission")}`)
|
|
||||||
throw new Error(`${result.left}`)
|
|
||||||
} else {
|
|
||||||
requestSaved()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setRESTSaveContext({
|
|
||||||
originLocation: "team-collection",
|
|
||||||
requestID: picked.value.requestID,
|
|
||||||
req: cloneDeep(requestUpdated),
|
|
||||||
})
|
|
||||||
} else if (picked.value.pickedType === "teams-folder") {
|
|
||||||
if (!isHoppRESTRequest(requestUpdated))
|
|
||||||
throw new Error("requestUpdated is not a REST Request")
|
|
||||||
|
|
||||||
if (collectionsType.value.type !== "team-collections")
|
|
||||||
throw new Error("Collections Type mismatch")
|
|
||||||
|
|
||||||
const result = await runMutation(CreateRequestInCollectionDocument, {
|
|
||||||
collectionID: picked.value.folderID,
|
|
||||||
data: {
|
|
||||||
request: JSON.stringify(requestUpdated),
|
|
||||||
teamID: collectionsType.value.selectedTeam.id,
|
|
||||||
title: requestUpdated.name,
|
|
||||||
},
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (E.isLeft(result)) {
|
|
||||||
toast.error(`${t("profile.no_permission")}`)
|
|
||||||
console.error(result.left)
|
|
||||||
} else {
|
|
||||||
setRESTSaveContext({
|
|
||||||
originLocation: "team-collection",
|
|
||||||
requestID: result.right.createRequestInCollection.id,
|
|
||||||
teamID: collectionsType.value.selectedTeam.id,
|
|
||||||
collectionID: picked.value.folderID,
|
|
||||||
req: cloneDeep(requestUpdated),
|
|
||||||
})
|
|
||||||
|
|
||||||
requestSaved()
|
|
||||||
}
|
|
||||||
} else if (picked.value.pickedType === "teams-collection") {
|
} else if (picked.value.pickedType === "teams-collection") {
|
||||||
if (!isHoppRESTRequest(requestUpdated))
|
if (!isHoppRESTRequest(requestUpdated))
|
||||||
throw new Error("requestUpdated is not a REST Request")
|
throw new Error("requestUpdated is not a REST Request")
|
||||||
|
|
||||||
if (collectionsType.value.type !== "team-collections")
|
updateTeamCollectionOrFolder(picked.value.collectionID, requestUpdated)
|
||||||
|
} else if (picked.value.pickedType === "teams-folder") {
|
||||||
|
if (!isHoppRESTRequest(requestUpdated))
|
||||||
|
throw new Error("requestUpdated is not a REST Request")
|
||||||
|
|
||||||
|
updateTeamCollectionOrFolder(picked.value.folderID, requestUpdated)
|
||||||
|
} else if (picked.value.pickedType === "teams-request") {
|
||||||
|
if (!isHoppRESTRequest(requestUpdated))
|
||||||
|
throw new Error("requestUpdated is not a REST Request")
|
||||||
|
|
||||||
|
if (
|
||||||
|
collectionsType.value.type !== "team-collections" ||
|
||||||
|
!collectionsType.value.selectedTeam
|
||||||
|
)
|
||||||
throw new Error("Collections Type mismatch")
|
throw new Error("Collections Type mismatch")
|
||||||
|
|
||||||
const result = await runMutation(CreateRequestInCollectionDocument, {
|
modalLoadingState.value = true
|
||||||
collectionID: picked.value.collectionID,
|
|
||||||
data: {
|
|
||||||
title: requestUpdated.name,
|
|
||||||
request: JSON.stringify(requestUpdated),
|
|
||||||
teamID: collectionsType.value.selectedTeam.id,
|
|
||||||
},
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (E.isLeft(result)) {
|
const data = {
|
||||||
toast.error(`${t("profile.no_permission")}`)
|
request: JSON.stringify(requestUpdated),
|
||||||
console.error(result.left)
|
title: requestUpdated.name,
|
||||||
} else {
|
|
||||||
setRESTSaveContext({
|
|
||||||
originLocation: "team-collection",
|
|
||||||
requestID: result.right.createRequestInCollection.id,
|
|
||||||
teamID: collectionsType.value.selectedTeam.id,
|
|
||||||
collectionID: picked.value.collectionID,
|
|
||||||
req: cloneDeep(requestUpdated),
|
|
||||||
})
|
|
||||||
|
|
||||||
requestSaved()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pipe(
|
||||||
|
updateTeamRequest(picked.value.requestID, data),
|
||||||
|
TE.match(
|
||||||
|
(err: GQLError<string>) => {
|
||||||
|
toast.error(`${getErrorMessage(err)}`)
|
||||||
|
modalLoadingState.value = false
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
modalLoadingState.value = false
|
||||||
|
requestSaved()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)()
|
||||||
} else if (picked.value.pickedType === "gql-my-request") {
|
} else if (picked.value.pickedType === "gql-my-request") {
|
||||||
// TODO: Check for GQL request ?
|
// TODO: Check for GQL request ?
|
||||||
editGraphqlRequest(
|
editGraphqlRequest(
|
||||||
@@ -389,12 +309,81 @@ const saveRequestAs = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a team collection or folder and sets the save context to the updated request
|
||||||
|
* @param collectionID - ID of the collection or folder
|
||||||
|
* @param requestUpdated - Updated request
|
||||||
|
*/
|
||||||
|
const updateTeamCollectionOrFolder = (
|
||||||
|
collectionID: string,
|
||||||
|
requestUpdated: HoppRESTRequest
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
collectionsType.value.type !== "team-collections" ||
|
||||||
|
!collectionsType.value.selectedTeam
|
||||||
|
)
|
||||||
|
throw new Error("Collections Type mismatch")
|
||||||
|
|
||||||
|
modalLoadingState.value = true
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: requestUpdated.name,
|
||||||
|
request: JSON.stringify(requestUpdated),
|
||||||
|
teamID: collectionsType.value.selectedTeam.id,
|
||||||
|
}
|
||||||
|
pipe(
|
||||||
|
createRequestInCollection(collectionID, data),
|
||||||
|
TE.match(
|
||||||
|
(err: GQLError<string>) => {
|
||||||
|
toast.error(`${getErrorMessage(err)}`)
|
||||||
|
modalLoadingState.value = false
|
||||||
|
},
|
||||||
|
(result) => {
|
||||||
|
const { createRequestInCollection } = result
|
||||||
|
|
||||||
|
setRESTSaveContext({
|
||||||
|
originLocation: "team-collection",
|
||||||
|
requestID: createRequestInCollection.id,
|
||||||
|
collectionID: createRequestInCollection.collection.id,
|
||||||
|
teamID: createRequestInCollection.collection.team.id,
|
||||||
|
req: requestUpdated,
|
||||||
|
})
|
||||||
|
modalLoadingState.value = false
|
||||||
|
requestSaved()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
|
||||||
const requestSaved = () => {
|
const requestSaved = () => {
|
||||||
toast.success(`${t("request.added")}`)
|
toast.success(`${t("request.added")}`)
|
||||||
hideModal()
|
hideModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateColl = (ev: CollectionType["type"]) => {
|
const hideModal = () => {
|
||||||
collectionsType.value.type = ev
|
picked.value = null
|
||||||
|
emit("hide-modal")
|
||||||
|
}
|
||||||
|
|
||||||
|
const getErrorMessage = (err: GQLError<string>) => {
|
||||||
|
console.error(err)
|
||||||
|
if (err.type === "network_error") {
|
||||||
|
return t("error.network_error")
|
||||||
|
} else {
|
||||||
|
switch (err.error) {
|
||||||
|
case "team_coll/short_title":
|
||||||
|
return t("collection.name_length_insufficient")
|
||||||
|
case "team/invalid_coll_id":
|
||||||
|
return t("team.invalid_id")
|
||||||
|
case "team/not_required_role":
|
||||||
|
return t("profile.no_permission")
|
||||||
|
case "team_req/not_required_role":
|
||||||
|
return t("profile.no_permission")
|
||||||
|
case "Forbidden resource":
|
||||||
|
return t("profile.no_permission")
|
||||||
|
default:
|
||||||
|
return t("error.something_went_wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,624 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col flex-1">
|
||||||
|
<div
|
||||||
|
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
|
||||||
|
:style="
|
||||||
|
saveRequest
|
||||||
|
? 'top: calc(var(--upper-secondary-sticky-fold) - var(--line-height-body))'
|
||||||
|
: 'top: var(--upper-secondary-sticky-fold)'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-if="hasNoTeamAccess"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
disabled
|
||||||
|
class="!rounded-none"
|
||||||
|
:icon="IconPlus"
|
||||||
|
:title="t('team.no_access')"
|
||||||
|
:label="t('action.new')"
|
||||||
|
/>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-else
|
||||||
|
:icon="IconPlus"
|
||||||
|
:label="t('action.new')"
|
||||||
|
class="!rounded-none"
|
||||||
|
@click="emit('display-modal-add')"
|
||||||
|
/>
|
||||||
|
<span class="flex">
|
||||||
|
<ButtonSecondary
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
to="https://docs.hoppscotch.io/features/collections"
|
||||||
|
blank
|
||||||
|
:title="t('app.wiki')"
|
||||||
|
:icon="IconHelpCircle"
|
||||||
|
/>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-if="!saveRequest"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:disabled="
|
||||||
|
collectionsType.type === 'team-collections' &&
|
||||||
|
collectionsType.selectedTeam === undefined
|
||||||
|
"
|
||||||
|
:icon="IconArchive"
|
||||||
|
:title="t('modal.import_export')"
|
||||||
|
@click="emit('display-modal-import-export')"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col overflow-hidden">
|
||||||
|
<SmartTree :adapter="teamAdapter">
|
||||||
|
<template #content="{ node, toggleChildren, isOpen }">
|
||||||
|
<CollectionsCollection
|
||||||
|
v-if="node.data.type === 'collections'"
|
||||||
|
:data="node.data.data.data"
|
||||||
|
:collections-type="collectionsType.type"
|
||||||
|
:is-open="isOpen"
|
||||||
|
:export-loading="exportLoading"
|
||||||
|
:has-no-team-access="hasNoTeamAccess"
|
||||||
|
:is-selected="
|
||||||
|
isSelected({
|
||||||
|
collectionID: node.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
folder-type="collection"
|
||||||
|
@add-request="
|
||||||
|
node.data.type === 'collections' &&
|
||||||
|
emit('add-request', {
|
||||||
|
path: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@add-folder="
|
||||||
|
node.data.type === 'collections' &&
|
||||||
|
emit('add-folder', {
|
||||||
|
path: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@edit-collection="
|
||||||
|
node.data.type === 'collections' &&
|
||||||
|
emit('edit-collection', {
|
||||||
|
collectionIndex: node.id,
|
||||||
|
collection: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@export-data="
|
||||||
|
node.data.type === 'collections' &&
|
||||||
|
emit('export-data', node.data.data.data)
|
||||||
|
"
|
||||||
|
@remove-collection="emit('remove-collection', node.id)"
|
||||||
|
@toggle-children="
|
||||||
|
() => {
|
||||||
|
toggleChildren(),
|
||||||
|
saveRequest &&
|
||||||
|
emit('select', {
|
||||||
|
pickedType: 'teams-collection',
|
||||||
|
collectionID: node.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<CollectionsCollection
|
||||||
|
v-if="node.data.type === 'folders'"
|
||||||
|
:data="node.data.data.data"
|
||||||
|
:collections-type="collectionsType.type"
|
||||||
|
:is-open="isOpen"
|
||||||
|
:export-loading="exportLoading"
|
||||||
|
:has-no-team-access="hasNoTeamAccess"
|
||||||
|
:is-selected="
|
||||||
|
isSelected({
|
||||||
|
folderID: node.data.data.data.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
folder-type="folder"
|
||||||
|
@add-request="
|
||||||
|
node.data.type === 'folders' &&
|
||||||
|
emit('add-request', {
|
||||||
|
path: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@add-folder="
|
||||||
|
node.data.type === 'folders' &&
|
||||||
|
emit('add-folder', {
|
||||||
|
path: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@edit-collection="
|
||||||
|
node.data.type === 'folders' &&
|
||||||
|
emit('edit-folder', {
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@export-data="
|
||||||
|
node.data.type === 'folders' &&
|
||||||
|
emit('export-data', node.data.data.data)
|
||||||
|
"
|
||||||
|
@remove-collection="
|
||||||
|
node.data.type === 'folders' &&
|
||||||
|
emit('remove-folder', node.data.data.data.id)
|
||||||
|
"
|
||||||
|
@toggle-children="
|
||||||
|
() => {
|
||||||
|
toggleChildren(),
|
||||||
|
saveRequest &&
|
||||||
|
emit('select', {
|
||||||
|
pickedType: 'teams-folder',
|
||||||
|
folderID: node.data.data.data.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<CollectionsRequest
|
||||||
|
v-if="node.data.type === 'requests'"
|
||||||
|
:request="node.data.data.data.request"
|
||||||
|
:collections-type="collectionsType.type"
|
||||||
|
:duplicate-loading="duplicateLoading"
|
||||||
|
:is-active="isActiveRequest(node.data.data.data.id)"
|
||||||
|
:has-no-team-access="hasNoTeamAccess"
|
||||||
|
:is-selected="
|
||||||
|
isSelected({
|
||||||
|
requestID: node.data.data.data.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@edit-request="
|
||||||
|
node.data.type === 'requests' &&
|
||||||
|
emit('edit-request', {
|
||||||
|
requestIndex: node.data.data.data.id,
|
||||||
|
request: node.data.data.data.request,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@duplicate-request="
|
||||||
|
node.data.type === 'requests' &&
|
||||||
|
emit('duplicate-request', {
|
||||||
|
folderPath: node.data.data.parentIndex,
|
||||||
|
request: node.data.data.data.request,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@remove-request="
|
||||||
|
node.data.type === 'requests' &&
|
||||||
|
emit('remove-request', {
|
||||||
|
folderPath: null,
|
||||||
|
requestIndex: node.data.data.data.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@select-request="
|
||||||
|
node.data.type === 'requests' &&
|
||||||
|
selectRequest({
|
||||||
|
request: node.data.data.data.request,
|
||||||
|
requestIndex: node.data.data.data.id,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #emptyNode="{ node }">
|
||||||
|
<div v-if="node === null">
|
||||||
|
<div
|
||||||
|
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||||
|
loading="lazy"
|
||||||
|
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||||
|
:alt="`${t('empty.collections')}`"
|
||||||
|
/>
|
||||||
|
<span class="pb-4 text-center">
|
||||||
|
{{ t("empty.collections") }}
|
||||||
|
</span>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-if="hasNoTeamAccess"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
disabled
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
:title="t('team.no_access')"
|
||||||
|
:label="t('add.new')"
|
||||||
|
/>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-else
|
||||||
|
:label="t('add.new')"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="emit('display-modal-add')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="node.data.type === 'collections'"
|
||||||
|
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||||
|
loading="lazy"
|
||||||
|
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||||
|
:alt="`${t('empty.collection')}`"
|
||||||
|
/>
|
||||||
|
<span class="pb-4 text-center">
|
||||||
|
{{ t("empty.collection") }}
|
||||||
|
</span>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-if="hasNoTeamAccess"
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
disabled
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
:title="t('team.no_access')"
|
||||||
|
:label="t('add.new')"
|
||||||
|
/>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-else
|
||||||
|
:label="t('add.new')"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="
|
||||||
|
node.data.type === 'collections' &&
|
||||||
|
emit('add-folder', {
|
||||||
|
path: node.id,
|
||||||
|
folder: node.data.data.data,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="node.data.type === 'folders'"
|
||||||
|
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="`/images/states/${colorMode.value}/pack.svg`"
|
||||||
|
loading="lazy"
|
||||||
|
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||||
|
:alt="`${t('empty.folder')}`"
|
||||||
|
/>
|
||||||
|
<span class="text-center">
|
||||||
|
{{ t("empty.folder") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SmartTree>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconArchive from "~icons/lucide/archive"
|
||||||
|
import IconPlus from "~icons/lucide/plus"
|
||||||
|
import IconHelpCircle from "~icons/lucide/help-circle"
|
||||||
|
import { computed, PropType, Ref, toRef } from "vue"
|
||||||
|
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||||
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import { useColorMode } from "@composables/theming"
|
||||||
|
import { TeamCollection } from "~/helpers/teams/TeamCollection"
|
||||||
|
import { TeamRequest } from "~/helpers/teams/TeamRequest"
|
||||||
|
import { ChildrenResult, SmartTreeAdapter } from "~/helpers/treeAdapter"
|
||||||
|
import { cloneDeep } from "lodash-es"
|
||||||
|
import { HoppRESTRequest } from "@hoppscotch/data"
|
||||||
|
import { useReadonlyStream } from "~/composables/stream"
|
||||||
|
import { restSaveContext$ } from "~/newstore/RESTSession"
|
||||||
|
import { pipe } from "fp-ts/function"
|
||||||
|
import * as O from "fp-ts/Option"
|
||||||
|
import { Picked } from "~/helpers/types/HoppPicked.js"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||||
|
|
||||||
|
type CollectionType =
|
||||||
|
| {
|
||||||
|
type: "team-collections"
|
||||||
|
selectedTeam: SelectedTeam
|
||||||
|
}
|
||||||
|
| { type: "my-collections"; selectedTeam: undefined }
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
collectionsType: {
|
||||||
|
type: Object as PropType<CollectionType>,
|
||||||
|
default: () => ({ type: "my-collections", selectedTeam: undefined }),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
teamCollectionList: {
|
||||||
|
type: Array as PropType<TeamCollection[]>,
|
||||||
|
default: () => [],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
teamLoadingCollections: {
|
||||||
|
type: Array as PropType<string[]>,
|
||||||
|
default: () => [],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
saveRequest: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
exportLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
duplicateLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
picked: {
|
||||||
|
type: Object as PropType<Picked | null>,
|
||||||
|
default: null,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(
|
||||||
|
event: "add-request",
|
||||||
|
payload: {
|
||||||
|
path: string
|
||||||
|
folder: TeamCollection
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "add-folder",
|
||||||
|
payload: {
|
||||||
|
path: string
|
||||||
|
folder: TeamCollection
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "edit-collection",
|
||||||
|
payload: {
|
||||||
|
collectionIndex: string
|
||||||
|
collection: TeamCollection
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "edit-folder",
|
||||||
|
payload: {
|
||||||
|
folder: TeamCollection
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "edit-request",
|
||||||
|
payload: {
|
||||||
|
requestIndex: string
|
||||||
|
request: HoppRESTRequest
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "duplicate-request",
|
||||||
|
payload: {
|
||||||
|
folderPath: string
|
||||||
|
request: HoppRESTRequest
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(event: "export-data", payload: TeamCollection): void
|
||||||
|
(event: "remove-collection", payload: string): void
|
||||||
|
(event: "remove-folder", payload: string): void
|
||||||
|
(
|
||||||
|
event: "remove-request",
|
||||||
|
payload: {
|
||||||
|
folderPath: string | null
|
||||||
|
requestIndex: string
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
event: "select-request",
|
||||||
|
payload: {
|
||||||
|
request: HoppRESTRequest
|
||||||
|
requestIndex: string
|
||||||
|
isActive: boolean
|
||||||
|
folderPath?: string | undefined
|
||||||
|
}
|
||||||
|
): void
|
||||||
|
(event: "select", payload: Picked | null): void
|
||||||
|
(event: "expand-team-collection", payload: string): void
|
||||||
|
(event: "display-modal-add"): void
|
||||||
|
(event: "display-modal-import-export"): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const teamCollectionsList = toRef(props, "teamCollectionList")
|
||||||
|
|
||||||
|
const hasNoTeamAccess = computed(
|
||||||
|
() =>
|
||||||
|
props.collectionsType.type === "team-collections" &&
|
||||||
|
(props.collectionsType.selectedTeam === undefined ||
|
||||||
|
props.collectionsType.selectedTeam.myRole === "VIEWER")
|
||||||
|
)
|
||||||
|
|
||||||
|
const isSelected = computed(() => {
|
||||||
|
return ({
|
||||||
|
collectionID,
|
||||||
|
folderID,
|
||||||
|
requestID,
|
||||||
|
}: {
|
||||||
|
collectionID?: string | undefined
|
||||||
|
folderID?: string | undefined
|
||||||
|
requestID?: string | undefined
|
||||||
|
}) => {
|
||||||
|
if (collectionID !== undefined) {
|
||||||
|
return (
|
||||||
|
props.picked &&
|
||||||
|
props.picked.pickedType === "teams-collection" &&
|
||||||
|
props.picked.collectionID === collectionID
|
||||||
|
)
|
||||||
|
} else if (requestID !== undefined) {
|
||||||
|
return (
|
||||||
|
props.picked &&
|
||||||
|
props.picked.pickedType === "teams-request" &&
|
||||||
|
props.picked.requestID === requestID
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
props.picked &&
|
||||||
|
props.picked.pickedType === "teams-folder" &&
|
||||||
|
props.picked.folderID === folderID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const active = useReadonlyStream(restSaveContext$, null)
|
||||||
|
|
||||||
|
const isActiveRequest = computed(() => {
|
||||||
|
return (requestID: string) => {
|
||||||
|
return pipe(
|
||||||
|
active.value,
|
||||||
|
O.fromNullable,
|
||||||
|
O.filter(
|
||||||
|
(active) =>
|
||||||
|
active.originLocation === "team-collection" &&
|
||||||
|
active.requestID === requestID
|
||||||
|
),
|
||||||
|
O.isSome
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectRequest = (data: {
|
||||||
|
request: HoppRESTRequest
|
||||||
|
requestIndex: string
|
||||||
|
}) => {
|
||||||
|
const { request, requestIndex } = data
|
||||||
|
if (props.saveRequest) {
|
||||||
|
emit("select", {
|
||||||
|
pickedType: "teams-request",
|
||||||
|
requestID: requestIndex,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emit("select-request", {
|
||||||
|
request: request,
|
||||||
|
requestIndex: requestIndex,
|
||||||
|
isActive: isActiveRequest.value(requestIndex),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamCollections = {
|
||||||
|
type: "collections"
|
||||||
|
data: {
|
||||||
|
parentIndex: null
|
||||||
|
data: TeamCollection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamFolder = {
|
||||||
|
type: "folders"
|
||||||
|
data: {
|
||||||
|
parentIndex: string
|
||||||
|
data: TeamCollection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamRequests = {
|
||||||
|
type: "requests"
|
||||||
|
data: {
|
||||||
|
parentIndex: string
|
||||||
|
data: TeamRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeamCollectionNode = TeamCollections | TeamFolder | TeamRequests
|
||||||
|
|
||||||
|
class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
|
||||||
|
constructor(public data: Ref<TeamCollection[]>) {}
|
||||||
|
|
||||||
|
findCollInTree(
|
||||||
|
tree: TeamCollection[],
|
||||||
|
targetID: string
|
||||||
|
): TeamCollection | null {
|
||||||
|
for (const coll of tree) {
|
||||||
|
// If the direct child matched, then return that
|
||||||
|
if (coll.id === targetID) return coll
|
||||||
|
|
||||||
|
// Else run it in the children
|
||||||
|
if (coll.children) {
|
||||||
|
const result = this.findCollInTree(coll.children, targetID)
|
||||||
|
if (result) return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If nothing matched, return null
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
getChildren(id: string | null): Ref<ChildrenResult<TeamCollectionNode>> {
|
||||||
|
return computed(() => {
|
||||||
|
if (id === null) {
|
||||||
|
if (props.teamLoadingCollections.includes("root")) {
|
||||||
|
return {
|
||||||
|
status: "loading",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = this.data.value.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
data: {
|
||||||
|
type: "collections",
|
||||||
|
data: {
|
||||||
|
parentIndex: null,
|
||||||
|
data: item,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
status: "loaded",
|
||||||
|
data: cloneDeep(data),
|
||||||
|
} as ChildrenResult<TeamCollections>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parsedID = id.split("/")[id.split("/").length - 1]
|
||||||
|
|
||||||
|
!props.teamLoadingCollections.includes(parsedID) &&
|
||||||
|
emit("expand-team-collection", parsedID)
|
||||||
|
|
||||||
|
if (props.teamLoadingCollections.includes(parsedID)) {
|
||||||
|
return {
|
||||||
|
status: "loading",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const items = this.findCollInTree(this.data.value, parsedID)
|
||||||
|
if (items) {
|
||||||
|
const data = [
|
||||||
|
...(items.children
|
||||||
|
? items.children.map((item) => ({
|
||||||
|
id: `${id}/${item.id}`,
|
||||||
|
data: {
|
||||||
|
type: "folders",
|
||||||
|
data: {
|
||||||
|
parentIndex: parsedID,
|
||||||
|
data: item,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
: []),
|
||||||
|
...(items.requests
|
||||||
|
? items.requests.map((item) => ({
|
||||||
|
id: `${id}/${item.id}`,
|
||||||
|
data: {
|
||||||
|
type: "requests",
|
||||||
|
data: {
|
||||||
|
parentIndex: parsedID,
|
||||||
|
data: item,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
status: "loaded",
|
||||||
|
data: cloneDeep(data),
|
||||||
|
} as ChildrenResult<TeamFolder | TeamRequests>
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
status: "loaded",
|
||||||
|
data: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamAdapter: SmartTreeAdapter<TeamCollectionNode> =
|
||||||
|
new TeamCollectionsAdapter(teamCollectionsList)
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-1">
|
||||||
|
<SmartIntersection
|
||||||
|
class="flex flex-col flex-1"
|
||||||
|
@intersecting="onTeamSelectIntersect"
|
||||||
|
>
|
||||||
|
<tippy
|
||||||
|
interactive
|
||||||
|
trigger="click"
|
||||||
|
theme="popover"
|
||||||
|
placement="bottom"
|
||||||
|
:on-shown="() => tippyActions!.focus()"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="`${t('collection.select_team')}`"
|
||||||
|
class="bg-transparent border-b border-dividerLight select-wrapper"
|
||||||
|
>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-if="collectionsType.selectedTeam"
|
||||||
|
:icon="IconUsers"
|
||||||
|
:label="collectionsType.selectedTeam.name"
|
||||||
|
class="flex-1 !justify-start pr-8 rounded-none"
|
||||||
|
/>
|
||||||
|
<ButtonSecondary
|
||||||
|
v-else
|
||||||
|
:label="`${t('collection.select_team')}`"
|
||||||
|
class="flex-1 !justify-start pr-8 rounded-none"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<template #content="{ hide }">
|
||||||
|
<div
|
||||||
|
ref="tippyActions"
|
||||||
|
class="flex flex-col focus:outline-none"
|
||||||
|
tabindex="0"
|
||||||
|
@keyup.escape="hide()"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isTeamListLoading && myTeams.length === 0"
|
||||||
|
class="flex flex-col items-center justify-center flex-1 p-2"
|
||||||
|
>
|
||||||
|
<SmartSpinner class="my-2" />
|
||||||
|
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="myTeams.length > 0" class="flex flex-col">
|
||||||
|
<SmartItem
|
||||||
|
v-for="(team, index) in myTeams"
|
||||||
|
:key="`team-${index}`"
|
||||||
|
:label="team.name"
|
||||||
|
:info-icon="
|
||||||
|
team.id === collectionsType.selectedTeam?.id
|
||||||
|
? IconDone
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:active-info-icon="team.id === collectionsType.selectedTeam?.id"
|
||||||
|
:icon="IconUsers"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
updateSelectedTeam(team)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<hr />
|
||||||
|
<SmartItem
|
||||||
|
:icon="IconPlus"
|
||||||
|
:label="t('team.create_new')"
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
displayTeamModalAdd(true)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex flex-col items-center justify-center p-2 text-secondaryLight"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="`/images/states/${colorMode.value}/add_group.svg`"
|
||||||
|
loading="lazy"
|
||||||
|
class="inline-flex flex-col object-contain object-center mb-4 w-14 h-14"
|
||||||
|
:alt="`${t('empty.teams')}`"
|
||||||
|
/>
|
||||||
|
<span class="pb-4 text-center">
|
||||||
|
{{ t("empty.teams") }}
|
||||||
|
</span>
|
||||||
|
<ButtonSecondary
|
||||||
|
:label="t('team.create_new')"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="
|
||||||
|
() => {
|
||||||
|
displayTeamModalAdd(true)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</tippy>
|
||||||
|
</SmartIntersection>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import IconUsers from "~icons/lucide/users"
|
||||||
|
import IconDone from "~icons/lucide/check"
|
||||||
|
import { PropType, ref } from "vue"
|
||||||
|
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||||
|
import { TippyComponent } from "vue-tippy"
|
||||||
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import { useColorMode } from "@composables/theming"
|
||||||
|
import IconPlus from "~icons/lucide/plus"
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
|
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||||
|
|
||||||
|
type CollectionType =
|
||||||
|
| {
|
||||||
|
type: "team-collections"
|
||||||
|
selectedTeam: SelectedTeam
|
||||||
|
}
|
||||||
|
| { type: "my-collections"; selectedTeam: undefined }
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
collectionsType: {
|
||||||
|
type: Object as PropType<CollectionType>,
|
||||||
|
default: () => ({ type: "my-collections", selectedTeam: undefined }),
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
myTeams: {
|
||||||
|
type: Array as PropType<GetMyTeamsQuery["myTeams"]>,
|
||||||
|
default: () => [],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isTeamListLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const tippyActions = ref<TippyComponent | null>(null)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update-selected-team", payload: SelectedTeam): void
|
||||||
|
(e: "team-select-intersect", payload: boolean): void
|
||||||
|
(e: "display-team-modal-add", payload: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const updateSelectedTeam = (team: SelectedTeam) => {
|
||||||
|
emit("update-selected-team", team)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTeamSelectIntersect = () => {
|
||||||
|
emit("team-select-intersect", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayTeamModalAdd = (display: boolean) => {
|
||||||
|
emit("display-team-modal-add", display)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
v-for="(folder, index) in collection.folders"
|
v-for="(folder, index) in collection.folders"
|
||||||
:key="`folder-${String(index)}`"
|
:key="`folder-${String(index)}`"
|
||||||
:picked="picked"
|
:picked="picked"
|
||||||
:saving-mode="savingMode"
|
:save-request="saveRequest"
|
||||||
:folder="folder"
|
:folder="folder"
|
||||||
:folder-index="index"
|
:folder-index="index"
|
||||||
:folder-path="`${collectionIndex}/${String(index)}`"
|
:folder-path="`${collectionIndex}/${String(index)}`"
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
v-for="(request, index) in collection.requests"
|
v-for="(request, index) in collection.requests"
|
||||||
:key="`request-${String(index)}`"
|
:key="`request-${String(index)}`"
|
||||||
:picked="picked"
|
:picked="picked"
|
||||||
:saving-mode="savingMode"
|
:save-request="saveRequest"
|
||||||
:request="request"
|
:request="request"
|
||||||
:collection-index="collectionIndex"
|
:collection-index="collectionIndex"
|
||||||
:folder-index="-1"
|
:folder-index="-1"
|
||||||
@@ -183,9 +183,19 @@
|
|||||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||||
:alt="`${t('empty.collection')}`"
|
:alt="`${t('empty.collection')}`"
|
||||||
/>
|
/>
|
||||||
<span class="text-center">
|
<span class="pb-4 text-center">
|
||||||
{{ t("empty.collection") }}
|
{{ t("empty.collection") }}
|
||||||
</span>
|
</span>
|
||||||
|
<ButtonSecondary
|
||||||
|
:label="t('add.new')"
|
||||||
|
filled
|
||||||
|
outline
|
||||||
|
@click="
|
||||||
|
emit('add-folder', {
|
||||||
|
path: `${collectionIndex}`,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,11 +225,12 @@ import {
|
|||||||
removeGraphqlCollection,
|
removeGraphqlCollection,
|
||||||
moveGraphqlRequest,
|
moveGraphqlRequest,
|
||||||
} from "~/newstore/collections"
|
} from "~/newstore/collections"
|
||||||
|
import { Picked } from "~/helpers/types/HoppPicked"
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
picked: { type: Object, default: null },
|
picked: { type: Object, default: null },
|
||||||
// Whether the viewing context is related to picking (activates 'select' events)
|
// Whether the viewing context is related to picking (activates 'select' events)
|
||||||
savingMode: { type: Boolean, default: false },
|
saveRequest: { type: Boolean, default: false },
|
||||||
collectionIndex: { type: Number, default: null },
|
collectionIndex: { type: Number, default: null },
|
||||||
collection: { type: Object, default: () => ({}) },
|
collection: { type: Object, default: () => ({}) },
|
||||||
isFiltered: Boolean,
|
isFiltered: Boolean,
|
||||||
@@ -231,7 +242,7 @@ const t = useI18n()
|
|||||||
|
|
||||||
// TODO: improve types plz
|
// TODO: improve types plz
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "select", i: { picked: any }): void
|
(e: "select", i: Picked | null): void
|
||||||
(e: "edit-request", i: any): void
|
(e: "edit-request", i: any): void
|
||||||
(e: "duplicate-request", i: any): void
|
(e: "duplicate-request", i: any): void
|
||||||
(e: "add-request", i: any): void
|
(e: "add-request", i: any): void
|
||||||
@@ -267,15 +278,13 @@ const collectionIcon = computed(() => {
|
|||||||
|
|
||||||
const pick = () => {
|
const pick = () => {
|
||||||
emit("select", {
|
emit("select", {
|
||||||
picked: {
|
pickedType: "gql-my-collection",
|
||||||
pickedType: "gql-my-collection",
|
collectionIndex: props.collectionIndex,
|
||||||
collectionIndex: props.collectionIndex,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleShowChildren = () => {
|
const toggleShowChildren = () => {
|
||||||
if (props.savingMode) {
|
if (props.saveRequest) {
|
||||||
pick()
|
pick()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +297,7 @@ const removeCollection = () => {
|
|||||||
props.picked?.pickedType === "gql-my-collection" &&
|
props.picked?.pickedType === "gql-my-collection" &&
|
||||||
props.picked?.collectionIndex === props.collectionIndex
|
props.picked?.collectionIndex === props.collectionIndex
|
||||||
) {
|
) {
|
||||||
emit("select", { picked: null })
|
emit("select", null)
|
||||||
}
|
}
|
||||||
removeGraphqlCollection(props.collectionIndex)
|
removeGraphqlCollection(props.collectionIndex)
|
||||||
toast.success(`${t("state.deleted")}`)
|
toast.success(`${t("state.deleted")}`)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
v-for="(subFolder, subFolderIndex) in folder.folders"
|
v-for="(subFolder, subFolderIndex) in folder.folders"
|
||||||
:key="`subFolder-${String(subFolderIndex)}`"
|
:key="`subFolder-${String(subFolderIndex)}`"
|
||||||
:picked="picked"
|
:picked="picked"
|
||||||
:saving-mode="savingMode"
|
:save-request="saveRequest"
|
||||||
:folder="subFolder"
|
:folder="subFolder"
|
||||||
:folder-index="subFolderIndex"
|
:folder-index="subFolderIndex"
|
||||||
:folder-path="`${folderPath}/${String(subFolderIndex)}`"
|
:folder-path="`${folderPath}/${String(subFolderIndex)}`"
|
||||||
@@ -149,7 +149,7 @@
|
|||||||
v-for="(request, index) in folder.requests"
|
v-for="(request, index) in folder.requests"
|
||||||
:key="`request-${String(index)}`"
|
:key="`request-${String(index)}`"
|
||||||
:picked="picked"
|
:picked="picked"
|
||||||
:saving-mode="savingMode"
|
:save-request="saveRequest"
|
||||||
:request="request"
|
:request="request"
|
||||||
:collection-index="collectionIndex"
|
:collection-index="collectionIndex"
|
||||||
:folder-index="folderIndex"
|
:folder-index="folderIndex"
|
||||||
@@ -212,7 +212,7 @@ const colorMode = useColorMode()
|
|||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
picked: { type: Object, default: null },
|
picked: { type: Object, default: null },
|
||||||
// Whether the request is in a selectable mode (activates 'select' event)
|
// Whether the request is in a selectable mode (activates 'select' event)
|
||||||
savingMode: { type: Boolean, default: false },
|
saveRequest: { type: Boolean, default: false },
|
||||||
folder: { type: Object, default: () => ({}) },
|
folder: { type: Object, default: () => ({}) },
|
||||||
folderIndex: { type: Number, default: null },
|
folderIndex: { type: Number, default: null },
|
||||||
collectionIndex: { type: Number, default: null },
|
collectionIndex: { type: Number, default: null },
|
||||||
@@ -263,7 +263,7 @@ const pick = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleShowChildren = () => {
|
const toggleShowChildren = () => {
|
||||||
if (props.savingMode) {
|
if (props.saveRequest) {
|
||||||
pick()
|
pick()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ import IconFolderPlus from "~icons/lucide/folder-plus"
|
|||||||
import IconDownload from "~icons/lucide/download"
|
import IconDownload from "~icons/lucide/download"
|
||||||
import IconGithub from "~icons/lucide/github"
|
import IconGithub from "~icons/lucide/github"
|
||||||
import { computed, ref } from "vue"
|
import { computed, ref } from "vue"
|
||||||
import { currentUser$ } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
@@ -120,7 +120,10 @@ const emit = defineEmits<{
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
const collections = useReadonlyStream(graphqlCollections$, [])
|
const collections = useReadonlyStream(graphqlCollections$, [])
|
||||||
const currentUser = useReadonlyStream(currentUser$, null)
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
// Template refs
|
// Template refs
|
||||||
const tippyActions = ref<any | null>(null)
|
const tippyActions = ref<any | null>(null)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<ButtonSecondary
|
<ButtonSecondary
|
||||||
v-if="!savingMode"
|
v-if="!saveRequest"
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
:icon="IconRotateCCW"
|
:icon="IconRotateCCW"
|
||||||
:title="t('action.restore')"
|
:title="t('action.restore')"
|
||||||
@@ -148,7 +148,7 @@ const props = defineProps({
|
|||||||
// Whether the object is selected (show the tick mark)
|
// Whether the object is selected (show the tick mark)
|
||||||
picked: { type: Object, default: null },
|
picked: { type: Object, default: null },
|
||||||
// Whether the request is being saved (activate 'select' event)
|
// Whether the request is being saved (activate 'select' event)
|
||||||
savingMode: { type: Boolean, default: false },
|
saveRequest: { type: Boolean, default: false },
|
||||||
request: { type: Object as PropType<HoppGQLRequest>, default: () => ({}) },
|
request: { type: Object as PropType<HoppGQLRequest>, default: () => ({}) },
|
||||||
folderPath: { type: String, default: null },
|
folderPath: { type: String, default: null },
|
||||||
requestIndex: { type: Number, default: null },
|
requestIndex: { type: Number, default: null },
|
||||||
@@ -169,16 +169,14 @@ const isSelected = computed(
|
|||||||
|
|
||||||
const pick = () => {
|
const pick = () => {
|
||||||
emit("select", {
|
emit("select", {
|
||||||
picked: {
|
pickedType: "gql-my-request",
|
||||||
pickedType: "gql-my-request",
|
folderPath: props.folderPath,
|
||||||
folderPath: props.folderPath,
|
requestIndex: props.requestIndex,
|
||||||
requestIndex: props.requestIndex,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectRequest = () => {
|
const selectRequest = () => {
|
||||||
if (props.savingMode) {
|
if (props.saveRequest) {
|
||||||
pick()
|
pick()
|
||||||
} else {
|
} else {
|
||||||
setGQLSession({
|
setGQLSession({
|
||||||
@@ -213,7 +211,7 @@ const removeRequest = () => {
|
|||||||
props.picked.folderPath === props.folderPath &&
|
props.picked.folderPath === props.folderPath &&
|
||||||
props.picked.requestIndex === props.requestIndex
|
props.picked.requestIndex === props.requestIndex
|
||||||
) {
|
) {
|
||||||
emit("select", { picked: null })
|
emit("select", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
removeGraphqlRequest(props.folderPath, props.requestIndex)
|
removeGraphqlRequest(props.folderPath, props.requestIndex)
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="{ 'rounded border border-divider': savingMode }">
|
<div :class="{ 'rounded border border-divider': saveRequest }">
|
||||||
<div
|
<div
|
||||||
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto border-b divide-y divide-dividerLight border-dividerLight"
|
class="sticky z-10 flex flex-col flex-shrink-0 overflow-x-auto rounded-t bg-primary"
|
||||||
:class="{ 'bg-primary': !savingMode }"
|
:style="
|
||||||
|
saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-if="showCollActions"
|
|
||||||
v-model="filterText"
|
v-model="filterText"
|
||||||
type="search"
|
type="search"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
:placeholder="t('action.search')"
|
:placeholder="t('action.search')"
|
||||||
class="flex px-4 py-2 bg-transparent"
|
class="py-2 pl-4 pr-2 bg-transparent"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-between flex-1">
|
<div
|
||||||
|
class="flex justify-between flex-1 flex-shrink-0 border-y bg-primary border-dividerLight"
|
||||||
|
>
|
||||||
<ButtonSecondary
|
<ButtonSecondary
|
||||||
:icon="IconPlus"
|
:icon="IconPlus"
|
||||||
:label="t('action.new')"
|
:label="t('action.new')"
|
||||||
@@ -28,7 +31,7 @@
|
|||||||
:icon="IconHelpCircle"
|
:icon="IconHelpCircle"
|
||||||
/>
|
/>
|
||||||
<ButtonSecondary
|
<ButtonSecondary
|
||||||
v-if="showCollActions"
|
v-if="!saveRequest"
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
:title="t('modal.import_export')"
|
:title="t('modal.import_export')"
|
||||||
:icon="IconArchive"
|
:icon="IconArchive"
|
||||||
@@ -37,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-col">
|
<div class="flex flex-col">
|
||||||
<CollectionsGraphqlCollection
|
<CollectionsGraphqlCollection
|
||||||
v-for="(collection, index) in filteredCollections"
|
v-for="(collection, index) in filteredCollections"
|
||||||
:key="`collection-${index}`"
|
:key="`collection-${index}`"
|
||||||
@@ -46,7 +49,7 @@
|
|||||||
:collection-index="index"
|
:collection-index="index"
|
||||||
:collection="collection"
|
:collection="collection"
|
||||||
:is-filtered="filterText.length > 0"
|
:is-filtered="filterText.length > 0"
|
||||||
:saving-mode="savingMode"
|
:save-request="saveRequest"
|
||||||
@edit-collection="editCollection(collection, index)"
|
@edit-collection="editCollection(collection, index)"
|
||||||
@add-request="addRequest($event)"
|
@add-request="addRequest($event)"
|
||||||
@add-folder="addFolder($event)"
|
@add-folder="addFolder($event)"
|
||||||
@@ -154,10 +157,8 @@ import { useColorMode } from "@composables/theming"
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
props: {
|
props: {
|
||||||
// Whether to activate the ability to pick items (activates 'select' events)
|
// Whether to activate the ability to pick items (activates 'select' events)
|
||||||
savingMode: { type: Boolean, default: false },
|
saveRequest: { type: Boolean, default: false },
|
||||||
picked: { type: Object, default: null },
|
picked: { type: Object, default: null },
|
||||||
// Whether to show the 'New' and 'Import/Export' actions
|
|
||||||
showCollActions: { type: Boolean, default: true },
|
|
||||||
},
|
},
|
||||||
emits: ["select", "use-collection"],
|
emits: ["select", "use-collection"],
|
||||||
setup() {
|
setup() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,354 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
|
||||||
<div
|
|
||||||
class="flex items-stretch group"
|
|
||||||
@dragover.prevent
|
|
||||||
@drop.prevent="dropEvent"
|
|
||||||
@dragover="dragging = true"
|
|
||||||
@drop="dragging = false"
|
|
||||||
@dragleave="dragging = false"
|
|
||||||
@dragend="dragging = false"
|
|
||||||
@contextmenu.prevent="options.tippy.show()"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex items-center justify-center px-4 cursor-pointer"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="getCollectionIcon"
|
|
||||||
class="svg-icons"
|
|
||||||
:class="{ 'text-accent': isSelected }"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
>
|
|
||||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
|
||||||
{{ collection.name }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="flex">
|
|
||||||
<ButtonSecondary
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:icon="IconFilePlus"
|
|
||||||
:title="t('request.new')"
|
|
||||||
class="hidden group-hover:inline-flex"
|
|
||||||
@click="
|
|
||||||
$emit('add-request', {
|
|
||||||
path: `${collectionIndex}`,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<ButtonSecondary
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:icon="IconFolderPlus"
|
|
||||||
:title="t('folder.new')"
|
|
||||||
class="hidden group-hover:inline-flex"
|
|
||||||
@click="
|
|
||||||
$emit('add-folder', {
|
|
||||||
folder: collection,
|
|
||||||
path: `${collectionIndex}`,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<tippy
|
|
||||||
ref="options"
|
|
||||||
interactive
|
|
||||||
trigger="click"
|
|
||||||
theme="popover"
|
|
||||||
:on-shown="() => tippyActions.focus()"
|
|
||||||
>
|
|
||||||
<ButtonSecondary
|
|
||||||
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.r="requestAction.$el.click()"
|
|
||||||
@keyup.n="folderAction.$el.click()"
|
|
||||||
@keyup.e="edit.$el.click()"
|
|
||||||
@keyup.delete="deleteAction.$el.click()"
|
|
||||||
@keyup.x="exportAction.$el.click()"
|
|
||||||
@keyup.escape="hide()"
|
|
||||||
>
|
|
||||||
<SmartItem
|
|
||||||
ref="requestAction"
|
|
||||||
:icon="IconFilePlus"
|
|
||||||
:label="t('request.new')"
|
|
||||||
:shortcut="['R']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('add-request', {
|
|
||||||
path: `${collectionIndex}`,
|
|
||||||
})
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="folderAction"
|
|
||||||
:icon="IconFolderPlus"
|
|
||||||
:label="t('folder.new')"
|
|
||||||
:shortcut="['N']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('add-folder', {
|
|
||||||
folder: collection,
|
|
||||||
path: `${collectionIndex}`,
|
|
||||||
})
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="edit"
|
|
||||||
:icon="IconEdit"
|
|
||||||
:label="t('action.edit')"
|
|
||||||
:shortcut="['E']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('edit-collection')
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="exportAction"
|
|
||||||
:icon="IconDownload"
|
|
||||||
:label="t('export.title')"
|
|
||||||
:shortcut="['X']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
exportCollection()
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="deleteAction"
|
|
||||||
:icon="IconTrash2"
|
|
||||||
:label="t('action.delete')"
|
|
||||||
:shortcut="['⌫']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
removeCollection()
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</tippy>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showChildren || isFiltered" class="flex">
|
|
||||||
<div
|
|
||||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
></div>
|
|
||||||
<div class="flex flex-col flex-1 truncate">
|
|
||||||
<CollectionsMyFolder
|
|
||||||
v-for="(folder, index) in collection.folders"
|
|
||||||
:key="`folder-${index}`"
|
|
||||||
:folder="folder"
|
|
||||||
:folder-index="index"
|
|
||||||
:folder-path="`${collectionIndex}/${index}`"
|
|
||||||
:collection-index="collectionIndex"
|
|
||||||
:save-request="saveRequest"
|
|
||||||
:collections-type="collectionsType"
|
|
||||||
:is-filtered="isFiltered"
|
|
||||||
:picked="picked"
|
|
||||||
@add-request="$emit('add-request', $event)"
|
|
||||||
@add-folder="$emit('add-folder', $event)"
|
|
||||||
@edit-folder="$emit('edit-folder', $event)"
|
|
||||||
@edit-request="$emit('edit-request', $event)"
|
|
||||||
@duplicate-request="$emit('duplicate-request', $event)"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
@remove-request="$emit('remove-request', $event)"
|
|
||||||
@remove-folder="$emit('remove-folder', $event)"
|
|
||||||
/>
|
|
||||||
<CollectionsMyRequest
|
|
||||||
v-for="(request, index) in collection.requests"
|
|
||||||
:key="`request-${index}`"
|
|
||||||
:request="request"
|
|
||||||
:collection-index="collectionIndex"
|
|
||||||
:folder-index="-1"
|
|
||||||
:folder-name="collection.name"
|
|
||||||
:folder-path="`${collectionIndex}`"
|
|
||||||
:request-index="index"
|
|
||||||
:save-request="saveRequest"
|
|
||||||
:collections-type="collectionsType"
|
|
||||||
:picked="picked"
|
|
||||||
@edit-request="$emit('edit-request', $event)"
|
|
||||||
@duplicate-request="$emit('duplicate-request', $event)"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
@remove-request="$emit('remove-request', $event)"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
(collection.folders == undefined ||
|
|
||||||
collection.folders.length === 0) &&
|
|
||||||
(collection.requests == undefined ||
|
|
||||||
collection.requests.length === 0)
|
|
||||||
"
|
|
||||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
|
||||||
loading="lazy"
|
|
||||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
|
||||||
:alt="`${t('empty.collection')}`"
|
|
||||||
/>
|
|
||||||
<span class="text-center">
|
|
||||||
{{ t("empty.collection") }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import IconCircle from "~icons/lucide/circle"
|
|
||||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
|
||||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
|
||||||
import IconFilePlus from "~icons/lucide/file-plus"
|
|
||||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
|
||||||
import IconDownload from "~icons/lucide/download"
|
|
||||||
import IconTrash2 from "~icons/lucide/trash-2"
|
|
||||||
import IconEdit from "~icons/lucide/edit"
|
|
||||||
import IconFolder from "~icons/lucide/folder"
|
|
||||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
|
||||||
import { useColorMode } from "@composables/theming"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
import { useToast } from "@composables/toast"
|
|
||||||
import { defineComponent, ref, markRaw } from "vue"
|
|
||||||
import { moveRESTRequest } from "~/newstore/collections"
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
|
||||||
collectionIndex: { type: Number, default: null },
|
|
||||||
collection: { type: Object, default: () => ({}) },
|
|
||||||
isFiltered: Boolean,
|
|
||||||
saveRequest: Boolean,
|
|
||||||
collectionsType: { type: Object, default: () => ({}) },
|
|
||||||
picked: { type: Object, default: () => ({}) },
|
|
||||||
},
|
|
||||||
emits: [
|
|
||||||
"select",
|
|
||||||
"expand-collection",
|
|
||||||
"add-collection",
|
|
||||||
"remove-collection",
|
|
||||||
"add-folder",
|
|
||||||
"add-request",
|
|
||||||
"edit-folder",
|
|
||||||
"edit-request",
|
|
||||||
"duplicate-request",
|
|
||||||
"remove-folder",
|
|
||||||
"remove-request",
|
|
||||||
"select-collection",
|
|
||||||
"unselect-collection",
|
|
||||||
"edit-collection",
|
|
||||||
],
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
colorMode: useColorMode(),
|
|
||||||
toast: useToast(),
|
|
||||||
t: useI18n(),
|
|
||||||
|
|
||||||
// Template refs
|
|
||||||
tippyActions: ref<any | null>(null),
|
|
||||||
options: ref<any | null>(null),
|
|
||||||
requestAction: ref<any | null>(null),
|
|
||||||
folderAction: ref<any | null>(null),
|
|
||||||
edit: ref<any | null>(null),
|
|
||||||
deleteAction: ref<any | null>(null),
|
|
||||||
exportAction: ref<any | null>(null),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
IconCircle: markRaw(IconCircle),
|
|
||||||
IconCheckCircle: markRaw(IconCheckCircle),
|
|
||||||
IconFilePlus: markRaw(IconFilePlus),
|
|
||||||
IconFolderPlus: markRaw(IconFolderPlus),
|
|
||||||
IconMoreVertical: markRaw(IconMoreVertical),
|
|
||||||
IconEdit: markRaw(IconEdit),
|
|
||||||
IconDownload: markRaw(IconDownload),
|
|
||||||
IconTrash2: markRaw(IconTrash2),
|
|
||||||
|
|
||||||
showChildren: false,
|
|
||||||
dragging: false,
|
|
||||||
selectedFolder: {},
|
|
||||||
prevCursor: "",
|
|
||||||
cursor: "",
|
|
||||||
pageNo: 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isSelected(): boolean {
|
|
||||||
return (
|
|
||||||
this.picked &&
|
|
||||||
this.picked.pickedType === "my-collection" &&
|
|
||||||
this.picked.collectionIndex === this.collectionIndex
|
|
||||||
)
|
|
||||||
},
|
|
||||||
getCollectionIcon() {
|
|
||||||
if (this.isSelected) return IconCheckCircle
|
|
||||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
|
||||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
|
||||||
else return IconFolder
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
exportCollection() {
|
|
||||||
const collectionJSON = JSON.stringify(this.collection)
|
|
||||||
|
|
||||||
const file = new Blob([collectionJSON], { type: "application/json" })
|
|
||||||
const a = document.createElement("a")
|
|
||||||
const url = URL.createObjectURL(file)
|
|
||||||
a.href = url
|
|
||||||
|
|
||||||
a.download = `${this.collection.name}.json`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
this.toast.success(this.t("state.download_started").toString())
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}, 1000)
|
|
||||||
},
|
|
||||||
toggleShowChildren() {
|
|
||||||
if (this.$props.saveRequest)
|
|
||||||
this.$emit("select", {
|
|
||||||
picked: {
|
|
||||||
pickedType: "my-collection",
|
|
||||||
collectionIndex: this.collectionIndex,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
this.$emit("expand-collection", this.collection.id)
|
|
||||||
this.showChildren = !this.showChildren
|
|
||||||
},
|
|
||||||
removeCollection() {
|
|
||||||
this.$emit("remove-collection", {
|
|
||||||
collectionIndex: this.collectionIndex,
|
|
||||||
collectionID: this.collection.id,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
dropEvent({ dataTransfer }: any) {
|
|
||||||
this.dragging = !this.dragging
|
|
||||||
const folderPath = dataTransfer.getData("folderPath")
|
|
||||||
const requestIndex = dataTransfer.getData("requestIndex")
|
|
||||||
moveRESTRequest(folderPath, requestIndex, `${this.collectionIndex}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
|
||||||
<div
|
|
||||||
class="flex items-stretch group"
|
|
||||||
@dragover.prevent
|
|
||||||
@drop.prevent="dropEvent"
|
|
||||||
@dragover="dragging = true"
|
|
||||||
@drop="dragging = false"
|
|
||||||
@dragleave="dragging = false"
|
|
||||||
@dragend="dragging = false"
|
|
||||||
@contextmenu.prevent="options.tippy.show()"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex items-center justify-center px-4 cursor-pointer"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="getCollectionIcon"
|
|
||||||
class="svg-icons"
|
|
||||||
:class="{ 'text-accent': isSelected }"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
>
|
|
||||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
|
||||||
{{ folder.name ? folder.name : folder.title }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="flex">
|
|
||||||
<ButtonSecondary
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:icon="IconFilePlus"
|
|
||||||
:title="t('request.new')"
|
|
||||||
class="hidden group-hover:inline-flex"
|
|
||||||
@click="$emit('add-request', { path: folderPath })"
|
|
||||||
/>
|
|
||||||
<ButtonSecondary
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:icon="IconFolderPlus"
|
|
||||||
:title="t('folder.new')"
|
|
||||||
class="hidden group-hover:inline-flex"
|
|
||||||
@click="$emit('add-folder', { folder, path: folderPath })"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<tippy
|
|
||||||
ref="options"
|
|
||||||
interactive
|
|
||||||
trigger="click"
|
|
||||||
theme="popover"
|
|
||||||
:on-shown="() => tippyActions.focus()"
|
|
||||||
>
|
|
||||||
<ButtonSecondary
|
|
||||||
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.r="requestAction.$el.click()"
|
|
||||||
@keyup.n="folderAction.$el.click()"
|
|
||||||
@keyup.e="edit.$el.click()"
|
|
||||||
@keyup.delete="deleteAction.$el.click()"
|
|
||||||
@keyup.x="exportAction.$el.click()"
|
|
||||||
@keyup.escape="hide()"
|
|
||||||
>
|
|
||||||
<SmartItem
|
|
||||||
ref="requestAction"
|
|
||||||
:icon="IconFilePlus"
|
|
||||||
:label="t('request.new')"
|
|
||||||
:shortcut="['R']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('add-request', { path: folderPath })
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="folderAction"
|
|
||||||
:icon="IconFolderPlus"
|
|
||||||
:label="t('folder.new')"
|
|
||||||
:shortcut="['N']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('add-folder', { folder, path: folderPath })
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="edit"
|
|
||||||
:icon="IconEdit"
|
|
||||||
:label="t('action.edit')"
|
|
||||||
:shortcut="['E']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('edit-folder', {
|
|
||||||
folder,
|
|
||||||
folderIndex,
|
|
||||||
collectionIndex,
|
|
||||||
folderPath,
|
|
||||||
})
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="exportAction"
|
|
||||||
:icon="IconDownload"
|
|
||||||
:label="t('export.title')"
|
|
||||||
:shortcut="['X']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
exportFolder()
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="deleteAction"
|
|
||||||
:icon="IconTrash2"
|
|
||||||
:label="t('action.delete')"
|
|
||||||
:shortcut="['⌫']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
removeFolder()
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</tippy>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showChildren || isFiltered" class="flex">
|
|
||||||
<div
|
|
||||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
></div>
|
|
||||||
<div class="flex flex-col flex-1 truncate">
|
|
||||||
<!-- Referring to this component only (this is recursive) -->
|
|
||||||
<Folder
|
|
||||||
v-for="(subFolder, subFolderIndex) in folder.folders"
|
|
||||||
:key="`subFolder-${subFolderIndex}`"
|
|
||||||
:folder="subFolder"
|
|
||||||
:folder-index="subFolderIndex"
|
|
||||||
:collection-index="collectionIndex"
|
|
||||||
:save-request="saveRequest"
|
|
||||||
:collections-type="collectionsType"
|
|
||||||
:folder-path="`${folderPath}/${subFolderIndex}`"
|
|
||||||
:picked="picked"
|
|
||||||
@add-request="$emit('add-request', $event)"
|
|
||||||
@add-folder="$emit('add-folder', $event)"
|
|
||||||
@edit-folder="$emit('edit-folder', $event)"
|
|
||||||
@edit-request="$emit('edit-request', $event)"
|
|
||||||
@duplicate-request="$emit('duplicate-request', $event)"
|
|
||||||
@update-team-collections="$emit('update-team-collections')"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
@remove-request="$emit('remove-request', $event)"
|
|
||||||
@remove-folder="$emit('remove-folder', $event)"
|
|
||||||
/>
|
|
||||||
<CollectionsMyRequest
|
|
||||||
v-for="(request, index) in folder.requests"
|
|
||||||
:key="`request-${index}`"
|
|
||||||
:request="request"
|
|
||||||
:collection-index="collectionIndex"
|
|
||||||
:folder-index="folderIndex"
|
|
||||||
:folder-name="folder.name"
|
|
||||||
:folder-path="folderPath"
|
|
||||||
:request-index="index"
|
|
||||||
:picked="picked"
|
|
||||||
:save-request="saveRequest"
|
|
||||||
:collections-type="collectionsType"
|
|
||||||
@edit-request="$emit('edit-request', $event)"
|
|
||||||
@duplicate-request="$emit('duplicate-request', $event)"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
@remove-request="$emit('remove-request', $event)"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
folder.folders &&
|
|
||||||
folder.folders.length === 0 &&
|
|
||||||
folder.requests &&
|
|
||||||
folder.requests.length === 0
|
|
||||||
"
|
|
||||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
|
||||||
loading="lazy"
|
|
||||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
|
||||||
:alt="`${t('empty.folder')}`"
|
|
||||||
/>
|
|
||||||
<span class="text-center">
|
|
||||||
{{ t("empty.folder") }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import IconFilePlus from "~icons/lucide/file-plus"
|
|
||||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
|
||||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
|
||||||
import IconEdit from "~icons/lucide/edit"
|
|
||||||
import IconDownload from "~icons/lucide/download"
|
|
||||||
import IconTrash2 from "~icons/lucide/trash-2"
|
|
||||||
import IconFolder from "~icons/lucide/folder"
|
|
||||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
|
||||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
|
||||||
import { defineComponent, ref } from "vue"
|
|
||||||
import { useColorMode } from "@composables/theming"
|
|
||||||
import { useToast } from "@composables/toast"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
import { moveRESTRequest } from "~/newstore/collections"
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: "Folder",
|
|
||||||
props: {
|
|
||||||
folder: { type: Object, default: () => ({}) },
|
|
||||||
folderIndex: { type: Number, default: null },
|
|
||||||
collectionIndex: { type: Number, default: null },
|
|
||||||
folderPath: { type: String, default: null },
|
|
||||||
saveRequest: Boolean,
|
|
||||||
isFiltered: Boolean,
|
|
||||||
collectionsType: { type: Object, default: () => ({}) },
|
|
||||||
picked: { type: Object, default: () => ({}) },
|
|
||||||
},
|
|
||||||
emits: [
|
|
||||||
"add-request",
|
|
||||||
"add-folder",
|
|
||||||
"edit-folder",
|
|
||||||
"update-team",
|
|
||||||
"remove-folder",
|
|
||||||
"edit-request",
|
|
||||||
"duplicate-request",
|
|
||||||
"select",
|
|
||||||
"remove-request",
|
|
||||||
"update-team-collections",
|
|
||||||
],
|
|
||||||
setup() {
|
|
||||||
const t = useI18n()
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Template refs
|
|
||||||
tippyActions: ref<any | null>(null),
|
|
||||||
options: ref<any | null>(null),
|
|
||||||
requestAction: ref<any | null>(null),
|
|
||||||
folderAction: ref<any | null>(null),
|
|
||||||
edit: ref<any | null>(null),
|
|
||||||
deleteAction: ref<any | null>(null),
|
|
||||||
exportAction: ref<any | null>(null),
|
|
||||||
t,
|
|
||||||
toast: useToast(),
|
|
||||||
colorMode: useColorMode(),
|
|
||||||
IconFilePlus,
|
|
||||||
IconFolderPlus,
|
|
||||||
IconMoreVertical,
|
|
||||||
IconEdit,
|
|
||||||
IconDownload,
|
|
||||||
IconTrash2,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showChildren: false,
|
|
||||||
dragging: false,
|
|
||||||
prevCursor: "",
|
|
||||||
cursor: "",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isSelected(): boolean {
|
|
||||||
return (
|
|
||||||
this.picked &&
|
|
||||||
this.picked.pickedType === "my-folder" &&
|
|
||||||
this.picked.folderPath === this.folderPath
|
|
||||||
)
|
|
||||||
},
|
|
||||||
getCollectionIcon() {
|
|
||||||
if (this.isSelected) return IconCheckCircle
|
|
||||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
|
||||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
|
||||||
else return IconFolder
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
exportFolder() {
|
|
||||||
const folderJSON = JSON.stringify(this.folder)
|
|
||||||
|
|
||||||
const file = new Blob([folderJSON], { type: "application/json" })
|
|
||||||
const a = document.createElement("a")
|
|
||||||
const url = URL.createObjectURL(file)
|
|
||||||
a.href = url
|
|
||||||
|
|
||||||
a.download = `${this.folder.name}.json`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
this.toast.success(this.t("state.download_started").toString())
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}, 1000)
|
|
||||||
},
|
|
||||||
toggleShowChildren() {
|
|
||||||
if (this.$props.saveRequest)
|
|
||||||
this.$emit("select", {
|
|
||||||
picked: {
|
|
||||||
pickedType: "my-folder",
|
|
||||||
collectionIndex: this.collectionIndex,
|
|
||||||
folderName: this.folder.name,
|
|
||||||
folderPath: this.folderPath,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
this.showChildren = !this.showChildren
|
|
||||||
},
|
|
||||||
removeFolder() {
|
|
||||||
this.$emit("remove-folder", {
|
|
||||||
folder: this.folder,
|
|
||||||
folderPath: this.folderPath,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
dropEvent({ dataTransfer }) {
|
|
||||||
this.dragging = !this.dragging
|
|
||||||
const folderPath = dataTransfer.getData("folderPath")
|
|
||||||
const requestIndex = dataTransfer.getData("requestIndex")
|
|
||||||
moveRESTRequest(folderPath, requestIndex, this.folderPath)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,433 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
|
||||||
<div
|
|
||||||
class="flex items-stretch group"
|
|
||||||
draggable="true"
|
|
||||||
@dragstart="dragStart"
|
|
||||||
@dragover.stop
|
|
||||||
@dragleave="dragging = false"
|
|
||||||
@dragend="dragging = false"
|
|
||||||
@contextmenu.prevent="options.tippy.show()"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
|
||||||
:class="getRequestLabelColor(request.method)"
|
|
||||||
@click="selectRequest()"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="IconCheckCircle"
|
|
||||||
v-if="isSelected"
|
|
||||||
class="svg-icons"
|
|
||||||
:class="{ 'text-accent': isSelected }"
|
|
||||||
/>
|
|
||||||
<span v-else class="font-semibold truncate text-tiny">
|
|
||||||
{{ request.method }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
|
||||||
@click="selectRequest()"
|
|
||||||
>
|
|
||||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
|
||||||
{{ request.name }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="isActive"
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
|
|
||||||
:title="`${t('collection.request_in_use')}`"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="flex">
|
|
||||||
<ButtonSecondary
|
|
||||||
v-if="!saveRequest"
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:icon="IconRotateCCW"
|
|
||||||
:title="t('action.restore')"
|
|
||||||
class="hidden group-hover:inline-flex"
|
|
||||||
@click="selectRequest()"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<tippy
|
|
||||||
ref="options"
|
|
||||||
interactive
|
|
||||||
trigger="click"
|
|
||||||
theme="popover"
|
|
||||||
:on-shown="() => tippyActions.focus()"
|
|
||||||
>
|
|
||||||
<ButtonSecondary
|
|
||||||
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.e="edit.$el.click()"
|
|
||||||
@keyup.d="duplicate.$el.click()"
|
|
||||||
@keyup.delete="deleteAction.$el.click()"
|
|
||||||
@keyup.escape="hide()"
|
|
||||||
>
|
|
||||||
<SmartItem
|
|
||||||
ref="edit"
|
|
||||||
:icon="IconEdit"
|
|
||||||
:label="t('action.edit')"
|
|
||||||
:shortcut="['E']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
emit('edit-request', {
|
|
||||||
collectionIndex,
|
|
||||||
folderIndex,
|
|
||||||
folderName,
|
|
||||||
request,
|
|
||||||
requestIndex,
|
|
||||||
folderPath,
|
|
||||||
})
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="duplicate"
|
|
||||||
:icon="IconCopy"
|
|
||||||
:label="t('action.duplicate')"
|
|
||||||
:shortcut="['D']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
emit('duplicate-request', {
|
|
||||||
collectionIndex,
|
|
||||||
folderIndex,
|
|
||||||
folderName,
|
|
||||||
request,
|
|
||||||
requestIndex,
|
|
||||||
folderPath,
|
|
||||||
})
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="deleteAction"
|
|
||||||
:icon="IconTrash2"
|
|
||||||
:label="t('action.delete')"
|
|
||||||
:shortcut="['⌫']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
removeRequest()
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</tippy>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<HttpReqChangeConfirmModal
|
|
||||||
:show="confirmChange"
|
|
||||||
@hide-modal="confirmChange = false"
|
|
||||||
@save-change="saveRequestChange"
|
|
||||||
@discard-change="discardRequestChange"
|
|
||||||
/>
|
|
||||||
<CollectionsSaveRequest
|
|
||||||
mode="rest"
|
|
||||||
:show="showSaveRequestModal"
|
|
||||||
@hide-modal="showSaveRequestModal = false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
|
||||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
|
||||||
import IconEdit from "~icons/lucide/edit"
|
|
||||||
import IconCopy from "~icons/lucide/copy"
|
|
||||||
import IconTrash2 from "~icons/lucide/trash-2"
|
|
||||||
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
|
||||||
import { ref, computed } from "vue"
|
|
||||||
import {
|
|
||||||
HoppRESTRequest,
|
|
||||||
safelyExtractRESTRequest,
|
|
||||||
translateToNewRequest,
|
|
||||||
isEqualHoppRESTRequest,
|
|
||||||
} from "@hoppscotch/data"
|
|
||||||
import * as E from "fp-ts/Either"
|
|
||||||
import { cloneDeep } from "lodash-es"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
import { useToast } from "@composables/toast"
|
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
|
||||||
import {
|
|
||||||
getDefaultRESTRequest,
|
|
||||||
getRESTRequest,
|
|
||||||
restSaveContext$,
|
|
||||||
setRESTRequest,
|
|
||||||
setRESTSaveContext,
|
|
||||||
getRESTSaveContext,
|
|
||||||
} from "~/newstore/RESTSession"
|
|
||||||
import { editRESTRequest } from "~/newstore/collections"
|
|
||||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
|
||||||
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
|
|
||||||
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
request: HoppRESTRequest
|
|
||||||
collectionIndex: number
|
|
||||||
folderIndex: number
|
|
||||||
folderName: string
|
|
||||||
requestIndex: number
|
|
||||||
saveRequest: boolean
|
|
||||||
collectionsType: object
|
|
||||||
folderPath: string
|
|
||||||
picked?: {
|
|
||||||
pickedType: string
|
|
||||||
collectionIndex: number
|
|
||||||
folderPath: string
|
|
||||||
folderName: string
|
|
||||||
requestIndex: number
|
|
||||||
}
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(
|
|
||||||
e: "select",
|
|
||||||
data:
|
|
||||||
| {
|
|
||||||
picked: {
|
|
||||||
pickedType: string
|
|
||||||
collectionIndex: number
|
|
||||||
folderPath: string
|
|
||||||
folderName: string
|
|
||||||
requestIndex: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
): void
|
|
||||||
|
|
||||||
(
|
|
||||||
e: "remove-request",
|
|
||||||
data: {
|
|
||||||
folderPath: string
|
|
||||||
requestIndex: number
|
|
||||||
}
|
|
||||||
): void
|
|
||||||
|
|
||||||
(
|
|
||||||
e: "duplicate-request",
|
|
||||||
data: {
|
|
||||||
collectionIndex: number
|
|
||||||
folderIndex: number
|
|
||||||
folderName: string
|
|
||||||
request: HoppRESTRequest
|
|
||||||
folderPath: string
|
|
||||||
requestIndex: number
|
|
||||||
}
|
|
||||||
): void
|
|
||||||
|
|
||||||
(
|
|
||||||
e: "edit-request",
|
|
||||||
data: {
|
|
||||||
collectionIndex: number
|
|
||||||
folderIndex: number
|
|
||||||
folderName: string
|
|
||||||
request: HoppRESTRequest
|
|
||||||
folderPath: string
|
|
||||||
requestIndex: number
|
|
||||||
}
|
|
||||||
): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const t = useI18n()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const dragging = ref(false)
|
|
||||||
const requestMethodLabels = {
|
|
||||||
get: "text-green-500",
|
|
||||||
post: "text-yellow-500",
|
|
||||||
put: "text-blue-500",
|
|
||||||
delete: "text-red-500",
|
|
||||||
default: "text-gray-500",
|
|
||||||
}
|
|
||||||
const confirmChange = ref(false)
|
|
||||||
const showSaveRequestModal = ref(false)
|
|
||||||
|
|
||||||
// Template refs
|
|
||||||
const tippyActions = ref<any | null>(null)
|
|
||||||
const options = ref<any | null>(null)
|
|
||||||
const edit = ref<any | null>(null)
|
|
||||||
const duplicate = ref<any | null>(null)
|
|
||||||
const deleteAction = ref<any | null>(null)
|
|
||||||
|
|
||||||
const active = useReadonlyStream(restSaveContext$, null)
|
|
||||||
|
|
||||||
const isSelected = computed(
|
|
||||||
() =>
|
|
||||||
props.picked &&
|
|
||||||
props.picked.pickedType === "my-request" &&
|
|
||||||
props.picked.folderPath === props.folderPath &&
|
|
||||||
props.picked.requestIndex === props.requestIndex
|
|
||||||
)
|
|
||||||
|
|
||||||
const isActive = computed(
|
|
||||||
() =>
|
|
||||||
active.value &&
|
|
||||||
active.value.originLocation === "user-collection" &&
|
|
||||||
active.value.folderPath === props.folderPath &&
|
|
||||||
active.value.requestIndex === props.requestIndex
|
|
||||||
)
|
|
||||||
|
|
||||||
const dragStart = ({ dataTransfer }: DragEvent) => {
|
|
||||||
if (dataTransfer) {
|
|
||||||
dragging.value = !dragging.value
|
|
||||||
dataTransfer.setData("folderPath", props.folderPath)
|
|
||||||
dataTransfer.setData("requestIndex", props.requestIndex.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeRequest = () => {
|
|
||||||
emit("remove-request", {
|
|
||||||
folderPath: props.folderPath,
|
|
||||||
requestIndex: props.requestIndex,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRequestLabelColor = (method: string) =>
|
|
||||||
requestMethodLabels[
|
|
||||||
method.toLowerCase() as keyof typeof requestMethodLabels
|
|
||||||
] || requestMethodLabels.default
|
|
||||||
|
|
||||||
const setRestReq = (request: any) => {
|
|
||||||
setRESTRequest(
|
|
||||||
cloneDeep(
|
|
||||||
safelyExtractRESTRequest(
|
|
||||||
translateToNewRequest(request),
|
|
||||||
getDefaultRESTRequest()
|
|
||||||
)
|
|
||||||
),
|
|
||||||
{
|
|
||||||
originLocation: "user-collection",
|
|
||||||
folderPath: props.folderPath,
|
|
||||||
requestIndex: props.requestIndex,
|
|
||||||
req: cloneDeep(request),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loads request from the save once, checks for unsaved changes, but ignores default values */
|
|
||||||
const selectRequest = () => {
|
|
||||||
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
|
|
||||||
if (props.saveRequest) {
|
|
||||||
emit("select", {
|
|
||||||
picked: {
|
|
||||||
pickedType: "my-request",
|
|
||||||
collectionIndex: props.collectionIndex,
|
|
||||||
folderPath: props.folderPath,
|
|
||||||
folderName: props.folderName,
|
|
||||||
requestIndex: props.requestIndex,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
|
|
||||||
confirmChange.value = false
|
|
||||||
setRestReq(props.request)
|
|
||||||
} else if (!active.value) {
|
|
||||||
// If the current request is the same as the request to be loaded in, there is no data loss
|
|
||||||
const currentReq = getRESTRequest()
|
|
||||||
|
|
||||||
if (isEqualHoppRESTRequest(currentReq, props.request)) {
|
|
||||||
setRestReq(props.request)
|
|
||||||
} else {
|
|
||||||
confirmChange.value = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const currentReqWithNoChange = active.value.req
|
|
||||||
const currentFullReq = getRESTRequest()
|
|
||||||
|
|
||||||
// Check if whether user clicked the same request or not
|
|
||||||
if (!isActive.value && currentReqWithNoChange !== undefined) {
|
|
||||||
// Check if there is any changes done on the current request
|
|
||||||
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
|
|
||||||
setRestReq(props.request)
|
|
||||||
} else {
|
|
||||||
confirmChange.value = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setRESTSaveContext(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save current request to the collection */
|
|
||||||
const saveRequestChange = () => {
|
|
||||||
const saveCtx = getRESTSaveContext()
|
|
||||||
saveCurrentRequest(saveCtx)
|
|
||||||
confirmChange.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Discard changes and change the current request and context */
|
|
||||||
const discardRequestChange = () => {
|
|
||||||
setRestReq(props.request)
|
|
||||||
if (!isActive.value) {
|
|
||||||
setRESTSaveContext({
|
|
||||||
originLocation: "user-collection",
|
|
||||||
folderPath: props.folderPath,
|
|
||||||
requestIndex: props.requestIndex,
|
|
||||||
req: cloneDeep(props.request),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmChange.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
|
|
||||||
if (!saveCtx) {
|
|
||||||
showSaveRequestModal.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (saveCtx.originLocation === "user-collection") {
|
|
||||||
try {
|
|
||||||
editRESTRequest(
|
|
||||||
saveCtx.folderPath,
|
|
||||||
saveCtx.requestIndex,
|
|
||||||
getRESTRequest()
|
|
||||||
)
|
|
||||||
setRestReq(props.request)
|
|
||||||
toast.success(`${t("request.saved")}`)
|
|
||||||
} catch (e) {
|
|
||||||
setRESTSaveContext(null)
|
|
||||||
saveCurrentRequest(saveCtx)
|
|
||||||
}
|
|
||||||
} else if (saveCtx.originLocation === "team-collection") {
|
|
||||||
const req = getRESTRequest()
|
|
||||||
try {
|
|
||||||
runMutation(UpdateRequestDocument, {
|
|
||||||
requestID: saveCtx.requestID,
|
|
||||||
data: {
|
|
||||||
title: req.name,
|
|
||||||
request: JSON.stringify(req),
|
|
||||||
},
|
|
||||||
})().then((result) => {
|
|
||||||
if (E.isLeft(result)) {
|
|
||||||
toast.error(`${t("profile.no_permission")}`)
|
|
||||||
} else {
|
|
||||||
toast.success(`${t("request.saved")}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setRestReq(props.request)
|
|
||||||
} catch (error) {
|
|
||||||
showSaveRequestModal.value = true
|
|
||||||
toast.error(`${t("error.something_went_wrong")}`)
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div
|
|
||||||
class="flex items-stretch group"
|
|
||||||
@dragover.prevent
|
|
||||||
@drop.prevent="dropEvent"
|
|
||||||
@dragover="dragging = true"
|
|
||||||
@drop="dragging = false"
|
|
||||||
@dragleave="dragging = false"
|
|
||||||
@dragend="dragging = false"
|
|
||||||
@contextmenu.prevent="options!.tippy.show()"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex items-center justify-center px-4 cursor-pointer"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="getCollectionIcon"
|
|
||||||
class="svg-icons"
|
|
||||||
:class="{ 'text-accent': isSelected }"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
>
|
|
||||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
|
||||||
{{ collection.title }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="flex">
|
|
||||||
<ButtonSecondary
|
|
||||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:icon="IconFilePlus"
|
|
||||||
:title="t('request.new')"
|
|
||||||
class="hidden group-hover:inline-flex"
|
|
||||||
@click="
|
|
||||||
$emit('add-request', {
|
|
||||||
folder: collection,
|
|
||||||
path: `${collectionIndex}`,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<ButtonSecondary
|
|
||||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:icon="IconFolderPlus"
|
|
||||||
:title="t('folder.new')"
|
|
||||||
class="hidden group-hover:inline-flex"
|
|
||||||
@click="
|
|
||||||
$emit('add-folder', {
|
|
||||||
folder: collection,
|
|
||||||
path: `${collectionIndex}`,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<tippy
|
|
||||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
|
||||||
ref="options"
|
|
||||||
interactive
|
|
||||||
trigger="click"
|
|
||||||
theme="popover"
|
|
||||||
:on-shown="() => tippyActions!.focus()"
|
|
||||||
>
|
|
||||||
<ButtonSecondary
|
|
||||||
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.r="requestAction!.$el.click()"
|
|
||||||
@keyup.n="folderAction!.$el.click()"
|
|
||||||
@keyup.e="edit!.$el.click()"
|
|
||||||
@keyup.delete="deleteAction!.$el.click()"
|
|
||||||
@keyup.x="exportAction!.$el.click()"
|
|
||||||
@keyup.escape="hide()"
|
|
||||||
>
|
|
||||||
<SmartItem
|
|
||||||
ref="requestAction"
|
|
||||||
:icon="IconFilePlus"
|
|
||||||
:label="t('request.new')"
|
|
||||||
:shortcut="['R']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('add-request', {
|
|
||||||
folder: collection,
|
|
||||||
path: `${collectionIndex}`,
|
|
||||||
})
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="folderAction"
|
|
||||||
:icon="IconFolderPlus"
|
|
||||||
:label="t('folder.new')"
|
|
||||||
:shortcut="['N']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('add-folder', {
|
|
||||||
folder: collection,
|
|
||||||
path: `${collectionIndex}`,
|
|
||||||
})
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="edit"
|
|
||||||
:icon="IconEdit"
|
|
||||||
:label="t('action.edit')"
|
|
||||||
:shortcut="['E']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('edit-collection')
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="exportAction"
|
|
||||||
:icon="IconDownload"
|
|
||||||
:label="t('export.title')"
|
|
||||||
:shortcut="['X']"
|
|
||||||
:loading="exportLoading"
|
|
||||||
@click="exportCollection"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="deleteAction"
|
|
||||||
:icon="IconTrash2"
|
|
||||||
:label="t('action.delete')"
|
|
||||||
:shortcut="['⌫']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
removeCollection()
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</tippy>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showChildren || isFiltered" class="flex">
|
|
||||||
<div
|
|
||||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
></div>
|
|
||||||
<div class="flex flex-col flex-1 truncate">
|
|
||||||
<CollectionsTeamsFolder
|
|
||||||
v-for="(folder, index) in collection.children"
|
|
||||||
:key="`folder-${index}`"
|
|
||||||
:folder="folder"
|
|
||||||
:folder-index="index"
|
|
||||||
:folder-path="`${collectionIndex}/${index}`"
|
|
||||||
:collection-index="collectionIndex"
|
|
||||||
:save-request="saveRequest"
|
|
||||||
:collections-type="collectionsType"
|
|
||||||
:is-filtered="isFiltered"
|
|
||||||
:picked="picked"
|
|
||||||
:loading-collection-i-ds="loadingCollectionIDs"
|
|
||||||
@add-request="$emit('add-request', $event)"
|
|
||||||
@add-folder="$emit('add-folder', $event)"
|
|
||||||
@edit-folder="$emit('edit-folder', $event)"
|
|
||||||
@edit-request="$emit('edit-request', $event)"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
@expand-collection="expandCollection"
|
|
||||||
@remove-request="$emit('remove-request', $event)"
|
|
||||||
@remove-folder="$emit('remove-folder', $event)"
|
|
||||||
@duplicate-request="$emit('duplicate-request', $event)"
|
|
||||||
/>
|
|
||||||
<CollectionsTeamsRequest
|
|
||||||
v-for="(request, index) in collection.requests"
|
|
||||||
:key="`request-${index}`"
|
|
||||||
:request="request.request"
|
|
||||||
:collection-index="collectionIndex"
|
|
||||||
:folder-index="-1"
|
|
||||||
:folder-name="collection.name"
|
|
||||||
:request-index="request.id"
|
|
||||||
:save-request="saveRequest"
|
|
||||||
:collection-i-d="collection.id"
|
|
||||||
:collections-type="collectionsType"
|
|
||||||
:picked="picked"
|
|
||||||
@edit-request="editRequest($event)"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
@remove-request="$emit('remove-request', $event)"
|
|
||||||
@duplicate-request="$emit('duplicate-request', $event)"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="loadingCollectionIDs.includes(collection.id)"
|
|
||||||
class="flex flex-col items-center justify-center p-4"
|
|
||||||
>
|
|
||||||
<SmartSpinner class="my-4" />
|
|
||||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="
|
|
||||||
(collection.children == undefined ||
|
|
||||||
collection.children.length === 0) &&
|
|
||||||
(collection.requests == undefined ||
|
|
||||||
collection.requests.length === 0)
|
|
||||||
"
|
|
||||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
|
||||||
loading="lazy"
|
|
||||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
|
||||||
:alt="`${t('empty.collection')}`"
|
|
||||||
/>
|
|
||||||
<span class="text-center">
|
|
||||||
{{ t("empty.collection") }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
|
||||||
import IconTrash2 from "~icons/lucide/trash-2"
|
|
||||||
import IconDownload from "~icons/lucide/download"
|
|
||||||
import IconEdit from "~icons/lucide/edit"
|
|
||||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
|
||||||
import IconFilePlus from "~icons/lucide/file-plus"
|
|
||||||
import IconCircle from "~icons/lucide/circle"
|
|
||||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
|
||||||
import IconFolder from "~icons/lucide/folder"
|
|
||||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
|
||||||
import { defineComponent, ref } from "vue"
|
|
||||||
import * as E from "fp-ts/Either"
|
|
||||||
import {
|
|
||||||
getCompleteCollectionTree,
|
|
||||||
teamCollToHoppRESTColl,
|
|
||||||
} from "~/helpers/backend/helpers"
|
|
||||||
import { moveRESTTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
|
|
||||||
import { useColorMode } from "@composables/theming"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
import { useToast } from "@composables/toast"
|
|
||||||
import { TippyComponent } from "vue-tippy"
|
|
||||||
import SmartItem from "@components/smart/Item.vue"
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
props: {
|
|
||||||
collectionIndex: { type: Number, default: null },
|
|
||||||
collection: { type: Object, default: () => ({}) },
|
|
||||||
isFiltered: Boolean,
|
|
||||||
saveRequest: Boolean,
|
|
||||||
collectionsType: { type: Object, default: () => ({}) },
|
|
||||||
picked: { type: Object, default: () => ({}) },
|
|
||||||
loadingCollectionIDs: { type: Array, default: () => [] },
|
|
||||||
},
|
|
||||||
emits: [
|
|
||||||
"edit-collection",
|
|
||||||
"add-request",
|
|
||||||
"add-folder",
|
|
||||||
"edit-folder",
|
|
||||||
"edit-request",
|
|
||||||
"remove-folder",
|
|
||||||
"select",
|
|
||||||
"remove-request",
|
|
||||||
"duplicate-request",
|
|
||||||
"expand-collection",
|
|
||||||
"remove-collection",
|
|
||||||
],
|
|
||||||
setup() {
|
|
||||||
const t = useI18n()
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Template refs
|
|
||||||
tippyActions: ref<TippyComponent | null>(null),
|
|
||||||
options: ref<TippyComponent | null>(null),
|
|
||||||
requestAction: ref<typeof SmartItem | null>(null),
|
|
||||||
folderAction: ref<typeof SmartItem | null>(null),
|
|
||||||
edit: ref<typeof SmartItem | null>(null),
|
|
||||||
deleteAction: ref<typeof SmartItem | null>(null),
|
|
||||||
exportAction: ref<typeof SmartItem | null>(null),
|
|
||||||
exportLoading: ref<boolean>(false),
|
|
||||||
t,
|
|
||||||
toast: useToast(),
|
|
||||||
colorMode: useColorMode(),
|
|
||||||
|
|
||||||
IconCheckCircle,
|
|
||||||
IconCircle,
|
|
||||||
IconFilePlus,
|
|
||||||
IconFolderPlus,
|
|
||||||
IconEdit,
|
|
||||||
IconDownload,
|
|
||||||
IconTrash2,
|
|
||||||
IconMoreVertical,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showChildren: false,
|
|
||||||
dragging: false,
|
|
||||||
selectedFolder: {},
|
|
||||||
prevCursor: "",
|
|
||||||
cursor: "",
|
|
||||||
pageNo: 0,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isSelected(): boolean {
|
|
||||||
return (
|
|
||||||
this.picked &&
|
|
||||||
this.picked.pickedType === "teams-collection" &&
|
|
||||||
this.picked.collectionID === this.collection.id
|
|
||||||
)
|
|
||||||
},
|
|
||||||
getCollectionIcon() {
|
|
||||||
if (this.isSelected) return IconCheckCircle
|
|
||||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
|
||||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
|
||||||
else return IconFolder
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async exportCollection() {
|
|
||||||
this.exportLoading = true
|
|
||||||
|
|
||||||
const result = await getCompleteCollectionTree(this.collection.id)()
|
|
||||||
|
|
||||||
if (E.isLeft(result)) {
|
|
||||||
this.toast.error(this.t("error.something_went_wrong").toString())
|
|
||||||
console.log(result.left)
|
|
||||||
this.exportLoading = false
|
|
||||||
this.options!.tippy.hide()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hoppColl = teamCollToHoppRESTColl(result.right)
|
|
||||||
|
|
||||||
const collectionJSON = JSON.stringify(hoppColl)
|
|
||||||
|
|
||||||
const file = new Blob([collectionJSON], { type: "application/json" })
|
|
||||||
const a = document.createElement("a")
|
|
||||||
const url = URL.createObjectURL(file)
|
|
||||||
a.href = url
|
|
||||||
|
|
||||||
a.download = `${hoppColl.name}.json`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
this.toast.success(this.t("state.download_started").toString())
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
this.exportLoading = false
|
|
||||||
|
|
||||||
this.options!.tippy.hide()
|
|
||||||
},
|
|
||||||
editRequest(event: any) {
|
|
||||||
this.$emit("edit-request", event)
|
|
||||||
if (this.$props.saveRequest)
|
|
||||||
this.$emit("select", {
|
|
||||||
picked: {
|
|
||||||
pickedType: "teams-collection",
|
|
||||||
collectionID: this.collection.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
toggleShowChildren() {
|
|
||||||
if (this.$props.saveRequest)
|
|
||||||
this.$emit("select", {
|
|
||||||
picked: {
|
|
||||||
pickedType: "teams-collection",
|
|
||||||
collectionID: this.collection.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
this.$emit("expand-collection", this.collection.id)
|
|
||||||
this.showChildren = !this.showChildren
|
|
||||||
},
|
|
||||||
removeCollection() {
|
|
||||||
this.$emit("remove-collection", {
|
|
||||||
collectionIndex: this.collectionIndex,
|
|
||||||
collectionID: this.collection.id,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
expandCollection(collectionID: string) {
|
|
||||||
this.$emit("expand-collection", collectionID)
|
|
||||||
},
|
|
||||||
async dropEvent({ dataTransfer }: any) {
|
|
||||||
this.dragging = !this.dragging
|
|
||||||
const requestIndex = dataTransfer.getData("requestIndex")
|
|
||||||
const moveRequestResult = await moveRESTTeamRequest(
|
|
||||||
requestIndex,
|
|
||||||
this.collection.id
|
|
||||||
)()
|
|
||||||
if (E.isLeft(moveRequestResult))
|
|
||||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
|
||||||
<div
|
|
||||||
class="flex items-stretch group"
|
|
||||||
@dragover.prevent
|
|
||||||
@drop.prevent="dropEvent"
|
|
||||||
@dragover="dragging = true"
|
|
||||||
@drop="dragging = false"
|
|
||||||
@dragleave="dragging = false"
|
|
||||||
@dragend="dragging = false"
|
|
||||||
@contextmenu.prevent="options!.tippy.show()"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex items-center justify-center px-4 cursor-pointer"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="getCollectionIcon"
|
|
||||||
class="svg-icons"
|
|
||||||
:class="{ 'text-accent': isSelected }"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
>
|
|
||||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
|
||||||
{{ folder.name ? folder.name : folder.title }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="flex">
|
|
||||||
<ButtonSecondary
|
|
||||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:icon="IconFilePlus"
|
|
||||||
:title="t('request.new')"
|
|
||||||
class="hidden group-hover:inline-flex"
|
|
||||||
@click="$emit('add-request', { folder, path: folderPath })"
|
|
||||||
/>
|
|
||||||
<ButtonSecondary
|
|
||||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:icon="IconFolderPlus"
|
|
||||||
:title="t('folder.new')"
|
|
||||||
class="hidden group-hover:inline-flex"
|
|
||||||
@click="$emit('add-folder', { folder, path: folderPath })"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<tippy
|
|
||||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
|
||||||
ref="options"
|
|
||||||
interactive
|
|
||||||
trigger="click"
|
|
||||||
theme="popover"
|
|
||||||
:on-shown="() => tippyActions!.focus()"
|
|
||||||
>
|
|
||||||
<ButtonSecondary
|
|
||||||
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.r="requestAction!.$el.click()"
|
|
||||||
@keyup.n="folderAction!.$el.click()"
|
|
||||||
@keyup.e="edit!.$el.click()"
|
|
||||||
@keyup.delete="deleteAction!.$el.click()"
|
|
||||||
@keyup.x="exportAction!.$el.click()"
|
|
||||||
@keyup.escape="hide()"
|
|
||||||
>
|
|
||||||
<SmartItem
|
|
||||||
ref="requestAction"
|
|
||||||
:icon="IconFilePlus"
|
|
||||||
:label="t('request.new')"
|
|
||||||
:shortcut="['R']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('add-request', { folder, path: folderPath })
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="folderAction"
|
|
||||||
:icon="IconFolderPlus"
|
|
||||||
:label="t('folder.new')"
|
|
||||||
:shortcut="['N']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('add-folder', { folder, path: folderPath })
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="edit"
|
|
||||||
:icon="IconEdit"
|
|
||||||
:label="t('action.edit')"
|
|
||||||
:shortcut="['E']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
$emit('edit-folder', {
|
|
||||||
folder,
|
|
||||||
folderIndex,
|
|
||||||
collectionIndex,
|
|
||||||
folderPath: '',
|
|
||||||
})
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="exportAction"
|
|
||||||
:icon="IconDownload"
|
|
||||||
:label="t('export.title')"
|
|
||||||
:shortcut="['X']"
|
|
||||||
:loading="exportLoading"
|
|
||||||
@click="exportFolder"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="deleteAction"
|
|
||||||
:icon="IconTrash2"
|
|
||||||
:label="t('action.delete')"
|
|
||||||
:shortcut="['⌫']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
removeFolder()
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</tippy>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="showChildren || isFiltered" class="flex">
|
|
||||||
<div
|
|
||||||
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
|
||||||
@click="toggleShowChildren()"
|
|
||||||
></div>
|
|
||||||
<div class="flex flex-col flex-1 truncate">
|
|
||||||
<!-- Referring to this component only (this is recursive) -->
|
|
||||||
<Folder
|
|
||||||
v-for="(subFolder, subFolderIndex) in folder.children"
|
|
||||||
:key="`subFolder-${subFolderIndex}`"
|
|
||||||
:folder="subFolder"
|
|
||||||
:folder-index="subFolderIndex"
|
|
||||||
:collection-index="collectionIndex"
|
|
||||||
:save-request="saveRequest"
|
|
||||||
:collections-type="collectionsType"
|
|
||||||
:folder-path="`${folderPath}/${subFolderIndex}`"
|
|
||||||
:picked="picked"
|
|
||||||
:loading-collection-i-ds="loadingCollectionIDs"
|
|
||||||
@add-request="$emit('add-request', $event)"
|
|
||||||
@add-folder="$emit('add-folder', $event)"
|
|
||||||
@edit-folder="$emit('edit-folder', $event)"
|
|
||||||
@edit-request="$emit('edit-request', $event)"
|
|
||||||
@update-team-collections="$emit('update-team-collections')"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
@expand-collection="expandCollection"
|
|
||||||
@remove-request="$emit('remove-request', $event)"
|
|
||||||
@remove-folder="$emit('remove-folder', $event)"
|
|
||||||
@duplicate-request="$emit('duplicate-request', $event)"
|
|
||||||
/>
|
|
||||||
<CollectionsTeamsRequest
|
|
||||||
v-for="(request, index) in folder.requests"
|
|
||||||
:key="`request-${index}`"
|
|
||||||
:request="request.request"
|
|
||||||
:collection-index="collectionIndex"
|
|
||||||
:folder-index="folderIndex"
|
|
||||||
:folder-name="folder.name"
|
|
||||||
:request-index="request.id"
|
|
||||||
:save-request="saveRequest"
|
|
||||||
:collections-type="collectionsType"
|
|
||||||
:picked="picked"
|
|
||||||
:collection-i-d="folder.id"
|
|
||||||
@edit-request="$emit('edit-request', $event)"
|
|
||||||
@select="$emit('select', $event)"
|
|
||||||
@remove-request="$emit('remove-request', $event)"
|
|
||||||
@duplicate-request="$emit('duplicate-request', $event)"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="loadingCollectionIDs.includes(folder.id)"
|
|
||||||
class="flex flex-col items-center justify-center p-4"
|
|
||||||
>
|
|
||||||
<SmartSpinner class="my-4" />
|
|
||||||
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="
|
|
||||||
(folder.children == undefined || folder.children.length === 0) &&
|
|
||||||
(folder.requests == undefined || folder.requests.length === 0)
|
|
||||||
"
|
|
||||||
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="`/images/states/${colorMode.value}/pack.svg`"
|
|
||||||
loading="lazy"
|
|
||||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
|
||||||
:alt="`${t('empty.folder')}`"
|
|
||||||
/>
|
|
||||||
<span class="text-center">
|
|
||||||
{{ t("empty.folder") }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
|
||||||
import IconEdit from "~icons/lucide/edit"
|
|
||||||
import IconDownload from "~icons/lucide/download"
|
|
||||||
import IconTrash2 from "~icons/lucide/trash-2"
|
|
||||||
import IconFilePlus from "~icons/lucide/file-plus"
|
|
||||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
|
||||||
import IconFolderPlus from "~icons/lucide/folder-plus"
|
|
||||||
import IconFolder from "~icons/lucide/folder"
|
|
||||||
import IconFolderOpen from "~icons/lucide/folder-open"
|
|
||||||
import { defineComponent, ref } from "vue"
|
|
||||||
import * as E from "fp-ts/Either"
|
|
||||||
import {
|
|
||||||
getCompleteCollectionTree,
|
|
||||||
teamCollToHoppRESTColl,
|
|
||||||
} from "~/helpers/backend/helpers"
|
|
||||||
import { moveRESTTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
import { useToast } from "@composables/toast"
|
|
||||||
import { useColorMode } from "@composables/theming"
|
|
||||||
import { TippyComponent } from "vue-tippy"
|
|
||||||
import SmartItem from "@components/smart/Item.vue"
|
|
||||||
|
|
||||||
export default defineComponent({
|
|
||||||
name: "Folder",
|
|
||||||
props: {
|
|
||||||
folder: { type: Object, default: () => ({}) },
|
|
||||||
folderIndex: { type: Number, default: null },
|
|
||||||
collectionIndex: { type: Number, default: null },
|
|
||||||
folderPath: { type: String, default: null },
|
|
||||||
saveRequest: Boolean,
|
|
||||||
isFiltered: Boolean,
|
|
||||||
collectionsType: { type: Object, default: () => ({}) },
|
|
||||||
picked: { type: Object, default: () => ({}) },
|
|
||||||
loadingCollectionIDs: { type: Array, default: () => [] },
|
|
||||||
},
|
|
||||||
emits: [
|
|
||||||
"add-request",
|
|
||||||
"add-folder",
|
|
||||||
"edit-folder",
|
|
||||||
"update-team-collections",
|
|
||||||
"edit-request",
|
|
||||||
"remove-request",
|
|
||||||
"duplicate-request",
|
|
||||||
"select",
|
|
||||||
"remove-folder",
|
|
||||||
"expand-collection",
|
|
||||||
],
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
// Template refs
|
|
||||||
tippyActions: ref<TippyComponent | null>(null),
|
|
||||||
options: ref<TippyComponent | null>(null),
|
|
||||||
requestAction: ref<typeof SmartItem | null>(null),
|
|
||||||
folderAction: ref<typeof SmartItem | null>(null),
|
|
||||||
edit: ref<typeof SmartItem | null>(null),
|
|
||||||
deleteAction: ref<typeof SmartItem | null>(null),
|
|
||||||
exportAction: ref<typeof SmartItem | null>(null),
|
|
||||||
exportLoading: ref<boolean>(false),
|
|
||||||
toast: useToast(),
|
|
||||||
t: useI18n(),
|
|
||||||
colorMode: useColorMode(),
|
|
||||||
IconFilePlus,
|
|
||||||
IconFolderPlus,
|
|
||||||
IconCheckCircle,
|
|
||||||
IconFolder,
|
|
||||||
IconFolderOpen,
|
|
||||||
IconMoreVertical,
|
|
||||||
IconEdit,
|
|
||||||
IconDownload,
|
|
||||||
IconTrash2,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showChildren: false,
|
|
||||||
dragging: false,
|
|
||||||
prevCursor: "",
|
|
||||||
cursor: "",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
isSelected(): boolean {
|
|
||||||
return (
|
|
||||||
this.picked &&
|
|
||||||
this.picked.pickedType === "teams-folder" &&
|
|
||||||
this.picked.folderID === this.folder.id
|
|
||||||
)
|
|
||||||
},
|
|
||||||
getCollectionIcon() {
|
|
||||||
if (this.isSelected) return IconCheckCircle
|
|
||||||
else if (!this.showChildren && !this.isFiltered) return IconFolder
|
|
||||||
else if (this.showChildren || this.isFiltered) return IconFolderOpen
|
|
||||||
else return IconFolder
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async exportFolder() {
|
|
||||||
this.exportLoading = true
|
|
||||||
|
|
||||||
const result = await getCompleteCollectionTree(this.folder.id)()
|
|
||||||
|
|
||||||
if (E.isLeft(result)) {
|
|
||||||
this.toast.error(this.t("error.something_went_wrong").toString())
|
|
||||||
console.log(result.left)
|
|
||||||
this.exportLoading = false
|
|
||||||
this.options!.tippy.hide()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hoppColl = teamCollToHoppRESTColl(result.right)
|
|
||||||
|
|
||||||
const collectionJSON = JSON.stringify(hoppColl)
|
|
||||||
|
|
||||||
const file = new Blob([collectionJSON], { type: "application/json" })
|
|
||||||
const a = document.createElement("a")
|
|
||||||
const url = URL.createObjectURL(file)
|
|
||||||
a.href = url
|
|
||||||
|
|
||||||
a.download = `${hoppColl.name}.json`
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
this.toast.success(this.t("state.download_started").toString())
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
document.body.removeChild(a)
|
|
||||||
URL.revokeObjectURL(url)
|
|
||||||
}, 1000)
|
|
||||||
|
|
||||||
this.exportLoading = false
|
|
||||||
|
|
||||||
this.options!.tippy.hide()
|
|
||||||
},
|
|
||||||
toggleShowChildren() {
|
|
||||||
if (this.$props.saveRequest)
|
|
||||||
this.$emit("select", {
|
|
||||||
picked: {
|
|
||||||
pickedType: "teams-folder",
|
|
||||||
folderID: this.folder.id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
this.$emit("expand-collection", this.$props.folder.id)
|
|
||||||
this.showChildren = !this.showChildren
|
|
||||||
},
|
|
||||||
removeFolder() {
|
|
||||||
this.$emit("remove-folder", {
|
|
||||||
collectionsType: this.collectionsType,
|
|
||||||
folder: this.folder,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
expandCollection(collectionID: number) {
|
|
||||||
this.$emit("expand-collection", collectionID)
|
|
||||||
},
|
|
||||||
async dropEvent({ dataTransfer }: any) {
|
|
||||||
this.dragging = !this.dragging
|
|
||||||
const requestIndex = dataTransfer.getData("requestIndex")
|
|
||||||
const moveRequestResult = await moveRESTTeamRequest(
|
|
||||||
requestIndex,
|
|
||||||
this.folder.id
|
|
||||||
)()
|
|
||||||
if (E.isLeft(moveRequestResult))
|
|
||||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
|
|
||||||
<div
|
|
||||||
class="flex items-stretch group"
|
|
||||||
draggable="true"
|
|
||||||
@dragstart="dragStart"
|
|
||||||
@dragover.stop
|
|
||||||
@dragleave="dragging = false"
|
|
||||||
@dragend="dragging = false"
|
|
||||||
@contextmenu.prevent="options.tippy.show()"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
|
|
||||||
:class="getRequestLabelColor(request.method)"
|
|
||||||
@click="selectRequest()"
|
|
||||||
>
|
|
||||||
<component
|
|
||||||
:is="IconCheckCircle"
|
|
||||||
v-if="isSelected"
|
|
||||||
class="svg-icons"
|
|
||||||
:class="{ 'text-accent': isSelected }"
|
|
||||||
/>
|
|
||||||
<span v-else class="font-semibold truncate text-tiny">
|
|
||||||
{{ request.method }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
|
|
||||||
@click="selectRequest()"
|
|
||||||
>
|
|
||||||
<span class="truncate" :class="{ 'text-accent': isSelected }">
|
|
||||||
{{ request.name }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="isActive"
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
|
|
||||||
:title="`${t('collection.request_in_use')}`"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<div class="flex">
|
|
||||||
<ButtonSecondary
|
|
||||||
v-if="!saveRequest"
|
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
|
||||||
:icon="IconRotateCCW"
|
|
||||||
:title="t('action.restore')"
|
|
||||||
class="hidden group-hover:inline-flex"
|
|
||||||
@click="selectRequest()"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<tippy
|
|
||||||
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
|
|
||||||
ref="options"
|
|
||||||
interactive
|
|
||||||
trigger="click"
|
|
||||||
theme="popover"
|
|
||||||
:on-shown="() => tippyActions.focus()"
|
|
||||||
>
|
|
||||||
<ButtonSecondary
|
|
||||||
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.e="edit.$el.click()"
|
|
||||||
@keyup.d="duplicate.$el.click()"
|
|
||||||
@keyup.delete="deleteAction.$el.click()"
|
|
||||||
@keyup.escape="hide()"
|
|
||||||
>
|
|
||||||
<SmartItem
|
|
||||||
ref="edit"
|
|
||||||
:icon="IconEdit"
|
|
||||||
:label="t('action.edit')"
|
|
||||||
:shortcut="['E']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
emit('edit-request', {
|
|
||||||
collectionIndex,
|
|
||||||
folderIndex,
|
|
||||||
folderName,
|
|
||||||
request,
|
|
||||||
requestIndex,
|
|
||||||
})
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="duplicate"
|
|
||||||
:icon="IconCopy"
|
|
||||||
:label="t('action.duplicate')"
|
|
||||||
:shortcut="['D']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
emit('duplicate-request', {
|
|
||||||
request,
|
|
||||||
requestIndex,
|
|
||||||
collectionID,
|
|
||||||
})
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<SmartItem
|
|
||||||
ref="deleteAction"
|
|
||||||
:icon="IconTrash2"
|
|
||||||
:label="t('action.delete')"
|
|
||||||
:shortcut="['⌫']"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
removeRequest()
|
|
||||||
hide()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</tippy>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<HttpReqChangeConfirmModal
|
|
||||||
:show="confirmChange"
|
|
||||||
@hide-modal="confirmChange = false"
|
|
||||||
@save-change="saveRequestChange"
|
|
||||||
@discard-change="discardRequestChange"
|
|
||||||
/>
|
|
||||||
<CollectionsSaveRequest
|
|
||||||
mode="rest"
|
|
||||||
:show="showSaveRequestModal"
|
|
||||||
@hide-modal="showSaveRequestModal = false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import IconMoreVertical from "~icons/lucide/more-vertical"
|
|
||||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
|
||||||
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
|
||||||
import IconEdit from "~icons/lucide/edit"
|
|
||||||
import IconCopy from "~icons/lucide/copy"
|
|
||||||
import IconTrash2 from "~icons/lucide/trash-2"
|
|
||||||
import { ref, computed } from "vue"
|
|
||||||
import {
|
|
||||||
HoppRESTRequest,
|
|
||||||
isEqualHoppRESTRequest,
|
|
||||||
safelyExtractRESTRequest,
|
|
||||||
translateToNewRequest,
|
|
||||||
} from "@hoppscotch/data"
|
|
||||||
import * as E from "fp-ts/Either"
|
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
import { useToast } from "@composables/toast"
|
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
|
||||||
import {
|
|
||||||
getDefaultRESTRequest,
|
|
||||||
restSaveContext$,
|
|
||||||
setRESTRequest,
|
|
||||||
setRESTSaveContext,
|
|
||||||
getRESTSaveContext,
|
|
||||||
getRESTRequest,
|
|
||||||
} from "~/newstore/RESTSession"
|
|
||||||
import { editRESTRequest } from "~/newstore/collections"
|
|
||||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
|
||||||
import { Team, UpdateRequestDocument } from "~/helpers/backend/graphql"
|
|
||||||
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
request: HoppRESTRequest
|
|
||||||
collectionIndex: number
|
|
||||||
folderIndex: number
|
|
||||||
folderName?: string
|
|
||||||
requestIndex: string
|
|
||||||
saveRequest: boolean
|
|
||||||
collectionsType: {
|
|
||||||
type: "my-collections" | "team-collections"
|
|
||||||
selectedTeam: Team | undefined
|
|
||||||
}
|
|
||||||
collectionID: string
|
|
||||||
picked?: {
|
|
||||||
pickedType: string
|
|
||||||
requestID: string
|
|
||||||
}
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(
|
|
||||||
e: "select",
|
|
||||||
data:
|
|
||||||
| {
|
|
||||||
picked: {
|
|
||||||
pickedType: string
|
|
||||||
requestID: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
): void
|
|
||||||
|
|
||||||
(
|
|
||||||
e: "remove-request",
|
|
||||||
data: {
|
|
||||||
folderPath: string | undefined
|
|
||||||
requestIndex: string
|
|
||||||
}
|
|
||||||
): void
|
|
||||||
|
|
||||||
(
|
|
||||||
e: "edit-request",
|
|
||||||
data: {
|
|
||||||
collectionIndex: number
|
|
||||||
folderIndex: number
|
|
||||||
folderName: string | undefined
|
|
||||||
requestIndex: string
|
|
||||||
request: HoppRESTRequest
|
|
||||||
}
|
|
||||||
): void
|
|
||||||
|
|
||||||
(
|
|
||||||
e: "duplicate-request",
|
|
||||||
data: {
|
|
||||||
collectionID: number | string
|
|
||||||
requestIndex: string
|
|
||||||
request: HoppRESTRequest
|
|
||||||
}
|
|
||||||
): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const t = useI18n()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const dragging = ref(false)
|
|
||||||
const requestMethodLabels = {
|
|
||||||
get: "text-green-500",
|
|
||||||
post: "text-yellow-500",
|
|
||||||
put: "text-blue-500",
|
|
||||||
delete: "text-red-500",
|
|
||||||
default: "text-gray-500",
|
|
||||||
}
|
|
||||||
const confirmChange = ref(false)
|
|
||||||
const showSaveRequestModal = ref(false)
|
|
||||||
|
|
||||||
// Template refs
|
|
||||||
const tippyActions = ref<any | null>(null)
|
|
||||||
const options = ref<any | null>(null)
|
|
||||||
const edit = ref<any | null>(null)
|
|
||||||
const duplicate = ref<any | null>(null)
|
|
||||||
const deleteAction = ref<any | null>(null)
|
|
||||||
|
|
||||||
const active = useReadonlyStream(restSaveContext$, null)
|
|
||||||
|
|
||||||
const isSelected = computed(
|
|
||||||
() =>
|
|
||||||
props.picked &&
|
|
||||||
props.picked.pickedType === "teams-request" &&
|
|
||||||
props.picked.requestID === props.requestIndex
|
|
||||||
)
|
|
||||||
|
|
||||||
const isActive = computed(
|
|
||||||
() =>
|
|
||||||
active.value &&
|
|
||||||
active.value.originLocation === "team-collection" &&
|
|
||||||
active.value.requestID === props.requestIndex
|
|
||||||
)
|
|
||||||
|
|
||||||
const dragStart = ({ dataTransfer }: DragEvent) => {
|
|
||||||
if (dataTransfer) {
|
|
||||||
dragging.value = !dragging.value
|
|
||||||
dataTransfer.setData("requestIndex", props.requestIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeRequest = () => {
|
|
||||||
emit("remove-request", {
|
|
||||||
folderPath: props.folderName,
|
|
||||||
requestIndex: props.requestIndex,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRequestLabelColor = (method: string): string => {
|
|
||||||
return (
|
|
||||||
(requestMethodLabels as any)[method.toLowerCase()] ||
|
|
||||||
requestMethodLabels.default
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setRestReq = (request: HoppRESTRequest) => {
|
|
||||||
setRESTRequest(
|
|
||||||
safelyExtractRESTRequest(
|
|
||||||
translateToNewRequest(request),
|
|
||||||
getDefaultRESTRequest()
|
|
||||||
),
|
|
||||||
{
|
|
||||||
originLocation: "team-collection",
|
|
||||||
requestID: props.requestIndex,
|
|
||||||
req: request,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectRequest = () => {
|
|
||||||
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
|
|
||||||
if (props.saveRequest) {
|
|
||||||
emit("select", {
|
|
||||||
picked: {
|
|
||||||
pickedType: "teams-request",
|
|
||||||
requestID: props.requestIndex,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
|
|
||||||
confirmChange.value = false
|
|
||||||
setRestReq(props.request)
|
|
||||||
} else if (!active.value) {
|
|
||||||
confirmChange.value = true
|
|
||||||
} else {
|
|
||||||
const currentReqWithNoChange = active.value.req
|
|
||||||
const currentFullReq = getRESTRequest()
|
|
||||||
|
|
||||||
// Check if whether user clicked the same request or not
|
|
||||||
if (!isActive.value && currentReqWithNoChange) {
|
|
||||||
// Check if there is any changes done on the current request
|
|
||||||
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
|
|
||||||
setRestReq(props.request)
|
|
||||||
} else {
|
|
||||||
confirmChange.value = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setRESTSaveContext(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save current request to the collection */
|
|
||||||
const saveRequestChange = () => {
|
|
||||||
const saveCtx = getRESTSaveContext()
|
|
||||||
saveCurrentRequest(saveCtx)
|
|
||||||
confirmChange.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Discard changes and change the current request and context */
|
|
||||||
const discardRequestChange = () => {
|
|
||||||
setRestReq(props.request)
|
|
||||||
if (!isActive.value) {
|
|
||||||
setRESTSaveContext({
|
|
||||||
originLocation: "team-collection",
|
|
||||||
requestID: props.requestIndex,
|
|
||||||
req: props.request,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
confirmChange.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
|
|
||||||
if (!saveCtx) {
|
|
||||||
showSaveRequestModal.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (saveCtx.originLocation === "team-collection") {
|
|
||||||
const req = getRESTRequest()
|
|
||||||
try {
|
|
||||||
runMutation(UpdateRequestDocument, {
|
|
||||||
requestID: saveCtx.requestID,
|
|
||||||
data: {
|
|
||||||
title: req.name,
|
|
||||||
request: JSON.stringify(req),
|
|
||||||
},
|
|
||||||
})().then((result) => {
|
|
||||||
if (E.isLeft(result)) {
|
|
||||||
toast.error(`${t("profile.no_permission")}`)
|
|
||||||
} else {
|
|
||||||
toast.success(`${t("request.saved")}`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
setRestReq(props.request)
|
|
||||||
} catch (error) {
|
|
||||||
showSaveRequestModal.value = true
|
|
||||||
toast.error(`${t("error.something_went_wrong")}`)
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
} else if (saveCtx.originLocation === "user-collection") {
|
|
||||||
try {
|
|
||||||
editRESTRequest(
|
|
||||||
saveCtx.folderPath,
|
|
||||||
saveCtx.requestIndex,
|
|
||||||
getRESTRequest()
|
|
||||||
)
|
|
||||||
setRestReq(props.request)
|
|
||||||
toast.success(`${t("request.saved")}`)
|
|
||||||
} catch (e) {
|
|
||||||
setRESTSaveContext(null)
|
|
||||||
saveCurrentRequest(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
import { nextTick, ref, watch } from "vue"
|
import { nextTick, ref, watch } from "vue"
|
||||||
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||||
import { onLoggedIn } from "@composables/auth"
|
import { onLoggedIn } from "@composables/auth"
|
||||||
import { currentUserInfo$ } from "~/helpers/teams/BackendUserInfo"
|
import { platform } from "~/platform"
|
||||||
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { useLocalState } from "~/newstore/localstate"
|
import { useLocalState } from "~/newstore/localstate"
|
||||||
@@ -111,7 +111,10 @@ const emit = defineEmits<{
|
|||||||
(e: "update-selected-team", team: SelectedTeam): void
|
(e: "update-selected-team", team: SelectedTeam): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const currentUser = useReadonlyStream(currentUserInfo$, null)
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
const adapter = new TeamListAdapter(true)
|
const adapter = new TeamListAdapter(true)
|
||||||
const myTeams = useReadonlyStream(adapter.teamList$, null)
|
const myTeams = useReadonlyStream(adapter.teamList$, null)
|
||||||
@@ -138,7 +141,9 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onLoggedIn(() => {
|
onLoggedIn(() => {
|
||||||
adapter.initialize()
|
try {
|
||||||
|
adapter.initialize()
|
||||||
|
} catch (e) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
const onTeamSelectIntersect = () => {
|
const onTeamSelectIntersect = () => {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ import IconDownload from "~icons/lucide/download"
|
|||||||
import IconGithub from "~icons/lucide/github"
|
import IconGithub from "~icons/lucide/github"
|
||||||
import { computed, ref } from "vue"
|
import { computed, ref } from "vue"
|
||||||
import { Environment } from "@hoppscotch/data"
|
import { Environment } from "@hoppscotch/data"
|
||||||
import { currentUser$ } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
@@ -141,7 +141,10 @@ const t = useI18n()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const myEnvironments = useReadonlyStream(environments$, [])
|
const myEnvironments = useReadonlyStream(environments$, [])
|
||||||
const currentUser = useReadonlyStream(currentUser$, null)
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
// Template refs
|
// Template refs
|
||||||
const tippyActions = ref<TippyComponent | null>(null)
|
const tippyActions = ref<TippyComponent | null>(null)
|
||||||
@@ -187,7 +190,7 @@ const createEnvironmentGist = async () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
toast.success(t("export.gist_created").toString())
|
toast.success(t("export.gist_created").toString())
|
||||||
window.open(res.html_url)
|
window.open(res.data.html_url)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(t("error.something_went_wrong").toString())
|
toast.error(t("error.something_went_wrong").toString())
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|||||||
@@ -183,7 +183,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
import { isEqual } from "lodash-es"
|
import { isEqual } from "lodash-es"
|
||||||
import { currentUser$ } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
import { Team } from "~/helpers/backend/graphql"
|
import { Team } from "~/helpers/backend/graphql"
|
||||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||||
import { useI18n } from "~/composables/i18n"
|
import { useI18n } from "~/composables/i18n"
|
||||||
@@ -222,7 +222,10 @@ const globalEnvironment = computed(() => ({
|
|||||||
variables: globalEnv.value,
|
variables: globalEnv.value,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const currentUser = useReadonlyStream(currentUser$, null)
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
const updateSelectedTeam = (newSelectedTeam: SelectedTeam) => {
|
const updateSelectedTeam = (newSelectedTeam: SelectedTeam) => {
|
||||||
environmentType.value.selectedTeam = newSelectedTeam
|
environmentType.value.selectedTeam = newSelectedTeam
|
||||||
|
|||||||
@@ -122,16 +122,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "vue"
|
import { defineComponent } from "vue"
|
||||||
import {
|
import { platform } from "~/platform"
|
||||||
signInUserWithGoogle,
|
|
||||||
signInUserWithGithub,
|
|
||||||
signInUserWithMicrosoft,
|
|
||||||
setProviderInfo,
|
|
||||||
currentUser$,
|
|
||||||
signInWithEmail,
|
|
||||||
linkWithFBCredentialFromAuthError,
|
|
||||||
getGithubCredentialFromResult,
|
|
||||||
} from "~/helpers/fb/auth"
|
|
||||||
import IconGithub from "~icons/auth/github"
|
import IconGithub from "~icons/auth/github"
|
||||||
import IconGoogle from "~icons/auth/google"
|
import IconGoogle from "~icons/auth/google"
|
||||||
import IconEmail from "~icons/auth/email"
|
import IconEmail from "~icons/auth/email"
|
||||||
@@ -174,6 +165,8 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||||
|
|
||||||
this.subscribeToStream(currentUser$, (user) => {
|
this.subscribeToStream(currentUser$, (user) => {
|
||||||
if (user) this.hideModal()
|
if (user) this.hideModal()
|
||||||
})
|
})
|
||||||
@@ -186,8 +179,7 @@ export default defineComponent({
|
|||||||
this.signingInWithGoogle = true
|
this.signingInWithGoogle = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signInUserWithGoogle()
|
await platform.auth.signInUserWithGoogle()
|
||||||
this.showLoginSuccess()
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
/*
|
/*
|
||||||
@@ -202,35 +194,32 @@ export default defineComponent({
|
|||||||
async signInWithGithub() {
|
async signInWithGithub() {
|
||||||
this.signingInWithGitHub = true
|
this.signingInWithGitHub = true
|
||||||
|
|
||||||
try {
|
const result = await platform.auth.signInUserWithGithub()
|
||||||
const result = await signInUserWithGithub()
|
|
||||||
const credential = getGithubCredentialFromResult(result)!
|
|
||||||
const token = credential.accessToken
|
|
||||||
setProviderInfo(result.providerId!, token!)
|
|
||||||
|
|
||||||
this.showLoginSuccess()
|
if (!result) {
|
||||||
} catch (e) {
|
this.signingInWithGitHub = false
|
||||||
console.error(e)
|
return
|
||||||
// This user's email is already present in Firebase but with other providers, namely Google or Microsoft
|
}
|
||||||
if (
|
|
||||||
(e as any).code === "auth/account-exists-with-different-credential"
|
|
||||||
) {
|
|
||||||
this.toast.info(`${this.t("auth.account_exists")}`, {
|
|
||||||
duration: 0,
|
|
||||||
closeOnSwipe: false,
|
|
||||||
action: {
|
|
||||||
text: `${this.t("action.yes")}`,
|
|
||||||
onClick: async (_, toastObject) => {
|
|
||||||
await linkWithFBCredentialFromAuthError(e)
|
|
||||||
this.showLoginSuccess()
|
|
||||||
|
|
||||||
toastObject.goAway(0)
|
if (result.type === "success") {
|
||||||
},
|
// this.showLoginSuccess()
|
||||||
|
} else if (result.type === "account-exists-with-different-cred") {
|
||||||
|
this.toast.info(`${this.t("auth.account_exists")}`, {
|
||||||
|
duration: 0,
|
||||||
|
closeOnSwipe: false,
|
||||||
|
action: {
|
||||||
|
text: `${this.t("action.yes")}`,
|
||||||
|
onClick: async (_, toastObject) => {
|
||||||
|
await result.link()
|
||||||
|
this.showLoginSuccess()
|
||||||
|
|
||||||
|
toastObject.goAway(0)
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
} else {
|
})
|
||||||
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
} else {
|
||||||
}
|
console.log("error logging into github", result.err)
|
||||||
|
this.toast.error(`${this.t("error.something_went_wrong")}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.signingInWithGitHub = false
|
this.signingInWithGitHub = false
|
||||||
@@ -239,8 +228,8 @@ export default defineComponent({
|
|||||||
this.signingInWithMicrosoft = true
|
this.signingInWithMicrosoft = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signInUserWithMicrosoft()
|
await platform.auth.signInUserWithMicrosoft()
|
||||||
this.showLoginSuccess()
|
// this.showLoginSuccess()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
/*
|
/*
|
||||||
@@ -259,11 +248,8 @@ export default defineComponent({
|
|||||||
async signInWithEmail() {
|
async signInWithEmail() {
|
||||||
this.signingInWithEmail = true
|
this.signingInWithEmail = true
|
||||||
|
|
||||||
const actionCodeSettings = {
|
await platform.auth
|
||||||
url: `${import.meta.env.VITE_BASE_URL}/enter`,
|
.signInWithEmail(this.form.email)
|
||||||
handleCodeInApp: true,
|
|
||||||
}
|
|
||||||
await signInWithEmail(this.form.email, actionCodeSettings)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.mode = "email-sent"
|
this.mode = "email-sent"
|
||||||
setLocalConfig("emailForSignIn", this.form.email)
|
setLocalConfig("emailForSignIn", this.form.email)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { ref } from "vue"
|
|||||||
import IconLogOut from "~icons/lucide/log-out"
|
import IconLogOut from "~icons/lucide/log-out"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { signOutUser } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
outline: {
|
outline: {
|
||||||
@@ -47,7 +47,7 @@ const t = useI18n()
|
|||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
await signOutUser()
|
await platform.auth.signOutUser()
|
||||||
toast.success(`${t("auth.logged_out")}`)
|
toast.success(`${t("auth.logged_out")}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|||||||
@@ -331,14 +331,25 @@ const setRestReq = (request: HoppRESTRequest | null | undefined) => {
|
|||||||
// (That is not a really good behaviour tho ¯\_(ツ)_/¯)
|
// (That is not a really good behaviour tho ¯\_(ツ)_/¯)
|
||||||
const useHistory = (entry: RESTHistoryEntry) => {
|
const useHistory = (entry: RESTHistoryEntry) => {
|
||||||
const currentFullReq = getRESTRequest()
|
const currentFullReq = getRESTRequest()
|
||||||
|
|
||||||
|
const currentReqWithNoChange = getRESTSaveContext()?.req
|
||||||
|
|
||||||
|
// checks if the current request is the same as the save context request if present
|
||||||
|
if (
|
||||||
|
currentReqWithNoChange &&
|
||||||
|
isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)
|
||||||
|
) {
|
||||||
|
props.page === "rest" && setRestReq(entry.request)
|
||||||
|
clickedHistory.value = entry
|
||||||
|
}
|
||||||
// Initial state trigers a popup
|
// Initial state trigers a popup
|
||||||
if (!clickedHistory.value) {
|
else if (!clickedHistory.value) {
|
||||||
clickedHistory.value = entry
|
clickedHistory.value = entry
|
||||||
confirmChange.value = true
|
confirmChange.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Checks if there are any change done in current request and the history request
|
// Checks if there are any change done in current request and the history request
|
||||||
if (
|
else if (
|
||||||
!isEqualHoppRESTRequest(
|
!isEqualHoppRESTRequest(
|
||||||
currentFullReq,
|
currentFullReq,
|
||||||
clickedHistory.value.request as HoppRESTRequest
|
clickedHistory.value.request as HoppRESTRequest
|
||||||
@@ -347,7 +358,7 @@ const useHistory = (entry: RESTHistoryEntry) => {
|
|||||||
clickedHistory.value = entry
|
clickedHistory.value = entry
|
||||||
confirmChange.value = true
|
confirmChange.value = true
|
||||||
} else {
|
} else {
|
||||||
props.page === "rest" && setRestReq(entry.request as HoppRESTRequest)
|
props.page === "rest" && setRestReq(entry.request)
|
||||||
clickedHistory.value = entry
|
clickedHistory.value = entry
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,15 @@
|
|||||||
<ButtonPrimary
|
<ButtonPrimary
|
||||||
v-focus
|
v-focus
|
||||||
:label="t('action.save')"
|
:label="t('action.save')"
|
||||||
|
:loading="loading"
|
||||||
outline
|
outline
|
||||||
@click="saveApiChange"
|
@click="saveChange"
|
||||||
/>
|
/>
|
||||||
<ButtonSecondary
|
<ButtonSecondary
|
||||||
:label="t('action.dont_save')"
|
:label="t('action.dont_save')"
|
||||||
outline
|
outline
|
||||||
filled
|
filled
|
||||||
@click="discardApiChange"
|
@click="discardChange"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<ButtonSecondary
|
<ButtonSecondary
|
||||||
@@ -43,6 +44,7 @@ const t = useI18n()
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
|
loading?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -51,11 +53,11 @@ const emit = defineEmits<{
|
|||||||
(e: "hide-modal"): void
|
(e: "hide-modal"): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const saveApiChange = () => {
|
const saveChange = () => {
|
||||||
emit("save-change")
|
emit("save-change")
|
||||||
}
|
}
|
||||||
|
|
||||||
const discardApiChange = () => {
|
const discardChange = () => {
|
||||||
emit("discard-change")
|
emit("discard-change")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ import { ref, watchEffect, computed } from "vue"
|
|||||||
import { pipe } from "fp-ts/function"
|
import { pipe } from "fp-ts/function"
|
||||||
import * as TE from "fp-ts/TaskEither"
|
import * as TE from "fp-ts/TaskEither"
|
||||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||||
import { currentUser$ } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
|
|
||||||
import { onAuthEvent, onLoggedIn } from "@composables/auth"
|
import { onAuthEvent, onLoggedIn } from "@composables/auth"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
@@ -102,7 +102,10 @@ usePageHead({
|
|||||||
title: computed(() => t("navigation.profile")),
|
title: computed(() => t("navigation.profile")),
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentUser = useReadonlyStream(currentUser$, null)
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
const displayName = ref(currentUser.value?.displayName)
|
const displayName = ref(currentUser.value?.displayName)
|
||||||
watchEffect(() => (displayName.value = currentUser.value?.displayName))
|
watchEffect(() => (displayName.value = currentUser.value?.displayName))
|
||||||
@@ -121,7 +124,9 @@ const loading = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onLoggedIn(() => {
|
onLoggedIn(() => {
|
||||||
adapter.initialize()
|
try {
|
||||||
|
adapter.initialize()
|
||||||
|
} catch (e) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
onAuthEvent((ev) => {
|
onAuthEvent((ev) => {
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ import { useI18n } from "~/composables/i18n"
|
|||||||
import { useToast } from "~/composables/toast"
|
import { useToast } from "~/composables/toast"
|
||||||
import { GetMyTeamsDocument, GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
import { GetMyTeamsDocument, GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||||
import { deleteUser } from "~/helpers/backend/mutations/Profile"
|
import { deleteUser } from "~/helpers/backend/mutations/Profile"
|
||||||
import { signOutUser } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -162,7 +162,7 @@ const deleteUserAccount = async () => {
|
|||||||
deletingUser.value = false
|
deletingUser.value = false
|
||||||
showDeleteAccountModal.value = false
|
showDeleteAccountModal.value = false
|
||||||
toast.success(t("settings.account_deleted"))
|
toast.success(t("settings.account_deleted"))
|
||||||
signOutUser()
|
platform.auth.signOutUser()
|
||||||
router.push(`/`)
|
router.push(`/`)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
65
packages/hoppscotch-common/src/components/smart/Tree.vue
Normal file
65
packages/hoppscotch-common/src/components/smart/Tree.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col flex-1">
|
||||||
|
<div
|
||||||
|
v-if="rootNodes.status === 'loaded' && rootNodes.data.length > 0"
|
||||||
|
class="flex flex-col"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="rootNode in rootNodes.data"
|
||||||
|
:key="rootNode.id"
|
||||||
|
class="flex flex-col flex-1"
|
||||||
|
>
|
||||||
|
<SmartTreeBranch
|
||||||
|
:node-item="rootNode"
|
||||||
|
:adapter="adapter as SmartTreeAdapter<T>"
|
||||||
|
>
|
||||||
|
<template #default="{ node, toggleChildren, isOpen }">
|
||||||
|
<slot
|
||||||
|
name="content"
|
||||||
|
:node="node as TreeNode<T>"
|
||||||
|
:toggle-children="toggleChildren as () => void"
|
||||||
|
:is-open="isOpen as boolean"
|
||||||
|
></slot>
|
||||||
|
</template>
|
||||||
|
<template #emptyNode="{ node }">
|
||||||
|
<slot name="emptyNode" :node="node"></slot>
|
||||||
|
</template>
|
||||||
|
</SmartTreeBranch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="rootNodes.status === 'loading'"
|
||||||
|
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||||
|
>
|
||||||
|
<SmartSpinner class="my-4" />
|
||||||
|
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="rootNodes.status === 'loaded' && rootNodes.data.length === 0"
|
||||||
|
class="flex flex-col flex-1"
|
||||||
|
>
|
||||||
|
<slot name="emptyNode" :node="(null as TreeNode<T> | null)"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T extends any">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useI18n } from "~/composables/i18n"
|
||||||
|
import { SmartTreeAdapter, TreeNode } from "~/helpers/treeAdapter"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/**
|
||||||
|
* The adapter that will be used to fetch the tree data
|
||||||
|
* @template T The type of the data that will be stored in the tree
|
||||||
|
*/
|
||||||
|
adapter: SmartTreeAdapter<T>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the root nodes from the adapter by passing the node id as null
|
||||||
|
*/
|
||||||
|
const rootNodes = computed(() => props.adapter.getChildren(null).value)
|
||||||
|
</script>
|
||||||
103
packages/hoppscotch-common/src/components/smart/TreeBranch.vue
Normal file
103
packages/hoppscotch-common/src/components/smart/TreeBranch.vue
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<slot
|
||||||
|
:node="nodeItem"
|
||||||
|
:toggle-children="toggleNodeChildren"
|
||||||
|
:is-open="isNodeOpen"
|
||||||
|
></slot>
|
||||||
|
|
||||||
|
<!-- This is a performance optimization trick -->
|
||||||
|
<!-- Once expanded, Vue will traverse through the children and expand the tree up
|
||||||
|
but when we collapse, the tree and the components are disposed. This is wasteful
|
||||||
|
and comes with performance issues if the children list is expensive to render.
|
||||||
|
Hence, here we render children only when first expanded, and after that, even if collapsed,
|
||||||
|
we just hide the children.
|
||||||
|
-->
|
||||||
|
<div v-if="childrenRendered" v-show="showChildren" class="flex">
|
||||||
|
<div
|
||||||
|
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
|
||||||
|
@click="toggleNodeChildren"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
v-if="childNodes.status === 'loaded' && childNodes.data.length > 0"
|
||||||
|
class="flex flex-col flex-1 truncate"
|
||||||
|
>
|
||||||
|
<TreeBranch
|
||||||
|
v-for="childNode in childNodes.data"
|
||||||
|
:key="childNode.id"
|
||||||
|
:node-item="childNode"
|
||||||
|
:adapter="adapter"
|
||||||
|
>
|
||||||
|
<!-- The child slot is given a dynamic name in order to not break Volar -->
|
||||||
|
<template #[CHILD_SLOT_NAME]="{ node, toggleChildren, isOpen }">
|
||||||
|
<!-- Casting to help with type checking -->
|
||||||
|
<slot
|
||||||
|
:node="node as TreeNode<T>"
|
||||||
|
:toggle-children="toggleChildren as () => void"
|
||||||
|
:is-open="isOpen as boolean"
|
||||||
|
></slot>
|
||||||
|
</template>
|
||||||
|
<template #emptyNode="{ node }">
|
||||||
|
<slot name="emptyNode" :node="node"></slot>
|
||||||
|
</template>
|
||||||
|
</TreeBranch>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="childNodes.status === 'loading'"
|
||||||
|
class="flex flex-col items-center justify-center flex-1 p-4"
|
||||||
|
>
|
||||||
|
<SmartSpinner class="my-4" />
|
||||||
|
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="childNodes.status === 'loaded' && childNodes.data.length === 0"
|
||||||
|
class="flex flex-col flex-1"
|
||||||
|
>
|
||||||
|
<slot name="emptyNode" :node="nodeItem"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts" generic="T extends any">
|
||||||
|
import { computed, ref } from "vue"
|
||||||
|
import { useI18n } from "~/composables/i18n"
|
||||||
|
import { SmartTreeAdapter, TreeNode } from "~/helpers/treeAdapter"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/**
|
||||||
|
* The node item that will be used to render the tree branch
|
||||||
|
* @template T The type of the data passed to the tree branch
|
||||||
|
*/
|
||||||
|
adapter: SmartTreeAdapter<T>
|
||||||
|
/**
|
||||||
|
* The node item that will be used to render the tree branch content
|
||||||
|
*/
|
||||||
|
nodeItem: TreeNode<T>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const CHILD_SLOT_NAME = "default"
|
||||||
|
const t = useI18n()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks whether the children on this branch were ever rendered
|
||||||
|
* See the usage inside '<template>' for more info
|
||||||
|
*/
|
||||||
|
const childrenRendered = ref(false)
|
||||||
|
|
||||||
|
const showChildren = ref(false)
|
||||||
|
const isNodeOpen = ref(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the child nodes from the adapter by passing the node id of the current node
|
||||||
|
*/
|
||||||
|
const childNodes = computed(
|
||||||
|
() => props.adapter.getChildren(props.nodeItem.id).value
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleNodeChildren = () => {
|
||||||
|
if (!childrenRendered.value) childrenRendered.value = true
|
||||||
|
|
||||||
|
showChildren.value = !showChildren.value
|
||||||
|
isNodeOpen.value = !isNodeOpen.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -108,7 +108,9 @@ const loading = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onLoggedIn(() => {
|
onLoggedIn(() => {
|
||||||
adapter.initialize()
|
try {
|
||||||
|
adapter.initialize()
|
||||||
|
} catch (e) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayModalAdd = (shouldDisplay: boolean) => {
|
const displayModalAdd = (shouldDisplay: boolean) => {
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import {
|
import { platform } from "~/platform"
|
||||||
currentUser$,
|
import { AuthEvent, HoppUser } from "~/platform/auth"
|
||||||
HoppUser,
|
import { Subscription } from "rxjs"
|
||||||
AuthEvent,
|
import { onBeforeUnmount, onMounted, watch, WatchStopHandle } from "vue"
|
||||||
authEvents$,
|
import { useReadonlyStream } from "./stream"
|
||||||
authIdToken$,
|
|
||||||
} from "@helpers/fb/auth"
|
|
||||||
import {
|
|
||||||
map,
|
|
||||||
distinctUntilChanged,
|
|
||||||
filter,
|
|
||||||
Subscription,
|
|
||||||
combineLatestWith,
|
|
||||||
} from "rxjs"
|
|
||||||
import { onBeforeUnmount, onMounted } from "vue"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A Vue composable function that is called when the auth status
|
* A Vue composable function that is called when the auth status
|
||||||
@@ -21,26 +11,25 @@ import { onBeforeUnmount, onMounted } from "vue"
|
|||||||
* was already resolved before mount.
|
* was already resolved before mount.
|
||||||
*/
|
*/
|
||||||
export function onLoggedIn(exec: (user: HoppUser) => void) {
|
export function onLoggedIn(exec: (user: HoppUser) => void) {
|
||||||
let sub: Subscription | null = null
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
|
let watchStop: WatchStopHandle | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
sub = currentUser$
|
if (currentUser.value) exec(currentUser.value)
|
||||||
.pipe(
|
|
||||||
// We don't consider the state as logged in unless we also have an id token
|
watchStop = watch(currentUser, (newVal, prev) => {
|
||||||
combineLatestWith(authIdToken$),
|
if (prev === null && newVal !== null) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
exec(newVal)
|
||||||
filter(([_, token]) => !!token),
|
}
|
||||||
map((user) => !!user), // Get a logged in status (true or false)
|
})
|
||||||
distinctUntilChanged(), // Don't propagate unless the status updates
|
|
||||||
filter((x) => x) // Don't propagate unless it is logged in
|
|
||||||
)
|
|
||||||
.subscribe(() => {
|
|
||||||
exec(currentUser$.value!)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
sub?.unsubscribe()
|
watchStop?.()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +46,8 @@ export function onLoggedIn(exec: (user: HoppUser) => void) {
|
|||||||
* @param func A function which accepts an event
|
* @param func A function which accepts an event
|
||||||
*/
|
*/
|
||||||
export function onAuthEvent(func: (ev: AuthEvent) => void) {
|
export function onAuthEvent(func: (ev: AuthEvent) => void) {
|
||||||
|
const authEvents$ = platform.auth.getAuthEventsStream()
|
||||||
|
|
||||||
let sub: Subscription | null = null
|
let sub: Subscription | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
CombinedError,
|
CombinedError,
|
||||||
Operation,
|
Operation,
|
||||||
OperationResult,
|
OperationResult,
|
||||||
|
Client,
|
||||||
} from "@urql/core"
|
} from "@urql/core"
|
||||||
import { authExchange } from "@urql/exchange-auth"
|
import { authExchange } from "@urql/exchange-auth"
|
||||||
import { devtoolsExchange } from "@urql/devtools"
|
import { devtoolsExchange } from "@urql/devtools"
|
||||||
@@ -21,12 +22,7 @@ import * as TE from "fp-ts/TaskEither"
|
|||||||
import { pipe, constVoid, flow } from "fp-ts/function"
|
import { pipe, constVoid, flow } from "fp-ts/function"
|
||||||
import { subscribe, pipe as wonkaPipe } from "wonka"
|
import { subscribe, pipe as wonkaPipe } from "wonka"
|
||||||
import { filter, map, Subject } from "rxjs"
|
import { filter, map, Subject } from "rxjs"
|
||||||
import {
|
import { platform } from "~/platform"
|
||||||
authIdToken$,
|
|
||||||
getAuthIDToken,
|
|
||||||
probableUser$,
|
|
||||||
waitProbableLoginToConfirm,
|
|
||||||
} from "~/helpers/fb/auth"
|
|
||||||
|
|
||||||
// TODO: Implement caching
|
// TODO: Implement caching
|
||||||
|
|
||||||
@@ -57,11 +53,7 @@ export const gqlClientError$ = new Subject<GQLClientErrorEvent>()
|
|||||||
const createSubscriptionClient = () => {
|
const createSubscriptionClient = () => {
|
||||||
return new SubscriptionClient(BACKEND_WS_URL, {
|
return new SubscriptionClient(BACKEND_WS_URL, {
|
||||||
reconnect: true,
|
reconnect: true,
|
||||||
connectionParams: () => {
|
connectionParams: () => platform.auth.getBackendHeaders(),
|
||||||
return {
|
|
||||||
authorization: `Bearer ${authIdToken$.value}`,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
connectionCallback(error) {
|
connectionCallback(error) {
|
||||||
if (error?.length > 0) {
|
if (error?.length > 0) {
|
||||||
gqlClientError$.next({
|
gqlClientError$.next({
|
||||||
@@ -79,7 +71,7 @@ const createHoppClient = () => {
|
|||||||
dedupExchange,
|
dedupExchange,
|
||||||
authExchange({
|
authExchange({
|
||||||
addAuthToOperation({ authState, operation }) {
|
addAuthToOperation({ authState, operation }) {
|
||||||
if (!authState || !authState.authToken) {
|
if (!authState) {
|
||||||
return operation
|
return operation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,28 +80,29 @@ const createHoppClient = () => {
|
|||||||
? operation.context.fetchOptions()
|
? operation.context.fetchOptions()
|
||||||
: operation.context.fetchOptions || {}
|
: operation.context.fetchOptions || {}
|
||||||
|
|
||||||
|
const authHeaders = platform.auth.getBackendHeaders()
|
||||||
|
|
||||||
return makeOperation(operation.kind, operation, {
|
return makeOperation(operation.kind, operation, {
|
||||||
...operation.context,
|
...operation.context,
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
...fetchOptions,
|
...fetchOptions,
|
||||||
headers: {
|
headers: {
|
||||||
...fetchOptions.headers,
|
...fetchOptions.headers,
|
||||||
Authorization: `Bearer ${authState.authToken}`,
|
...authHeaders,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
willAuthError({ authState }) {
|
willAuthError() {
|
||||||
return !authState || !authState.authToken
|
return platform.auth.willBackendHaveAuthError()
|
||||||
},
|
},
|
||||||
getAuth: async () => {
|
getAuth: async () => {
|
||||||
if (!probableUser$.value) return { authToken: null }
|
const probableUser = platform.auth.getProbableUser()
|
||||||
|
|
||||||
await waitProbableLoginToConfirm()
|
if (probableUser !== null)
|
||||||
|
await platform.auth.waitProbableLoginToConfirm()
|
||||||
|
|
||||||
return {
|
return {}
|
||||||
authToken: getAuthIDToken(),
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
fetchExchange,
|
fetchExchange,
|
||||||
@@ -137,31 +130,40 @@ const createHoppClient = () => {
|
|||||||
return createClient({
|
return createClient({
|
||||||
url: BACKEND_GQL_URL,
|
url: BACKEND_GQL_URL,
|
||||||
exchanges,
|
exchanges,
|
||||||
|
...(platform.auth.getGQLClientOptions
|
||||||
|
? platform.auth.getGQLClientOptions()
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let subscriptionClient: SubscriptionClient | null
|
let subscriptionClient: SubscriptionClient | null
|
||||||
export const client = ref(createHoppClient())
|
export const client = ref<Client>()
|
||||||
|
|
||||||
authIdToken$.subscribe((idToken) => {
|
|
||||||
// triggering reconnect by closing the websocket client
|
|
||||||
if (idToken && subscriptionClient) {
|
|
||||||
subscriptionClient?.client?.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// creating new subscription
|
|
||||||
if (idToken && !subscriptionClient) {
|
|
||||||
subscriptionClient = createSubscriptionClient()
|
|
||||||
}
|
|
||||||
|
|
||||||
// closing existing subscription client.
|
|
||||||
if (!idToken && subscriptionClient) {
|
|
||||||
subscriptionClient.close()
|
|
||||||
subscriptionClient = null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export function initBackendGQLClient() {
|
||||||
client.value = createHoppClient()
|
client.value = createHoppClient()
|
||||||
})
|
|
||||||
|
platform.auth.onBackendGQLClientShouldReconnect(() => {
|
||||||
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
|
// triggering reconnect by closing the websocket client
|
||||||
|
if (currentUser && subscriptionClient) {
|
||||||
|
subscriptionClient?.client?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// creating new subscription
|
||||||
|
if (currentUser && !subscriptionClient) {
|
||||||
|
subscriptionClient = createSubscriptionClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
// closing existing subscription client.
|
||||||
|
if (!currentUser && subscriptionClient) {
|
||||||
|
subscriptionClient.close()
|
||||||
|
subscriptionClient = null
|
||||||
|
}
|
||||||
|
|
||||||
|
client.value = createHoppClient()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type RunQueryOptions<T = any, V = object> = {
|
type RunQueryOptions<T = any, V = object> = {
|
||||||
query: TypedDocumentNode<T, V>
|
query: TypedDocumentNode<T, V>
|
||||||
@@ -185,7 +187,7 @@ export const runGQLQuery = <DocType, DocVarType, DocErrorType extends string>(
|
|||||||
args: RunQueryOptions<DocType, DocVarType>
|
args: RunQueryOptions<DocType, DocVarType>
|
||||||
): Promise<E.Either<GQLError<DocErrorType>, DocType>> => {
|
): Promise<E.Either<GQLError<DocErrorType>, DocType>> => {
|
||||||
const request = createRequest<DocType, DocVarType>(args.query, args.variables)
|
const request = createRequest<DocType, DocVarType>(args.query, args.variables)
|
||||||
const source = client.value.executeQuery(request, {
|
const source = client.value!.executeQuery(request, {
|
||||||
requestPolicy: "network-only",
|
requestPolicy: "network-only",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -250,7 +252,7 @@ export const runGQLSubscription = <
|
|||||||
) => {
|
) => {
|
||||||
const result$ = new Subject<E.Either<GQLError<DocErrorType>, DocType>>()
|
const result$ = new Subject<E.Either<GQLError<DocErrorType>, DocType>>()
|
||||||
|
|
||||||
const source = client.value.executeSubscription(
|
const source = client.value!.executeSubscription(
|
||||||
createRequest(args.query, args.variables)
|
createRequest(args.query, args.variables)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -342,8 +344,8 @@ export const runMutation = <
|
|||||||
pipe(
|
pipe(
|
||||||
TE.tryCatch(
|
TE.tryCatch(
|
||||||
() =>
|
() =>
|
||||||
client.value
|
client
|
||||||
.mutation(mutation, variables, {
|
.value!.mutation(mutation, variables, {
|
||||||
requestPolicy: "cache-and-network",
|
requestPolicy: "cache-and-network",
|
||||||
...additionalConfig,
|
...additionalConfig,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { TeamCollection } from "../teams/TeamCollection"
|
|||||||
import { TeamRequest } from "../teams/TeamRequest"
|
import { TeamRequest } from "../teams/TeamRequest"
|
||||||
import { GQLError, runGQLQuery } from "./GQLClient"
|
import { GQLError, runGQLQuery } from "./GQLClient"
|
||||||
import {
|
import {
|
||||||
|
ExportAsJsonDocument,
|
||||||
GetCollectionChildrenIDsDocument,
|
GetCollectionChildrenIDsDocument,
|
||||||
GetCollectionRequestsDocument,
|
GetCollectionRequestsDocument,
|
||||||
GetCollectionTitleDocument,
|
GetCollectionTitleDocument,
|
||||||
@@ -125,3 +126,23 @@ export const teamCollToHoppRESTColl = (
|
|||||||
folders: coll.children?.map(teamCollToHoppRESTColl) ?? [],
|
folders: coll.children?.map(teamCollToHoppRESTColl) ?? [],
|
||||||
requests: coll.requests?.map((x) => x.request) ?? [],
|
requests: coll.requests?.map((x) => x.request) ?? [],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the JSON string of all the collection of the specified team
|
||||||
|
* @param teamID - ID of the team
|
||||||
|
* @returns Either of the JSON string of the collection or the error
|
||||||
|
*/
|
||||||
|
export const getTeamCollectionJSON = async (teamID: string) => {
|
||||||
|
const data = await runGQLQuery({
|
||||||
|
query: ExportAsJsonDocument,
|
||||||
|
variables: {
|
||||||
|
teamID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (E.isLeft(data)) {
|
||||||
|
return E.left(data.left)
|
||||||
|
}
|
||||||
|
|
||||||
|
return E.right(data.right)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { runMutation } from "../GQLClient"
|
||||||
|
import {
|
||||||
|
CreateChildCollectionDocument,
|
||||||
|
CreateChildCollectionMutation,
|
||||||
|
CreateChildCollectionMutationVariables,
|
||||||
|
CreateNewRootCollectionDocument,
|
||||||
|
CreateNewRootCollectionMutation,
|
||||||
|
CreateNewRootCollectionMutationVariables,
|
||||||
|
DeleteCollectionDocument,
|
||||||
|
DeleteCollectionMutation,
|
||||||
|
DeleteCollectionMutationVariables,
|
||||||
|
ImportFromJsonDocument,
|
||||||
|
ImportFromJsonMutation,
|
||||||
|
ImportFromJsonMutationVariables,
|
||||||
|
RenameCollectionDocument,
|
||||||
|
RenameCollectionMutation,
|
||||||
|
RenameCollectionMutationVariables,
|
||||||
|
} from "../graphql"
|
||||||
|
|
||||||
|
type CreateNewRootCollectionError = "team_coll/short_title"
|
||||||
|
type CreateChildCollectionError = "team_coll/short_title"
|
||||||
|
type RenameCollectionError = "team_coll/short_title"
|
||||||
|
type DeleteCollectionError = "team/invalid_coll_id"
|
||||||
|
|
||||||
|
export const createNewRootCollection = (title: string, teamID: string) =>
|
||||||
|
runMutation<
|
||||||
|
CreateNewRootCollectionMutation,
|
||||||
|
CreateNewRootCollectionMutationVariables,
|
||||||
|
CreateNewRootCollectionError
|
||||||
|
>(CreateNewRootCollectionDocument, {
|
||||||
|
title,
|
||||||
|
teamID,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createChildCollection = (
|
||||||
|
childTitle: string,
|
||||||
|
collectionID: string
|
||||||
|
) =>
|
||||||
|
runMutation<
|
||||||
|
CreateChildCollectionMutation,
|
||||||
|
CreateChildCollectionMutationVariables,
|
||||||
|
CreateChildCollectionError
|
||||||
|
>(CreateChildCollectionDocument, {
|
||||||
|
childTitle,
|
||||||
|
collectionID,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Can be used to rename both collection and folder (considered same in BE) */
|
||||||
|
export const renameCollection = (collectionID: string, newTitle: string) =>
|
||||||
|
runMutation<
|
||||||
|
RenameCollectionMutation,
|
||||||
|
RenameCollectionMutationVariables,
|
||||||
|
RenameCollectionError
|
||||||
|
>(RenameCollectionDocument, {
|
||||||
|
collectionID,
|
||||||
|
newTitle,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Can be used to delete both collection and folder (considered same in BE) */
|
||||||
|
export const deleteCollection = (collectionID: string) =>
|
||||||
|
runMutation<
|
||||||
|
DeleteCollectionMutation,
|
||||||
|
DeleteCollectionMutationVariables,
|
||||||
|
DeleteCollectionError
|
||||||
|
>(DeleteCollectionDocument, {
|
||||||
|
collectionID,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const importJSONToTeam = (collectionJSON: string, teamID: string) =>
|
||||||
|
runMutation<ImportFromJsonMutation, ImportFromJsonMutationVariables, "">(
|
||||||
|
ImportFromJsonDocument,
|
||||||
|
{
|
||||||
|
jsonString: collectionJSON,
|
||||||
|
teamID,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -1,14 +1,66 @@
|
|||||||
import { runMutation } from "../GQLClient"
|
import { runMutation } from "../GQLClient"
|
||||||
import {
|
import {
|
||||||
|
CreateRequestInCollectionDocument,
|
||||||
|
CreateRequestInCollectionMutation,
|
||||||
|
CreateRequestInCollectionMutationVariables,
|
||||||
|
DeleteRequestDocument,
|
||||||
|
DeleteRequestMutation,
|
||||||
|
DeleteRequestMutationVariables,
|
||||||
MoveRestTeamRequestDocument,
|
MoveRestTeamRequestDocument,
|
||||||
MoveRestTeamRequestMutation,
|
MoveRestTeamRequestMutation,
|
||||||
MoveRestTeamRequestMutationVariables,
|
MoveRestTeamRequestMutationVariables,
|
||||||
|
UpdateRequestDocument,
|
||||||
|
UpdateRequestMutation,
|
||||||
|
UpdateRequestMutationVariables,
|
||||||
} from "../graphql"
|
} from "../graphql"
|
||||||
|
|
||||||
type MoveRestTeamRequestErrors =
|
type MoveRestTeamRequestErrors =
|
||||||
| "team_req/not_found"
|
| "team_req/not_found"
|
||||||
| "team_req/invalid_target_id"
|
| "team_req/invalid_target_id"
|
||||||
|
|
||||||
|
type DeleteRequestErrors = "team_req/not_found"
|
||||||
|
|
||||||
|
export const createRequestInCollection = (
|
||||||
|
collectionID: string,
|
||||||
|
data: {
|
||||||
|
request: string
|
||||||
|
teamID: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
runMutation<
|
||||||
|
CreateRequestInCollectionMutation,
|
||||||
|
CreateRequestInCollectionMutationVariables,
|
||||||
|
""
|
||||||
|
>(CreateRequestInCollectionDocument, {
|
||||||
|
collectionID,
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateTeamRequest = (
|
||||||
|
requestID: string,
|
||||||
|
data: {
|
||||||
|
request: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
) =>
|
||||||
|
runMutation<UpdateRequestMutation, UpdateRequestMutationVariables, "">(
|
||||||
|
UpdateRequestDocument,
|
||||||
|
{
|
||||||
|
requestID,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const deleteTeamRequest = (requestID: string) =>
|
||||||
|
runMutation<
|
||||||
|
DeleteRequestMutation,
|
||||||
|
DeleteRequestMutationVariables,
|
||||||
|
DeleteRequestErrors
|
||||||
|
>(DeleteRequestDocument, {
|
||||||
|
requestID,
|
||||||
|
})
|
||||||
|
|
||||||
export const moveRESTTeamRequest = (requestID: string, collectionID: string) =>
|
export const moveRESTTeamRequest = (requestID: string, collectionID: string) =>
|
||||||
runMutation<
|
runMutation<
|
||||||
MoveRestTeamRequestMutation,
|
MoveRestTeamRequestMutation,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
setUserId,
|
setUserId,
|
||||||
setUserProperties,
|
setUserProperties,
|
||||||
} from "firebase/analytics"
|
} from "firebase/analytics"
|
||||||
import { authEvents$ } from "./auth"
|
import { platform } from "~/platform"
|
||||||
import {
|
import {
|
||||||
HoppAccentColor,
|
HoppAccentColor,
|
||||||
HoppBgColor,
|
HoppBgColor,
|
||||||
@@ -42,13 +42,15 @@ export function initAnalytics() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initLoginListeners() {
|
function initLoginListeners() {
|
||||||
|
const authEvents$ = platform.auth.getAuthEventsStream()
|
||||||
|
|
||||||
authEvents$.subscribe((ev) => {
|
authEvents$.subscribe((ev) => {
|
||||||
if (ev.event === "login") {
|
if (ev.event === "login") {
|
||||||
if (settingsStore.value.TELEMETRY_ENABLED && analytics) {
|
if (settingsStore.value.TELEMETRY_ENABLED && analytics) {
|
||||||
setUserId(analytics, ev.user.uid)
|
setUserId(analytics, ev.user.uid)
|
||||||
|
|
||||||
logEvent(analytics, "login", {
|
logEvent(analytics, "login", {
|
||||||
method: ev.user.providerData[0]?.providerId, // Assume the first provider is the login provider
|
method: ev.user.provider, // Assume the first provider is the login provider
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else if (ev.event === "logout") {
|
} else if (ev.event === "logout") {
|
||||||
|
|||||||
@@ -1,434 +0,0 @@
|
|||||||
import {
|
|
||||||
User,
|
|
||||||
getAuth,
|
|
||||||
onAuthStateChanged,
|
|
||||||
onIdTokenChanged,
|
|
||||||
signInWithPopup,
|
|
||||||
GoogleAuthProvider,
|
|
||||||
GithubAuthProvider,
|
|
||||||
OAuthProvider,
|
|
||||||
signInWithEmailAndPassword as signInWithEmailAndPass,
|
|
||||||
isSignInWithEmailLink as isSignInWithEmailLinkFB,
|
|
||||||
fetchSignInMethodsForEmail,
|
|
||||||
sendSignInLinkToEmail,
|
|
||||||
signInWithEmailLink as signInWithEmailLinkFB,
|
|
||||||
ActionCodeSettings,
|
|
||||||
signOut,
|
|
||||||
linkWithCredential,
|
|
||||||
AuthCredential,
|
|
||||||
AuthError,
|
|
||||||
UserCredential,
|
|
||||||
updateProfile,
|
|
||||||
updateEmail,
|
|
||||||
sendEmailVerification,
|
|
||||||
reauthenticateWithCredential,
|
|
||||||
} from "firebase/auth"
|
|
||||||
import {
|
|
||||||
onSnapshot,
|
|
||||||
getFirestore,
|
|
||||||
setDoc,
|
|
||||||
doc,
|
|
||||||
updateDoc,
|
|
||||||
} from "firebase/firestore"
|
|
||||||
import { BehaviorSubject, filter, Subject, Subscription } from "rxjs"
|
|
||||||
import {
|
|
||||||
setLocalConfig,
|
|
||||||
getLocalConfig,
|
|
||||||
removeLocalConfig,
|
|
||||||
} from "~/newstore/localpersistence"
|
|
||||||
|
|
||||||
export type HoppUser = User & {
|
|
||||||
provider?: string
|
|
||||||
accessToken?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuthEvent =
|
|
||||||
| { event: "probable_login"; user: HoppUser } // We have previous login state, but the app is waiting for authentication
|
|
||||||
| { event: "login"; user: HoppUser } // We are authenticated
|
|
||||||
| { event: "logout" } // No authentication and we have no previous state
|
|
||||||
| { event: "authTokenUpdate"; user: HoppUser; newToken: string | null } // Token has been updated
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A BehaviorSubject emitting the currently logged in user (or null if not logged in)
|
|
||||||
*/
|
|
||||||
export const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
|
|
||||||
/**
|
|
||||||
* A BehaviorSubject emitting the current idToken
|
|
||||||
*/
|
|
||||||
export const authIdToken$ = new BehaviorSubject<string | null>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A subject that emits events related to authentication flows
|
|
||||||
*/
|
|
||||||
export const authEvents$ = new Subject<AuthEvent>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Like currentUser$ but also gives probable user value
|
|
||||||
*/
|
|
||||||
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves when the probable login resolves into proper login
|
|
||||||
*/
|
|
||||||
export const waitProbableLoginToConfirm = () =>
|
|
||||||
new Promise<void>((resolve, reject) => {
|
|
||||||
if (authIdToken$.value) resolve()
|
|
||||||
|
|
||||||
if (!probableUser$.value) reject(new Error("no_probable_user"))
|
|
||||||
|
|
||||||
let sub: Subscription | null = null
|
|
||||||
|
|
||||||
sub = authIdToken$.pipe(filter((token) => !!token)).subscribe(() => {
|
|
||||||
sub?.unsubscribe()
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the firebase authentication related subjects
|
|
||||||
*/
|
|
||||||
export function initAuth() {
|
|
||||||
const auth = getAuth()
|
|
||||||
const firestore = getFirestore()
|
|
||||||
|
|
||||||
let extraSnapshotStop: (() => void) | null = null
|
|
||||||
|
|
||||||
probableUser$.next(JSON.parse(getLocalConfig("login_state") ?? "null"))
|
|
||||||
|
|
||||||
onAuthStateChanged(auth, (user) => {
|
|
||||||
/** Whether the user was logged in before */
|
|
||||||
const wasLoggedIn = currentUser$.value !== null
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
probableUser$.next(user)
|
|
||||||
} else {
|
|
||||||
probableUser$.next(null)
|
|
||||||
removeLocalConfig("login_state")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user && extraSnapshotStop) {
|
|
||||||
extraSnapshotStop()
|
|
||||||
extraSnapshotStop = null
|
|
||||||
} else if (user) {
|
|
||||||
// Merge all the user info from all the authenticated providers
|
|
||||||
user.providerData.forEach((profile) => {
|
|
||||||
if (!profile) return
|
|
||||||
|
|
||||||
const us = {
|
|
||||||
updatedOn: new Date(),
|
|
||||||
provider: profile.providerId,
|
|
||||||
name: profile.displayName,
|
|
||||||
email: profile.email,
|
|
||||||
photoUrl: profile.photoURL,
|
|
||||||
uid: profile.uid,
|
|
||||||
}
|
|
||||||
|
|
||||||
setDoc(doc(firestore, "users", user.uid), us, { merge: true }).catch(
|
|
||||||
(e) => console.error("error updating", us, e)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
extraSnapshotStop = onSnapshot(
|
|
||||||
doc(firestore, "users", user.uid),
|
|
||||||
(doc) => {
|
|
||||||
const data = doc.data()
|
|
||||||
|
|
||||||
const userUpdate: HoppUser = user
|
|
||||||
|
|
||||||
if (data) {
|
|
||||||
// Write extra provider data
|
|
||||||
userUpdate.provider = data.provider
|
|
||||||
userUpdate.accessToken = data.accessToken
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUser$.next(userUpdate)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
currentUser$.next(user)
|
|
||||||
|
|
||||||
// User wasn't found before, but now is there (login happened)
|
|
||||||
if (!wasLoggedIn && user) {
|
|
||||||
authEvents$.next({
|
|
||||||
event: "login",
|
|
||||||
user: currentUser$.value!,
|
|
||||||
})
|
|
||||||
} else if (wasLoggedIn && !user) {
|
|
||||||
// User was found before, but now is not there (logout happened)
|
|
||||||
authEvents$.next({
|
|
||||||
event: "logout",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onIdTokenChanged(auth, async (user) => {
|
|
||||||
if (user) {
|
|
||||||
authIdToken$.next(await user.getIdToken())
|
|
||||||
|
|
||||||
authEvents$.next({
|
|
||||||
event: "authTokenUpdate",
|
|
||||||
newToken: authIdToken$.value,
|
|
||||||
user: currentUser$.value!, // Force not-null because user is defined
|
|
||||||
})
|
|
||||||
|
|
||||||
setLocalConfig("login_state", JSON.stringify(user))
|
|
||||||
} else {
|
|
||||||
authIdToken$.next(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAuthIDToken(): string | null {
|
|
||||||
return authIdToken$.getValue()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign user in with a popup using Google
|
|
||||||
*/
|
|
||||||
export async function signInUserWithGoogle() {
|
|
||||||
return await signInWithPopup(getAuth(), new GoogleAuthProvider())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign user in with a popup using Github
|
|
||||||
*/
|
|
||||||
export async function signInUserWithGithub() {
|
|
||||||
return await signInWithPopup(
|
|
||||||
getAuth(),
|
|
||||||
new GithubAuthProvider().addScope("gist")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign user in with a popup using Microsoft
|
|
||||||
*/
|
|
||||||
export async function signInUserWithMicrosoft() {
|
|
||||||
return await signInWithPopup(getAuth(), new OAuthProvider("microsoft.com"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign user in with email and password
|
|
||||||
*/
|
|
||||||
export async function signInWithEmailAndPassword(
|
|
||||||
email: string,
|
|
||||||
password: string
|
|
||||||
) {
|
|
||||||
return await signInWithEmailAndPass(getAuth(), email, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the sign in methods for a given email address
|
|
||||||
*
|
|
||||||
* @param email - Email to get the methods of
|
|
||||||
*
|
|
||||||
* @returns Promise for string array of the auth provider methods accessible
|
|
||||||
*/
|
|
||||||
export async function getSignInMethodsForEmail(email: string) {
|
|
||||||
return await fetchSignInMethodsForEmail(getAuth(), email)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function linkWithFBCredential(
|
|
||||||
user: User,
|
|
||||||
credential: AuthCredential
|
|
||||||
) {
|
|
||||||
return await linkWithCredential(user, credential)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Links account with another account given in a auth/account-exists-with-different-credential error
|
|
||||||
*
|
|
||||||
* @param error - Error caught after trying to login
|
|
||||||
*
|
|
||||||
* @returns Promise of UserCredential
|
|
||||||
*/
|
|
||||||
export async function linkWithFBCredentialFromAuthError(error: unknown) {
|
|
||||||
// credential is not null since this function is called after an auth/account-exists-with-different-credential error, ie credentials actually exist
|
|
||||||
const credentials = OAuthProvider.credentialFromError(error as AuthError)!
|
|
||||||
|
|
||||||
const otherLinkedProviders = (
|
|
||||||
await getSignInMethodsForEmail((error as AuthError).customData.email!)
|
|
||||||
).filter((providerId) => credentials.providerId !== providerId)
|
|
||||||
|
|
||||||
let user: User | null = null
|
|
||||||
|
|
||||||
if (otherLinkedProviders.indexOf("google.com") >= -1) {
|
|
||||||
user = (await signInUserWithGoogle()).user
|
|
||||||
} else if (otherLinkedProviders.indexOf("github.com") >= -1) {
|
|
||||||
user = (await signInUserWithGithub()).user
|
|
||||||
} else if (otherLinkedProviders.indexOf("microsoft.com") >= -1) {
|
|
||||||
user = (await signInUserWithMicrosoft()).user
|
|
||||||
}
|
|
||||||
|
|
||||||
// user is not null since going through each provider will return a user
|
|
||||||
return await linkWithCredential(user!, credentials)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends an email with the signin link to the user
|
|
||||||
*
|
|
||||||
* @param email - Email to send the email to
|
|
||||||
* @param actionCodeSettings - The settings to apply to the link
|
|
||||||
*/
|
|
||||||
export async function signInWithEmail(
|
|
||||||
email: string,
|
|
||||||
actionCodeSettings: ActionCodeSettings
|
|
||||||
) {
|
|
||||||
return await sendSignInLinkToEmail(getAuth(), email, actionCodeSettings)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks and returns whether the sign in link is an email link
|
|
||||||
*
|
|
||||||
* @param url - The URL to look in
|
|
||||||
*/
|
|
||||||
export function isSignInWithEmailLink(url: string) {
|
|
||||||
return isSignInWithEmailLinkFB(getAuth(), url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends an email with sign in with email link
|
|
||||||
*
|
|
||||||
* @param email - Email to log in to
|
|
||||||
* @param url - The action URL which is used to validate login
|
|
||||||
*/
|
|
||||||
export async function signInWithEmailLink(email: string, url: string) {
|
|
||||||
return await signInWithEmailLinkFB(getAuth(), email, url)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signs out the user
|
|
||||||
*/
|
|
||||||
export async function signOutUser() {
|
|
||||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
|
||||||
|
|
||||||
await signOut(getAuth())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the provider id and relevant provider auth token
|
|
||||||
* as user metadata
|
|
||||||
*
|
|
||||||
* @param id - The provider ID
|
|
||||||
* @param token - The relevant auth token for the given provider
|
|
||||||
*/
|
|
||||||
export async function setProviderInfo(id: string, token: string) {
|
|
||||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
|
||||||
|
|
||||||
const us = {
|
|
||||||
updatedOn: new Date(),
|
|
||||||
provider: id,
|
|
||||||
accessToken: token,
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateDoc(
|
|
||||||
doc(getFirestore(), "users", currentUser$.value.uid),
|
|
||||||
us
|
|
||||||
).catch((e) => console.error("error updating", us, e))
|
|
||||||
} catch (e) {
|
|
||||||
console.error("error updating", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the user's display name
|
|
||||||
*
|
|
||||||
* @param name - The new display name
|
|
||||||
*/
|
|
||||||
export async function setDisplayName(name: string) {
|
|
||||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
|
||||||
|
|
||||||
const us = {
|
|
||||||
displayName: name,
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateProfile(currentUser$.value, us)
|
|
||||||
} catch (e) {
|
|
||||||
console.error("error updating", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send user's email address verification mail
|
|
||||||
*/
|
|
||||||
export async function verifyEmailAddress() {
|
|
||||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendEmailVerification(currentUser$.value)
|
|
||||||
} catch (e) {
|
|
||||||
console.error("error updating", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the user's email address
|
|
||||||
*
|
|
||||||
* @param email - The new email address
|
|
||||||
*/
|
|
||||||
export async function setEmailAddress(email: string) {
|
|
||||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateEmail(currentUser$.value, email)
|
|
||||||
} catch (e) {
|
|
||||||
await reauthenticateUser()
|
|
||||||
console.error("error updating", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reauthenticate the user with the given credential
|
|
||||||
*/
|
|
||||||
async function reauthenticateUser() {
|
|
||||||
if (!currentUser$.value) throw new Error("No user has logged in")
|
|
||||||
const currentAuthMethod = currentUser$.value.provider
|
|
||||||
let credential
|
|
||||||
if (currentAuthMethod === "google.com") {
|
|
||||||
const result = await signInUserWithGithub()
|
|
||||||
credential = GithubAuthProvider.credentialFromResult(result)
|
|
||||||
} else if (currentAuthMethod === "github.com") {
|
|
||||||
const result = await signInUserWithGoogle()
|
|
||||||
credential = GoogleAuthProvider.credentialFromResult(result)
|
|
||||||
} else if (currentAuthMethod === "microsoft.com") {
|
|
||||||
const result = await signInUserWithMicrosoft()
|
|
||||||
credential = OAuthProvider.credentialFromResult(result)
|
|
||||||
} else if (currentAuthMethod === "password") {
|
|
||||||
const email = prompt(
|
|
||||||
"Reauthenticate your account using your current email:"
|
|
||||||
)
|
|
||||||
const actionCodeSettings = {
|
|
||||||
url: `${process.env.BASE_URL}/enter`,
|
|
||||||
handleCodeInApp: true,
|
|
||||||
}
|
|
||||||
await signInWithEmail(email as string, actionCodeSettings)
|
|
||||||
.then(() =>
|
|
||||||
alert(
|
|
||||||
`Check your inbox - we sent an email to ${email}. It contains a magic link that will reauthenticate your account.`
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
alert(`Error: ${e.message}`)
|
|
||||||
console.error(e)
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await reauthenticateWithCredential(
|
|
||||||
currentUser$.value,
|
|
||||||
credential as AuthCredential
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
console.error("error updating", e)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getGithubCredentialFromResult(result: UserCredential) {
|
|
||||||
return GithubAuthProvider.credentialFromResult(result)
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
translateToNewRESTCollection,
|
translateToNewRESTCollection,
|
||||||
translateToNewGQLCollection,
|
translateToNewGQLCollection,
|
||||||
} from "@hoppscotch/data"
|
} from "@hoppscotch/data"
|
||||||
import { currentUser$ } from "./auth"
|
import { platform } from "~/platform"
|
||||||
import {
|
import {
|
||||||
restCollections$,
|
restCollections$,
|
||||||
graphqlCollections$,
|
graphqlCollections$,
|
||||||
@@ -44,20 +44,22 @@ export async function writeCollections(
|
|||||||
collection: any[],
|
collection: any[],
|
||||||
flag: CollectionFlags
|
flag: CollectionFlags
|
||||||
) {
|
) {
|
||||||
if (currentUser$.value === null)
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
|
if (currentUser === null)
|
||||||
throw new Error("User not logged in to write collections")
|
throw new Error("User not logged in to write collections")
|
||||||
|
|
||||||
const cl = {
|
const cl = {
|
||||||
updatedOn: new Date(),
|
updatedOn: new Date(),
|
||||||
author: currentUser$.value.uid,
|
author: currentUser.uid,
|
||||||
author_name: currentUser$.value.displayName,
|
author_name: currentUser.displayName,
|
||||||
author_image: currentUser$.value.photoURL,
|
author_image: currentUser.photoURL,
|
||||||
collection,
|
collection,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDoc(
|
await setDoc(
|
||||||
doc(getFirestore(), "users", currentUser$.value.uid, flag, "sync"),
|
doc(getFirestore(), "users", currentUser.uid, flag, "sync"),
|
||||||
cl
|
cl
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -67,10 +69,14 @@ export async function writeCollections(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initCollections() {
|
export function initCollections() {
|
||||||
|
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||||
|
|
||||||
const restCollSub = restCollections$.subscribe((collections) => {
|
const restCollSub = restCollections$.subscribe((collections) => {
|
||||||
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
loadedRESTCollections &&
|
loadedRESTCollections &&
|
||||||
currentUser$.value &&
|
currentUser &&
|
||||||
settingsStore.value.syncCollections
|
settingsStore.value.syncCollections
|
||||||
) {
|
) {
|
||||||
writeCollections(collections, "collections")
|
writeCollections(collections, "collections")
|
||||||
@@ -80,7 +86,7 @@ export function initCollections() {
|
|||||||
const gqlCollSub = graphqlCollections$.subscribe((collections) => {
|
const gqlCollSub = graphqlCollections$.subscribe((collections) => {
|
||||||
if (
|
if (
|
||||||
loadedGraphqlCollections &&
|
loadedGraphqlCollections &&
|
||||||
currentUser$.value &&
|
currentUser &&
|
||||||
settingsStore.value.syncCollections
|
settingsStore.value.syncCollections
|
||||||
) {
|
) {
|
||||||
writeCollections(collections, "collectionsGraphql")
|
writeCollections(collections, "collectionsGraphql")
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
onSnapshot,
|
onSnapshot,
|
||||||
setDoc,
|
setDoc,
|
||||||
} from "firebase/firestore"
|
} from "firebase/firestore"
|
||||||
import { currentUser$ } from "./auth"
|
import { platform } from "~/platform"
|
||||||
import {
|
import {
|
||||||
environments$,
|
environments$,
|
||||||
globalEnv$,
|
globalEnv$,
|
||||||
@@ -32,26 +32,22 @@ let loadedEnvironments = false
|
|||||||
let loadedGlobals = true
|
let loadedGlobals = true
|
||||||
|
|
||||||
async function writeEnvironments(environment: Environment[]) {
|
async function writeEnvironments(environment: Environment[]) {
|
||||||
if (currentUser$.value == null)
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
|
if (currentUser === null)
|
||||||
throw new Error("Cannot write environments when signed out")
|
throw new Error("Cannot write environments when signed out")
|
||||||
|
|
||||||
const ev = {
|
const ev = {
|
||||||
updatedOn: new Date(),
|
updatedOn: new Date(),
|
||||||
author: currentUser$.value.uid,
|
author: currentUser.uid,
|
||||||
author_name: currentUser$.value.displayName,
|
author_name: currentUser.displayName,
|
||||||
author_image: currentUser$.value.photoURL,
|
author_image: currentUser.photoURL,
|
||||||
environment,
|
environment,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDoc(
|
await setDoc(
|
||||||
doc(
|
doc(getFirestore(), "users", currentUser.uid, "environments", "sync"),
|
||||||
getFirestore(),
|
|
||||||
"users",
|
|
||||||
currentUser$.value.uid,
|
|
||||||
"environments",
|
|
||||||
"sync"
|
|
||||||
),
|
|
||||||
ev
|
ev
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -61,20 +57,22 @@ async function writeEnvironments(environment: Environment[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function writeGlobalEnvironment(variables: Environment["variables"]) {
|
async function writeGlobalEnvironment(variables: Environment["variables"]) {
|
||||||
if (currentUser$.value == null)
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
|
if (currentUser === null)
|
||||||
throw new Error("Cannot write global environment when signed out")
|
throw new Error("Cannot write global environment when signed out")
|
||||||
|
|
||||||
const ev = {
|
const ev = {
|
||||||
updatedOn: new Date(),
|
updatedOn: new Date(),
|
||||||
author: currentUser$.value.uid,
|
author: currentUser.uid,
|
||||||
author_name: currentUser$.value.displayName,
|
author_name: currentUser.displayName,
|
||||||
author_image: currentUser$.value.photoURL,
|
author_image: currentUser.photoURL,
|
||||||
variables,
|
variables,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDoc(
|
await setDoc(
|
||||||
doc(getFirestore(), "users", currentUser$.value.uid, "globalEnv", "sync"),
|
doc(getFirestore(), "users", currentUser.uid, "globalEnv", "sync"),
|
||||||
ev
|
ev
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -84,9 +82,13 @@ async function writeGlobalEnvironment(variables: Environment["variables"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initEnvironments() {
|
export function initEnvironments() {
|
||||||
|
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||||
|
|
||||||
const envListenSub = environments$.subscribe((envs) => {
|
const envListenSub = environments$.subscribe((envs) => {
|
||||||
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentUser$.value &&
|
currentUser &&
|
||||||
settingsStore.value.syncEnvironments &&
|
settingsStore.value.syncEnvironments &&
|
||||||
loadedEnvironments
|
loadedEnvironments
|
||||||
) {
|
) {
|
||||||
@@ -95,11 +97,9 @@ export function initEnvironments() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const globalListenSub = globalEnv$.subscribe((vars) => {
|
const globalListenSub = globalEnv$.subscribe((vars) => {
|
||||||
if (
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
currentUser$.value &&
|
|
||||||
settingsStore.value.syncEnvironments &&
|
if (currentUser && settingsStore.value.syncEnvironments && loadedGlobals) {
|
||||||
loadedGlobals
|
|
||||||
) {
|
|
||||||
writeGlobalEnvironment(vars)
|
writeGlobalEnvironment(vars)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
updateDoc,
|
updateDoc,
|
||||||
} from "firebase/firestore"
|
} from "firebase/firestore"
|
||||||
import { FormDataKeyValue } from "@hoppscotch/data"
|
import { FormDataKeyValue } from "@hoppscotch/data"
|
||||||
import { currentUser$ } from "./auth"
|
import { platform } from "~/platform"
|
||||||
import { getSettingSubject, settingsStore } from "~/newstore/settings"
|
import { getSettingSubject, settingsStore } from "~/newstore/settings"
|
||||||
import {
|
import {
|
||||||
GQLHistoryEntry,
|
GQLHistoryEntry,
|
||||||
@@ -76,7 +76,9 @@ async function writeHistory(
|
|||||||
? purgeFormDataFromRequest(entry as RESTHistoryEntry)
|
? purgeFormDataFromRequest(entry as RESTHistoryEntry)
|
||||||
: entry
|
: entry
|
||||||
|
|
||||||
if (currentUser$.value == null)
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
|
if (currentUser === null)
|
||||||
throw new Error("User not logged in to sync history")
|
throw new Error("User not logged in to sync history")
|
||||||
|
|
||||||
const hs = {
|
const hs = {
|
||||||
@@ -85,10 +87,7 @@ async function writeHistory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addDoc(
|
await addDoc(collection(getFirestore(), "users", currentUser.uid, col), hs)
|
||||||
collection(getFirestore(), "users", currentUser$.value.uid, col),
|
|
||||||
hs
|
|
||||||
)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("error writing to history", hs, e)
|
console.error("error writing to history", hs, e)
|
||||||
throw e
|
throw e
|
||||||
@@ -99,12 +98,14 @@ async function deleteHistory(
|
|||||||
entry: (RESTHistoryEntry | GQLHistoryEntry) & { id: string },
|
entry: (RESTHistoryEntry | GQLHistoryEntry) & { id: string },
|
||||||
col: HistoryFBCollections
|
col: HistoryFBCollections
|
||||||
) {
|
) {
|
||||||
if (currentUser$.value == null)
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
|
if (currentUser === null)
|
||||||
throw new Error("User not logged in to delete history")
|
throw new Error("User not logged in to delete history")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteDoc(
|
await deleteDoc(
|
||||||
doc(getFirestore(), "users", currentUser$.value.uid, col, entry.id)
|
doc(getFirestore(), "users", currentUser.uid, col, entry.id)
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("error deleting history", entry, e)
|
console.error("error deleting history", entry, e)
|
||||||
@@ -113,11 +114,13 @@ async function deleteHistory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearHistory(col: HistoryFBCollections) {
|
async function clearHistory(col: HistoryFBCollections) {
|
||||||
if (currentUser$.value == null)
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
|
if (currentUser === null)
|
||||||
throw new Error("User not logged in to clear history")
|
throw new Error("User not logged in to clear history")
|
||||||
|
|
||||||
const { docs } = await getDocs(
|
const { docs } = await getDocs(
|
||||||
collection(getFirestore(), "users", currentUser$.value.uid, col)
|
collection(getFirestore(), "users", currentUser.uid, col)
|
||||||
)
|
)
|
||||||
|
|
||||||
await Promise.all(docs.map((e) => deleteHistory(e as any, col)))
|
await Promise.all(docs.map((e) => deleteHistory(e as any, col)))
|
||||||
@@ -127,12 +130,13 @@ async function toggleStar(
|
|||||||
entry: (RESTHistoryEntry | GQLHistoryEntry) & { id: string },
|
entry: (RESTHistoryEntry | GQLHistoryEntry) & { id: string },
|
||||||
col: HistoryFBCollections
|
col: HistoryFBCollections
|
||||||
) {
|
) {
|
||||||
if (currentUser$.value == null)
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
throw new Error("User not logged in to toggle star")
|
|
||||||
|
if (currentUser === null) throw new Error("User not logged in to toggle star")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateDoc(
|
await updateDoc(
|
||||||
doc(getFirestore(), "users", currentUser$.value.uid, col, entry.id),
|
doc(getFirestore(), "users", currentUser.uid, col, entry.id),
|
||||||
{ star: !entry.star }
|
{ star: !entry.star }
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -142,12 +146,12 @@ async function toggleStar(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initHistory() {
|
export function initHistory() {
|
||||||
|
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||||
|
|
||||||
const restHistorySub = restHistoryStore.dispatches$.subscribe((dispatch) => {
|
const restHistorySub = restHistoryStore.dispatches$.subscribe((dispatch) => {
|
||||||
if (
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
loadedRESTHistory &&
|
|
||||||
currentUser$.value &&
|
if (loadedRESTHistory && currentUser && settingsStore.value.syncHistory) {
|
||||||
settingsStore.value.syncHistory
|
|
||||||
) {
|
|
||||||
if (dispatch.dispatcher === "addEntry") {
|
if (dispatch.dispatcher === "addEntry") {
|
||||||
writeHistory(dispatch.payload.entry, "history")
|
writeHistory(dispatch.payload.entry, "history")
|
||||||
} else if (dispatch.dispatcher === "deleteEntry") {
|
} else if (dispatch.dispatcher === "deleteEntry") {
|
||||||
@@ -162,9 +166,11 @@ export function initHistory() {
|
|||||||
|
|
||||||
const gqlHistorySub = graphqlHistoryStore.dispatches$.subscribe(
|
const gqlHistorySub = graphqlHistoryStore.dispatches$.subscribe(
|
||||||
(dispatch) => {
|
(dispatch) => {
|
||||||
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
loadedGraphqlHistory &&
|
loadedGraphqlHistory &&
|
||||||
currentUser$.value &&
|
currentUser &&
|
||||||
settingsStore.value.syncHistory
|
settingsStore.value.syncHistory
|
||||||
) {
|
) {
|
||||||
if (dispatch.dispatcher === "addEntry") {
|
if (dispatch.dispatcher === "addEntry") {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { initializeApp } from "firebase/app"
|
import { initializeApp } from "firebase/app"
|
||||||
|
import { platform } from "~/platform"
|
||||||
import { initAnalytics } from "./analytics"
|
import { initAnalytics } from "./analytics"
|
||||||
import { initAuth } from "./auth"
|
|
||||||
import { initCollections } from "./collections"
|
import { initCollections } from "./collections"
|
||||||
import { initEnvironments } from "./environments"
|
import { initEnvironments } from "./environments"
|
||||||
import { initHistory } from "./history"
|
import { initHistory } from "./history"
|
||||||
@@ -24,7 +24,7 @@ export function initializeFirebase() {
|
|||||||
try {
|
try {
|
||||||
initializeApp(firebaseConfig)
|
initializeApp(firebaseConfig)
|
||||||
|
|
||||||
initAuth()
|
platform.auth.performAuthInit()
|
||||||
initSettings()
|
initSettings()
|
||||||
initCollections()
|
initCollections()
|
||||||
initHistory()
|
initHistory()
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
import { doc, getDoc, getFirestore, setDoc } from "firebase/firestore"
|
import { doc, getDoc, getFirestore, setDoc } from "firebase/firestore"
|
||||||
import { cloneDeep } from "lodash-es"
|
import { cloneDeep } from "lodash-es"
|
||||||
import { HoppRESTRequest, translateToNewRequest } from "@hoppscotch/data"
|
import { HoppRESTRequest, translateToNewRequest } from "@hoppscotch/data"
|
||||||
import { currentUser$, HoppUser } from "./auth"
|
import { platform } from "~/platform"
|
||||||
|
import { HoppUser } from "~/platform/auth"
|
||||||
import { restRequest$ } from "~/newstore/RESTSession"
|
import { restRequest$ } from "~/newstore/RESTSession"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +45,7 @@ function writeCurrentRequest(user: HoppUser, request: HoppRESTRequest) {
|
|||||||
* @returns Fetched request object if exists else null
|
* @returns Fetched request object if exists else null
|
||||||
*/
|
*/
|
||||||
export async function loadRequestFromSync(): Promise<HoppRESTRequest | null> {
|
export async function loadRequestFromSync(): Promise<HoppRESTRequest | null> {
|
||||||
const currentUser = currentUser$.value
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
if (!currentUser)
|
if (!currentUser)
|
||||||
throw new Error("Cannot load request from sync without login")
|
throw new Error("Cannot load request from sync without login")
|
||||||
@@ -66,6 +67,8 @@ export async function loadRequestFromSync(): Promise<HoppRESTRequest | null> {
|
|||||||
* Unsubscribe to stop syncing.
|
* Unsubscribe to stop syncing.
|
||||||
*/
|
*/
|
||||||
export function startRequestSync(): Subscription {
|
export function startRequestSync(): Subscription {
|
||||||
|
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||||
|
|
||||||
const sub = combineLatest([
|
const sub = combineLatest([
|
||||||
currentUser$,
|
currentUser$,
|
||||||
restRequest$.pipe(distinctUntilChanged()),
|
restRequest$.pipe(distinctUntilChanged()),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
onSnapshot,
|
onSnapshot,
|
||||||
setDoc,
|
setDoc,
|
||||||
} from "firebase/firestore"
|
} from "firebase/firestore"
|
||||||
import { currentUser$ } from "./auth"
|
import { platform } from "~/platform"
|
||||||
import { applySetting, settingsStore, SettingsType } from "~/newstore/settings"
|
import { applySetting, settingsStore, SettingsType } from "~/newstore/settings"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,21 +20,23 @@ let loadedSettings = false
|
|||||||
* Write Transform
|
* Write Transform
|
||||||
*/
|
*/
|
||||||
async function writeSettings(setting: string, value: any) {
|
async function writeSettings(setting: string, value: any) {
|
||||||
if (currentUser$.value === null)
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
|
if (currentUser === null)
|
||||||
throw new Error("Cannot write setting, user not signed in")
|
throw new Error("Cannot write setting, user not signed in")
|
||||||
|
|
||||||
const st = {
|
const st = {
|
||||||
updatedOn: new Date(),
|
updatedOn: new Date(),
|
||||||
author: currentUser$.value.uid,
|
author: currentUser.uid,
|
||||||
author_name: currentUser$.value.displayName,
|
author_name: currentUser.displayName,
|
||||||
author_image: currentUser$.value.photoURL,
|
author_image: currentUser.photoURL,
|
||||||
name: setting,
|
name: setting,
|
||||||
value,
|
value,
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setDoc(
|
await setDoc(
|
||||||
doc(getFirestore(), "users", currentUser$.value.uid, "settings", setting),
|
doc(getFirestore(), "users", currentUser.uid, "settings", setting),
|
||||||
st
|
st
|
||||||
)
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -44,8 +46,12 @@ async function writeSettings(setting: string, value: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initSettings() {
|
export function initSettings() {
|
||||||
|
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||||
|
|
||||||
settingsStore.dispatches$.subscribe((dispatch) => {
|
settingsStore.dispatches$.subscribe((dispatch) => {
|
||||||
if (currentUser$.value && loadedSettings) {
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
|
if (currentUser && loadedSettings) {
|
||||||
if (dispatch.dispatcher === "bulkApplySettings") {
|
if (dispatch.dispatcher === "bulkApplySettings") {
|
||||||
Object.keys(dispatch.payload).forEach((key) => {
|
Object.keys(dispatch.payload).forEach((key) => {
|
||||||
writeSettings(key, dispatch.payload[key])
|
writeSettings(key, dispatch.payload[key])
|
||||||
|
|||||||
34
packages/hoppscotch-common/src/helpers/gist.ts
Normal file
34
packages/hoppscotch-common/src/helpers/gist.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
import * as TE from "fp-ts/TaskEither"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an gist on GitHub with the collection JSON
|
||||||
|
* @param collectionJSON - JSON string of the collection
|
||||||
|
* @param accessToken - GitHub access token
|
||||||
|
* @returns Either of the response of the GitHub Gist API or the error
|
||||||
|
*/
|
||||||
|
export const createCollectionGists = (
|
||||||
|
collectionJSON: string,
|
||||||
|
accessToken: string
|
||||||
|
) => {
|
||||||
|
return TE.tryCatch(
|
||||||
|
async () =>
|
||||||
|
axios.post(
|
||||||
|
"https://api.github.com/gists",
|
||||||
|
{
|
||||||
|
files: {
|
||||||
|
"hoppscotch-collections.json": {
|
||||||
|
content: collectionJSON,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${accessToken}`,
|
||||||
|
Accept: "application/vnd.github.v3+json",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(reason) => reason
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { pipe } from "fp-ts/function"
|
|
||||||
import * as E from "fp-ts/Either"
|
|
||||||
import { BehaviorSubject } from "rxjs"
|
|
||||||
import { authIdToken$ } from "../fb/auth"
|
|
||||||
import { runGQLQuery } from "../backend/GQLClient"
|
|
||||||
import { GetUserInfoDocument } from "../backend/graphql"
|
|
||||||
|
|
||||||
/*
|
|
||||||
* This file deals with interfacing data provided by the
|
|
||||||
* Hoppscotch Backend server
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines the information provided about a user
|
|
||||||
*/
|
|
||||||
export interface UserInfo {
|
|
||||||
/**
|
|
||||||
* UID of the user
|
|
||||||
*/
|
|
||||||
uid: string
|
|
||||||
/**
|
|
||||||
* Displayable name of the user (or null if none available)
|
|
||||||
*/
|
|
||||||
displayName: string | null
|
|
||||||
/**
|
|
||||||
* Email of the user (or null if none available)
|
|
||||||
*/
|
|
||||||
email: string | null
|
|
||||||
/**
|
|
||||||
* URL to the profile photo of the user (or null if none available)
|
|
||||||
*/
|
|
||||||
photoURL: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An observable subject onto the currently logged in user info (is null if not logged in)
|
|
||||||
*/
|
|
||||||
export const currentUserInfo$ = new BehaviorSubject<UserInfo | null>(null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the currenUserInfo$ view and sets up its update mechanism
|
|
||||||
*/
|
|
||||||
export function initUserInfo() {
|
|
||||||
authIdToken$.subscribe((token) => {
|
|
||||||
if (token) {
|
|
||||||
updateUserInfo()
|
|
||||||
} else {
|
|
||||||
currentUserInfo$.next(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs the actual user info fetching
|
|
||||||
*/
|
|
||||||
async function updateUserInfo() {
|
|
||||||
const result = await runGQLQuery({
|
|
||||||
query: GetUserInfoDocument,
|
|
||||||
})
|
|
||||||
|
|
||||||
currentUserInfo$.next(
|
|
||||||
pipe(
|
|
||||||
result,
|
|
||||||
E.matchW(
|
|
||||||
() => null,
|
|
||||||
(x) => ({
|
|
||||||
uid: x.me.uid,
|
|
||||||
displayName: x.me.displayName ?? null,
|
|
||||||
email: x.me.email ?? null,
|
|
||||||
photoURL: x.me.photoURL ?? null,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import * as E from "fp-ts/Either"
|
|||||||
import { BehaviorSubject } from "rxjs"
|
import { BehaviorSubject } from "rxjs"
|
||||||
import { GQLError, runGQLQuery } from "../backend/GQLClient"
|
import { GQLError, runGQLQuery } from "../backend/GQLClient"
|
||||||
import { GetMyTeamsDocument, GetMyTeamsQuery } from "../backend/graphql"
|
import { GetMyTeamsDocument, GetMyTeamsQuery } from "../backend/graphql"
|
||||||
import { authIdToken$ } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
|
|
||||||
const BACKEND_PAGE_SIZE = 10
|
const BACKEND_PAGE_SIZE = 10
|
||||||
const POLL_DURATION = 10000
|
const POLL_DURATION = 10000
|
||||||
@@ -47,8 +47,10 @@ export default class TeamListAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fetchList() {
|
async fetchList() {
|
||||||
|
const currentUser = platform.auth.getCurrentUser()
|
||||||
|
|
||||||
// if the authIdToken is not present, don't fetch the teams list, as it will fail anyway
|
// if the authIdToken is not present, don't fetch the teams list, as it will fail anyway
|
||||||
if (!authIdToken$.value) return
|
if (!currentUser) return
|
||||||
|
|
||||||
this.loading$.next(true)
|
this.loading$.next(true)
|
||||||
|
|
||||||
|
|||||||
34
packages/hoppscotch-common/src/helpers/treeAdapter.ts
Normal file
34
packages/hoppscotch-common/src/helpers/treeAdapter.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Ref } from "vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Representation of a tree node in the SmartTreeAdapter.
|
||||||
|
*/
|
||||||
|
export type TreeNode<T> = {
|
||||||
|
id: string
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Representation of children result from a tree node when there will be a loading state.
|
||||||
|
*/
|
||||||
|
export type ChildrenResult<T> =
|
||||||
|
| {
|
||||||
|
status: "loading"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
status: "loaded"
|
||||||
|
data: Array<TreeNode<T>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tree adapter that can be used with the SmartTree component.
|
||||||
|
* @template T The type of data that is stored in the tree.
|
||||||
|
*/
|
||||||
|
export interface SmartTreeAdapter<T> {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param nodeID - id of the node to get children for
|
||||||
|
* @returns - Ref that contains the children of the node. It is reactive and will be updated when the children are changed.
|
||||||
|
*/
|
||||||
|
getChildren: (nodeID: string | null) => Ref<ChildrenResult<T>>
|
||||||
|
}
|
||||||
47
packages/hoppscotch-common/src/helpers/types/HoppPicked.ts
Normal file
47
packages/hoppscotch-common/src/helpers/types/HoppPicked.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Picked is used to defrentiate
|
||||||
|
* the select item in the save request dialog
|
||||||
|
* The save request dialog can be used
|
||||||
|
* to save a request, folder or a collection
|
||||||
|
* seperately for my and teams for REST.
|
||||||
|
* also for graphQL collections
|
||||||
|
*/
|
||||||
|
export type Picked =
|
||||||
|
| {
|
||||||
|
pickedType: "my-request"
|
||||||
|
folderPath: string
|
||||||
|
requestIndex: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
pickedType: "my-folder"
|
||||||
|
folderPath: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
pickedType: "my-collection"
|
||||||
|
collectionIndex: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
pickedType: "teams-request"
|
||||||
|
requestID: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
pickedType: "teams-folder"
|
||||||
|
folderID: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
pickedType: "teams-collection"
|
||||||
|
collectionID: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
pickedType: "gql-my-request"
|
||||||
|
folderPath: string
|
||||||
|
requestIndex: number
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
pickedType: "gql-my-folder"
|
||||||
|
folderPath: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
pickedType: "gql-my-collection"
|
||||||
|
collectionIndex: number
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createApp, Ref } from "vue"
|
import { createApp } from "vue"
|
||||||
|
import { PlatformDef, setPlatformDef } from "./platform"
|
||||||
import { setupLocalPersistence } from "./newstore/localpersistence"
|
import { setupLocalPersistence } from "./newstore/localpersistence"
|
||||||
import { performMigrations } from "./helpers/migrations"
|
import { performMigrations } from "./helpers/migrations"
|
||||||
import { initializeFirebase } from "./helpers/fb"
|
import { initializeFirebase } from "./helpers/fb"
|
||||||
import { initUserInfo } from "./helpers/teams/BackendUserInfo"
|
import { initBackendGQLClient } from "./helpers/backend/GQLClient"
|
||||||
import { HOPP_MODULES } from "@modules/."
|
import { HOPP_MODULES } from "@modules/."
|
||||||
|
|
||||||
import "virtual:windi.css"
|
import "virtual:windi.css"
|
||||||
@@ -12,33 +13,16 @@ import "nprogress/nprogress.css"
|
|||||||
|
|
||||||
import App from "./App.vue"
|
import App from "./App.vue"
|
||||||
|
|
||||||
export type PlatformDef = {
|
|
||||||
ui?: {
|
|
||||||
appHeader?: {
|
|
||||||
paddingTop?: Ref<string>
|
|
||||||
paddingLeft?: Ref<string>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defines the fields, functions and properties that will be
|
|
||||||
* filled in by the individual platforms.
|
|
||||||
*
|
|
||||||
* This value is populated upon calling `createHoppApp`
|
|
||||||
*/
|
|
||||||
export let platform: PlatformDef
|
|
||||||
|
|
||||||
export function createHoppApp(el: string | Element, platformDef: PlatformDef) {
|
export function createHoppApp(el: string | Element, platformDef: PlatformDef) {
|
||||||
platform = platformDef
|
setPlatformDef(platformDef)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
// Some basic work that needs to be done before module inits even
|
// Some basic work that needs to be done before module inits even
|
||||||
initializeFirebase()
|
initializeFirebase()
|
||||||
|
initBackendGQLClient()
|
||||||
setupLocalPersistence()
|
setupLocalPersistence()
|
||||||
performMigrations()
|
performMigrations()
|
||||||
initUserInfo()
|
|
||||||
|
|
||||||
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))
|
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { settingsStore } from "~/newstore/settings"
|
|||||||
import { App } from "vue"
|
import { App } from "vue"
|
||||||
import { APP_IS_IN_DEV_MODE } from "~/helpers/dev"
|
import { APP_IS_IN_DEV_MODE } from "~/helpers/dev"
|
||||||
import { gqlClientError$ } from "~/helpers/backend/GQLClient"
|
import { gqlClientError$ } from "~/helpers/backend/GQLClient"
|
||||||
import { currentUser$ } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tag names we allow giving to Sentry
|
* The tag names we allow giving to Sentry
|
||||||
@@ -164,6 +164,8 @@ function subscribeToAppEventsForReporting() {
|
|||||||
* additional data tags for the error reporting
|
* additional data tags for the error reporting
|
||||||
*/
|
*/
|
||||||
function subscribeForAppDataTags() {
|
function subscribeForAppDataTags() {
|
||||||
|
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||||
|
|
||||||
currentUser$.subscribe((user) => {
|
currentUser$.subscribe((user) => {
|
||||||
if (sentryActive) {
|
if (sentryActive) {
|
||||||
Sentry.setTag("user_logged_in", !!user)
|
Sentry.setTag("user_logged_in", !!user)
|
||||||
|
|||||||
@@ -10,8 +10,7 @@
|
|||||||
import { defineComponent } from "vue"
|
import { defineComponent } from "vue"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { initializeFirebase } from "~/helpers/fb"
|
import { initializeFirebase } from "~/helpers/fb"
|
||||||
import { isSignInWithEmailLink, signInWithEmailLink } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
import { getLocalConfig, removeLocalConfig } from "~/newstore/localpersistence"
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
@@ -29,29 +28,14 @@ export default defineComponent({
|
|||||||
initializeFirebase()
|
initializeFirebase()
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
if (isSignInWithEmailLink(window.location.href)) {
|
this.signingInWithEmail = true
|
||||||
this.signingInWithEmail = true
|
|
||||||
|
|
||||||
let email = getLocalConfig("emailForSignIn")
|
try {
|
||||||
|
await platform.auth.processMagicLink()
|
||||||
if (!email) {
|
} catch (e) {
|
||||||
email = window.prompt(
|
this.error = e.message
|
||||||
"Please provide your email for confirmation"
|
} finally {
|
||||||
) as string
|
this.signingInWithEmail = false
|
||||||
}
|
|
||||||
|
|
||||||
await signInWithEmailLink(email, window.location.href)
|
|
||||||
.then(() => {
|
|
||||||
removeLocalConfig("emailForSignIn")
|
|
||||||
this.$router.push({ path: "/" })
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
this.signingInWithEmail = false
|
|
||||||
this.error = e.message
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
this.signingInWithEmail = false
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ import {
|
|||||||
} from "~/helpers/backend/graphql"
|
} from "~/helpers/backend/graphql"
|
||||||
import { acceptTeamInvitation } from "~/helpers/backend/mutations/TeamInvitation"
|
import { acceptTeamInvitation } from "~/helpers/backend/mutations/TeamInvitation"
|
||||||
import { initializeFirebase } from "~/helpers/fb"
|
import { initializeFirebase } from "~/helpers/fb"
|
||||||
import { currentUser$, probableUser$ } from "~/helpers/fb/auth"
|
import { platform } from "~/platform"
|
||||||
import { onLoggedIn } from "@composables/auth"
|
import { onLoggedIn } from "@composables/auth"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
@@ -197,8 +197,15 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const probableUser = useReadonlyStream(probableUser$, null)
|
const probableUser = useReadonlyStream(
|
||||||
const currentUser = useReadonlyStream(currentUser$, null)
|
platform.auth.getProbableUserStream(),
|
||||||
|
platform.auth.getProbableUser()
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
const loadingCurrentUser = computed(() => {
|
const loadingCurrentUser = computed(() => {
|
||||||
if (!probableUser.value) return false
|
if (!probableUser.value) return false
|
||||||
|
|||||||
@@ -211,13 +211,9 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watchEffect, computed } from "vue"
|
import { ref, watchEffect, computed } from "vue"
|
||||||
import {
|
|
||||||
currentUser$,
|
import { platform } from "~/platform"
|
||||||
probableUser$,
|
|
||||||
setDisplayName,
|
|
||||||
setEmailAddress,
|
|
||||||
verifyEmailAddress,
|
|
||||||
} from "~/helpers/fb/auth"
|
|
||||||
import { invokeAction } from "~/helpers/actions"
|
import { invokeAction } from "~/helpers/actions"
|
||||||
|
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
@@ -247,8 +243,14 @@ usePageHead({
|
|||||||
const SYNC_COLLECTIONS = useSetting("syncCollections")
|
const SYNC_COLLECTIONS = useSetting("syncCollections")
|
||||||
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
|
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
|
||||||
const SYNC_HISTORY = useSetting("syncHistory")
|
const SYNC_HISTORY = useSetting("syncHistory")
|
||||||
const currentUser = useReadonlyStream(currentUser$, null)
|
const currentUser = useReadonlyStream(
|
||||||
const probableUser = useReadonlyStream(probableUser$, null)
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
const probableUser = useReadonlyStream(
|
||||||
|
platform.auth.getProbableUserStream(),
|
||||||
|
platform.auth.getProbableUser()
|
||||||
|
)
|
||||||
|
|
||||||
const loadingCurrentUser = computed(() => {
|
const loadingCurrentUser = computed(() => {
|
||||||
if (!probableUser.value) return false
|
if (!probableUser.value) return false
|
||||||
@@ -262,7 +264,8 @@ watchEffect(() => (displayName.value = currentUser.value?.displayName))
|
|||||||
|
|
||||||
const updateDisplayName = () => {
|
const updateDisplayName = () => {
|
||||||
updatingDisplayName.value = true
|
updatingDisplayName.value = true
|
||||||
setDisplayName(displayName.value as string)
|
platform.auth
|
||||||
|
.setDisplayName(displayName.value as string)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`${t("profile.updated")}`)
|
toast.success(`${t("profile.updated")}`)
|
||||||
})
|
})
|
||||||
@@ -280,7 +283,8 @@ watchEffect(() => (emailAddress.value = currentUser.value?.email))
|
|||||||
|
|
||||||
const updateEmailAddress = () => {
|
const updateEmailAddress = () => {
|
||||||
updatingEmailAddress.value = true
|
updatingEmailAddress.value = true
|
||||||
setEmailAddress(emailAddress.value as string)
|
platform.auth
|
||||||
|
.setEmailAddress(emailAddress.value as string)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`${t("profile.updated")}`)
|
toast.success(`${t("profile.updated")}`)
|
||||||
})
|
})
|
||||||
@@ -296,7 +300,8 @@ const verifyingEmailAddress = ref(false)
|
|||||||
|
|
||||||
const sendEmailVerification = () => {
|
const sendEmailVerification = () => {
|
||||||
verifyingEmailAddress.value = true
|
verifyingEmailAddress.value = true
|
||||||
verifyEmailAddress()
|
platform.auth
|
||||||
|
.verifyEmailAddress()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
toast.success(`${t("profile.email_verification_mail")}`)
|
toast.success(`${t("profile.email_verification_mail")}`)
|
||||||
})
|
})
|
||||||
|
|||||||
214
packages/hoppscotch-common/src/platform/auth.ts
Normal file
214
packages/hoppscotch-common/src/platform/auth.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { ClientOptions } from "@urql/core"
|
||||||
|
import { Observable } from "rxjs"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A common (and required) set of fields that describe a user.
|
||||||
|
*/
|
||||||
|
export type HoppUser = {
|
||||||
|
/** A unique ID identifying the user */
|
||||||
|
uid: string
|
||||||
|
|
||||||
|
/** The name to be displayed as the user's */
|
||||||
|
displayName: string | null
|
||||||
|
|
||||||
|
/** The user's email address */
|
||||||
|
email: string | null
|
||||||
|
|
||||||
|
/** URL to the profile picture of the user */
|
||||||
|
photoURL: string | null
|
||||||
|
|
||||||
|
// Regarding `provider` and `accessToken`:
|
||||||
|
// The current implementation and use case for these 2 fields are super weird due to legacy.
|
||||||
|
// Currrently these fields are only basically populated for Github Auth as we need the access token issued
|
||||||
|
// by it to implement Gist submission. I would really love refactor to make this thing more sane.
|
||||||
|
|
||||||
|
/** Name of the provider authenticating (NOTE: See notes on `platform/auth.ts`) */
|
||||||
|
provider?: string
|
||||||
|
/** Access Token for the auth of the user against the given `provider`. */
|
||||||
|
accessToken?: string
|
||||||
|
emailVerified: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthEvent =
|
||||||
|
| { event: "probable_login"; user: HoppUser } // We have previous login state, but the app is waiting for authentication
|
||||||
|
| { event: "login"; user: HoppUser } // We are authenticated
|
||||||
|
| { event: "logout" } // No authentication and we have no previous state
|
||||||
|
|
||||||
|
export type GithubSignInResult =
|
||||||
|
| { type: "success"; user: HoppUser } // The authentication was a success
|
||||||
|
| { type: "account-exists-with-different-cred"; link: () => Promise<void> } // We authenticated correctly, but the provider didn't match, so we give the user the opportunity to link to continue completing auth
|
||||||
|
| { type: "error"; err: unknown } // Auth failed completely and we don't know why
|
||||||
|
|
||||||
|
export type AuthPlatformDef = {
|
||||||
|
/**
|
||||||
|
* Returns an observable that emits the current user as per the auth implementation.
|
||||||
|
*
|
||||||
|
* NOTES:
|
||||||
|
* 1. Make sure to emit non-null values once you have credentials to perform backend operations. (Get required tokens ?)
|
||||||
|
* 2. It is best to let the stream emit a value immediately on subscription (we can do that by basing this on a BehaviourSubject)
|
||||||
|
*
|
||||||
|
* @returns An observable which returns a `HoppUser` or null if not logged in (or login not completed)
|
||||||
|
*/
|
||||||
|
getCurrentUserStream: () => Observable<HoppUser | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a stream to events happening in the auth mechanism. Common uses these events to
|
||||||
|
* let subsystems know something is changed by the authentication status and to react accordingly
|
||||||
|
*
|
||||||
|
* @returns An observable which emits an AuthEvent over time
|
||||||
|
*/
|
||||||
|
getAuthEventsStream: () => Observable<AuthEvent>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar to `getCurrentUserStream` but deals with the authentication being `probable`.
|
||||||
|
* Probable User for states where, "We haven't authed yet but we are guessing this person will auth eventually".
|
||||||
|
* This allows for things like Header component to presumpt a state until we auth properly and avoid flashing a "logged out" state.
|
||||||
|
*
|
||||||
|
* NOTES:
|
||||||
|
* 1. It is best to let the stream emit a value immediately on subscription (we can do that by basing this on a BehaviourSubject)
|
||||||
|
* 2. Once the authentication is confirmed, this stream should emit the same values as `getCurrentUserStream`.
|
||||||
|
*
|
||||||
|
* @returns An obsverable which returns a `HoppUser` for the probable user (or confirmed user if authed) or null if we don't know about a probable user
|
||||||
|
*/
|
||||||
|
getProbableUserStream: () => Observable<HoppUser | null>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently authed user. (Similar rules apply as `getCurrentUserStream`)
|
||||||
|
* @returns The authenticated user or null if not logged in
|
||||||
|
*/
|
||||||
|
getCurrentUser: () => HoppUser | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the most probable to complete auth user. (Similar rules apply as `getProbableUserStream`)
|
||||||
|
* @returns The probable user or null if have no idea who will auth in
|
||||||
|
*/
|
||||||
|
getProbableUser: () => HoppUser | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [This is only for Common Init logic to call!]
|
||||||
|
* Called by Common when it is time to perform initialization activities for authentication.
|
||||||
|
* (This is the best place to do init work for the auth subsystem in the platform).
|
||||||
|
*/
|
||||||
|
performAuthInit: () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the headers that should be applied by the backend GQL API client (see GQLClient)
|
||||||
|
* inorder to talk to the backend (like apply auth headers ?)
|
||||||
|
* @returns An object with the header key and header values as strings
|
||||||
|
*/
|
||||||
|
getBackendHeaders: () => Record<string, string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the backend GQL API client encounters an auth error to check if with the
|
||||||
|
* current state, if an auth error is possible. This lets the backend GQL client know that
|
||||||
|
* it can expect an auth error and we should wait and (possibly retry) to re-execute an operation.
|
||||||
|
* This is useful for cases where queries might fail as the tokens just expired and need to be refreshed,
|
||||||
|
* so the app can get the new token and GQL client knows to re-execute the same query.
|
||||||
|
|
||||||
|
* @returns Whether an error is expected or not
|
||||||
|
*/
|
||||||
|
willBackendHaveAuthError: () => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to register a callback where the backend GQL client should reconnect/reconfigure subscriptions
|
||||||
|
* as some communication parameter changed over time. Like for example, the backend subscription system
|
||||||
|
* on a id token based mechanism should be let known that the id token has changed and reconnect the subscription
|
||||||
|
* connection with the updated params.
|
||||||
|
* @param func The callback function to call
|
||||||
|
*/
|
||||||
|
onBackendGQLClientShouldReconnect: (func: () => void) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* provide the client options for GqlClient
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
getGQLClientOptions?: () => ClientOptions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the string content that should be returned when the user selects to
|
||||||
|
* copy auth token from Developer Options.
|
||||||
|
*
|
||||||
|
* @returns The auth token (or equivalent) as a string if we have one to give, else null
|
||||||
|
*/
|
||||||
|
getDevOptsBackendIDToken: () => string | null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an empty promise that only resolves when the current probable user because confirmed.
|
||||||
|
*
|
||||||
|
* Note:
|
||||||
|
* 1. Make sure there is a probable user before waiting, as if not, this function will throw
|
||||||
|
* 2. If the probable user is already confirmed, this function will return an immediately resolved promise
|
||||||
|
*/
|
||||||
|
waitProbableLoginToConfirm: () => Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called to sign in user with email (magic link). This should send backend calls to send the auth email.
|
||||||
|
* @param email The email that is logging in.
|
||||||
|
* @returns An empty promise that is resolved when the operation is complete
|
||||||
|
*/
|
||||||
|
signInWithEmail: (email: string) => Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a given link is a valid sign in with email, magic link response url.
|
||||||
|
* (i.e, a URL that COULD be from a magic link email)
|
||||||
|
* @param url The url to check
|
||||||
|
* @returns Whether this is valid or not (NOTE: This is just a structural check not whether this is accepted (hence, not async))
|
||||||
|
*/
|
||||||
|
isSignInWithEmailLink: (url: string) => boolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function that validates the magic link redirect and signs in the user
|
||||||
|
*
|
||||||
|
* @param email - Email to log in to
|
||||||
|
* @param url - The action URL which is used to validate login
|
||||||
|
* @returns A promise that resolves with the user info when auth is completed
|
||||||
|
*/
|
||||||
|
signInWithEmailLink: (email: string, url: string) => Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* function that validates the magic link & signs the user in
|
||||||
|
*/
|
||||||
|
processMagicLink: () => Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends email verification email (the checkmark besides the email)
|
||||||
|
* @returns When the check has succeed and completed
|
||||||
|
*/
|
||||||
|
verifyEmailAddress: () => Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs user in with Google.
|
||||||
|
* @returns A promise that resolves with the user info when auth is completed
|
||||||
|
*/
|
||||||
|
signInUserWithGoogle: () => Promise<void>
|
||||||
|
/**
|
||||||
|
* Signs user in with Github.
|
||||||
|
* @returns A promise that resolves with the auth status, giving an opportunity to link if or handle failures
|
||||||
|
*/
|
||||||
|
signInUserWithGithub: () => Promise<GithubSignInResult> | Promise<undefined>
|
||||||
|
/**
|
||||||
|
* Signs user in with Microsoft.
|
||||||
|
* @returns A promise that resolves with the user info when auth is completed
|
||||||
|
*/
|
||||||
|
signInUserWithMicrosoft: () => Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs out the user from auth
|
||||||
|
* @returns An empty promise that is resolved when the operation is complete
|
||||||
|
*/
|
||||||
|
signOutUser: () => Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the email address of the user
|
||||||
|
* @param email The new email to set this to.
|
||||||
|
* @returns An empty promise that is resolved when the operation is complete
|
||||||
|
*/
|
||||||
|
setEmailAddress: (email: string) => Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the display name of the user
|
||||||
|
* @param name The new name to set this to.
|
||||||
|
* @returns An empty promise that is resolved when the operation is complete
|
||||||
|
*/
|
||||||
|
setDisplayName: (name: string) => Promise<void>
|
||||||
|
}
|
||||||
13
packages/hoppscotch-common/src/platform/index.ts
Normal file
13
packages/hoppscotch-common/src/platform/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { AuthPlatformDef } from "./auth"
|
||||||
|
import { UIPlatformDef } from "./ui"
|
||||||
|
|
||||||
|
export type PlatformDef = {
|
||||||
|
ui?: UIPlatformDef
|
||||||
|
auth: AuthPlatformDef
|
||||||
|
}
|
||||||
|
|
||||||
|
export let platform: PlatformDef
|
||||||
|
|
||||||
|
export function setPlatformDef(def: PlatformDef) {
|
||||||
|
platform = def
|
||||||
|
}
|
||||||
8
packages/hoppscotch-common/src/platform/ui.ts
Normal file
8
packages/hoppscotch-common/src/platform/ui.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { Ref } from "vue"
|
||||||
|
|
||||||
|
export type UIPlatformDef = {
|
||||||
|
appHeader?: {
|
||||||
|
paddingTop?: Ref<string>
|
||||||
|
paddingLeft?: Ref<string>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,5 +36,9 @@
|
|||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"src/**/*.tsx",
|
"src/**/*.tsx",
|
||||||
"src/**/*.vue",
|
"src/**/*.vue",
|
||||||
]
|
],
|
||||||
|
"vueCompilerOptions": {
|
||||||
|
"jsxTemplates": true,
|
||||||
|
"experimentalRfc436": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default defineConfig({
|
|||||||
lowerSecondaryStickyFold: "var(--lower-secondary-sticky-fold)",
|
lowerSecondaryStickyFold: "var(--lower-secondary-sticky-fold)",
|
||||||
lowerTertiaryStickyFold: "var(--lower-tertiary-sticky-fold)",
|
lowerTertiaryStickyFold: "var(--lower-tertiary-sticky-fold)",
|
||||||
sidebarPrimaryStickyFold: "var(--sidebar-primary-sticky-fold)",
|
sidebarPrimaryStickyFold: "var(--sidebar-primary-sticky-fold)",
|
||||||
|
sidebarSecondaryStickyFold: "var(--line-height-body)",
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
primary: "var(--primary-color)",
|
primary: "var(--primary-color)",
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import { HstVue } from "@histoire/plugin-vue"
|
|||||||
import { defineConfig } from "histoire"
|
import { defineConfig } from "histoire"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
theme: {
|
||||||
|
title: "Hoppscotch • UI",
|
||||||
|
logo: {
|
||||||
|
square: "/logo.png",
|
||||||
|
light: "/logo.png",
|
||||||
|
dark: "/logo.png",
|
||||||
|
},
|
||||||
|
// logoHref: "https://ui.hoppscotch.io",
|
||||||
|
favicon: 'favicon.ico',
|
||||||
|
},
|
||||||
setupFile: "histoire.setup.ts",
|
setupFile: "histoire.setup.ts",
|
||||||
plugins: [HstVue()],
|
plugins: [HstVue()],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"story:dev": "histoire dev",
|
"story:dev": "histoire dev",
|
||||||
"story:build": "histoire build",
|
"story:build": "histoire build",
|
||||||
"story:preview": "histoire preview"
|
"story:preview": "histoire preview",
|
||||||
|
"do-build-ui": "pnpm run story:build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hoppscotch/vue-toasted": "^0.1.0",
|
"@hoppscotch/vue-toasted": "^0.1.0",
|
||||||
|
|||||||
BIN
packages/hoppscotch-ui/public/favicon.ico
Normal file
BIN
packages/hoppscotch-ui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
packages/hoppscotch-ui/public/logo.png
Normal file
BIN
packages/hoppscotch-ui/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -63,78 +63,82 @@
|
|||||||
</SmartLink>
|
</SmartLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script lang="ts">
|
||||||
defineProps({
|
import { defineComponent } from "vue"
|
||||||
to: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
exact: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
blank: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
label: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: "",
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* This will be a component!
|
|
||||||
*/
|
|
||||||
icon: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* This will be a component!
|
|
||||||
*/
|
|
||||||
svg: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
reverse: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
outline: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
shortcut: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
active: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
activeInfoIcon: {
|
export default defineComponent({
|
||||||
type: Boolean,
|
props: {
|
||||||
default: false,
|
to: {
|
||||||
},
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
exact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
blank: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* This will be a component!
|
||||||
|
*/
|
||||||
|
icon: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* This will be a component!
|
||||||
|
*/
|
||||||
|
svg: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
reverse: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
outline: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
shortcut: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
activeInfoIcon: {
|
||||||
* This will be a component!
|
type: Boolean,
|
||||||
*/
|
default: false,
|
||||||
infoIcon: {
|
},
|
||||||
type: Object,
|
|
||||||
default: null,
|
/**
|
||||||
|
* This will be a component!
|
||||||
|
*/
|
||||||
|
infoIcon: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -106,36 +106,25 @@ import { HoppUIPluginOptions, HOPP_UI_OPTIONS } from "./../../index"
|
|||||||
const { t, onModalOpen, onModalClose } =
|
const { t, onModalOpen, onModalClose } =
|
||||||
inject<HoppUIPluginOptions>(HOPP_UI_OPTIONS) ?? {}
|
inject<HoppUIPluginOptions>(HOPP_UI_OPTIONS) ?? {}
|
||||||
|
|
||||||
defineProps({
|
withDefaults(
|
||||||
dialog: {
|
defineProps<{
|
||||||
type: Boolean,
|
dialog: boolean,
|
||||||
default: false,
|
title: string,
|
||||||
},
|
dimissible: boolean,
|
||||||
title: {
|
placement: string,
|
||||||
type: String,
|
fullWidth: boolean,
|
||||||
default: "",
|
styles: string,
|
||||||
},
|
closeText: string | null,
|
||||||
dimissible: {
|
}>(), {
|
||||||
type: Boolean,
|
dialog: false,
|
||||||
default: true,
|
title: "",
|
||||||
},
|
dimissible: true,
|
||||||
placement: {
|
placement: "top",
|
||||||
type: String,
|
fullWidth: false,
|
||||||
default: "top",
|
styles: "sm:max-w-lg",
|
||||||
},
|
closeText: null
|
||||||
fullWidth: {
|
}
|
||||||
type: Boolean,
|
)
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
styles: {
|
|
||||||
type: String,
|
|
||||||
default: "sm:max-w-lg",
|
|
||||||
},
|
|
||||||
closeText: {
|
|
||||||
type: String,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "close"): void
|
(e: "close"): void
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<icon-lucide-loader class="animate-spin svg-icons" />
|
<icon-lucide-loader class="animate-spin svg-icons" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from "vue"
|
||||||
|
|
||||||
|
export default defineComponent({})
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>Hoppscotch - Open source API development ecosystem</title>
|
<title>Hoppscotch • Open source API development ecosystem</title>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hoppscotch/common": "workspace:^",
|
"@hoppscotch/common": "workspace:^",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
"firebase": "^9.8.4",
|
||||||
"process": "^0.11.10",
|
"process": "^0.11.10",
|
||||||
|
"rxjs": "^7.5.5",
|
||||||
"stream-browserify": "^3.0.0",
|
"stream-browserify": "^3.0.0",
|
||||||
"util": "^0.12.4",
|
"util": "^0.12.4",
|
||||||
"vue": "^3.2.41",
|
"vue": "^3.2.41",
|
||||||
|
|||||||
436
packages/hoppscotch-web/src/firebase/auth.ts
Normal file
436
packages/hoppscotch-web/src/firebase/auth.ts
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import {
|
||||||
|
AuthEvent,
|
||||||
|
AuthPlatformDef,
|
||||||
|
HoppUser,
|
||||||
|
} from "@hoppscotch/common/platform/auth"
|
||||||
|
import {
|
||||||
|
Subscription,
|
||||||
|
BehaviorSubject,
|
||||||
|
Subject,
|
||||||
|
filter,
|
||||||
|
map,
|
||||||
|
combineLatest,
|
||||||
|
} from "rxjs"
|
||||||
|
import {
|
||||||
|
setDoc,
|
||||||
|
onSnapshot,
|
||||||
|
updateDoc,
|
||||||
|
doc,
|
||||||
|
getFirestore,
|
||||||
|
} from "firebase/firestore"
|
||||||
|
import {
|
||||||
|
AuthError,
|
||||||
|
AuthCredential,
|
||||||
|
User as FBUser,
|
||||||
|
sendSignInLinkToEmail,
|
||||||
|
linkWithCredential,
|
||||||
|
getAuth,
|
||||||
|
ActionCodeSettings,
|
||||||
|
isSignInWithEmailLink as isSignInWithEmailLinkFB,
|
||||||
|
signInWithEmailLink as signInWithEmailLinkFB,
|
||||||
|
sendEmailVerification,
|
||||||
|
signInWithPopup,
|
||||||
|
GoogleAuthProvider,
|
||||||
|
GithubAuthProvider,
|
||||||
|
OAuthProvider,
|
||||||
|
fetchSignInMethodsForEmail,
|
||||||
|
updateEmail,
|
||||||
|
updateProfile,
|
||||||
|
reauthenticateWithCredential,
|
||||||
|
onAuthStateChanged,
|
||||||
|
onIdTokenChanged,
|
||||||
|
signOut,
|
||||||
|
} from "firebase/auth"
|
||||||
|
import {
|
||||||
|
getLocalConfig,
|
||||||
|
removeLocalConfig,
|
||||||
|
setLocalConfig,
|
||||||
|
} from "@hoppscotch/common/newstore/localpersistence"
|
||||||
|
|
||||||
|
export const currentUserFB$ = new BehaviorSubject<FBUser | null>(null)
|
||||||
|
export const authEvents$ = new Subject<AuthEvent>()
|
||||||
|
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
|
||||||
|
|
||||||
|
const authIdToken$ = new BehaviorSubject<string | null>(null)
|
||||||
|
|
||||||
|
async function signInWithEmailLink(email: string, url: string) {
|
||||||
|
return await signInWithEmailLinkFB(getAuth(), email, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fbUserToHoppUser(user: FBUser): HoppUser {
|
||||||
|
return {
|
||||||
|
uid: user.uid,
|
||||||
|
displayName: user.displayName,
|
||||||
|
email: user.email,
|
||||||
|
photoURL: user.photoURL,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
|
||||||
|
|
||||||
|
const EMAIL_ACTION_CODE_SETTINGS: ActionCodeSettings = {
|
||||||
|
url: `${import.meta.env.VITE_BASE_URL}/enter`,
|
||||||
|
handleCodeInApp: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInUserWithGithubFB() {
|
||||||
|
return await signInWithPopup(
|
||||||
|
getAuth(),
|
||||||
|
new GithubAuthProvider().addScope("gist")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInUserWithGoogleFB() {
|
||||||
|
return await signInWithPopup(getAuth(), new GoogleAuthProvider())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInUserWithMicrosoftFB() {
|
||||||
|
return await signInWithPopup(getAuth(), new OAuthProvider("microsoft.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reauthenticate the user with the given credential
|
||||||
|
*/
|
||||||
|
async function reauthenticateUser() {
|
||||||
|
if (!currentUserFB$.value || !currentUser$.value)
|
||||||
|
throw new Error("No user has logged in")
|
||||||
|
|
||||||
|
const currentAuthMethod = currentUser$.value.provider
|
||||||
|
|
||||||
|
let credential
|
||||||
|
if (currentAuthMethod === "google.com") {
|
||||||
|
// const result = await signInUserWithGithubFB()
|
||||||
|
const result = await signInUserWithGoogleFB()
|
||||||
|
credential = GithubAuthProvider.credentialFromResult(result)
|
||||||
|
} else if (currentAuthMethod === "github.com") {
|
||||||
|
// const result = await signInUserWithGoogleFB()
|
||||||
|
const result = await signInUserWithGithubFB()
|
||||||
|
credential = GoogleAuthProvider.credentialFromResult(result)
|
||||||
|
} else if (currentAuthMethod === "microsoft.com") {
|
||||||
|
const result = await signInUserWithMicrosoftFB()
|
||||||
|
credential = OAuthProvider.credentialFromResult(result)
|
||||||
|
} else if (currentAuthMethod === "password") {
|
||||||
|
const email = prompt(
|
||||||
|
"Reauthenticate your account using your current email:"
|
||||||
|
)
|
||||||
|
|
||||||
|
await def
|
||||||
|
.signInWithEmail(email as string)
|
||||||
|
.then(() =>
|
||||||
|
alert(
|
||||||
|
`Check your inbox - we sent an email to ${email}. It contains a magic link that will reauthenticate your account.`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
alert(`Error: ${e.message}`)
|
||||||
|
console.error(e)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await reauthenticateWithCredential(
|
||||||
|
currentUserFB$.value,
|
||||||
|
credential as AuthCredential
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("error updating", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Links account with another account given in a auth/account-exists-with-different-credential error
|
||||||
|
*
|
||||||
|
* @param error - Error caught after trying to login
|
||||||
|
*
|
||||||
|
* @returns Promise of UserCredential
|
||||||
|
*/
|
||||||
|
async function linkWithFBCredentialFromAuthError(error: unknown) {
|
||||||
|
// credential is not null since this function is called after an auth/account-exists-with-different-credential error, ie credentials actually exist
|
||||||
|
const credentials = OAuthProvider.credentialFromError(error as AuthError)!
|
||||||
|
|
||||||
|
const otherLinkedProviders = (
|
||||||
|
await getSignInMethodsForEmail((error as AuthError).customData.email!)
|
||||||
|
).filter((providerId) => credentials.providerId !== providerId)
|
||||||
|
|
||||||
|
let user: FBUser | null = null
|
||||||
|
|
||||||
|
if (otherLinkedProviders.indexOf("google.com") >= -1) {
|
||||||
|
user = (await signInUserWithGoogleFB()).user
|
||||||
|
} else if (otherLinkedProviders.indexOf("github.com") >= -1) {
|
||||||
|
user = (await signInUserWithGithubFB()).user
|
||||||
|
} else if (otherLinkedProviders.indexOf("microsoft.com") >= -1) {
|
||||||
|
user = (await signInUserWithMicrosoftFB()).user
|
||||||
|
}
|
||||||
|
|
||||||
|
// user is not null since going through each provider will return a user
|
||||||
|
return await linkWithCredential(user!, credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setProviderInfo(id: string, token: string) {
|
||||||
|
if (!currentUser$.value) throw new Error("No user has logged in")
|
||||||
|
|
||||||
|
const us = {
|
||||||
|
updatedOn: new Date(),
|
||||||
|
provider: id,
|
||||||
|
accessToken: token,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateDoc(doc(getFirestore(), "users", currentUser$.value.uid), us)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("error updating provider info", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSignInMethodsForEmail(email: string) {
|
||||||
|
return await fetchSignInMethodsForEmail(getAuth(), email)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const def: AuthPlatformDef = {
|
||||||
|
getCurrentUserStream: () => currentUser$,
|
||||||
|
getAuthEventsStream: () => authEvents$,
|
||||||
|
getProbableUserStream: () => probableUser$,
|
||||||
|
|
||||||
|
getCurrentUser: () => currentUser$.value,
|
||||||
|
getProbableUser: () => probableUser$.value,
|
||||||
|
|
||||||
|
getBackendHeaders() {
|
||||||
|
return {
|
||||||
|
authorization: `Bearer ${authIdToken$.value}`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
willBackendHaveAuthError() {
|
||||||
|
return !authIdToken$.value
|
||||||
|
},
|
||||||
|
onBackendGQLClientShouldReconnect(func) {
|
||||||
|
authIdToken$.subscribe(() => {
|
||||||
|
func()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
getDevOptsBackendIDToken() {
|
||||||
|
return authIdToken$.value
|
||||||
|
},
|
||||||
|
performAuthInit() {
|
||||||
|
// todo: implement
|
||||||
|
const auth = getAuth()
|
||||||
|
const firestore = getFirestore()
|
||||||
|
|
||||||
|
combineLatest([currentUserFB$, authIdToken$])
|
||||||
|
.pipe(
|
||||||
|
map(([user, token]) => {
|
||||||
|
// If there is no auth token, we will just consider as the auth as not complete
|
||||||
|
if (token === null) return null
|
||||||
|
if (user !== null) return fbUserToHoppUser(user)
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((x) => {
|
||||||
|
currentUser$.next(x)
|
||||||
|
})
|
||||||
|
|
||||||
|
let extraSnapshotStop: (() => void) | null = null
|
||||||
|
|
||||||
|
probableUser$.next(JSON.parse(getLocalConfig("login_state") ?? "null"))
|
||||||
|
|
||||||
|
onAuthStateChanged(auth, (user) => {
|
||||||
|
const wasLoggedIn = currentUser$.value !== null
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
probableUser$.next(user)
|
||||||
|
} else {
|
||||||
|
probableUser$.next(null)
|
||||||
|
removeLocalConfig("login_state")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user && extraSnapshotStop) {
|
||||||
|
extraSnapshotStop()
|
||||||
|
extraSnapshotStop = null
|
||||||
|
} else if (user) {
|
||||||
|
// Merge all the user info from all the authenticated providers
|
||||||
|
user.providerData.forEach((profile) => {
|
||||||
|
if (!profile) return
|
||||||
|
|
||||||
|
const us = {
|
||||||
|
updatedOn: new Date(),
|
||||||
|
provider: profile.providerId,
|
||||||
|
name: profile.displayName,
|
||||||
|
email: profile.email,
|
||||||
|
photoUrl: profile.photoURL,
|
||||||
|
uid: profile.uid,
|
||||||
|
}
|
||||||
|
|
||||||
|
setDoc(doc(firestore, "users", user.uid), us, { merge: true }).catch(
|
||||||
|
(e) => console.error("error updating", us, e)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
extraSnapshotStop = onSnapshot(
|
||||||
|
doc(firestore, "users", user.uid),
|
||||||
|
(doc) => {
|
||||||
|
const data = doc.data()
|
||||||
|
|
||||||
|
const userUpdate: HoppUser = fbUserToHoppUser(user)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
// Write extra provider data
|
||||||
|
userUpdate.provider = data.provider
|
||||||
|
userUpdate.accessToken = data.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser$.next(userUpdate)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUserFB$.next(user)
|
||||||
|
currentUser$.next(user === null ? null : fbUserToHoppUser(user))
|
||||||
|
|
||||||
|
// User wasn't found before, but now is there (login happened)
|
||||||
|
if (!wasLoggedIn && user) {
|
||||||
|
authEvents$.next({
|
||||||
|
event: "login",
|
||||||
|
user: currentUser$.value!,
|
||||||
|
})
|
||||||
|
} else if (wasLoggedIn && !user) {
|
||||||
|
// User was found before, but now is not there (logout happened)
|
||||||
|
authEvents$.next({
|
||||||
|
event: "logout",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onIdTokenChanged(auth, async (user) => {
|
||||||
|
if (user) {
|
||||||
|
authIdToken$.next(await user.getIdToken())
|
||||||
|
|
||||||
|
setLocalConfig("login_state", JSON.stringify(user))
|
||||||
|
} else {
|
||||||
|
authIdToken$.next(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
waitProbableLoginToConfirm() {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (authIdToken$.value) resolve()
|
||||||
|
|
||||||
|
if (!probableUser$.value) reject(new Error("no_probable_user"))
|
||||||
|
|
||||||
|
let sub: Subscription | null = null
|
||||||
|
sub = authIdToken$.pipe(filter((token) => !!token)).subscribe(() => {
|
||||||
|
sub?.unsubscribe()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async signInWithEmail(email: string) {
|
||||||
|
return await sendSignInLinkToEmail(
|
||||||
|
getAuth(),
|
||||||
|
email,
|
||||||
|
EMAIL_ACTION_CODE_SETTINGS
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
isSignInWithEmailLink(url: string) {
|
||||||
|
return isSignInWithEmailLinkFB(getAuth(), url)
|
||||||
|
},
|
||||||
|
|
||||||
|
async verifyEmailAddress() {
|
||||||
|
if (!currentUserFB$.value) throw new Error("No user has logged in")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmailVerification(currentUserFB$.value)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("error verifying email address", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async signInUserWithGoogle() {
|
||||||
|
await signInUserWithGoogleFB()
|
||||||
|
},
|
||||||
|
async signInUserWithGithub() {
|
||||||
|
try {
|
||||||
|
const cred = await signInUserWithGithubFB()
|
||||||
|
const oAuthCred = GithubAuthProvider.credentialFromResult(cred)!
|
||||||
|
const token = oAuthCred.accessToken
|
||||||
|
await setProviderInfo(cred.providerId!, token!)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "success",
|
||||||
|
user: fbUserToHoppUser(cred.user),
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("error while logging in with github", e)
|
||||||
|
|
||||||
|
if ((e as any).code === "auth/account-exists-with-different-credential") {
|
||||||
|
return {
|
||||||
|
type: "account-exists-with-different-cred",
|
||||||
|
link: async () => {
|
||||||
|
await linkWithFBCredentialFromAuthError(e)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
err: e,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async signInUserWithMicrosoft() {
|
||||||
|
await signInUserWithMicrosoftFB()
|
||||||
|
},
|
||||||
|
async signInWithEmailLink(email: string, url: string) {
|
||||||
|
await signInWithEmailLinkFB(getAuth(), email, url)
|
||||||
|
},
|
||||||
|
async setEmailAddress(email: string) {
|
||||||
|
if (!currentUserFB$.value) throw new Error("No user has logged in")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateEmail(currentUserFB$.value, email)
|
||||||
|
} catch (e) {
|
||||||
|
await reauthenticateUser()
|
||||||
|
console.log("error setting email address", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setDisplayName(name: string) {
|
||||||
|
if (!currentUserFB$.value) throw new Error("No user has logged in")
|
||||||
|
|
||||||
|
const us = {
|
||||||
|
displayName: name,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateProfile(currentUserFB$.value, us)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("error updating display name", e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async signOutUser() {
|
||||||
|
if (!currentUser$.value) throw new Error("No user has logged in")
|
||||||
|
|
||||||
|
await signOut(getAuth())
|
||||||
|
},
|
||||||
|
async processMagicLink() {
|
||||||
|
if (this.isSignInWithEmailLink(window.location.href)) {
|
||||||
|
let email = getLocalConfig("emailForSignIn")
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
email = window.prompt(
|
||||||
|
"Please provide your email for confirmation"
|
||||||
|
) as string
|
||||||
|
}
|
||||||
|
|
||||||
|
await signInWithEmailLink(email, window.location.href)
|
||||||
|
|
||||||
|
removeLocalConfig("emailForSignIn")
|
||||||
|
window.location.href = "/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
import { createHoppApp } from "@hoppscotch/common"
|
import { createHoppApp } from "@hoppscotch/common"
|
||||||
|
import { def as authDef } from "./firebase/auth"
|
||||||
|
|
||||||
createHoppApp("#app", {})
|
createHoppApp("#app", {
|
||||||
|
auth: authDef,
|
||||||
|
})
|
||||||
|
|||||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
@@ -673,7 +673,9 @@ importers:
|
|||||||
eslint: ^8.28.0
|
eslint: ^8.28.0
|
||||||
eslint-plugin-prettier: ^4.2.1
|
eslint-plugin-prettier: ^4.2.1
|
||||||
eslint-plugin-vue: ^9.5.1
|
eslint-plugin-vue: ^9.5.1
|
||||||
|
firebase: ^9.8.4
|
||||||
process: ^0.11.10
|
process: ^0.11.10
|
||||||
|
rxjs: ^7.5.5
|
||||||
stream-browserify: ^3.0.0
|
stream-browserify: ^3.0.0
|
||||||
typescript: ^4.6.4
|
typescript: ^4.6.4
|
||||||
unplugin-icons: ^0.14.9
|
unplugin-icons: ^0.14.9
|
||||||
@@ -696,7 +698,9 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@hoppscotch/common': link:../hoppscotch-common
|
'@hoppscotch/common': link:../hoppscotch-common
|
||||||
buffer: 6.0.3
|
buffer: 6.0.3
|
||||||
|
firebase: 9.8.4
|
||||||
process: 0.11.10
|
process: 0.11.10
|
||||||
|
rxjs: 7.5.5
|
||||||
stream-browserify: 3.0.0
|
stream-browserify: 3.0.0
|
||||||
util: 0.12.4
|
util: 0.12.4
|
||||||
vue: 3.2.45
|
vue: 3.2.45
|
||||||
@@ -7080,7 +7084,7 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/after/0.8.2:
|
/after/0.8.2:
|
||||||
resolution: {integrity: sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=}
|
resolution: {integrity: sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/agent-base/6.0.2:
|
/agent-base/6.0.2:
|
||||||
@@ -7670,7 +7674,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
/base64-arraybuffer/0.1.4:
|
/base64-arraybuffer/0.1.4:
|
||||||
resolution: {integrity: sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=}
|
resolution: {integrity: sha512-a1eIFi4R9ySrbiMuyTGx5e92uRH5tQY6kArNcFaKBUleIoLjdjBg7Zxm3Mqm3Kmkf27HLR/1fnxX9q8GQ7Iavg==}
|
||||||
engines: {node: '>= 0.6.0'}
|
engines: {node: '>= 0.6.0'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
@@ -8187,14 +8191,14 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/component-bind/1.0.0:
|
/component-bind/1.0.0:
|
||||||
resolution: {integrity: sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=}
|
resolution: {integrity: sha512-WZveuKPeKAG9qY+FkYDeADzdHyTYdIboXS59ixDeRJL5ZhxpqUnxSOwop4FQjMsiYm3/Or8cegVbpAHNA7pHxw==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/component-emitter/1.3.0:
|
/component-emitter/1.3.0:
|
||||||
resolution: {integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==}
|
resolution: {integrity: sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==}
|
||||||
|
|
||||||
/component-inherit/0.0.3:
|
/component-inherit/0.0.3:
|
||||||
resolution: {integrity: sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=}
|
resolution: {integrity: sha512-w+LhYREhatpVqTESyGFg3NlP6Iu0kEKUHETY9GoZP/pQyW4mHFZuFWRUCIqVPZ36ueVLtoOEZaAqbCF2RDndaA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/concat-map/0.0.1:
|
/concat-map/0.0.1:
|
||||||
@@ -11014,7 +11018,7 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/has-cors/1.1.0:
|
/has-cors/1.1.0:
|
||||||
resolution: {integrity: sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=}
|
resolution: {integrity: sha512-g5VNKdkFuUuVCP9gYfDJHjK2nqdQJ7aDLTnycnc2+RvsOQbuLdF5pm7vuE5J76SEBIQjs4kQY/BWq74JUmjbXA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/has-flag/3.0.0:
|
/has-flag/3.0.0:
|
||||||
@@ -11336,7 +11340,7 @@ packages:
|
|||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
/indexof/0.0.1:
|
/indexof/0.0.1:
|
||||||
resolution: {integrity: sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=}
|
resolution: {integrity: sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/inflight/1.0.6:
|
/inflight/1.0.6:
|
||||||
@@ -15925,7 +15929,7 @@ packages:
|
|||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/to-array/0.1.4:
|
/to-array/0.1.4:
|
||||||
resolution: {integrity: sha1-F+bBH3PdTz10zaek/zI46a2b+JA=}
|
resolution: {integrity: sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/to-fast-properties/2.0.0:
|
/to-fast-properties/2.0.0:
|
||||||
@@ -18288,7 +18292,7 @@ packages:
|
|||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/yeast/0.1.2:
|
/yeast/0.1.2:
|
||||||
resolution: {integrity: sha1-AI4G2AlDIMNy28L47XagymyKxBk=}
|
resolution: {integrity: sha512-8HFIh676uyGYP6wP13R/j6OJ/1HwJ46snpvzE7aHAN3Ryqh2yX6Xox2B4CUmTwwOIzlG3Bs7ocsP5dZH/R1Qbg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/yn/3.1.1:
|
/yn/3.1.1:
|
||||||
|
|||||||
Reference in New Issue
Block a user