Compare commits

..

66 Commits

Author SHA1 Message Date
Balu Babu
a134e20cdf chore: reverted old services to old ports 2023-11-14 04:49:45 +05:30
Balu Babu
a501282f2b chore: made changes to hoppscotch-aio for subpath access 2023-11-13 22:42:08 +05:30
Balu Babu
eb248fa0df chore: added subpath support to hoppscotch-backend service 2023-11-13 21:35:22 +05:30
Balu Babu
f1e4ac7fc4 chore: hoppscotch-sh-admin supports std http ports 2023-11-13 02:14:43 +05:30
Balu Babu
28059ddc60 chore: hoppscotch-app supports std http ports 2023-11-13 01:42:23 +05:30
jamesgeorge007
64517a53af feat: subpath access for individual containers 2023-11-10 12:51:35 +05:30
jamesgeorge007
79a9285f93 chore: dedicated Caddyfiles based on AIO container launch strategies
Determine the Caddy config file based on the `ENABLE_SUBPATH_BASED_ACCESS` environment variable.
2023-11-10 12:51:35 +05:30
jamesgeorge007
aebcbac979 chore: generate multiple builds for sh-admin
Enables seamless transition to/from subpath based access
2023-11-10 12:51:35 +05:30
jamesgeorge007
d19d96ba9c chore: add TODO comment for pending Caddy configurations and port updates 2023-11-10 12:51:35 +05:30
jamesgeorge007
312940009e refactor: infer base URL from the BASE_URL env var across the app 2023-11-10 12:51:35 +05:30
jamesgeorge007
8703a0dcfd build: generate build with admin as the base for subpath based access 2023-11-10 12:51:35 +05:30
jamesgeorge007
03e21e0b0c fix: ensure any non-existent file in the server is routed to the corresponding SPA
try_files avoids 404s, redirecting to SPA root; catch-all handles unknown paths for selfhost-web.
2023-11-10 12:51:35 +05:30
jamesgeorge007
d6e4b6497f chore: convey the base route as admin where sh-admin gets served 2023-11-10 12:51:35 +05:30
jamesgeorge007
2f1fca2917 chore: update aio Caddyfile 2023-11-10 12:51:35 +05:30
jamesgeorge007
04092d8597 refactor: make service worker ignore important routes 2023-11-10 12:51:35 +05:30
Balu Babu
4caf0053cd feat: introduction of shared-requests (#3476)
* feat: added new property to existing shortcode model in prisma schema

* chore: created shared-requests module

* chore: created shared-request model

* chore: complete sharedRequest query

* chore: completed mutation to create a SharedRequest

* chore: completed subscription to create a SharedRequest

* chore: completed query to fetch all user created shared-requests

* chore: completed mutation to delete a SharedRequest

* chore: completed subscription to delete a SharedRequest

* chore: removed unused dependncues in share-requests module

* chore: added shared-requests into user deletion spec

* test: added all testcases for shared-request module

* test: modified all relevant tests in shortcode module

* chore: added deprecated label to all queries,mutations and subscriptions in the shortcode module

* chore: resolved all comments raised in review

* feat: added ability to update and listen to updates of shared-requests

* chore: added updatedOn field to shortcode model

* chore: fixed issue with updateSharedRequest method

* chore: fixed incorrect value getting updated

* chore: added all test-cases for updateSharedRequest method

* chore: created migration for shared-requests

* chore: moved shared-requests into shortcode module

* chore: added missing import in shortcode tests

* chore: changed properties to embedProperties in shortcode model

* feat: generated migrations file for new schema changes to Shortcodes table

* chore: changed target of old-backend service in docker-compose file

* chore: fixed issue with updatedOn field in shortcodes model

* chore: removed unused dependencies

* fix: handle invalid input for shortcode properties

* Revert "fix: handle invalid input for shortcode properties"

This reverts commit 4dcb0afb18.

* chore: changed updateShortcode method name to updateEmbedProperties

* chore: changed target of hoppscotch-old-backend service to prod

---------

Co-authored-by: Mir Arif Hasan <arif.ishan05@gmail.com>
2023-11-07 17:57:51 +05:30
Andrew Bastin
93ce86f32d chore: merge hoppscotch/release/2023.8.3 into hoppscotch/release/2023.12.0 2023-11-06 18:56:01 +05:30
Andrew Bastin
4ebf850cb6 chore: bump version to 2023.8.3 2023-11-06 17:39:31 +05:30
Balu Babu
76af7d5e10 fix: mailer template issue (#3475) 2023-11-06 17:25:36 +05:30
Joel Jacob Stephen
507fe69efe feat: new banner service and added ability to bind additional services from other platforms (#3474)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-11-06 11:41:19 +05:30
Joel Jacob Stephen
23e3739718 feat: introducing a new smart table hoppscotch ui component (#3178)
Co-authored-by: Anwarul Islam <anwaarulislaam@gmail.com>
2023-11-06 11:31:55 +05:30
Nicolas Merget
5428a73811 fix: add optional chaining for teamMembers to handle undefined team (#3484)
Co-authored-by: James George <jamesgeorge998001@gmail.com>
2023-11-06 11:25:39 +05:30
Anwarul Islam
4a154e6569 chore: fix spelling mistake on type import (#3487) 2023-11-06 11:25:03 +05:30
Liyas Thomas
0aa5825d8b fix: cleanup ui and improve consistency in input elements (#3494) 2023-11-06 10:56:15 +05:30
Andrew Bastin
bdb63e99d5 fix: pin @lezer/highlight to 1.1.4 to prevent page breaks 2023-11-03 23:30:46 +05:30
Andrew Bastin
6daa043a1b chore: merge hoppscotch/release/2023.8.3 into hoppscotch/release/2023.12.0 2023-11-03 10:12:54 +05:30
James George
8175ec640a chore(data): bump dependencies (#3473) 2023-11-02 23:53:52 +05:30
James George
b5307e4a89 chore(common): implement enforced pre-commit type checks for FE service files (#3472) 2023-11-02 23:37:27 +05:30
Akash K
19294802be fix: graphql page crashing and broken syntax highlighting (#3488) 2023-11-02 23:10:37 +05:30
Andrew Bastin
cbe3e14b47 refactor: versioning and migration mechanism for public data structures (#3457)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2023-11-02 18:54:16 +05:30
Joel Jacob Stephen
9dcbc4a126 refactor: updated dashboard gql queries and components to use the new infra type of the updated schema (#3455) 2023-11-01 23:40:19 +05:30
Gaurav K P
01df1663ad fix(common): handle false negatives in url validation (#3465) 2023-11-01 22:23:33 +05:30
Anwarul Islam
a215860782 feat: replacing windicss by tailwindcss in hoppscotch-ui (#3076)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: Joel Jacob Stephen <70131076+JoelJacobStephen@users.noreply.github.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2023-11-01 20:55:08 +05:30
Nivedin
abd5288da8 refactor: move sentry to platform (#3451) 2023-11-01 18:17:55 +05:30
Michel Tomas
a89bc473f6 fix(self-hosted/web): add "useCredentials: true" to Vite PWA options (#3460) 2023-11-01 09:46:20 +05:30
Mir Arif Hasan
59b5a50a97 HBE-296 feat: introducing 'infra' type and splitting model properties between 'admin' and 'infra' (#3445)
* feat: infra type added in admin module

* feat: infra-resolver added in admin module

* feat: feedback resolved

* feat: deprecated tag added in some admin ResolveFields

* build: update pnpm-lock file

* feat: add field in infra type

* feat: admin extends user partially

* feat: admin extends user with omitting some fields

* chore: remove unused imports

* build: conflict resolve in pnpm lock file
2023-10-30 17:03:43 +06:00
Andrew Bastin
57cb59027b chore: bump codemirror dependencies 2023-10-19 13:37:07 +05:30
Andrew Bastin
d1c9c3583f chore: merge hoppscotch/release/2023.8.3 into hoppscotch/release/2023.12.0 2023-10-19 09:34:49 +05:30
James George
2462492c86 chore(cli): bump dependencies (#3441)
* chore: bump CLI dependencies

* chore: update package.json

Bump version and specify minimum Node.js version
2023-10-16 18:23:22 +05:30
Joel Jacob Stephen
7a9f0c8756 refactor: improvements to the auth implementation in admin dashboard (#3444)
* refactor: abstract axios queries to a separate helper file

* chore: delete unnecessary file

* chore: remove unnecessary console logs

* refactor: updated urls for api and authquery helpers

* refactor: updated auth implementation

* refactor: use default axios instance

* chore: improve code readability

* refactor: separate instances for rest and gql calls

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

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

* refactor: better error handling in login component

* chore: deleted unnecessary files and restructured some files

* feat: new errors file with typed error message formats

* refactor: removed unwanted usage of async await

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

* refactor: convey boolean return type in a better way

* chore: apply suggestions

* refactor: handle case when mailcatcher is not active

---------

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

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

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

* build: removed depricated apollo-server-plugin package

* build: pnpm-lock file added

* build: swc integrated

* Revert "build: swc integrated"

This reverts commit 803a01f38f210dfbcd603665893d29af565c8908.

* feat: upgrade graphql* packages version

* feat: upgrade point release

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

* chore: removed absolute wrapper divs

* feat: add import button when graphql collections are empty

* chore: add icon for button

---------

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

* feat: added rerun ability

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

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

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

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

* fix: prevent multiple window reloads

---------

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

* refactor: updated vite config to support the new plugin

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

* chore: remove console log

* fix: an unknown keyboard event issue

---------

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

* chore: linting

* refactor: eliminate redundancy

* chore: update comment about the watcher purpose

---------

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

* refactor: export environment without id

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

* refactor: new zod specific type for HoppEnvPair

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

* refactor: moved zod as a dependency to devDependency

* refactor: separated repeating logic to helper file

* refactor: removed unnecessary to string operation

* chore: rearranged smart item placement

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

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

* refactor: better logic for type shapes

* chore: bump hoppscotch-cli package version

---------

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

* refactor: alert user that headers are sent to connection_init

* refactor: send headers only when headers are populated

* chore: cleanup code
2023-09-28 21:57:07 +05:30
Andrew Bastin
65884293be chore: introduce docker buildx for multi-platform build 2023-09-18 21:16:23 +05:30
Andrew Bastin
3cb4861bac chore: pin netlify-cli version on ui deploy script 2023-09-18 20:51:42 +05:30
330 changed files with 11991 additions and 7765 deletions

View File

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

View File

@@ -59,3 +59,6 @@ VITE_BACKEND_API_URL=http://localhost:3170/v1
# Terms Of Service And Privacy Policy Links (Optional) # Terms Of Service And Privacy Policy Links (Optional)
VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms VITE_APP_TOS_LINK=https://docs.hoppscotch.io/support/terms
VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy VITE_APP_PRIVACY_POLICY_LINK=https://docs.hoppscotch.io/support/privacy
# Set to `true` for subpath based access
ENABLE_SUBPATH_BASED_ACCESS=false

View File

@@ -18,6 +18,9 @@ jobs:
- name: Setup QEMU - name: Setup QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub - name: Log in to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v2
with: with:

View File

@@ -36,7 +36,7 @@ jobs:
# Deploy the ui site with netlify-cli # Deploy the ui site with netlify-cli
- name: Deploy to Netlify (ui) - name: Deploy to Netlify (ui)
run: npx netlify-cli deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod run: npx netlify-cli@15.11.0 deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
env: env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }} NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

11
Caddyfile Normal file
View File

@@ -0,0 +1,11 @@
:3500 {
handle_path /admin* {
reverse_proxy localhost:3100
}
handle_path /backend* {
reverse_proxy localhost:3170
}
reverse_proxy localhost:3000 # Proxy other requests to your server running on port 3001
}

View File

@@ -0,0 +1,15 @@
:3000 {
try_files {path} /
root * /site/selfhost-web
file_server
}
:3100 {
try_files {path} /
root * /site/sh-admin
file_server
}
:80 {
respond 404
}

View File

@@ -0,0 +1,33 @@
:3000 {
respond 404
}
:3100 {
respond 404
}
:80 {
# Serve the `selfhost-web` SPA by default
root * /site/selfhost-web
file_server
handle_path /admin* {
root * /site/sh-admin-subpath-access
file_server
# Ensures any non-existent file in the server is routed to the SPA
try_files {path} /
}
# Handle requests under `/backend*` path
handle_path /backend* {
reverse_proxy localhost:8080
}
# Catch-all route for unknown paths, serves `selfhost-web` SPA
handle {
root * /site/selfhost-web
file_server
try_files {path} /
}
}

View File

@@ -1,11 +0,0 @@
:3000 {
try_files {path} /
root * /site/selfhost-web
file_server
}
:3100 {
try_files {path} /
root * /site/sh-admin
file_server
}

View File

@@ -49,7 +49,8 @@ execSync(`npx import-meta-env -x build.env -e build.env -p "/site/**/*"`)
fs.rmSync("build.env") fs.rmSync("build.env")
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"], "App/Admin Dashboard Caddy") const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile'
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
const backendProcess = runChildProcessWithPrefix("pnpm", ["run", "start:prod"], "Backend Server") const backendProcess = runChildProcessWithPrefix("pnpm", ["run", "start:prod"], "Backend Server")
caddyProcess.on("exit", (code) => { caddyProcess.on("exit", (code) => {

View File

@@ -17,7 +17,7 @@ services:
environment: environment:
# Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well) # Edit the below line to match your PostgresDB URL if you have an outside DB (make sure to update the .env file as well)
- DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300 - DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch?connect_timeout=300
- PORT=3170 - PORT=8080
volumes: volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target. # Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app # - ./packages/hoppscotch-backend/:/usr/src/app
@@ -26,6 +26,7 @@ services:
hoppscotch-db: hoppscotch-db:
condition: service_healthy condition: service_healthy
ports: ports:
- "3180:80"
- "3170:3170" - "3170:3170"
# The main hoppscotch app. This will be hosted at port 3000 # The main hoppscotch app. This will be hosted at port 3000
@@ -42,7 +43,8 @@ services:
depends_on: depends_on:
- hoppscotch-backend - hoppscotch-backend
ports: ports:
- "3000:8080" - "3080:80"
- "3000:3000"
# The Self Host dashboard for managing the app. This will be hosted at port 3100 # The Self Host dashboard for managing the app. This will be hosted at port 3100
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for # NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
@@ -58,7 +60,8 @@ services:
depends_on: depends_on:
- hoppscotch-backend - hoppscotch-backend
ports: ports:
- "3100:8080" - "3280:80"
- "3100:3100"
# The service that spins up all 3 services at once in one container # The service that spins up all 3 services at once in one container
hoppscotch-aio: hoppscotch-aio:
@@ -75,7 +78,8 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
- "3100:3100" - "3100:3100"
- "3170:3170" - "3170:8080"
- "3080:80"
# The preset DB service, you can delete/comment the below lines if # The preset DB service, you can delete/comment the below lines if
# you are using an external postgres instance # you are using an external postgres instance

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
:3170 {
reverse_proxy localhost:80
}

View File

@@ -0,0 +1,3 @@
:80 :3170 {
reverse_proxy localhost:8080
}

View File

@@ -0,0 +1,7 @@
:80 :3170 {
handle_path /backend* {
reverse_proxy localhost:8080
}
}

View File

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

View File

@@ -0,0 +1,15 @@
/*
Warnings:
- A unique constraint covering the columns `[id]` on the table `Shortcode` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "Shortcode" ADD COLUMN "embedProperties" JSONB,
ADD COLUMN "updatedOn" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- CreateIndex
CREATE UNIQUE INDEX "Shortcode_id_key" ON "Shortcode"("id");
-- AddForeignKey
ALTER TABLE "Shortcode" ADD CONSTRAINT "Shortcode_creatorUid_fkey" FOREIGN KEY ("creatorUid") REFERENCES "User"("uid") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -68,11 +68,13 @@ model TeamRequest {
} }
model Shortcode { model Shortcode {
id String @id id String @id @unique
request Json request Json
embedProperties Json?
creatorUid String? creatorUid String?
User User? @relation(fields: [creatorUid], references: [uid])
createdOn DateTime @default(now()) createdOn DateTime @default(now())
updatedOn DateTime @updatedAt @default(now())
@@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique") @@unique(fields: [id, creatorUid], name: "creator_uid_shortcode_unique")
} }
@@ -102,6 +104,7 @@ model User {
currentGQLSession Json? currentGQLSession Json?
createdOn DateTime @default(now()) @db.Timestamp(3) createdOn DateTime @default(now()) @db.Timestamp(3)
invitedUsers InvitedUsers[] invitedUsers InvitedUsers[]
shortcodes Shortcode[]
} }
model Account { model Account {

View File

@@ -0,0 +1,71 @@
#!/usr/local/bin/node
// @ts-check
import { execSync, spawn } from 'child_process';
import fs from 'fs';
import process from 'process';
function runChildProcessWithPrefix(command, args, prefix) {
const childProcess = spawn(command, args);
childProcess.stdout.on('data', (data) => {
const output = data.toString().trim().split('\n');
output.forEach((line) => {
console.log(`${prefix} | ${line}`);
});
});
childProcess.stderr.on('data', (data) => {
const error = data.toString().trim().split('\n');
error.forEach((line) => {
console.error(`${prefix} | ${line}`);
});
});
childProcess.on('close', (code) => {
console.log(`${prefix} Child process exited with code ${code}`);
});
childProcess.on('error', (stuff) => {
console.log('error');
console.log(stuff);
});
return childProcess;
}
const caddyFileName =
process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true'
? 'backend-subpath.Caddyfile'
: 'backend-multiport.Caddyfile';
const caddyProcess = runChildProcessWithPrefix(
'caddy',
['run', '--config', `/etc/caddy/${caddyFileName}`, '--adapter', 'caddyfile'],
'App/Admin Dashboard Caddy',
);
const backendProcess = runChildProcessWithPrefix(
'pnpm',
['run', 'start:prod'],
'Backend Server',
);
caddyProcess.on('exit', (code) => {
console.log(`Exiting process because Caddy Server exited with code ${code}`);
process.exit(code);
});
backendProcess.on('exit', (code) => {
console.log(
`Exiting process because Backend Server exited with code ${code}`,
);
process.exit(code);
});
process.on('SIGINT', () => {
console.log('SIGINT received, exiting...');
caddyProcess.kill('SIGINT');
backendProcess.kill('SIGINT');
process.exit(0);
});

View File

@@ -1,4 +1,9 @@
import { ObjectType } from '@nestjs/graphql'; import { ObjectType, OmitType } from '@nestjs/graphql';
import { User } from 'src/user/user.model';
@ObjectType() @ObjectType()
export class Admin {} export class Admin extends OmitType(User, [
'isAdmin',
'currentRESTSession',
'currentGQLSession',
]) {}

View File

@@ -10,6 +10,7 @@ import { TeamInvitationModule } from '../team-invitation/team-invitation.module'
import { TeamEnvironmentsModule } from '../team-environments/team-environments.module'; import { TeamEnvironmentsModule } from '../team-environments/team-environments.module';
import { TeamCollectionModule } from '../team-collection/team-collection.module'; import { TeamCollectionModule } from '../team-collection/team-collection.module';
import { TeamRequestModule } from '../team-request/team-request.module'; import { TeamRequestModule } from '../team-request/team-request.module';
import { InfraResolver } from './infra.resolver';
@Module({ @Module({
imports: [ imports: [
@@ -23,7 +24,7 @@ import { TeamRequestModule } from '../team-request/team-request.module';
TeamCollectionModule, TeamCollectionModule,
TeamRequestModule, TeamRequestModule,
], ],
providers: [AdminResolver, AdminService], providers: [InfraResolver, AdminResolver, AdminService],
exports: [AdminService], exports: [AdminService],
}) })
export class AdminModule {} export class AdminModule {}

View File

@@ -21,15 +21,15 @@ import { InvitedUser } from './invited-user.model';
import { GqlUser } from '../decorators/gql-user.decorator'; import { GqlUser } from '../decorators/gql-user.decorator';
import { PubSubService } from '../pubsub/pubsub.service'; import { PubSubService } from '../pubsub/pubsub.service';
import { Team, TeamMember } from '../team/team.model'; import { Team, TeamMember } from '../team/team.model';
import { User } from '../user/user.model';
import { TeamInvitation } from '../team-invitation/team-invitation.model';
import { PaginationArgs } from '../types/input-types.args';
import { import {
AddUserToTeamArgs, AddUserToTeamArgs,
ChangeUserRoleInTeamArgs, ChangeUserRoleInTeamArgs,
} from './input-types.args'; } from './input-types.args';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { User } from 'src/user/user.model';
import { PaginationArgs } from 'src/types/input-types.args';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => Admin) @Resolver(() => Admin)
@@ -51,6 +51,7 @@ export class AdminResolver {
@ResolveField(() => [User], { @ResolveField(() => [User], {
description: 'Returns a list of all admin users in infra', description: 'Returns a list of all admin users in infra',
deprecationReason: 'Use `infra` query instead',
}) })
@UseGuards(GqlAuthGuard, GqlAdminGuard) @UseGuards(GqlAuthGuard, GqlAdminGuard)
async admins() { async admins() {
@@ -59,6 +60,7 @@ export class AdminResolver {
} }
@ResolveField(() => User, { @ResolveField(() => User, {
description: 'Returns a user info by UID', description: 'Returns a user info by UID',
deprecationReason: 'Use `infra` query instead',
}) })
@UseGuards(GqlAuthGuard, GqlAdminGuard) @UseGuards(GqlAuthGuard, GqlAdminGuard)
async userInfo( async userInfo(
@@ -76,6 +78,7 @@ export class AdminResolver {
@ResolveField(() => [User], { @ResolveField(() => [User], {
description: 'Returns a list of all the users in infra', description: 'Returns a list of all the users in infra',
deprecationReason: 'Use `infra` query instead',
}) })
@UseGuards(GqlAuthGuard, GqlAdminGuard) @UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers( async allUsers(
@@ -88,6 +91,7 @@ export class AdminResolver {
@ResolveField(() => [InvitedUser], { @ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users', description: 'Returns a list of all the invited users',
deprecationReason: 'Use `infra` query instead',
}) })
async invitedUsers(@Parent() admin: Admin): Promise<InvitedUser[]> { async invitedUsers(@Parent() admin: Admin): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers(); const users = await this.adminService.fetchInvitedUsers();
@@ -96,6 +100,7 @@ export class AdminResolver {
@ResolveField(() => [Team], { @ResolveField(() => [Team], {
description: 'Returns a list of all the teams in the infra', description: 'Returns a list of all the teams in the infra',
deprecationReason: 'Use `infra` query instead',
}) })
async allTeams( async allTeams(
@Parent() admin: Admin, @Parent() admin: Admin,
@@ -106,6 +111,7 @@ export class AdminResolver {
} }
@ResolveField(() => Team, { @ResolveField(() => Team, {
description: 'Returns a team info by ID when requested by Admin', description: 'Returns a team info by ID when requested by Admin',
deprecationReason: 'Use `infra` query instead',
}) })
async teamInfo( async teamInfo(
@Parent() admin: Admin, @Parent() admin: Admin,
@@ -123,6 +129,7 @@ export class AdminResolver {
@ResolveField(() => Number, { @ResolveField(() => Number, {
description: 'Return count of all the members in a team', description: 'Return count of all the members in a team',
deprecationReason: 'Use `infra` query instead',
}) })
async membersCountInTeam( async membersCountInTeam(
@Parent() admin: Admin, @Parent() admin: Admin,
@@ -140,6 +147,7 @@ export class AdminResolver {
@ResolveField(() => Number, { @ResolveField(() => Number, {
description: 'Return count of all the stored collections in a team', description: 'Return count of all the stored collections in a team',
deprecationReason: 'Use `infra` query instead',
}) })
async collectionCountInTeam( async collectionCountInTeam(
@Parent() admin: Admin, @Parent() admin: Admin,
@@ -155,6 +163,7 @@ export class AdminResolver {
} }
@ResolveField(() => Number, { @ResolveField(() => Number, {
description: 'Return count of all the stored requests in a team', description: 'Return count of all the stored requests in a team',
deprecationReason: 'Use `infra` query instead',
}) })
async requestCountInTeam( async requestCountInTeam(
@Parent() admin: Admin, @Parent() admin: Admin,
@@ -171,6 +180,7 @@ export class AdminResolver {
@ResolveField(() => Number, { @ResolveField(() => Number, {
description: 'Return count of all the stored environments in a team', description: 'Return count of all the stored environments in a team',
deprecationReason: 'Use `infra` query instead',
}) })
async environmentCountInTeam( async environmentCountInTeam(
@Parent() admin: Admin, @Parent() admin: Admin,
@@ -187,6 +197,7 @@ export class AdminResolver {
@ResolveField(() => [TeamInvitation], { @ResolveField(() => [TeamInvitation], {
description: 'Return all the pending invitations in a team', description: 'Return all the pending invitations in a team',
deprecationReason: 'Use `infra` query instead',
}) })
async pendingInvitationCountInTeam( async pendingInvitationCountInTeam(
@Parent() admin: Admin, @Parent() admin: Admin,
@@ -205,6 +216,7 @@ export class AdminResolver {
@ResolveField(() => Number, { @ResolveField(() => Number, {
description: 'Return total number of Users in organization', description: 'Return total number of Users in organization',
deprecationReason: 'Use `infra` query instead',
}) })
async usersCount() { async usersCount() {
return this.adminService.getUsersCount(); return this.adminService.getUsersCount();
@@ -212,6 +224,7 @@ export class AdminResolver {
@ResolveField(() => Number, { @ResolveField(() => Number, {
description: 'Return total number of Teams in organization', description: 'Return total number of Teams in organization',
deprecationReason: 'Use `infra` query instead',
}) })
async teamsCount() { async teamsCount() {
return this.adminService.getTeamsCount(); return this.adminService.getTeamsCount();
@@ -219,6 +232,7 @@ export class AdminResolver {
@ResolveField(() => Number, { @ResolveField(() => Number, {
description: 'Return total number of Team Collections in organization', description: 'Return total number of Team Collections in organization',
deprecationReason: 'Use `infra` query instead',
}) })
async teamCollectionsCount() { async teamCollectionsCount() {
return this.adminService.getTeamCollectionsCount(); return this.adminService.getTeamCollectionsCount();
@@ -226,6 +240,7 @@ export class AdminResolver {
@ResolveField(() => Number, { @ResolveField(() => Number, {
description: 'Return total number of Team Requests in organization', description: 'Return total number of Team Requests in organization',
deprecationReason: 'Use `infra` query instead',
}) })
async teamRequestsCount() { async teamRequestsCount() {
return this.adminService.getTeamRequestsCount(); return this.adminService.getTeamRequestsCount();

View File

@@ -74,7 +74,7 @@ export class AdminService {
try { try {
await this.mailerService.sendUserInvitationEmail(inviteeEmail, { await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
template: 'code-your-own', template: 'user-invitation',
variables: { variables: {
inviteeEmail: inviteeEmail, inviteeEmail: inviteeEmail,
magicLink: `${process.env.VITE_BASE_URL}`, magicLink: `${process.env.VITE_BASE_URL}`,

View File

@@ -0,0 +1,10 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Admin } from './admin.model';
@ObjectType()
export class Infra {
@Field(() => Admin, {
description: 'Admin who executed the action',
})
executedBy: Admin;
}

View File

@@ -0,0 +1,205 @@
import { UseGuards } from '@nestjs/common';
import { Args, ID, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { Infra } from './infra.model';
import { AdminService } from './admin.service';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { GqlAdminGuard } from './guards/gql-admin.guard';
import { User } from 'src/user/user.model';
import { AuthUser } from 'src/types/AuthUser';
import { throwErr } from 'src/utils';
import * as E from 'fp-ts/Either';
import { Admin } from './admin.model';
import { PaginationArgs } from 'src/types/input-types.args';
import { InvitedUser } from './invited-user.model';
import { Team } from 'src/team/team.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { GqlAdmin } from './decorators/gql-admin.decorator';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Infra)
export class InfraResolver {
constructor(private adminService: AdminService) {}
@Query(() => Infra, {
description: 'Fetch details of the Infrastructure',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
infra(@GqlAdmin() admin: Admin) {
const infra: Infra = { executedBy: admin };
return infra;
}
@ResolveField(() => [User], {
description: 'Returns a list of all admin users in infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async admins() {
const admins = await this.adminService.fetchAdmins();
return admins;
}
@ResolveField(() => User, {
description: 'Returns a user info by UID',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async userInfo(
@Args({
name: 'userUid',
type: () => ID,
description: 'The user UID',
})
userUid: string,
): Promise<AuthUser> {
const user = await this.adminService.fetchUserInfo(userUid);
if (E.isLeft(user)) throwErr(user.left);
return user.right;
}
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
const users = await this.adminService.fetchUsers(args.cursor, args.take);
return users;
}
@ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users',
})
async invitedUsers(): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers();
return users;
}
@ResolveField(() => [Team], {
description: 'Returns a list of all the teams in the infra',
})
async allTeams(@Args() args: PaginationArgs): Promise<Team[]> {
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
return teams;
}
@ResolveField(() => Team, {
description: 'Returns a team info by ID when requested by Admin',
})
async teamInfo(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which info to fetch',
})
teamID: string,
): Promise<Team> {
const team = await this.adminService.getTeamInfo(teamID);
if (E.isLeft(team)) throwErr(team.left);
return team.right;
}
@ResolveField(() => Number, {
description: 'Return count of all the members in a team',
})
async membersCountInTeam(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
nullable: false,
})
teamID: string,
): Promise<number> {
const teamMembersCount = await this.adminService.membersCountInTeam(teamID);
return teamMembersCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored collections in a team',
})
async collectionCountInTeam(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamCollCount = await this.adminService.collectionCountInTeam(teamID);
return teamCollCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored requests in a team',
})
async requestCountInTeam(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamReqCount = await this.adminService.requestCountInTeam(teamID);
return teamReqCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored environments in a team',
})
async environmentCountInTeam(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const envsCount = await this.adminService.environmentCountInTeam(teamID);
return envsCount;
}
@ResolveField(() => [TeamInvitation], {
description: 'Return all the pending invitations in a team',
})
async pendingInvitationCountInTeam(
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
) {
const invitations = await this.adminService.pendingInvitationCountInTeam(
teamID,
);
return invitations;
}
@ResolveField(() => Number, {
description: 'Return total number of Users in organization',
})
async usersCount() {
return this.adminService.getUsersCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Teams in organization',
})
async teamsCount() {
return this.adminService.getTeamsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Collections in organization',
})
async teamCollectionsCount() {
return this.adminService.getTeamCollectionsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Requests in organization',
})
async teamRequestsCount() {
return this.adminService.getTeamRequestsCount();
}
}

View File

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

View File

@@ -229,7 +229,7 @@ export class AuthService {
} }
await this.mailerService.sendEmail(email, { await this.mailerService.sendEmail(email, {
template: 'code-your-own', template: 'user-invitation',
variables: { variables: {
inviteeEmail: email, inviteeEmail: email,
magicLink: `${url}/enter?token=${generatedTokens.token}`, magicLink: `${url}/enter?token=${generatedTokens.token}`,

View File

@@ -318,18 +318,6 @@ export const TEAM_INVITATION_NOT_FOUND =
*/ */
export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const; export const SHORTCODE_NOT_FOUND = 'shortcode/not_found' as const;
/**
* Invalid ShortCode format
* (ShortcodeService)
*/
export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const;
/**
* ShortCode already exists in DB
* (ShortcodeService)
*/
export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const;
/** /**
* Invalid or non-existent TEAM ENVIRONMENT ID * Invalid or non-existent TEAM ENVIRONMENT ID
* (TeamEnvironmentsService) * (TeamEnvironmentsService)
@@ -621,3 +609,24 @@ export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
*/ */
export const MAILER_FROM_ADDRESS_UNDEFINED = export const MAILER_FROM_ADDRESS_UNDEFINED =
'mailer/from_address_undefined' as const; 'mailer/from_address_undefined' as const;
/**
* SharedRequest invalid request JSON format
* (ShortcodeService)
*/
export const SHORTCODE_INVALID_REQUEST_JSON =
'shortcode/request_invalid_format' as const;
/**
* SharedRequest invalid properties JSON format
* (ShortcodeService)
*/
export const SHORTCODE_INVALID_PROPERTIES_JSON =
'shortcode/properties_invalid_format' as const;
/**
* SharedRequest invalid properties not found
* (ShortcodeService)
*/
export const SHORTCODE_PROPERTIES_NOT_FOUND =
'shortcode/properties_not_found' as const;

View File

@@ -27,6 +27,7 @@ import { UserRequestUserCollectionResolver } from './user-request/resolvers/user
import { UserEnvsUserResolver } from './user-environment/user.resolver'; import { UserEnvsUserResolver } from './user-environment/user.resolver';
import { UserHistoryUserResolver } from './user-history/user.resolver'; import { UserHistoryUserResolver } from './user-history/user.resolver';
import { UserSettingsUserResolver } from './user-settings/user.resolver'; import { UserSettingsUserResolver } from './user-settings/user.resolver';
import { InfraResolver } from './admin/infra.resolver';
/** /**
* All the resolvers present in the application. * All the resolvers present in the application.
@@ -34,6 +35,7 @@ import { UserSettingsUserResolver } from './user-settings/user.resolver';
* NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate * NOTE: This needs to be KEPT UP-TO-DATE to keep the schema accurate
*/ */
const RESOLVERS = [ const RESOLVERS = [
InfraResolver,
AdminResolver, AdminResolver,
ShortcodeResolver, ShortcodeResolver,
TeamResolver, TeamResolver,
@@ -93,9 +95,7 @@ export async function emitGQLSchemaFile() {
numberScalarMode: 'integer', numberScalarMode: 'integer',
}); });
const schemaString = printSchema(schema, { const schemaString = printSchema(schema);
commentDescriptions: true,
});
logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`); logger.log(`Writing schema to GQL_SCHEMA_EMIT_LOCATION (${destination})`);

View File

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

View File

@@ -8,7 +8,7 @@ export type MailDescription = {
}; };
export type UserMagicLinkMailDescription = { export type UserMagicLinkMailDescription = {
template: 'code-your-own'; template: 'user-invitation';
variables: { variables: {
inviteeEmail: string; inviteeEmail: string;
magicLink: string; magicLink: string;
@@ -16,7 +16,7 @@ export type UserMagicLinkMailDescription = {
}; };
export type AdminUserInvitationMailDescription = { export type AdminUserInvitationMailDescription = {
template: 'code-your-own'; template: 'user-invitation';
variables: { variables: {
inviteeEmail: string; inviteeEmail: string;
magicLink: string; magicLink: string;

View File

@@ -27,7 +27,7 @@ export class MailerService {
case 'team-invitation': case 'team-invitation':
return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`; return `${mailDesc.variables.invitee} invited you to join ${mailDesc.variables.invite_team_name} in Hoppscotch`;
case 'code-your-own': case 'user-invitation':
return 'Sign in to Hoppscotch'; return 'Sign in to Hoppscotch';
} }
} }

View File

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

View File

@@ -69,5 +69,7 @@ export type TopicDef = {
[topic: `team_req/${string}/req_deleted`]: string; [topic: `team_req/${string}/req_deleted`]: string;
[topic: `team/${string}/invite_added`]: TeamInvitation; [topic: `team/${string}/invite_added`]: TeamInvitation;
[topic: `team/${string}/invite_removed`]: string; [topic: `team/${string}/invite_removed`]: string;
[topic: `shortcode/${string}/${'created' | 'revoked'}`]: Shortcode; [
topic: `shortcode/${string}/${'created' | 'revoked' | 'updated'}`
]: Shortcode;
}; };

View File

@@ -3,7 +3,7 @@ import { Field, ID, ObjectType } from '@nestjs/graphql';
@ObjectType() @ObjectType()
export class Shortcode { export class Shortcode {
@Field(() => ID, { @Field(() => ID, {
description: 'The shortcode. 12 digit alphanumeric.', description: 'The 12 digit alphanumeric code',
}) })
id: string; id: string;
@@ -12,6 +12,12 @@ export class Shortcode {
}) })
request: string; request: string;
@Field({
description: 'JSON string representing the properties for an embed',
nullable: true,
})
properties: string;
@Field({ @Field({
description: 'Timestamp of when the Shortcode was created', description: 'Timestamp of when the Shortcode was created',
}) })

View File

@@ -1,5 +1,4 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from 'src/prisma/prisma.module'; import { PrismaModule } from 'src/prisma/prisma.module';
import { PubSubModule } from 'src/pubsub/pubsub.module'; import { PubSubModule } from 'src/pubsub/pubsub.module';
import { UserModule } from 'src/user/user.module'; import { UserModule } from 'src/user/user.module';
@@ -7,14 +6,7 @@ import { ShortcodeResolver } from './shortcode.resolver';
import { ShortcodeService } from './shortcode.service'; import { ShortcodeService } from './shortcode.service';
@Module({ @Module({
imports: [ imports: [PrismaModule, UserModule, PubSubModule],
PrismaModule,
UserModule,
PubSubModule,
JwtModule.register({
secret: process.env.JWT_SECRET,
}),
],
providers: [ShortcodeService, ShortcodeResolver], providers: [ShortcodeService, ShortcodeResolver],
exports: [ShortcodeService], exports: [ShortcodeService],
}) })

View File

@@ -1,6 +1,5 @@
import { import {
Args, Args,
Context,
ID, ID,
Mutation, Mutation,
Query, Query,
@@ -11,14 +10,12 @@ import * as E from 'fp-ts/Either';
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
import { Shortcode } from './shortcode.model'; import { Shortcode } from './shortcode.model';
import { ShortcodeService } from './shortcode.service'; import { ShortcodeService } from './shortcode.service';
import { UserService } from 'src/user/user.service';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlUser } from 'src/decorators/gql-user.decorator';
import { GqlAuthGuard } from 'src/guards/gql-auth.guard'; import { GqlAuthGuard } from 'src/guards/gql-auth.guard';
import { User } from 'src/user/user.model'; import { User } from 'src/user/user.model';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { AuthUser } from '../types/AuthUser'; import { AuthUser } from '../types/AuthUser';
import { JwtService } from '@nestjs/jwt';
import { PaginationArgs } from 'src/types/input-types.args'; import { PaginationArgs } from 'src/types/input-types.args';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
@@ -28,9 +25,7 @@ import { SkipThrottle } from '@nestjs/throttler';
export class ShortcodeResolver { export class ShortcodeResolver {
constructor( constructor(
private readonly shortcodeService: ShortcodeService, private readonly shortcodeService: ShortcodeService,
private readonly userService: UserService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private jwtService: JwtService,
) {} ) {}
/* Queries */ /* Queries */
@@ -64,20 +59,53 @@ export class ShortcodeResolver {
@Mutation(() => Shortcode, { @Mutation(() => Shortcode, {
description: 'Create a shortcode for the given request.', description: 'Create a shortcode for the given request.',
}) })
@UseGuards(GqlAuthGuard)
async createShortcode( async createShortcode(
@GqlUser() user: AuthUser,
@Args({ @Args({
name: 'request', name: 'request',
description: 'JSON string of the request object', description: 'JSON string of the request object',
}) })
request: string, request: string,
@Context() ctx: any, @Args({
name: 'properties',
description: 'JSON string of the properties of the embed',
nullable: true,
})
properties: string,
) { ) {
const decodedAccessToken = this.jwtService.verify(
ctx.req.cookies['access_token'],
);
const result = await this.shortcodeService.createShortcode( const result = await this.shortcodeService.createShortcode(
request, request,
decodedAccessToken?.sub, properties,
user,
);
if (E.isLeft(result)) throwErr(result.left);
return result.right;
}
@Mutation(() => Shortcode, {
description: 'Update a user generated Shortcode',
})
@UseGuards(GqlAuthGuard)
async updateEmbedProperties(
@GqlUser() user: AuthUser,
@Args({
name: 'code',
type: () => ID,
description: 'The Shortcode to update',
})
code: string,
@Args({
name: 'properties',
description: 'JSON string of the properties of the embed',
})
properties: string,
) {
const result = await this.shortcodeService.updateEmbedProperties(
code,
user.uid,
properties,
); );
if (E.isLeft(result)) throwErr(result.left); if (E.isLeft(result)) throwErr(result.left);
@@ -114,6 +142,16 @@ export class ShortcodeResolver {
return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`); return this.pubsub.asyncIterator(`shortcode/${user.uid}/created`);
} }
@Subscription(() => Shortcode, {
description: 'Listen for Shortcode updates',
resolve: (value) => value,
})
@SkipThrottle()
@UseGuards(GqlAuthGuard)
myShortcodesUpdated(@GqlUser() user: AuthUser) {
return this.pubsub.asyncIterator(`shortcode/${user.uid}/updated`);
}
@Subscription(() => Shortcode, { @Subscription(() => Shortcode, {
description: 'Listen for shortcode deletion', description: 'Listen for shortcode deletion',
resolve: (value) => value, resolve: (value) => value,

View File

@@ -1,13 +1,15 @@
import { mockDeep, mockReset } from 'jest-mock-extended'; import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { import {
SHORTCODE_ALREADY_EXISTS, SHORTCODE_INVALID_PROPERTIES_JSON,
SHORTCODE_INVALID_JSON, SHORTCODE_INVALID_REQUEST_JSON,
SHORTCODE_NOT_FOUND, SHORTCODE_NOT_FOUND,
SHORTCODE_PROPERTIES_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { Shortcode } from './shortcode.model'; import { Shortcode } from './shortcode.model';
import { ShortcodeService } from './shortcode.service'; import { ShortcodeService } from './shortcode.service';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import { AuthUser } from 'src/types/AuthUser';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
@@ -22,7 +24,7 @@ const mockFB = {
doc: mockDocFunc, doc: mockDocFunc,
}, },
}; };
const mockUserService = new UserService(mockFB as any, mockPubSub as any); const mockUserService = new UserService(mockPrisma as any, mockPubSub as any);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
@@ -38,18 +40,34 @@ beforeEach(() => {
}); });
const createdOn = new Date(); const createdOn = new Date();
const shortCodeWithOutUser = { const user: AuthUser = {
id: '123', uid: '123344',
request: '{}', email: 'dwight@dundermifflin.com',
displayName: 'Dwight Schrute',
photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute',
isAdmin: false,
refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb',
createdOn: createdOn, createdOn: createdOn,
creatorUid: null, currentGQLSession: {},
currentRESTSession: {},
}; };
const shortCodeWithUser = { const mockEmbed = {
id: '123', id: '123',
request: '{}', request: '{}',
embedProperties: '{}',
createdOn: createdOn, createdOn: createdOn,
creatorUid: 'user_uid_1', creatorUid: user.uid,
updatedOn: createdOn,
};
const mockShortcode = {
id: '123',
request: '{}',
embedProperties: null,
createdOn: createdOn,
creatorUid: user.uid,
updatedOn: createdOn,
}; };
const shortcodes = [ const shortcodes = [
@@ -58,33 +76,38 @@ const shortcodes = [
request: { request: {
hello: 'there', hello: 'there',
}, },
creatorUid: 'testuser', embedProperties: {
foo: 'bar',
},
creatorUid: user.uid,
createdOn: new Date(), createdOn: new Date(),
updatedOn: createdOn,
}, },
{ {
id: 'blablabla1', id: 'blablabla1',
request: { request: {
hello: 'there', hello: 'there',
}, },
creatorUid: 'testuser', embedProperties: {
foo: 'bar',
},
creatorUid: user.uid,
createdOn: new Date(), createdOn: new Date(),
updatedOn: createdOn,
}, },
]; ];
describe('ShortcodeService', () => { describe('ShortcodeService', () => {
describe('getShortCode', () => { describe('getShortCode', () => {
test('should return a valid shortcode with valid shortcode ID', async () => { test('should return a valid Shortcode with valid Shortcode ID', async () => {
mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce( mockPrisma.shortcode.findFirstOrThrow.mockResolvedValueOnce(mockEmbed);
shortCodeWithOutUser,
);
const result = await shortcodeService.getShortCode( const result = await shortcodeService.getShortCode(mockEmbed.id);
shortCodeWithOutUser.id,
);
expect(result).toEqualRight(<Shortcode>{ expect(result).toEqualRight(<Shortcode>{
id: shortCodeWithOutUser.id, id: mockEmbed.id,
createdOn: shortCodeWithOutUser.createdOn, createdOn: mockEmbed.createdOn,
request: JSON.stringify(shortCodeWithOutUser.request), request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify(mockEmbed.embedProperties),
}); });
}); });
@@ -99,10 +122,10 @@ describe('ShortcodeService', () => {
}); });
describe('fetchUserShortCodes', () => { describe('fetchUserShortCodes', () => {
test('should return list of shortcodes with valid inputs and no cursor', async () => { test('should return list of Shortcode with valid inputs and no cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes); mockPrisma.shortcode.findMany.mockResolvedValueOnce(shortcodes);
const result = await shortcodeService.fetchUserShortCodes('testuser', { const result = await shortcodeService.fetchUserShortCodes(user.uid, {
cursor: null, cursor: null,
take: 10, take: 10,
}); });
@@ -110,20 +133,22 @@ describe('ShortcodeService', () => {
{ {
id: shortcodes[0].id, id: shortcodes[0].id,
request: JSON.stringify(shortcodes[0].request), request: JSON.stringify(shortcodes[0].request),
properties: JSON.stringify(shortcodes[0].embedProperties),
createdOn: shortcodes[0].createdOn, createdOn: shortcodes[0].createdOn,
}, },
{ {
id: shortcodes[1].id, id: shortcodes[1].id,
request: JSON.stringify(shortcodes[1].request), request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify(shortcodes[1].embedProperties),
createdOn: shortcodes[1].createdOn, createdOn: shortcodes[1].createdOn,
}, },
]); ]);
}); });
test('should return list of shortcodes with valid inputs and cursor', async () => { test('should return list of Shortcode with valid inputs and cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]); mockPrisma.shortcode.findMany.mockResolvedValue([shortcodes[1]]);
const result = await shortcodeService.fetchUserShortCodes('testuser', { const result = await shortcodeService.fetchUserShortCodes(user.uid, {
cursor: 'blablabla', cursor: 'blablabla',
take: 10, take: 10,
}); });
@@ -131,6 +156,7 @@ describe('ShortcodeService', () => {
{ {
id: shortcodes[1].id, id: shortcodes[1].id,
request: JSON.stringify(shortcodes[1].request), request: JSON.stringify(shortcodes[1].request),
properties: JSON.stringify(shortcodes[1].embedProperties),
createdOn: shortcodes[1].createdOn, createdOn: shortcodes[1].createdOn,
}, },
]); ]);
@@ -139,7 +165,7 @@ describe('ShortcodeService', () => {
test('should return an empty array for an invalid cursor', async () => { test('should return an empty array for an invalid cursor', async () => {
mockPrisma.shortcode.findMany.mockResolvedValue([]); mockPrisma.shortcode.findMany.mockResolvedValue([]);
const result = await shortcodeService.fetchUserShortCodes('testuser', { const result = await shortcodeService.fetchUserShortCodes(user.uid, {
cursor: 'invalidcursor', cursor: 'invalidcursor',
take: 10, take: 10,
}); });
@@ -171,77 +197,111 @@ describe('ShortcodeService', () => {
}); });
describe('createShortcode', () => { describe('createShortcode', () => {
test('should throw SHORTCODE_INVALID_JSON error if incoming request data is invalid', async () => { test('should throw SHORTCODE_INVALID_REQUEST_JSON error if incoming request data is invalid', async () => {
const result = await shortcodeService.createShortcode( const result = await shortcodeService.createShortcode(
'invalidRequest', 'invalidRequest',
'user_uid_1', null,
user,
); );
expect(result).toEqualLeft(SHORTCODE_INVALID_JSON); expect(result).toEqualLeft(SHORTCODE_INVALID_REQUEST_JSON);
}); });
test('should successfully create a new shortcode with valid user uid', async () => { test('should throw SHORTCODE_INVALID_PROPERTIES_JSON error if incoming properties data is invalid', async () => {
// generateUniqueShortCodeID --> getShortCode const result = await shortcodeService.createShortcode(
'{}',
'invalid_data',
user,
);
expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON);
});
test('should successfully create a new Embed with valid user uid', async () => {
// generateUniqueShortCodeID --> getShortcode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError', 'NotFoundError',
); );
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser); mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed);
const result = await shortcodeService.createShortcode('{}', 'user_uid_1'); const result = await shortcodeService.createShortcode('{}', '{}', user);
expect(result).toEqualRight({ expect(result).toEqualRight(<Shortcode>{
id: shortCodeWithUser.id, id: mockEmbed.id,
createdOn: shortCodeWithUser.createdOn, createdOn: mockEmbed.createdOn,
request: JSON.stringify(shortCodeWithUser.request), request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify(mockEmbed.embedProperties),
}); });
}); });
test('should successfully create a new shortcode with null user uid', async () => { test('should successfully create a new ShortCode with valid user uid', async () => {
// generateUniqueShortCodeID --> getShortCode // generateUniqueShortCodeID --> getShortcode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError', 'NotFoundError',
); );
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser); mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode);
const result = await shortcodeService.createShortcode('{}', null); const result = await shortcodeService.createShortcode('{}', null, user);
expect(result).toEqualRight({ expect(result).toEqualRight(<Shortcode>{
id: shortCodeWithUser.id, id: mockShortcode.id,
createdOn: shortCodeWithUser.createdOn, createdOn: mockShortcode.createdOn,
request: JSON.stringify(shortCodeWithOutUser.request), request: JSON.stringify(mockShortcode.request),
properties: mockShortcode.embedProperties,
}); });
}); });
test('should send pubsub message to `shortcode/{uid}/created` on successful creation of shortcode', async () => { test('should send pubsub message to `shortcode/{uid}/created` on successful creation of a Shortcode', async () => {
// generateUniqueShortCodeID --> getShortCode // generateUniqueShortCodeID --> getShortcode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce( mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError', 'NotFoundError',
); );
mockPrisma.shortcode.create.mockResolvedValueOnce(shortCodeWithUser); mockPrisma.shortcode.create.mockResolvedValueOnce(mockShortcode);
const result = await shortcodeService.createShortcode('{}', null, user);
const result = await shortcodeService.createShortcode('{}', 'user_uid_1');
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${shortCodeWithUser.creatorUid}/created`, `shortcode/${mockShortcode.creatorUid}/created`,
{ <Shortcode>{
id: shortCodeWithUser.id, id: mockShortcode.id,
createdOn: shortCodeWithUser.createdOn, createdOn: mockShortcode.createdOn,
request: JSON.stringify(shortCodeWithUser.request), request: JSON.stringify(mockShortcode.request),
properties: mockShortcode.embedProperties,
},
);
});
test('should send pubsub message to `shortcode/{uid}/created` on successful creation of an Embed', async () => {
// generateUniqueShortCodeID --> getShortcode
mockPrisma.shortcode.findFirstOrThrow.mockRejectedValueOnce(
'NotFoundError',
);
mockPrisma.shortcode.create.mockResolvedValueOnce(mockEmbed);
const result = await shortcodeService.createShortcode('{}', '{}', user);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${mockEmbed.creatorUid}/created`,
<Shortcode>{
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify(mockEmbed.embedProperties),
}, },
); );
}); });
}); });
describe('revokeShortCode', () => { describe('revokeShortCode', () => {
test('should return true on successful deletion of shortcode with valid inputs', async () => { test('should return true on successful deletion of Shortcode with valid inputs', async () => {
mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser); mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed);
const result = await shortcodeService.revokeShortCode( const result = await shortcodeService.revokeShortCode(
shortCodeWithUser.id, mockEmbed.id,
shortCodeWithUser.creatorUid, mockEmbed.creatorUid,
); );
expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({ expect(mockPrisma.shortcode.delete).toHaveBeenCalledWith({
where: { where: {
creator_uid_shortcode_unique: { creator_uid_shortcode_unique: {
creatorUid: shortCodeWithUser.creatorUid, creatorUid: mockEmbed.creatorUid,
id: shortCodeWithUser.id, id: mockEmbed.id,
}, },
}, },
}); });
@@ -249,52 +309,53 @@ describe('ShortcodeService', () => {
expect(result).toEqualRight(true); expect(result).toEqualRight(true);
}); });
test('should return SHORTCODE_NOT_FOUND error when shortcode is invalid and user uid is valid', async () => { test('should return SHORTCODE_NOT_FOUND error when Shortcode is invalid and user uid is valid', async () => {
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
expect( expect(
shortcodeService.revokeShortCode('invalid', 'testuser'), shortcodeService.revokeShortCode('invalid', 'testuser'),
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
}); });
test('should return SHORTCODE_NOT_FOUND error when shortcode is valid and user uid is invalid', async () => { test('should return SHORTCODE_NOT_FOUND error when Shortcode is valid and user uid is invalid', async () => {
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
expect( expect(
shortcodeService.revokeShortCode('blablablabla', 'invalidUser'), shortcodeService.revokeShortCode('blablablabla', 'invalidUser'),
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
}); });
test('should return SHORTCODE_NOT_FOUND error when both shortcode and user uid are invalid', async () => { test('should return SHORTCODE_NOT_FOUND error when both Shortcode and user uid are invalid', async () => {
mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound'); mockPrisma.shortcode.delete.mockRejectedValue('RecordNotFound');
expect( expect(
shortcodeService.revokeShortCode('invalid', 'invalid'), shortcodeService.revokeShortCode('invalid', 'invalid'),
).resolves.toEqualLeft(SHORTCODE_NOT_FOUND); ).resolves.toEqualLeft(SHORTCODE_NOT_FOUND);
}); });
test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of shortcode', async () => { test('should send pubsub message to `shortcode/{uid}/revoked` on successful deletion of Shortcode', async () => {
mockPrisma.shortcode.delete.mockResolvedValueOnce(shortCodeWithUser); mockPrisma.shortcode.delete.mockResolvedValueOnce(mockEmbed);
const result = await shortcodeService.revokeShortCode( const result = await shortcodeService.revokeShortCode(
shortCodeWithUser.id, mockEmbed.id,
shortCodeWithUser.creatorUid, mockEmbed.creatorUid,
); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${shortCodeWithUser.creatorUid}/revoked`, `shortcode/${mockEmbed.creatorUid}/revoked`,
{ {
id: shortCodeWithUser.id, id: mockEmbed.id,
createdOn: shortCodeWithUser.createdOn, createdOn: mockEmbed.createdOn,
request: JSON.stringify(shortCodeWithUser.request), request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify(mockEmbed.embedProperties),
}, },
); );
}); });
}); });
describe('deleteUserShortCodes', () => { describe('deleteUserShortCodes', () => {
test('should successfully delete all users shortcodes with valid user uid', async () => { test('should successfully delete all users Shortcodes with valid user uid', async () => {
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 }); mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 1 });
const result = await shortcodeService.deleteUserShortCodes( const result = await shortcodeService.deleteUserShortCodes(
shortCodeWithUser.creatorUid, mockEmbed.creatorUid,
); );
expect(result).toEqual(1); expect(result).toEqual(1);
}); });
@@ -303,9 +364,81 @@ describe('ShortcodeService', () => {
mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 }); mockPrisma.shortcode.deleteMany.mockResolvedValueOnce({ count: 0 });
const result = await shortcodeService.deleteUserShortCodes( const result = await shortcodeService.deleteUserShortCodes(
shortCodeWithUser.creatorUid, mockEmbed.creatorUid,
); );
expect(result).toEqual(0); expect(result).toEqual(0);
}); });
}); });
describe('updateShortcode', () => {
test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid', async () => {
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'',
);
expect(result).toEqualLeft(SHORTCODE_PROPERTIES_NOT_FOUND);
});
test('should return SHORTCODE_PROPERTIES_NOT_FOUND error when updatedProps in invalid JSON format', async () => {
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'{kk',
);
expect(result).toEqualLeft(SHORTCODE_INVALID_PROPERTIES_JSON);
});
test('should return SHORTCODE_NOT_FOUND error when Shortcode ID is invalid', async () => {
mockPrisma.shortcode.update.mockRejectedValue('RecordNotFound');
const result = await shortcodeService.updateEmbedProperties(
'invalidID',
user.uid,
'{}',
);
expect(result).toEqualLeft(SHORTCODE_NOT_FOUND);
});
test('should successfully update a Shortcodes with valid inputs', async () => {
mockPrisma.shortcode.update.mockResolvedValueOnce({
...mockEmbed,
embedProperties: '{"foo":"bar"}',
});
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'{"foo":"bar"}',
);
expect(result).toEqualRight({
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify('{"foo":"bar"}'),
});
});
test('should send pubsub message to `shortcode/{uid}/updated` on successful Update of Shortcode', async () => {
mockPrisma.shortcode.update.mockResolvedValueOnce({
...mockEmbed,
embedProperties: '{"foo":"bar"}',
});
const result = await shortcodeService.updateEmbedProperties(
mockEmbed.id,
user.uid,
'{"foo":"bar"}',
);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`shortcode/${mockEmbed.creatorUid}/updated`,
{
id: mockEmbed.id,
createdOn: mockEmbed.createdOn,
request: JSON.stringify(mockEmbed.request),
properties: JSON.stringify('{"foo":"bar"}'),
},
);
});
});
}); });

