Merge remote-tracking branch 'hoppscotch/hoppscotch/main'
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hoppscotch/cli",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
|
||||
"homepage": "https://hoppscotch.io",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -5,42 +5,52 @@ import { execAsync, getErrorCode, getTestJsonFilePath } from "../utils";
|
||||
describe("Test 'hopp test <file>' command:", () => {
|
||||
test("No collection file path provided.", async () => {
|
||||
const cmd = `node ./bin/hopp test`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
const out = getErrorCode(stdout);
|
||||
const { stderr } = await execAsync(cmd);
|
||||
const out = getErrorCode(stderr);
|
||||
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Collection file not found.", async () => {
|
||||
const cmd = `node ./bin/hopp test notfound.json`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
const out = getErrorCode(stdout);
|
||||
const { stderr } = await execAsync(cmd);
|
||||
const out = getErrorCode(stderr);
|
||||
|
||||
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(
|
||||
"malformed-collection.json"
|
||||
)}`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
const out = getErrorCode(stdout);
|
||||
const { stderr } = await execAsync(cmd);
|
||||
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");
|
||||
});
|
||||
|
||||
test("Invalid arguement.", async () => {
|
||||
const cmd = `node ./bin/hopp invalid-arg`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
const out = getErrorCode(stdout);
|
||||
const { stderr } = await execAsync(cmd);
|
||||
const out = getErrorCode(stderr);
|
||||
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Collection file not JSON type.", async () => {
|
||||
const cmd = `node ./bin/hopp test ${getTestJsonFilePath("notjson.txt")}`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
const out = getErrorCode(stdout);
|
||||
const { stderr } = await execAsync(cmd);
|
||||
const out = getErrorCode(stderr);
|
||||
|
||||
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 () => {
|
||||
const cmd = `${VALID_TEST_CMD} --env`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
const out = getErrorCode(stdout);
|
||||
const { stderr } = await execAsync(cmd);
|
||||
const out = getErrorCode(stderr);
|
||||
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("ENV file not JSON type.", async () => {
|
||||
const cmd = `${VALID_TEST_CMD} --env ${getTestJsonFilePath("notjson.txt")}`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
const out = getErrorCode(stdout);
|
||||
const { stderr } = await execAsync(cmd);
|
||||
const out = getErrorCode(stderr);
|
||||
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_FILE_TYPE");
|
||||
});
|
||||
|
||||
test("ENV file not found.", async () => {
|
||||
const cmd = `${VALID_TEST_CMD} --env notfound.json`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
const out = getErrorCode(stdout);
|
||||
const { stderr } = await execAsync(cmd);
|
||||
const out = getErrorCode(stderr);
|
||||
|
||||
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 () => {
|
||||
const cmd = `${VALID_TEST_CMD} --delay`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
const out = getErrorCode(stdout);
|
||||
const { stderr } = await execAsync(cmd);
|
||||
const out = getErrorCode(stderr);
|
||||
|
||||
expect(out).toBe<HoppErrorCode>("INVALID_ARGUMENT");
|
||||
});
|
||||
|
||||
test("Invalid value passed to delay flag.", async () => {
|
||||
const cmd = `${VALID_TEST_CMD} --delay 'NaN'`;
|
||||
const { stdout } = await execAsync(cmd);
|
||||
const out = getErrorCode(stdout);
|
||||
|
||||
const { stderr } = await execAsync(cmd);
|
||||
const out = getErrorCode(stderr);
|
||||
console.log("invalid value thing", out)
|
||||
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.", () => {
|
||||
return expect(
|
||||
collectionsRunner({ collections: [], envs: SAMPLE_ENVS })()
|
||||
collectionsRunner({ collections: [], envs: SAMPLE_ENVS })
|
||||
).resolves.toStrictEqual([]);
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("collectionsRunner", () => {
|
||||
},
|
||||
],
|
||||
envs: SAMPLE_ENVS,
|
||||
})()
|
||||
})
|
||||
).resolves.toMatchObject([]);
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("collectionsRunner", () => {
|
||||
},
|
||||
],
|
||||
envs: SAMPLE_ENVS,
|
||||
})()
|
||||
})
|
||||
).resolves.toMatchObject([
|
||||
{
|
||||
path: "collection/request",
|
||||
@@ -116,7 +116,7 @@ describe("collectionsRunner", () => {
|
||||
},
|
||||
],
|
||||
envs: SAMPLE_ENVS,
|
||||
})()
|
||||
})
|
||||
).resolves.toMatchObject([
|
||||
{
|
||||
path: "collection/folder/request",
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { HoppCLIError } from "../../../types/errors";
|
||||
import { parseCollectionData } from "../../../utils/mutators";
|
||||
|
||||
import "@relmify/jest-fp-ts";
|
||||
|
||||
describe("parseCollectionData", () => {
|
||||
test("Reading non-existing file.", () => {
|
||||
return expect(
|
||||
parseCollectionData("./src/__tests__/samples/notexist.json")()
|
||||
).resolves.toSubsetEqualLeft(<HoppCLIError>{
|
||||
parseCollectionData("./src/__tests__/samples/notexist.json")
|
||||
).rejects.toMatchObject(<HoppCLIError>{
|
||||
code: "FILE_NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
test("Unparseable JSON contents.", () => {
|
||||
return expect(
|
||||
parseCollectionData("./src/__tests__/samples/malformed-collection.json")()
|
||||
).resolves.toSubsetEqualLeft(<HoppCLIError>{
|
||||
code: "MALFORMED_COLLECTION",
|
||||
parseCollectionData("./src/__tests__/samples/malformed-collection.json")
|
||||
).rejects.toMatchObject(<HoppCLIError>{
|
||||
code: "UNKNOWN_ERROR",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,15 +22,15 @@ describe("parseCollectionData", () => {
|
||||
return expect(
|
||||
parseCollectionData(
|
||||
"./src/__tests__/samples/malformed-collection2.json"
|
||||
)()
|
||||
).resolves.toSubsetEqualLeft(<HoppCLIError>{
|
||||
)
|
||||
).rejects.toMatchObject(<HoppCLIError>{
|
||||
code: "MALFORMED_COLLECTION",
|
||||
});
|
||||
});
|
||||
|
||||
test("Valid HoppCollection.", () => {
|
||||
return expect(
|
||||
parseCollectionData("./src/__tests__/samples/passes.json")()
|
||||
).resolves.toBeRight();
|
||||
parseCollectionData("./src/__tests__/samples/passes.json")
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as TE from "fp-ts/TaskEither";
|
||||
import { pipe, flow } from "fp-ts/function";
|
||||
import {
|
||||
collectionsRunner,
|
||||
collectionsRunnerExit,
|
||||
@@ -10,18 +8,23 @@ import { parseCollectionData } from "../utils/mutators";
|
||||
import { parseEnvsData } from "../options/test/env";
|
||||
import { TestCmdOptions } from "../types/commands";
|
||||
import { parseDelayOption } from "../options/test/delay";
|
||||
import { HoppEnvs } from "../types/request";
|
||||
import { isHoppCLIError } from "../utils/checks";
|
||||
|
||||
export const test = (path: string, options: TestCmdOptions) => async () => {
|
||||
await pipe(
|
||||
TE.Do,
|
||||
TE.bind("envs", () => parseEnvsData(options.env)),
|
||||
TE.bind("collections", () => parseCollectionData(path)),
|
||||
TE.bind("delay", () => parseDelayOption(options.delay)),
|
||||
TE.chainTaskK(collectionsRunner),
|
||||
TE.chainW(flow(collectionsRunnerResult, collectionsRunnerExit, TE.of)),
|
||||
TE.mapLeft((e) => {
|
||||
handleError(e);
|
||||
try {
|
||||
const delay = options.delay ? parseDelayOption(options.delay) : 0
|
||||
const envs = options.env ? await parseEnvsData(options.env) : <HoppEnvs>{ global: [], selected: [] }
|
||||
const collections = await parseCollectionData(path)
|
||||
|
||||
const report = await collectionsRunner({collections, envs, delay})
|
||||
const hasSucceeded = collectionsRunnerResult(report)
|
||||
collectionsRunnerExit(hasSucceeded)
|
||||
} catch(e) {
|
||||
if(isHoppCLIError(e)) {
|
||||
handleError(e)
|
||||
process.exit(1);
|
||||
})
|
||||
)();
|
||||
}
|
||||
else throw e
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { log } from "console";
|
||||
import * as S from "fp-ts/string";
|
||||
import { HoppError, HoppErrorCode } from "../types/errors";
|
||||
import { hasProperty, isSafeCommanderError } from "../utils/checks";
|
||||
@@ -7,7 +6,7 @@ import { exceptionColors } from "../utils/getters";
|
||||
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.
|
||||
* @param e Error data to parse.
|
||||
* @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)) {
|
||||
log(ERROR_CODE, ERROR_MSG);
|
||||
console.error(ERROR_CODE, ERROR_MSG);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,20 +1,14 @@
|
||||
import * as TE from "fp-ts/TaskEither";
|
||||
import * as S from "fp-ts/string";
|
||||
import { pipe } from "fp-ts/function";
|
||||
import { error, HoppCLIError } from "../../types/errors";
|
||||
import { error } from "../../types/errors";
|
||||
|
||||
export const parseDelayOption = (
|
||||
delay: unknown
|
||||
): TE.TaskEither<HoppCLIError, number> =>
|
||||
!S.isString(delay)
|
||||
? TE.right(0)
|
||||
: pipe(
|
||||
delay,
|
||||
Number,
|
||||
TE.fromPredicate(Number.isSafeInteger, () =>
|
||||
error({
|
||||
code: "INVALID_ARGUMENT",
|
||||
data: "Expected '-d, --delay' value to be number",
|
||||
})
|
||||
)
|
||||
);
|
||||
export function parseDelayOption(delay: string): number {
|
||||
const maybeInt = Number.parseInt(delay)
|
||||
|
||||
if(!Number.isNaN(maybeInt)) {
|
||||
return maybeInt
|
||||
} else {
|
||||
throw error({
|
||||
code: "INVALID_ARGUMENT",
|
||||
data: "Expected '-d, --delay' value to be number",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,27 @@
|
||||
import fs from "fs/promises";
|
||||
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 { error } from "../../types/errors";
|
||||
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.
|
||||
* @param path Path of env.json file to be parsed.
|
||||
* @returns For successful parsing we get HoppEnvs object.
|
||||
*/
|
||||
export const parseEnvsData = (
|
||||
path: unknown
|
||||
): TE.TaskEither<HoppCLIError, HoppEnvs> =>
|
||||
!S.isString(path)
|
||||
? TE.right({ global: [], selected: [] })
|
||||
: pipe(
|
||||
// Checking if the env.json file exists or not.
|
||||
checkFile(path),
|
||||
export async function parseEnvsData(path: string) {
|
||||
const contents = await readJsonFile(path)
|
||||
|
||||
// Trying to read given env json file path.
|
||||
TE.chainW((checkedPath) =>
|
||||
TE.tryCatch(
|
||||
() => fs.readFile(checkedPath),
|
||||
(reason) =>
|
||||
error({ code: "UNKNOWN_ERROR", data: E.toError(reason) })
|
||||
)
|
||||
),
|
||||
if(!(contents && typeof contents === "object" && !Array.isArray(contents))) {
|
||||
throw error({ code: "MALFORMED_ENV_FILE", path, data: null })
|
||||
}
|
||||
|
||||
// Trying to JSON parse the read file data and mapping the entries to HoppEnvPairs.
|
||||
TE.chainEitherKW((data) =>
|
||||
pipe(
|
||||
data.toString(),
|
||||
J.parse,
|
||||
E.map((jsonData) =>
|
||||
jsonData && typeof jsonData === "object" && !isArray(jsonData)
|
||||
? pipe(
|
||||
jsonData,
|
||||
Object.entries,
|
||||
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) })
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
const envPairs: Array<HoppEnvPair> = []
|
||||
|
||||
for( const [key,value] of Object.entries(contents)) {
|
||||
if(typeof value !== "string") {
|
||||
throw error({ code: "MALFORMED_ENV_FILE", path, data: {value: value} })
|
||||
}
|
||||
|
||||
envPairs.push({key, value})
|
||||
}
|
||||
return <HoppEnvs>{ global: [], selected: envPairs }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export type TestCmdOptions = {
|
||||
env: string;
|
||||
delay: number;
|
||||
env: string | undefined;
|
||||
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 {
|
||||
HoppCollection,
|
||||
HoppRESTRequest,
|
||||
isHoppRESTRequest,
|
||||
} from "@hoppscotch/data";
|
||||
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 { error, HoppCLIError, HoppErrnoException } from "../types/errors";
|
||||
import { HoppCollectionFileExt } from "../types/collections";
|
||||
import { HoppEnvFileExt } from "../types/commands";
|
||||
import { HoppCLIError, HoppErrnoException } from "../types/errors";
|
||||
|
||||
/**
|
||||
* Determines whether an object has a property with given name.
|
||||
@@ -71,59 +62,6 @@ export const isRESTCollection = (
|
||||
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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as T from "fp-ts/Task";
|
||||
import * as A from "fp-ts/Array";
|
||||
import { pipe } from "fp-ts/function";
|
||||
import { bold } from "chalk";
|
||||
@@ -43,8 +42,8 @@ const { WARN, FAIL } = exceptionColors;
|
||||
* @returns List of report for each processed request.
|
||||
*/
|
||||
export const collectionsRunner =
|
||||
(param: CollectionRunnerParam): T.Task<RequestReport[]> =>
|
||||
async () => {
|
||||
async (param: CollectionRunnerParam): Promise<RequestReport[]> =>
|
||||
{
|
||||
const envs: HoppEnvs = param.envs;
|
||||
const delay = param.delay ?? 0;
|
||||
const requestsReport: RequestReport[] = [];
|
||||
@@ -213,10 +212,10 @@ export const collectionsRunnerResult = (
|
||||
* Else, exit with code 1.
|
||||
* @param result Boolean defining the collections-runner result.
|
||||
*/
|
||||
export const collectionsRunnerExit = (result: boolean) => {
|
||||
export const collectionsRunnerExit = (result: boolean): never => {
|
||||
if (!result) {
|
||||
const EXIT_MSG = FAIL(`\nExited with code 1`);
|
||||
process.stdout.write(EXIT_MSG);
|
||||
process.stderr.write(EXIT_MSG);
|
||||
process.exit(1);
|
||||
}
|
||||
process.exit(0);
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
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 { error, HoppCLIError } from "../types/errors";
|
||||
import { isRESTCollection, isHoppErrnoException, checkFile } from "./checks";
|
||||
import { error } from "../types/errors";
|
||||
import { isRESTCollection, isHoppErrnoException } from "./checks";
|
||||
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
|
||||
|
||||
/**
|
||||
@@ -39,49 +34,44 @@ export const parseErrorMessage = (e: unknown) => {
|
||||
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
|
||||
* the parsed collectiona array.
|
||||
* @param path Collection json file path.
|
||||
* @returns For successful parsing we get array of HoppCollection<HoppRESTRequest>,
|
||||
*/
|
||||
export const parseCollectionData = (
|
||||
export async function parseCollectionData(
|
||||
path: string
|
||||
): TE.TaskEither<HoppCLIError, HoppCollection<HoppRESTRequest>[]> =>
|
||||
pipe(
|
||||
TE.of(path),
|
||||
): Promise<HoppCollection<HoppRESTRequest>[]> {
|
||||
let contents = await readJsonFile(path)
|
||||
|
||||
// Checking if given file path exists or not.
|
||||
TE.chain(checkFile),
|
||||
const maybeArrayOfCollections: unknown[] = Array.isArray(contents) ? contents : [contents]
|
||||
|
||||
// Trying to read give collection json path.
|
||||
TE.chainW((checkedPath) =>
|
||||
TE.tryCatch(
|
||||
() => fs.readFile(checkedPath),
|
||||
(reason) => error({ code: "UNKNOWN_ERROR", data: E.toError(reason) })
|
||||
)
|
||||
),
|
||||
if(maybeArrayOfCollections.some((x) => !isRESTCollection(x))) {
|
||||
throw error({
|
||||
code: "MALFORMED_COLLECTION",
|
||||
path,
|
||||
data: "Please check the collection data.",
|
||||
})
|
||||
}
|
||||
|
||||
// Checking if parsed file data is array.
|
||||
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.",
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
return maybeArrayOfCollections as HoppCollection<HoppRESTRequest>[]
|
||||
};
|
||||
|
||||
@@ -391,6 +391,7 @@
|
||||
"copy_link": "Copy link",
|
||||
"duration": "Duration",
|
||||
"enter_curl": "Enter cURL command",
|
||||
"duplicated": "Request duplicated",
|
||||
"generate_code": "Generate code",
|
||||
"generated_code": "Generated code",
|
||||
"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']
|
||||
CollectionsAddFolder: typeof import('./components/collections/AddFolder.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']
|
||||
CollectionsEditFolder: typeof import('./components/collections/EditFolder.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']
|
||||
CollectionsGraphqlRequest: typeof import('./components/collections/graphql/Request.vue')['default']
|
||||
CollectionsImportExport: typeof import('./components/collections/ImportExport.vue')['default']
|
||||
CollectionsMyCollection: typeof import('./components/collections/my/Collection.vue')['default']
|
||||
CollectionsMyFolder: typeof import('./components/collections/my/Folder.vue')['default']
|
||||
CollectionsMyRequest: typeof import('./components/collections/my/Request.vue')['default']
|
||||
CollectionsMyCollections: typeof import('./components/collections/MyCollections.vue')['default']
|
||||
CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
|
||||
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
|
||||
CollectionsTeamsCollection: typeof import('./components/collections/teams/Collection.vue')['default']
|
||||
CollectionsTeamsFolder: typeof import('./components/collections/teams/Folder.vue')['default']
|
||||
CollectionsTeamsRequest: typeof import('./components/collections/teams/Request.vue')['default']
|
||||
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
|
||||
CollectionsTeamSelect: typeof import('./components/collections/TeamSelect.vue')['default']
|
||||
Environments: typeof import('./components/environments/index.vue')['default']
|
||||
EnvironmentsChooseType: typeof import('./components/environments/ChooseType.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']
|
||||
SmartTabs: typeof import('./../../hoppscotch-ui/src/components/smart/Tabs.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']
|
||||
SmartWindows: typeof import('./../../hoppscotch-ui/src/components/smart/Windows.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 { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { authIdToken$ } from "~/helpers/fb/auth"
|
||||
|
||||
const userAuthToken = useReadonlyStream(authIdToken$, null)
|
||||
import { platform } from "~/platform"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
@@ -53,8 +50,9 @@ const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
|
||||
|
||||
// Copy user auth token to clipboard
|
||||
const copyUserAuthToken = () => {
|
||||
if (userAuthToken.value) {
|
||||
copyToClipboard(userAuthToken.value)
|
||||
const token = platform.auth.getDevOptsBackendIDToken()
|
||||
if (token) {
|
||||
copyToClipboard(token)
|
||||
copyIcon.value = IconCheck
|
||||
toast.success(`${t("state.copied_to_clipboard")}`)
|
||||
} else {
|
||||
|
||||
@@ -219,7 +219,7 @@ import { showChat } from "@modules/crisp"
|
||||
import { useSetting } from "@composables/settings"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
import { TippyComponent } from "vue-tippy"
|
||||
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
|
||||
import { invokeAction } from "@helpers/actions"
|
||||
@@ -236,7 +236,10 @@ const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
|
||||
|
||||
const navigatorShare = !!navigator.share
|
||||
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
const currentUser = useReadonlyStream(
|
||||
platform.auth.getCurrentUserStream(),
|
||||
platform.auth.getCurrentUser()
|
||||
)
|
||||
|
||||
watch(
|
||||
() => ZEN_MODE.value,
|
||||
|
||||
@@ -171,11 +171,10 @@ import IconUploadCloud from "~icons/lucide/upload-cloud"
|
||||
import IconUserPlus from "~icons/lucide/user-plus"
|
||||
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
|
||||
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
|
||||
import { probableUser$ } from "@helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { invokeAction } from "@helpers/actions"
|
||||
import { platform } from "~/index"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
@@ -194,7 +193,10 @@ const mdAndLarger = breakpoints.greater("md")
|
||||
|
||||
const network = reactive(useNetwork())
|
||||
|
||||
const currentUser = useReadonlyStream(probableUser$, null)
|
||||
const currentUser = useReadonlyStream(
|
||||
platform.auth.getProbableUserStream(),
|
||||
platform.auth.getProbableUser()
|
||||
)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
|
||||
@@ -41,47 +41,52 @@
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
<script setup lang="ts">
|
||||
import { watch, ref } from "vue"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["submit", "hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
loadingState: boolean
|
||||
}>(),
|
||||
{
|
||||
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,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(isShowing: boolean) {
|
||||
if (!isShowing) {
|
||||
this.name = null
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addNewCollection() {
|
||||
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")
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const addNewCollection = () => {
|
||||
if (!name.value) {
|
||||
toast.error(t("collection.invalid_name"))
|
||||
return
|
||||
}
|
||||
|
||||
emit("submit", name.value)
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
name.value = ""
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('folder.new')"
|
||||
@close="$emit('hide-modal')"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
@@ -41,52 +41,51 @@
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
folder: { type: Object, default: () => ({}) },
|
||||
folderPath: { type: String, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["hide-modal", "add-folder"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
loadingState: boolean
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
loadingState: false,
|
||||
}
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(isShowing: boolean) {
|
||||
if (!isShowing) this.name = null
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addFolder() {
|
||||
if (!this.name) {
|
||||
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")
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const addFolder = () => {
|
||||
if (name.value.trim() === "") {
|
||||
toast.error(t("folder.invalid_name"))
|
||||
return
|
||||
}
|
||||
emit("add-folder", name.value)
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
name.value = ""
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,23 +48,20 @@ import { getRESTRequest } from "~/newstore/RESTSession"
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
loadingState: boolean
|
||||
folder?: object
|
||||
folderPath?: string
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
loadingState: boolean
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
loadingState: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(
|
||||
e: "add-request",
|
||||
v: {
|
||||
name: string
|
||||
folder: object | undefined
|
||||
path: string | undefined
|
||||
}
|
||||
): void
|
||||
(event: "hide-modal"): void
|
||||
(event: "add-request", name: string): void
|
||||
}>()
|
||||
|
||||
const name = ref("")
|
||||
@@ -79,15 +76,11 @@ watch(
|
||||
)
|
||||
|
||||
const addRequest = () => {
|
||||
if (!name.value) {
|
||||
if (name.value.trim() === "") {
|
||||
toast.error(`${t("error.empty_req_name")}`)
|
||||
return
|
||||
}
|
||||
emit("add-request", {
|
||||
name: name.value,
|
||||
folder: props.folder,
|
||||
path: props.folderPath,
|
||||
})
|
||||
emit("add-request", name.value)
|
||||
}
|
||||
|
||||
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>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
editingCollectionName: { type: String, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["submit", "hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
toast: useToast(),
|
||||
t: useI18n(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: null,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
editingCollectionName(val) {
|
||||
this.name = val
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveCollection() {
|
||||
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")
|
||||
},
|
||||
},
|
||||
})
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
loadingState: boolean
|
||||
editingCollectionName: string
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
loadingState: false,
|
||||
editingCollectionName: "",
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "submit", name: string): void
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const name = ref("")
|
||||
|
||||
watch(
|
||||
() => props.editingCollectionName,
|
||||
(newName) => {
|
||||
name.value = newName
|
||||
}
|
||||
)
|
||||
|
||||
const saveCollection = () => {
|
||||
if (name.value.trim() === "") {
|
||||
toast.error(t("collection.invalid_name"))
|
||||
return
|
||||
}
|
||||
|
||||
emit("submit", name.value)
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
name.value = ""
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('folder.edit')"
|
||||
@close="$emit('hide-modal')"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col">
|
||||
@@ -41,46 +41,52 @@
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
editingFolderName: { type: String, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["submit", "hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
t: useI18n(),
|
||||
toast: useToast(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
name: null,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
editingFolderName(val) {
|
||||
this.name = val
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
editFolder() {
|
||||
if (!this.name) {
|
||||
this.toast.error(this.t("folder.invalid_name"))
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.name)
|
||||
},
|
||||
hideModal() {
|
||||
this.name = null
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
loadingState: boolean
|
||||
editingFolderName: string
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
loadingState: false,
|
||||
editingFolderName: "",
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "submit", name: string): void
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const name = ref("")
|
||||
|
||||
watch(
|
||||
() => props.editingFolderName,
|
||||
(newName) => {
|
||||
name.value = newName
|
||||
}
|
||||
)
|
||||
|
||||
const editFolder = () => {
|
||||
if (name.value.trim() === "") {
|
||||
toast.error(t("folder.invalid_name"))
|
||||
return
|
||||
}
|
||||
|
||||
emit("submit", name.value)
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
name.value = ""
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
<div class="flex flex-col">
|
||||
<input
|
||||
id="selectLabelEditReq"
|
||||
v-model="requestUpdateData.name"
|
||||
v-model="name"
|
||||
v-focus
|
||||
class="input floating-input"
|
||||
placeholder=" "
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
@keyup.enter="saveRequest"
|
||||
@keyup.enter="editRequest"
|
||||
/>
|
||||
<label for="selectLabelEditReq">
|
||||
{{ t("action.label") }}
|
||||
@@ -28,7 +28,7 @@
|
||||
:label="t('action.save')"
|
||||
:loading="loadingState"
|
||||
outline
|
||||
@click="saveRequest"
|
||||
@click="editRequest"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
@@ -41,48 +41,52 @@
|
||||
</SmartModal>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
show: Boolean,
|
||||
editingRequestName: { type: String, default: null },
|
||||
loadingState: Boolean,
|
||||
},
|
||||
emits: ["submit", "hide-modal"],
|
||||
setup() {
|
||||
return {
|
||||
t: useI18n(),
|
||||
toast: useToast(),
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
requestUpdateData: {
|
||||
name: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
editingRequestName(val) {
|
||||
this.requestUpdateData.name = val
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveRequest() {
|
||||
if (!this.requestUpdateData.name) {
|
||||
this.toast.error(this.t("request.invalid_name"))
|
||||
return
|
||||
}
|
||||
this.$emit("submit", this.requestUpdateData)
|
||||
},
|
||||
hideModal() {
|
||||
this.requestUpdateData = { name: null }
|
||||
this.$emit("hide-modal")
|
||||
},
|
||||
},
|
||||
})
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
loadingState: boolean
|
||||
editingRequestName: string
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
loadingState: false,
|
||||
editingRequestName: "",
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "submit", name: string): void
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const name = ref("")
|
||||
|
||||
watch(
|
||||
() => props.editingRequestName,
|
||||
(newName) => {
|
||||
name.value = newName
|
||||
}
|
||||
)
|
||||
|
||||
const editRequest = () => {
|
||||
if (name.value.trim() === "") {
|
||||
toast.error(t("request.invalid_name"))
|
||||
return
|
||||
}
|
||||
|
||||
emit("submit", name.value)
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
name.value = ""
|
||||
emit("hide-modal")
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<SmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="`${t('modal.collections')}`"
|
||||
:title="t('modal.collections')"
|
||||
styles="sm:max-w-md"
|
||||
@close="hideModal"
|
||||
>
|
||||
@@ -81,7 +81,6 @@
|
||||
<div class="select-wrapper">
|
||||
<select
|
||||
v-model="mySelectedCollectionID"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="select"
|
||||
autofocus
|
||||
@@ -93,6 +92,7 @@
|
||||
v-for="(collection, collectionIndex) in myCollections"
|
||||
:key="`collection-${collectionIndex}`"
|
||||
:value="collectionIndex"
|
||||
class="bg-primary"
|
||||
>
|
||||
{{ collection.name }}
|
||||
</option>
|
||||
@@ -126,8 +126,9 @@
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.download_file')"
|
||||
:icon="IconDownload"
|
||||
:loading="exportingTeamCollections"
|
||||
:label="t('export.as_json')"
|
||||
@click="exportJSON"
|
||||
@click="emit('export-json-collection')"
|
||||
/>
|
||||
<span
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
@@ -149,12 +150,9 @@
|
||||
: false
|
||||
"
|
||||
:icon="IconGithub"
|
||||
:loading="creatingGistCollection"
|
||||
:label="t('export.create_secret_gist')"
|
||||
@click="
|
||||
() => {
|
||||
createCollectionGist()
|
||||
}
|
||||
"
|
||||
@click="emit('create-collection-gist')"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -167,217 +165,96 @@
|
||||
import IconArrowLeft from "~icons/lucide/arrow-left"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
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 * as E from "fp-ts/Either"
|
||||
import { HoppRESTRequest, HoppCollection } from "@hoppscotch/data"
|
||||
import axios from "axios"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
|
||||
import { RESTCollectionImporters } from "~/helpers/import-export/import/importers"
|
||||
import { StepReturnValue } from "~/helpers/import-export/steps"
|
||||
import { runGQLQuery, runMutation } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
ExportAsJsonDocument,
|
||||
ImportFromJsonDocument,
|
||||
} from "~/helpers/backend/graphql"
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
collectionsType:
|
||||
| {
|
||||
type: "team-collections"
|
||||
selectedTeam: {
|
||||
id: string
|
||||
}
|
||||
}
|
||||
| { type: "my-collections" }
|
||||
}>()
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
|
||||
type CollectionType = "team-collections" | "my-collections"
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
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<{
|
||||
(e: "hide-modal"): void
|
||||
(e: "update-team-collections"): void
|
||||
(e: "export-json-collection"): void
|
||||
(e: "create-collection-gist"): void
|
||||
(e: "import-to-teams", payload: HoppCollection<HoppRESTRequest>[]): void
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
const myCollections = useReadonlyStream(restCollections$, [])
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
const hasFile = ref(false)
|
||||
const hasGist = ref(false)
|
||||
|
||||
// Template refs
|
||||
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 importerType = ref<number | null>(null)
|
||||
|
||||
const stepResults = ref<StepReturnValue[]>([])
|
||||
|
||||
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
|
||||
const mySelectedCollectionID = ref<number | undefined>(undefined)
|
||||
const inputChooseGistToImportFrom = ref<string>("")
|
||||
|
||||
const importerModules = computed(() =>
|
||||
RESTCollectionImporters.filter(
|
||||
(i) => i.applicableTo?.includes(props.collectionsType) ?? true
|
||||
)
|
||||
)
|
||||
|
||||
const importerModule = computed(() => {
|
||||
if (importerType.value === null) return null
|
||||
return importerModules.value[importerType.value]
|
||||
})
|
||||
|
||||
const importerSteps = computed(() => importerModule.value?.steps ?? null)
|
||||
|
||||
const enableImportButton = computed(
|
||||
() => !(stepResults.value.length === importerSteps.value?.length)
|
||||
)
|
||||
|
||||
watch(mySelectedCollectionID, (newValue) => {
|
||||
if (newValue === undefined) return
|
||||
stepResults.value = []
|
||||
stepResults.value.push(newValue)
|
||||
})
|
||||
|
||||
const importingMyCollections = ref(false)
|
||||
|
||||
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) => {
|
||||
watch(inputChooseGistToImportFrom, (url) => {
|
||||
stepResults.value = []
|
||||
if (v === "") {
|
||||
if (url === "") {
|
||||
hasGist.value = false
|
||||
} else {
|
||||
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 = () => {
|
||||
stepResults.value = []
|
||||
if (!inputChooseFileToImportFrom.value[0]) {
|
||||
|
||||
const inputFileToImport = inputChooseFileToImportFrom.value[0]
|
||||
|
||||
if (!inputFileToImport) {
|
||||
hasFile.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!inputChooseFileToImportFrom.value[0].files ||
|
||||
inputChooseFileToImportFrom.value[0].files.length === 0
|
||||
) {
|
||||
if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
|
||||
inputChooseFileToImportFrom.value[0].value = ""
|
||||
hasFile.value = false
|
||||
toast.show(t("action.choose_file").toString())
|
||||
@@ -403,6 +312,7 @@ const onFileChange = () => {
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = ({ target }) => {
|
||||
const content = target!.result as string | null
|
||||
if (!content) {
|
||||
@@ -414,20 +324,29 @@ const onFileChange = () => {
|
||||
stepResults.value.push(content)
|
||||
hasFile.value = !!content?.length
|
||||
}
|
||||
reader.readAsText(inputChooseFileToImportFrom.value[0].files[0])
|
||||
|
||||
reader.readAsText(inputFileToImport.files[0])
|
||||
}
|
||||
|
||||
const enableImportButton = computed(
|
||||
() => !(stepResults.value.length === importerSteps.value?.length)
|
||||
)
|
||||
const fileImported = () => {
|
||||
toast.success(t("state.file_imported").toString())
|
||||
hideModal()
|
||||
}
|
||||
const failedImport = () => {
|
||||
toast.error(t("import.failed").toString())
|
||||
}
|
||||
const hideModal = () => {
|
||||
resetImport()
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const resetImport = () => {
|
||||
importerType.value = null
|
||||
hasFile.value = false
|
||||
hasGist.value = false
|
||||
stepResults.value = []
|
||||
inputChooseFileToImportFrom.value = ""
|
||||
hasFile.value = false
|
||||
inputChooseGistToImportFrom.value = ""
|
||||
hasGist.value = false
|
||||
mySelectedCollectionID.value = undefined
|
||||
}
|
||||
</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>
|
||||
<CollectionsGraphql
|
||||
v-if="mode === 'graphql'"
|
||||
:show-coll-actions="false"
|
||||
:picked="picked"
|
||||
:saving-mode="true"
|
||||
:save-request="true"
|
||||
@select="onSelect"
|
||||
/>
|
||||
<Collections
|
||||
@@ -37,8 +36,8 @@
|
||||
:picked="picked"
|
||||
:save-request="true"
|
||||
@select="onSelect"
|
||||
@update-collection="updateColl"
|
||||
@update-coll-type="onUpdateCollType"
|
||||
@update-team="updateTeam"
|
||||
@update-collection-type="updateCollectionType"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -46,6 +45,7 @@
|
||||
<span class="flex space-x-2">
|
||||
<ButtonPrimary
|
||||
:label="`${t('action.save')}`"
|
||||
:loading="modalLoadingState"
|
||||
outline
|
||||
@click="saveRequestAs"
|
||||
/>
|
||||
@@ -61,99 +61,75 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { HoppGQLRequest, isHoppRESTRequest } from "@hoppscotch/data"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import {
|
||||
editGraphqlRequest,
|
||||
editRESTRequest,
|
||||
saveGraphqlRequestAs,
|
||||
saveRESTRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
HoppGQLRequest,
|
||||
HoppRESTRequest,
|
||||
isHoppRESTRequest,
|
||||
} from "@hoppscotch/data"
|
||||
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 {
|
||||
getRESTRequest,
|
||||
setRESTSaveContext,
|
||||
useRESTRequestName,
|
||||
} from "~/newstore/RESTSession"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { runMutation } from "~/helpers/backend/GQLClient"
|
||||
import {
|
||||
CreateRequestInCollectionDocument,
|
||||
UpdateRequestDocument,
|
||||
} from "~/helpers/backend/graphql"
|
||||
editGraphqlRequest,
|
||||
editRESTRequest,
|
||||
saveGraphqlRequestAs,
|
||||
saveRESTRequestAs,
|
||||
} from "~/newstore/collections"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
|
||||
|
||||
type CollectionType =
|
||||
| {
|
||||
type: "my-collections"
|
||||
}
|
||||
| {
|
||||
type: "team-collections"
|
||||
// TODO: Figure this type out
|
||||
selectedTeam: {
|
||||
id: string
|
||||
}
|
||||
selectedTeam: SelectedTeam
|
||||
}
|
||||
| { type: "my-collections"; selectedTeam: undefined }
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
mode: "rest" | "graphql"
|
||||
show: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
show: boolean
|
||||
mode: "rest" | "graphql"
|
||||
}>(),
|
||||
{
|
||||
show: false,
|
||||
mode: "rest",
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
event: "edit-request",
|
||||
payload: {
|
||||
folderPath: string
|
||||
requestIndex: string
|
||||
request: HoppRESTRequest
|
||||
}
|
||||
): void
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// TODO: Use a better implementation with computed ?
|
||||
// This implementation can't work across updates to mode prop (which won't happen tho)
|
||||
const requestName =
|
||||
const requestName = ref(
|
||||
props.mode === "rest" ? useRESTRequestName() : useGQLRequestName()
|
||||
)
|
||||
|
||||
const requestData = reactive({
|
||||
name: requestName,
|
||||
@@ -164,11 +140,13 @@ const requestData = reactive({
|
||||
|
||||
const collectionsType = ref<CollectionType>({
|
||||
type: "my-collections",
|
||||
selectedTeam: undefined,
|
||||
})
|
||||
|
||||
// TODO: Figure this type out
|
||||
const picked = ref<Picked | null>(null)
|
||||
|
||||
const modalLoadingState = ref(false)
|
||||
|
||||
// Resets
|
||||
watch(
|
||||
() => requestData.collectionIndex,
|
||||
@@ -184,20 +162,18 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// All the methods
|
||||
const onUpdateCollType = (newCollType: CollectionType) => {
|
||||
collectionsType.value = newCollType
|
||||
const updateTeam = (newTeam: SelectedTeam) => {
|
||||
collectionsType.value.selectedTeam = newTeam
|
||||
}
|
||||
|
||||
const onSelect = ({ picked: pickedVal }: { picked: Picked | null }) => {
|
||||
const updateCollectionType = (type: CollectionType["type"]) => {
|
||||
collectionsType.value.type = type
|
||||
}
|
||||
|
||||
const onSelect = (pickedVal: Picked | null) => {
|
||||
picked.value = pickedVal
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
picked.value = null
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const saveRequestAs = async () => {
|
||||
if (!requestName.value) {
|
||||
toast.error(`${t("error.empty_req_name")}`)
|
||||
@@ -208,35 +184,25 @@ const saveRequestAs = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Clone Deep because objects are shared by reference so updating
|
||||
// just one bit will update other referenced shared instances
|
||||
const requestUpdated =
|
||||
props.mode === "rest"
|
||||
? cloneDeep(getRESTRequest())
|
||||
: cloneDeep(getGQLSession().request)
|
||||
|
||||
// // Filter out all REST file inputs
|
||||
// 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 (picked.value.pickedType === "my-collection") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
editRESTRequest(
|
||||
picked.value.folderPath,
|
||||
picked.value.requestIndex,
|
||||
const insertionIndex = saveRESTRequestAs(
|
||||
`${picked.value.collectionIndex}`,
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: picked.value.requestIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
folderPath: `${picked.value.collectionIndex}`,
|
||||
requestIndex: insertionIndex,
|
||||
req: requestUpdated,
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
@@ -253,114 +219,68 @@ const saveRequestAs = async () => {
|
||||
originLocation: "user-collection",
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: insertionIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
req: requestUpdated,
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
} else if (picked.value.pickedType === "my-collection") {
|
||||
} else if (picked.value.pickedType === "my-request") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
throw new Error("requestUpdated is not a REST Request")
|
||||
|
||||
const insertionIndex = saveRESTRequestAs(
|
||||
`${picked.value.collectionIndex}`,
|
||||
editRESTRequest(
|
||||
picked.value.folderPath,
|
||||
picked.value.requestIndex,
|
||||
requestUpdated
|
||||
)
|
||||
|
||||
setRESTSaveContext({
|
||||
originLocation: "user-collection",
|
||||
folderPath: `${picked.value.collectionIndex}`,
|
||||
requestIndex: insertionIndex,
|
||||
req: cloneDeep(requestUpdated),
|
||||
folderPath: picked.value.folderPath,
|
||||
requestIndex: picked.value.requestIndex,
|
||||
req: requestUpdated,
|
||||
})
|
||||
|
||||
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") {
|
||||
if (!isHoppRESTRequest(requestUpdated))
|
||||
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")
|
||||
|
||||
const result = await runMutation(CreateRequestInCollectionDocument, {
|
||||
collectionID: picked.value.collectionID,
|
||||
data: {
|
||||
title: requestUpdated.name,
|
||||
request: JSON.stringify(requestUpdated),
|
||||
teamID: collectionsType.value.selectedTeam.id,
|
||||
},
|
||||
})()
|
||||
modalLoadingState.value = true
|
||||
|
||||
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.collectionID,
|
||||
req: cloneDeep(requestUpdated),
|
||||
})
|
||||
|
||||
requestSaved()
|
||||
const data = {
|
||||
request: JSON.stringify(requestUpdated),
|
||||
title: requestUpdated.name,
|
||||
}
|
||||
|
||||
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") {
|
||||
// TODO: Check for GQL request ?
|
||||
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 = () => {
|
||||
toast.success(`${t("request.added")}`)
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const updateColl = (ev: CollectionType["type"]) => {
|
||||
collectionsType.value.type = ev
|
||||
const hideModal = () => {
|
||||
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>
|
||||
|
||||
@@ -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"
|
||||
:key="`folder-${String(index)}`"
|
||||
:picked="picked"
|
||||
:saving-mode="savingMode"
|
||||
:save-request="saveRequest"
|
||||
:folder="folder"
|
||||
:folder-index="index"
|
||||
:folder-path="`${collectionIndex}/${String(index)}`"
|
||||
@@ -160,7 +160,7 @@
|
||||
v-for="(request, index) in collection.requests"
|
||||
:key="`request-${String(index)}`"
|
||||
:picked="picked"
|
||||
:saving-mode="savingMode"
|
||||
:save-request="saveRequest"
|
||||
:request="request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="-1"
|
||||
@@ -183,9 +183,19 @@
|
||||
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
|
||||
:alt="`${t('empty.collection')}`"
|
||||
/>
|
||||
<span class="text-center">
|
||||
<span class="pb-4 text-center">
|
||||
{{ t("empty.collection") }}
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
:label="t('add.new')"
|
||||
filled
|
||||
outline
|
||||
@click="
|
||||
emit('add-folder', {
|
||||
path: `${collectionIndex}`,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,11 +225,12 @@ import {
|
||||
removeGraphqlCollection,
|
||||
moveGraphqlRequest,
|
||||
} from "~/newstore/collections"
|
||||
import { Picked } from "~/helpers/types/HoppPicked"
|
||||
|
||||
const props = defineProps({
|
||||
picked: { type: Object, default: null },
|
||||
// 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 },
|
||||
collection: { type: Object, default: () => ({}) },
|
||||
isFiltered: Boolean,
|
||||
@@ -231,7 +242,7 @@ const t = useI18n()
|
||||
|
||||
// TODO: improve types plz
|
||||
const emit = defineEmits<{
|
||||
(e: "select", i: { picked: any }): void
|
||||
(e: "select", i: Picked | null): void
|
||||
(e: "edit-request", i: any): void
|
||||
(e: "duplicate-request", i: any): void
|
||||
(e: "add-request", i: any): void
|
||||
@@ -267,15 +278,13 @@ const collectionIcon = computed(() => {
|
||||
|
||||
const pick = () => {
|
||||
emit("select", {
|
||||
picked: {
|
||||
pickedType: "gql-my-collection",
|
||||
collectionIndex: props.collectionIndex,
|
||||
},
|
||||
pickedType: "gql-my-collection",
|
||||
collectionIndex: props.collectionIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const toggleShowChildren = () => {
|
||||
if (props.savingMode) {
|
||||
if (props.saveRequest) {
|
||||
pick()
|
||||
}
|
||||
|
||||
@@ -288,7 +297,7 @@ const removeCollection = () => {
|
||||
props.picked?.pickedType === "gql-my-collection" &&
|
||||
props.picked?.collectionIndex === props.collectionIndex
|
||||
) {
|
||||
emit("select", { picked: null })
|
||||
emit("select", null)
|
||||
}
|
||||
removeGraphqlCollection(props.collectionIndex)
|
||||
toast.success(`${t("state.deleted")}`)
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
v-for="(subFolder, subFolderIndex) in folder.folders"
|
||||
:key="`subFolder-${String(subFolderIndex)}`"
|
||||
:picked="picked"
|
||||
:saving-mode="savingMode"
|
||||
:save-request="saveRequest"
|
||||
:folder="subFolder"
|
||||
:folder-index="subFolderIndex"
|
||||
:folder-path="`${folderPath}/${String(subFolderIndex)}`"
|
||||
@@ -149,7 +149,7 @@
|
||||
v-for="(request, index) in folder.requests"
|
||||
:key="`request-${String(index)}`"
|
||||
:picked="picked"
|
||||
:saving-mode="savingMode"
|
||||
:save-request="saveRequest"
|
||||
:request="request"
|
||||
:collection-index="collectionIndex"
|
||||
:folder-index="folderIndex"
|
||||
@@ -212,7 +212,7 @@ const colorMode = useColorMode()
|
||||
const props = defineProps({
|
||||
picked: { type: Object, default: null },
|
||||
// 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: () => ({}) },
|
||||
folderIndex: { type: Number, default: null },
|
||||
collectionIndex: { type: Number, default: null },
|
||||
@@ -263,7 +263,7 @@ const pick = () => {
|
||||
}
|
||||
|
||||
const toggleShowChildren = () => {
|
||||
if (props.savingMode) {
|
||||
if (props.saveRequest) {
|
||||
pick()
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ import IconFolderPlus from "~icons/lucide/folder-plus"
|
||||
import IconDownload from "~icons/lucide/download"
|
||||
import IconGithub from "~icons/lucide/github"
|
||||
import { computed, ref } from "vue"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
@@ -120,7 +120,10 @@ const emit = defineEmits<{
|
||||
const toast = useToast()
|
||||
const t = useI18n()
|
||||
const collections = useReadonlyStream(graphqlCollections$, [])
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
const currentUser = useReadonlyStream(
|
||||
platform.auth.getCurrentUserStream(),
|
||||
platform.auth.getCurrentUser()
|
||||
)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<any | null>(null)
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
</span>
|
||||
<div class="flex">
|
||||
<ButtonSecondary
|
||||
v-if="!savingMode"
|
||||
v-if="!saveRequest"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconRotateCCW"
|
||||
:title="t('action.restore')"
|
||||
@@ -148,7 +148,7 @@ const props = defineProps({
|
||||
// Whether the object is selected (show the tick mark)
|
||||
picked: { type: Object, default: null },
|
||||
// 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: () => ({}) },
|
||||
folderPath: { type: String, default: null },
|
||||
requestIndex: { type: Number, default: null },
|
||||
@@ -169,16 +169,14 @@ const isSelected = computed(
|
||||
|
||||
const pick = () => {
|
||||
emit("select", {
|
||||
picked: {
|
||||
pickedType: "gql-my-request",
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
},
|
||||
pickedType: "gql-my-request",
|
||||
folderPath: props.folderPath,
|
||||
requestIndex: props.requestIndex,
|
||||
})
|
||||
}
|
||||
|
||||
const selectRequest = () => {
|
||||
if (props.savingMode) {
|
||||
if (props.saveRequest) {
|
||||
pick()
|
||||
} else {
|
||||
setGQLSession({
|
||||
@@ -213,7 +211,7 @@ const removeRequest = () => {
|
||||
props.picked.folderPath === props.folderPath &&
|
||||
props.picked.requestIndex === props.requestIndex
|
||||
) {
|
||||
emit("select", { picked: null })
|
||||
emit("select", null)
|
||||
}
|
||||
|
||||
removeGraphqlRequest(props.folderPath, props.requestIndex)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<template>
|
||||
<div :class="{ 'rounded border border-divider': savingMode }">
|
||||
<div :class="{ 'rounded border border-divider': saveRequest }">
|
||||
<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="{ 'bg-primary': !savingMode }"
|
||||
class="sticky z-10 flex flex-col flex-shrink-0 overflow-x-auto rounded-t bg-primary"
|
||||
:style="
|
||||
saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0'
|
||||
"
|
||||
>
|
||||
<input
|
||||
v-if="showCollActions"
|
||||
v-model="filterText"
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
: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
|
||||
:icon="IconPlus"
|
||||
:label="t('action.new')"
|
||||
@@ -28,7 +31,7 @@
|
||||
:icon="IconHelpCircle"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
v-if="showCollActions"
|
||||
v-if="!saveRequest"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('modal.import_export')"
|
||||
:icon="IconArchive"
|
||||
@@ -37,7 +40,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-col">
|
||||
<div class="flex flex-col">
|
||||
<CollectionsGraphqlCollection
|
||||
v-for="(collection, index) in filteredCollections"
|
||||
:key="`collection-${index}`"
|
||||
@@ -46,7 +49,7 @@
|
||||
:collection-index="index"
|
||||
:collection="collection"
|
||||
:is-filtered="filterText.length > 0"
|
||||
:saving-mode="savingMode"
|
||||
:save-request="saveRequest"
|
||||
@edit-collection="editCollection(collection, index)"
|
||||
@add-request="addRequest($event)"
|
||||
@add-folder="addFolder($event)"
|
||||
@@ -154,10 +157,8 @@ import { useColorMode } from "@composables/theming"
|
||||
export default defineComponent({
|
||||
props: {
|
||||
// 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 },
|
||||
// Whether to show the 'New' and 'Import/Export' actions
|
||||
showCollActions: { type: Boolean, default: true },
|
||||
},
|
||||
emits: ["select", "use-collection"],
|
||||
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 { GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { onLoggedIn } from "@composables/auth"
|
||||
import { currentUserInfo$ } from "~/helpers/teams/BackendUserInfo"
|
||||
import { platform } from "~/platform"
|
||||
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useLocalState } from "~/newstore/localstate"
|
||||
@@ -111,7 +111,10 @@ const emit = defineEmits<{
|
||||
(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 myTeams = useReadonlyStream(adapter.teamList$, null)
|
||||
@@ -138,7 +141,9 @@ watch(
|
||||
)
|
||||
|
||||
onLoggedIn(() => {
|
||||
adapter.initialize()
|
||||
try {
|
||||
adapter.initialize()
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
const onTeamSelectIntersect = () => {
|
||||
|
||||
@@ -107,7 +107,7 @@ import IconDownload from "~icons/lucide/download"
|
||||
import IconGithub from "~icons/lucide/github"
|
||||
import { computed, ref } from "vue"
|
||||
import { Environment } from "@hoppscotch/data"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
import axios from "axios"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
@@ -141,7 +141,10 @@ const t = useI18n()
|
||||
const loading = ref(false)
|
||||
|
||||
const myEnvironments = useReadonlyStream(environments$, [])
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
const currentUser = useReadonlyStream(
|
||||
platform.auth.getCurrentUserStream(),
|
||||
platform.auth.getCurrentUser()
|
||||
)
|
||||
|
||||
// Template refs
|
||||
const tippyActions = ref<TippyComponent | null>(null)
|
||||
@@ -187,7 +190,7 @@ const createEnvironmentGist = async () => {
|
||||
)
|
||||
|
||||
toast.success(t("export.gist_created").toString())
|
||||
window.open(res.html_url)
|
||||
window.open(res.data.html_url)
|
||||
} catch (e) {
|
||||
toast.error(t("error.something_went_wrong").toString())
|
||||
console.error(e)
|
||||
|
||||
@@ -183,7 +183,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { isEqual } from "lodash-es"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
import { Team } from "~/helpers/backend/graphql"
|
||||
import { useReadonlyStream, useStream } from "@composables/stream"
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
@@ -222,7 +222,10 @@ const globalEnvironment = computed(() => ({
|
||||
variables: globalEnv.value,
|
||||
}))
|
||||
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
const currentUser = useReadonlyStream(
|
||||
platform.auth.getCurrentUserStream(),
|
||||
platform.auth.getCurrentUser()
|
||||
)
|
||||
|
||||
const updateSelectedTeam = (newSelectedTeam: SelectedTeam) => {
|
||||
environmentType.value.selectedTeam = newSelectedTeam
|
||||
|
||||
@@ -122,16 +122,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
import {
|
||||
signInUserWithGoogle,
|
||||
signInUserWithGithub,
|
||||
signInUserWithMicrosoft,
|
||||
setProviderInfo,
|
||||
currentUser$,
|
||||
signInWithEmail,
|
||||
linkWithFBCredentialFromAuthError,
|
||||
getGithubCredentialFromResult,
|
||||
} from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
import IconGithub from "~icons/auth/github"
|
||||
import IconGoogle from "~icons/auth/google"
|
||||
import IconEmail from "~icons/auth/email"
|
||||
@@ -174,6 +165,8 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||
|
||||
this.subscribeToStream(currentUser$, (user) => {
|
||||
if (user) this.hideModal()
|
||||
})
|
||||
@@ -186,8 +179,7 @@ export default defineComponent({
|
||||
this.signingInWithGoogle = true
|
||||
|
||||
try {
|
||||
await signInUserWithGoogle()
|
||||
this.showLoginSuccess()
|
||||
await platform.auth.signInUserWithGoogle()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
/*
|
||||
@@ -202,35 +194,32 @@ export default defineComponent({
|
||||
async signInWithGithub() {
|
||||
this.signingInWithGitHub = true
|
||||
|
||||
try {
|
||||
const result = await signInUserWithGithub()
|
||||
const credential = getGithubCredentialFromResult(result)!
|
||||
const token = credential.accessToken
|
||||
setProviderInfo(result.providerId!, token!)
|
||||
const result = await platform.auth.signInUserWithGithub()
|
||||
|
||||
this.showLoginSuccess()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// 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()
|
||||
if (!result) {
|
||||
this.signingInWithGitHub = false
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
@@ -239,8 +228,8 @@ export default defineComponent({
|
||||
this.signingInWithMicrosoft = true
|
||||
|
||||
try {
|
||||
await signInUserWithMicrosoft()
|
||||
this.showLoginSuccess()
|
||||
await platform.auth.signInUserWithMicrosoft()
|
||||
// this.showLoginSuccess()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
/*
|
||||
@@ -259,11 +248,8 @@ export default defineComponent({
|
||||
async signInWithEmail() {
|
||||
this.signingInWithEmail = true
|
||||
|
||||
const actionCodeSettings = {
|
||||
url: `${import.meta.env.VITE_BASE_URL}/enter`,
|
||||
handleCodeInApp: true,
|
||||
}
|
||||
await signInWithEmail(this.form.email, actionCodeSettings)
|
||||
await platform.auth
|
||||
.signInWithEmail(this.form.email)
|
||||
.then(() => {
|
||||
this.mode = "email-sent"
|
||||
setLocalConfig("emailForSignIn", this.form.email)
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ref } from "vue"
|
||||
import IconLogOut from "~icons/lucide/log-out"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { signOutUser } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
|
||||
defineProps({
|
||||
outline: {
|
||||
@@ -47,7 +47,7 @@ const t = useI18n()
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await signOutUser()
|
||||
await platform.auth.signOutUser()
|
||||
toast.success(`${t("auth.logged_out")}`)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -331,14 +331,25 @@ const setRestReq = (request: HoppRESTRequest | null | undefined) => {
|
||||
// (That is not a really good behaviour tho ¯\_(ツ)_/¯)
|
||||
const useHistory = (entry: RESTHistoryEntry) => {
|
||||
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
|
||||
if (!clickedHistory.value) {
|
||||
else if (!clickedHistory.value) {
|
||||
clickedHistory.value = entry
|
||||
confirmChange.value = true
|
||||
return
|
||||
}
|
||||
// Checks if there are any change done in current request and the history request
|
||||
if (
|
||||
else if (
|
||||
!isEqualHoppRESTRequest(
|
||||
currentFullReq,
|
||||
clickedHistory.value.request as HoppRESTRequest
|
||||
@@ -347,7 +358,7 @@ const useHistory = (entry: RESTHistoryEntry) => {
|
||||
clickedHistory.value = entry
|
||||
confirmChange.value = true
|
||||
} else {
|
||||
props.page === "rest" && setRestReq(entry.request as HoppRESTRequest)
|
||||
props.page === "rest" && setRestReq(entry.request)
|
||||
clickedHistory.value = entry
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,14 +16,15 @@
|
||||
<ButtonPrimary
|
||||
v-focus
|
||||
:label="t('action.save')"
|
||||
:loading="loading"
|
||||
outline
|
||||
@click="saveApiChange"
|
||||
@click="saveChange"
|
||||
/>
|
||||
<ButtonSecondary
|
||||
:label="t('action.dont_save')"
|
||||
outline
|
||||
filled
|
||||
@click="discardApiChange"
|
||||
@click="discardChange"
|
||||
/>
|
||||
</span>
|
||||
<ButtonSecondary
|
||||
@@ -43,6 +44,7 @@ const t = useI18n()
|
||||
|
||||
defineProps<{
|
||||
show: boolean
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -51,11 +53,11 @@ const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const saveApiChange = () => {
|
||||
const saveChange = () => {
|
||||
emit("save-change")
|
||||
}
|
||||
|
||||
const discardApiChange = () => {
|
||||
const discardChange = () => {
|
||||
emit("discard-change")
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ import { ref, watchEffect, computed } from "vue"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as TE from "fp-ts/TaskEither"
|
||||
import { GQLError } from "~/helpers/backend/GQLClient"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
|
||||
import { onAuthEvent, onLoggedIn } from "@composables/auth"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
@@ -102,7 +102,10 @@ usePageHead({
|
||||
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)
|
||||
watchEffect(() => (displayName.value = currentUser.value?.displayName))
|
||||
@@ -121,7 +124,9 @@ const loading = computed(
|
||||
)
|
||||
|
||||
onLoggedIn(() => {
|
||||
adapter.initialize()
|
||||
try {
|
||||
adapter.initialize()
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
onAuthEvent((ev) => {
|
||||
|
||||
@@ -109,7 +109,7 @@ import { useI18n } from "~/composables/i18n"
|
||||
import { useToast } from "~/composables/toast"
|
||||
import { GetMyTeamsDocument, GetMyTeamsQuery } from "~/helpers/backend/graphql"
|
||||
import { deleteUser } from "~/helpers/backend/mutations/Profile"
|
||||
import { signOutUser } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
@@ -162,7 +162,7 @@ const deleteUserAccount = async () => {
|
||||
deletingUser.value = false
|
||||
showDeleteAccountModal.value = false
|
||||
toast.success(t("settings.account_deleted"))
|
||||
signOutUser()
|
||||
platform.auth.signOutUser()
|
||||
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(() => {
|
||||
adapter.initialize()
|
||||
try {
|
||||
adapter.initialize()
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
const displayModalAdd = (shouldDisplay: boolean) => {
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
import {
|
||||
currentUser$,
|
||||
HoppUser,
|
||||
AuthEvent,
|
||||
authEvents$,
|
||||
authIdToken$,
|
||||
} from "@helpers/fb/auth"
|
||||
import {
|
||||
map,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
Subscription,
|
||||
combineLatestWith,
|
||||
} from "rxjs"
|
||||
import { onBeforeUnmount, onMounted } from "vue"
|
||||
import { platform } from "~/platform"
|
||||
import { AuthEvent, HoppUser } from "~/platform/auth"
|
||||
import { Subscription } from "rxjs"
|
||||
import { onBeforeUnmount, onMounted, watch, WatchStopHandle } from "vue"
|
||||
import { useReadonlyStream } from "./stream"
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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(() => {
|
||||
sub = currentUser$
|
||||
.pipe(
|
||||
// We don't consider the state as logged in unless we also have an id token
|
||||
combineLatestWith(authIdToken$),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
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!)
|
||||
})
|
||||
if (currentUser.value) exec(currentUser.value)
|
||||
|
||||
watchStop = watch(currentUser, (newVal, prev) => {
|
||||
if (prev === null && newVal !== null) {
|
||||
exec(newVal)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sub?.unsubscribe()
|
||||
watchStop?.()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,6 +46,8 @@ export function onLoggedIn(exec: (user: HoppUser) => void) {
|
||||
* @param func A function which accepts an event
|
||||
*/
|
||||
export function onAuthEvent(func: (ev: AuthEvent) => void) {
|
||||
const authEvents$ = platform.auth.getAuthEventsStream()
|
||||
|
||||
let sub: Subscription | null = null
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CombinedError,
|
||||
Operation,
|
||||
OperationResult,
|
||||
Client,
|
||||
} from "@urql/core"
|
||||
import { authExchange } from "@urql/exchange-auth"
|
||||
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 { subscribe, pipe as wonkaPipe } from "wonka"
|
||||
import { filter, map, Subject } from "rxjs"
|
||||
import {
|
||||
authIdToken$,
|
||||
getAuthIDToken,
|
||||
probableUser$,
|
||||
waitProbableLoginToConfirm,
|
||||
} from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
|
||||
// TODO: Implement caching
|
||||
|
||||
@@ -57,11 +53,7 @@ export const gqlClientError$ = new Subject<GQLClientErrorEvent>()
|
||||
const createSubscriptionClient = () => {
|
||||
return new SubscriptionClient(BACKEND_WS_URL, {
|
||||
reconnect: true,
|
||||
connectionParams: () => {
|
||||
return {
|
||||
authorization: `Bearer ${authIdToken$.value}`,
|
||||
}
|
||||
},
|
||||
connectionParams: () => platform.auth.getBackendHeaders(),
|
||||
connectionCallback(error) {
|
||||
if (error?.length > 0) {
|
||||
gqlClientError$.next({
|
||||
@@ -79,7 +71,7 @@ const createHoppClient = () => {
|
||||
dedupExchange,
|
||||
authExchange({
|
||||
addAuthToOperation({ authState, operation }) {
|
||||
if (!authState || !authState.authToken) {
|
||||
if (!authState) {
|
||||
return operation
|
||||
}
|
||||
|
||||
@@ -88,28 +80,29 @@ const createHoppClient = () => {
|
||||
? operation.context.fetchOptions()
|
||||
: operation.context.fetchOptions || {}
|
||||
|
||||
const authHeaders = platform.auth.getBackendHeaders()
|
||||
|
||||
return makeOperation(operation.kind, operation, {
|
||||
...operation.context,
|
||||
fetchOptions: {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
...fetchOptions.headers,
|
||||
Authorization: `Bearer ${authState.authToken}`,
|
||||
...authHeaders,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
willAuthError({ authState }) {
|
||||
return !authState || !authState.authToken
|
||||
willAuthError() {
|
||||
return platform.auth.willBackendHaveAuthError()
|
||||
},
|
||||
getAuth: async () => {
|
||||
if (!probableUser$.value) return { authToken: null }
|
||||
const probableUser = platform.auth.getProbableUser()
|
||||
|
||||
await waitProbableLoginToConfirm()
|
||||
if (probableUser !== null)
|
||||
await platform.auth.waitProbableLoginToConfirm()
|
||||
|
||||
return {
|
||||
authToken: getAuthIDToken(),
|
||||
}
|
||||
return {}
|
||||
},
|
||||
}),
|
||||
fetchExchange,
|
||||
@@ -137,31 +130,40 @@ const createHoppClient = () => {
|
||||
return createClient({
|
||||
url: BACKEND_GQL_URL,
|
||||
exchanges,
|
||||
...(platform.auth.getGQLClientOptions
|
||||
? platform.auth.getGQLClientOptions()
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
let subscriptionClient: SubscriptionClient | null
|
||||
export const client = ref(createHoppClient())
|
||||
|
||||
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 const client = ref<Client>()
|
||||
|
||||
export function initBackendGQLClient() {
|
||||
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> = {
|
||||
query: TypedDocumentNode<T, V>
|
||||
@@ -185,7 +187,7 @@ export const runGQLQuery = <DocType, DocVarType, DocErrorType extends string>(
|
||||
args: RunQueryOptions<DocType, DocVarType>
|
||||
): Promise<E.Either<GQLError<DocErrorType>, DocType>> => {
|
||||
const request = createRequest<DocType, DocVarType>(args.query, args.variables)
|
||||
const source = client.value.executeQuery(request, {
|
||||
const source = client.value!.executeQuery(request, {
|
||||
requestPolicy: "network-only",
|
||||
})
|
||||
|
||||
@@ -250,7 +252,7 @@ export const runGQLSubscription = <
|
||||
) => {
|
||||
const result$ = new Subject<E.Either<GQLError<DocErrorType>, DocType>>()
|
||||
|
||||
const source = client.value.executeSubscription(
|
||||
const source = client.value!.executeSubscription(
|
||||
createRequest(args.query, args.variables)
|
||||
)
|
||||
|
||||
@@ -342,8 +344,8 @@ export const runMutation = <
|
||||
pipe(
|
||||
TE.tryCatch(
|
||||
() =>
|
||||
client.value
|
||||
.mutation(mutation, variables, {
|
||||
client
|
||||
.value!.mutation(mutation, variables, {
|
||||
requestPolicy: "cache-and-network",
|
||||
...additionalConfig,
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { TeamCollection } from "../teams/TeamCollection"
|
||||
import { TeamRequest } from "../teams/TeamRequest"
|
||||
import { GQLError, runGQLQuery } from "./GQLClient"
|
||||
import {
|
||||
ExportAsJsonDocument,
|
||||
GetCollectionChildrenIDsDocument,
|
||||
GetCollectionRequestsDocument,
|
||||
GetCollectionTitleDocument,
|
||||
@@ -125,3 +126,23 @@ export const teamCollToHoppRESTColl = (
|
||||
folders: coll.children?.map(teamCollToHoppRESTColl) ?? [],
|
||||
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 {
|
||||
CreateRequestInCollectionDocument,
|
||||
CreateRequestInCollectionMutation,
|
||||
CreateRequestInCollectionMutationVariables,
|
||||
DeleteRequestDocument,
|
||||
DeleteRequestMutation,
|
||||
DeleteRequestMutationVariables,
|
||||
MoveRestTeamRequestDocument,
|
||||
MoveRestTeamRequestMutation,
|
||||
MoveRestTeamRequestMutationVariables,
|
||||
UpdateRequestDocument,
|
||||
UpdateRequestMutation,
|
||||
UpdateRequestMutationVariables,
|
||||
} from "../graphql"
|
||||
|
||||
type MoveRestTeamRequestErrors =
|
||||
| "team_req/not_found"
|
||||
| "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) =>
|
||||
runMutation<
|
||||
MoveRestTeamRequestMutation,
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
setUserId,
|
||||
setUserProperties,
|
||||
} from "firebase/analytics"
|
||||
import { authEvents$ } from "./auth"
|
||||
import { platform } from "~/platform"
|
||||
import {
|
||||
HoppAccentColor,
|
||||
HoppBgColor,
|
||||
@@ -42,13 +42,15 @@ export function initAnalytics() {
|
||||
}
|
||||
|
||||
function initLoginListeners() {
|
||||
const authEvents$ = platform.auth.getAuthEventsStream()
|
||||
|
||||
authEvents$.subscribe((ev) => {
|
||||
if (ev.event === "login") {
|
||||
if (settingsStore.value.TELEMETRY_ENABLED && analytics) {
|
||||
setUserId(analytics, ev.user.uid)
|
||||
|
||||
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") {
|
||||
|
||||
@@ -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,
|
||||
translateToNewGQLCollection,
|
||||
} from "@hoppscotch/data"
|
||||
import { currentUser$ } from "./auth"
|
||||
import { platform } from "~/platform"
|
||||
import {
|
||||
restCollections$,
|
||||
graphqlCollections$,
|
||||
@@ -44,20 +44,22 @@ export async function writeCollections(
|
||||
collection: any[],
|
||||
flag: CollectionFlags
|
||||
) {
|
||||
if (currentUser$.value === null)
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (currentUser === null)
|
||||
throw new Error("User not logged in to write collections")
|
||||
|
||||
const cl = {
|
||||
updatedOn: new Date(),
|
||||
author: currentUser$.value.uid,
|
||||
author_name: currentUser$.value.displayName,
|
||||
author_image: currentUser$.value.photoURL,
|
||||
author: currentUser.uid,
|
||||
author_name: currentUser.displayName,
|
||||
author_image: currentUser.photoURL,
|
||||
collection,
|
||||
}
|
||||
|
||||
try {
|
||||
await setDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid, flag, "sync"),
|
||||
doc(getFirestore(), "users", currentUser.uid, flag, "sync"),
|
||||
cl
|
||||
)
|
||||
} catch (e) {
|
||||
@@ -67,10 +69,14 @@ export async function writeCollections(
|
||||
}
|
||||
|
||||
export function initCollections() {
|
||||
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||
|
||||
const restCollSub = restCollections$.subscribe((collections) => {
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (
|
||||
loadedRESTCollections &&
|
||||
currentUser$.value &&
|
||||
currentUser &&
|
||||
settingsStore.value.syncCollections
|
||||
) {
|
||||
writeCollections(collections, "collections")
|
||||
@@ -80,7 +86,7 @@ export function initCollections() {
|
||||
const gqlCollSub = graphqlCollections$.subscribe((collections) => {
|
||||
if (
|
||||
loadedGraphqlCollections &&
|
||||
currentUser$.value &&
|
||||
currentUser &&
|
||||
settingsStore.value.syncCollections
|
||||
) {
|
||||
writeCollections(collections, "collectionsGraphql")
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
onSnapshot,
|
||||
setDoc,
|
||||
} from "firebase/firestore"
|
||||
import { currentUser$ } from "./auth"
|
||||
import { platform } from "~/platform"
|
||||
import {
|
||||
environments$,
|
||||
globalEnv$,
|
||||
@@ -32,26 +32,22 @@ let loadedEnvironments = false
|
||||
let loadedGlobals = true
|
||||
|
||||
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")
|
||||
|
||||
const ev = {
|
||||
updatedOn: new Date(),
|
||||
author: currentUser$.value.uid,
|
||||
author_name: currentUser$.value.displayName,
|
||||
author_image: currentUser$.value.photoURL,
|
||||
author: currentUser.uid,
|
||||
author_name: currentUser.displayName,
|
||||
author_image: currentUser.photoURL,
|
||||
environment,
|
||||
}
|
||||
|
||||
try {
|
||||
await setDoc(
|
||||
doc(
|
||||
getFirestore(),
|
||||
"users",
|
||||
currentUser$.value.uid,
|
||||
"environments",
|
||||
"sync"
|
||||
),
|
||||
doc(getFirestore(), "users", currentUser.uid, "environments", "sync"),
|
||||
ev
|
||||
)
|
||||
} catch (e) {
|
||||
@@ -61,20 +57,22 @@ async function writeEnvironments(environment: Environment[]) {
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
const ev = {
|
||||
updatedOn: new Date(),
|
||||
author: currentUser$.value.uid,
|
||||
author_name: currentUser$.value.displayName,
|
||||
author_image: currentUser$.value.photoURL,
|
||||
author: currentUser.uid,
|
||||
author_name: currentUser.displayName,
|
||||
author_image: currentUser.photoURL,
|
||||
variables,
|
||||
}
|
||||
|
||||
try {
|
||||
await setDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid, "globalEnv", "sync"),
|
||||
doc(getFirestore(), "users", currentUser.uid, "globalEnv", "sync"),
|
||||
ev
|
||||
)
|
||||
} catch (e) {
|
||||
@@ -84,9 +82,13 @@ async function writeGlobalEnvironment(variables: Environment["variables"]) {
|
||||
}
|
||||
|
||||
export function initEnvironments() {
|
||||
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||
|
||||
const envListenSub = environments$.subscribe((envs) => {
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (
|
||||
currentUser$.value &&
|
||||
currentUser &&
|
||||
settingsStore.value.syncEnvironments &&
|
||||
loadedEnvironments
|
||||
) {
|
||||
@@ -95,11 +97,9 @@ export function initEnvironments() {
|
||||
})
|
||||
|
||||
const globalListenSub = globalEnv$.subscribe((vars) => {
|
||||
if (
|
||||
currentUser$.value &&
|
||||
settingsStore.value.syncEnvironments &&
|
||||
loadedGlobals
|
||||
) {
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (currentUser && settingsStore.value.syncEnvironments && loadedGlobals) {
|
||||
writeGlobalEnvironment(vars)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
updateDoc,
|
||||
} from "firebase/firestore"
|
||||
import { FormDataKeyValue } from "@hoppscotch/data"
|
||||
import { currentUser$ } from "./auth"
|
||||
import { platform } from "~/platform"
|
||||
import { getSettingSubject, settingsStore } from "~/newstore/settings"
|
||||
import {
|
||||
GQLHistoryEntry,
|
||||
@@ -76,7 +76,9 @@ async function writeHistory(
|
||||
? purgeFormDataFromRequest(entry as RESTHistoryEntry)
|
||||
: entry
|
||||
|
||||
if (currentUser$.value == null)
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (currentUser === null)
|
||||
throw new Error("User not logged in to sync history")
|
||||
|
||||
const hs = {
|
||||
@@ -85,10 +87,7 @@ async function writeHistory(
|
||||
}
|
||||
|
||||
try {
|
||||
await addDoc(
|
||||
collection(getFirestore(), "users", currentUser$.value.uid, col),
|
||||
hs
|
||||
)
|
||||
await addDoc(collection(getFirestore(), "users", currentUser.uid, col), hs)
|
||||
} catch (e) {
|
||||
console.error("error writing to history", hs, e)
|
||||
throw e
|
||||
@@ -99,12 +98,14 @@ async function deleteHistory(
|
||||
entry: (RESTHistoryEntry | GQLHistoryEntry) & { id: string },
|
||||
col: HistoryFBCollections
|
||||
) {
|
||||
if (currentUser$.value == null)
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (currentUser === null)
|
||||
throw new Error("User not logged in to delete history")
|
||||
|
||||
try {
|
||||
await deleteDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid, col, entry.id)
|
||||
doc(getFirestore(), "users", currentUser.uid, col, entry.id)
|
||||
)
|
||||
} catch (e) {
|
||||
console.error("error deleting history", entry, e)
|
||||
@@ -113,11 +114,13 @@ async function deleteHistory(
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
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)))
|
||||
@@ -127,12 +130,13 @@ async function toggleStar(
|
||||
entry: (RESTHistoryEntry | GQLHistoryEntry) & { id: string },
|
||||
col: HistoryFBCollections
|
||||
) {
|
||||
if (currentUser$.value == null)
|
||||
throw new Error("User not logged in to toggle star")
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (currentUser === null) throw new Error("User not logged in to toggle star")
|
||||
|
||||
try {
|
||||
await updateDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid, col, entry.id),
|
||||
doc(getFirestore(), "users", currentUser.uid, col, entry.id),
|
||||
{ star: !entry.star }
|
||||
)
|
||||
} catch (e) {
|
||||
@@ -142,12 +146,12 @@ async function toggleStar(
|
||||
}
|
||||
|
||||
export function initHistory() {
|
||||
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||
|
||||
const restHistorySub = restHistoryStore.dispatches$.subscribe((dispatch) => {
|
||||
if (
|
||||
loadedRESTHistory &&
|
||||
currentUser$.value &&
|
||||
settingsStore.value.syncHistory
|
||||
) {
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (loadedRESTHistory && currentUser && settingsStore.value.syncHistory) {
|
||||
if (dispatch.dispatcher === "addEntry") {
|
||||
writeHistory(dispatch.payload.entry, "history")
|
||||
} else if (dispatch.dispatcher === "deleteEntry") {
|
||||
@@ -162,9 +166,11 @@ export function initHistory() {
|
||||
|
||||
const gqlHistorySub = graphqlHistoryStore.dispatches$.subscribe(
|
||||
(dispatch) => {
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (
|
||||
loadedGraphqlHistory &&
|
||||
currentUser$.value &&
|
||||
currentUser &&
|
||||
settingsStore.value.syncHistory
|
||||
) {
|
||||
if (dispatch.dispatcher === "addEntry") {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { initializeApp } from "firebase/app"
|
||||
import { platform } from "~/platform"
|
||||
import { initAnalytics } from "./analytics"
|
||||
import { initAuth } from "./auth"
|
||||
import { initCollections } from "./collections"
|
||||
import { initEnvironments } from "./environments"
|
||||
import { initHistory } from "./history"
|
||||
@@ -24,7 +24,7 @@ export function initializeFirebase() {
|
||||
try {
|
||||
initializeApp(firebaseConfig)
|
||||
|
||||
initAuth()
|
||||
platform.auth.performAuthInit()
|
||||
initSettings()
|
||||
initCollections()
|
||||
initHistory()
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import { doc, getDoc, getFirestore, setDoc } from "firebase/firestore"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
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"
|
||||
|
||||
/**
|
||||
@@ -44,7 +45,7 @@ function writeCurrentRequest(user: HoppUser, request: HoppRESTRequest) {
|
||||
* @returns Fetched request object if exists else null
|
||||
*/
|
||||
export async function loadRequestFromSync(): Promise<HoppRESTRequest | null> {
|
||||
const currentUser = currentUser$.value
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (!currentUser)
|
||||
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.
|
||||
*/
|
||||
export function startRequestSync(): Subscription {
|
||||
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||
|
||||
const sub = combineLatest([
|
||||
currentUser$,
|
||||
restRequest$.pipe(distinctUntilChanged()),
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
onSnapshot,
|
||||
setDoc,
|
||||
} from "firebase/firestore"
|
||||
import { currentUser$ } from "./auth"
|
||||
import { platform } from "~/platform"
|
||||
import { applySetting, settingsStore, SettingsType } from "~/newstore/settings"
|
||||
|
||||
/**
|
||||
@@ -20,21 +20,23 @@ let loadedSettings = false
|
||||
* Write Transform
|
||||
*/
|
||||
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")
|
||||
|
||||
const st = {
|
||||
updatedOn: new Date(),
|
||||
author: currentUser$.value.uid,
|
||||
author_name: currentUser$.value.displayName,
|
||||
author_image: currentUser$.value.photoURL,
|
||||
author: currentUser.uid,
|
||||
author_name: currentUser.displayName,
|
||||
author_image: currentUser.photoURL,
|
||||
name: setting,
|
||||
value,
|
||||
}
|
||||
|
||||
try {
|
||||
await setDoc(
|
||||
doc(getFirestore(), "users", currentUser$.value.uid, "settings", setting),
|
||||
doc(getFirestore(), "users", currentUser.uid, "settings", setting),
|
||||
st
|
||||
)
|
||||
} catch (e) {
|
||||
@@ -44,8 +46,12 @@ async function writeSettings(setting: string, value: any) {
|
||||
}
|
||||
|
||||
export function initSettings() {
|
||||
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||
|
||||
settingsStore.dispatches$.subscribe((dispatch) => {
|
||||
if (currentUser$.value && loadedSettings) {
|
||||
const currentUser = platform.auth.getCurrentUser()
|
||||
|
||||
if (currentUser && loadedSettings) {
|
||||
if (dispatch.dispatcher === "bulkApplySettings") {
|
||||
Object.keys(dispatch.payload).forEach((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 { GQLError, runGQLQuery } from "../backend/GQLClient"
|
||||
import { GetMyTeamsDocument, GetMyTeamsQuery } from "../backend/graphql"
|
||||
import { authIdToken$ } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
|
||||
const BACKEND_PAGE_SIZE = 10
|
||||
const POLL_DURATION = 10000
|
||||
@@ -47,8 +47,10 @@ export default class TeamListAdapter {
|
||||
}
|
||||
|
||||
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 (!authIdToken$.value) return
|
||||
if (!currentUser) return
|
||||
|
||||
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 { performMigrations } from "./helpers/migrations"
|
||||
import { initializeFirebase } from "./helpers/fb"
|
||||
import { initUserInfo } from "./helpers/teams/BackendUserInfo"
|
||||
import { initBackendGQLClient } from "./helpers/backend/GQLClient"
|
||||
import { HOPP_MODULES } from "@modules/."
|
||||
|
||||
import "virtual:windi.css"
|
||||
@@ -12,33 +13,16 @@ import "nprogress/nprogress.css"
|
||||
|
||||
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) {
|
||||
platform = platformDef
|
||||
setPlatformDef(platformDef)
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Some basic work that needs to be done before module inits even
|
||||
initializeFirebase()
|
||||
initBackendGQLClient()
|
||||
setupLocalPersistence()
|
||||
performMigrations()
|
||||
initUserInfo()
|
||||
|
||||
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { settingsStore } from "~/newstore/settings"
|
||||
import { App } from "vue"
|
||||
import { APP_IS_IN_DEV_MODE } from "~/helpers/dev"
|
||||
import { gqlClientError$ } from "~/helpers/backend/GQLClient"
|
||||
import { currentUser$ } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
|
||||
/**
|
||||
* The tag names we allow giving to Sentry
|
||||
@@ -164,6 +164,8 @@ function subscribeToAppEventsForReporting() {
|
||||
* additional data tags for the error reporting
|
||||
*/
|
||||
function subscribeForAppDataTags() {
|
||||
const currentUser$ = platform.auth.getCurrentUserStream()
|
||||
|
||||
currentUser$.subscribe((user) => {
|
||||
if (sentryActive) {
|
||||
Sentry.setTag("user_logged_in", !!user)
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
import { defineComponent } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { initializeFirebase } from "~/helpers/fb"
|
||||
import { isSignInWithEmailLink, signInWithEmailLink } from "~/helpers/fb/auth"
|
||||
import { getLocalConfig, removeLocalConfig } from "~/newstore/localpersistence"
|
||||
import { platform } from "~/platform"
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
@@ -29,29 +28,14 @@ export default defineComponent({
|
||||
initializeFirebase()
|
||||
},
|
||||
async mounted() {
|
||||
if (isSignInWithEmailLink(window.location.href)) {
|
||||
this.signingInWithEmail = true
|
||||
this.signingInWithEmail = true
|
||||
|
||||
let email = getLocalConfig("emailForSignIn")
|
||||
|
||||
if (!email) {
|
||||
email = window.prompt(
|
||||
"Please provide your email for confirmation"
|
||||
) as string
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
try {
|
||||
await platform.auth.processMagicLink()
|
||||
} catch (e) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
this.signingInWithEmail = false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -156,7 +156,7 @@ import {
|
||||
} from "~/helpers/backend/graphql"
|
||||
import { acceptTeamInvitation } from "~/helpers/backend/mutations/TeamInvitation"
|
||||
import { initializeFirebase } from "~/helpers/fb"
|
||||
import { currentUser$, probableUser$ } from "~/helpers/fb/auth"
|
||||
import { platform } from "~/platform"
|
||||
import { onLoggedIn } from "@composables/auth"
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
import { useToast } from "@composables/toast"
|
||||
@@ -197,8 +197,15 @@ export default defineComponent({
|
||||
}
|
||||
})
|
||||
|
||||
const probableUser = useReadonlyStream(probableUser$, null)
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
const probableUser = useReadonlyStream(
|
||||
platform.auth.getProbableUserStream(),
|
||||
platform.auth.getProbableUser()
|
||||
)
|
||||
|
||||
const currentUser = useReadonlyStream(
|
||||
platform.auth.getCurrentUserStream(),
|
||||
platform.auth.getCurrentUser()
|
||||
)
|
||||
|
||||
const loadingCurrentUser = computed(() => {
|
||||
if (!probableUser.value) return false
|
||||
|
||||
@@ -211,13 +211,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect, computed } from "vue"
|
||||
import {
|
||||
currentUser$,
|
||||
probableUser$,
|
||||
setDisplayName,
|
||||
setEmailAddress,
|
||||
verifyEmailAddress,
|
||||
} from "~/helpers/fb/auth"
|
||||
|
||||
import { platform } from "~/platform"
|
||||
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
|
||||
import { useReadonlyStream } from "@composables/stream"
|
||||
@@ -247,8 +243,14 @@ usePageHead({
|
||||
const SYNC_COLLECTIONS = useSetting("syncCollections")
|
||||
const SYNC_ENVIRONMENTS = useSetting("syncEnvironments")
|
||||
const SYNC_HISTORY = useSetting("syncHistory")
|
||||
const currentUser = useReadonlyStream(currentUser$, null)
|
||||
const probableUser = useReadonlyStream(probableUser$, null)
|
||||
const currentUser = useReadonlyStream(
|
||||
platform.auth.getCurrentUserStream(),
|
||||
platform.auth.getCurrentUser()
|
||||
)
|
||||
const probableUser = useReadonlyStream(
|
||||
platform.auth.getProbableUserStream(),
|
||||
platform.auth.getProbableUser()
|
||||
)
|
||||
|
||||
const loadingCurrentUser = computed(() => {
|
||||
if (!probableUser.value) return false
|
||||
@@ -262,7 +264,8 @@ watchEffect(() => (displayName.value = currentUser.value?.displayName))
|
||||
|
||||
const updateDisplayName = () => {
|
||||
updatingDisplayName.value = true
|
||||
setDisplayName(displayName.value as string)
|
||||
platform.auth
|
||||
.setDisplayName(displayName.value as string)
|
||||
.then(() => {
|
||||
toast.success(`${t("profile.updated")}`)
|
||||
})
|
||||
@@ -280,7 +283,8 @@ watchEffect(() => (emailAddress.value = currentUser.value?.email))
|
||||
|
||||
const updateEmailAddress = () => {
|
||||
updatingEmailAddress.value = true
|
||||
setEmailAddress(emailAddress.value as string)
|
||||
platform.auth
|
||||
.setEmailAddress(emailAddress.value as string)
|
||||
.then(() => {
|
||||
toast.success(`${t("profile.updated")}`)
|
||||
})
|
||||
@@ -296,7 +300,8 @@ const verifyingEmailAddress = ref(false)
|
||||
|
||||
const sendEmailVerification = () => {
|
||||
verifyingEmailAddress.value = true
|
||||
verifyEmailAddress()
|
||||
platform.auth
|
||||
.verifyEmailAddress()
|
||||
.then(() => {
|
||||
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/**/*.tsx",
|
||||
"src/**/*.vue",
|
||||
]
|
||||
],
|
||||
"vueCompilerOptions": {
|
||||
"jsxTemplates": true,
|
||||
"experimentalRfc436": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export default defineConfig({
|
||||
lowerSecondaryStickyFold: "var(--lower-secondary-sticky-fold)",
|
||||
lowerTertiaryStickyFold: "var(--lower-tertiary-sticky-fold)",
|
||||
sidebarPrimaryStickyFold: "var(--sidebar-primary-sticky-fold)",
|
||||
sidebarSecondaryStickyFold: "var(--line-height-body)",
|
||||
},
|
||||
colors: {
|
||||
primary: "var(--primary-color)",
|
||||
|
||||
@@ -2,6 +2,16 @@ import { HstVue } from "@histoire/plugin-vue"
|
||||
import { defineConfig } from "histoire"
|
||||
|
||||
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",
|
||||
plugins: [HstVue()],
|
||||
})
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"build": "vite build",
|
||||
"story:dev": "histoire dev",
|
||||
"story:build": "histoire build",
|
||||
"story:preview": "histoire preview"
|
||||
"story:preview": "histoire preview",
|
||||
"do-build-ui": "pnpm run story:build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
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,
|
||||
},
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
|
||||
activeInfoIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
export default defineComponent({
|
||||
props: {
|
||||
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,
|
||||
},
|
||||
|
||||
/**
|
||||
* This will be a component!
|
||||
*/
|
||||
infoIcon: {
|
||||
type: Object,
|
||||
default: null,
|
||||
activeInfoIcon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
/**
|
||||
* This will be a component!
|
||||
*/
|
||||
infoIcon: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -106,36 +106,25 @@ import { HoppUIPluginOptions, HOPP_UI_OPTIONS } from "./../../index"
|
||||
const { t, onModalOpen, onModalClose } =
|
||||
inject<HoppUIPluginOptions>(HOPP_UI_OPTIONS) ?? {}
|
||||
|
||||
defineProps({
|
||||
dialog: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
dimissible: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: "top",
|
||||
},
|
||||
fullWidth: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
styles: {
|
||||
type: String,
|
||||
default: "sm:max-w-lg",
|
||||
},
|
||||
closeText: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
dialog: boolean,
|
||||
title: string,
|
||||
dimissible: boolean,
|
||||
placement: string,
|
||||
fullWidth: boolean,
|
||||
styles: string,
|
||||
closeText: string | null,
|
||||
}>(), {
|
||||
dialog: false,
|
||||
title: "",
|
||||
dimissible: true,
|
||||
placement: "top",
|
||||
fullWidth: false,
|
||||
styles: "sm:max-w-lg",
|
||||
closeText: null
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "close"): void
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<template>
|
||||
<icon-lucide-loader class="animate-spin svg-icons" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue"
|
||||
|
||||
export default defineComponent({})
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Hoppscotch - Open source API development ecosystem</title>
|
||||
<title>Hoppscotch • Open source API development ecosystem</title>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
"dependencies": {
|
||||
"@hoppscotch/common": "workspace:^",
|
||||
"buffer": "^6.0.3",
|
||||
"firebase": "^9.8.4",
|
||||
"process": "^0.11.10",
|
||||
"rxjs": "^7.5.5",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"util": "^0.12.4",
|
||||
"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 { def as authDef } from "./firebase/auth"
|
||||
|
||||
createHoppApp("#app", {})
|
||||
createHoppApp("#app", {
|
||||
auth: authDef,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user