View File

@@ -1,10 +1,14 @@
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import * as T from 'fp-ts/Task'; import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option';
import * as TO from 'fp-ts/TaskOption'; import * as TO from 'fp-ts/TaskOption';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { SHORTCODE_INVALID_JSON, SHORTCODE_NOT_FOUND } from 'src/errors'; import {
SHORTCODE_INVALID_PROPERTIES_JSON,
SHORTCODE_INVALID_REQUEST_JSON,
SHORTCODE_NOT_FOUND,
SHORTCODE_PROPERTIES_NOT_FOUND,
} from 'src/errors';
import { UserDataHandler } from 'src/user/user.data.handler'; import { UserDataHandler } from 'src/user/user.data.handler';
import { Shortcode } from './shortcode.model'; import { Shortcode } from './shortcode.model';
import { Shortcode as DBShortCode } from '@prisma/client'; import { Shortcode as DBShortCode } from '@prisma/client';
@@ -46,10 +50,14 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
* @param shortcodeInfo Prisma Shortcode type * @param shortcodeInfo Prisma Shortcode type
* @returns GQL Shortcode * @returns GQL Shortcode
*/ */
private returnShortCode(shortcodeInfo: DBShortCode): Shortcode { private cast(shortcodeInfo: DBShortCode): Shortcode {
return <Shortcode>{ return <Shortcode>{
id: shortcodeInfo.id, id: shortcodeInfo.id,
request: JSON.stringify(shortcodeInfo.request), request: JSON.stringify(shortcodeInfo.request),
properties:
shortcodeInfo.embedProperties != null
? JSON.stringify(shortcodeInfo.embedProperties)
: null,
createdOn: shortcodeInfo.createdOn, createdOn: shortcodeInfo.createdOn,
}; };
} }
@@ -94,7 +102,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({ const shortcodeInfo = await this.prisma.shortcode.findFirstOrThrow({
where: { id: shortcode }, where: { id: shortcode },
}); });
return E.right(this.returnShortCode(shortcodeInfo)); return E.right(this.cast(shortcodeInfo));
} catch (error) { } catch (error) {
return E.left(SHORTCODE_NOT_FOUND); return E.left(SHORTCODE_NOT_FOUND);
} }
@@ -104,14 +112,22 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
* Create a new ShortCode * Create a new ShortCode
* *
* @param request JSON string of request details * @param request JSON string of request details
* @param userUID user UID, if present * @param userInfo user UI
* @param properties JSON string of embed properties, if present
* @returns Either of ShortCode or error * @returns Either of ShortCode or error
*/ */
async createShortcode(request: string, userUID: string | null) { async createShortcode(
const shortcodeData = stringToJson(request); request: string,
if (E.isLeft(shortcodeData)) return E.left(SHORTCODE_INVALID_JSON); properties: string | null = null,
userInfo: AuthUser,
) {
const requestData = stringToJson(request);
if (E.isLeft(requestData) || !requestData.right)
return E.left(SHORTCODE_INVALID_REQUEST_JSON);
const user = await this.userService.findUserById(userUID); const parsedProperties = stringToJson(properties);
if (E.isLeft(parsedProperties))
return E.left(SHORTCODE_INVALID_PROPERTIES_JSON);
const generatedShortCode = await this.generateUniqueShortCodeID(); const generatedShortCode = await this.generateUniqueShortCodeID();
if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left); if (E.isLeft(generatedShortCode)) return E.left(generatedShortCode.left);
@@ -119,8 +135,9 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
const createdShortCode = await this.prisma.shortcode.create({ const createdShortCode = await this.prisma.shortcode.create({
data: { data: {
id: generatedShortCode.right, id: generatedShortCode.right,
request: shortcodeData.right, request: requestData.right,
creatorUid: O.isNone(user) ? null : user.value.uid, embedProperties: parsedProperties.right ?? undefined,
creatorUid: userInfo.uid,
}, },
}); });
@@ -128,11 +145,11 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
if (createdShortCode.creatorUid) { if (createdShortCode.creatorUid) {
this.pubsub.publish( this.pubsub.publish(
`shortcode/${createdShortCode.creatorUid}/created`, `shortcode/${createdShortCode.creatorUid}/created`,
this.returnShortCode(createdShortCode), this.cast(createdShortCode),
); );
} }
return E.right(this.returnShortCode(createdShortCode)); return E.right(this.cast(createdShortCode));
} }
/** /**
@@ -156,7 +173,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
}); });
const fetchedShortCodes: Shortcode[] = shortCodes.map((code) => const fetchedShortCodes: Shortcode[] = shortCodes.map((code) =>
this.returnShortCode(code), this.cast(code),
); );
return fetchedShortCodes; return fetchedShortCodes;
@@ -182,7 +199,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
this.pubsub.publish( this.pubsub.publish(
`shortcode/${deletedShortCodes.creatorUid}/revoked`, `shortcode/${deletedShortCodes.creatorUid}/revoked`,
this.returnShortCode(deletedShortCodes), this.cast(deletedShortCodes),
); );
return E.right(true); return E.right(true);
@@ -205,4 +222,45 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
return deletedShortCodes.count; return deletedShortCodes.count;
} }
/**
* Update a created Shortcode
* @param shortcodeID Shortcode ID
* @param uid User Uid
* @returns Updated Shortcode
*/
async updateEmbedProperties(
shortcodeID: string,
uid: string,
updatedProps: string,
) {
if (!updatedProps) return E.left(SHORTCODE_PROPERTIES_NOT_FOUND);
const parsedProperties = stringToJson(updatedProps);
if (E.isLeft(parsedProperties) || !parsedProperties.right)
return E.left(SHORTCODE_INVALID_PROPERTIES_JSON);
try {
const updatedShortcode = await this.prisma.shortcode.update({
where: {
creator_uid_shortcode_unique: {
creatorUid: uid,
id: shortcodeID,
},
},
data: {
embedProperties: parsedProperties.right,
},
});
this.pubsub.publish(
`shortcode/${updatedShortcode.creatorUid}/updated`,
this.cast(updatedShortcode),
);
return E.right(this.cast(updatedShortcode));
} catch (error) {
return E.left(SHORTCODE_NOT_FOUND);
}
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "@hoppscotch/cli", "name": "@hoppscotch/cli",
"version": "0.3.2", "version": "0.4.0",
"description": "A CLI to run Hoppscotch test scripts in CI environments.", "description": "A CLI to run Hoppscotch test scripts in CI environments.",
"homepage": "https://hoppscotch.io", "homepage": "https://hoppscotch.io",
"main": "dist/index.js", "main": "dist/index.js",
@@ -10,6 +10,9 @@
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"engines": {
"node": ">=18"
},
"scripts": { "scripts": {
"build": "pnpm exec tsup", "build": "pnpm exec tsup",
"dev": "pnpm exec tsup --watch", "dev": "pnpm exec tsup --watch",
@@ -38,26 +41,24 @@
"devDependencies": { "devDependencies": {
"@hoppscotch/data": "workspace:^", "@hoppscotch/data": "workspace:^",
"@hoppscotch/js-sandbox": "workspace:^", "@hoppscotch/js-sandbox": "workspace:^",
"@relmify/jest-fp-ts": "^2.0.2", "@relmify/jest-fp-ts": "^2.1.1",
"@swc/core": "^1.2.181", "@swc/core": "^1.3.92",
"@types/axios": "^0.14.0", "@types/jest": "^29.5.5",
"@types/chalk": "^2.2.0", "@types/lodash": "^4.14.199",
"@types/commander": "^2.12.2", "@types/qs": "^6.9.8",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.181",
"@types/qs": "^6.9.7",
"axios": "^0.21.4", "axios": "^0.21.4",
"chalk": "^4.1.1", "chalk": "^4.1.2",
"commander": "^8.0.0", "commander": "^11.0.0",
"esm": "^3.2.25", "esm": "^3.2.25",
"fp-ts": "^2.12.1", "fp-ts": "^2.16.1",
"io-ts": "^2.2.16", "io-ts": "^2.2.20",
"jest": "^27.5.1", "jest": "^29.7.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"prettier": "^2.8.4", "prettier": "^3.0.3",
"qs": "^6.10.3", "qs": "^6.11.2",
"ts-jest": "^27.1.4", "ts-jest": "^29.1.1",
"tsup": "^5.12.7", "tsup": "^7.2.0",
"typescript": "^4.6.4" "typescript": "^5.2.2",
"zod": "^3.22.4"
} }
} }

View File

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

View File

@@ -1,27 +1,45 @@
import { error } from "../../types/errors"; import { error } from "../../types/errors";
import { HoppEnvs, HoppEnvPair } from "../../types/request"; import {
HoppEnvs,
HoppEnvPair,
HoppEnvKeyPairObject,
HoppEnvExportObject,
HoppBulkEnvExportObject,
} from "../../types/request";
import { readJsonFile } from "../../utils/mutators"; import { readJsonFile } from "../../utils/mutators";
/** /**
* Parses env json file for given path and validates the parsed env json object. * Parses env json file for given path and validates the parsed env json object.
* @param path Path of env.json file to be parsed. * @param path Path of env.json file to be parsed.
* @returns For successful parsing we get HoppEnvs object. * @returns For successful parsing we get HoppEnvs object.
*/ */
export async function parseEnvsData(path: string) { export async function parseEnvsData(path: string) {
const contents = await readJsonFile(path) const contents = await readJsonFile(path);
const envPairs: Array<HoppEnvPair> = [];
const HoppEnvKeyPairResult = HoppEnvKeyPairObject.safeParse(contents);
const HoppEnvExportObjectResult = HoppEnvExportObject.safeParse(contents);
const HoppBulkEnvExportObjectResult =
HoppBulkEnvExportObject.safeParse(contents);
if(!(contents && typeof contents === "object" && !Array.isArray(contents))) { // CLI doesnt support bulk environments export.
throw error({ code: "MALFORMED_ENV_FILE", path, data: null }) // Hence we check for this case and throw an error if it matches the format.
if (HoppBulkEnvExportObjectResult.success) {
throw error({ code: "BULK_ENV_FILE", path, data: error });
} }
const envPairs: Array<HoppEnvPair> = [] // Checks if the environment file is of the correct format.
// If it doesnt match either of them, we throw an error.
for( const [key,value] of Object.entries(contents)) { if (!(HoppEnvKeyPairResult.success || HoppEnvExportObjectResult.success)) {
if(typeof value !== "string") { throw error({ code: "MALFORMED_ENV_FILE", path, data: error });
throw error({ code: "MALFORMED_ENV_FILE", path, data: {value: value} })
} }
envPairs.push({key, value}) if (HoppEnvKeyPairResult.success) {
for (const [key, value] of Object.entries(HoppEnvKeyPairResult.data)) {
envPairs.push({ key, value });
} }
return <HoppEnvs>{ global: [], selected: envPairs } } else if (HoppEnvExportObjectResult.success) {
const { key, value } = HoppEnvExportObjectResult.data.variables[0];
envPairs.push({ key, value });
}
return <HoppEnvs>{ global: [], selected: envPairs };
} }

View File

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

View File

@@ -1,6 +1,7 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"; import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { TestReport } from "../interfaces/response"; import { TestReport } from "../interfaces/response";
import { HoppCLIError } from "./errors"; import { HoppCLIError } from "./errors";
import { z } from "zod";
export type FormDataEntry = { export type FormDataEntry = {
key: string; key: string;
@@ -9,6 +10,22 @@ export type FormDataEntry = {
export type HoppEnvPair = { key: string; value: string }; export type HoppEnvPair = { key: string; value: string };
export const HoppEnvKeyPairObject = z.record(z.string(), z.string());
// Shape of the single environment export object that is exported from the app.
export const HoppEnvExportObject = z.object({
name: z.string(),
variables: z.array(
z.object({
key: z.string(),
value: z.string(),
})
),
});
// Shape of the bulk environment export object that is exported from the app.
export const HoppBulkEnvExportObject = z.array(HoppEnvExportObject);
export type HoppEnvs = { export type HoppEnvs = {
global: HoppEnvPair[]; global: HoppEnvPair[];
selected: HoppEnvPair[]; selected: HoppEnvPair[];

View File

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

View File

@@ -1,7 +1,25 @@
/*
* Write hoppscotch-common related custom styles in this file.
* If styles are sharable across all package then write into hoppscotch-ui/assets/scss/styles.scss file.
*/
* { * {
@apply backface-hidden; backface-visibility: hidden;
@apply before:backface-hidden; -moz-backface-visibility: hidden;
@apply after:backface-hidden; -webkit-backface-visibility: hidden;
&::before {
backface-visibility: hidden;
-moz-backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
&::after {
backface-visibility: hidden;
-moz-backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
@apply selection:bg-accentDark; @apply selection:bg-accentDark;
@apply selection:text-accentContrast; @apply selection:text-accentContrast;
@apply overscroll-none; @apply overscroll-none;
@@ -11,17 +29,25 @@
@apply antialiased; @apply antialiased;
accent-color: var(--accent-color); accent-color: var(--accent-color);
font-variant-ligatures: common-ligatures; font-variant-ligatures: common-ligatures;
// Colors
--info-color: #ec4899;
--success-color: #10b981;
--blue-color: #3b82f6;
--warning-color: #f59e0b;
--cl-error-color: #ef4444;
--sv-error-color: #dc2626;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@apply bg-transparent; @apply bg-transparent;
@apply border-solid border-l border-dividerLight border-t-0 border-b-0 border-r-0; @apply border-b-0 border-l border-r-0 border-t-0 border-solid border-dividerLight;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
@apply bg-divider bg-clip-content; @apply bg-divider bg-clip-content;
@apply rounded-full; @apply rounded-full;
@apply border-solid border-transparent border-4; @apply border-4 border-solid border-transparent;
@apply hover:bg-dividerDark; @apply hover:bg-dividerDark;
@apply hover:bg-clip-content; @apply hover:bg-clip-content;
} }
@@ -54,7 +80,7 @@ html {
body { body {
@apply bg-primary; @apply bg-primary;
@apply text-secondary text-body; @apply text-body text-secondary;
@apply font-medium; @apply font-medium;
@apply select-none; @apply select-none;
@apply overflow-x-hidden; @apply overflow-x-hidden;
@@ -124,8 +150,8 @@ a {
&.link { &.link {
@apply items-center; @apply items-center;
@apply py-0.5 px-1; @apply px-1 py-0.5;
@apply -my-0.5 -mx-1; @apply -mx-1 -my-0.5;
@apply text-accent; @apply text-accent;
@apply rounded; @apply rounded;
@apply hover:text-accentDark; @apply hover:text-accentDark;
@@ -137,7 +163,7 @@ a {
.cm-tooltip { .cm-tooltip {
.tippy-box { .tippy-box {
@apply shadow-none; @apply shadow-none #{!important};
@apply fixed; @apply fixed;
@apply inline-flex; @apply inline-flex;
@apply -mt-8; @apply -mt-8;
@@ -154,7 +180,7 @@ a {
@apply flex; @apply flex;
@apply text-tiny text-primary; @apply text-tiny text-primary;
@apply font-semibold; @apply font-semibold;
@apply py-1 px-2; @apply px-2 py-1;
@apply truncate; @apply truncate;
@apply leading-normal; @apply leading-normal;
@apply items-center; @apply items-center;
@@ -162,7 +188,7 @@ a {
kbd { kbd {
@apply hidden; @apply hidden;
@apply font-sans; @apply font-sans;
@apply bg-gray-500/45; background-color: rgba(107, 114, 128, 0.45);
@apply text-primaryLight; @apply text-primaryLight;
@apply rounded-sm; @apply rounded-sm;
@apply px-1; @apply px-1;
@@ -170,6 +196,12 @@ a {
@apply truncate; @apply truncate;
@apply sm:inline-flex; @apply sm:inline-flex;
} }
.env-icon {
@apply transition;
@apply inline-flex;
@apply items-center;
}
} }
.tippy-svg-arrow { .tippy-svg-arrow {
@@ -195,7 +227,7 @@ a {
@apply max-h-[45vh]; @apply max-h-[45vh];
@apply items-stretch; @apply items-stretch;
@apply overflow-y-auto; @apply overflow-y-auto;
@apply text-secondary text-body; @apply text-body text-secondary;
@apply p-2; @apply p-2;
@apply leading-normal; @apply leading-normal;
@apply focus:outline-none; @apply focus:outline-none;
@@ -234,7 +266,7 @@ hr {
.heading { .heading {
@apply font-bold; @apply font-bold;
@apply text-secondaryDark text-lg; @apply text-lg text-secondaryDark;
@apply tracking-tight; @apply tracking-tight;
} }
@@ -243,7 +275,7 @@ hr {
.textarea { .textarea {
@apply flex; @apply flex;
@apply w-full; @apply w-full;
@apply py-2 px-4; @apply px-4 py-2;
@apply bg-transparent; @apply bg-transparent;
@apply rounded; @apply rounded;
@apply text-secondaryDark; @apply text-secondaryDark;
@@ -284,7 +316,7 @@ button {
@apply transform; @apply transform;
@apply origin-top-left; @apply origin-top-left;
@apply scale-75; @apply scale-75;
@apply translate-x-1 -translate-y-4; @apply -translate-y-4 translate-x-1;
} }
.floating-input:focus-within ~ label { .floating-input:focus-within ~ label {
@@ -293,7 +325,7 @@ button {
.floating-input ~ .end-actions { .floating-input ~ .end-actions {
@apply absolute; @apply absolute;
@apply right-0.2; @apply right-[.05rem];
@apply inset-y-0; @apply inset-y-0;
@apply flex; @apply flex;
@apply items-center; @apply items-center;
@@ -335,23 +367,23 @@ pre.ace_editor {
} }
.info-response { .info-response {
@apply text-pink-500; color: var(--info-color);
} }
.success-response { .success-response {
@apply text-green-500; color: var(--success-color);
} }
.redir-response { .redir-response {
@apply text-yellow-500; color: var(--warning-color);
} }
.cl-error-response { .cl-error-response {
@apply text-red-500; color: var(--cl-error-color);
} }
.sv-error-response { .sv-error-response {
@apply text-red-600; color: var(--sv-error-color);
} }
.missing-data-response { .missing-data-response {
@@ -366,7 +398,7 @@ pre.ace_editor {
@apply px-4 py-2; @apply px-4 py-2;
@apply bg-tooltip; @apply bg-tooltip;
@apply border-secondaryDark; @apply border-secondaryDark;
@apply text-primary text-body; @apply text-body text-primary;
@apply justify-between; @apply justify-between;
@apply shadow-lg; @apply shadow-lg;
@apply font-semibold; @apply font-semibold;
@@ -394,7 +426,7 @@ pre.ace_editor {
@apply before:opacity-10; @apply before:opacity-10;
@apply before:inset-0; @apply before:inset-0;
@apply before:transition; @apply before:transition;
@apply before:content-DEFAULT; @apply before:content-[''];
@apply hover:no-underline; @apply hover:no-underline;
@apply hover:before:opacity-20; @apply hover:before:opacity-20;
} }
@@ -428,7 +460,7 @@ pre.ace_editor {
@apply before:opacity-0; @apply before:opacity-0;
@apply before:z-20; @apply before:z-20;
@apply before:transition; @apply before:transition;
@apply before:content-DEFAULT; @apply before:content-[''];
@apply hover:before:opacity-100; @apply hover:before:opacity-100;
} }
@@ -501,22 +533,6 @@ pre.ace_editor {
} }
} }
.cm-panel.cm-search [name="close"] {
@apply flex;
@apply items-center;
@apply justify-center;
@apply min-h-5;
@apply min-w-5;
@apply bg-primaryDark #{!important};
@apply sticky #{!important};
@apply right-0 #{!important};
@apply ml-auto #{!important};
@apply my-auto #{!important};
@apply rounded #{!important};
@apply outline #{!important};
@apply outline-divider #{!important};
}
.shortcut-key { .shortcut-key {
@apply inline-flex; @apply inline-flex;
@apply font-sans; @apply font-sans;

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -1,274 +0,0 @@
@mixin base-theme {
--font-sans: "Inter Variable", sans-serif;
--font-icon: "Material Symbols Rounded Variable";
--font-mono: "Roboto Mono Variable", monospace;
--font-size-body: 0.75rem;
--font-size-tiny: 0.688rem;
--line-height-body: 1rem;
--upper-primary-sticky-fold: 4.125rem;
--upper-secondary-sticky-fold: 6.188rem;
--upper-tertiary-sticky-fold: 8.25rem;
--upper-fourth-sticky-fold: 10.2rem;
--upper-mobile-primary-sticky-fold: 6.625rem;
--upper-mobile-secondary-sticky-fold: 8.688rem;
--upper-mobile-sticky-fold: 10.75rem;
--upper-mobile-tertiary-sticky-fold: 8.25rem;
--lower-primary-sticky-fold: 3rem;
--lower-secondary-sticky-fold: 5.063rem;
--lower-tertiary-sticky-fold: 7.125rem;
--lower-fourth-sticky-fold: 9.188rem;
--sidebar-primary-sticky-fold: 2rem;
}
@mixin dark-theme {
--primary-color: theme("colors.dark.800");
--primary-light-color: theme("colors.dark.600");
--primary-dark-color: theme("colors.neutral.800");
--primary-contrast-color: theme("colors.neutral.900");
--secondary-color: theme("colors.neutral.400");
--secondary-light-color: theme("colors.neutral.500");
--secondary-dark-color: theme("colors.neutral.50");
--divider-color: theme("colors.neutral.800");
--divider-light-color: theme("colors.dark.500");
--divider-dark-color: theme("colors.dark.300");
--error-color: theme("colors.stone.800");
--tooltip-color: theme("colors.neutral.100");
--popover-color: theme("colors.dark.700");
--editor-theme: "merbivore_soft";
}
@mixin light-theme {
--primary-color: theme("colors.white");
--primary-light-color: theme("colors.gray.50");
--primary-dark-color: theme("colors.gray.100");
--primary-contrast-color: theme("colors.light.50");
--secondary-color: theme("colors.gray.500");
--secondary-light-color: theme("colors.gray.400");
--secondary-dark-color: theme("colors.gray.900");
--divider-color: theme("colors.gray.100");
--divider-light-color: theme("colors.gray.100");
--divider-dark-color: theme("colors.gray.300");
--error-color: theme("colors.yellow.100");
--tooltip-color: theme("colors.neutral.800");
--popover-color: theme("colors.white");
--editor-theme: "textmate";
}
@mixin black-theme {
--primary-color: theme("colors.dark.900");
--primary-light-color: theme("colors.neutral.900");
--primary-dark-color: theme("colors.dark.800");
--primary-contrast-color: theme("colors.dark.900");
--secondary-color: theme("colors.neutral.400");
--secondary-light-color: theme("colors.neutral.500");
--secondary-dark-color: theme("colors.neutral.100");
--divider-color: theme("colors.dark.600");
--divider-light-color: theme("colors.dark.800");
--divider-dark-color: theme("colors.dark.200");
--error-color: theme("colors.stone.900");
--tooltip-color: theme("colors.neutral.100");
--popover-color: theme("colors.dark.900");
--editor-theme: "twilight";
}
@mixin dark-editor-theme {
--editor-type-color: theme("colors.purple.400");
--editor-name-color: theme("colors.blue.400");
--editor-operator-color: theme("colors.indigo.400");
--editor-invalid-color: theme("colors.red.400");
--editor-separator-color: theme("colors.gray.400");
--editor-meta-color: theme("colors.gray.400");
--editor-variable-color: theme("colors.green.400");
--editor-link-color: theme("colors.cyan.400");
--editor-process-color: theme("colors.fuchsia.400");
--editor-constant-color: theme("colors.violet.400");
--editor-keyword-color: theme("colors.pink.400");
}
@mixin light-editor-theme {
--editor-type-color: theme("colors.purple.600");
--editor-name-color: theme("colors.red.600");
--editor-operator-color: theme("colors.indigo.600");
--editor-invalid-color: theme("colors.red.600");
--editor-separator-color: theme("colors.gray.600");
--editor-meta-color: theme("colors.gray.600");
--editor-variable-color: theme("colors.green.600");
--editor-link-color: theme("colors.cyan.600");
--editor-process-color: theme("colors.blue.600");
--editor-constant-color: theme("colors.fuchsia.600");
--editor-keyword-color: theme("colors.pink.600");
}
@mixin black-editor-theme {
--editor-type-color: theme("colors.purple.400");
--editor-name-color: theme("colors.fuchsia.400");
--editor-operator-color: theme("colors.indigo.400");
--editor-invalid-color: theme("colors.red.400");
--editor-separator-color: theme("colors.gray.400");
--editor-meta-color: theme("colors.gray.400");
--editor-variable-color: theme("colors.green.400");
--editor-link-color: theme("colors.cyan.400");
--editor-process-color: theme("colors.violet.400");
--editor-constant-color: theme("colors.blue.400");
--editor-keyword-color: theme("colors.pink.400");
}
@mixin green-theme {
--accent-color: theme("colors.green.500");
--accent-light-color: theme("colors.green.400");
--accent-dark-color: theme("colors.green.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.green.200");
--gradient-via-color: theme("colors.green.400");
--gradient-to-color: theme("colors.green.600");
}
@mixin teal-theme {
--accent-color: theme("colors.teal.500");
--accent-light-color: theme("colors.teal.400");
--accent-dark-color: theme("colors.teal.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.teal.200");
--gradient-via-color: theme("colors.teal.400");
--gradient-to-color: theme("colors.teal.600");
}
@mixin blue-theme {
--accent-color: theme("colors.blue.500");
--accent-light-color: theme("colors.blue.400");
--accent-dark-color: theme("colors.blue.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.blue.200");
--gradient-via-color: theme("colors.blue.400");
--gradient-to-color: theme("colors.blue.600");
}
@mixin indigo-theme {
--accent-color: theme("colors.indigo.500");
--accent-light-color: theme("colors.indigo.400");
--accent-dark-color: theme("colors.indigo.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.indigo.200");
--gradient-via-color: theme("colors.indigo.400");
--gradient-to-color: theme("colors.indigo.600");
}
@mixin purple-theme {
--accent-color: theme("colors.purple.500");
--accent-light-color: theme("colors.purple.400");
--accent-dark-color: theme("colors.purple.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.purple.200");
--gradient-via-color: theme("colors.purple.400");
--gradient-to-color: theme("colors.purple.600");
}
@mixin yellow-theme {
--accent-color: theme("colors.yellow.500");
--accent-light-color: theme("colors.yellow.400");
--accent-dark-color: theme("colors.yellow.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.yellow.200");
--gradient-via-color: theme("colors.yellow.400");
--gradient-to-color: theme("colors.yellow.600");
}
@mixin orange-theme {
--accent-color: theme("colors.orange.500");
--accent-light-color: theme("colors.orange.400");
--accent-dark-color: theme("colors.orange.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.orange.200");
--gradient-via-color: theme("colors.orange.400");
--gradient-to-color: theme("colors.orange.600");
}
@mixin red-theme {
--accent-color: theme("colors.red.500");
--accent-light-color: theme("colors.red.400");
--accent-dark-color: theme("colors.red.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.red.200");
--gradient-via-color: theme("colors.red.400");
--gradient-to-color: theme("colors.red.600");
}
@mixin pink-theme {
--accent-color: theme("colors.pink.500");
--accent-light-color: theme("colors.pink.400");
--accent-dark-color: theme("colors.pink.600");
--accent-contrast-color: theme("colors.white");
--gradient-from-color: theme("colors.pink.200");
--gradient-via-color: theme("colors.pink.400");
--gradient-to-color: theme("colors.pink.600");
}
:root {
@include base-theme;
@include dark-theme;
@include dark-editor-theme;
@include green-theme;
}
:root.light {
@include light-theme;
@include light-editor-theme;
color-scheme: light;
}
:root.dark {
@include dark-theme;
@include dark-editor-theme;
color-scheme: dark;
}
:root.black {
@include black-theme;
@include black-editor-theme;
color-scheme: dark;
}
:root[data-accent="blue"] {
@include blue-theme;
}
:root[data-accent="green"] {
@include green-theme;
}
:root[data-accent="teal"] {
@include teal-theme;
}
:root[data-accent="indigo"] {
@include indigo-theme;
}
:root[data-accent="purple"] {
@include purple-theme;
}
:root[data-accent="orange"] {
@include orange-theme;
}
:root[data-accent="pink"] {
@include pink-theme;
}
:root[data-accent="red"] {
@include red-theme;
}
:root[data-accent="yellow"] {
@include yellow-theme;
}

View File

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

View File

@@ -0,0 +1,81 @@
@mixin base-theme {
--font-sans: "Inter Variable", sans-serif;
--font-icon: "Material Symbols Rounded Variable";
--font-mono: "Roboto Mono Variable", monospace;
--font-size-body: 0.75rem;
--font-size-tiny: 0.688rem;
--line-height-body: 1rem;
--upper-primary-sticky-fold: 4.125rem;
--upper-secondary-sticky-fold: 6.188rem;
--upper-tertiary-sticky-fold: 8.25rem;
--upper-fourth-sticky-fold: 10.2rem;
--upper-mobile-primary-sticky-fold: 6.625rem;
--upper-mobile-secondary-sticky-fold: 8.688rem;
--upper-mobile-sticky-fold: 10.75rem;
--upper-mobile-tertiary-sticky-fold: 8.25rem;
--lower-primary-sticky-fold: 3rem;
--lower-secondary-sticky-fold: 5.063rem;
--lower-tertiary-sticky-fold: 7.125rem;
--lower-fourth-sticky-fold: 9.188rem;
--sidebar-primary-sticky-fold: 2rem;
}
@mixin dark-theme {
--primary-color: #181818;
--primary-light-color: #1c1c1e;
--primary-dark-color: #262626;
--primary-contrast-color: #171717;
--secondary-color: #a3a3a3;
--secondary-light-color: #737373;
--secondary-dark-color: #fafafa;
--divider-color: #262626;
--divider-light-color: #1f1f1f;
--divider-dark-color: #2d2d2d;
--error-color: #292524;
--tooltip-color: #f5f5f5;
--popover-color: #1b1b1b;
--editor-theme: "merbivore_soft";
}
@mixin light-theme {
--primary-color: #ffffff;
--primary-light-color: #f9fafb;
--primary-dark-color: #f3f4f6;
--primary-contrast-color: #fdfdfd;
--secondary-color: #6b7280;
--secondary-light-color: #9ca3af;
--secondary-dark-color: #111827;
--divider-color: #f3f4f6;
--divider-light-color: #f3f4f6;
--divider-dark-color: #d1d5db;
--error-color: #fef3c7;
--tooltip-color: #262626;
--popover-color: #ffffff;
--editor-theme: "textmate";
}
@mixin black-theme {
--primary-color: #0f0f0f;
--primary-light-color: #171717;
--primary-dark-color: #181818;
--primary-contrast-color: #0f0f0f;
--secondary-color: #a3a3a3;
--secondary-light-color: #737373;
--secondary-dark-color: #f5f5f5;
--divider-color: #1c1c1e;
--divider-light-color: #181818;
--divider-dark-color: #323232;
--error-color: #1c1917;
--tooltip-color: #f5f5f5;
--popover-color: #0f0f0f;
--editor-theme: "twilight";
}

View File

@@ -0,0 +1,41 @@
@mixin dark-editor-theme {
--editor-type-color: #a78bfa;
--editor-name-color: #60a5fa;
--editor-operator-color: #818cf8;
--editor-invalid-color: #f87171;
--editor-separator-color: #9ca3af;
--editor-meta-color: #9ca3af;
--editor-variable-color: #34d399;
--editor-link-color: #22d3ee;
--editor-process-color: #e879f9;
--editor-constant-color: #a78bfa;
--editor-keyword-color: #f472b6;
}
@mixin light-editor-theme {
--editor-type-color: #7c3aed;
--editor-name-color: #dc2626;
--editor-operator-color: #4f46e5;
--editor-invalid-color: #dc2626;
--editor-separator-color: #4b5563;
--editor-meta-color: #4b5563;
--editor-variable-color: #059669;
--editor-link-color: #0891b2;
--editor-process-color: #2563eb;
--editor-constant-color: #c026d3;
--editor-keyword-color: #db2777;
}
@mixin black-editor-theme {
--editor-type-color: #a78bfa;
--editor-name-color: #e879f9;
--editor-operator-color: #818cf8;
--editor-invalid-color: #f87171;
--editor-separator-color: #9ca3af;
--editor-meta-color: #9ca3af;
--editor-variable-color: #34d399;
--editor-link-color: #22d3ee;
--editor-process-color: #a78bfa;
--editor-constant-color: #60a5fa;
--editor-keyword-color: #f472b6;
}

View File

@@ -0,0 +1,64 @@
@import "./base-themes.scss";
@import "./editor-themes.scss";
@import "./accent-themes.scss";
:root {
@include base-theme;
@include dark-theme;
@include green-theme;
@include dark-editor-theme;
}
:root.light {
@include light-theme;
@include light-editor-theme;
color-scheme: light;
}
:root.dark {
@include dark-theme;
@include dark-editor-theme;
color-scheme: dark;
}
:root.black {
@include black-theme;
@include black-editor-theme;
color-scheme: dark;
}
:root[data-accent="blue"] {
@include blue-theme;
}
:root[data-accent="green"] {
@include green-theme;
}
:root[data-accent="teal"] {
@include teal-theme;
}
:root[data-accent="indigo"] {
@include indigo-theme;
}
:root[data-accent="purple"] {
@include purple-theme;
}
:root[data-accent="orange"] {
@include orange-theme;
}
:root[data-accent="pink"] {
@include pink-theme;
}
:root[data-accent="red"] {
@include red-theme;
}
:root[data-accent="yellow"] {
@include yellow-theme;
}

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "@hoppscotch/common", "name": "@hoppscotch/common",
"private": true, "private": true,
"version": "2023.8.1", "version": "2023.8.3",
"scripts": { "scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*", "dev": "pnpm exec npm-run-all -p -l dev:*",
"test": "vitest --run", "test": "vitest --run",
@@ -17,22 +17,22 @@
"postinstall": "pnpm run gql-codegen", "postinstall": "pnpm run gql-codegen",
"do-test": "pnpm run test", "do-test": "pnpm run test",
"do-lint": "pnpm run prod-lint", "do-lint": "pnpm run prod-lint",
"do-typecheck": "pnpm run lint", "do-typecheck": "node type-check.mjs",
"do-lintfix": "pnpm run lintfix" "do-lintfix": "pnpm run lintfix"
}, },
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^10.1.0", "@apidevtools/swagger-parser": "^10.1.0",
"@codemirror/autocomplete": "^6.9.0", "@codemirror/autocomplete": "^6.10.2",
"@codemirror/commands": "^6.2.4", "@codemirror/commands": "^6.3.0",
"@codemirror/lang-javascript": "^6.1.9", "@codemirror/lang-javascript": "^6.2.1",
"@codemirror/lang-json": "^6.0.1", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-xml": "^6.0.2", "@codemirror/lang-xml": "^6.0.2",
"@codemirror/language": "^6.9.0", "@codemirror/language": "^6.9.2",
"@codemirror/legacy-modes": "^6.3.3", "@codemirror/legacy-modes": "^6.3.3",
"@codemirror/lint": "^6.4.0", "@codemirror/lint": "^6.4.2",
"@codemirror/search": "^6.5.1", "@codemirror/search": "^6.5.4",
"@codemirror/state": "^6.2.1", "@codemirror/state": "^6.3.1",
"@codemirror/view": "^6.16.0", "@codemirror/view": "^6.22.0",
"@fontsource-variable/inter": "^5.0.8", "@fontsource-variable/inter": "^5.0.8",
"@fontsource-variable/material-symbols-rounded": "^5.0.7", "@fontsource-variable/material-symbols-rounded": "^5.0.7",
"@fontsource-variable/roboto-mono": "^5.0.9", "@fontsource-variable/roboto-mono": "^5.0.9",
@@ -41,9 +41,7 @@
"@hoppscotch/js-sandbox": "workspace:^", "@hoppscotch/js-sandbox": "workspace:^",
"@hoppscotch/ui": "workspace:^", "@hoppscotch/ui": "workspace:^",
"@hoppscotch/vue-toasted": "^0.1.0", "@hoppscotch/vue-toasted": "^0.1.0",
"@lezer/highlight": "^1.1.6", "@lezer/highlight": "1.1.4",
"@sentry/tracing": "^7.64.0",
"@sentry/vue": "^7.64.0",
"@urql/core": "^4.1.1", "@urql/core": "^4.1.1",
"@urql/devtools": "^2.0.3", "@urql/devtools": "^2.0.3",
"@urql/exchange-auth": "^2.1.6", "@urql/exchange-auth": "^2.1.6",
@@ -133,12 +131,18 @@
"@vue/compiler-sfc": "^3.3.4", "@vue/compiler-sfc": "^3.3.4",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/runtime-core": "^3.3.4", "@vue/runtime-core": "^3.3.4",
"autoprefixer": "^10.4.14",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"eslint": "^8.47.0", "eslint": "^8.47.0",
"eslint-plugin-prettier": "^5.0.0", "eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.17.0", "eslint-plugin-vue": "^9.17.0",
"glob": "^10.3.10",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.4.23",
"prettier-plugin-tailwindcss": "^0.5.6",
"tailwindcss": "^3.3.2",
"vite-plugin-fonts": "^0.6.0",
"openapi-types": "^12.1.3", "openapi-types": "^12.1.3",
"rollup-plugin-polyfill-node": "^0.12.0", "rollup-plugin-polyfill-node": "^0.12.0",
"sass": "^1.66.0", "sass": "^1.66.0",
@@ -154,9 +158,7 @@
"vite-plugin-pages-sitemap": "^1.6.1", "vite-plugin-pages-sitemap": "^1.6.1",
"vite-plugin-pwa": "^0.16.4", "vite-plugin-pwa": "^0.16.4",
"vite-plugin-vue-layouts": "^0.8.0", "vite-plugin-vue-layouts": "^0.8.0",
"vite-plugin-windicss": "^1.9.1",
"vitest": "^0.34.2", "vitest": "^0.34.2",
"vue-tsc": "^1.8.8", "vue-tsc": "^1.8.8"
"windicss": "^3.5.6"
} }
} }

View File

@@ -2,7 +2,7 @@
<div> <div>
<div <div
v-if="isLoadingInitialRoute" v-if="isLoadingInitialRoute"
class="flex flex-col items-center justify-center min-h-screen" class="flex min-h-screen flex-col items-center justify-center"
> >
<HoppSmartSpinner /> <HoppSmartSpinner />
</div> </div>

View File

@@ -5,10 +5,11 @@
// Read more: https://github.com/vuejs/core/pull/3399 // Read more: https://github.com/vuejs/core/pull/3399
export {} export {}
declare module 'vue' { declare module "vue" {
export interface GlobalComponents { export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default'] AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default'] AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
AppBanner: typeof import('./components/app/Banner.vue')['default']
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default'] AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default'] AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
AppFooter: typeof import('./components/app/Footer.vue')['default'] AppFooter: typeof import('./components/app/Footer.vue')['default']
@@ -140,6 +141,7 @@ declare module 'vue' {
HttpTests: typeof import('./components/http/Tests.vue')['default'] HttpTests: typeof import('./components/http/Tests.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default'] HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
IconLucideActivity: typeof import('~icons/lucide/activity')['default'] IconLucideActivity: typeof import('~icons/lucide/activity')['default']
IconLucideAlertCircle: typeof import('~icons/lucide/alert-circle')['default']
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default'] IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'] IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default'] IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
@@ -189,7 +191,6 @@ declare module 'vue' {
SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default'] SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default'] SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default'] SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
SmartFontSizePicker: typeof import('./components/smart/FontSizePicker.vue')['default']
SmartInput: typeof import('./../../hoppscotch-ui/src/components/smart/Input.vue')['default'] SmartInput: typeof import('./../../hoppscotch-ui/src/components/smart/Input.vue')['default']
SmartIntersection: typeof import('./../../hoppscotch-ui/src/components/smart/Intersection.vue')['default'] SmartIntersection: typeof import('./../../hoppscotch-ui/src/components/smart/Intersection.vue')['default']
SmartItem: typeof import('./../../hoppscotch-ui/src/components/smart/Item.vue')['default'] SmartItem: typeof import('./../../hoppscotch-ui/src/components/smart/Item.vue')['default']

View File

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

View File

@@ -1,22 +0,0 @@
<template>
<div
class="relative flex items-center px-4 py-2 transition bg-error text-tiny group"
role="alert"
>
<icon-lucide-info class="mr-2" />
<span class="text-secondaryDark">
<span class="md:hidden">
{{ t("helpers.offline_short") }}
</span>
<span class="<md:hidden">
{{ t("helpers.offline") }}
</span>
</span>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "~/composables/i18n"
const t = useI18n()
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div
:role="bannerRole"
class="flex items-center px-4 py-2 text-tiny"
:class="bannerColor"
>
<component :is="bannerIcon" class="mr-2 text-white" />
<span class="text-white">
<span v-if="banner.alternateText" class="md:hidden">
{{ banner.alternateText }}
</span>
<span class="<md:hidden">
{{ banner.text }}
</span>
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { BannerContent, BannerType } from "~/services/banner.service"
import IconAlertCircle from "~icons/lucide/alert-circle"
import IconAlertTriangle from "~icons/lucide/alert-triangle"
import IconInfo from "~icons/lucide/info"
const props = defineProps<{
banner: BannerContent
}>()
const ariaRoles: Record<BannerType, string> = {
error: "alert",
warning: "status",
info: "status",
}
const bgColors: Record<BannerType, string> = {
error: "bg-red-700",
warning: "bg-yellow-700",
info: "bg-stone-800",
}
const icons = {
info: IconInfo,
warning: IconAlertCircle,
error: IconAlertTriangle,
}
const bannerColor = computed(() => bgColors[props.banner.type])
const bannerIcon = computed(() => icons[props.banner.type])
const bannerRole = computed(() => ariaRoles[props.banner.type])
</script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
ref="contextMenuRef" ref="contextMenuRef"
class="fixed bg-popover shadow-lg transform translate-y-8 border border-dividerDark p-2 rounded" class="fixed translate-y-8 transform rounded border border-dividerDark bg-popover p-2 shadow-lg"
:style="`top: ${position.top}px; left: ${position.left}px; z-index: 1000;`" :style="`top: ${position.top}px; left: ${position.left}px; z-index: 1000;`"
> >
<div v-if="contextMenuOptions" class="flex flex-col"> <div v-if="contextMenuOptions" class="flex flex-col">

View File

@@ -6,7 +6,7 @@
@close="hideModal" @close="hideModal"
> >
<template #body> <template #body>
<p class="px-2 mb-4 text-secondaryLight"> <p class="mb-4 px-2 text-secondaryLight">
{{ t("app.developer_option_description") }} {{ t("app.developer_option_description") }}
</p> </p>
<div class="flex flex-1"> <div class="flex flex-1">

View File

@@ -175,7 +175,7 @@
@click="COLUMN_LAYOUT = !COLUMN_LAYOUT" @click="COLUMN_LAYOUT = !COLUMN_LAYOUT"
/> />
<span <span
class="transition transform" class="transform transition"
:class="{ :class="{
'rotate-180': SIDEBAR_ON_LEFT, 'rotate-180': SIDEBAR_ON_LEFT,
}" }"

View File

@@ -1,28 +1,28 @@
<template> <template>
<div> <div>
<header <header
class="flex items-center justify-between flex-1 flex-shrink-0 px-2 py-2 space-x-2 overflow-x-auto overflow-y-hidden" class="flex flex-1 flex-shrink-0 items-center justify-between space-x-2 overflow-x-auto overflow-y-hidden px-2 py-2"
> >
<div <div
class="inline-flex items-center justify-start flex-1 space-x-2" class="inline-flex flex-1 items-center justify-start space-x-2"
:style="{ :style="{
paddingTop: platform.ui?.appHeader?.paddingTop?.value, paddingTop: platform.ui?.appHeader?.paddingTop?.value,
paddingLeft: platform.ui?.appHeader?.paddingLeft?.value, paddingLeft: platform.ui?.appHeader?.paddingLeft?.value,
}" }"
> >
<HoppButtonSecondary <HoppButtonSecondary
class="tracking-wide !font-bold !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark uppercase" class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')" :label="t('app.name')"
to="/" to="/"
/> />
</div> </div>
<div class="inline-flex items-center justify-center flex-1 space-x-2"> <div class="inline-flex flex-1 items-center justify-center space-x-2">
<button <button
class="flex flex-1 items-center justify-between px-2 py-1 self-stretch bg-primaryDark transition text-secondaryLight cursor-text rounded border border-dividerDark max-w-60 hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary" class="flex max-w-[15rem] flex-1 cursor-text items-center justify-between self-stretch rounded border border-dividerDark bg-primaryDark px-2 py-1 text-secondaryLight transition hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
@click="invokeAction('modals.search.toggle')" @click="invokeAction('modals.search.toggle')"
> >
<span class="inline-flex flex-1 items-center"> <span class="inline-flex flex-1 items-center">
<icon-lucide-search class="mr-2 svg-icons" /> <icon-lucide-search class="svg-icons mr-2" />
{{ t("app.search") }} {{ t("app.search") }}
</span> </span>
<span class="flex space-x-1"> <span class="flex space-x-1">
@@ -48,7 +48,7 @@
@click="invokeAction('modals.support.toggle')" @click="invokeAction('modals.support.toggle')"
/> />
</div> </div>
<div class="inline-flex items-center justify-end flex-1 space-x-2"> <div class="inline-flex flex-1 items-center justify-end space-x-2">
<div <div
v-if="currentUser === null" v-if="currentUser === null"
class="inline-flex items-center space-x-2" class="inline-flex items-center space-x-2"
@@ -56,7 +56,7 @@
<HoppButtonSecondary <HoppButtonSecondary
:icon="IconUploadCloud" :icon="IconUploadCloud"
:label="t('header.save_workspace')" :label="t('header.save_workspace')"
class="hidden md:flex bg-green-500/15 py-1.75 border border-green-600/25 !text-green-500 hover:bg-green-400/10 focus-visible:bg-green-400/10 focus-visible:border-green-800/50 !focus-visible:text-green-600 hover:border-green-800/50 !hover:text-green-600" class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 hidden border border-green-600/25 bg-green-500/[.15] !text-green-500 hover:border-green-800/50 hover:bg-green-400/10 focus-visible:border-green-800/50 focus-visible:bg-green-400/10 md:flex"
@click="invokeAction('modals.login.toggle')" @click="invokeAction('modals.login.toggle')"
/> />
<HoppButtonPrimary <HoppButtonPrimary
@@ -77,13 +77,13 @@
@handle-click="handleTeamEdit()" @handle-click="handleTeamEdit()"
/> />
<div <div
class="flex border divide-x rounded bg-green-500/15 divide-green-600/25 border-green-600/25 focus-within:bg-green-400/10 focus-within:border-green-800/50 focus-within:divide-green-800/50 hover:bg-green-400/10 hover:border-green-800/50 hover:divide-green-800/50" class="flex divide-x divide-green-600/25 rounded border border-green-600/25 bg-green-500/[.15] focus-within:divide-green-800/50 focus-within:border-green-800/50 focus-within:bg-green-400/10 hover:divide-green-800/50 hover:border-green-800/50 hover:bg-green-400/10"
> >
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')" :title="t('team.invite_tooltip')"
:icon="IconUserPlus" :icon="IconUserPlus"
class="py-1.75 !text-green-500 !focus-visible:text-green-600 !hover:text-green-600" class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500"
@click="handleInvite()" @click="handleInvite()"
/> />
<HoppButtonSecondary <HoppButtonSecondary
@@ -95,7 +95,7 @@
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('team.edit')" :title="t('team.edit')"
:icon="IconSettings" :icon="IconSettings"
class="py-1.75 !text-green-500 !focus-visible:text-green-600 !hover:text-green-600" class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500"
@click="handleTeamEdit()" @click="handleTeamEdit()"
/> />
</div> </div>
@@ -110,7 +110,7 @@
:title="t('workspace.change')" :title="t('workspace.change')"
:label="mdAndLarger ? workspaceName : ``" :label="mdAndLarger ? workspaceName : ``"
:icon="workspace.type === 'personal' ? IconUser : IconUsers" :icon="workspace.type === 'personal' ? IconUser : IconUsers"
class="pr-8 select-wrapper rounded bg-blue-500/15 py-1.75 border border-blue-600/25 !text-blue-500 focus-visible:bg-blue-400/10 focus-visible:border-blue-800/50 !focus-visible:text-blue-600 hover:bg-blue-400/10 hover:border-blue-800/50 !hover:text-blue-600" class="select-wrapper !focus-visible:text-blue-600 !hover:text-blue-600 rounded border border-blue-600/25 bg-blue-500/[.15] py-[0.4375rem] pr-8 !text-blue-500 hover:border-blue-800/50 hover:bg-blue-400/10 focus-visible:border-blue-800/50 focus-visible:bg-blue-400/10"
/> />
<template #content="{ hide }"> <template #content="{ hide }">
<div <div
@@ -176,7 +176,7 @@
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<div class="flex flex-col px-2 text-tiny"> <div class="flex flex-col px-2 text-tiny">
<span class="inline-flex font-semibold truncate"> <span class="inline-flex truncate font-semibold">
{{ {{
currentUser.displayName || currentUser.displayName ||
t("profile.default_hopp_displayname") t("profile.default_hopp_displayname")
@@ -215,7 +215,7 @@
</div> </div>
</div> </div>
</header> </header>
<AppAnnouncement v-if="!network.isOnline" /> <AppBanner v-if="banner" :banner="banner" />
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" /> <TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
<TeamsInvite <TeamsInvite
v-if="workspace.type === 'team' && workspace.teamID" v-if="workspace.type === 'team' && workspace.teamID"
@@ -231,29 +231,40 @@
@invite-team="inviteTeam(editingTeamName, editingTeamID)" @invite-team="inviteTeam(editingTeamName, editingTeamID)"
@refetch-teams="refetchTeams" @refetch-teams="refetchTeams"
/> />
<HoppSmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_team')"
@hide-modal="confirmRemove = false"
@resolve="deleteTeam"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, reactive, ref, watch } from "vue"
import IconUser from "~icons/lucide/user"
import IconUsers from "~icons/lucide/users"
import IconSettings from "~icons/lucide/settings"
import IconDownload from "~icons/lucide/download"
import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUserPlus from "~icons/lucide/user-plus"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
import { defineActionHandler, invokeAction } from "@helpers/actions" import { defineActionHandler, invokeAction } from "@helpers/actions"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
import { useToast } from "~/composables/toast"
import { WorkspaceService } from "~/services/workspace.service" import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { installPWA, pwaDefferedPrompt } from "@modules/pwa"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { computed, reactive, ref, watch } from "vue"
import { useToast } from "~/composables/toast"
import { GetMyTeamsQuery, TeamMemberRole } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
import { platform } from "~/platform"
import IconDownload from "~icons/lucide/download"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSettings from "~icons/lucide/settings"
import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUser from "~icons/lucide/user"
import IconUserPlus from "~icons/lucide/user-plus"
import IconUsers from "~icons/lucide/users"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { deleteTeam as backendDeleteTeam } from "~/helpers/backend/mutations/Team"
import { BannerService } from "~/services/banner.service"
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
@@ -271,13 +282,29 @@ const showTeamsModal = ref(false)
const breakpoints = useBreakpoints(breakpointsTailwind) const breakpoints = useBreakpoints(breakpointsTailwind)
const mdAndLarger = breakpoints.greater("md") const mdAndLarger = breakpoints.greater("md")
const { content: banner } = useService(BannerService)
const network = reactive(useNetwork()) const network = reactive(useNetwork())
watch(network, () => {
if (network.isOnline) {
banner.value = null
return
}
banner.value = {
type: "info",
text: t("helpers.offline"),
alternateText: t("helpers.offline_short"),
}
})
const currentUser = useReadonlyStream( const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(), platform.auth.getProbableUserStream(),
platform.auth.getProbableUser() platform.auth.getProbableUser()
) )
const confirmRemove = ref(false)
const teamID = ref<string | null>(null)
const selectedTeam = ref<GetMyTeamsQuery["myTeams"][number] | undefined>() const selectedTeam = ref<GetMyTeamsQuery["myTeams"][number] | undefined>()
// TeamList-Adapter // TeamList-Adapter
@@ -377,6 +404,24 @@ const handleTeamEdit = () => {
} }
} }
const deleteTeam = () => {
if (!teamID.value) return
pipe(
backendDeleteTeam(teamID.value),
TE.match(
(err) => {
// TODO: Better errors ? We know the possible errors now
toast.error(`${t("error.something_went_wrong")}`)
console.error(err)
},
() => {
invokeAction("workspace.switch.personal")
toast.success(`${t("team.deleted")}`)
}
)
)() // Tasks (and TEs) are lazy, so call the function returned
}
// Template refs // Template refs
const tippyActions = ref<any | null>(null) const tippyActions = ref<any | null>(null)
const profile = ref<any | null>(null) const profile = ref<any | null>(null)
@@ -405,6 +450,12 @@ defineActionHandler(
computed(() => !currentUser.value) computed(() => !currentUser.value)
) )
defineActionHandler("modals.team.delete", ({ teamId }) => {
if (selectedTeam.value?.myRole !== TeamMemberRole.Owner) return noPermission()
teamID.value = teamId
confirmRemove.value = true
})
const noPermission = () => { const noPermission = () => {
toast.error(`${t("profile.no_permission")}`) toast.error(`${t("profile.no_permission")}`)
} }

View File

@@ -1,7 +1,7 @@
<template> <template>
<div v-if="inspectionResults && inspectionResults.length > 0"> <div v-if="inspectionResults && inspectionResults.length > 0">
<tippy interactive trigger="click" theme="popover"> <tippy interactive trigger="click" theme="popover">
<div class="flex justify-center items-center flex-1 flex-col"> <div class="flex flex-1 flex-col items-center justify-center">
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:icon="IconAlertTriangle" :icon="IconAlertTriangle"
@@ -10,12 +10,12 @@
/> />
</div> </div>
<template #content="{ hide }"> <template #content="{ hide }">
<div class="flex flex-col space-y-2 items-start flex-1"> <div class="flex flex-1 flex-col items-start space-y-2">
<div <div
class="flex justify-between border rounded pl-2 border-divider bg-popover sticky top-0 self-stretch" class="sticky top-0 flex justify-between self-stretch rounded border border-divider bg-popover pl-2"
> >
<span class="flex items-center flex-1"> <span class="flex flex-1 items-center">
<icon-lucide-activity class="mr-2 svg-icons text-accent" /> <icon-lucide-activity class="svg-icons mr-2 text-accent" />
<span class="font-bold"> <span class="font-bold">
{{ t("inspections.title") }} {{ t("inspections.title") }}
</span> </span>
@@ -31,10 +31,10 @@
<div <div
v-for="(inspector, index) in inspectionResults" v-for="(inspector, index) in inspectionResults"
:key="index" :key="index"
class="flex self-stretch max-w-md w-full" class="flex w-full max-w-md self-stretch"
> >
<div <div
class="flex flex-col flex-1 rounded border border-dashed border-dividerDark divide-y divide-dashed divide-dividerDark" class="flex flex-1 flex-col divide-y divide-dashed divide-dividerDark rounded border border-dashed border-dividerDark"
> >
<span <span
v-if="inspector.text.type === 'text'" v-if="inspector.text.type === 'text'"
@@ -44,13 +44,13 @@
<HoppSmartLink <HoppSmartLink
blank blank
:to="inspector.doc.link" :to="inspector.doc.link"
class="text-accent hover:text-accentDark transition" class="text-accent transition hover:text-accentDark"
> >
{{ inspector.doc.text }} {{ inspector.doc.text }}
<icon-lucide-arrow-up-right class="svg-icons" /> <icon-lucide-arrow-up-right class="svg-icons" />
</HoppSmartLink> </HoppSmartLink>
</span> </span>
<span v-if="inspector.action" class="flex p-2 space-x-2"> <span v-if="inspector.action" class="flex space-x-2 p-2">
<HoppButtonSecondary <HoppButtonSecondary
:label="inspector.action.text" :label="inspector.action.text"
outline outline

View File

@@ -8,7 +8,7 @@
> >
<template #body> <template #body>
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<h2 class="p-4 font-semibold font-bold text-secondaryDark"> <h2 class="p-4 font-bold font-semibold text-secondaryDark">
{{ t("layout.name") }} {{ t("layout.name") }}
</h2> </h2>
<HoppSmartItem <HoppSmartItem
@@ -27,7 +27,7 @@
active active
@click="expandCollection" @click="expandCollection"
/> />
<h2 class="p-4 font-semibold font-bold text-secondaryDark"> <h2 class="p-4 font-bold font-semibold text-secondaryDark">
{{ t("support.title") }} {{ t("support.title") }}
</h2> </h2>
<template <template

View File

@@ -16,13 +16,13 @@
class="share-link" class="share-link"
tabindex="0" tabindex="0"
> >
<component :is="platform.icon" class="w-6 h-6" /> <component :is="platform.icon" class="h-6 w-6" />
<span class="mt-3"> <span class="mt-3">
{{ platform.name }} {{ platform.name }}
</span> </span>
</a> </a>
<button class="share-link" @click="copyAppLink"> <button class="share-link" @click="copyAppLink">
<component :is="copyIcon" class="w-6 h-6 text-xl" /> <component :is="copyIcon" class="h-6 w-6 text-xl" />
<span class="mt-3"> <span class="mt-3">
{{ t("app.copy") }} {{ t("app.copy") }}
</span> </span>
@@ -119,12 +119,12 @@ const hideModal = () => {
.share-link { .share-link {
@apply border border-dividerLight; @apply border border-dividerLight;
@apply rounded; @apply rounded;
@apply flex-col flex; @apply flex flex-col;
@apply p-4; @apply p-4;
@apply items-center; @apply items-center;
@apply justify-center; @apply justify-center;
@apply font-semibold; @apply font-semibold;
@apply hover: (bg-primaryLight text-secondaryDark); @apply hover:bg-primaryLight hover:text-secondaryDark;
@apply focus:outline-none; @apply focus:outline-none;
@apply focus-visible:border-divider; @apply focus-visible:border-divider;

View File

@@ -2,7 +2,7 @@
<HoppSmartSlideOver :show="show" :title="t('app.shortcuts')" @close="close()"> <HoppSmartSlideOver :show="show" :title="t('app.shortcuts')" @close="close()">
<template #content> <template #content>
<div <div
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto bg-primary" class="sticky top-0 z-10 flex flex-shrink-0 flex-col overflow-x-auto bg-primary"
> >
<HoppSmartInput <HoppSmartInput
v-model="filterText" v-model="filterText"
@@ -17,7 +17,7 @@
v-if="isEmpty(shortcutsResults)" v-if="isEmpty(shortcutsResults)"
:text="`${t('state.nothing_found')} ‟${filterText}”`" :text="`${t('state.nothing_found')} ‟${filterText}”`"
> >
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <icon-lucide-search class="svg-icons pb-2 opacity-75" />
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<details <details
@@ -28,16 +28,16 @@
open open
> >
<summary <summary
class="flex items-center flex-1 min-w-0 px-6 py-4 font-semibold transition cursor-pointer focus:outline-none text-secondaryLight hover:text-secondaryDark" class="flex min-w-0 flex-1 cursor-pointer items-center px-6 py-4 font-semibold text-secondaryLight transition hover:text-secondaryDark focus:outline-none"
> >
<icon-lucide-chevron-right class="mr-2 indicator" /> <icon-lucide-chevron-right class="indicator mr-2" />
<span <span
class="font-semibold truncate capitalize-first text-secondaryDark" class="capitalize-first truncate font-semibold text-secondaryDark"
> >
{{ sectionTitle }} {{ sectionTitle }}
</span> </span>
</summary> </summary>
<div class="flex flex-col px-6 pb-4 space-y-2"> <div class="flex flex-col space-y-2 px-6 pb-4">
<AppShortcutsEntry <AppShortcutsEntry
v-for="(shortcut, index) in sectionResults" v-for="(shortcut, index) in sectionResults"
:key="`shortcut-${index}`" :key="`shortcut-${index}`"

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex items-center py-1"> <div class="flex items-center py-1">
<span class="flex flex-1 mr-4"> <span class="mr-4 flex flex-1">
{{ shortcut.label }} {{ shortcut.label }}
</span> </span>
<kbd <kbd

View File

@@ -1,17 +1,17 @@
<template> <template>
<div class="flex flex-col items-center justify-center text-secondaryLight"> <div class="flex flex-col items-center justify-center text-secondaryLight">
<div class="flex mb-4 space-x-2"> <div class="mb-4 flex space-x-2">
<div class="flex flex-col items-end space-y-4 text-right"> <div class="flex flex-col items-end space-y-4 text-right">
<span class="flex items-center flex-1"> <span class="flex flex-1 items-center">
{{ t("shortcut.request.send_request") }} {{ t("shortcut.request.send_request") }}
</span> </span>
<span class="flex items-center flex-1"> <span class="flex flex-1 items-center">
{{ t("shortcut.general.show_all") }} {{ t("shortcut.general.show_all") }}
</span> </span>
<span class="flex items-center flex-1"> <span class="flex flex-1 items-center">
{{ t("shortcut.general.command_menu") }} {{ t("shortcut.general.command_menu") }}
</span> </span>
<span class="flex items-center flex-1"> <span class="flex flex-1 items-center">
{{ t("shortcut.general.help_menu") }} {{ t("shortcut.general.help_menu") }}
</span> </span>
</div> </div>

View File

@@ -1,6 +1,6 @@
<template> <template>
<aside class="flex justify-between h-full md:flex-col"> <aside class="flex h-full justify-between md:flex-col">
<nav class="flex flex-1 flex-nowrap md:flex-col md:flex-none bg-primary"> <nav class="flex flex-1 flex-nowrap bg-primary md:flex-none md:flex-col">
<HoppSmartLink <HoppSmartLink
v-for="(navigation, index) in primaryNavigation" v-for="(navigation, index) in primaryNavigation"
:key="`navigation-${index}`" :key="`navigation-${index}`"
@@ -73,10 +73,10 @@ const primaryNavigation = [
.nav-link { .nav-link {
@apply relative; @apply relative;
@apply p-4; @apply p-4;
@apply flex flex-col flex-1; @apply flex flex-1 flex-col;
@apply items-center; @apply items-center;
@apply justify-center; @apply justify-center;
@apply hover: (bg-primaryDark text-secondaryDark); @apply hover:bg-primaryDark hover:text-secondaryDark;
@apply focus-visible:text-secondaryDark; @apply focus-visible:text-secondaryDark;
@apply after:absolute; @apply after:absolute;
@apply after:inset-x-0; @apply after:inset-x-0;
@@ -85,12 +85,12 @@ const primaryNavigation = [
@apply after:bottom-0; @apply after:bottom-0;
@apply after:md:bottom-auto; @apply after:md:bottom-auto;
@apply after:md:left-0; @apply after:md:left-0;
@apply after:z-2; @apply after:z-10;
@apply after:h-0.5; @apply after:h-0.5;
@apply after:md:h-full; @apply after:md:h-full;
@apply after:w-full; @apply after:w-full;
@apply after:md:w-0.5; @apply after:md:w-0.5;
@apply after:content-DEFAULT; @apply after:content-[""];
@apply focus:after:bg-divider; @apply focus:after:bg-divider;
.svg-icons { .svg-icons {

View File

@@ -1,7 +1,7 @@
<template> <template>
<button <button
ref="el" ref="el"
class="flex items-center flex-1 px-6 py-4 font-medium space-x-4 transition cursor-pointer relative search-entry focus:outline-none" class="search-entry relative flex flex-1 cursor-pointer items-center space-x-4 px-6 py-4 font-medium transition focus:outline-none"
:class="{ 'active bg-primaryLight text-secondaryDark': active }" :class="{ 'active bg-primaryLight text-secondaryDark': active }"
tabindex="-1" tabindex="-1"
@click="emit('action')" @click="emit('action')"
@@ -9,7 +9,7 @@
> >
<component <component
:is="entry.icon" :is="entry.icon"
class="opacity-50 svg-icons" class="svg-icons opacity-50"
:class="{ 'opacity-100': active }" :class="{ 'opacity-100': active }"
/> />
<template <template
@@ -112,9 +112,9 @@ watch(
@apply after:left-0; @apply after:left-0;
@apply after:bottom-0; @apply after:bottom-0;
@apply after:bg-transparent; @apply after:bg-transparent;
@apply after:z-2; @apply after:z-10;
@apply after:w-0.5; @apply after:w-0.5;
@apply after:content-DEFAULT; @apply after:content-[''];
&.active { &.active {
@apply after:bg-accentLight; @apply after:bg-accentLight;

View File

@@ -8,7 +8,7 @@
{{ historyEntry.request.url }} {{ historyEntry.request.url }}
</span> </span>
<span <span
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1" class="flex flex-shrink-0 truncate rounded-md border border-dividerDark px-1 text-tiny font-semibold"
> >
{{ historyEntry.request.query.split("\n")[0] }} {{ historyEntry.request.query.split("\n")[0] }}
</span> </span>

View File

@@ -1,5 +1,5 @@
<template> <template>
<span class="flex flex-1 space-x-2 items-center"> <span class="flex flex-1 items-center space-x-2">
<template v-for="(folder, index) in pathFolders" :key="index"> <template v-for="(folder, index) in pathFolders" :key="index">
<span class="block" :class="{ truncate: index !== 0 }"> <span class="block" :class="{ truncate: index !== 0 }">
{{ folder.name }} {{ folder.name }}

View File

@@ -5,7 +5,7 @@
</span> </span>
<icon-lucide-chevron-right class="flex flex-shrink-0" /> <icon-lucide-chevron-right class="flex flex-shrink-0" />
<span <span
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1" class="flex flex-shrink-0 truncate rounded-md border border-dividerDark px-1 text-tiny font-semibold"
:class="entryStatus.className" :class="entryStatus.className"
> >
{{ historyEntry.request.method }} {{ historyEntry.request.method }}

View File

@@ -8,8 +8,8 @@
</template> </template>
<span <span
v-if="request" v-if="request"
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1" class="flex flex-shrink-0 truncate rounded-md border border-dividerDark px-1 text-tiny font-semibold"
:class="getMethodLabelColorClassOf(request)" :style="{ color: getMethodLabelColorClassOf(request) }"
> >
{{ request.method.toUpperCase() }} {{ request.method.toUpperCase() }}
</span> </span>

View File

@@ -6,7 +6,7 @@
@close="emit('hide-modal')" @close="emit('hide-modal')"
> >
<template #body> <template #body>
<div class="flex flex-col border-b transition border-divider"> <div class="flex flex-col border-b border-divider transition">
<div class="flex items-center"> <div class="flex items-center">
<input <input
id="command" id="command"
@@ -16,14 +16,14 @@
autocomplete="off" autocomplete="off"
name="command" name="command"
:placeholder="`${t('app.type_a_command_search')}`" :placeholder="`${t('app.type_a_command_search')}`"
class="flex flex-1 text-base bg-transparent text-secondaryDark px-6 py-5" class="flex flex-1 bg-transparent px-6 py-5 text-base text-secondaryDark"
/> />
<HoppSmartSpinner v-if="searchSession?.loading" class="mr-6" /> <HoppSmartSpinner v-if="searchSession?.loading" class="mr-6" />
</div> </div>
</div> </div>
<div <div
v-if="searchSession && search.length > 0" v-if="searchSession && search.length > 0"
class="flex flex-col flex-1 overflow-y-auto border-b border-divider divide-y divide-dividerLight" class="flex flex-1 flex-col divide-y divide-dividerLight overflow-y-auto border-b border-divider"
> >
<div <div
v-for="([sectionID, sectionResult], sectionIndex) in scoredResults" v-for="([sectionID, sectionResult], sectionIndex) in scoredResults"
@@ -31,7 +31,7 @@
class="flex flex-col" class="flex flex-col"
> >
<h5 <h5
class="px-6 py-2 bg-primaryContrast z-10 text-secondaryLight sticky top-0" class="sticky top-0 z-10 bg-primaryContrast px-6 py-2 text-secondaryLight"
> >
{{ sectionResult.title }} {{ sectionResult.title }}
</h5> </h5>
@@ -49,7 +49,7 @@
:text="`${t('state.nothing_found')} ‟${search}”`" :text="`${t('state.nothing_found')} ‟${search}”`"
> >
<template #icon> <template #icon>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <icon-lucide-search class="svg-icons pb-2 opacity-75" />
</template> </template>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('action.clear')" :label="t('action.clear')"
@@ -59,7 +59,7 @@
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
</div> </div>
<div <div
class="flex flex-shrink-0 text-tiny text-secondaryLight p-4 justify-between whitespace-nowrap overflow-auto <sm:hidden" class="flex flex-shrink-0 justify-between overflow-auto whitespace-nowrap p-4 text-tiny text-secondaryLight <sm:hidden"
> >
<div class="flex items-center"> <div class="flex items-center">
<kbd class="shortcut-key"></kbd> <kbd class="shortcut-key"></kbd>

View File

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

View File

@@ -12,16 +12,16 @@
@dragleave="ordering = false" @dragleave="ordering = false"
@dragend="resetDragState" @dragend="resetDragState"
></div> ></div>
<div class="flex flex-col relative"> <div class="relative flex flex-col">
<div <div
class="absolute bg-accent opacity-0 pointer-events-none inset-0 z-1 transition" class="z-1 pointer-events-none absolute inset-0 bg-accent opacity-0 transition"
:class="{ :class="{
'opacity-25': 'opacity-25':
dragging && notSameDestination && notSameParentDestination, dragging && notSameDestination && notSameParentDestination,
}" }"
></div> ></div>
<div <div
class="flex items-stretch group relative z-3 cursor-pointer pointer-events-auto" class="z-3 group pointer-events-auto relative flex cursor-pointer items-stretch"
:draggable="!hasNoTeamAccess" :draggable="!hasNoTeamAccess"
@dragstart="dragStart" @dragstart="dragStart"
@drop="handelDrop($event)" @drop="handelDrop($event)"
@@ -36,11 +36,11 @@
@contextmenu.prevent="options?.tippy.show()" @contextmenu.prevent="options?.tippy.show()"
> >
<div <div
class="flex items-center justify-center flex-1 min-w-0" class="flex min-w-0 flex-1 items-center justify-center"
@click="emit('toggle-children')" @click="emit('toggle-children')"
> >
<span <span
class="flex items-center justify-center px-4 pointer-events-none" class="pointer-events-none flex items-center justify-center px-4"
> >
<HoppSmartSpinner v-if="isCollLoading" /> <HoppSmartSpinner v-if="isCollLoading" />
<component <component
@@ -51,7 +51,7 @@
/> />
</span> </span>
<span <span
class="flex flex-1 min-w-0 py-2 pr-2 transition pointer-events-none group-hover:text-secondaryDark" class="pointer-events-none flex min-w-0 flex-1 py-2 pr-2 transition group-hover:text-secondaryDark"
> >
<span class="truncate" :class="{ 'text-accent': isSelected }"> <span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collectionName }} {{ collectionName }}

View File

@@ -26,7 +26,7 @@
<div v-if="step.name === 'FILE_IMPORT'" class="space-y-4"> <div v-if="step.name === 'FILE_IMPORT'" class="space-y-4">
<p class="flex items-center"> <p class="flex items-center">
<span <span
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary text-dividerDark" class="mr-4 inline-flex flex-shrink-0 items-center justify-center rounded-full border-4 border-primary text-dividerDark"
:class="{ :class="{
'!text-green-500': hasFile, '!text-green-500': hasFile,
}" }"
@@ -38,14 +38,14 @@
</span> </span>
</p> </p>
<p <p
class="flex flex-col ml-10 border border-dashed rounded border-dividerDark" class="ml-10 flex flex-col rounded border border-dashed border-dividerDark"
> >
<input <input
id="inputChooseFileToImportFrom" id="inputChooseFileToImportFrom"
ref="inputChooseFileToImportFrom" ref="inputChooseFileToImportFrom"
name="inputChooseFileToImportFrom" name="inputChooseFileToImportFrom"
type="file" type="file"
class="p-4 cursor-pointer transition file:transition file:cursor-pointer text-secondary hover:text-secondaryDark file:mr-2 file:py-2 file:px-4 file:rounded file:border-0 file:text-secondary hover:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark" class="cursor-pointer p-4 text-secondary transition file:mr-2 file:cursor-pointer file:rounded file:border-0 file:bg-primaryLight file:px-4 file:py-2 file:text-secondary file:transition hover:text-secondaryDark hover:file:bg-primaryDark hover:file:text-secondaryDark"
:accept="step.metadata.acceptedFileTypes" :accept="step.metadata.acceptedFileTypes"
@change="onFileChange" @change="onFileChange"
/> />
@@ -54,7 +54,7 @@
<div v-else-if="step.name === 'URL_IMPORT'" class="space-y-4"> <div v-else-if="step.name === 'URL_IMPORT'" class="space-y-4">
<p class="flex items-center"> <p class="flex items-center">
<span <span
class="inline-flex items-center justify-center flex-shrink-0 mr-4 border-4 rounded-full border-primary text-dividerDark" class="mr-4 inline-flex flex-shrink-0 items-center justify-center rounded-full border-4 border-primary text-dividerDark"
:class="{ :class="{
'!text-green-500': hasGist, '!text-green-500': hasGist,
}" }"
@@ -65,7 +65,7 @@
{{ t(`${step.metadata.caption}`) }} {{ t(`${step.metadata.caption}`) }}
</span> </span>
</p> </p>
<p class="flex flex-col ml-10"> <p class="ml-10 flex flex-col">
<input <input
v-model="inputChooseGistToImportFrom" v-model="inputChooseGistToImportFrom"
type="url" type="url"

View File

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

View File

@@ -13,7 +13,7 @@
@dragend="resetDragState" @dragend="resetDragState"
></div> ></div>
<div <div
class="flex items-stretch group" class="group flex items-stretch"
:draggable="!hasNoTeamAccess" :draggable="!hasNoTeamAccess"
@drop="handelDrop" @drop="handelDrop"
@dragstart="dragStart" @dragstart="dragStart"
@@ -23,12 +23,13 @@
@contextmenu.prevent="options?.tippy.show()" @contextmenu.prevent="options?.tippy.show()"
> >
<div <div
class="flex items-center justify-center flex-1 min-w-0 cursor-pointer pointer-events-auto" class="pointer-events-auto flex min-w-0 flex-1 cursor-pointer items-center justify-center"
@click="selectRequest()" @click="selectRequest()"
> >
<span <span
class="flex items-center justify-center w-16 px-2 truncate pointer-events-none" class="pointer-events-none flex w-16 items-center justify-center truncate px-2"
:class="requestLabelColor" :class="requestLabelColor"
:style="{ color: requestLabelColor }"
> >
<component <component
:is="IconCheckCircle" :is="IconCheckCircle"
@@ -37,12 +38,12 @@
:class="{ 'text-accent': isSelected }" :class="{ 'text-accent': isSelected }"
/> />
<HoppSmartSpinner v-else-if="isRequestLoading" /> <HoppSmartSpinner v-else-if="isRequestLoading" />
<span v-else class="font-semibold truncate text-tiny"> <span v-else class="truncate text-tiny font-semibold">
{{ request.method }} {{ request.method }}
</span> </span>
</span> </span>
<span <span
class="flex items-center flex-1 min-w-0 py-2 pr-2 pointer-events-none transition group-hover:text-secondaryDark" class="pointer-events-none flex min-w-0 flex-1 items-center py-2 pr-2 transition group-hover:text-secondaryDark"
> >
<span class="truncate" :class="{ 'text-accent': isSelected }"> <span class="truncate" :class="{ 'text-accent': isSelected }">
{{ request.name }} {{ request.name }}
@@ -50,15 +51,15 @@
<span <span
v-if="isActive" v-if="isActive"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3" class="relative mx-3 flex h-1.5 w-1.5 flex-shrink-0"
:title="`${t('collection.request_in_use')}`" :title="`${t('collection.request_in_use')}`"
> >
<span <span
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping" class="absolute inline-flex h-full w-full flex-shrink-0 animate-ping rounded-full bg-green-500 opacity-75"
> >
</span> </span>
<span <span
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500" class="relative inline-flex h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500"
></span> ></span>
</span> </span>
</span> </span>

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]"> <div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div <div
class="flex items-stretch group" class="group flex items-stretch"
@dragover.prevent @dragover.prevent
@drop.prevent="dropEvent" @drop.prevent="dropEvent"
@dragover="dragging = true" @dragover="dragging = true"
@@ -11,7 +11,7 @@
@contextmenu.prevent="options.tippy.show()" @contextmenu.prevent="options.tippy.show()"
> >
<span <span
class="flex items-center justify-center px-4 cursor-pointer" class="flex cursor-pointer items-center justify-center px-4"
@click="toggleShowChildren()" @click="toggleShowChildren()"
> >
<component <component
@@ -21,7 +21,7 @@
/> />
</span> </span>
<span <span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark" class="flex min-w-0 flex-1 cursor-pointer py-2 pr-2 transition group-hover:text-secondaryDark"
@click="toggleShowChildren()" @click="toggleShowChildren()"
> >
<span class="truncate" :class="{ 'text-accent': isSelected }"> <span class="truncate" :class="{ 'text-accent': isSelected }">
@@ -136,10 +136,10 @@
</div> </div>
<div v-if="showChildren || isFiltered" class="flex"> <div v-if="showChildren || isFiltered" class="flex">
<div <div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-0.5 hover:bg-dividerDark hover:scale-x-125" class="ml-[1.375rem] flex w-0.5 transform cursor-nsResize bg-dividerLight transition hover:scale-x-125 hover:bg-dividerDark"
@click="toggleShowChildren()" @click="toggleShowChildren()"
></div> ></div>
<div class="flex flex-col flex-1 truncate"> <div class="flex flex-1 flex-col truncate">
<CollectionsGraphqlFolder <CollectionsGraphqlFolder
v-for="(folder, index) in collection.folders" v-for="(folder, index) in collection.folders"
:key="`folder-${String(index)}`" :key="`folder-${String(index)}`"
@@ -220,7 +220,8 @@ import {
moveGraphqlRequest, moveGraphqlRequest,
} from "~/newstore/collections" } from "~/newstore/collections"
import { Picked } from "~/helpers/types/HoppPicked" import { Picked } from "~/helpers/types/HoppPicked"
import { getTabsRefTo } from "~/helpers/graphql/tab" import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
const props = defineProps({ const props = defineProps({
picked: { type: Object, default: null }, picked: { type: Object, default: null },
@@ -235,6 +236,8 @@ const colorMode = useColorMode()
const toast = useToast() const toast = useToast()
const t = useI18n() const t = useI18n()
const tabs = useService(GQLTabService)
// TODO: improve types plz // TODO: improve types plz
const emit = defineEmits<{ const emit = defineEmits<{
(e: "select", i: Picked | null): void (e: "select", i: Picked | null): void
@@ -295,7 +298,7 @@ const removeCollection = () => {
emit("select", null) emit("select", null)
} }
const possibleTabs = getTabsRefTo((tab) => { const possibleTabs = tabs.getTabsRefTo((tab) => {
const ctx = tab.document.saveContext const ctx = tab.document.saveContext
if (!ctx) return false if (!ctx) return false

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]"> <div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div <div
class="flex items-stretch group" class="group flex items-stretch"
@dragover.prevent @dragover.prevent
@drop.prevent="dropEvent" @drop.prevent="dropEvent"
@dragover="dragging = true" @dragover="dragging = true"
@@ -11,7 +11,7 @@
@contextmenu.prevent="options.tippy.show()" @contextmenu.prevent="options.tippy.show()"
> >
<span <span
class="flex items-center justify-center px-4 cursor-pointer" class="flex cursor-pointer items-center justify-center px-4"
@click="toggleShowChildren()" @click="toggleShowChildren()"
> >
<component <component
@@ -21,7 +21,7 @@
/> />
</span> </span>
<span <span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark" class="flex min-w-0 flex-1 cursor-pointer py-2 pr-2 transition group-hover:text-secondaryDark"
@click="toggleShowChildren()" @click="toggleShowChildren()"
> >
<span class="truncate" :class="{ 'text-accent': isSelected }"> <span class="truncate" :class="{ 'text-accent': isSelected }">
@@ -128,10 +128,10 @@
</div> </div>
<div v-if="showChildren || isFiltered" class="flex"> <div v-if="showChildren || isFiltered" class="flex">
<div <div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-0.5 hover:bg-dividerDark hover:scale-x-125" class="ml-[1.375rem] flex w-0.5 transform cursor-nsResize bg-dividerLight transition hover:scale-x-125 hover:bg-dividerDark"
@click="toggleShowChildren()" @click="toggleShowChildren()"
></div> ></div>
<div class="flex flex-col flex-1 truncate"> <div class="flex flex-1 flex-col truncate">
<!-- Referring to this component only (this is recursive) --> <!-- Referring to this component only (this is recursive) -->
<Folder <Folder
v-for="(subFolder, subFolderIndex) in folder.folders" v-for="(subFolder, subFolderIndex) in folder.folders"
@@ -203,12 +203,15 @@ import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming" import { useColorMode } from "@composables/theming"
import { removeGraphqlFolder, moveGraphqlRequest } from "~/newstore/collections" import { removeGraphqlFolder, moveGraphqlRequest } from "~/newstore/collections"
import { computed, ref } from "vue" import { computed, ref } from "vue"
import { getTabsRefTo } from "~/helpers/graphql/tab" import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
const toast = useToast() const toast = useToast()
const t = useI18n() const t = useI18n()
const colorMode = useColorMode() const colorMode = useColorMode()
const tabs = useService(GQLTabService)
const props = defineProps({ const props = defineProps({
picked: { type: Object, default: null }, picked: { type: Object, default: null },
// Whether the request is in a selectable mode (activates 'select' event) // Whether the request is in a selectable mode (activates 'select' event)
@@ -277,7 +280,7 @@ const removeFolder = () => {
emit("select", { picked: null }) emit("select", { picked: null })
} }
const possibleTabs = getTabsRefTo((tab) => { const possibleTabs = tabs.getTabsRefTo((tab) => {
const ctx = tab.document.saveContext const ctx = tab.document.saveContext
if (!ctx) return false if (!ctx) return false

View File

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

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]"> <div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div <div
class="flex items-stretch group" class="group flex items-stretch"
draggable="true" draggable="true"
@dragstart="dragStart" @dragstart="dragStart"
@dragover.stop @dragover.stop
@@ -10,7 +10,7 @@
@contextmenu.prevent="options.tippy.show()" @contextmenu.prevent="options.tippy.show()"
> >
<span <span
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer" class="flex w-16 cursor-pointer items-center justify-center truncate px-2"
@click="selectRequest()" @click="selectRequest()"
> >
<component <component
@@ -20,7 +20,7 @@
/> />
</span> </span>
<span <span
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark" class="flex min-w-0 flex-1 cursor-pointer items-center py-2 pr-2 transition group-hover:text-secondaryDark"
@click="selectRequest()" @click="selectRequest()"
> >
<span class="truncate" :class="{ 'text-accent': isSelected }"> <span class="truncate" :class="{ 'text-accent': isSelected }">
@@ -29,15 +29,15 @@
<span <span
v-if="isActive" v-if="isActive"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3" class="relative mx-3 flex h-1.5 w-1.5 flex-shrink-0"
:title="`${t('collection.request_in_use')}`" :title="`${t('collection.request_in_use')}`"
> >
<span <span
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping" class="absolute inline-flex h-full w-full flex-shrink-0 animate-ping rounded-full bg-green-500 opacity-75"
> >
</span> </span>
<span <span
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500" class="relative inline-flex h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500"
></span> ></span>
</span> </span>
</span> </span>
@@ -137,12 +137,8 @@ import { useToast } from "@composables/toast"
import { HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data" import { HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
import { removeGraphqlRequest } from "~/newstore/collections" import { removeGraphqlRequest } from "~/newstore/collections"
import { import { useService } from "dioc/vue"
createNewTab, import { GQLTabService } from "~/services/tab/graphql"
getTabRefWithSaveContext,
currentTabID,
currentActiveTab,
} from "~/helpers/graphql/tab"
// Template refs // Template refs
const tippyActions = ref<any | null>(null) const tippyActions = ref<any | null>(null)
@@ -154,6 +150,8 @@ const deleteAction = ref<any | null>(null)
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
const tabs = useService(GQLTabService)
const props = defineProps({ const props = defineProps({
// Whether the object is selected (show the tick mark) // Whether the object is selected (show the tick mark)
picked: { type: Object, default: null }, picked: { type: Object, default: null },
@@ -165,7 +163,7 @@ const props = defineProps({
}) })
const isActive = computed(() => { const isActive = computed(() => {
const saveCtx = currentActiveTab.value?.document.saveContext const saveCtx = tabs.currentActiveTab.value?.document.saveContext
if (!saveCtx) return false if (!saveCtx) return false
@@ -201,7 +199,7 @@ const selectRequest = () => {
if (props.saveRequest) { if (props.saveRequest) {
pick() pick()
} else { } else {
const possibleTab = getTabRefWithSaveContext({ const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection", originLocation: "user-collection",
folderPath: props.folderPath, folderPath: props.folderPath,
requestIndex: props.requestIndex, requestIndex: props.requestIndex,
@@ -209,11 +207,11 @@ const selectRequest = () => {
// Switch to that request if that request is open // Switch to that request if that request is open
if (possibleTab) { if (possibleTab) {
currentTabID.value = possibleTab.value.id tabs.setActiveTab(possibleTab.value.id)
return return
} }
createNewTab({ tabs.createNewTab({
saveContext: { saveContext: {
originLocation: "user-collection", originLocation: "user-collection",
folderPath: props.folderPath, folderPath: props.folderPath,
@@ -253,7 +251,7 @@ const removeRequest = () => {
} }
// Detach the request from any of the tabs // Detach the request from any of the tabs
const possibleTab = getTabRefWithSaveContext({ const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection", originLocation: "user-collection",
folderPath: props.folderPath, folderPath: props.folderPath,
requestIndex: props.requestIndex, requestIndex: props.requestIndex,

View File

@@ -1,7 +1,7 @@
<template> <template>
<div :class="{ 'rounded border border-divider': saveRequest }"> <div :class="{ 'rounded border border-divider': saveRequest }">
<div <div
class="sticky z-10 flex flex-col flex-shrink-0 overflow-x-auto rounded-t bg-primary" class="sticky z-10 flex flex-shrink-0 flex-col overflow-x-auto rounded-t bg-primary"
:style=" :style="
saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0' saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0'
" "
@@ -11,10 +11,10 @@
type="search" type="search"
autocomplete="off" autocomplete="off"
:placeholder="t('action.search')" :placeholder="t('action.search')"
class="py-2 pl-4 pr-2 bg-transparent !border-0" class="!border-0 bg-transparent py-2 pl-4 pr-2"
/> />
<div <div
class="flex justify-between flex-1 flex-shrink-0 border-y bg-primary border-dividerLight" class="flex flex-1 flex-shrink-0 justify-between border-y border-dividerLight bg-primary"
> >
<HoppButtonSecondary <HoppButtonSecondary
:icon="IconPlus" :icon="IconPlus"
@@ -34,7 +34,7 @@
v-if="!saveRequest" v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
:title="t('modal.import_export')" :title="t('modal.import_export')"
:icon="IconArchive" :icon="IconImport"
@click="displayModalImportExport(true)" @click="displayModalImportExport(true)"
/> />
</div> </div>
@@ -66,19 +66,34 @@
:alt="`${t('empty.collections')}`" :alt="`${t('empty.collections')}`"
:text="t('empty.collections')" :text="t('empty.collections')"
> >
<div class="flex flex-col items-center space-y-4">
<span class="text-center text-secondaryLight">
{{ t("collection.import_or_create") }}
</span>
<div class="flex flex-col items-stretch gap-4">
<HoppButtonPrimary
:icon="IconImport"
:label="t('import.title')"
filled
outline
@click="displayModalImportExport(true)"
/>
<HoppButtonSecondary <HoppButtonSecondary
:label="t('add.new')" :label="t('add.new')"
filled filled
outline outline
:icon="IconPlus"
@click="displayModalAdd(true)" @click="displayModalAdd(true)"
/> />
</div>
</div>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-if="!(filteredCollections.length !== 0 || collections.length === 0)" v-if="!(filteredCollections.length !== 0 || collections.length === 0)"
:text="`${t('state.nothing_found')}${filterText}`" :text="`${t('state.nothing_found')}${filterText}`"
> >
<template #icon> <template #icon>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <icon-lucide-search class="svg-icons pb-2 opacity-75" />
</template> </template>
</HoppSmartPlaceholder> </HoppSmartPlaceholder>
<CollectionsGraphqlAdd <CollectionsGraphqlAdd
@@ -140,12 +155,13 @@ import {
import IconPlus from "~icons/lucide/plus" import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle" import IconHelpCircle from "~icons/lucide/help-circle"
import IconArchive from "~icons/lucide/archive" import IconImport from "~icons/lucide/folder-down"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming" import { useColorMode } from "@composables/theming"
import { platform } from "~/platform" import { platform } from "~/platform"
import { createNewTab, currentActiveTab } from "~/helpers/graphql/tab" import { useService } from "dioc/vue"
import { GQLTabService } from "~/services/tab/graphql"
export default defineComponent({ export default defineComponent({
props: { props: {
@@ -158,14 +174,16 @@ export default defineComponent({
const collections = useReadonlyStream(graphqlCollections$, [], "deep") const collections = useReadonlyStream(graphqlCollections$, [], "deep")
const colorMode = useColorMode() const colorMode = useColorMode()
const t = useI18n() const t = useI18n()
const tabs = useService(GQLTabService)
return { return {
collections, collections,
colorMode, colorMode,
t, t,
tabs,
IconPlus, IconPlus,
IconHelpCircle, IconHelpCircle,
IconArchive, IconImport,
} }
}, },
data() { data() {
@@ -267,13 +285,13 @@ export default defineComponent({
}, },
onAddRequest({ name, path, index }) { onAddRequest({ name, path, index }) {
const newRequest = { const newRequest = {
...currentActiveTab.value.document.request, ...this.tabs.currentActiveTab.value.document.request,
name, name,
} }
saveGraphqlRequestAs(path, newRequest) saveGraphqlRequestAs(path, newRequest)
createNewTab({ this.tabs.createNewTab({
saveContext: { saveContext: {
originLocation: "user-collection", originLocation: "user-collection",
folderPath: path, folderPath: path,

View File

@@ -11,20 +11,19 @@
@dragend="draggingToRoot = false" @dragend="draggingToRoot = false"
> >
<div <div
class="sticky z-10 flex flex-col flex-shrink-0 overflow-x-auto border-b bg-primary border-dividerLight" class="sticky z-10 flex flex-shrink-0 flex-col overflow-x-auto border-b border-dividerLight bg-primary"
:class="{ 'rounded-t': saveRequest }" :class="{ 'rounded-t': saveRequest }"
:style=" :style="
saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0' saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0'
" "
> >
<WorkspaceCurrent :section="t('tab.collections')" /> <WorkspaceCurrent :section="t('tab.collections')" />
<input
<HoppSmartInput
v-model="filterTexts" v-model="filterTexts"
:placeholder="t('action.search')"
input-styles="py-2 pl-4 pr-2 bg-transparent !border-0"
type="search" type="search"
:autofocus="false" autocomplete="off"
class="flex h-8 w-full bg-transparent p-4 py-2"
:placeholder="t('action.search')"
:disabled="collectionsType.type === 'team-collections'" :disabled="collectionsType.type === 'team-collections'"
/> />
</div> </div>
@@ -86,12 +85,12 @@
@display-modal-import-export="displayModalImportExport(true)" @display-modal-import-export="displayModalImportExport(true)"
/> />
<div <div
class="hidden bg-primaryDark flex-col flex-1 items-center py-15 justify-center px-4 text-secondaryLight" class="py-15 hidden flex-1 flex-col items-center justify-center bg-primaryDark px-4 text-secondaryLight"
:class="{ :class="{
'!flex': draggingToRoot && currentReorderingStatus.type !== 'request', '!flex': draggingToRoot && currentReorderingStatus.type !== 'request',
}" }"
> >
<icon-lucide-list-end class="svg-icons !w-8 !h-8" /> <icon-lucide-list-end class="svg-icons !h-8 !w-8" />
</div> </div>
<CollectionsAdd <CollectionsAdd
:show="showModalAdd" :show="showModalAdd"
@@ -219,12 +218,6 @@ import {
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { platform } from "~/platform" import { platform } from "~/platform"
import { createCollectionGists } from "~/helpers/gist" import { createCollectionGists } from "~/helpers/gist"
import {
createNewTab,
currentActiveTab,
currentTabID,
getTabRefWithSaveContext,
} from "~/helpers/rest/tab"
import { import {
getRequestsByPath, getRequestsByPath,
resolveSaveContextOnRequestReorder, resolveSaveContextOnRequestReorder,
@@ -239,9 +232,11 @@ import { currentReorderingStatus$ } from "~/newstore/reordering"
import { defineActionHandler } from "~/helpers/actions" import { defineActionHandler } from "~/helpers/actions"
import { WorkspaceService } from "~/services/workspace.service" import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { RESTTabService } from "~/services/tab/rest"
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
const tabs = useService(RESTTabService)
const props = defineProps({ const props = defineProps({
saveRequest: { saveRequest: {
@@ -377,22 +372,26 @@ const updateSelectedTeam = (team: SelectedTeam) => {
const workspace = workspaceService.currentWorkspace const workspace = workspaceService.currentWorkspace
// Used to switch collection type and team when user switch workspace in the global workspace switcher // Used to switch collection type and team when user switch workspace in the global workspace switcher
// Check if there is a teamID in the workspace, if yes, switch to team collection and select the team // Check if there is a teamID in the workspace, if yes, switch to team collections and select the team
// If there is no teamID, switch to my environment // If there is no teamID, switch to my collections
watch( watch(
() => { () => {
const space = workspace.value const space = workspace.value
return space.type === "personal" ? undefined : space.teamID
if (space.type === "personal") return undefined
else return space.teamID
}, },
(teamID) => { (teamID) => {
if (!teamID) { if (teamID) {
switchToMyCollections()
} else if (teamID) {
const team = myTeams.value?.find((t) => t.id === teamID) const team = myTeams.value?.find((t) => t.id === teamID)
if (team) updateSelectedTeam(team) if (team) {
updateSelectedTeam(team)
} }
return
}
return switchToMyCollections()
},
{
immediate: true,
} }
) )
@@ -650,7 +649,7 @@ const addRequest = (payload: {
const onAddRequest = (requestName: string) => { const onAddRequest = (requestName: string) => {
const newRequest = { const newRequest = {
...cloneDeep(currentActiveTab.value.document.request), ...cloneDeep(tabs.currentActiveTab.value.document.request),
name: requestName, name: requestName,
} }
@@ -659,7 +658,7 @@ const onAddRequest = (requestName: string) => {
if (!path) return if (!path) return
const insertionIndex = saveRESTRequestAs(path, newRequest) const insertionIndex = saveRESTRequestAs(path, newRequest)
createNewTab({ tabs.createNewTab({
request: newRequest, request: newRequest,
isDirty: false, isDirty: false,
saveContext: { saveContext: {
@@ -708,7 +707,7 @@ const onAddRequest = (requestName: string) => {
(result) => { (result) => {
const { createRequestInCollection } = result const { createRequestInCollection } = result
createNewTab({ tabs.createNewTab({
request: newRequest, request: newRequest,
isDirty: false, isDirty: false,
saveContext: { saveContext: {
@@ -931,7 +930,7 @@ const updateEditingRequest = (newName: string) => {
if (folderPath === null || requestIndex === null) return if (folderPath === null || requestIndex === null) return
const possibleActiveTab = getTabRefWithSaveContext({ const possibleActiveTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection", originLocation: "user-collection",
requestIndex, requestIndex,
folderPath, folderPath,
@@ -975,7 +974,7 @@ const updateEditingRequest = (newName: string) => {
) )
)() )()
const possibleTab = getTabRefWithSaveContext({ const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "team-collection", originLocation: "team-collection",
requestID, requestID,
}) })
@@ -1211,7 +1210,7 @@ const onRemoveRequest = () => {
emit("select", null) emit("select", null)
} }
const possibleTab = getTabRefWithSaveContext({ const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection", originLocation: "user-collection",
folderPath, folderPath,
requestIndex, requestIndex,
@@ -1271,7 +1270,7 @@ const onRemoveRequest = () => {
)() )()
// If there is a tab attached to this request, dissociate its state and mark it dirty // If there is a tab attached to this request, dissociate its state and mark it dirty
const possibleTab = getTabRefWithSaveContext({ const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "team-collection", originLocation: "team-collection",
requestID, requestID,
}) })
@@ -1304,14 +1303,14 @@ const selectRequest = (selectedRequest: {
let possibleTab = null let possibleTab = null
if (collectionsType.value.type === "team-collections") { if (collectionsType.value.type === "team-collections") {
possibleTab = getTabRefWithSaveContext({ possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "team-collection", originLocation: "team-collection",
requestID: requestIndex, requestID: requestIndex,
}) })
if (possibleTab) { if (possibleTab) {
currentTabID.value = possibleTab.value.id tabs.setActiveTab(possibleTab.value.id)
} else { } else {
createNewTab({ tabs.createNewTab({
request: cloneDeep(request), request: cloneDeep(request),
isDirty: false, isDirty: false,
saveContext: { saveContext: {
@@ -1321,16 +1320,16 @@ const selectRequest = (selectedRequest: {
}) })
} }
} else { } else {
possibleTab = getTabRefWithSaveContext({ possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection", originLocation: "user-collection",
requestIndex: parseInt(requestIndex), requestIndex: parseInt(requestIndex),
folderPath: folderPath!, folderPath: folderPath!,
}) })
if (possibleTab) { if (possibleTab) {
currentTabID.value = possibleTab.value.id tabs.setActiveTab(possibleTab.value.id)
} else { } else {
// If not, open the request in a new tab // If not, open the request in a new tab
createNewTab({ tabs.createNewTab({
request: cloneDeep(request), request: cloneDeep(request),
isDirty: false, isDirty: false,
saveContext: { saveContext: {
@@ -1373,7 +1372,7 @@ const dropRequest = (payload: {
destinationCollectionIndex destinationCollectionIndex
) )
const possibleTab = getTabRefWithSaveContext({ const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "user-collection", originLocation: "user-collection",
folderPath, folderPath,
requestIndex: pathToLastIndex(requestIndex), requestIndex: pathToLastIndex(requestIndex),
@@ -1422,7 +1421,7 @@ const dropRequest = (payload: {
1 1
) )
const possibleTab = getTabRefWithSaveContext({ const possibleTab = tabs.getTabRefWithSaveContext({
originLocation: "team-collection", originLocation: "team-collection",
requestID: requestIndex, requestID: requestIndex,
}) })
@@ -1938,6 +1937,12 @@ const exportJSONCollection = async () => {
await getJSONCollection() await getJSONCollection()
const parsedCollections = JSON.parse(collectionJSON.value)
if (!parsedCollections.length) {
return toast.error(t("error.no_collections_to_export"))
}
initializeDownloadCollection(collectionJSON.value, null) initializeDownloadCollection(collectionJSON.value, null)
} }

View File

@@ -5,9 +5,9 @@
@close="hideModal" @close="hideModal"
> >
<template #body> <template #body>
<div class="flex space-y-4 flex-1 flex-col"> <div class="flex flex-1 flex-col space-y-4">
<div class="flex items-center space-x-8 ml-2"> <div class="ml-2 flex items-center space-x-8">
<label for="name" class="font-semibold min-w-10">{{ <label for="name" class="min-w-10 font-semibold">{{
t("environment.name") t("environment.name")
}}</label> }}</label>
<input <input
@@ -17,8 +17,8 @@
class="input" class="input"
/> />
</div> </div>
<div class="flex items-center space-x-8 ml-2"> <div class="ml-2 flex items-center space-x-8">
<label for="value" class="font-semibold min-w-10">{{ <label for="value" class="min-w-10 font-semibold">{{
t("environment.value") t("environment.value")
}}</label> }}</label>
<input <input
@@ -28,17 +28,17 @@
:placeholder="t('environment.value')" :placeholder="t('environment.value')"
/> />
</div> </div>
<div class="flex items-center space-x-8 ml-2"> <div class="ml-2 flex items-center space-x-8">
<label for="scope" class="font-semibold min-w-10"> <label for="scope" class="min-w-10 font-semibold">
{{ t("environment.scope") }} {{ t("environment.scope") }}
</label> </label>
<div <div
class="relative flex flex-1 flex-col border border-divider rounded focus-visible:border-dividerDark" class="relative flex flex-1 flex-col rounded border border-divider focus-visible:border-dividerDark"
> >
<EnvironmentsSelector v-model="scope" :is-scope-selector="true" /> <EnvironmentsSelector v-model="scope" :is-scope-selector="true" />
</div> </div>
</div> </div>
<div v-if="replaceWithVariable" class="flex space-x-2 mt-3"> <div v-if="replaceWithVariable" class="mt-3 flex space-x-2">
<div class="min-w-18" /> <div class="min-w-18" />
<HoppSmartCheckbox <HoppSmartCheckbox
:on="replaceWithVariable" :on="replaceWithVariable"
@@ -83,11 +83,14 @@ import {
import * as TE from "fp-ts/TaskEither" import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function" import { pipe } from "fp-ts/function"
import { updateTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment" import { updateTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
import { currentActiveTab } from "~/helpers/rest/tab" import { RESTTabService } from "~/services/tab/rest"
import { useService } from "dioc/vue"
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
const tabs = useService(RESTTabService)
const props = defineProps<{ const props = defineProps<{
show: boolean show: boolean
position: { top: number; left: number } position: { top: number; left: number }
@@ -189,8 +192,8 @@ const addEnvironment = async () => {
//replace the current tab endpoint with the variable name with << and >> //replace the current tab endpoint with the variable name with << and >>
const variableName = `<<${editingName.value}>>` const variableName = `<<${editingName.value}>>`
//replace the currenttab endpoint containing the value in the text with variablename //replace the currenttab endpoint containing the value in the text with variablename
currentActiveTab.value.document.request.endpoint = tabs.currentActiveTab.value.document.request.endpoint =
currentActiveTab.value.document.request.endpoint.replace( tabs.currentActiveTab.value.document.request.endpoint.replace(
editingValue.value, editingValue.value,
variableName variableName
) )

View File

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

View File

@@ -20,7 +20,7 @@
: `${t('environment.select')}` : `${t('environment.select')}`
: '' : ''
" "
class="flex-1 !justify-start pr-8 rounded-none" class="flex-1 !justify-start rounded-none pr-8"
/> />
</span> </span>
<template #content="{ hide }"> <template #content="{ hide }">
@@ -66,7 +66,7 @@
/> />
<HoppSmartTabs <HoppSmartTabs
v-model="selectedEnvTab" v-model="selectedEnvTab"
:styles="`sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-0 top-0 bg-primary ${ :styles="`sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary ${
!isTeamSelected || workspace.type === 'personal' !isTeamSelected || workspace.type === 'personal'
? 'bg-primaryLight' ? 'bg-primaryLight'
: '' : ''
@@ -101,7 +101,7 @@
<img <img
:src="`/images/states/${colorMode.value}/blockchain.svg`" :src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy" loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-2" class="mb-2 inline-flex h-16 w-16 flex-col object-contain object-center"
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
/> />
<span class="pb-2 text-center"> <span class="pb-2 text-center">
@@ -148,7 +148,7 @@
<img <img
:src="`/images/states/${colorMode.value}/blockchain.svg`" :src="`/images/states/${colorMode.value}/blockchain.svg`"
loading="lazy" loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-2" class="mb-2 inline-flex h-16 w-16 flex-col object-contain object-center"
:alt="`${t('empty.environments')}`" :alt="`${t('empty.environments')}`"
/> />
<span class="pb-2 text-center"> <span class="pb-2 text-center">
@@ -160,7 +160,7 @@
v-if="!teamListLoading && teamAdapterError" v-if="!teamListLoading && teamAdapterError"
class="flex flex-col items-center py-4" class="flex flex-col items-center py-4"
> >
<icon-lucide-help-circle class="mb-4 svg-icons" /> <icon-lucide-help-circle class="svg-icons mb-4" />
{{ getErrorMessage(teamAdapterError) }} {{ getErrorMessage(teamAdapterError) }}
</div> </div>
</HoppSmartTab> </HoppSmartTab>
@@ -190,7 +190,7 @@
@keyup.escape="hide()" @keyup.escape="hide()"
> >
<div <div
class="sticky top-0 font-semibold truncate flex items-center justify-between text-secondaryDark bg-primary border border-divider rounded pl-4" class="sticky top-0 flex items-center justify-between truncate rounded border border-divider bg-primary pl-4 font-semibold text-secondaryDark"
> >
{{ t("environment.global_variables") }} {{ t("environment.global_variables") }}
<HoppButtonSecondary <HoppButtonSecondary
@@ -205,12 +205,12 @@
" "
/> />
</div> </div>
<div class="my-2 flex flex-col flex-1 space-y-2 pl-4 pr-2"> <div class="my-2 flex flex-1 flex-col space-y-2 pl-4 pr-2">
<div class="flex flex-1 space-x-4"> <div class="flex flex-1 space-x-4">
<span class="w-1/4 min-w-32 truncate text-tiny font-semibold"> <span class="min-w-32 w-1/4 truncate text-tiny font-semibold">
{{ t("environment.name") }} {{ t("environment.name") }}
</span> </span>
<span class="w-full min-w-32 truncate text-tiny font-semibold"> <span class="min-w-32 w-full truncate text-tiny font-semibold">
{{ t("environment.value") }} {{ t("environment.value") }}
</span> </span>
</div> </div>
@@ -219,10 +219,10 @@
:key="index" :key="index"
class="flex flex-1 space-x-4" class="flex flex-1 space-x-4"
> >
<span class="text-secondaryLight w-1/4 min-w-32 truncate"> <span class="min-w-32 w-1/4 truncate text-secondaryLight">
{{ variable.key }} {{ variable.key }}
</span> </span>
<span class="text-secondaryLight w-full min-w-32 truncate"> <span class="min-w-32 w-full truncate text-secondaryLight">
{{ variable.value }} {{ variable.value }}
</span> </span>
</div> </div>
@@ -231,7 +231,7 @@
</div> </div>
</div> </div>
<div <div
class="sticky top-0 mt-2 font-semibold truncate flex items-center justify-between text-secondaryDark bg-primary border border-divider rounded pl-4" class="sticky top-0 mt-2 flex items-center justify-between truncate rounded border border-divider bg-primary pl-4 font-semibold text-secondaryDark"
:class="{ :class="{
'bg-primaryLight': !selectedEnv.variables, 'bg-primaryLight': !selectedEnv.variables,
}" }"
@@ -252,16 +252,16 @@
</div> </div>
<div <div
v-if="selectedEnv.type === 'NO_ENV_SELECTED'" v-if="selectedEnv.type === 'NO_ENV_SELECTED'"
class="text-secondaryLight my-2 flex flex-col flex-1 pl-4" class="my-2 flex flex-1 flex-col pl-4 text-secondaryLight"
> >
{{ t("environment.no_active_environment") }} {{ t("environment.no_active_environment") }}
</div> </div>
<div v-else class="my-2 flex flex-col flex-1 space-y-2 pl-4 pr-2"> <div v-else class="my-2 flex flex-1 flex-col space-y-2 pl-4 pr-2">
<div class="flex flex-1 space-x-4"> <div class="flex flex-1 space-x-4">
<span class="w-1/4 min-w-32 truncate text-tiny font-semibold"> <span class="min-w-32 w-1/4 truncate text-tiny font-semibold">
{{ t("environment.name") }} {{ t("environment.name") }}
</span> </span>
<span class="w-full min-w-32 truncate text-tiny font-semibold"> <span class="min-w-32 w-full truncate text-tiny font-semibold">
{{ t("environment.value") }} {{ t("environment.value") }}
</span> </span>
</div> </div>
@@ -270,10 +270,10 @@
:key="index" :key="index"
class="flex flex-1 space-x-4" class="flex flex-1 space-x-4"
> >
<span class="text-secondaryLight w-1/4 min-w-32 truncate"> <span class="min-w-32 w-1/4 truncate text-secondaryLight">
{{ variable.key }} {{ variable.key }}
</span> </span>
<span class="text-secondaryLight w-full min-w-32 truncate"> <span class="min-w-32 w-full truncate text-secondaryLight">
{{ variable.value }} {{ variable.value }}
</span> </span>
</div> </div>
@@ -478,7 +478,8 @@ watch(
teamEnvListAdapter.changeTeamID(newVal.teamID) teamEnvListAdapter.changeTeamID(newVal.teamID)
} }
} }
} },
{ immediate: true }
) )
const selectedEnv = computed(() => { const selectedEnv = computed(() => {

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