Compare commits

...

30 Commits

Author SHA1 Message Date
Liyas Thomas
20c8973f5d chore: set X-Frame-Options to SAMEORIGIN 2023-02-01 23:46:15 +05:30
Liyas Thomas
461d67ce90 feat: deploy hoppscotch-ui 2023-02-01 23:15:50 +05:30
Liyas Thomas
492c3a0902 fix: open gist html_url after export 2023-02-01 20:59:12 +05:30
Akash K
d5d516ce18 chore: abstract auth from hoppscotch/commons to hoppscotch/web (#2899) 2023-02-01 20:47:22 +05:30
Nivedin
f676f94278 fix: graphql save request emit payload (#2913) 2023-02-01 15:20:13 +05:30
Liyas Thomas
cd6e40f01c chore: uniform ui in rest and graphql collections 2023-01-31 22:39:24 +05:30
Nivedin
59a8a22e8a fix: search on collections > empty state ui (#2912)
fix: collection search filter ui
2023-01-31 22:33:10 +05:30
Nivedin
2910164d5a feat : smart tree component (#2865)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-01-31 17:15:03 +05:30
Liyas Thomas
b95e2b365a fix: broken environment highlight color 2023-01-30 10:01:33 +05:30
Liyas Thomas
73e788b513 chore: fix broken RouterLink component 2023-01-28 09:49:14 +05:30
Petro S
15d135c11b chore: fixed i18n grammatical errors (#2908) 2023-01-28 08:44:31 +05:30
Anwarul Islam
0fcda0be1a refactor: hoppscotch ui (#2887)
* feat: hopp ui initialized

* feat: button components added

* feat: windi css integration

* chore: package removed from hopp ui

* feat: storybook added

* feat: move all smart components hoppscotch-ui

* fix: import issue from components/smart

* fix: env input component import

* feat: add hoppui to windicss config

* fix: remove storybook

* feat: move components from hoppscotch-ui

* feat: storybook added

* feat: storybook progress

* feat: themeing storybook

* feat: add stories

* chore: package updated

* chore: stories added

* feat: stories added

* feat: stories added

* feat: icons resolved

* feat: i18n composable resolved

* feat: histoire added

* chore: resolved prettier issue

* feat: radio story added

* feat: story added for all components

* feat: new components added to stories

* fix: resolved issues

* feat: readme.md added

* feat: context/provider added

* chore: removed app component registry

* chore: remove importing of all components in hopp-ui to allow code splitting

* chore: fix vite config errors

* chore: jsdoc added

* chore: any replaced with smart-item

* chore: i18n added to ui components

* chore: clean up - removed a duplicate button

---------

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-01-28 08:27:00 +05:30
Andrew Bastin
9d7052c626 chore: add CODEOWNERS 2023-01-13 21:53:37 -05:00
Masaki Tagawa
5841d2eb66 chore(i18n): update i18n translations 2023-01-09 20:53:12 +05:30
tzhangm
ee07a90b5e chore: update i18n translations 2023-01-05 13:27:17 +05:30
5idereal
70d2f1e3d9 chore: update i18n translations (#2892) 2023-01-03 12:51:33 +05:30
Liyas Thomas
acafc072db chore: minor ui improvements 2022-12-29 11:10:16 +05:30
Anwarul Islam
51e40581b0 fix: login modal not visible in small screen 2022-12-26 01:40:55 +06:00
Andrew Bastin
1e5dd1cc53 chore: introduce platform object for platform specific code 2022-12-21 19:21:52 -05:00
Liyas Thomas
3d7b057026 chore: updated i18n translation, minor ux improvements 2022-12-17 09:57:57 +05:30
Anwarul Islam
d36ab337d7 feat: ability to delete user account and data (#2863)
* feat: add gql mutation

* feat: added delete account section in profile page

* feat: separate shortcodes section to a component

* feat: delete user modal

* feat: delete user account

* feat: navigate to homepage after delete

* chore: improve ui

* fix: delete user mutation

* chore: minor ui improvements

* chore: correct grammar in certain i18n strings

* feat: delection section separated to component

* feat: separate user delete section into component

* feat: defer fetch my teams

* feat: disable delete account button on loading state

* Update Shortcodes.vue

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2022-12-17 09:31:39 +05:30
Liyas Thomas
012f9b5314 feat: prettify xml request body - fixed #2878 2022-12-15 17:06:18 +05:30
Liyas Thomas
ba6069324f chore: minor ui improvements 2022-12-14 19:29:04 +05:30
Liyas Thomas
0d26d4cdbd ci: updated workflow comments 2022-12-14 19:10:52 +05:30
Liyas Thomas
4b920feffa ci: maximize build space 2022-12-14 16:33:33 +05:30
Andrew Bastin
830373efb3 chore: reintroduce sitemap generation (#2874) 2022-12-10 21:10:45 -05:00
Akash K
c3f18671ec fix: cannot write to body when a request is loaded from history (#2873)
* fix: cannot write body when a request is loaded from history

* fix: import `toRaw()` from vue

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2022-12-09 20:39:36 +05:30
Liyas Thomas
0d33758ba4 ci: introduce staging deployment actions 2022-12-07 12:11:06 +05:30
Akash K
e7e8c397ef fix: circular watcher dependencies on invite.vue causing infinite loop (#2871) 2022-12-06 15:59:38 -05:00
Liyas Thomas
b04b12c7a0 fix: broken links 2022-12-06 12:09:20 +05:30
197 changed files with 10129 additions and 6275 deletions

View File

@@ -1,72 +1,63 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
name: "CodeQL analysis"
on:
push:
branches: [ main ]
branches: [main]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
branches: [main]
schedule:
- cron: '39 7 * * 2'
# ┌───────────── minute (0 - 59)
# │ ┌───────────── hour (0 - 23)
# │ │ ┌───────────── day of the month (1 - 31)
# │ │ │ ┌───────────── month (1 - 12 or JAN-DEC)
# │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT)
# │ │ │ │ │
# │ │ │ │ │
# │ │ │ │ │
# * * * * *
- cron: '30 1 * * 0'
jobs:
analyze:
name: Analyze
# CodeQL runs on ubuntu-latest, windows-latest, and macos-latest
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
# required for all workflows
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
# only required for workflows in private repositories
actions: read
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Checkout
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
# Run extended queries including queries using machine learning
queries: security-extended
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
# Run extended queries including queries using machine learning
queries: security-extended
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below).
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
# ✏️ If the Autobuild fails above, remove it and uncomment the following
# three lines and modify them (or add more) to build your code if your
# project uses a compiled language
#- run: |
# make bootstrap
# make release
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

View File

@@ -1,15 +1,15 @@
name: Deploy to Live Channel
name: Deploy to Firebase (production)
on:
push:
branches:
- main
branches: [main]
jobs:
deploy_live_website:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
- name: Checkout
uses: actions/checkout@v3
- name: Deploy to Firebase (production)

View File

@@ -1,27 +1,33 @@
name: Deploy to Netlify
name: Deploy to Netlify (production)
on:
push:
branches: [main]
jobs:
build:
name: Push build files to Netlify
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
- name: Checkout
uses: actions/checkout@v3
- name: Setup Environment
- name: Setup environment
run: mv .env.example .env
- name: Setup and run pnpm install
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 7
run_install: true
- name: Build Site
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Build site
env:
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: production
@@ -35,7 +41,7 @@ jobs:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_PRODUCTION_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- name: Create Sentry Release
- name: Create Sentry release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -1,19 +1,20 @@
name: Deploy to Staging Netlify
name: Deploy to Netlify (staging)
on:
push:
# TODO: Migrate to staging branch only
branches: [main]
branches: [staging]
pull_request:
branches: [staging]
jobs:
build:
name: Push build files to Netlify
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
- name: Checkout
uses: actions/checkout@v3
- name: Setup and run pnpm install
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
env:
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
@@ -21,7 +22,13 @@ jobs:
version: 7
run_install: true
- name: Build Site
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Build site
env:
VITE_GA_ID: ${{ secrets.STAGING_GA_ID }}
VITE_GTM_ID: ${{ secrets.STAGING_GTM_ID }}
@@ -47,7 +54,7 @@ jobs:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STAGING_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- name: Create Sentry Release
- name: Create Sentry release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

41
.github/workflows/deploy-netlify-ui.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Deploy to Netlify (ui)
on:
push:
branches: [main]
# run this workflow only if an update is made to the ui package
paths:
- "packages/hoppscotch-ui/**"
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup environment
run: mv .env.example .env
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 7
run_install: true
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Build site
run: pnpm run generate-ui
# Deploy the ui site with netlify-cli
- name: Deploy to Netlify (ui)
run: npx netlify-cli deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

View File

@@ -1,60 +0,0 @@
name: Deploy to Preview Netlify
on:
pull_request:
branches:
- main
jobs:
build:
name: Push build files to Netlify
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Setup and run pnpm install
uses: pnpm/action-setup@v2.2.4
env:
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
with:
version: 7
run_install: true
- name: Build Site
env:
VITE_GA_ID: ${{ secrets.STAGING_GA_ID }}
VITE_GTM_ID: ${{ secrets.STAGING_GTM_ID }}
VITE_API_KEY: ${{ secrets.STAGING_FB_API_KEY }}
VITE_AUTH_DOMAIN: ${{ secrets.STAGING_FB_AUTH_DOMAIN }}
VITE_DATABASE_URL: ${{ secrets.STAGING_FB_DATABASE_URL }}
VITE_PROJECT_ID: ${{ secrets.STAGING_FB_PROJECT_ID }}
VITE_STORAGE_BUCKET: ${{ secrets.STAGING_FB_STORAGE_BUCKET }}
VITE_MESSAGING_SENDER_ID: ${{ secrets.STAGING_FB_MESSAGING_SENDER_ID }}
VITE_APP_ID: ${{ secrets.STAGING_FB_APP_ID }}
VITE_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
VITE_BACKEND_GQL_URL: ${{ secrets.STAGING_BACKEND_GQL_URL }}
VITE_BACKEND_WS_URL: ${{ secrets.STAGING_BACKEND_WS_URL }}
VITE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
VITE_SENTRY_RELEASE_TAG: ${{ github.sha }}
VITE_SENTRY_ENVIRONMENT: staging
run: pnpm run generate
# Deploy the preview site with netlify-cli
- name: Deploy to Netlify (preview)
run: npx netlify-cli deploy --dir=packages/hoppscotch-web/dist --alias=preview
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_STAGING_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
- name: Create Sentry Release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
environment: preview
ignore_missing: true
ignore_empty: true
version: ${{ github.sha }}

View File

@@ -7,11 +7,20 @@ on:
types: [published]
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
publish:
name: Publish
runs-on: ubuntu-latest
steps:
- name: Check out the repo
- name: Maximize build space
uses: easimon/maximize-build-space@master
with:
root-reserve-mb: 8192
swap-size-mb: 18432
remove-dotnet: 'true'
remove-android: 'true'
remove-haskell: 'true'
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
@@ -41,6 +50,6 @@ jobs:
with:
context: .
push: true
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -2,12 +2,13 @@ name: Node.js CI
on:
push:
branches: [main]
branches: [main, staging]
pull_request:
branches: [main]
branches: [main, staging]
jobs:
build:
test:
name: Test
runs-on: ubuntu-latest
strategy:
@@ -15,22 +16,22 @@ jobs:
node-version: ["lts/*"]
steps:
- name: Checkout Repository
- name: Checkout
uses: actions/checkout@v3
- name: Setup Environment
- name: Setup environment
run: mv .env.example .env
- name: Setup and run pnpm install
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 7
run_install: true
- name: Use Node.js ${{ matrix.node-version }}
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
node-version: ${{ matrix.node }}
cache: pnpm
- name: Run tests

27
CODEOWNERS Normal file
View File

@@ -0,0 +1,27 @@
# CODEOWNERS is prioritized from bottom to top
# If none of the below matched
* @AndrewBastin @liyasthomas
# Packages
/packages/codemirror-lang-graphql/ @AndrewBastin
/packages/hoppscotch-cli/ @aitchnyu
/packages/hoppscotch-common/ @amk-dev @AndrewBastin
/packages/hoppscotch-data/ @AndrewBastin
/packages/hoppscotch-js-sandbox/ @aitchnyu
/packages/hoppscotch-ui/ @anwarulislam
/packages/hoppscotch-web/ @amk-dev @AndrewBastin
# Sections within Hoppscotch Common
/packages/hoppscotch-common/src/components @anwarulislam
/packages/hoppscotch-common/src/components/collections @nivedin @amk-dev
/packages/hoppscotch-common/src/components/environments @nivedin @amk-dev
/packages/hoppscotch-common/src/composables @amk-dev
/packages/hoppscotch-common/src/modules @AndrewBastin @amk-dev
/packages/hoppscotch-common/src/pages @AndrewBastin @amk-dev
/packages/hoppscotch-common/src/newstore @AndrewBastin @amk-dev
README.md @liyasthomas
# The lockfile has no owner
pnpm-lock.yaml

View File

@@ -36,14 +36,14 @@
<p>
<a href="https://hoppscotch.io/#gh-light-mode-only" target="_blank">
<img
src="./packages/hoppscotch-app/public/images/banner-light.png"
src="./packages/hoppscotch-common/public/images/banner-light.png"
alt="Hoppscotch"
width="100%"
/>
</a>
<a href="https://hoppscotch.io/#gh-dark-mode-only" target="_blank">
<img
src="./packages/hoppscotch-app/public/images/banner-dark.png"
src="./packages/hoppscotch-common/public/images/banner-dark.png"
alt="Hoppscotch"
width="100%"
/>
@@ -317,7 +317,7 @@ docker run --rm --name hoppscotch -p 3000:3000 hoppscotch/hoppscotch:latest
3. Install dependencies by running `pnpm install` within the directory that you cloned (probably `hoppscotch`).
4. Update [`.env.example`](https://github.com/hoppscotch/hoppscotch/blob/main/.env.example) file found in the root of repository with your own keys and rename it to `.env`.
5. Build the release files with `pnpm run generate`.
6. Find the built project in `packages/hoppscotch-app/dist`. Host these files on any [static hosting servers](https://www.pluralsight.com/blog/software-development/where-to-host-your-jamstack-site).
6. Find the built project in `packages/hoppscotch-web/dist`. Host these files on any [static hosting servers](https://www.pluralsight.com/blog/software-development/where-to-host-your-jamstack-site).
## **Contributing**

View File

@@ -10,7 +10,7 @@
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Frame-Options = "SAMEORIGIN"
X-XSS-Protection = "1; mode=block"
[[redirects]]

View File

@@ -15,7 +15,8 @@
"typecheck": "pnpm -r do-typecheck",
"lintfix": "pnpm -r do-lintfix",
"pre-commit": "pnpm -r do-lint && pnpm -r do-typecheck",
"test": "pnpm -r do-test"
"test": "pnpm -r do-test",
"generate-ui": "pnpm -r do-build-ui"
},
"workspaces": [
"./packages/*"

View File

@@ -135,7 +135,7 @@ a {
@apply shadow-none;
@apply fixed;
@apply inline-flex;
@apply -mt-6;
@apply -mt-8;
}
}
@@ -501,6 +501,10 @@ pre.ace_editor {
@apply rounded;
@apply ml-2;
@apply px-1;
@apply min-w-5;
@apply min-h-5;
@apply items-center;
@apply justify-center;
@apply border border-dividerDark;
@apply shadow-sm;
@apply <sm:hidden;
@@ -534,8 +538,16 @@ details[open] summary .indicator {
}
.env-highlight {
* {
@apply text-accentContrast;
@apply text-accentContrast;
&.env-found {
@apply bg-accentDark;
@apply hover:bg-accent;
}
&.env-not-found {
@apply bg-red-500;
@apply hover:bg-red-600;
}
}

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Dit lyk nie asof hierdie blaaier ondersteuning vir bedieners gestuurde geleenthede het nie.",
"check_console_details": "Kyk na die konsole -log vir meer inligting.",
"curl_invalid_format": "cURL is nie behoorlik geformateer nie",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Leë versoeknaam",
"f12_details": "(F12 vir meer inligting)",
"gql_prettify_invalid_query": "Kon nie 'n ongeldige navraag mooi maak nie, los sintaksisfoute op en probeer weer",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Aksent kleur",
"account": "Rekening",
"account_deleted": "Your account has been deleted",
"account_description": "Pas u rekeninginstellings aan.",
"account_email_description": "Jou primêre e -posadres.",
"account_name_description": "Dit is u vertoonnaam.",
@@ -445,6 +449,8 @@
"change_font_size": "Verander lettergrootte",
"choose_language": "Kies taal",
"dark_mode": "Donker",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Eksperimente",
"experiments_notice": "Dit is 'n versameling eksperimente waaraan ons werk, wat nuttig, pret of beide kan wees. Hulle is nie finaal nie en is moontlik nie stabiel nie, dus moenie paniekerig raak as iets te vreemd gebeur nie. Skakel net die ding uit. Grappies eenkant,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "يبدو أن هذا المستعرض لا يدعم أحداث إرسال الخادم.",
"check_console_details": "تحقق من سجل وحدة التحكم للحصول على التفاصيل.",
"curl_invalid_format": "لم يتم تنسيق cURL بشكل صحيح",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "اسم الطلب فارغ",
"f12_details": "(للحصول على تفاصيل F12)",
"gql_prettify_invalid_query": "تعذر تحسين استعلام غير صالح وحل أخطاء بنية الاستعلام وحاول مرة أخرى",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "لون التمييز",
"account": "حساب",
"account_deleted": "Your account has been deleted",
"account_description": "تخصيص إعدادات حسابك.",
"account_email_description": "عنوان بريدك الإلكتروني الأساسي.",
"account_name_description": "هذا هو اسم العرض الخاص بك.",
@@ -445,6 +449,8 @@
"change_font_size": "تغيير حجم الخط",
"choose_language": "اختر اللغة",
"dark_mode": "داكن",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "توسيع التنقل",
"experiments": "التجارب",
"experiments_notice": "هذه مجموعة من التجارب التي نعمل عليها والتي قد تكون مفيدة ، أو ممتعة ، أو كليهما ، أو لا شيء. إنها ليست نهائية وقد لا تكون مستقرة ، لذلك إذا حدث شيء غريب للغاية ، فلا داعي للذعر. فقط قم بإيقاف تشغيل الشيء. النكات جانبا،",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Sembla que aquest navegador no és compatible amb els Esdeveniments Enviats pel Servidor (Server Sent Events).",
"check_console_details": "Consulta el registre de la consola per obtenir més informació.",
"curl_invalid_format": "cURL no està formatat correctament",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Nom de la sol·licitud buida",
"f12_details": "(F12 per obtenir més informació)",
"gql_prettify_invalid_query": "No s'ha pogut definir una consulta no vàlida, resoldre els errors de sintaxi de la consulta i tornar-ho a provar",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Color d'accent",
"account": "Compte",
"account_deleted": "Your account has been deleted",
"account_description": "Personalitzeu la configuració del compte.",
"account_email_description": "La vostra adreça de correu electrònic principal.",
"account_name_description": "Aquest és el vostre nom d'exposició",
@@ -445,6 +449,8 @@
"change_font_size": "Canvia la mida de la lletra",
"choose_language": "Tria l'idioma",
"dark_mode": "Fosc",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Ampliar navegació",
"experiments": "Experiments",
"experiments_notice": "Es tracta d'una col·lecció d'experiments en què estem treballant que poden resultar útils, divertits, o ambdós. No són finals i potser no són estables, de manera que si passa alguna cosa massa estrany, no us espanteu. Només cal que el desactiveu. Bromes a part,",

View File

@@ -1,43 +1,43 @@
{
"action": {
"autoscroll": "Autoscroll",
"autoscroll": "自动滚动",
"cancel": "取消",
"choose_file": "选择文件",
"clear": "清除",
"clear_all": "全部清除",
"close": "Close",
"close": "关闭",
"connect": "连接",
"connecting": "Connecting",
"connecting": "连接中",
"copy": "复制",
"delete": "删除",
"disconnect": "断开连接",
"dismiss": "忽略",
"dont_save": "不保存",
"download_file": "下载文件",
"drag_to_reorder": "Drag to reorder",
"drag_to_reorder": "拖曳以重新排序",
"duplicate": "复制",
"edit": "编辑",
"filter": "Filter",
"filter": "过滤",
"go_back": "返回",
"group_by": "Group by",
"group_by": "分组方式",
"label": "标签",
"learn_more": "了解更多",
"less": "更少",
"more": "更多",
"new": "新增",
"no": "否",
"open_workspace": "Open workspace",
"open_workspace": "打开工作区",
"paste": "粘贴",
"prettify": "美化",
"remove": "移除",
"restore": "恢复",
"save": "保存",
"scroll_to_bottom": "Scroll to bottom",
"scroll_to_top": "Scroll to top",
"scroll_to_bottom": "滚动至底部",
"scroll_to_top": "滚动至顶部",
"search": "搜索",
"send": "发送",
"start": "开始",
"starting": "Starting",
"starting": "正在开始",
"stop": "停止",
"to_close": "以关闭",
"to_navigate": "以定位",
@@ -174,8 +174,8 @@
"profile": "登录以查看你的个人档案",
"protocols": "协议为空",
"schema": "连接至 GraphQL 端点",
"shortcodes": "Shortcodes are empty",
"subscription": "Subscriptions are empty",
"shortcodes": "Shortcodes 为空",
"subscription": "订阅为空",
"team_name": "团队名称为空",
"teams": "团队为空",
"tests": "没有针对该请求的测试"
@@ -188,13 +188,13 @@
"deleted": "环境已删除",
"edit": "编辑环境",
"invalid_name": "请提供有效的环境名称",
"my_environments": "My Environments",
"my_environments": "我的环境",
"nested_overflow": "环境嵌套深度超过限制10层",
"new": "新建环境",
"no_environment": "无环境",
"no_environment_description": "没有选择环境。选择如何处理以下变量。",
"select": "选择环境",
"team_environments": "Team Environments",
"team_environments": "团队环境",
"title": "环境",
"updated": "环境已更新",
"variable_list": "变量列表"
@@ -203,6 +203,9 @@
"browser_support_sse": "该浏览器似乎不支持 SSE。",
"check_console_details": "检查控制台日志以获悉详情",
"curl_invalid_format": "cURL 格式不正确",
"danger_zone": "Danger zone",
"delete_account": "您的帐号目前为这些团队的拥有者:",
"delete_account_description": "您在删除帐号前必须先将您自己从团队中移除、转移拥有权,或是删除团队。",
"empty_req_name": "空请求名称",
"f12_details": "F12 详情)",
"gql_prettify_invalid_query": "无法美化无效的查询,处理查询语法错误并重试",
@@ -215,8 +218,8 @@
"network_error": "好像发生了网络错误,请重试。",
"network_fail": "无法发送请求",
"no_duration": "无持续时间",
"no_results_found": "No matches found",
"page_not_found": "This page could not be found",
"no_results_found": "找不到结果",
"page_not_found": "找不到此頁面",
"script_fail": "无法执行预请求脚本",
"something_went_wrong": "发生了一些错误",
"test_script_fail": "无法执行请求脚本"
@@ -226,12 +229,12 @@
"create_secret_gist": "创建私密 Gist",
"gist_created": "已创建 Gist",
"require_github": "使用 GitHub 登录以创建私密 Gist",
"title": "Export"
"title": "导出"
},
"filter": {
"all": "All",
"none": "None",
"starred": "Starred"
"all": "全部",
"none": "",
"starred": "已加星号"
},
"folder": {
"created": "已创建文件夹",
@@ -247,8 +250,8 @@
"subscriptions": "订阅"
},
"group": {
"time": "Time",
"url": "URL"
"time": "时间",
"url": "网址"
},
"header": {
"install_pwa": "安装应用",
@@ -291,10 +294,10 @@
"from_postman_description": "从 Postman 集合中导入",
"from_url": "从 URL 导入",
"gist_url": "输入 Gist URL",
"import_from_url_invalid_fetch": "Couldn't get data from the url",
"import_from_url_invalid_file_format": "Error while importing collections",
"import_from_url_invalid_type": "Unsupported type. accepted values are 'hoppscotch', 'openapi', 'postman', 'insomnia'",
"import_from_url_success": "Collections Imported",
"import_from_url_invalid_fetch": "无法从网址取得资料",
"import_from_url_invalid_file_format": "导入组合时发生错误",
"import_from_url_invalid_type": "不支持此类型。可接受的值为 'hoppscotch''openapi''postman''insomnia'",
"import_from_url_success": "已导入组合",
"json_description": "从 Hoppscotch 的集合文件导入JSON",
"title": "导入"
},
@@ -313,16 +316,16 @@
"import_export": "导入/导出"
},
"mqtt": {
"already_subscribed": "You are already subscribed to this topic.",
"clean_session": "Clean Session",
"clear_input": "Clear input",
"clear_input_on_send": "Clear input on send",
"client_id": "Client ID",
"color": "Pick a color",
"already_subscribed": "您已经订阅了此主題。",
"clean_session": "清除会话",
"clear_input": "清除输入",
"clear_input_on_send": "发送后清除输入",
"client_id": "客户端 ID",
"color": "选择颜色",
"communication": "通讯",
"connection_config": "Connection Config",
"connection_not_authorized": "This MQTT connection does not use any authentication.",
"invalid_topic": "Please provide a topic for the subscription",
"connection_config": "连接配置",
"connection_not_authorized": "此MQTT连接未使用任何验证。",
"invalid_topic": "请提供该订阅的主题",
"keep_alive": "Keep Alive",
"log": "日志",
"lw_message": "Last-Will Message",
@@ -330,8 +333,8 @@
"lw_retain": "Last-Will Retain",
"lw_topic": "Last-Will Topic",
"message": "消息",
"new": "New Subscription",
"not_connected": "Please start a MQTT connection first.",
"new": "新订阅",
"not_connected": "请先启动MQTT连接。",
"publish": "发布",
"qos": "QoS",
"ssl": "SSL",
@@ -358,7 +361,7 @@
},
"profile": {
"app_settings": "应用设置",
"default_hopp_displayname": "Unnamed User",
"default_hopp_displayname": "未命名使用者",
"editor": "编辑者",
"editor_description": "编辑者可以添加、编辑和删除请求。",
"email_verification_mail": "确认邮件已发送至你的邮箱,请点击链接以验证你的电子邮箱。",
@@ -381,9 +384,9 @@
"choose_language": "选择语言",
"content_type": "内容类型",
"content_type_titles": {
"others": "Others",
"structured": "Structured",
"text": "Text"
"others": "其他",
"structured": "结构",
"text": "文字"
},
"copy_link": "复制链接",
"duration": "持续时间",
@@ -415,11 +418,11 @@
"type": "请求类型",
"url": "URL",
"variables": "变量",
"view_my_links": "View my links"
"view_my_links": "查看我的链接"
},
"response": {
"body": "响应体",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"filter_response_body": "筛选JSON响应本体使用JSONPath语法",
"headers": "响应头",
"html": "HTML",
"image": "图像",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "强调色",
"account": "帐户",
"account_deleted": "已刪除您的账号",
"account_description": "自定义您的帐户设置。",
"account_email_description": "您的主要电子邮箱地址。",
"account_name_description": "这是您的显示名称。",
@@ -445,6 +449,8 @@
"change_font_size": "更改字体大小",
"choose_language": "选择语言",
"dark_mode": "暗色",
"delete_account": "刪除账号",
"delete_account_description": "一旦您删除了您的帐号,您的所有数据将被永久删除。此操作无法复原。",
"expand_navigation": "展开导航栏",
"experiments": "实验功能",
"experiments_notice": "下面是我们正在开发中的一些实验功能,这些功能可能会很有用,可能很有趣,又或者二者都是或都不是。这些功能并非最终版本且可能不稳定,所以如果发生了一些过于奇怪的事情,不要惊慌,关掉它们就好了。玩笑归玩笑,",
@@ -471,8 +477,8 @@
"proxy_use_toggle": "使用代理中间件发送请求",
"read_the": "阅读",
"reset_default": "重置为默认",
"short_codes": "Short codes",
"short_codes_description": "Short codes which were created by you.",
"short_codes": "快捷键",
"short_codes_description": "我们为您打造的快捷键。",
"sidebar_on_left": "侧边栏移至左侧",
"sync": "同步",
"sync_collections": "集合",
@@ -486,16 +492,16 @@
"theme_description": "自定义您的应用程序主题。",
"use_experimental_url_bar": "使用实验性的带有环境高亮的 URL 栏",
"user": "用户",
"verified_email": "Verified email",
"verified_email": "已验证电子邮件地址",
"verify_email": "验证电子邮箱"
},
"shortcodes": {
"actions": "Actions",
"created_on": "Created on",
"deleted": "Shortcode deleted",
"method": "Method",
"not_found": "Shortcode not found",
"short_code": "Short code",
"actions": "操作",
"created_on": "创建于",
"deleted": "已刪除快捷键",
"method": "方法",
"not_found": "找不到快捷键",
"short_code": "快捷键",
"url": "URL"
},
"shortcut": {
@@ -537,9 +543,9 @@
"title": "请求"
},
"response": {
"copy": "Copy response to clipboard",
"download": "Download response as file",
"title": "Response"
"copy": "复制响应至剪贴板",
"download": "下载响应",
"title": "响应"
},
"theme": {
"black": "切换为黑色主题",
@@ -557,7 +563,7 @@
},
"socketio": {
"communication": "通讯",
"connection_not_authorized": "This SocketIO connection does not use any authentication.",
"connection_not_authorized": "SocketIO连接未使用任何验证。",
"event_name": "事件名称",
"events": "事件",
"log": "日志",
@@ -575,9 +581,9 @@
"connected": "已连接",
"connected_to": "已连接到 {name}",
"connecting_to": "正在连接到 {name}……",
"connection_error": "Failed to connect",
"connection_failed": "Connection failed",
"connection_lost": "Connection lost",
"connection_error": "连接错误",
"connection_failed": "连接失败",
"connection_lost": "连接丢失",
"copied_to_clipboard": "已复制到剪贴板",
"deleted": "已删除",
"deprecated": "已弃用",
@@ -592,17 +598,17 @@
"history_deleted": "历史记录已删除",
"linewrap": "换行",
"loading": "正在加载……",
"message_received": "Message: {message} arrived on topic: {topic}",
"mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}",
"message_received": "信息:{message}已到达主题:{topic}",
"mqtt_subscription_failed": "订阅此主题时发生错误:{topic}",
"none": "无",
"nothing_found": "没有找到",
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}",
"published_message": "Published message: {message} to topic: {topic}",
"reconnection_error": "Failed to reconnect",
"subscribed_failed": "Failed to subscribe to topic: {topic}",
"subscribed_success": "Successfully subscribed to topic: {topic}",
"unsubscribed_failed": "Failed to unsubscribe from topic: {topic}",
"unsubscribed_success": "Successfully unsubscribed from topic: {topic}",
"published_error": "将信息:{topic}发布至主题:{message}时发生错误",
"published_message": "已将此信息:{message}发布至主题:{topic}",
"reconnection_error": "重连失败",
"subscribed_failed": "无法订阅此主題:{topic}",
"subscribed_success": "成功订阅此主題:{topic}",
"unsubscribed_failed": "无法取消订阅此主題:{topic}",
"unsubscribed_success": "成功取消订阅此主題:{topic}",
"waiting_send_request": "等待发送请求"
},
"support": {
@@ -687,9 +693,9 @@
"we_sent_invite_link_description": "请所有受邀者检查他们的收件箱,点击链接以加入团队。"
},
"team_environment": {
"deleted": "Environment Deleted",
"duplicate": "Environment Duplicated",
"not_found": "Environment not found."
"deleted": "已刪除环境",
"duplicate": "已复制环境",
"not_found": "找不到环境。"
},
"test": {
"failed": "测试失败",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Zdá se, že tento prohlížeč nemá podporu událostí odeslaných serverem.",
"check_console_details": "Podrobnosti najdete v protokolu konzoly.",
"curl_invalid_format": "cURL nemá správný formát",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Název prázdného požadavku",
"f12_details": "(F12 pro podrobnosti)",
"gql_prettify_invalid_query": "Neplatný dotaz nelze předběžně upravit, vyřešit chyby syntaxe dotazu a zkusit to znovu",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Akcentní barva",
"account": "Účet",
"account_deleted": "Your account has been deleted",
"account_description": "Přizpůsobte si nastavení účtu.",
"account_email_description": "Vaše primární e -mailová adresa.",
"account_name_description": "Toto je vaše zobrazované jméno.",
@@ -445,6 +449,8 @@
"change_font_size": "Změnit velikost písma",
"choose_language": "Vyber jazyk",
"dark_mode": "Temný",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Experimenty",
"experiments_notice": "Toto je sbírka experimentů, na kterých pracujeme a které se mohou ukázat jako užitečné, zábavné, obojí, nebo ani jedno. Nejsou konečné a nemusí být stabilní, takže pokud se stane něco příliš divného, nepanikařte. Prostě vypni tu nebezpečnou věc. Vtipy stranou,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Det ser ikke ud til, at denne browser understøtter Server Sent Events.",
"check_console_details": "Tjek konsollog for at få flere oplysninger.",
"curl_invalid_format": "cURL er ikke formateret korrekt",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Tom anmodningsnavn",
"f12_details": "(F12 for detaljer)",
"gql_prettify_invalid_query": "Kunne ikke prætificere en ugyldig forespørgsel, løse forespørgselssyntaksfejl og prøve igen",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Accent farve",
"account": "Konto",
"account_deleted": "Your account has been deleted",
"account_description": "Tilpas dine kontoindstillinger.",
"account_email_description": "Din primære e -mail -adresse.",
"account_name_description": "Dette er dit visningsnavn.",
@@ -445,6 +449,8 @@
"change_font_size": "Skift skriftstørrelse",
"choose_language": "Vælg sprog",
"dark_mode": "Mørk",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Eksperimenter",
"experiments_notice": "Dette er en samling af eksperimenter, vi arbejder på, der kan vise sig at være nyttige, sjove, begge dele eller ingen af dem. De er ikke endelige og er muligvis ikke stabile, så hvis der sker noget alt for mærkeligt, skal du ikke gå i panik. Bare sluk for dang -tingen. Vittigheder til side,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Dieser Browser scheint keine Unterstützung für die vom Server gesendete Ereignisse zu haben.",
"check_console_details": "Einzelheiten findest Du in der Browser-Konsole.",
"curl_invalid_format": "cURL ist nicht richtig formatiert",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Leerer Anfragename",
"f12_details": "(F12 für Details)",
"gql_prettify_invalid_query": "Eine ungültige Abfrage konnte nicht verschönert werden. Fehler in der Abfragesyntax beheben und erneut versuchen",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Akzentfarbe",
"account": "Konto",
"account_deleted": "Your account has been deleted",
"account_description": "Passe Deine Kontoeinstellungen an.",
"account_email_description": "Deine primäre E-Mail-Adresse.",
"account_name_description": "Dies ist Dein Anzeigename.",
@@ -445,6 +449,8 @@
"change_font_size": "Schriftgröße ändern",
"choose_language": "Sprache wählen",
"dark_mode": "Dunkel",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Menüpunkte vergrößern",
"experiments": "Experimente",
"experiments_notice": "Dies ist eine Sammlung von Experimenten, an denen wir aktuell arbeiten und die sich als nützlich erweisen könnten, Spaß machen, beides oder keines von beiden. Sie sind nicht endgültig und möglicherweise nicht stabil. Wenn also etwas übermäßig Seltsames passiert, gerate nicht in Panik. Schalte das verdammte Ding einfach aus. Scherz beiseite,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Αυτό το πρόγραμμα περιήγησης δεν φαίνεται να υποστηρίζει διακομιστές που έχουν σταλεί συμβάντα.",
"check_console_details": "Ελέγξτε το αρχείο καταγραφής της κονσόλας για λεπτομέρειες.",
"curl_invalid_format": "Το cURL δεν έχει μορφοποιηθεί σωστά",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Όνομα κενού αιτήματος",
"f12_details": "(F12 για λεπτομέρειες)",
"gql_prettify_invalid_query": "Δεν ήταν δυνατή η προεπιλογή ενός μη έγκυρου ερωτήματος, η επίλυση σφαλμάτων σύνταξης ερωτήματος και η δοκιμή ξανά",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Χρώμα προφοράς",
"account": "λογαριασμός",
"account_deleted": "Your account has been deleted",
"account_description": "Προσαρμόστε τις ρυθμίσεις του λογαριασμού σας.",
"account_email_description": "Η κύρια διεύθυνση email σας.",
"account_name_description": "Αυτό είναι το εμφανιζόμενο όνομά σας.",
@@ -445,6 +449,8 @@
"change_font_size": "Αλλαγή μεγέθους γραμματοσειράς",
"choose_language": "Διάλεξε γλώσσα",
"dark_mode": "Σκοτάδι",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Επέκταση navigation",
"experiments": "Πειράματα",
"experiments_notice": "Αυτή είναι μια συλλογή πειραμάτων που δουλεύουμε και που μπορεί να αποδειχθούν χρήσιμα, διασκεδαστικά, και τα δύο ή κανένα από τα δύο. Δεν είναι οριστικά και μπορεί να μην είναι σταθερά, οπότε αν συμβεί κάτι υπερβολικά περίεργο, μην πανικοβληθείτε. Απλώς απενεργοποιήστε το πράγμα. Τα αστεία στην άκρη,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
"check_console_details": "Check console log for details.",
"curl_invalid_format": "cURL is not formatted properly",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Empty Request Name",
"f12_details": "(F12 for details)",
"gql_prettify_invalid_query": "Couldn't prettify an invalid query, solve query syntax errors and try again",
@@ -388,6 +391,7 @@
"copy_link": "Copy link",
"duration": "Duration",
"enter_curl": "Enter cURL command",
"duplicated": "Request duplicated",
"generate_code": "Generate code",
"generated_code": "Generated code",
"header_list": "Header List",
@@ -437,6 +441,7 @@
"settings": {
"accent_color": "Accent color",
"account": "Account",
"account_deleted": "Your account has been deleted",
"account_description": "Customize your account settings.",
"account_email_description": "Your primary email address.",
"account_name_description": "This is your display name.",
@@ -445,6 +450,8 @@
"change_font_size": "Change font size",
"choose_language": "Choose language",
"dark_mode": "Dark",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Experiments",
"experiments_notice": "This is a collection of experiments we're working on that might turn out to be useful, fun, both, or neither. They're not final and may not be stable, so if something overly weird happens, don't panic. Just turn the dang thing off. Jokes aside, ",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Este navegador no parece ser compatible con los eventos enviados por el servidor.",
"check_console_details": "Consulta el registro de la consola para obtener más detalles.",
"curl_invalid_format": "cURL no está formateado correctamente",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Nombre de petición vacío",
"f12_details": "(F12 para más detalles)",
"gql_prettify_invalid_query": "No se puede aplicar embellecedor a una consulta no válida, resuelve los errores de sintaxis de la consulta y vuelve a intentarlo",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Color de acentuación",
"account": "Cuenta",
"account_deleted": "Your account has been deleted",
"account_description": "Personaliza la configuración de tu cuenta.",
"account_email_description": "Tu dirección de correo electrónico principal.",
"account_name_description": "Este es tu nombre para mostrar.",
@@ -445,6 +449,8 @@
"change_font_size": "Cambiar tamaño de fuente",
"choose_language": "Elegir idioma",
"dark_mode": "Oscuro",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expandir la navegación",
"experiments": "Experimentos",
"experiments_notice": "Esta es una colección de experimentos en los que estamos trabajando que podrían resultar útiles, divertidos, ambos o ninguno. No son definitivos y es posible que no sean estables, por lo que si sucede algo demasiado extraño, no se asuste. Solo apaga la maldita cosa. Fuera de bromas,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Tämä selain ei näytä tukevan palvelimen lähettämiä tapahtumia.",
"check_console_details": "Katso lisätietoja konsolilokista.",
"curl_invalid_format": "cURL ei ole alustettu oikein",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Tyhjä pyynnön nimi",
"f12_details": "(F12 lisätietoja)",
"gql_prettify_invalid_query": "Virheellistä kyselyä ei voitu määrittää, ratkaista kyselyn syntaksivirheet ja yrittää uudelleen",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Korostusväri",
"account": "Tili",
"account_deleted": "Your account has been deleted",
"account_description": "Muokkaa tilisi asetuksia.",
"account_email_description": "Ensisijainen sähköpostiosoitteesi.",
"account_name_description": "Tämä on näyttönimesi.",
@@ -445,6 +449,8 @@
"change_font_size": "Vaihda fontin kokoa",
"choose_language": "Valitse kieli",
"dark_mode": "Tumma",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Kokeet",
"experiments_notice": "Tämä on kokoelma kokeiluja, joita parhaillaan käsittelemme ja jotka voivat osoittautua hyödyllisiksi, hauskoiksi, molemmiksi tai kumpikaan. Ne eivät ole lopullisia eivätkä ehkä vakaita, joten jos jotain liian outoa tapahtuu, älä panikoi. Kytke vain paska pois päältä. Vitsit sivuun,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Ce navigateur ne semble pas prendre en charge les événements envoyés par le serveur.",
"check_console_details": "Consultez le journal de la console pour plus de détails.",
"curl_invalid_format": "cURL n'est pas formaté correctement",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Nom de la requête vide",
"f12_details": "(F12 pour les détails)",
"gql_prettify_invalid_query": "Impossible de formater une requête non valide, résolvez les erreurs de syntaxe de la requête et réessayer",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Couleur d'accent",
"account": "Compte",
"account_deleted": "Your account has been deleted",
"account_description": "Personnalisez les paramètres de votre compte.",
"account_email_description": "Votre adresse e-mail principale.",
"account_name_description": "Ceci est votre nom d'affichage.",
@@ -445,6 +449,8 @@
"change_font_size": "Changer la taille de la police",
"choose_language": "Choisissez la langue",
"dark_mode": "Sombre",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Expériences",
"experiments_notice": "Il s'agit d'une collection d'expériences sur lesquelles nous travaillons et qui pourraient s'avérer utiles, amusantes, les deux ou aucune. Ils ne sont pas définitifs et peuvent ne pas être stables, donc si quelque chose de trop étrange se produit, ne paniquez pas. Il suffit d'éteindre le truc. Blague à part,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "נראה שלדפדפן זה אין תמיכה באירועי שרת נשלח.",
"check_console_details": "בדוק את יומן המסוף לפרטים.",
"curl_invalid_format": "cURL אינו בפורמט תקין",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "שם הבקשה ריק",
"f12_details": "(F12 לפרטים)",
"gql_prettify_invalid_query": "לא ניתן היה לייפות שאילתה לא חוקית, לפתור שגיאות תחביר שאילתות ולנסות שוב",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "צבע הדגשה",
"account": "חֶשְׁבּוֹן",
"account_deleted": "Your account has been deleted",
"account_description": "התאם אישית את הגדרות החשבון שלך.",
"account_email_description": "כתובת הדוא\"ל הראשית שלך.",
"account_name_description": "זהו שם התצוגה שלך.",
@@ -445,6 +449,8 @@
"change_font_size": "שנה גודל פונט",
"choose_language": "בחר שפה",
"dark_mode": "אפל",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "ניסויים",
"experiments_notice": "זהו אוסף של ניסויים שעליהם אנו עובדים שעשויים להיות שימושיים, מהנים, שניהם או לא. הם לא סופיים ואולי לא יציבים, אז אם קורה משהו מוזר מדי, אל תיבהל. פשוט כבה את העניין המטומטם. בדיחות בצד,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": " ऐसा लगता है कि इस ब्राउज़र में सर्वर से भेजे गए इवेंट का समर्थन नहीं है।",
"check_console_details": " विवरण के लिए कंसोल लॉग की जाँच करें।",
"curl_invalid_format": " कर्ल ठीक से स्वरूपित नहीं है",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": " खाली अनुरोध का नाम",
"f12_details": " (विवरण के लिए F12)",
"gql_prettify_invalid_query": " अमान्य क्वेरी को सुंदर नहीं बना सका, क्वेरी सिंटैक्स त्रुटियों को हल नहीं कर सका और पुनः प्रयास करें",
@@ -438,6 +441,7 @@
"settings": {
"accent_color": "स्वरोंका रंग",
"account": "खाता",
"account_deleted": "Your account has been deleted",
"account_description": "अपनी खाता सेटिंग कस्टमाइज़ करें।",
"account_email_description": "आपका प्राथमिक ईमेल पता।",
"account_name_description": "यह आपका प्रदर्शन नाम है।",
@@ -446,6 +450,8 @@
"change_font_size": "फॉण्ट आकार बदलें",
"choose_language": "भाषा चुनें",
"dark_mode": "अँधेरा",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "नेविगेशन का विस्तार करें",
"experiments": "प्रयोगों",
"experiments_notice": "यह उन प्रयोगों का एक संग्रह है, जिन पर हम काम कर रहे हैं, जो उपयोगी, मज़ेदार, दोनों या न ही हो सकते हैं। वे अंतिम नहीं हैं और स्थिर नहीं हो सकते हैं, इसलिए यदि कुछ अजीब होता है, तो घबराएं नहीं। बस खतरे को बंद कर दें। एक तरफ मजाक,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Úgy tűnik, hogy ez a böngésző nem támogatja a kiszolgáló által küldött eseményeket.",
"check_console_details": "Nézze meg a konzolnaplót a részletekért.",
"curl_invalid_format": "A cURL nincs megfelelően formázva",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Üres kérésnév",
"f12_details": "(F12 a részletekért)",
"gql_prettify_invalid_query": "Nem sikerült csinosítani egy érvénytelen lekérdezést, oldja meg a lekérdezés szintaktikai hibáit, és próbálja újra",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Kiemelőszín",
"account": "Fiók",
"account_deleted": "Your account has been deleted",
"account_description": "A fiókbeállítások személyre szabása.",
"account_email_description": "Az Ön elsődleges e-mail-címe.",
"account_name_description": "Ez a megjelenített neve.",
@@ -445,6 +449,8 @@
"change_font_size": "Betűméret megváltoztatása",
"choose_language": "Nyelv kiválasztása",
"dark_mode": "Sötét",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Navigáció kinyitása",
"experiments": "Kísérletek",
"experiments_notice": "Ez olyan kísérletek gyűjteménye, amelyeken dolgozunk, és amelyek hasznosak, szórakoztatóak lehetnek, mindkettő, vagy egyik sem. Ezek nem véglegesek és nem stabilak, ezért ha valami túl furcsa dolog történik, ne essen pánikba. Egyszerűen kapcsolja ki a hibás dolgot. Viccet félretéve, ",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Browser ini sepertinya tidak memiliki dukungan Server Sent Events.",
"check_console_details": "Periksa console log untuk detailnya.",
"curl_invalid_format": "cURL tidak diformat dengan benar",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Nama Permintaan Kosong",
"f12_details": "(F12 untuk detailnya)",
"gql_prettify_invalid_query": "Tidak dapat prettify kueri yang tidak valid, menyelesaikan kesalahan sintaksis kueri, dan coba lagi",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Accent color",
"account": "Akun",
"account_deleted": "Your account has been deleted",
"account_description": "Sesuaikan pengaturan akun Anda.",
"account_email_description": "Alamat surel utama Anda.",
"account_name_description": "Ini adalah nama tampilan Anda.",
@@ -445,6 +449,8 @@
"change_font_size": "Ubah ukuran font",
"choose_language": "Pilih bahasa",
"dark_mode": "Gelap",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Perluas navigasi",
"experiments": "Eksperimen",
"experiments_notice": "Ini adalah kumpulan eksperimen yang sedang kami kerjakan yang mungkin berguna, menyenangkan, keduanya, atau tidak keduanya. Mereka tidak final dan mungkin tidak stabil, jadi jika sesuatu yang terlalu aneh terjadi, jangan panik. Matikan saja. Kesampingkan lelucon, ",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Questo browser non sembra supportare gli eventi inviati dal server (Server Sent Events).",
"check_console_details": "Controlla il log della console per i dettagli.",
"curl_invalid_format": "cURL non è formattato correttamente",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Nome richiesta vuoto",
"f12_details": "(F12 per i dettagli)",
"gql_prettify_invalid_query": "Impossibile abbellire una query non valida, risolvere gli errori di sintassi della query e riprovare",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Colore in risalto",
"account": "Account",
"account_deleted": "Your account has been deleted",
"account_description": "Personalizza le impostazioni del tuo account.",
"account_email_description": "Il tuo indirizzo email principale.",
"account_name_description": "Questo è il tuo nome mostrato.",
@@ -445,6 +449,8 @@
"change_font_size": "Cambia la dimensione dei caratteri",
"choose_language": "Scegli la lingua",
"dark_mode": "Scuro",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Espandi navigazione",
"experiments": "Sperimentale",
"experiments_notice": "Questa è una raccolta di esperimenti su cui stiamo lavorando che potrebbero rivelarsi utili, divertenti, entrambi o nessuno dei due. Non sono definitivi e potrebbero non essere stabili, quindi se succede qualcosa di eccessivamente strano, niente panico. Basta solo disabilitare quella dannata cosa. Scherzi a parte, ",

File diff suppressed because it is too large Load Diff

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "이 브라우저는 서버 전송 이벤트를 지원하지 않는 것 같습니다.",
"check_console_details": "자세한 내용은 콘솔 로그를 확인하세요.",
"curl_invalid_format": "cURL 형식이 올바르지 않습니다.",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "빈 요청 이름",
"f12_details": "(자세한 내용은 F12)",
"gql_prettify_invalid_query": "잘못된 쿼리를 구문 강조할 수 없습니다. 쿼리 구문 오류를 해결하고 다시 시도하세요.",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "강조색",
"account": "계정",
"account_deleted": "Your account has been deleted",
"account_description": "계정 설정을 수정합니다.",
"account_email_description": "기본 이메일 주소입니다.",
"account_name_description": "디스플레이 이름입니다.",
@@ -445,6 +449,8 @@
"change_font_size": "글자 크기 변경",
"choose_language": "언어 선택",
"dark_mode": "어두운 테마",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "내비게이션 확장",
"experiments": "실험실",
"experiments_notice": "이것은 유용하거나 재미있거나 둘 다이거나 어느 쪽도 아닌, 아직 실험 중인 기능입니다. 완성되지 않았고 안정적이지 않을 수 있으므로 이상한 일이 발생하더라도 당황하지 마세요. 진지하게 말해서, 끄세요.",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Deze browser lijkt geen ondersteuning te hebben voor door de server verzonden gebeurtenissen.",
"check_console_details": "Controleer het consolelogboek voor details.",
"curl_invalid_format": "cURL is niet correct geformatteerd",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Lege aanvraagnaam",
"f12_details": "(F12 voor details)",
"gql_prettify_invalid_query": "Kon een ongeldige zoekopdracht niet mooier maken, syntaxisfouten in de query oplossen en opnieuw proberen",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Accentkleur",
"account": "Account",
"account_deleted": "Your account has been deleted",
"account_description": "Pas uw accountinstellingen aan.",
"account_email_description": "Uw primaire e-mailadres.",
"account_name_description": "Dit is uw weergavenaam.",
@@ -445,6 +449,8 @@
"change_font_size": "Verander lettergrootte",
"choose_language": "Kies een taal",
"dark_mode": "Donker",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Experimentele functies",
"experiments_notice": "Dit is een verzameling experimenten waaraan we werken en die nuttig, leuk, beide of geen van beide kunnen te zijn. Ze zijn niet definitief en zijn mogelijk niet stabiel, dus als er iets heel raars gebeurt, raak dan niet in paniek. Zet de functie uit, en",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Denne nettleseren ser ikke ut til å ha Server Sent Events -støtte.",
"check_console_details": "Sjekk konsollloggen for detaljer.",
"curl_invalid_format": "cURL er ikke riktig formatert",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Tom forespørselsnavn",
"f12_details": "(F12 for detaljer)",
"gql_prettify_invalid_query": "Kunne ikke forskjønne en ugyldig spørring, løse spørringssyntaksfeil og prøve igjen",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Aksentfarge",
"account": "Konto",
"account_deleted": "Your account has been deleted",
"account_description": "Tilpass kontoinnstillingene dine.",
"account_email_description": "Din primære e-postadresse.",
"account_name_description": "Dette er visningsnavnet ditt.",
@@ -445,6 +449,8 @@
"change_font_size": "Endre skriftstørrelse",
"choose_language": "Velg språk",
"dark_mode": "Mørk",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Eksperimenter",
"experiments_notice": "Dette er en samling eksperimenter vi jobber med som kan vise seg å være nyttige, morsomme, begge deler eller ingen av dem. De er ikke endelige og er kanskje ikke stabile, så hvis det skjer noe altfor rart, ikke få panikk. Bare slå av det. Vitser til side,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Wygląda na to, że ta przeglądarka nie obsługuje zdarzeń wysłanych przez serwer.",
"check_console_details": "Sprawdź dziennik konsoli, aby uzyskać szczegółowe informacje.",
"curl_invalid_format": "cURL nie jest poprawnie sformatowany",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Pusta nazwa żądania",
"f12_details": "(F12 po szczegóły)",
"gql_prettify_invalid_query": "Nie można poprawić czytelności nieprawidłowego zapytania, napraw błędy składni zapytania i spróbuj ponownie",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Kolor akcentu",
"account": "Konto",
"account_deleted": "Your account has been deleted",
"account_description": "Dostosuj ustawienia swojego konta.",
"account_email_description": "Twój podstawowy adres e-mail.",
"account_name_description": "To jest Twoja nazwa wyświetlana.",
@@ -445,6 +449,8 @@
"change_font_size": "Zmień rozmiar czczionki",
"choose_language": "Wybierz język",
"dark_mode": "Ciemny",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Eksperymenty",
"experiments_notice": "To jest zbiór eksperymentów, nad którymi pracujemy, które mogą okazać się przydatne, zabawne, obie te rzeczy albo żadne. Nie są ostateczne i mogą nie być stabilne, więc jeśli wydarzy się coś zbyt dziwnego, nie panikuj. Po prostu wyłącz to cholerstwo. Żarty na bok,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Este navegador não parece ter suporte para eventos enviados pelo servidor.",
"check_console_details": "Verifique o log do console para obter detalhes.",
"curl_invalid_format": "cURL não está formatado corretamente",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Nome de requisição vazio",
"f12_details": "(F12 para detalhes)",
"gql_prettify_invalid_query": "Não foi possível justificar uma requisição inválida, resolva os erros de sintaxe da requisição e tente novamente",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Cor de destaque",
"account": "Conta",
"account_deleted": "Your account has been deleted",
"account_description": "Personalize as configurações da sua conta.",
"account_email_description": "Seu endereço de e-mail principal.",
"account_name_description": "Este é o seu nome de exibição.",
@@ -445,6 +449,8 @@
"change_font_size": "Mudar TAMANHO DA FONTE",
"choose_language": "Escolha o seu idioma",
"dark_mode": "Escuro",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expandir navegação",
"experiments": "Experimentos",
"experiments_notice": "Esta é uma coleção de experimentos em que estamos trabalhando que podem ser úteis, divertidos, ambos ou nenhum dos dois. Eles não são finais e podem não ser estáveis, portanto, se algo muito estranho acontecer, não entre em pânico. Apenas desligue essa maldita coisa. Piadas à parte,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Este navegador não parece ter suporte para eventos enviados pelo servidor.",
"check_console_details": "Verifique o log do console para obter detalhes.",
"curl_invalid_format": "cURL não está formatado corretamente",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Nome do pedido vazio",
"f12_details": "(F12 para detalhes)",
"gql_prettify_invalid_query": "Não foi possível justificar uma consulta inválida, resolva os erros de sintaxe da consulta e tente novamente",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Cor de destaque",
"account": "Conta",
"account_deleted": "Your account has been deleted",
"account_description": "Personalize as configurações da sua conta.",
"account_email_description": "Seu endereço de e-mail principal.",
"account_name_description": "Este é o seu nome de exibição.",
@@ -445,6 +449,8 @@
"change_font_size": "Mudar TAMANHO DA FONTE",
"choose_language": "Escolha o seu idioma",
"dark_mode": "Escuro",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Experimentos",
"experiments_notice": "Esta é uma coleção de experimentos em que estamos trabalhando que podem ser úteis, divertidos, ambos ou nenhum dos dois. Eles não são finais e podem não ser estáveis, portanto, se algo muito estranho acontecer, não entre em pânico. Apenas desligue essa maldita coisa. Piadas à parte,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Acest browser pare să nu aibă suport pentru Server Sent Events.",
"check_console_details": "Verificați jurnalul consolei pentru detalii.",
"curl_invalid_format": "cURL nu este formatat corect",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Nume cerere goală",
"f12_details": "(F12 pentru detalii)",
"gql_prettify_invalid_query": "Nu am putut formata o interogare nevalidă, rezolvați erorile de sintaxă ale interogării și încercați din nou",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Culoare de accent",
"account": "Cont",
"account_deleted": "Your account has been deleted",
"account_description": "Personalizați setările contului.",
"account_email_description": "Adresa dvs. de e-mail principală.",
"account_name_description": "Acesta este numele dvs. afișat.",
@@ -445,6 +449,8 @@
"change_font_size": "Schimbă marimea fontului",
"choose_language": "Alege limba",
"dark_mode": "Întunecat",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expandează navigarea",
"experiments": "Experimente",
"experiments_notice": "Aceasta este o colecție de experimente la care lucrăm, care s-ar putea dovedi a fi utile, distractive, ambele sau nici una. Nu sunt definitive și s-ar putea să nu fie stabile, așa că, dacă se întâmplă ceva prea ciudat, nu vă panicați. Doar oprește-l. Lăsând glumele deoparte,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Похоже, в этом браузере нет поддержки событий, отправленных сервером.",
"check_console_details": "Подробности смотрите в журнале консоли.",
"curl_invalid_format": "cURL неправильно отформатирован",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Пустое имя запроса",
"f12_details": "(F12 для подробностей)",
"gql_prettify_invalid_query": "Не удалось определить недопустимый запрос, устранить синтаксические ошибки запроса и повторить попытку.",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Основной цвет",
"account": "Счет",
"account_deleted": "Your account has been deleted",
"account_description": "Настройте параметры своей учетной записи.",
"account_email_description": "Ваш основной адрес электронной почты.",
"account_name_description": "Это ваше отображаемое имя.",
@@ -445,6 +449,8 @@
"change_font_size": "Изменить размер шрифта",
"choose_language": "Выберите язык",
"dark_mode": "Темный",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Раскрыть панель навигации",
"experiments": "Эксперименты",
"experiments_notice": "Это набор экспериментов, над которыми мы работаем, которые могут оказаться полезными, интересными, и тем, и другим, или ни тем, ни другим. Они не окончательные и могут быть нестабильными, поэтому, если произойдет что-то слишком странное, не паникуйте. Просто выключи эту чертову штуку. Шутки в сторону,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Изгледа да овај прегледач нема подршку за Послане догађаје са сервера.",
"check_console_details": "Детаље потражите у дневнику конзоле.",
"curl_invalid_format": "цУРЛ није правилно форматиран",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Празан назив захтева",
"f12_details": "(Ф12 за детаље)",
"gql_prettify_invalid_query": "Није могуће унапредити неважећи упит, решити грешке у синтакси упита и покушати поново",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Боја акцента",
"account": "Рачун",
"account_deleted": "Your account has been deleted",
"account_description": "Прилагодите поставке налога.",
"account_email_description": "Ваша примарна адреса е -поште.",
"account_name_description": "Ово је ваше име за приказ.",
@@ -445,6 +449,8 @@
"change_font_size": "Промените величину фонта",
"choose_language": "Изабери језик",
"dark_mode": "Дарк",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Експерименти",
"experiments_notice": "Ово је збирка експеримената на којима радимо и који би се могли показати корисним, забавним, обоје или ниједно. Нису коначни и можда нису стабилни, па ако се догоди нешто превише чудно, немојте паничарити. Само искључите опасну ствар. Шалу на страну,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Den här webbläsaren verkar inte ha stöd för Server Sent Events.",
"check_console_details": "Kontrollera konsolloggen för mer information.",
"curl_invalid_format": "cURL är inte korrekt formaterad",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Tom förfrågningsnamn",
"f12_details": "(F12 för detaljer)",
"gql_prettify_invalid_query": "Det gick inte att pryda en ogiltig fråga, lösa frågesyntaxfel och försök igen",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Accentfärg",
"account": "konto",
"account_deleted": "Your account has been deleted",
"account_description": "Anpassa dina kontoinställningar.",
"account_email_description": "Din primära e -postadress.",
"account_name_description": "Detta är ditt visningsnamn.",
@@ -445,6 +449,8 @@
"change_font_size": "Ändra typsnittsstorlek",
"choose_language": "Välj språk",
"dark_mode": "Mörk",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Experiment",
"experiments_notice": "Det här är en samling experiment vi arbetar med som kan visa sig vara användbara, roliga, båda eller ingen av dem. De är inte slutgiltiga och kanske inte är stabila, så om inget alltför konstigt händer, var inte rädd. Stäng bara av det jävla. Skämt åt sidan,",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Bu tarayıcıda SSE desteği yok gibi görünüyor.",
"check_console_details": "Ayrıntılar için konsol günlüğünü kontrol edin.",
"curl_invalid_format": "cURL düzgün biçimlendirilmemiş",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Boş İstek Adı",
"f12_details": "(Ayrıntılar için F12)",
"gql_prettify_invalid_query": "Geçersiz bir sorgu güzelleştirilemedi, sorgu sözdizimi hatalarını çözüp tekrar deneyin",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Vurgu rengi",
"account": "Hesap",
"account_deleted": "Your account has been deleted",
"account_description": "Hesap ayarlarınızı özelleştirin.",
"account_email_description": "Birincil e-posta adresiniz.",
"account_name_description": "Bu sizin görünen adınız.",
@@ -445,6 +449,8 @@
"change_font_size": "Yazı tipi boyutunu değiştir",
"choose_language": "Dil seçiniz",
"dark_mode": "Karanlık",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Gezinmeyi genişlet",
"experiments": "Deneyler",
"experiments_notice": "Bu, üzerinde çalıştığımız, yararlı, eğlenceli, her ikisi de ya da hiçbiri olabilecek bir deneyler koleksiyonudur. Nihai değiller ve istikrarlı olmayabilirler, bu yüzden aşırı garip bir şey olursa panik yapmayın.",

View File

@@ -19,7 +19,7 @@
"edit": "編輯",
"filter": "篩選回應",
"go_back": "返回",
"group_by": "Group by",
"group_by": "分組方式",
"label": "標籤",
"learn_more": "瞭解更多",
"less": "更少",
@@ -175,7 +175,7 @@
"protocols": "協定為空",
"schema": "連線至 GraphQL 端點",
"shortcodes": "Shortcodes 為空",
"subscription": "Subscriptions are empty",
"subscription": "訂閱為空",
"team_name": "團隊名稱為空",
"teams": "團隊為空",
"tests": "沒有針對該請求的測試"
@@ -203,6 +203,9 @@
"browser_support_sse": "此瀏覽器似乎不支援 SSE。",
"check_console_details": "檢查控制台日誌以獲悉詳情",
"curl_invalid_format": "cURL 格式不正確",
"danger_zone": "Danger zone",
"delete_account": "您的帳號目前為這些團隊的擁有者:",
"delete_account_description": "您在刪除帳號前必須先將您自己從團隊中移除、轉移擁有權,或是刪除團隊。",
"empty_req_name": "空請求名稱",
"f12_details": "(按下 F12 以獲悉詳情)",
"gql_prettify_invalid_query": "無法美化無效的查詢,處理查詢語法錯誤並重試",
@@ -229,9 +232,9 @@
"title": "匯出"
},
"filter": {
"all": "All",
"none": "None",
"starred": "Starred"
"all": "全部",
"none": "",
"starred": "已加星號"
},
"folder": {
"created": "已建立資料夾",
@@ -247,8 +250,8 @@
"subscriptions": "訂閱"
},
"group": {
"time": "Time",
"url": "URL"
"time": "時間",
"url": "網址"
},
"header": {
"install_pwa": "安裝應用程式",
@@ -313,16 +316,16 @@
"import_export": "匯入/匯出"
},
"mqtt": {
"already_subscribed": "You are already subscribed to this topic.",
"clean_session": "Clean Session",
"clear_input": "Clear input",
"clear_input_on_send": "Clear input on send",
"client_id": "Client ID",
"color": "Pick a color",
"already_subscribed": "您已經訂閱了此主題。",
"clean_session": "清理工作階段",
"clear_input": "清除輸入",
"clear_input_on_send": "傳送後清除輸入",
"client_id": "客戶端 ID",
"color": "選擇顏色",
"communication": "通訊",
"connection_config": "Connection Config",
"connection_not_authorized": "This MQTT connection does not use any authentication.",
"invalid_topic": "Please provide a topic for the subscription",
"connection_config": "連線設定",
"connection_not_authorized": " MQTT 連線未使用任何驗證。",
"invalid_topic": "請提供該訂閱的主題",
"keep_alive": "Keep Alive",
"log": "日誌",
"lw_message": "Last-Will Message",
@@ -330,8 +333,8 @@
"lw_retain": "Last-Will Retain",
"lw_topic": "Last-Will Topic",
"message": "訊息",
"new": "New Subscription",
"not_connected": "Please start a MQTT connection first.",
"new": "新訂閱",
"not_connected": "請先啟動 MQTT 連線。",
"publish": "發佈",
"qos": "QoS",
"ssl": "SSL",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "強調色",
"account": "帳號",
"account_deleted": "已刪除您的帳號",
"account_description": "自定義您的帳號設定。",
"account_email_description": "您的主要電子郵件地址。",
"account_name_description": "這是您的顯示名稱。",
@@ -445,6 +449,8 @@
"change_font_size": "更改字型大小",
"choose_language": "選擇語言",
"dark_mode": "暗色",
"delete_account": "刪除帳號",
"delete_account_description": "一旦您刪除了您的帳號,您的所有資料將被永久刪除。此操作無法復原。",
"expand_navigation": "展開導航欄",
"experiments": "實驗功能",
"experiments_notice": "下面是我們正在開發中的一些實驗功能,這些功能可能會很有用,可能很有趣,又或者二者都是或都不是。這些功能並非最終版本且可能不穩定,所以如果發生了一些過於奇怪的事情,不要驚慌,關掉它們就好了。玩笑歸玩笑,",

View File

@@ -3,42 +3,42 @@
"autoscroll": "Автопрокручування",
"cancel": "Скасувати",
"choose_file": "Виберіть файл",
"clear": "Ясно",
"clear": "Очистити",
"clear_all": "Очистити все",
"close": "Закрити",
"connect": "Підключіться",
"connecting": "Connecting",
"connect": "Підключитись",
"connecting": "Підключення",
"copy": "Копіювати",
"delete": "Видалити",
"disconnect": "Відключити",
"disconnect": "Відключитись",
"dismiss": "Відхилити",
"dont_save": "Не зберігати",
"download_file": "Завантажити файл",
"drag_to_reorder": "Перетягніть для зміни порядку",
"duplicate": "Дублювати",
"edit": "Редагувати",
"filter": "Фільтр відповіді",
"go_back": "Повертайся",
"group_by": "Group by",
"filter": "Фільтрувати відповіді",
"go_back": "Повернутись",
"group_by": "Групувати за",
"label": "Мітка",
"learn_more": "Вчи більше",
"learn_more": "Дізнатись більше",
"less": "Менше",
"more": "Більше",
"new": "Новий",
"no": "Немає",
"no": "Ні",
"open_workspace": "Відкрити робочу область",
"paste": "Вставити",
"prettify": "Прикрасьте",
"prettify": "Форматувати",
"remove": "Видалити",
"restore": "Відновлювати",
"restore": "Відновити",
"save": "Зберегти",
"scroll_to_bottom": "Прокрутити вниз",
"scroll_to_top": "Прокрутити вгору",
"search": "Пошук",
"send": "Надіслати",
"start": "Почати",
"starting": "Starting",
"stop": "Стій",
"starting": "Розпочинається",
"stop": "Зупити",
"to_close": "щоб закрити",
"to_navigate": "для навігації",
"to_select": "щоб вибрати",
@@ -52,7 +52,7 @@
"star": "Додати зірочку"
},
"app": {
"chat_with_us": "Спілкуйтеся з нами",
"chat_with_us": "Написати нам",
"contact_us": "Зв'яжіться з нами",
"copy": "Копіювати",
"copy_user_id": "Скопіювати токен автентифікації користувача",
@@ -66,10 +66,10 @@
"invite": "Запросити",
"invite_description": "У Hoppscotch ми розробили простий та інтуїтивно зрозумілий інтерфейс для створення та управління вашими API. Hoppscotch - це інструмент, який допомагає вам створювати, тестувати, документувати та ділитися своїми API.",
"invite_your_friends": "Запросіть своїх друзів",
"join_discord_community": "Приєднуйтесь до нашої спільноти Discord",
"join_discord_community": "Приєднуйтесь до нашої спільноти у Discord",
"keyboard_shortcuts": "Гарячі клавіши",
"name": "Гопскотч",
"new_version_found": "Знайдено нову версію. Оновіть, щоб оновити.",
"name": "Hoppscotch",
"new_version_found": "Знайдено нову версію. Перезавантажте сторінку, щоб оновити.",
"options": "Опції",
"proxy_privacy_policy": "Політика конфіденційності проксі",
"reload": "Перезавантажити",
@@ -84,17 +84,17 @@
"type_a_command_search": "Введіть команду або виконайте пошук…",
"we_use_cookies": "Ми використовуємо файли cookie",
"whats_new": "Що нового?",
"wiki": "Вікі"
"wiki": "Wiki"
},
"auth": {
"account_exists": "Обліковий запис існує з різними обліковими даними - увійдіть, щоб зв'язати обидва облікові записи",
"all_sign_in_options": "Усі параметри входу",
"continue_with_email": "Продовжити з електронною поштою",
"continue_with_github": "Продовжити з GitHub",
"continue_with_google": "Продовжуйте працювати з Google",
"continue_with_google": "Продовжити з Google",
"continue_with_microsoft": "Продовжити з Microsoft",
"email": "Електронна пошта",
"logged_out": "Вийшли з системи",
"logged_out": "Ви вийшли з облікового запису",
"login": "Увійти",
"login_success": "Вхід успішно здійснено",
"login_to_hoppscotch": "Увійдіть до Hoppscotch",
@@ -102,7 +102,7 @@
"re_enter_email": "Введіть електронну адресу ще раз",
"send_magic_link": "Надішліть чарівне посилання",
"sync": "Синхронізувати",
"we_sent_magic_link": "Ми надіслали вам чарівне посилання!",
"we_sent_magic_link": "Ми надіслали Вам чарівне посилання!",
"we_sent_magic_link_description": "Перевірте свою поштову скриньку - ми надіслали електронний лист на адресу {email}. Він містить чарівне посилання, яке дозволить вам увійти."
},
"authorization": {
@@ -154,7 +154,7 @@
},
"documentation": {
"generate": "Створення документації",
"generate_message": "Імпортуйте будь-яку колекцію Hoppscotch для створення документації API на ходу."
"generate_message": "Імпортуйте будь-яку колекцію Hoppscotch для автоматичного створення API-документації на ходу."
},
"empty": {
"authorization": "У цьому запиті не використовується авторизація",
@@ -175,15 +175,15 @@
"protocols": "Протоколи порожні",
"schema": "Підключіться до кінцевої точки GraphQL",
"shortcodes": "Короткі коди порожні",
"subscription": "Subscriptions are empty",
"subscription": "Підписки порожні",
"team_name": "Назва команди порожня",
"teams": "Команди порожні",
"tests": "Для цього запиту немає тестів"
},
"environment": {
"add_to_global": "Додати до Глобального",
"add_to_global": "Додати до Глобального середовища",
"added": "Додавання середовища",
"create_new": "Створіть нове середовище",
"create_new": "Створити нове середовище",
"created": "Середовище створено",
"deleted": "Видалення середовища",
"edit": "Редагувати середовище",
@@ -196,13 +196,16 @@
"select": "Виберіть середовище",
"team_environments": "Командні середовища",
"title": "Середовища",
"updated": "Environment updation",
"updated": "Оновлення середовища",
"variable_list": "Список змінних"
},
"error": {
"browser_support_sse": "Схоже, цей браузер не підтримує події, надіслані сервером.",
"check_console_details": "Детальніше перевірте журнал консолі.",
"curl_invalid_format": "cURL неправильно форматовано",
"danger_zone": "Небезпечна зона",
"delete_account": "Ваш обліковий запис на разі володіє цими командами:",
"delete_account_description": "Ви повинні або видалити себе, або передати право власності, або видалити ці команди, перш ніж ви зможете видалити свій обліковий запис.",
"empty_req_name": "Пуста назва запиту",
"f12_details": "(F12 для деталей)",
"gql_prettify_invalid_query": "Не вдалося попередньо визначити недійсний запит, вирішити синтаксичні помилки запиту та повторити спробу",
@@ -223,15 +226,15 @@
},
"export": {
"as_json": "Експортувати як JSON",
"create_secret_gist": "Створіть секретну суть",
"gist_created": "Суть створена",
"require_github": "Увійдіть за допомогою GitHub, щоб створити секретну історію",
"title": "Експорт"
"create_secret_gist": "Створити секретний GitHub Gist",
"gist_created": "Gist створений",
"require_github": "Увійдіть за допомогою GitHub, щоб створити секретний Gist",
"title": "Експортувати"
},
"filter": {
"all": "All",
"none": "None",
"starred": "Starred"
"all": "Всі",
"none": "Жодного",
"starred": "З Зірочкою"
},
"folder": {
"created": "Папка створена",
@@ -247,7 +250,7 @@
"subscriptions": "Підписки"
},
"group": {
"time": "Time",
"time": "Час",
"url": "URL"
},
"header": {
@@ -257,19 +260,19 @@
},
"helpers": {
"authorization": "Заголовок авторизації буде автоматично сформований під час надсилання запиту.",
"generate_documentation_first": "Спочатку сформуйте документацію",
"generate_documentation_first": "Спочатку згенеруйте документацію",
"network_fail": "Не вдається зв'язатися з кінцевою точкою API. Перевірте підключення до мережі та повторіть спробу.",
"offline": "Ви, здається, не в мережі. Дані в цій робочій області можуть бути не актуальними.",
"offline_short": "Ви, здається, не в мережі.",
"post_request_tests": "Тестові сценарії записуються на JavaScript і запускаються після отримання відповіді.",
"pre_request_script": "Сценарії попереднього запиту написані на JavaScript і запускаються перед надсиланням запиту.",
"script_fail": "Схоже, є збій у сценарії попереднього запиту. Перевірте помилку нижче та виправте відповідним чином сценарій.",
"test_script_fail": "Здається виникла помилка з тестовим сценарієм. Будь ласка, виправте помилки і спробуйте знову",
"tests": "Напишіть тестовий сценарій для автоматизації налагодження."
"post_request_tests": "Тестові скрипти записуються на JavaScript і запускаються після отримання відповіді.",
"pre_request_script": "Скрипти написані на JavaScript і запускаються перед надсиланням запиту.",
"script_fail": "Схоже, є збій у скрипті. Перевірте помилку нижче та виправте відповідним чином сценарій.",
"test_script_fail": "Здається виникла помилка з тестовим скриптом. Будь ласка, виправте помилки і спробуйте знову",
"tests": "Напишіть тестовий скрипт для автоматизації налагодження."
},
"hide": {
"collection": "Згорнути панель колекції",
"more": "Приховай більше",
"more": "Приховати більше",
"preview": "Приховати попередній перегляд",
"sidebar": "Приховати бічну панель"
},
@@ -283,20 +286,20 @@
"from_insomnia_description": "Імпорт із колекції Insomnia",
"from_json": "Імпорт з Hoppscotch",
"from_json_description": "Імпорт з файлу колекції Hoppscotch",
"from_my_collections": "Імпортувати з Моїх колекцій",
"from_my_collections": "Імпортувати з моїх колекцій",
"from_my_collections_description": "Імпортувати з мого файлу колекцій",
"from_openapi": "Імпорт з OpenAPI",
"from_openapi_description": "Імпорт з файлу специфікації OpenAPI (YML/JSON)",
"from_postman": "Імпортувати з Postman",
"from_postman_description": "Імпорт із колекції Postman",
"from_url": "Імпорт з URL",
"gist_url": "Введіть URL -адресу Gist",
"gist_url": "Введіть URL-адресу Gist",
"import_from_url_invalid_fetch": "Не вдалося отримати дані з url",
"import_from_url_invalid_file_format": "Помилка при імпорті колекцій",
"import_from_url_invalid_type": "Непідтримуваний тип. Допустимими значеннями є 'hoppscotch', 'openapi', 'postman', 'insomnia'",
"import_from_url_success": "Колекції імпортовано",
"json_description": "Імпортувати колекції з колекцій Hoppscotch JSON файлу",
"title": "Імпорт"
"title": "Імпортувати"
},
"layout": {
"collapse_collection": "Згорнути або розширити колекції",
@@ -313,16 +316,16 @@
"import_export": "Імпорт-експорт"
},
"mqtt": {
"already_subscribed": "You are already subscribed to this topic.",
"clean_session": "Clean Session",
"clear_input": "Clear input",
"clear_input_on_send": "Clear input on send",
"client_id": "Client ID",
"color": "Pick a color",
"communication": "Спілкування",
"connection_config": "Connection Config",
"connection_not_authorized": "This MQTT connection does not use any authentication.",
"invalid_topic": "Please provide a topic for the subscription",
"already_subscribed": "Ви вже підписані на цю тему.",
"clean_session": "Очистити сесію.",
"clear_input": "Очистити вхідні дані",
"clear_input_on_send": "Очистити вхідні дані після надсилання",
"client_id": "ID Клієнта",
"color": "Оберіть колір",
"communication": "Комунікації",
"connection_config": "Налаштування підключення",
"connection_not_authorized": "Це MQTT-з'єднання не використовує автентифікацію.",
"invalid_topic": "Будь ласка, вкажіть тему для підписки",
"keep_alive": "Keep Alive",
"log": "Журнал",
"lw_message": "Last-Will Message",
@@ -330,12 +333,12 @@
"lw_retain": "Last-Will Retain",
"lw_topic": "Last-Will Topic",
"message": "повідомлення",
"new": "New Subscription",
"not_connected": "Please start a MQTT connection first.",
"publish": "Публікуйте",
"new": "Нова Підписка",
"not_connected": "Будь ласка, спочатку створіть з'єднання MQTT.",
"publish": "Опублікувати",
"qos": "QoS",
"ssl": "SSL",
"subscribe": "Підпишіться",
"subscribe": "Підписатись",
"topic": "Тема",
"topic_name": "Назва теми",
"topic_title": "Опублікувати / підписатися на тему",
@@ -352,9 +355,9 @@
},
"preRequest": {
"javascript_code": "Код JavaScript",
"learn": "Прочитайте документацію",
"learn": "Прочитати документацію",
"script": "Сценарій попереднього запиту",
"snippets": "Фрагменти"
"snippets": "Сніпети"
},
"profile": {
"app_settings": "Параметри програми",
@@ -436,15 +439,18 @@
},
"settings": {
"accent_color": "Колір акценту",
"account": "Рахунок",
"account": "Обліковий запис",
"account_deleted": "Ваш обліковий запис успішно видалено.",
"account_description": "Налаштуйте налаштування свого облікового запису.",
"account_email_description": "Ваша основна електронна адреса.",
"account_name_description": "Це ваше відображуване ім'я.",
"background": "Довідка",
"background": "Колір фону",
"black_mode": "Чорний",
"change_font_size": "Змінити розмір шрифту",
"choose_language": "Виберіть мову",
"dark_mode": "Темний",
"delete_account": "Видалити обліковий запис",
"delete_account_description": "Якщо Ви видалите обліковий запис, усі дані будуть втрачені без можливості їх відновлення.",
"expand_navigation": "Розгорнути навігацію",
"experiments": "Експерименти",
"experiments_notice": "Це збірка експериментів, над якими ми працюємо, які можуть виявитися корисними, веселими, обома чи ні. Вони не остаточні і можуть бути нестійкими, тому, якщо трапиться щось надто дивне, не панікуйте. Просто вимкніть небезпеку. Жарти в сторону,",
@@ -630,7 +636,7 @@
"queries": "Запити",
"query": "Запит",
"schema": "Схема",
"socketio": "Сокет.IO",
"socketio": "Socket.IO",
"sse": "SSE",
"tests": "Тести",
"types": "Типи",
@@ -692,14 +698,14 @@
"not_found": "Середовище не знайдено."
},
"test": {
"failed": "помилка тесту",
"failed": "Помилка тесту",
"javascript_code": "Код JavaScript",
"learn": "Прочитайте документацію",
"passed": "тест пройдено",
"passed": "Тест пройдено",
"report": "Протокол випробування",
"results": "Результати тесту",
"script": "Сценарій",
"snippets": "Фрагменти"
"script": "Скрипти",
"snippets": "Сніпети"
},
"websocket": {
"communication": "Спілкування",

View File

@@ -203,6 +203,9 @@
"browser_support_sse": "Trình duyệt này dường như không có hỗ trợ Sự kiện do Máy chủ gửi.",
"check_console_details": "Kiểm tra nhật ký bảng điều khiển để biết chi tiết.",
"curl_invalid_format": "cURL không được định dạng đúng",
"danger_zone": "Danger zone",
"delete_account": "Your account is currently an owner in these teams:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.",
"empty_req_name": "Tên yêu cầu trống",
"f12_details": "(F12 để biết chi tiết)",
"gql_prettify_invalid_query": "Không thể xác minh trước một truy vấn không hợp lệ, hãy giải quyết các lỗi cú pháp truy vấn và thử lại",
@@ -437,6 +440,7 @@
"settings": {
"accent_color": "Màu nhấn",
"account": "Tài khoản",
"account_deleted": "Your account has been deleted",
"account_description": "Tùy chỉnh cài đặt tài khoản của bạn.",
"account_email_description": "Địa chỉ email chính của bạn.",
"account_name_description": "Đây là tên hiển thị của bạn.",
@@ -445,6 +449,8 @@
"change_font_size": "Thay đổi kích thước phông chữ",
"choose_language": "Chọn ngôn ngữ",
"dark_mode": "Tối tăm",
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"expand_navigation": "Expand navigation",
"experiments": "Thí nghiệm",
"experiments_notice": "Đây là một bộ sưu tập các thử nghiệm mà chúng tôi đang thực hiện có thể hữu ích, thú vị, cả hai hoặc không. Chúng không phải là cuối cùng và có thể không ổn định, vì vậy nếu có điều gì đó quá kỳ lạ xảy ra, đừng hoảng sợ. Chỉ cần tắt điều này đi. Chuyện cười sang một bên,",

View File

@@ -31,6 +31,7 @@
"@codemirror/state": "^6.1.0",
"@codemirror/view": "^6.0.2",
"@hoppscotch/codemirror-lang-graphql": "workspace:^0.2.0",
"@hoppscotch/ui": "workspace:^0.0.1",
"@hoppscotch/data": "workspace:^0.4.4",
"@hoppscotch/js-sandbox": "workspace:^2.1.0",
"@hoppscotch/vue-toasted": "^0.1.0",

View File

@@ -7,6 +7,7 @@ export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
AppFooter: typeof import('./components/app/Footer.vue')['default']
@@ -25,13 +26,13 @@ declare module '@vue/runtime-core' {
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
AppSidenav: typeof import('./components/app/Sidenav.vue')['default']
AppSupport: typeof import('./components/app/Support.vue')['default']
ButtonPrimary: typeof import('./components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./components/button/Secondary.vue')['default']
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default']
Collections: typeof import('./components/collections/index.vue')['default']
CollectionsAdd: typeof import('./components/collections/Add.vue')['default']
CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default']
CollectionsAddRequest: typeof import('./components/collections/AddRequest.vue')['default']
CollectionsChooseType: typeof import('./components/collections/ChooseType.vue')['default']
CollectionsCollection: typeof import('./components/collections/Collection.vue')['default']
CollectionsEdit: typeof import('./components/collections/Edit.vue')['default']
CollectionsEditFolder: typeof import('./components/collections/EditFolder.vue')['default']
CollectionsEditRequest: typeof import('./components/collections/EditRequest.vue')['default']
@@ -47,13 +48,11 @@ declare module '@vue/runtime-core' {
CollectionsGraphqlImportExport: typeof import('./components/collections/graphql/ImportExport.vue')['default']
CollectionsGraphqlRequest: typeof import('./components/collections/graphql/Request.vue')['default']
CollectionsImportExport: typeof import('./components/collections/ImportExport.vue')['default']
CollectionsMyCollection: typeof import('./components/collections/my/Collection.vue')['default']
CollectionsMyFolder: typeof import('./components/collections/my/Folder.vue')['default']
CollectionsMyRequest: typeof import('./components/collections/my/Request.vue')['default']
CollectionsMyCollections: typeof import('./components/collections/MyCollections.vue')['default']
CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamsCollection: typeof import('./components/collections/teams/Collection.vue')['default']
CollectionsTeamsFolder: typeof import('./components/collections/teams/Folder.vue')['default']
CollectionsTeamsRequest: typeof import('./components/collections/teams/Request.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
CollectionsTeamSelect: typeof import('./components/collections/TeamSelect.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsChooseType: typeof import('./components/environments/ChooseType.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
@@ -99,7 +98,6 @@ declare module '@vue/runtime-core' {
HttpTests: typeof import('./components/http/Tests.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
@@ -108,11 +106,9 @@ declare module '@vue/runtime-core' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideLoader: typeof import('~icons/lucide/loader')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUser: typeof import('~icons/lucide/user')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
LensesRenderersHTMLLensRenderer: typeof import('./components/lenses/renderers/HTMLLensRenderer.vue')['default']
@@ -124,36 +120,40 @@ declare module '@vue/runtime-core' {
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
ProfilePicture: typeof import('./components/profile/Picture.vue')['default']
ProfileShortcode: typeof import('./components/profile/Shortcode.vue')['default']
ProfileShortcodes: typeof import('./components/profile/Shortcodes.vue')['default']
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
RealtimeLog: typeof import('./components/realtime/Log.vue')['default']
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default']
SmartAnchor: typeof import('./components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./components/smart/AutoComplete.vue')['default']
SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default']
SmartChangeLanguage: typeof import('./components/smart/ChangeLanguage.vue')['default']
SmartCheckbox: typeof import('./components/smart/Checkbox.vue')['default']
SmartCheckbox: typeof import('./../../hoppscotch-ui/src/components/smart/Checkbox.vue')['default']
SmartColorModePicker: typeof import('./components/smart/ColorModePicker.vue')['default']
SmartConfirmModal: typeof import('./components/smart/ConfirmModal.vue')['default']
SmartConfirmModal: typeof import('./../../hoppscotch-ui/src/components/smart/ConfirmModal.vue')['default']
SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
SmartExpand: typeof import('./components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./components/smart/FileChip.vue')['default']
SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
SmartFontSizePicker: typeof import('./components/smart/FontSizePicker.vue')['default']
SmartIntersection: typeof import('./components/smart/Intersection.vue')['default']
SmartItem: typeof import('./components/smart/Item.vue')['default']
SmartLink: typeof import('./components/smart/Link.vue')['default']
SmartModal: typeof import('./components/smart/Modal.vue')['default']
SmartProgressRing: typeof import('./components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./components/smart/RadioGroup.vue')['default']
SmartSlideOver: typeof import('./components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./components/smart/Spinner.vue')['default']
SmartTab: typeof import('./components/smart/Tab.vue')['default']
SmartTabs: typeof import('./components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./components/smart/Toggle.vue')['default']
SmartWindow: typeof import('./components/smart/Window.vue')['default']
SmartWindows: typeof import('./components/smart/Windows.vue')['default']
SmartIntersection: typeof import('./../../hoppscotch-ui/src/components/smart/Intersection.vue')['default']
SmartItem: typeof import('./../../hoppscotch-ui/src/components/smart/Item.vue')['default']
SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default']
SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default']
SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default']
SmartSlideOver: typeof import('./../../hoppscotch-ui/src/components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./../../hoppscotch-ui/src/components/smart/Spinner.vue')['default']
SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default']
SmartTabs: typeof import('./../../hoppscotch-ui/src/components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./../../hoppscotch-ui/src/components/smart/Toggle.vue')['default']
SmartTree: typeof import('./components/smart/Tree.vue')['default']
SmartTreeBranch: typeof import('./components/smart/TreeBranch.vue')['default']
SmartWindow: typeof import('./../../hoppscotch-ui/src/components/smart/Window.vue')['default']
SmartWindows: typeof import('./../../hoppscotch-ui/src/components/smart/Windows.vue')['default']
TabPrimary: typeof import('./components/tab/Primary.vue')['default']
TabSecondary: typeof import('./components/tab/Secondary.vue')['default']
Teams: typeof import('./components/teams/index.vue')['default']

View File

@@ -0,0 +1,26 @@
<template>
<AppShortcuts :show="showShortcuts" @close="showShortcuts = false" />
<AppShare :show="showShare" @hide-modal="showShare = false" />
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
</template>
<script setup lang="ts">
import { ref } from "vue"
import { defineActionHandler } from "~/helpers/actions"
const showShortcuts = ref(false)
const showShare = ref(false)
const showLogin = ref(false)
defineActionHandler("flyouts.keybinds.toggle", () => {
showShortcuts.value = !showShortcuts.value
})
defineActionHandler("modals.share.toggle", () => {
showShare.value = !showShare.value
})
defineActionHandler("modals.login.toggle", () => {
showLogin.value = !showLogin.value
})
</script>

View File

@@ -29,10 +29,7 @@ import IconCheck from "~icons/lucide/check"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useReadonlyStream } from "@composables/stream"
import { authIdToken$ } from "~/helpers/fb/auth"
const userAuthToken = useReadonlyStream(authIdToken$, null)
import { platform } from "~/platform"
const t = useI18n()
@@ -53,8 +50,9 @@ const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
// Copy user auth token to clipboard
const copyUserAuthToken = () => {
if (userAuthToken.value) {
copyToClipboard(userAuthToken.value)
const token = platform.auth.getDevOptsBackendIDToken()
if (token) {
copyToClipboard(token)
copyIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
} else {

View File

@@ -50,9 +50,9 @@
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.d="documentation.$el.click()"
@keyup.s="shortcuts.$el.click()"
@keyup.c="chat.$el.click()"
@keyup.d="documentation!.$el.click()"
@keyup.s="shortcuts!.$el.click()"
@keyup.c="chat!.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
@@ -71,7 +71,7 @@
:shortcut="['S']"
@click="
() => {
showShortcuts = true
invokeAction('flyouts.keybinds.toggle')
hide()
}
"
@@ -122,7 +122,7 @@
:label="`${t('app.invite')}`"
@click="
() => {
showShare = true
invokeAction('modals.share.toggle')
hide()
}
"
@@ -154,7 +154,7 @@
'app.shortcuts'
)} <kbd>${getSpecialKey()}</kbd><kbd>K</kbd>`"
:icon="IconZap"
@click="showShortcuts = true"
@click="invokeAction('flyouts.keybinds.toggle')"
/>
<ButtonSecondary
v-if="navigatorShare"
@@ -188,8 +188,6 @@
</span>
</div>
</div>
<AppShortcuts :show="showShortcuts" @close="showShortcuts = false" />
<AppShare :show="showShare" @hide-modal="showShare = false" />
<AppDeveloperOptions
:show="showDeveloperOptions"
@hide-modal="showDeveloperOptions = false"
@@ -217,29 +215,19 @@ import IconGithub from "~icons/lucide/github"
import IconTwitter from "~icons/lucide/twitter"
import IconUserPlus from "~icons/lucide/user-plus"
import IconLock from "~icons/lucide/lock"
import { defineActionHandler } from "~/helpers/actions"
import { showChat } from "@modules/crisp"
import { useSetting } from "@composables/settings"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { TippyComponent } from "vue-tippy"
import SmartItem from "@components/smart/Item.vue"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { invokeAction } from "@helpers/actions"
import SmartItem from "@hoppscotch/ui/src/components/smart/Item.vue"
const t = useI18n()
const showShortcuts = ref(false)
const showShare = ref(false)
const showDeveloperOptions = ref(false)
defineActionHandler("flyouts.keybinds.toggle", () => {
showShortcuts.value = !showShortcuts.value
})
defineActionHandler("modals.share.toggle", () => {
showShare.value = !showShare.value
})
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
const SIDEBAR = useSetting("SIDEBAR")
const ZEN_MODE = useSetting("ZEN_MODE")
@@ -248,7 +236,10 @@ const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
const navigatorShare = !!navigator.share
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
watch(
() => ZEN_MODE.value,
@@ -283,7 +274,7 @@ const showDeveloperOptionModal = () => {
// Template refs
const tippyActions = ref<TippyComponent | null>(null)
const documentation = ref<typeof SmartItem | null>(null)
const shortcuts = ref<typeof SmartItem | null>(null)
const chat = ref<typeof SmartItem | null>(null)
const documentation = ref<typeof SmartItem>()
const shortcuts = ref<typeof SmartItem>()
const chat = ref<typeof SmartItem>()
</script>

View File

@@ -3,7 +3,13 @@
<header
class="flex items-center justify-between flex-1 flex-shrink-0 px-2 py-2 space-x-2 overflow-x-auto overflow-y-hidden"
>
<div class="inline-flex items-center space-x-2">
<div
class="inline-flex items-center space-x-2"
:style="{
paddingTop: platform.ui?.appHeader?.paddingTop?.value,
paddingLeft: platform.ui?.appHeader?.paddingLeft?.value,
}"
>
<ButtonSecondary
class="tracking-wide !font-bold !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark uppercase"
:label="t('app.name')"
@@ -42,12 +48,12 @@
:label="t('header.save_workspace')"
filled
class="hidden md:flex"
@click="showLogin = true"
@click="invokeAction('modals.login.toggle')"
/>
<ButtonPrimary
v-if="currentUser === null"
:label="t('header.login')"
@click="showLogin = true"
@click="invokeAction('modals.login.toggle')"
/>
<div v-else class="inline-flex items-center space-x-2">
<ButtonPrimary
@@ -150,7 +156,6 @@
</div>
</header>
<AppAnnouncement v-if="!network.isOnline" />
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
</div>
</template>
@@ -166,7 +171,7 @@ import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUserPlus from "~icons/lucide/user-plus"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
import { probableUser$ } from "@helpers/fb/auth"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { invokeAction } from "@helpers/actions"
@@ -181,7 +186,6 @@ const t = useI18n()
const showInstallButton = computed(() => !!pwaDefferedPrompt.value)
const showLogin = ref(false)
const showTeamsModal = ref(false)
const breakpoints = useBreakpoints(breakpointsTailwind)
@@ -189,7 +193,10 @@ const mdAndLarger = breakpoints.greater("md")
const network = reactive(useNetwork())
const currentUser = useReadonlyStream(probableUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
)
// Template refs
const tippyActions = ref<any | null>(null)

View File

@@ -76,10 +76,10 @@ type PaneEvent = {
size: number
}
const PANE_SIDEBAR_SIZE = ref(25)
const PANE_MAIN_SIZE = ref(75)
const PANE_MAIN_TOP_SIZE = ref(45)
const PANE_MAIN_BOTTOM_SIZE = ref(65)
const PANE_MAIN_SIZE = ref(74)
const PANE_SIDEBAR_SIZE = ref(26)
const PANE_MAIN_TOP_SIZE = ref(42)
const PANE_MAIN_BOTTOM_SIZE = ref(58)
if (!COLUMN_LAYOUT.value) {
PANE_MAIN_TOP_SIZE.value = 50

View File

@@ -41,47 +41,52 @@
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { watch, ref } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
export default defineComponent({
props: {
show: Boolean,
loadingState: Boolean,
},
emits: ["submit", "hide-modal"],
setup() {
return {
toast: useToast(),
t: useI18n(),
const toast = useToast()
const t = useI18n()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
}>(),
{
show: false,
loadingState: false,
}
)
const emit = defineEmits<{
(e: "submit", name: string): void
(e: "hide-modal"): void
}>()
const name = ref("")
watch(
() => props.show,
(show) => {
if (!show) {
name.value = ""
}
},
data() {
return {
name: null,
}
},
watch: {
show(isShowing: boolean) {
if (!isShowing) {
this.name = null
}
},
},
methods: {
addNewCollection() {
if (!this.name) {
this.toast.error(this.t("collection.invalid_name"))
return
}
this.$emit("submit", this.name)
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
}
)
const addNewCollection = () => {
if (!name.value) {
toast.error(t("collection.invalid_name"))
return
}
emit("submit", name.value)
}
const hideModal = () => {
name.value = ""
emit("hide-modal")
}
</script>

View File

@@ -3,7 +3,7 @@
v-if="show"
dialog
:title="t('folder.new')"
@close="$emit('hide-modal')"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
@@ -41,52 +41,51 @@
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
export default defineComponent({
props: {
show: Boolean,
folder: { type: Object, default: () => ({}) },
folderPath: { type: String, default: null },
collectionIndex: { type: Number, default: null },
loadingState: Boolean,
},
emits: ["hide-modal", "add-folder"],
setup() {
return {
toast: useToast(),
t: useI18n(),
const toast = useToast()
const t = useI18n()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
}>(),
{
show: false,
loadingState: false,
}
)
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "add-folder", name: string): void
}>()
const name = ref("")
watch(
() => props.show,
(show) => {
if (!show) {
name.value = ""
}
},
data() {
return {
name: null,
}
},
watch: {
show(isShowing: boolean) {
if (!isShowing) this.name = null
},
},
methods: {
addFolder() {
if (!this.name) {
this.toast.error(this.t("folder.invalid_name"))
return
}
this.$emit("add-folder", {
name: this.name,
folder: this.folder,
path: this.folderPath || `${this.collectionIndex}`,
})
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
}
)
const addFolder = () => {
if (name.value.trim() === "") {
toast.error(t("folder.invalid_name"))
return
}
emit("add-folder", name.value)
}
const hideModal = () => {
name.value = ""
emit("hide-modal")
}
</script>

View File

@@ -48,23 +48,20 @@ import { getRESTRequest } from "~/newstore/RESTSession"
const toast = useToast()
const t = useI18n()
const props = defineProps<{
show: boolean
loadingState: boolean
folder?: object
folderPath?: string
}>()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
}>(),
{
show: false,
loadingState: false,
}
)
const emit = defineEmits<{
(e: "hide-modal"): void
(
e: "add-request",
v: {
name: string
folder: object | undefined
path: string | undefined
}
): void
(event: "hide-modal"): void
(event: "add-request", name: string): void
}>()
const name = ref("")
@@ -79,15 +76,11 @@ watch(
)
const addRequest = () => {
if (!name.value) {
if (name.value.trim() === "") {
toast.error(`${t("error.empty_req_name")}`)
return
}
emit("add-request", {
name: name.value,
folder: props.folder,
path: props.folderPath,
})
emit("add-request", name.value)
}
const hideModal = () => {

View File

@@ -1,159 +0,0 @@
<template>
<div>
<SmartTabs
:id="'collections_tab'"
v-model="selectedCollectionTab"
render-inactive-tabs
>
<SmartTab
:id="'my-collections'"
:label="`${t('collection.my_collections')}`"
/>
<SmartTab
:id="'team-collections'"
:label="`${t('collection.team_collections')}`"
:disabled="!currentUser"
>
<SmartIntersection @intersecting="onTeamSelectIntersect">
<tippy
interactive
trigger="click"
theme="popover"
placement="bottom"
:on-shown="() => tippyActions.focus()"
>
<span
v-tippy="{ theme: 'tooltip' }"
:title="`${t('collection.select_team')}`"
class="bg-transparent border-b border-dividerLight select-wrapper"
>
<ButtonSecondary
v-if="collectionsType.selectedTeam"
:icon="IconUsers"
:label="collectionsType.selectedTeam.name"
class="flex-1 !justify-start pr-8 rounded-none"
/>
<ButtonSecondary
v-else
:label="`${t('collection.select_team')}`"
class="flex-1 !justify-start pr-8 rounded-none"
/>
</span>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<SmartItem
v-for="(team, index) in myTeams"
:key="`team-${index}`"
:label="team.name"
:info-icon="
team.id === collectionsType.selectedTeam?.id
? IconDone
: undefined
"
:active-info-icon="
team.id === collectionsType.selectedTeam?.id
"
:icon="IconUsers"
@click="
() => {
updateSelectedTeam(team)
hide()
}
"
/>
</div>
</template>
</tippy>
</SmartIntersection>
</SmartTab>
</SmartTabs>
</div>
</template>
<script setup lang="ts">
import IconUsers from "~icons/lucide/users"
import IconDone from "~icons/lucide/check"
import { ref, watch } from "vue"
import { GetMyTeamsQuery, Team } from "~/helpers/backend/graphql"
import { currentUserInfo$ } from "~/helpers/teams/BackendUserInfo"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useReadonlyStream } from "@composables/stream"
import { onLoggedIn } from "@composables/auth"
import { useI18n } from "@composables/i18n"
import { useLocalState } from "~/newstore/localstate"
type TeamData = GetMyTeamsQuery["myTeams"][number]
type CollectionTabs = "my-collections" | "team-collections"
const t = useI18n()
// Template refs
const tippyActions = ref<any | null>(null)
const selectedCollectionTab = ref<CollectionTabs>("my-collections")
defineProps<{
collectionsType: {
type: "my-collections" | "team-collections"
selectedTeam: Team | undefined
}
}>()
const emit = defineEmits<{
(e: "update-collection-type", tabID: string): void
(e: "update-selected-team", team: TeamData | undefined): void
}>()
const currentUser = useReadonlyStream(currentUserInfo$, null)
const adapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(adapter.teamList$, null)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
let teamListFetched = false
watch(myTeams, (teams) => {
if (teams && !teamListFetched) {
teamListFetched = true
if (REMEMBERED_TEAM_ID.value && currentUser) {
const team = teams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) updateSelectedTeam(team)
}
}
})
onLoggedIn(() => {
adapter.initialize()
})
watch(
() => currentUser.value,
(user) => {
if (!user) {
selectedCollectionTab.value = "my-collections"
}
}
)
const onTeamSelectIntersect = () => {
// Load team data as soon as intersection
adapter.fetchList()
}
const updateCollectionsType = (tabID: string) => {
emit("update-collection-type", tabID)
}
const updateSelectedTeam = (team: TeamData | undefined) => {
REMEMBERED_TEAM_ID.value = team?.id
emit("update-selected-team", team)
}
watch(selectedCollectionTab, (newValue: string) => {
updateCollectionsType(newValue)
})
</script>

View File

@@ -0,0 +1,252 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options?.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="emit('toggle-children')"
>
<component
:is="collectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 transition cursor-pointer group-hover:text-secondaryDark"
@click="emit('toggle-children')"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collectionName }}
</span>
</span>
<div v-if="!hasNoTeamAccess" class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="emit('add-request')"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="emit('add-folder')"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction?.$el.click()"
@keyup.n="folderAction?.$el.click()"
@keyup.e="edit?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.x="exportAction?.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
emit('add-request')
hide()
}
"
/>
<SmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
emit('add-folder')
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-collection')
hide()
}
"
/>
<SmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
:loading="exportLoading"
@click="
() => {
emit('export-data'),
collectionsType === 'my-collections' ? hide() : null
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
emit('remove-collection')
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconFilePlus from "~icons/lucide/file-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconDownload from "~icons/lucide/download"
import IconTrash2 from "~icons/lucide/trash-2"
import IconEdit from "~icons/lucide/edit"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import { PropType, ref, computed, watch } from "vue"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
import { TippyComponent } from "vue-tippy"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
type CollectionType = "my-collections" | "team-collections"
type FolderType = "collection" | "folder"
const t = useI18n()
const props = defineProps({
data: {
type: Object as PropType<HoppCollection<HoppRESTRequest> | TeamCollection>,
default: () => ({}),
required: true,
},
collectionsType: {
type: String as PropType<CollectionType>,
default: "my-collections",
required: true,
},
/**
* Collection component can be used for both collections and folders.
* folderType is used to determine which one it is.
*/
folderType: {
type: String as PropType<FolderType>,
default: "collection",
required: true,
},
isOpen: {
type: Boolean,
default: false,
required: true,
},
isSelected: {
type: Boolean,
default: false,
required: false,
},
exportLoading: {
type: Boolean,
default: false,
required: false,
},
hasNoTeamAccess: {
type: Boolean,
default: false,
required: false,
},
})
const emit = defineEmits<{
(event: "toggle-children"): void
(event: "add-request"): void
(event: "add-folder"): void
(event: "edit-collection"): void
(event: "export-data"): void
(event: "remove-collection"): void
(event: "drop-event", payload: DataTransfer): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const requestAction = ref<HTMLButtonElement | null>(null)
const folderAction = ref<HTMLButtonElement | null>(null)
const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const exportAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const dragging = ref(false)
const collectionIcon = computed(() => {
if (props.isSelected) return IconCheckCircle
else if (!props.isOpen) return IconFolder
else if (props.isOpen) return IconFolderOpen
else return IconFolder
})
const collectionName = computed(() => {
if ((props.data as HoppCollection<HoppRESTRequest>).name)
return (props.data as HoppCollection<HoppRESTRequest>).name
else return (props.data as TeamCollection).title
})
watch(
() => props.exportLoading,
(val) => {
if (!val) {
options.value!.tippy.hide()
}
}
)
const dropEvent = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
dragging.value = !dragging.value
emit("drop-event", dataTransfer)
}
}
</script>

View File

@@ -41,46 +41,52 @@
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { ref, watch } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
export default defineComponent({
props: {
show: Boolean,
editingCollectionName: { type: String, default: null },
loadingState: Boolean,
},
emits: ["submit", "hide-modal"],
setup() {
return {
toast: useToast(),
t: useI18n(),
}
},
data() {
return {
name: null,
}
},
watch: {
editingCollectionName(val) {
this.name = val
},
},
methods: {
saveCollection() {
if (!this.name) {
this.toast.error(this.t("collection.invalid_name"))
return
}
this.$emit("submit", this.name)
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
const t = useI18n()
const toast = useToast()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
editingCollectionName: string
}>(),
{
show: false,
loadingState: false,
editingCollectionName: "",
}
)
const emit = defineEmits<{
(e: "submit", name: string): void
(e: "hide-modal"): void
}>()
const name = ref("")
watch(
() => props.editingCollectionName,
(newName) => {
name.value = newName
}
)
const saveCollection = () => {
if (name.value.trim() === "") {
toast.error(t("collection.invalid_name"))
return
}
emit("submit", name.value)
}
const hideModal = () => {
name.value = ""
emit("hide-modal")
}
</script>

View File

@@ -3,7 +3,7 @@
v-if="show"
dialog
:title="t('folder.edit')"
@close="$emit('hide-modal')"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col">
@@ -41,46 +41,52 @@
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
export default defineComponent({
props: {
show: Boolean,
editingFolderName: { type: String, default: null },
loadingState: Boolean,
},
emits: ["submit", "hide-modal"],
setup() {
return {
t: useI18n(),
toast: useToast(),
}
},
data() {
return {
name: null,
}
},
watch: {
editingFolderName(val) {
this.name = val
},
},
methods: {
editFolder() {
if (!this.name) {
this.toast.error(this.t("folder.invalid_name"))
return
}
this.$emit("submit", this.name)
},
hideModal() {
this.name = null
this.$emit("hide-modal")
},
},
})
const t = useI18n()
const toast = useToast()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
editingFolderName: string
}>(),
{
show: false,
loadingState: false,
editingFolderName: "",
}
)
const emit = defineEmits<{
(e: "submit", name: string): void
(e: "hide-modal"): void
}>()
const name = ref("")
watch(
() => props.editingFolderName,
(newName) => {
name.value = newName
}
)
const editFolder = () => {
if (name.value.trim() === "") {
toast.error(t("folder.invalid_name"))
return
}
emit("submit", name.value)
}
const hideModal = () => {
name.value = ""
emit("hide-modal")
}
</script>

View File

@@ -9,13 +9,13 @@
<div class="flex flex-col">
<input
id="selectLabelEditReq"
v-model="requestUpdateData.name"
v-model="name"
v-focus
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
@keyup.enter="saveRequest"
@keyup.enter="editRequest"
/>
<label for="selectLabelEditReq">
{{ t("action.label") }}
@@ -28,7 +28,7 @@
:label="t('action.save')"
:loading="loadingState"
outline
@click="saveRequest"
@click="editRequest"
/>
<ButtonSecondary
:label="t('action.cancel')"
@@ -41,48 +41,52 @@
</SmartModal>
</template>
<script lang="ts">
import { defineComponent } from "vue"
<script setup lang="ts">
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
export default defineComponent({
props: {
show: Boolean,
editingRequestName: { type: String, default: null },
loadingState: Boolean,
},
emits: ["submit", "hide-modal"],
setup() {
return {
t: useI18n(),
toast: useToast(),
}
},
data() {
return {
requestUpdateData: {
name: null,
},
}
},
watch: {
editingRequestName(val) {
this.requestUpdateData.name = val
},
},
methods: {
saveRequest() {
if (!this.requestUpdateData.name) {
this.toast.error(this.t("request.invalid_name"))
return
}
this.$emit("submit", this.requestUpdateData)
},
hideModal() {
this.requestUpdateData = { name: null }
this.$emit("hide-modal")
},
},
})
const toast = useToast()
const t = useI18n()
const props = withDefaults(
defineProps<{
show: boolean
loadingState: boolean
editingRequestName: string
}>(),
{
show: false,
loadingState: false,
editingRequestName: "",
}
)
const emit = defineEmits<{
(e: "submit", name: string): void
(e: "hide-modal"): void
}>()
const name = ref("")
watch(
() => props.editingRequestName,
(newName) => {
name.value = newName
}
)
const editRequest = () => {
if (name.value.trim() === "") {
toast.error(t("request.invalid_name"))
return
}
emit("submit", name.value)
}
const hideModal = () => {
name.value = ""
emit("hide-modal")
}
</script>

View File

@@ -2,7 +2,7 @@
<SmartModal
v-if="show"
dialog
:title="`${t('modal.collections')}`"
:title="t('modal.collections')"
styles="sm:max-w-md"
@close="hideModal"
>
@@ -81,7 +81,6 @@
<div class="select-wrapper">
<select
v-model="mySelectedCollectionID"
type="text"
autocomplete="off"
class="select"
autofocus
@@ -93,6 +92,7 @@
v-for="(collection, collectionIndex) in myCollections"
:key="`collection-${collectionIndex}`"
:value="collectionIndex"
class="bg-primary"
>
{{ collection.name }}
</option>
@@ -126,8 +126,9 @@
v-tippy="{ theme: 'tooltip' }"
:title="t('action.download_file')"
:icon="IconDownload"
:loading="exportingTeamCollections"
:label="t('export.as_json')"
@click="exportJSON"
@click="emit('export-json-collection')"
/>
<span
v-tippy="{ theme: 'tooltip' }"
@@ -149,12 +150,9 @@
: false
"
:icon="IconGithub"
:loading="creatingGistCollection"
:label="t('export.create_secret_gist')"
@click="
() => {
createCollectionGist()
}
"
@click="emit('create-collection-gist')"
/>
</span>
</div>
@@ -167,217 +165,96 @@
import IconArrowLeft from "~icons/lucide/arrow-left"
import IconDownload from "~icons/lucide/download"
import IconGithub from "~icons/lucide/github"
import { computed, ref, watch } from "vue"
import { computed, PropType, ref, watch } from "vue"
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either"
import { HoppRESTRequest, HoppCollection } from "@hoppscotch/data"
import axios from "axios"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
import { RESTCollectionImporters } from "~/helpers/import-export/import/importers"
import { StepReturnValue } from "~/helpers/import-export/steps"
import { runGQLQuery, runMutation } from "~/helpers/backend/GQLClient"
import {
ExportAsJsonDocument,
ImportFromJsonDocument,
} from "~/helpers/backend/graphql"
const props = defineProps<{
show: boolean
collectionsType:
| {
type: "team-collections"
selectedTeam: {
id: string
}
}
| { type: "my-collections" }
}>()
const toast = useToast()
const t = useI18n()
type CollectionType = "team-collections" | "my-collections"
const props = defineProps({
show: {
type: Boolean,
default: false,
required: true,
},
collectionsType: {
type: String as PropType<CollectionType>,
default: "my-collections",
required: true,
},
exportingTeamCollections: {
type: Boolean,
default: false,
required: false,
},
creatingGistCollection: {
type: Boolean,
default: false,
required: false,
},
importingMyCollections: {
type: Boolean,
default: false,
required: false,
},
})
const emit = defineEmits<{
(e: "hide-modal"): void
(e: "update-team-collections"): void
(e: "export-json-collection"): void
(e: "create-collection-gist"): void
(e: "import-to-teams", payload: HoppCollection<HoppRESTRequest>[]): void
}>()
const toast = useToast()
const t = useI18n()
const myCollections = useReadonlyStream(restCollections$, [])
const currentUser = useReadonlyStream(currentUser$, null)
const hasFile = ref(false)
const hasGist = ref(false)
// Template refs
const mode = ref("import_export")
const mySelectedCollectionID = ref<undefined | number>(undefined)
const collectionJson = ref("")
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
const inputChooseGistToImportFrom = ref<string>("")
const getJSONCollection = async () => {
if (props.collectionsType.type === "my-collections") {
collectionJson.value = JSON.stringify(myCollections.value, null, 2)
} else {
collectionJson.value = pipe(
await runGQLQuery({
query: ExportAsJsonDocument,
variables: {
teamID: props.collectionsType.selectedTeam.id,
},
}),
E.matchW(
// TODO: Handle error case gracefully ?
() => {
throw new Error("Error exporting collection to JSON")
},
(x) => x.exportCollectionsToJSON
)
)
}
return collectionJson.value
}
const createCollectionGist = async () => {
if (!currentUser.value) {
toast.error(t("profile.no_permission").toString())
return
}
await getJSONCollection()
try {
const res = await axios.post(
"https://api.github.com/gists",
{
files: {
"hoppscotch-collections.json": {
content: collectionJson.value,
},
},
},
{
headers: {
Authorization: `token ${currentUser.value.accessToken}`,
Accept: "application/vnd.github.v3+json",
},
}
)
toast.success(t("export.gist_created").toString())
window.open(res.html_url)
} catch (e) {
toast.error(t("error.something_went_wrong").toString())
console.error(e)
}
}
const fileImported = () => {
toast.success(t("state.file_imported").toString())
hideModal()
}
const failedImport = () => {
toast.error(t("import.failed").toString())
}
const hideModal = () => {
mode.value = "import_export"
mySelectedCollectionID.value = undefined
resetImport()
emit("hide-modal")
}
const importerType = ref<number | null>(null)
const stepResults = ref<StepReturnValue[]>([])
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
const mySelectedCollectionID = ref<number | undefined>(undefined)
const inputChooseGistToImportFrom = ref<string>("")
const importerModules = computed(() =>
RESTCollectionImporters.filter(
(i) => i.applicableTo?.includes(props.collectionsType) ?? true
)
)
const importerModule = computed(() => {
if (importerType.value === null) return null
return importerModules.value[importerType.value]
})
const importerSteps = computed(() => importerModule.value?.steps ?? null)
const enableImportButton = computed(
() => !(stepResults.value.length === importerSteps.value?.length)
)
watch(mySelectedCollectionID, (newValue) => {
if (newValue === undefined) return
stepResults.value = []
stepResults.value.push(newValue)
})
const importingMyCollections = ref(false)
const importToTeams = async (content: HoppCollection<HoppRESTRequest>) => {
importingMyCollections.value = true
if (props.collectionsType.type !== "team-collections") return
const result = await runMutation(ImportFromJsonDocument, {
jsonString: JSON.stringify(content),
teamID: props.collectionsType.selectedTeam.id,
})()
if (E.isLeft(result)) {
console.error(result.left)
} else {
emit("update-team-collections")
}
importingMyCollections.value = false
}
const exportJSON = async () => {
await getJSONCollection()
const dataToWrite = collectionJson.value
const file = new Blob([dataToWrite], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
// TODO: get uri from meta
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a)
a.click()
toast.success(t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
}
const importerModules = computed(() =>
RESTCollectionImporters.filter(
(i) => i.applicableTo?.includes(props.collectionsType.type) ?? true
)
)
const importerType = ref<number | null>(null)
const importerModule = computed(() =>
importerType.value !== null ? importerModules.value[importerType.value] : null
)
const importerSteps = computed(() => importerModule.value?.steps ?? null)
const finishImport = async () => {
await importerAction(stepResults.value)
}
const importerAction = async (stepResults: any[]) => {
if (!importerModule.value) return
const result = await importerModule.value?.importer(stepResults as any)()
if (E.isLeft(result)) {
failedImport()
console.error("error", result.left)
} else if (E.isRight(result)) {
if (props.collectionsType.type === "team-collections") {
importToTeams(result.right)
fileImported()
} else {
appendRESTCollections(result.right)
fileImported()
}
}
}
const hasFile = ref(false)
const hasGist = ref(false)
watch(inputChooseGistToImportFrom, (v) => {
watch(inputChooseGistToImportFrom, (url) => {
stepResults.value = []
if (v === "") {
if (url === "") {
hasGist.value = false
} else {
hasGist.value = true
@@ -385,17 +262,49 @@ watch(inputChooseGistToImportFrom, (v) => {
}
})
const myCollections = useReadonlyStream(restCollections$, [])
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const importerAction = async (stepResults: StepReturnValue[]) => {
if (!importerModule.value) return
pipe(
await importerModule.value.importer(stepResults as any)(),
E.match(
(err) => {
failedImport()
console.error("error", err)
},
(result) => {
if (props.collectionsType === "team-collections") {
emit("import-to-teams", result)
} else {
appendRESTCollections(result)
fileImported()
}
}
)
)
}
const finishImport = async () => {
await importerAction(stepResults.value)
}
const onFileChange = () => {
stepResults.value = []
if (!inputChooseFileToImportFrom.value[0]) {
const inputFileToImport = inputChooseFileToImportFrom.value[0]
if (!inputFileToImport) {
hasFile.value = false
return
}
if (
!inputChooseFileToImportFrom.value[0].files ||
inputChooseFileToImportFrom.value[0].files.length === 0
) {
if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
inputChooseFileToImportFrom.value[0].value = ""
hasFile.value = false
toast.show(t("action.choose_file").toString())
@@ -403,6 +312,7 @@ const onFileChange = () => {
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
@@ -414,20 +324,29 @@ const onFileChange = () => {
stepResults.value.push(content)
hasFile.value = !!content?.length
}
reader.readAsText(inputChooseFileToImportFrom.value[0].files[0])
reader.readAsText(inputFileToImport.files[0])
}
const enableImportButton = computed(
() => !(stepResults.value.length === importerSteps.value?.length)
)
const fileImported = () => {
toast.success(t("state.file_imported").toString())
hideModal()
}
const failedImport = () => {
toast.error(t("import.failed").toString())
}
const hideModal = () => {
resetImport()
emit("hide-modal")
}
const resetImport = () => {
importerType.value = null
hasFile.value = false
hasGist.value = false
stepResults.value = []
inputChooseFileToImportFrom.value = ""
hasFile.value = false
inputChooseGistToImportFrom.value = ""
hasGist.value = false
mySelectedCollectionID.value = undefined
}
</script>

View File

@@ -0,0 +1,613 @@
<template>
<div class="flex flex-col flex-1">
<div
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
:style="
saveRequest
? 'top: calc(var(--upper-primary-sticky-fold) - var(--line-height-body))'
: 'top: var(--upper-primary-sticky-fold)'
"
>
<ButtonSecondary
:icon="IconPlus"
:label="t('action.new')"
class="!rounded-none"
@click="emit('display-modal-add')"
/>
<span class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/collections"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<ButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconArchive"
:title="t('modal.import_export')"
@click="emit('display-modal-import-export')"
/>
</span>
</div>
<div class="flex flex-col flex-1">
<SmartTree :adapter="myAdapter">
<template #content="{ node, toggleChildren, isOpen }">
<CollectionsCollection
v-if="node.data.type === 'collections'"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:is-selected="
isSelected({
collectionIndex: parseInt(node.id),
})
"
folder-type="collection"
@add-request="
node.data.type === 'collections' &&
emit('add-request', {
path: node.id,
folder: node.data.data.data,
})
"
@add-folder="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'collections' &&
emit('edit-collection', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'collections' &&
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-collection', node.id)"
@drop-event="dropEvent($event, node.id)"
@toggle-children="
() => {
toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'my-collection',
collectionIndex: parseInt(node.id),
})
}
"
/>
<CollectionsCollection
v-if="node.data.type === 'folders'"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:is-selected="
isSelected({
folderPath: node.id,
})
"
folder-type="folder"
@add-request="
node.data.type === 'folders' &&
emit('add-request', {
path: node.id,
folder: node.data.data.data,
})
"
@add-folder="
node.data.type === 'folders' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'folders' &&
emit('edit-folder', {
folderPath: node.id,
folder: node.data.data.data,
})
"
@export-data="
node.data.type === 'folders' &&
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-folder', node.id)"
@drop-event="dropEvent($event, node.id)"
@toggle-children="
() => {
toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'my-folder',
folderPath: node.id,
})
}
"
/>
<CollectionsRequest
v-if="node.data.type === 'requests'"
:request="node.data.data.data"
:collections-type="collectionsType.type"
:save-request="saveRequest"
:is-active="
isActiveRequest(
node.data.data.parentIndex,
parseInt(pathToIndex(node.id))
)
"
:is-selected="
isSelected({
folderPath: node.data.data.parentIndex,
requestIndex: parseInt(pathToIndex(node.id)),
})
"
@edit-request="
node.data.type === 'requests' &&
emit('edit-request', {
folderPath: node.data.data.parentIndex,
requestIndex: pathToIndex(node.id),
request: node.data.data.data,
})
"
@duplicate-request="
node.data.type === 'requests' &&
emit('duplicate-request', {
folderPath: node.data.data.parentIndex,
request: node.data.data.data,
})
"
@remove-request="
node.data.type === 'requests' &&
emit('remove-request', {
folderPath: node.data.data.parentIndex,
requestIndex: pathToIndex(node.id),
})
"
@select-request="
node.data.type === 'requests' &&
selectRequest({
request: node.data.data.data,
folderPath: node.data.data.parentIndex,
requestIndex: pathToIndex(node.id),
})
"
@drag-request="
dragRequest($event, {
folderPath: node.data.data.parentIndex,
requestIndex: pathToIndex(node.id),
})
"
/>
</template>
<template #emptyNode="{ node }">
<div
v-if="filterText.length !== 0 && filteredCollections.length === 0"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center">
{{ t("state.nothing_found") }} "{{ filterText }}"
</span>
</div>
<div v-else-if="node === null">
<div
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collections')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
<ButtonSecondary
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
/>
</div>
</div>
<div
v-else-if="node.data.type === 'collections'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collection") }}
</span>
<ButtonSecondary
:label="t('add.new')"
filled
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
</div>
<div
v-else-if="node.data.type === 'folders'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</template>
</SmartTree>
</div>
</div>
</template>
<script setup lang="ts">
import IconArchive from "~icons/lucide/archive"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { computed, PropType, Ref, toRef } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { ChildrenResult, SmartTreeAdapter } from "~/helpers/treeAdapter"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { useReadonlyStream } from "~/composables/stream"
import { restSaveContext$ } from "~/newstore/RESTSession"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import { Picked } from "~/helpers/types/HoppPicked.js"
export type Collection = {
type: "collections"
data: {
parentIndex: null
data: HoppCollection<HoppRESTRequest>
}
}
type Folder = {
type: "folders"
data: {
parentIndex: string
data: HoppCollection<HoppRESTRequest>
}
}
type Requests = {
type: "requests"
data: {
parentIndex: string
data: HoppRESTRequest
}
}
const t = useI18n()
const colorMode = useColorMode()
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
| {
type: "team-collections"
selectedTeam: SelectedTeam
}
| { type: "my-collections"; selectedTeam: undefined }
const props = defineProps({
filteredCollections: {
type: Array as PropType<HoppCollection<HoppRESTRequest>[]>,
default: () => [],
required: true,
},
collectionsType: {
type: Object as PropType<CollectionType>,
default: () => ({ type: "my-collections", selectedTeam: undefined }),
required: true,
},
filterText: {
type: String as PropType<string>,
default: "",
required: true,
},
saveRequest: {
type: Boolean,
default: false,
required: false,
},
picked: {
type: Object as PropType<Picked | null>,
default: null,
required: false,
},
})
const emit = defineEmits<{
(event: "display-modal-add"): void
(
event: "add-request",
payload: {
path: string
folder: HoppCollection<HoppRESTRequest>
}
): void
(
event: "add-folder",
payload: {
path: string
folder: HoppCollection<HoppRESTRequest>
}
): void
(
event: "edit-collection",
payload: {
collectionIndex: string
collection: HoppCollection<HoppRESTRequest>
}
): void
(
event: "edit-folder",
payload: {
folderPath: string
folder: HoppCollection<HoppRESTRequest>
}
): void
(
event: "edit-request",
payload: {
folderPath: string
requestIndex: string
request: HoppRESTRequest
}
): void
(
event: "duplicate-request",
payload: {
folderPath: string
request: HoppRESTRequest
}
): void
(event: "export-data", payload: HoppCollection<HoppRESTRequest>): void
(event: "remove-collection", payload: string): void
(event: "remove-folder", payload: string): void
(
event: "remove-request",
payload: {
folderPath: string | null
requestIndex: string
}
): void
(
event: "select-request",
payload: {
request: HoppRESTRequest
folderPath: string
requestIndex: string
isActive: boolean
}
): void
(
event: "drop-request",
payload: {
folderPath: string
requestIndex: string
collectionIndex: string
}
): void
(event: "select", payload: Picked | null): void
(event: "display-modal-import-export"): void
}>()
const refFilterCollection = toRef(props, "filteredCollections")
const pathToIndex = computed(() => {
return (path: string) => {
const pathArr = path.split("/")
return pathArr[pathArr.length - 1]
}
})
const isSelected = computed(() => {
return ({
collectionIndex,
folderPath,
requestIndex,
}: {
collectionIndex?: number | undefined
folderPath?: string | undefined
requestIndex?: number | undefined
}) => {
if (collectionIndex !== undefined) {
return (
props.picked &&
props.picked.pickedType === "my-collection" &&
props.picked.collectionIndex === collectionIndex
)
} else if (requestIndex !== undefined && folderPath !== undefined) {
return (
props.picked &&
props.picked.pickedType === "my-request" &&
props.picked.folderPath === folderPath &&
props.picked.requestIndex === requestIndex
)
} else {
return (
props.picked &&
props.picked.pickedType === "my-folder" &&
props.picked.folderPath === folderPath
)
}
}
})
const active = useReadonlyStream(restSaveContext$, null)
const isActiveRequest = computed(() => {
return (folderPath: string, requestIndex: number) => {
return pipe(
active.value,
O.fromNullable,
O.filter(
(active) =>
active.originLocation === "user-collection" &&
active.folderPath === folderPath &&
active.requestIndex === requestIndex
),
O.isSome
)
}
})
const selectRequest = (data: {
request: HoppRESTRequest
folderPath: string
requestIndex: string
}) => {
const { request, folderPath, requestIndex } = data
if (props.saveRequest) {
emit("select", {
pickedType: "my-request",
folderPath: folderPath,
requestIndex: parseInt(requestIndex),
})
} else {
emit("select-request", {
request,
folderPath,
requestIndex,
isActive: isActiveRequest.value(folderPath, parseInt(requestIndex)),
})
}
}
const dragRequest = (
dataTransfer: DataTransfer,
{
folderPath,
requestIndex,
}: { folderPath: string | null; requestIndex: string }
) => {
if (!folderPath) return
dataTransfer.setData("folderPath", folderPath)
dataTransfer.setData("requestIndex", requestIndex)
}
const dropEvent = (dataTransfer: DataTransfer, collectionIndex: string) => {
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
emit("drop-request", {
folderPath,
requestIndex,
collectionIndex,
})
}
type MyCollectionNode = Collection | Folder | Requests
class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {
constructor(public data: Ref<HoppCollection<HoppRESTRequest>[]>) {}
navigateToFolderWithIndexPath(
collections: HoppCollection<HoppRESTRequest>[],
indexPaths: number[]
) {
if (indexPaths.length === 0) return null
let target = collections[indexPaths.shift() as number]
while (indexPaths.length > 0)
target = target.folders[indexPaths.shift() as number]
return target !== undefined ? target : null
}
getChildren(id: string | null): Ref<ChildrenResult<MyCollectionNode>> {
return computed(() => {
if (id === null) {
const data = this.data.value.map((item, index) => ({
id: index.toString(),
data: {
type: "collections",
data: {
parentIndex: null,
data: item,
},
},
}))
return {
status: "loaded",
data: data,
} as ChildrenResult<Collection>
}
const indexPath = id.split("/").map((x) => parseInt(x))
const item = this.navigateToFolderWithIndexPath(
this.data.value,
indexPath
)
if (item) {
const data = [
...item.folders.map((item, index) => ({
id: `${id}/${index}`,
data: {
type: "folders",
data: {
parentIndex: id,
data: item,
},
},
})),
...item.requests.map((item, index) => ({
id: `${id}/${index}`,
data: {
type: "requests",
data: {
parentIndex: id,
data: item,
},
},
})),
]
return {
status: "loaded",
data: data,
} as ChildrenResult<Folder | Requests>
} else {
return {
status: "loaded",
data: [],
}
}
})
}
}
const myAdapter: SmartTreeAdapter<MyCollectionNode> = new MyCollectionsAdapter(
refFilterCollection
)
</script>

View File

@@ -0,0 +1,235 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
draggable="true"
@dragstart="dragStart"
@dragover.stop
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options?.tippy.show()"
>
<span
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
:class="requestLabelColor"
@click="selectRequest()"
>
<component
:is="IconCheckCircle"
v-if="isSelected"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
<span v-else class="font-semibold truncate text-tiny">
{{ request.method }}
</span>
</span>
<span
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="selectRequest()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ request.name }}
</span>
<span
v-if="isActive"
v-tippy="{ theme: 'tooltip' }"
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
:title="`${t('collection.request_in_use')}`"
>
<span
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
>
</span>
<span
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
></span>
</span>
</span>
<div v-if="!hasNoTeamAccess" class="flex">
<ButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
class="hidden group-hover:inline-flex"
@click="selectRequest()"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.e="edit?.$el.click()"
@keyup.d="duplicate?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-request')
hide()
}
"
/>
<SmartItem
ref="duplicate"
:icon="IconCopy"
:label="t('action.duplicate')"
:loading="duplicateLoading"
:shortcut="['D']"
@click="
() => {
emit('duplicate-request'),
collectionsType === 'my-collections' ? hide() : null
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
emit('remove-request')
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconCheckCircle from "~icons/lucide/check-circle"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import { ref, PropType, watch, computed } from "vue"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useI18n } from "@composables/i18n"
import { TippyComponent } from "vue-tippy"
import { pipe } from "fp-ts/function"
import * as RR from "fp-ts/ReadonlyRecord"
import * as O from "fp-ts/Option"
type CollectionType = "my-collections" | "team-collections"
const t = useI18n()
const props = defineProps({
request: {
type: Object as PropType<HoppRESTRequest>,
default: () => ({}),
required: true,
},
collectionsType: {
type: String as PropType<CollectionType>,
default: "my-collections",
required: true,
},
duplicateLoading: {
type: Boolean,
default: false,
required: false,
},
saveRequest: {
type: Boolean,
default: false,
required: false,
},
isActive: {
type: Boolean,
default: false,
required: false,
},
hasNoTeamAccess: {
type: Boolean,
default: false,
required: false,
},
isSelected: {
type: Boolean,
default: false,
required: false,
},
})
const emit = defineEmits<{
(event: "edit-request"): void
(event: "duplicate-request"): void
(event: "remove-request"): void
(event: "select-request"): void
(event: "drag-request", payload: DataTransfer): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const duplicate = ref<HTMLButtonElement | null>(null)
const dragging = ref(false)
const requestMethodLabels = {
get: "text-green-500",
post: "text-yellow-500",
put: "text-blue-500",
delete: "text-red-500",
default: "text-gray-500",
} as const
const requestLabelColor = computed(() =>
pipe(
requestMethodLabels,
RR.lookup(props.request.method.toLowerCase()),
O.getOrElseW(() => requestMethodLabels.default)
)
)
watch(
() => props.duplicateLoading,
(val) => {
if (!val) {
options.value!.tippy.hide()
}
}
)
const selectRequest = () => {
emit("select-request")
}
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
dragging.value = !dragging.value
emit("drag-request", dataTransfer)
}
}
</script>

View File

@@ -27,9 +27,8 @@
</label>
<CollectionsGraphql
v-if="mode === 'graphql'"
:show-coll-actions="false"
:picked="picked"
:saving-mode="true"
:save-request="true"
@select="onSelect"
/>
<Collections
@@ -37,8 +36,8 @@
:picked="picked"
:save-request="true"
@select="onSelect"
@update-collection="updateColl"
@update-coll-type="onUpdateCollType"
@update-team="updateTeam"
@update-collection-type="updateCollectionType"
/>
</div>
</template>
@@ -46,6 +45,7 @@
<span class="flex space-x-2">
<ButtonPrimary
:label="`${t('action.save')}`"
:loading="modalLoadingState"
outline
@click="saveRequestAs"
/>
@@ -61,99 +61,75 @@
</template>
<script setup lang="ts">
import { reactive, ref, watch } from "vue"
import * as E from "fp-ts/Either"
import { HoppGQLRequest, isHoppRESTRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import {
editGraphqlRequest,
editRESTRequest,
saveGraphqlRequestAs,
saveRESTRequestAs,
} from "~/newstore/collections"
HoppGQLRequest,
HoppRESTRequest,
isHoppRESTRequest,
} from "@hoppscotch/data"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { cloneDeep } from "lodash-es"
import { reactive, ref, watch } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import {
createRequestInCollection,
updateTeamRequest,
} from "~/helpers/backend/mutations/TeamRequest"
import { Picked } from "~/helpers/types/HoppPicked"
import { getGQLSession, useGQLRequestName } from "~/newstore/GQLSession"
import {
getRESTRequest,
setRESTSaveContext,
useRESTRequestName,
} from "~/newstore/RESTSession"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { runMutation } from "~/helpers/backend/GQLClient"
import {
CreateRequestInCollectionDocument,
UpdateRequestDocument,
} from "~/helpers/backend/graphql"
editGraphqlRequest,
editRESTRequest,
saveGraphqlRequestAs,
saveRESTRequestAs,
} from "~/newstore/collections"
import { GQLError } from "~/helpers/backend/GQLClient"
const t = useI18n()
const toast = useToast()
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
| {
type: "my-collections"
}
| {
type: "team-collections"
// TODO: Figure this type out
selectedTeam: {
id: string
}
selectedTeam: SelectedTeam
}
| { type: "my-collections"; selectedTeam: undefined }
type Picked =
| {
pickedType: "my-request"
folderPath: string
requestIndex: number
}
| {
pickedType: "my-folder"
folderPath: string
}
| {
pickedType: "my-collection"
collectionIndex: number
}
| {
pickedType: "teams-request"
requestID: string
}
| {
pickedType: "teams-folder"
folderID: string
}
| {
pickedType: "teams-collection"
collectionID: string
}
| {
pickedType: "gql-my-request"
folderPath: string
requestIndex: number
}
| {
pickedType: "gql-my-folder"
folderPath: string
}
| {
pickedType: "gql-my-collection"
collectionIndex: number
}
const props = defineProps<{
mode: "rest" | "graphql"
show: boolean
}>()
const props = withDefaults(
defineProps<{
show: boolean
mode: "rest" | "graphql"
}>(),
{
show: false,
mode: "rest",
}
)
const emit = defineEmits<{
(
event: "edit-request",
payload: {
folderPath: string
requestIndex: string
request: HoppRESTRequest
}
): void
(e: "hide-modal"): void
}>()
const toast = useToast()
// TODO: Use a better implementation with computed ?
// This implementation can't work across updates to mode prop (which won't happen tho)
const requestName =
const requestName = ref(
props.mode === "rest" ? useRESTRequestName() : useGQLRequestName()
)
const requestData = reactive({
name: requestName,
@@ -164,11 +140,13 @@ const requestData = reactive({
const collectionsType = ref<CollectionType>({
type: "my-collections",
selectedTeam: undefined,
})
// TODO: Figure this type out
const picked = ref<Picked | null>(null)
const modalLoadingState = ref(false)
// Resets
watch(
() => requestData.collectionIndex,
@@ -184,20 +162,18 @@ watch(
}
)
// All the methods
const onUpdateCollType = (newCollType: CollectionType) => {
collectionsType.value = newCollType
const updateTeam = (newTeam: SelectedTeam) => {
collectionsType.value.selectedTeam = newTeam
}
const onSelect = ({ picked: pickedVal }: { picked: Picked | null }) => {
const updateCollectionType = (type: CollectionType["type"]) => {
collectionsType.value.type = type
}
const onSelect = (pickedVal: Picked | null) => {
picked.value = pickedVal
}
const hideModal = () => {
picked.value = null
emit("hide-modal")
}
const saveRequestAs = async () => {
if (!requestName.value) {
toast.error(`${t("error.empty_req_name")}`)
@@ -208,35 +184,25 @@ const saveRequestAs = async () => {
return
}
// Clone Deep because objects are shared by reference so updating
// just one bit will update other referenced shared instances
const requestUpdated =
props.mode === "rest"
? cloneDeep(getRESTRequest())
: cloneDeep(getGQLSession().request)
// // Filter out all REST file inputs
// if (this.mode === "rest" && requestUpdated.bodyParams) {
// requestUpdated.bodyParams = requestUpdated.bodyParams.map((param) =>
// param?.value?.[0] instanceof File ? { ...param, value: "" } : param
// )
// }
if (picked.value.pickedType === "my-request") {
if (picked.value.pickedType === "my-collection") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
editRESTRequest(
picked.value.folderPath,
picked.value.requestIndex,
const insertionIndex = saveRESTRequestAs(
`${picked.value.collectionIndex}`,
requestUpdated
)
setRESTSaveContext({
originLocation: "user-collection",
folderPath: picked.value.folderPath,
requestIndex: picked.value.requestIndex,
req: cloneDeep(requestUpdated),
folderPath: `${picked.value.collectionIndex}`,
requestIndex: insertionIndex,
req: requestUpdated,
})
requestSaved()
@@ -253,114 +219,68 @@ const saveRequestAs = async () => {
originLocation: "user-collection",
folderPath: picked.value.folderPath,
requestIndex: insertionIndex,
req: cloneDeep(requestUpdated),
req: requestUpdated,
})
requestSaved()
} else if (picked.value.pickedType === "my-collection") {
} else if (picked.value.pickedType === "my-request") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
const insertionIndex = saveRESTRequestAs(
`${picked.value.collectionIndex}`,
editRESTRequest(
picked.value.folderPath,
picked.value.requestIndex,
requestUpdated
)
setRESTSaveContext({
originLocation: "user-collection",
folderPath: `${picked.value.collectionIndex}`,
requestIndex: insertionIndex,
req: cloneDeep(requestUpdated),
folderPath: picked.value.folderPath,
requestIndex: picked.value.requestIndex,
req: requestUpdated,
})
requestSaved()
} else if (picked.value.pickedType === "teams-request") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
if (collectionsType.value.type !== "team-collections")
throw new Error("Collections Type mismatch")
runMutation(UpdateRequestDocument, {
requestID: picked.value.requestID,
data: {
request: JSON.stringify(requestUpdated),
title: requestUpdated.name,
},
})().then((result) => {
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
throw new Error(`${result.left}`)
} else {
requestSaved()
}
})
setRESTSaveContext({
originLocation: "team-collection",
requestID: picked.value.requestID,
req: cloneDeep(requestUpdated),
})
} else if (picked.value.pickedType === "teams-folder") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
if (collectionsType.value.type !== "team-collections")
throw new Error("Collections Type mismatch")
const result = await runMutation(CreateRequestInCollectionDocument, {
collectionID: picked.value.folderID,
data: {
request: JSON.stringify(requestUpdated),
teamID: collectionsType.value.selectedTeam.id,
title: requestUpdated.name,
},
})()
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
console.error(result.left)
} else {
setRESTSaveContext({
originLocation: "team-collection",
requestID: result.right.createRequestInCollection.id,
teamID: collectionsType.value.selectedTeam.id,
collectionID: picked.value.folderID,
req: cloneDeep(requestUpdated),
})
requestSaved()
}
} else if (picked.value.pickedType === "teams-collection") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
if (collectionsType.value.type !== "team-collections")
updateTeamCollectionOrFolder(picked.value.collectionID, requestUpdated)
} else if (picked.value.pickedType === "teams-folder") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
updateTeamCollectionOrFolder(picked.value.folderID, requestUpdated)
} else if (picked.value.pickedType === "teams-request") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
if (
collectionsType.value.type !== "team-collections" ||
!collectionsType.value.selectedTeam
)
throw new Error("Collections Type mismatch")
const result = await runMutation(CreateRequestInCollectionDocument, {
collectionID: picked.value.collectionID,
data: {
title: requestUpdated.name,
request: JSON.stringify(requestUpdated),
teamID: collectionsType.value.selectedTeam.id,
},
})()
modalLoadingState.value = true
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
console.error(result.left)
} else {
setRESTSaveContext({
originLocation: "team-collection",
requestID: result.right.createRequestInCollection.id,
teamID: collectionsType.value.selectedTeam.id,
collectionID: picked.value.collectionID,
req: cloneDeep(requestUpdated),
})
requestSaved()
const data = {
request: JSON.stringify(requestUpdated),
title: requestUpdated.name,
}
pipe(
updateTeamRequest(picked.value.requestID, data),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
modalLoadingState.value = false
},
() => {
modalLoadingState.value = false
requestSaved()
}
)
)()
} else if (picked.value.pickedType === "gql-my-request") {
// TODO: Check for GQL request ?
editGraphqlRequest(
@@ -389,12 +309,81 @@ const saveRequestAs = async () => {
}
}
/**
* Updates a team collection or folder and sets the save context to the updated request
* @param collectionID - ID of the collection or folder
* @param requestUpdated - Updated request
*/
const updateTeamCollectionOrFolder = (
collectionID: string,
requestUpdated: HoppRESTRequest
) => {
if (
collectionsType.value.type !== "team-collections" ||
!collectionsType.value.selectedTeam
)
throw new Error("Collections Type mismatch")
modalLoadingState.value = true
const data = {
title: requestUpdated.name,
request: JSON.stringify(requestUpdated),
teamID: collectionsType.value.selectedTeam.id,
}
pipe(
createRequestInCollection(collectionID, data),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
modalLoadingState.value = false
},
(result) => {
const { createRequestInCollection } = result
setRESTSaveContext({
originLocation: "team-collection",
requestID: createRequestInCollection.id,
collectionID: createRequestInCollection.collection.id,
teamID: createRequestInCollection.collection.team.id,
req: requestUpdated,
})
modalLoadingState.value = false
requestSaved()
}
)
)()
}
const requestSaved = () => {
toast.success(`${t("request.added")}`)
hideModal()
}
const updateColl = (ev: CollectionType["type"]) => {
collectionsType.value.type = ev
const hideModal = () => {
picked.value = null
emit("hide-modal")
}
const getErrorMessage = (err: GQLError<string>) => {
console.error(err)
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "team_coll/short_title":
return t("collection.name_length_insufficient")
case "team/invalid_coll_id":
return t("team.invalid_id")
case "team/not_required_role":
return t("profile.no_permission")
case "team_req/not_required_role":
return t("profile.no_permission")
case "Forbidden resource":
return t("profile.no_permission")
default:
return t("error.something_went_wrong")
}
}
}
</script>

View File

@@ -0,0 +1,624 @@
<template>
<div class="flex flex-col flex-1">
<div
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
:style="
saveRequest
? 'top: calc(var(--upper-secondary-sticky-fold) - var(--line-height-body))'
: 'top: var(--upper-secondary-sticky-fold)'
"
>
<ButtonSecondary
v-if="hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
disabled
class="!rounded-none"
:icon="IconPlus"
:title="t('team.no_access')"
:label="t('action.new')"
/>
<ButtonSecondary
v-else
:icon="IconPlus"
:label="t('action.new')"
class="!rounded-none"
@click="emit('display-modal-add')"
/>
<span class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/collections"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<ButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:disabled="
collectionsType.type === 'team-collections' &&
collectionsType.selectedTeam === undefined
"
:icon="IconArchive"
:title="t('modal.import_export')"
@click="emit('display-modal-import-export')"
/>
</span>
</div>
<div class="flex flex-col overflow-hidden">
<SmartTree :adapter="teamAdapter">
<template #content="{ node, toggleChildren, isOpen }">
<CollectionsCollection
v-if="node.data.type === 'collections'"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess"
:is-selected="
isSelected({
collectionID: node.id,
})
"
folder-type="collection"
@add-request="
node.data.type === 'collections' &&
emit('add-request', {
path: node.id,
folder: node.data.data.data,
})
"
@add-folder="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'collections' &&
emit('edit-collection', {
collectionIndex: node.id,
collection: node.data.data.data,
})
"
@export-data="
node.data.type === 'collections' &&
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-collection', node.id)"
@toggle-children="
() => {
toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'teams-collection',
collectionID: node.id,
})
}
"
/>
<CollectionsCollection
v-if="node.data.type === 'folders'"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess"
:is-selected="
isSelected({
folderID: node.data.data.data.id,
})
"
folder-type="folder"
@add-request="
node.data.type === 'folders' &&
emit('add-request', {
path: node.id,
folder: node.data.data.data,
})
"
@add-folder="
node.data.type === 'folders' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
@edit-collection="
node.data.type === 'folders' &&
emit('edit-folder', {
folder: node.data.data.data,
})
"
@export-data="
node.data.type === 'folders' &&
emit('export-data', node.data.data.data)
"
@remove-collection="
node.data.type === 'folders' &&
emit('remove-folder', node.data.data.data.id)
"
@toggle-children="
() => {
toggleChildren(),
saveRequest &&
emit('select', {
pickedType: 'teams-folder',
folderID: node.data.data.data.id,
})
}
"
/>
<CollectionsRequest
v-if="node.data.type === 'requests'"
:request="node.data.data.data.request"
:collections-type="collectionsType.type"
:duplicate-loading="duplicateLoading"
:is-active="isActiveRequest(node.data.data.data.id)"
:has-no-team-access="hasNoTeamAccess"
:is-selected="
isSelected({
requestID: node.data.data.data.id,
})
"
@edit-request="
node.data.type === 'requests' &&
emit('edit-request', {
requestIndex: node.data.data.data.id,
request: node.data.data.data.request,
})
"
@duplicate-request="
node.data.type === 'requests' &&
emit('duplicate-request', {
folderPath: node.data.data.parentIndex,
request: node.data.data.data.request,
})
"
@remove-request="
node.data.type === 'requests' &&
emit('remove-request', {
folderPath: null,
requestIndex: node.data.data.data.id,
})
"
@select-request="
node.data.type === 'requests' &&
selectRequest({
request: node.data.data.data.request,
requestIndex: node.data.data.data.id,
})
"
/>
</template>
<template #emptyNode="{ node }">
<div v-if="node === null">
<div
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collections')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
</span>
<ButtonSecondary
v-if="hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
disabled
filled
outline
:title="t('team.no_access')"
:label="t('add.new')"
/>
<ButtonSecondary
v-else
:label="t('add.new')"
filled
outline
@click="emit('display-modal-add')"
/>
</div>
</div>
<div
v-else-if="node.data.type === 'collections'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collection") }}
</span>
<ButtonSecondary
v-if="hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
disabled
filled
outline
:title="t('team.no_access')"
:label="t('add.new')"
/>
<ButtonSecondary
v-else
:label="t('add.new')"
filled
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
</div>
<div
v-else-if="node.data.type === 'folders'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</template>
</SmartTree>
</div>
</div>
</template>
<script setup lang="ts">
import IconArchive from "~icons/lucide/archive"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import { computed, PropType, Ref, toRef } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
import { TeamRequest } from "~/helpers/teams/TeamRequest"
import { ChildrenResult, SmartTreeAdapter } from "~/helpers/treeAdapter"
import { cloneDeep } from "lodash-es"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useReadonlyStream } from "~/composables/stream"
import { restSaveContext$ } from "~/newstore/RESTSession"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import { Picked } from "~/helpers/types/HoppPicked.js"
const t = useI18n()
const colorMode = useColorMode()
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
| {
type: "team-collections"
selectedTeam: SelectedTeam
}
| { type: "my-collections"; selectedTeam: undefined }
const props = defineProps({
collectionsType: {
type: Object as PropType<CollectionType>,
default: () => ({ type: "my-collections", selectedTeam: undefined }),
required: true,
},
teamCollectionList: {
type: Array as PropType<TeamCollection[]>,
default: () => [],
required: true,
},
teamLoadingCollections: {
type: Array as PropType<string[]>,
default: () => [],
required: true,
},
saveRequest: {
type: Boolean,
default: false,
required: false,
},
exportLoading: {
type: Boolean,
default: false,
required: false,
},
duplicateLoading: {
type: Boolean,
default: false,
required: false,
},
picked: {
type: Object as PropType<Picked | null>,
default: null,
required: false,
},
})
const emit = defineEmits<{
(
event: "add-request",
payload: {
path: string
folder: TeamCollection
}
): void
(
event: "add-folder",
payload: {
path: string
folder: TeamCollection
}
): void
(
event: "edit-collection",
payload: {
collectionIndex: string
collection: TeamCollection
}
): void
(
event: "edit-folder",
payload: {
folder: TeamCollection
}
): void
(
event: "edit-request",
payload: {
requestIndex: string
request: HoppRESTRequest
}
): void
(
event: "duplicate-request",
payload: {
folderPath: string
request: HoppRESTRequest
}
): void
(event: "export-data", payload: TeamCollection): void
(event: "remove-collection", payload: string): void
(event: "remove-folder", payload: string): void
(
event: "remove-request",
payload: {
folderPath: string | null
requestIndex: string
}
): void
(
event: "select-request",
payload: {
request: HoppRESTRequest
requestIndex: string
isActive: boolean
folderPath?: string | undefined
}
): void
(event: "select", payload: Picked | null): void
(event: "expand-team-collection", payload: string): void
(event: "display-modal-add"): void
(event: "display-modal-import-export"): void
}>()
const teamCollectionsList = toRef(props, "teamCollectionList")
const hasNoTeamAccess = computed(
() =>
props.collectionsType.type === "team-collections" &&
(props.collectionsType.selectedTeam === undefined ||
props.collectionsType.selectedTeam.myRole === "VIEWER")
)
const isSelected = computed(() => {
return ({
collectionID,
folderID,
requestID,
}: {
collectionID?: string | undefined
folderID?: string | undefined
requestID?: string | undefined
}) => {
if (collectionID !== undefined) {
return (
props.picked &&
props.picked.pickedType === "teams-collection" &&
props.picked.collectionID === collectionID
)
} else if (requestID !== undefined) {
return (
props.picked &&
props.picked.pickedType === "teams-request" &&
props.picked.requestID === requestID
)
} else {
return (
props.picked &&
props.picked.pickedType === "teams-folder" &&
props.picked.folderID === folderID
)
}
}
})
const active = useReadonlyStream(restSaveContext$, null)
const isActiveRequest = computed(() => {
return (requestID: string) => {
return pipe(
active.value,
O.fromNullable,
O.filter(
(active) =>
active.originLocation === "team-collection" &&
active.requestID === requestID
),
O.isSome
)
}
})
const selectRequest = (data: {
request: HoppRESTRequest
requestIndex: string
}) => {
const { request, requestIndex } = data
if (props.saveRequest) {
emit("select", {
pickedType: "teams-request",
requestID: requestIndex,
})
} else {
emit("select-request", {
request: request,
requestIndex: requestIndex,
isActive: isActiveRequest.value(requestIndex),
})
}
}
type TeamCollections = {
type: "collections"
data: {
parentIndex: null
data: TeamCollection
}
}
type TeamFolder = {
type: "folders"
data: {
parentIndex: string
data: TeamCollection
}
}
type TeamRequests = {
type: "requests"
data: {
parentIndex: string
data: TeamRequest
}
}
type TeamCollectionNode = TeamCollections | TeamFolder | TeamRequests
class TeamCollectionsAdapter implements SmartTreeAdapter<TeamCollectionNode> {
constructor(public data: Ref<TeamCollection[]>) {}
findCollInTree(
tree: TeamCollection[],
targetID: string
): TeamCollection | null {
for (const coll of tree) {
// If the direct child matched, then return that
if (coll.id === targetID) return coll
// Else run it in the children
if (coll.children) {
const result = this.findCollInTree(coll.children, targetID)
if (result) return result
}
}
// If nothing matched, return null
return null
}
getChildren(id: string | null): Ref<ChildrenResult<TeamCollectionNode>> {
return computed(() => {
if (id === null) {
if (props.teamLoadingCollections.includes("root")) {
return {
status: "loading",
}
} else {
const data = this.data.value.map((item) => ({
id: item.id,
data: {
type: "collections",
data: {
parentIndex: null,
data: item,
},
},
}))
return {
status: "loaded",
data: cloneDeep(data),
} as ChildrenResult<TeamCollections>
}
} else {
const parsedID = id.split("/")[id.split("/").length - 1]
!props.teamLoadingCollections.includes(parsedID) &&
emit("expand-team-collection", parsedID)
if (props.teamLoadingCollections.includes(parsedID)) {
return {
status: "loading",
}
} else {
const items = this.findCollInTree(this.data.value, parsedID)
if (items) {
const data = [
...(items.children
? items.children.map((item) => ({
id: `${id}/${item.id}`,
data: {
type: "folders",
data: {
parentIndex: parsedID,
data: item,
},
},
}))
: []),
...(items.requests
? items.requests.map((item) => ({
id: `${id}/${item.id}`,
data: {
type: "requests",
data: {
parentIndex: parsedID,
data: item,
},
},
}))
: []),
]
return {
status: "loaded",
data: cloneDeep(data),
} as ChildrenResult<TeamFolder | TeamRequests>
} else {
return {
status: "loaded",
data: [],
}
}
}
}
})
}
}
const teamAdapter: SmartTreeAdapter<TeamCollectionNode> =
new TeamCollectionsAdapter(teamCollectionsList)
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div class="flex flex-1">
<SmartIntersection
class="flex flex-col flex-1"
@intersecting="onTeamSelectIntersect"
>
<tippy
interactive
trigger="click"
theme="popover"
placement="bottom"
:on-shown="() => tippyActions!.focus()"
>
<span
v-tippy="{ theme: 'tooltip' }"
:title="`${t('collection.select_team')}`"
class="bg-transparent border-b border-dividerLight select-wrapper"
>
<ButtonSecondary
v-if="collectionsType.selectedTeam"
:icon="IconUsers"
:label="collectionsType.selectedTeam.name"
class="flex-1 !justify-start pr-8 rounded-none"
/>
<ButtonSecondary
v-else
:label="`${t('collection.select_team')}`"
class="flex-1 !justify-start pr-8 rounded-none"
/>
</span>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<div
v-if="isTeamListLoading && myTeams.length === 0"
class="flex flex-col items-center justify-center flex-1 p-2"
>
<SmartSpinner class="my-2" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div v-else-if="myTeams.length > 0" class="flex flex-col">
<SmartItem
v-for="(team, index) in myTeams"
:key="`team-${index}`"
:label="team.name"
:info-icon="
team.id === collectionsType.selectedTeam?.id
? IconDone
: undefined
"
:active-info-icon="team.id === collectionsType.selectedTeam?.id"
:icon="IconUsers"
@click="
() => {
updateSelectedTeam(team)
hide()
}
"
/>
<hr />
<SmartItem
:icon="IconPlus"
:label="t('team.create_new')"
@click="
() => {
displayTeamModalAdd(true)
hide()
}
"
/>
</div>
<div
v-else
class="flex flex-col items-center justify-center p-2 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/add_group.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center mb-4 w-14 h-14"
:alt="`${t('empty.teams')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.teams") }}
</span>
<ButtonSecondary
:label="t('team.create_new')"
filled
outline
@click="
() => {
displayTeamModalAdd(true)
hide()
}
"
/>
</div>
</div>
</template>
</tippy>
</SmartIntersection>
</div>
</template>
<script setup lang="ts">
import IconUsers from "~icons/lucide/users"
import IconDone from "~icons/lucide/check"
import { PropType, ref } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import IconPlus from "~icons/lucide/plus"
const t = useI18n()
const colorMode = useColorMode()
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
| {
type: "team-collections"
selectedTeam: SelectedTeam
}
| { type: "my-collections"; selectedTeam: undefined }
defineProps({
collectionsType: {
type: Object as PropType<CollectionType>,
default: () => ({ type: "my-collections", selectedTeam: undefined }),
required: true,
},
myTeams: {
type: Array as PropType<GetMyTeamsQuery["myTeams"]>,
default: () => [],
required: true,
},
isTeamListLoading: {
type: Boolean,
default: false,
required: true,
},
})
const tippyActions = ref<TippyComponent | null>(null)
const emit = defineEmits<{
(e: "update-selected-team", payload: SelectedTeam): void
(e: "team-select-intersect", payload: boolean): void
(e: "display-team-modal-add", payload: boolean): void
}>()
const updateSelectedTeam = (team: SelectedTeam) => {
emit("update-selected-team", team)
}
const onTeamSelectIntersect = () => {
emit("team-select-intersect", true)
}
const displayTeamModalAdd = (display: boolean) => {
emit("display-team-modal-add", display)
}
</script>

View File

@@ -143,7 +143,7 @@
v-for="(folder, index) in collection.folders"
:key="`folder-${String(index)}`"
:picked="picked"
:saving-mode="savingMode"
:save-request="saveRequest"
:folder="folder"
:folder-index="index"
:folder-path="`${collectionIndex}/${String(index)}`"
@@ -160,7 +160,7 @@
v-for="(request, index) in collection.requests"
:key="`request-${String(index)}`"
:picked="picked"
:saving-mode="savingMode"
:save-request="saveRequest"
:request="request"
:collection-index="collectionIndex"
:folder-index="-1"
@@ -183,9 +183,19 @@
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="text-center">
<span class="pb-4 text-center">
{{ t("empty.collection") }}
</span>
<ButtonSecondary
:label="t('add.new')"
filled
outline
@click="
emit('add-folder', {
path: `${collectionIndex}`,
})
"
/>
</div>
</div>
</div>
@@ -215,11 +225,12 @@ import {
removeGraphqlCollection,
moveGraphqlRequest,
} from "~/newstore/collections"
import { Picked } from "~/helpers/types/HoppPicked"
const props = defineProps({
picked: { type: Object, default: null },
// Whether the viewing context is related to picking (activates 'select' events)
savingMode: { type: Boolean, default: false },
saveRequest: { type: Boolean, default: false },
collectionIndex: { type: Number, default: null },
collection: { type: Object, default: () => ({}) },
isFiltered: Boolean,
@@ -231,7 +242,7 @@ const t = useI18n()
// TODO: improve types plz
const emit = defineEmits<{
(e: "select", i: { picked: any }): void
(e: "select", i: Picked | null): void
(e: "edit-request", i: any): void
(e: "duplicate-request", i: any): void
(e: "add-request", i: any): void
@@ -267,15 +278,13 @@ const collectionIcon = computed(() => {
const pick = () => {
emit("select", {
picked: {
pickedType: "gql-my-collection",
collectionIndex: props.collectionIndex,
},
pickedType: "gql-my-collection",
collectionIndex: props.collectionIndex,
})
}
const toggleShowChildren = () => {
if (props.savingMode) {
if (props.saveRequest) {
pick()
}
@@ -288,7 +297,7 @@ const removeCollection = () => {
props.picked?.pickedType === "gql-my-collection" &&
props.picked?.collectionIndex === props.collectionIndex
) {
emit("select", { picked: null })
emit("select", null)
}
removeGraphqlCollection(props.collectionIndex)
toast.success(`${t("state.deleted")}`)

View File

@@ -132,7 +132,7 @@
v-for="(subFolder, subFolderIndex) in folder.folders"
:key="`subFolder-${String(subFolderIndex)}`"
:picked="picked"
:saving-mode="savingMode"
:save-request="saveRequest"
:folder="subFolder"
:folder-index="subFolderIndex"
:folder-path="`${folderPath}/${String(subFolderIndex)}`"
@@ -149,7 +149,7 @@
v-for="(request, index) in folder.requests"
:key="`request-${String(index)}`"
:picked="picked"
:saving-mode="savingMode"
:save-request="saveRequest"
:request="request"
:collection-index="collectionIndex"
:folder-index="folderIndex"
@@ -212,7 +212,7 @@ const colorMode = useColorMode()
const props = defineProps({
picked: { type: Object, default: null },
// Whether the request is in a selectable mode (activates 'select' event)
savingMode: { type: Boolean, default: false },
saveRequest: { type: Boolean, default: false },
folder: { type: Object, default: () => ({}) },
folderIndex: { type: Number, default: null },
collectionIndex: { type: Number, default: null },
@@ -263,7 +263,7 @@ const pick = () => {
}
const toggleShowChildren = () => {
if (props.savingMode) {
if (props.saveRequest) {
pick()
}

View File

@@ -99,7 +99,7 @@ import IconFolderPlus from "~icons/lucide/folder-plus"
import IconDownload from "~icons/lucide/download"
import IconGithub from "~icons/lucide/github"
import { computed, ref } from "vue"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useToast } from "@composables/toast"
@@ -120,7 +120,10 @@ const emit = defineEmits<{
const toast = useToast()
const t = useI18n()
const collections = useReadonlyStream(graphqlCollections$, [])
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
// Template refs
const tippyActions = ref<any | null>(null)

View File

@@ -29,7 +29,7 @@
</span>
<div class="flex">
<ButtonSecondary
v-if="!savingMode"
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
@@ -148,7 +148,7 @@ const props = defineProps({
// Whether the object is selected (show the tick mark)
picked: { type: Object, default: null },
// Whether the request is being saved (activate 'select' event)
savingMode: { type: Boolean, default: false },
saveRequest: { type: Boolean, default: false },
request: { type: Object as PropType<HoppGQLRequest>, default: () => ({}) },
folderPath: { type: String, default: null },
requestIndex: { type: Number, default: null },
@@ -169,16 +169,14 @@ const isSelected = computed(
const pick = () => {
emit("select", {
picked: {
pickedType: "gql-my-request",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
},
pickedType: "gql-my-request",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
})
}
const selectRequest = () => {
if (props.savingMode) {
if (props.saveRequest) {
pick()
} else {
setGQLSession({
@@ -213,7 +211,7 @@ const removeRequest = () => {
props.picked.folderPath === props.folderPath &&
props.picked.requestIndex === props.requestIndex
) {
emit("select", { picked: null })
emit("select", null)
}
removeGraphqlRequest(props.folderPath, props.requestIndex)

View File

@@ -1,18 +1,21 @@
<template>
<div :class="{ 'rounded border border-divider': savingMode }">
<div :class="{ 'rounded border border-divider': saveRequest }">
<div
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto border-b divide-y divide-dividerLight border-dividerLight"
:class="{ 'bg-primary': !savingMode }"
class="sticky z-10 flex flex-col flex-shrink-0 overflow-x-auto rounded-t bg-primary"
:style="
saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0'
"
>
<input
v-if="showCollActions"
v-model="filterText"
type="search"
autocomplete="off"
:placeholder="t('action.search')"
class="flex px-4 py-2 bg-transparent"
class="py-2 pl-4 pr-2 bg-transparent"
/>
<div class="flex justify-between flex-1">
<div
class="flex justify-between flex-1 flex-shrink-0 border-y bg-primary border-dividerLight"
>
<ButtonSecondary
:icon="IconPlus"
:label="t('action.new')"
@@ -28,7 +31,7 @@
:icon="IconHelpCircle"
/>
<ButtonSecondary
v-if="showCollActions"
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:title="t('modal.import_export')"
:icon="IconArchive"
@@ -37,7 +40,7 @@
</div>
</div>
</div>
<div class="flex-col">
<div class="flex flex-col">
<CollectionsGraphqlCollection
v-for="(collection, index) in filteredCollections"
:key="`collection-${index}`"
@@ -46,7 +49,7 @@
:collection-index="index"
:collection="collection"
:is-filtered="filterText.length > 0"
:saving-mode="savingMode"
:save-request="saveRequest"
@edit-collection="editCollection(collection, index)"
@add-request="addRequest($event)"
@add-folder="addFolder($event)"
@@ -154,10 +157,8 @@ import { useColorMode } from "@composables/theming"
export default defineComponent({
props: {
// Whether to activate the ability to pick items (activates 'select' events)
savingMode: { type: Boolean, default: false },
saveRequest: { type: Boolean, default: false },
picked: { type: Object, default: null },
// Whether to show the 'New' and 'Import/Export' actions
showCollActions: { type: Boolean, default: true },
},
emits: ["select", "use-collection"],
setup() {

File diff suppressed because it is too large Load Diff

View File

@@ -1,354 +0,0 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="toggleShowChildren()"
>
<component
:is="getCollectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="toggleShowChildren()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collection.name }}
</span>
</span>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="
$emit('add-request', {
path: `${collectionIndex}`,
})
"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="
$emit('add-folder', {
folder: collection,
path: `${collectionIndex}`,
})
"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()"
@keyup.delete="deleteAction.$el.click()"
@keyup.x="exportAction.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
$emit('add-request', {
path: `${collectionIndex}`,
})
hide()
}
"
/>
<SmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
$emit('add-folder', {
folder: collection,
path: `${collectionIndex}`,
})
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
$emit('edit-collection')
hide()
}
"
/>
<SmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
@click="
() => {
exportCollection()
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeCollection()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<div v-if="showChildren || isFiltered" class="flex">
<div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
@click="toggleShowChildren()"
></div>
<div class="flex flex-col flex-1 truncate">
<CollectionsMyFolder
v-for="(folder, index) in collection.folders"
:key="`folder-${index}`"
:folder="folder"
:folder-index="index"
:folder-path="`${collectionIndex}/${index}`"
:collection-index="collectionIndex"
:save-request="saveRequest"
:collections-type="collectionsType"
:is-filtered="isFiltered"
:picked="picked"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
/>
<CollectionsMyRequest
v-for="(request, index) in collection.requests"
:key="`request-${index}`"
:request="request"
:collection-index="collectionIndex"
:folder-index="-1"
:folder-name="collection.name"
:folder-path="`${collectionIndex}`"
:request-index="index"
:save-request="saveRequest"
:collections-type="collectionsType"
:picked="picked"
@edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
/>
<div
v-if="
(collection.folders == undefined ||
collection.folders.length === 0) &&
(collection.requests == undefined ||
collection.requests.length === 0)
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="text-center">
{{ t("empty.collection") }}
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import IconCircle from "~icons/lucide/circle"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconFilePlus from "~icons/lucide/file-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconDownload from "~icons/lucide/download"
import IconTrash2 from "~icons/lucide/trash-2"
import IconEdit from "~icons/lucide/edit"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import { useColorMode } from "@composables/theming"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { defineComponent, ref, markRaw } from "vue"
import { moveRESTRequest } from "~/newstore/collections"
export default defineComponent({
props: {
collectionIndex: { type: Number, default: null },
collection: { type: Object, default: () => ({}) },
isFiltered: Boolean,
saveRequest: Boolean,
collectionsType: { type: Object, default: () => ({}) },
picked: { type: Object, default: () => ({}) },
},
emits: [
"select",
"expand-collection",
"add-collection",
"remove-collection",
"add-folder",
"add-request",
"edit-folder",
"edit-request",
"duplicate-request",
"remove-folder",
"remove-request",
"select-collection",
"unselect-collection",
"edit-collection",
],
setup() {
return {
colorMode: useColorMode(),
toast: useToast(),
t: useI18n(),
// Template refs
tippyActions: ref<any | null>(null),
options: ref<any | null>(null),
requestAction: ref<any | null>(null),
folderAction: ref<any | null>(null),
edit: ref<any | null>(null),
deleteAction: ref<any | null>(null),
exportAction: ref<any | null>(null),
}
},
data() {
return {
IconCircle: markRaw(IconCircle),
IconCheckCircle: markRaw(IconCheckCircle),
IconFilePlus: markRaw(IconFilePlus),
IconFolderPlus: markRaw(IconFolderPlus),
IconMoreVertical: markRaw(IconMoreVertical),
IconEdit: markRaw(IconEdit),
IconDownload: markRaw(IconDownload),
IconTrash2: markRaw(IconTrash2),
showChildren: false,
dragging: false,
selectedFolder: {},
prevCursor: "",
cursor: "",
pageNo: 0,
}
},
computed: {
isSelected(): boolean {
return (
this.picked &&
this.picked.pickedType === "my-collection" &&
this.picked.collectionIndex === this.collectionIndex
)
},
getCollectionIcon() {
if (this.isSelected) return IconCheckCircle
else if (!this.showChildren && !this.isFiltered) return IconFolder
else if (this.showChildren || this.isFiltered) return IconFolderOpen
else return IconFolder
},
},
methods: {
exportCollection() {
const collectionJSON = JSON.stringify(this.collection)
const file = new Blob([collectionJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${this.collection.name}.json`
document.body.appendChild(a)
a.click()
this.toast.success(this.t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
},
toggleShowChildren() {
if (this.$props.saveRequest)
this.$emit("select", {
picked: {
pickedType: "my-collection",
collectionIndex: this.collectionIndex,
},
})
this.$emit("expand-collection", this.collection.id)
this.showChildren = !this.showChildren
},
removeCollection() {
this.$emit("remove-collection", {
collectionIndex: this.collectionIndex,
collectionID: this.collection.id,
})
},
dropEvent({ dataTransfer }: any) {
this.dragging = !this.dragging
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
moveRESTRequest(folderPath, requestIndex, `${this.collectionIndex}`)
},
},
})
</script>

View File

@@ -1,340 +0,0 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="toggleShowChildren()"
>
<component
:is="getCollectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="toggleShowChildren()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ folder.name ? folder.name : folder.title }}
</span>
</span>
<div class="flex">
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="$emit('add-request', { path: folderPath })"
/>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="$emit('add-folder', { folder, path: folderPath })"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction.$el.click()"
@keyup.n="folderAction.$el.click()"
@keyup.e="edit.$el.click()"
@keyup.delete="deleteAction.$el.click()"
@keyup.x="exportAction.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
$emit('add-request', { path: folderPath })
hide()
}
"
/>
<SmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
$emit('add-folder', { folder, path: folderPath })
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
$emit('edit-folder', {
folder,
folderIndex,
collectionIndex,
folderPath,
})
hide()
}
"
/>
<SmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
@click="
() => {
exportFolder()
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeFolder()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<div v-if="showChildren || isFiltered" class="flex">
<div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
@click="toggleShowChildren()"
></div>
<div class="flex flex-col flex-1 truncate">
<!-- Referring to this component only (this is recursive) -->
<Folder
v-for="(subFolder, subFolderIndex) in folder.folders"
:key="`subFolder-${subFolderIndex}`"
:folder="subFolder"
:folder-index="subFolderIndex"
:collection-index="collectionIndex"
:save-request="saveRequest"
:collections-type="collectionsType"
:folder-path="`${folderPath}/${subFolderIndex}`"
:picked="picked"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
@update-team-collections="$emit('update-team-collections')"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
/>
<CollectionsMyRequest
v-for="(request, index) in folder.requests"
:key="`request-${index}`"
:request="request"
:collection-index="collectionIndex"
:folder-index="folderIndex"
:folder-name="folder.name"
:folder-path="folderPath"
:request-index="index"
:picked="picked"
:save-request="saveRequest"
:collections-type="collectionsType"
@edit-request="$emit('edit-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
/>
<div
v-if="
folder.folders &&
folder.folders.length === 0 &&
folder.requests &&
folder.requests.length === 0
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import IconFilePlus from "~icons/lucide/file-plus"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconEdit from "~icons/lucide/edit"
import IconDownload from "~icons/lucide/download"
import IconTrash2 from "~icons/lucide/trash-2"
import IconFolder from "~icons/lucide/folder"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolderOpen from "~icons/lucide/folder-open"
import { defineComponent, ref } from "vue"
import { useColorMode } from "@composables/theming"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { moveRESTRequest } from "~/newstore/collections"
export default defineComponent({
name: "Folder",
props: {
folder: { type: Object, default: () => ({}) },
folderIndex: { type: Number, default: null },
collectionIndex: { type: Number, default: null },
folderPath: { type: String, default: null },
saveRequest: Boolean,
isFiltered: Boolean,
collectionsType: { type: Object, default: () => ({}) },
picked: { type: Object, default: () => ({}) },
},
emits: [
"add-request",
"add-folder",
"edit-folder",
"update-team",
"remove-folder",
"edit-request",
"duplicate-request",
"select",
"remove-request",
"update-team-collections",
],
setup() {
const t = useI18n()
return {
// Template refs
tippyActions: ref<any | null>(null),
options: ref<any | null>(null),
requestAction: ref<any | null>(null),
folderAction: ref<any | null>(null),
edit: ref<any | null>(null),
deleteAction: ref<any | null>(null),
exportAction: ref<any | null>(null),
t,
toast: useToast(),
colorMode: useColorMode(),
IconFilePlus,
IconFolderPlus,
IconMoreVertical,
IconEdit,
IconDownload,
IconTrash2,
}
},
data() {
return {
showChildren: false,
dragging: false,
prevCursor: "",
cursor: "",
}
},
computed: {
isSelected(): boolean {
return (
this.picked &&
this.picked.pickedType === "my-folder" &&
this.picked.folderPath === this.folderPath
)
},
getCollectionIcon() {
if (this.isSelected) return IconCheckCircle
else if (!this.showChildren && !this.isFiltered) return IconFolder
else if (this.showChildren || this.isFiltered) return IconFolderOpen
else return IconFolder
},
},
methods: {
exportFolder() {
const folderJSON = JSON.stringify(this.folder)
const file = new Blob([folderJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${this.folder.name}.json`
document.body.appendChild(a)
a.click()
this.toast.success(this.t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
},
toggleShowChildren() {
if (this.$props.saveRequest)
this.$emit("select", {
picked: {
pickedType: "my-folder",
collectionIndex: this.collectionIndex,
folderName: this.folder.name,
folderPath: this.folderPath,
},
})
this.showChildren = !this.showChildren
},
removeFolder() {
this.$emit("remove-folder", {
folder: this.folder,
folderPath: this.folderPath,
})
},
dropEvent({ dataTransfer }) {
this.dragging = !this.dragging
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
moveRESTRequest(folderPath, requestIndex, this.folderPath)
},
},
})
</script>

View File

@@ -1,433 +0,0 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
draggable="true"
@dragstart="dragStart"
@dragover.stop
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options.tippy.show()"
>
<span
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
:class="getRequestLabelColor(request.method)"
@click="selectRequest()"
>
<component
:is="IconCheckCircle"
v-if="isSelected"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
<span v-else class="font-semibold truncate text-tiny">
{{ request.method }}
</span>
</span>
<span
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="selectRequest()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ request.name }}
</span>
<span
v-if="isActive"
v-tippy="{ theme: 'tooltip' }"
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
:title="`${t('collection.request_in_use')}`"
>
<span
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
>
</span>
<span
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
></span>
</span>
</span>
<div class="flex">
<ButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
class="hidden group-hover:inline-flex"
@click="selectRequest()"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.e="edit.$el.click()"
@keyup.d="duplicate.$el.click()"
@keyup.delete="deleteAction.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-request', {
collectionIndex,
folderIndex,
folderName,
request,
requestIndex,
folderPath,
})
hide()
}
"
/>
<SmartItem
ref="duplicate"
:icon="IconCopy"
:label="t('action.duplicate')"
:shortcut="['D']"
@click="
() => {
emit('duplicate-request', {
collectionIndex,
folderIndex,
folderName,
request,
requestIndex,
folderPath,
})
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeRequest()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<HttpReqChangeConfirmModal
:show="confirmChange"
@hide-modal="confirmChange = false"
@save-change="saveRequestChange"
@discard-change="discardRequestChange"
/>
<CollectionsSaveRequest
mode="rest"
:show="showSaveRequestModal"
@hide-modal="showSaveRequestModal = false"
/>
</div>
</template>
<script setup lang="ts">
import IconCheckCircle from "~icons/lucide/check-circle"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import { ref, computed } from "vue"
import {
HoppRESTRequest,
safelyExtractRESTRequest,
translateToNewRequest,
isEqualHoppRESTRequest,
} from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { cloneDeep } from "lodash-es"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useReadonlyStream } from "@composables/stream"
import {
getDefaultRESTRequest,
getRESTRequest,
restSaveContext$,
setRESTRequest,
setRESTSaveContext,
getRESTSaveContext,
} from "~/newstore/RESTSession"
import { editRESTRequest } from "~/newstore/collections"
import { runMutation } from "~/helpers/backend/GQLClient"
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
const props = defineProps<{
request: HoppRESTRequest
collectionIndex: number
folderIndex: number
folderName: string
requestIndex: number
saveRequest: boolean
collectionsType: object
folderPath: string
picked?: {
pickedType: string
collectionIndex: number
folderPath: string
folderName: string
requestIndex: number
}
}>()
const emit = defineEmits<{
(
e: "select",
data:
| {
picked: {
pickedType: string
collectionIndex: number
folderPath: string
folderName: string
requestIndex: number
}
}
| undefined
): void
(
e: "remove-request",
data: {
folderPath: string
requestIndex: number
}
): void
(
e: "duplicate-request",
data: {
collectionIndex: number
folderIndex: number
folderName: string
request: HoppRESTRequest
folderPath: string
requestIndex: number
}
): void
(
e: "edit-request",
data: {
collectionIndex: number
folderIndex: number
folderName: string
request: HoppRESTRequest
folderPath: string
requestIndex: number
}
): void
}>()
const t = useI18n()
const toast = useToast()
const dragging = ref(false)
const requestMethodLabels = {
get: "text-green-500",
post: "text-yellow-500",
put: "text-blue-500",
delete: "text-red-500",
default: "text-gray-500",
}
const confirmChange = ref(false)
const showSaveRequestModal = ref(false)
// Template refs
const tippyActions = ref<any | null>(null)
const options = ref<any | null>(null)
const edit = ref<any | null>(null)
const duplicate = ref<any | null>(null)
const deleteAction = ref<any | null>(null)
const active = useReadonlyStream(restSaveContext$, null)
const isSelected = computed(
() =>
props.picked &&
props.picked.pickedType === "my-request" &&
props.picked.folderPath === props.folderPath &&
props.picked.requestIndex === props.requestIndex
)
const isActive = computed(
() =>
active.value &&
active.value.originLocation === "user-collection" &&
active.value.folderPath === props.folderPath &&
active.value.requestIndex === props.requestIndex
)
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
dragging.value = !dragging.value
dataTransfer.setData("folderPath", props.folderPath)
dataTransfer.setData("requestIndex", props.requestIndex.toString())
}
}
const removeRequest = () => {
emit("remove-request", {
folderPath: props.folderPath,
requestIndex: props.requestIndex,
})
}
const getRequestLabelColor = (method: string) =>
requestMethodLabels[
method.toLowerCase() as keyof typeof requestMethodLabels
] || requestMethodLabels.default
const setRestReq = (request: any) => {
setRESTRequest(
cloneDeep(
safelyExtractRESTRequest(
translateToNewRequest(request),
getDefaultRESTRequest()
)
),
{
originLocation: "user-collection",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
req: cloneDeep(request),
}
)
}
/** Loads request from the save once, checks for unsaved changes, but ignores default values */
const selectRequest = () => {
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
if (props.saveRequest) {
emit("select", {
picked: {
pickedType: "my-request",
collectionIndex: props.collectionIndex,
folderPath: props.folderPath,
folderName: props.folderName,
requestIndex: props.requestIndex,
},
})
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
confirmChange.value = false
setRestReq(props.request)
} else if (!active.value) {
// If the current request is the same as the request to be loaded in, there is no data loss
const currentReq = getRESTRequest()
if (isEqualHoppRESTRequest(currentReq, props.request)) {
setRestReq(props.request)
} else {
confirmChange.value = true
}
} else {
const currentReqWithNoChange = active.value.req
const currentFullReq = getRESTRequest()
// Check if whether user clicked the same request or not
if (!isActive.value && currentReqWithNoChange !== undefined) {
// Check if there is any changes done on the current request
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
setRestReq(props.request)
} else {
confirmChange.value = true
}
} else {
setRESTSaveContext(null)
}
}
}
/** Save current request to the collection */
const saveRequestChange = () => {
const saveCtx = getRESTSaveContext()
saveCurrentRequest(saveCtx)
confirmChange.value = false
}
/** Discard changes and change the current request and context */
const discardRequestChange = () => {
setRestReq(props.request)
if (!isActive.value) {
setRESTSaveContext({
originLocation: "user-collection",
folderPath: props.folderPath,
requestIndex: props.requestIndex,
req: cloneDeep(props.request),
})
}
confirmChange.value = false
}
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
if (!saveCtx) {
showSaveRequestModal.value = true
return
}
if (saveCtx.originLocation === "user-collection") {
try {
editRESTRequest(
saveCtx.folderPath,
saveCtx.requestIndex,
getRESTRequest()
)
setRestReq(props.request)
toast.success(`${t("request.saved")}`)
} catch (e) {
setRESTSaveContext(null)
saveCurrentRequest(saveCtx)
}
} else if (saveCtx.originLocation === "team-collection") {
const req = getRESTRequest()
try {
runMutation(UpdateRequestDocument, {
requestID: saveCtx.requestID,
data: {
title: req.name,
request: JSON.stringify(req),
},
})().then((result) => {
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
} else {
toast.success(`${t("request.saved")}`)
}
})
setRestReq(props.request)
} catch (error) {
showSaveRequestModal.value = true
toast.error(`${t("error.something_went_wrong")}`)
console.error(error)
}
}
}
</script>

View File

@@ -1,408 +0,0 @@
<template>
<div class="flex flex-col">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options!.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="toggleShowChildren()"
>
<component
:is="getCollectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="toggleShowChildren()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collection.title }}
</span>
</span>
<div class="flex">
<ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="
$emit('add-request', {
folder: collection,
path: `${collectionIndex}`,
})
"
/>
<ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="
$emit('add-folder', {
folder: collection,
path: `${collectionIndex}`,
})
"
/>
<span>
<tippy
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction!.$el.click()"
@keyup.n="folderAction!.$el.click()"
@keyup.e="edit!.$el.click()"
@keyup.delete="deleteAction!.$el.click()"
@keyup.x="exportAction!.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
$emit('add-request', {
folder: collection,
path: `${collectionIndex}`,
})
hide()
}
"
/>
<SmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
$emit('add-folder', {
folder: collection,
path: `${collectionIndex}`,
})
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
$emit('edit-collection')
hide()
}
"
/>
<SmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
:loading="exportLoading"
@click="exportCollection"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeCollection()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<div v-if="showChildren || isFiltered" class="flex">
<div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
@click="toggleShowChildren()"
></div>
<div class="flex flex-col flex-1 truncate">
<CollectionsTeamsFolder
v-for="(folder, index) in collection.children"
:key="`folder-${index}`"
:folder="folder"
:folder-index="index"
:folder-path="`${collectionIndex}/${index}`"
:collection-index="collectionIndex"
:save-request="saveRequest"
:collections-type="collectionsType"
:is-filtered="isFiltered"
:picked="picked"
:loading-collection-i-ds="loadingCollectionIDs"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)"
@select="$emit('select', $event)"
@expand-collection="expandCollection"
@remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
/>
<CollectionsTeamsRequest
v-for="(request, index) in collection.requests"
:key="`request-${index}`"
:request="request.request"
:collection-index="collectionIndex"
:folder-index="-1"
:folder-name="collection.name"
:request-index="request.id"
:save-request="saveRequest"
:collection-i-d="collection.id"
:collections-type="collectionsType"
:picked="picked"
@edit-request="editRequest($event)"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
/>
<div
v-if="loadingCollectionIDs.includes(collection.id)"
class="flex flex-col items-center justify-center p-4"
>
<SmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-else-if="
(collection.children == undefined ||
collection.children.length === 0) &&
(collection.requests == undefined ||
collection.requests.length === 0)
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collection')}`"
/>
<span class="text-center">
{{ t("empty.collection") }}
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconTrash2 from "~icons/lucide/trash-2"
import IconDownload from "~icons/lucide/download"
import IconEdit from "~icons/lucide/edit"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconFilePlus from "~icons/lucide/file-plus"
import IconCircle from "~icons/lucide/circle"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import { defineComponent, ref } from "vue"
import * as E from "fp-ts/Either"
import {
getCompleteCollectionTree,
teamCollToHoppRESTColl,
} from "~/helpers/backend/helpers"
import { moveRESTTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
import { useColorMode } from "@composables/theming"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { TippyComponent } from "vue-tippy"
import SmartItem from "@components/smart/Item.vue"
export default defineComponent({
props: {
collectionIndex: { type: Number, default: null },
collection: { type: Object, default: () => ({}) },
isFiltered: Boolean,
saveRequest: Boolean,
collectionsType: { type: Object, default: () => ({}) },
picked: { type: Object, default: () => ({}) },
loadingCollectionIDs: { type: Array, default: () => [] },
},
emits: [
"edit-collection",
"add-request",
"add-folder",
"edit-folder",
"edit-request",
"remove-folder",
"select",
"remove-request",
"duplicate-request",
"expand-collection",
"remove-collection",
],
setup() {
const t = useI18n()
return {
// Template refs
tippyActions: ref<TippyComponent | null>(null),
options: ref<TippyComponent | null>(null),
requestAction: ref<typeof SmartItem | null>(null),
folderAction: ref<typeof SmartItem | null>(null),
edit: ref<typeof SmartItem | null>(null),
deleteAction: ref<typeof SmartItem | null>(null),
exportAction: ref<typeof SmartItem | null>(null),
exportLoading: ref<boolean>(false),
t,
toast: useToast(),
colorMode: useColorMode(),
IconCheckCircle,
IconCircle,
IconFilePlus,
IconFolderPlus,
IconEdit,
IconDownload,
IconTrash2,
IconMoreVertical,
}
},
data() {
return {
showChildren: false,
dragging: false,
selectedFolder: {},
prevCursor: "",
cursor: "",
pageNo: 0,
}
},
computed: {
isSelected(): boolean {
return (
this.picked &&
this.picked.pickedType === "teams-collection" &&
this.picked.collectionID === this.collection.id
)
},
getCollectionIcon() {
if (this.isSelected) return IconCheckCircle
else if (!this.showChildren && !this.isFiltered) return IconFolder
else if (this.showChildren || this.isFiltered) return IconFolderOpen
else return IconFolder
},
},
methods: {
async exportCollection() {
this.exportLoading = true
const result = await getCompleteCollectionTree(this.collection.id)()
if (E.isLeft(result)) {
this.toast.error(this.t("error.something_went_wrong").toString())
console.log(result.left)
this.exportLoading = false
this.options!.tippy.hide()
return
}
const hoppColl = teamCollToHoppRESTColl(result.right)
const collectionJSON = JSON.stringify(hoppColl)
const file = new Blob([collectionJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${hoppColl.name}.json`
document.body.appendChild(a)
a.click()
this.toast.success(this.t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
this.exportLoading = false
this.options!.tippy.hide()
},
editRequest(event: any) {
this.$emit("edit-request", event)
if (this.$props.saveRequest)
this.$emit("select", {
picked: {
pickedType: "teams-collection",
collectionID: this.collection.id,
},
})
},
toggleShowChildren() {
if (this.$props.saveRequest)
this.$emit("select", {
picked: {
pickedType: "teams-collection",
collectionID: this.collection.id,
},
})
this.$emit("expand-collection", this.collection.id)
this.showChildren = !this.showChildren
},
removeCollection() {
this.$emit("remove-collection", {
collectionIndex: this.collectionIndex,
collectionID: this.collection.id,
})
},
expandCollection(collectionID: string) {
this.$emit("expand-collection", collectionID)
},
async dropEvent({ dataTransfer }: any) {
this.dragging = !this.dragging
const requestIndex = dataTransfer.getData("requestIndex")
const moveRequestResult = await moveRESTTeamRequest(
requestIndex,
this.collection.id
)()
if (E.isLeft(moveRequestResult))
this.toast.error(`${this.t("error.something_went_wrong")}`)
},
},
})
</script>

View File

@@ -1,383 +0,0 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options!.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="toggleShowChildren()"
>
<component
:is="getCollectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="toggleShowChildren()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ folder.name ? folder.name : folder.title }}
</span>
</span>
<div class="flex">
<ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="$emit('add-request', { folder, path: folderPath })"
/>
<ButtonSecondary
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="$emit('add-folder', { folder, path: folderPath })"
/>
<span>
<tippy
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction!.$el.click()"
@keyup.n="folderAction!.$el.click()"
@keyup.e="edit!.$el.click()"
@keyup.delete="deleteAction!.$el.click()"
@keyup.x="exportAction!.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
$emit('add-request', { folder, path: folderPath })
hide()
}
"
/>
<SmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
$emit('add-folder', { folder, path: folderPath })
hide()
}
"
/>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
$emit('edit-folder', {
folder,
folderIndex,
collectionIndex,
folderPath: '',
})
hide()
}
"
/>
<SmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
:loading="exportLoading"
@click="exportFolder"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeFolder()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<div v-if="showChildren || isFiltered" class="flex">
<div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
@click="toggleShowChildren()"
></div>
<div class="flex flex-col flex-1 truncate">
<!-- Referring to this component only (this is recursive) -->
<Folder
v-for="(subFolder, subFolderIndex) in folder.children"
:key="`subFolder-${subFolderIndex}`"
:folder="subFolder"
:folder-index="subFolderIndex"
:collection-index="collectionIndex"
:save-request="saveRequest"
:collections-type="collectionsType"
:folder-path="`${folderPath}/${subFolderIndex}`"
:picked="picked"
:loading-collection-i-ds="loadingCollectionIDs"
@add-request="$emit('add-request', $event)"
@add-folder="$emit('add-folder', $event)"
@edit-folder="$emit('edit-folder', $event)"
@edit-request="$emit('edit-request', $event)"
@update-team-collections="$emit('update-team-collections')"
@select="$emit('select', $event)"
@expand-collection="expandCollection"
@remove-request="$emit('remove-request', $event)"
@remove-folder="$emit('remove-folder', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
/>
<CollectionsTeamsRequest
v-for="(request, index) in folder.requests"
:key="`request-${index}`"
:request="request.request"
:collection-index="collectionIndex"
:folder-index="folderIndex"
:folder-name="folder.name"
:request-index="request.id"
:save-request="saveRequest"
:collections-type="collectionsType"
:picked="picked"
:collection-i-d="folder.id"
@edit-request="$emit('edit-request', $event)"
@select="$emit('select', $event)"
@remove-request="$emit('remove-request', $event)"
@duplicate-request="$emit('duplicate-request', $event)"
/>
<div
v-if="loadingCollectionIDs.includes(folder.id)"
class="flex flex-col items-center justify-center p-4"
>
<SmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-else-if="
(folder.children == undefined || folder.children.length === 0) &&
(folder.requests == undefined || folder.requests.length === 0)
"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.folder')}`"
/>
<span class="text-center">
{{ t("empty.folder") }}
</span>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconEdit from "~icons/lucide/edit"
import IconDownload from "~icons/lucide/download"
import IconTrash2 from "~icons/lucide/trash-2"
import IconFilePlus from "~icons/lucide/file-plus"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import { defineComponent, ref } from "vue"
import * as E from "fp-ts/Either"
import {
getCompleteCollectionTree,
teamCollToHoppRESTColl,
} from "~/helpers/backend/helpers"
import { moveRESTTeamRequest } from "~/helpers/backend/mutations/TeamRequest"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useColorMode } from "@composables/theming"
import { TippyComponent } from "vue-tippy"
import SmartItem from "@components/smart/Item.vue"
export default defineComponent({
name: "Folder",
props: {
folder: { type: Object, default: () => ({}) },
folderIndex: { type: Number, default: null },
collectionIndex: { type: Number, default: null },
folderPath: { type: String, default: null },
saveRequest: Boolean,
isFiltered: Boolean,
collectionsType: { type: Object, default: () => ({}) },
picked: { type: Object, default: () => ({}) },
loadingCollectionIDs: { type: Array, default: () => [] },
},
emits: [
"add-request",
"add-folder",
"edit-folder",
"update-team-collections",
"edit-request",
"remove-request",
"duplicate-request",
"select",
"remove-folder",
"expand-collection",
],
setup() {
return {
// Template refs
tippyActions: ref<TippyComponent | null>(null),
options: ref<TippyComponent | null>(null),
requestAction: ref<typeof SmartItem | null>(null),
folderAction: ref<typeof SmartItem | null>(null),
edit: ref<typeof SmartItem | null>(null),
deleteAction: ref<typeof SmartItem | null>(null),
exportAction: ref<typeof SmartItem | null>(null),
exportLoading: ref<boolean>(false),
toast: useToast(),
t: useI18n(),
colorMode: useColorMode(),
IconFilePlus,
IconFolderPlus,
IconCheckCircle,
IconFolder,
IconFolderOpen,
IconMoreVertical,
IconEdit,
IconDownload,
IconTrash2,
}
},
data() {
return {
showChildren: false,
dragging: false,
prevCursor: "",
cursor: "",
}
},
computed: {
isSelected(): boolean {
return (
this.picked &&
this.picked.pickedType === "teams-folder" &&
this.picked.folderID === this.folder.id
)
},
getCollectionIcon() {
if (this.isSelected) return IconCheckCircle
else if (!this.showChildren && !this.isFiltered) return IconFolder
else if (this.showChildren || this.isFiltered) return IconFolderOpen
else return IconFolder
},
},
methods: {
async exportFolder() {
this.exportLoading = true
const result = await getCompleteCollectionTree(this.folder.id)()
if (E.isLeft(result)) {
this.toast.error(this.t("error.something_went_wrong").toString())
console.log(result.left)
this.exportLoading = false
this.options!.tippy.hide()
return
}
const hoppColl = teamCollToHoppRESTColl(result.right)
const collectionJSON = JSON.stringify(hoppColl)
const file = new Blob([collectionJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
a.download = `${hoppColl.name}.json`
document.body.appendChild(a)
a.click()
this.toast.success(this.t("state.download_started").toString())
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
this.exportLoading = false
this.options!.tippy.hide()
},
toggleShowChildren() {
if (this.$props.saveRequest)
this.$emit("select", {
picked: {
pickedType: "teams-folder",
folderID: this.folder.id,
},
})
this.$emit("expand-collection", this.$props.folder.id)
this.showChildren = !this.showChildren
},
removeFolder() {
this.$emit("remove-folder", {
collectionsType: this.collectionsType,
folder: this.folder,
})
},
expandCollection(collectionID: number) {
this.$emit("expand-collection", collectionID)
},
async dropEvent({ dataTransfer }: any) {
this.dragging = !this.dragging
const requestIndex = dataTransfer.getData("requestIndex")
const moveRequestResult = await moveRESTTeamRequest(
requestIndex,
this.folder.id
)()
if (E.isLeft(moveRequestResult))
this.toast.error(`${this.t("error.something_went_wrong")}`)
},
},
})
</script>

View File

@@ -1,405 +0,0 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div
class="flex items-stretch group"
draggable="true"
@dragstart="dragStart"
@dragover.stop
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options.tippy.show()"
>
<span
class="flex items-center justify-center w-16 px-2 truncate cursor-pointer"
:class="getRequestLabelColor(request.method)"
@click="selectRequest()"
>
<component
:is="IconCheckCircle"
v-if="isSelected"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
<span v-else class="font-semibold truncate text-tiny">
{{ request.method }}
</span>
</span>
<span
class="flex items-center flex-1 min-w-0 py-2 pr-2 cursor-pointer transition group-hover:text-secondaryDark"
@click="selectRequest()"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ request.name }}
</span>
<span
v-if="isActive"
v-tippy="{ theme: 'tooltip' }"
class="relative h-1.5 w-1.5 flex flex-shrink-0 mx-3"
:title="`${t('collection.request_in_use')}`"
>
<span
class="absolute inline-flex flex-shrink-0 w-full h-full bg-green-500 rounded-full opacity-75 animate-ping"
>
</span>
<span
class="relative inline-flex flex-shrink-0 rounded-full h-1.5 w-1.5 bg-green-500"
></span>
</span>
</span>
<div class="flex">
<ButtonSecondary
v-if="!saveRequest"
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
class="hidden group-hover:inline-flex"
@click="selectRequest()"
/>
<span>
<tippy
v-if="collectionsType.selectedTeam.myRole !== 'VIEWER'"
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<ButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.e="edit.$el.click()"
@keyup.d="duplicate.$el.click()"
@keyup.delete="deleteAction.$el.click()"
@keyup.escape="hide()"
>
<SmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-request', {
collectionIndex,
folderIndex,
folderName,
request,
requestIndex,
})
hide()
}
"
/>
<SmartItem
ref="duplicate"
:icon="IconCopy"
:label="t('action.duplicate')"
:shortcut="['D']"
@click="
() => {
emit('duplicate-request', {
request,
requestIndex,
collectionID,
})
hide()
}
"
/>
<SmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeRequest()
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<HttpReqChangeConfirmModal
:show="confirmChange"
@hide-modal="confirmChange = false"
@save-change="saveRequestChange"
@discard-change="discardRequestChange"
/>
<CollectionsSaveRequest
mode="rest"
:show="showSaveRequestModal"
@hide-modal="showSaveRequestModal = false"
/>
</div>
</template>
<script setup lang="ts">
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import { ref, computed } from "vue"
import {
HoppRESTRequest,
isEqualHoppRESTRequest,
safelyExtractRESTRequest,
translateToNewRequest,
} from "@hoppscotch/data"
import * as E from "fp-ts/Either"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useReadonlyStream } from "@composables/stream"
import {
getDefaultRESTRequest,
restSaveContext$,
setRESTRequest,
setRESTSaveContext,
getRESTSaveContext,
getRESTRequest,
} from "~/newstore/RESTSession"
import { editRESTRequest } from "~/newstore/collections"
import { runMutation } from "~/helpers/backend/GQLClient"
import { Team, UpdateRequestDocument } from "~/helpers/backend/graphql"
import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
const props = defineProps<{
request: HoppRESTRequest
collectionIndex: number
folderIndex: number
folderName?: string
requestIndex: string
saveRequest: boolean
collectionsType: {
type: "my-collections" | "team-collections"
selectedTeam: Team | undefined
}
collectionID: string
picked?: {
pickedType: string
requestID: string
}
}>()
const emit = defineEmits<{
(
e: "select",
data:
| {
picked: {
pickedType: string
requestID: string
}
}
| undefined
): void
(
e: "remove-request",
data: {
folderPath: string | undefined
requestIndex: string
}
): void
(
e: "edit-request",
data: {
collectionIndex: number
folderIndex: number
folderName: string | undefined
requestIndex: string
request: HoppRESTRequest
}
): void
(
e: "duplicate-request",
data: {
collectionID: number | string
requestIndex: string
request: HoppRESTRequest
}
): void
}>()
const t = useI18n()
const toast = useToast()
const dragging = ref(false)
const requestMethodLabels = {
get: "text-green-500",
post: "text-yellow-500",
put: "text-blue-500",
delete: "text-red-500",
default: "text-gray-500",
}
const confirmChange = ref(false)
const showSaveRequestModal = ref(false)
// Template refs
const tippyActions = ref<any | null>(null)
const options = ref<any | null>(null)
const edit = ref<any | null>(null)
const duplicate = ref<any | null>(null)
const deleteAction = ref<any | null>(null)
const active = useReadonlyStream(restSaveContext$, null)
const isSelected = computed(
() =>
props.picked &&
props.picked.pickedType === "teams-request" &&
props.picked.requestID === props.requestIndex
)
const isActive = computed(
() =>
active.value &&
active.value.originLocation === "team-collection" &&
active.value.requestID === props.requestIndex
)
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
dragging.value = !dragging.value
dataTransfer.setData("requestIndex", props.requestIndex)
}
}
const removeRequest = () => {
emit("remove-request", {
folderPath: props.folderName,
requestIndex: props.requestIndex,
})
}
const getRequestLabelColor = (method: string): string => {
return (
(requestMethodLabels as any)[method.toLowerCase()] ||
requestMethodLabels.default
)
}
const setRestReq = (request: HoppRESTRequest) => {
setRESTRequest(
safelyExtractRESTRequest(
translateToNewRequest(request),
getDefaultRESTRequest()
),
{
originLocation: "team-collection",
requestID: props.requestIndex,
req: request,
}
)
}
const selectRequest = () => {
// Check if this is a save as request popup, if so we don't need to prompt the confirm change popup.
if (props.saveRequest) {
emit("select", {
picked: {
pickedType: "teams-request",
requestID: props.requestIndex,
},
})
} else if (isEqualHoppRESTRequest(props.request, getDefaultRESTRequest())) {
confirmChange.value = false
setRestReq(props.request)
} else if (!active.value) {
confirmChange.value = true
} else {
const currentReqWithNoChange = active.value.req
const currentFullReq = getRESTRequest()
// Check if whether user clicked the same request or not
if (!isActive.value && currentReqWithNoChange) {
// Check if there is any changes done on the current request
if (isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)) {
setRestReq(props.request)
} else {
confirmChange.value = true
}
} else {
setRESTSaveContext(null)
}
}
}
/** Save current request to the collection */
const saveRequestChange = () => {
const saveCtx = getRESTSaveContext()
saveCurrentRequest(saveCtx)
confirmChange.value = false
}
/** Discard changes and change the current request and context */
const discardRequestChange = () => {
setRestReq(props.request)
if (!isActive.value) {
setRESTSaveContext({
originLocation: "team-collection",
requestID: props.requestIndex,
req: props.request,
})
}
confirmChange.value = false
}
const saveCurrentRequest = (saveCtx: HoppRequestSaveContext | null) => {
if (!saveCtx) {
showSaveRequestModal.value = true
return
}
if (saveCtx.originLocation === "team-collection") {
const req = getRESTRequest()
try {
runMutation(UpdateRequestDocument, {
requestID: saveCtx.requestID,
data: {
title: req.name,
request: JSON.stringify(req),
},
})().then((result) => {
if (E.isLeft(result)) {
toast.error(`${t("profile.no_permission")}`)
} else {
toast.success(`${t("request.saved")}`)
}
})
setRestReq(props.request)
} catch (error) {
showSaveRequestModal.value = true
toast.error(`${t("error.something_went_wrong")}`)
console.error(error)
}
} else if (saveCtx.originLocation === "user-collection") {
try {
editRESTRequest(
saveCtx.folderPath,
saveCtx.requestIndex,
getRESTRequest()
)
setRestReq(props.request)
toast.success(`${t("request.saved")}`)
} catch (e) {
setRESTSaveContext(null)
saveCurrentRequest(null)
}
}
}
</script>

View File

@@ -12,7 +12,6 @@
<SmartTab
:id="'team-environments'"
:label="`${t('environment.team_environments')}`"
:disabled="!currentUser"
>
<SmartIntersection @intersecting="onTeamSelectIntersect">
<tippy
@@ -76,16 +75,17 @@
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { nextTick, ref, watch } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { onLoggedIn } from "@composables/auth"
import { currentUserInfo$ } from "~/helpers/teams/BackendUserInfo"
import { platform } from "~/platform"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useReadonlyStream } from "@composables/stream"
import { useLocalState } from "~/newstore/localstate"
import { useI18n } from "@composables/i18n"
import IconDone from "~icons/lucide/check"
import IconUsers from "~icons/lucide/users"
import { invokeAction } from "~/helpers/actions"
const t = useI18n()
@@ -111,7 +111,10 @@ const emit = defineEmits<{
(e: "update-selected-team", team: SelectedTeam): void
}>()
const currentUser = useReadonlyStream(currentUserInfo$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const adapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(adapter.teamList$, null)
@@ -138,7 +141,9 @@ watch(
)
onLoggedIn(() => {
adapter.initialize()
try {
adapter.initialize()
} catch (e) {}
})
const onTeamSelectIntersect = () => {
@@ -156,6 +161,9 @@ const updateSelectedTeam = (team: SelectedTeam) => {
}
watch(selectedEnvironmentTab, (newValue: EnvironmentTabs) => {
updateEnvironmentType(newValue)
if (newValue === "team-environments" && !currentUser.value) {
invokeAction("modals.login.toggle")
nextTick(() => (selectedEnvironmentTab.value = "my-environments"))
} else updateEnvironmentType(newValue)
})
</script>

View File

@@ -107,7 +107,7 @@ import IconDownload from "~icons/lucide/download"
import IconGithub from "~icons/lucide/github"
import { computed, ref } from "vue"
import { Environment } from "@hoppscotch/data"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import axios from "axios"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
@@ -141,7 +141,10 @@ const t = useI18n()
const loading = ref(false)
const myEnvironments = useReadonlyStream(environments$, [])
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
// Template refs
const tippyActions = ref<TippyComponent | null>(null)
@@ -187,7 +190,7 @@ const createEnvironmentGist = async () => {
)
toast.success(t("export.gist_created").toString())
window.open(res.html_url)
window.open(res.data.html_url)
} catch (e) {
toast.error(t("error.something_went_wrong").toString())
console.error(e)

View File

@@ -8,7 +8,6 @@
interactive
trigger="click"
theme="popover"
arrow
:on-shown="() => tippyActions!.focus()"
>
<span
@@ -69,7 +68,7 @@
</div>
</template>
</tippy>
<tippy v-else interactive trigger="click" theme="popover" arrow>
<tippy v-else interactive trigger="click" theme="popover">
<span
v-tippy="{ theme: 'tooltip' }"
:title="`${t('environment.select')}`"
@@ -184,7 +183,7 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { isEqual } from "lodash-es"
import { currentUser$ } from "~/helpers/fb/auth"
import { platform } from "~/platform"
import { Team } from "~/helpers/backend/graphql"
import { useReadonlyStream, useStream } from "@composables/stream"
import { useI18n } from "~/composables/i18n"
@@ -223,7 +222,10 @@ const globalEnvironment = computed(() => ({
variables: globalEnv.value,
}))
const currentUser = useReadonlyStream(currentUser$, null)
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const updateSelectedTeam = (newSelectedTeam: SelectedTeam) => {
environmentType.value.selectedTeam = newSelectedTeam

View File

@@ -122,7 +122,7 @@ import {
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { TippyComponent } from "vue-tippy"
import SmartItem from "@components/smart/Item.vue"
import SmartItem from "@hoppscotch/ui/src/components/smart/Item.vue"
const t = useI18n()
const toast = useToast()
@@ -140,9 +140,9 @@ const confirmRemove = ref(false)
const tippyActions = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const edit = ref<typeof SmartItem | null>(null)
const duplicate = ref<typeof SmartItem | null>(null)
const deleteAction = ref<typeof SmartItem | null>(null)
const edit = ref<typeof SmartItem>()
const duplicate = ref<typeof SmartItem>()
const deleteAction = ref<typeof SmartItem>()
const removeEnvironment = () => {
if (props.environmentIndex === null) return

View File

@@ -24,7 +24,6 @@
interactive
trigger="click"
theme="popover"
arrow
:on-shown="() => tippyActions!.focus()"
>
<ButtonSecondary
@@ -109,7 +108,7 @@ import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import IconMoreVertical from "~icons/lucide/more-vertical"
import { TippyComponent } from "vue-tippy"
import SmartItem from "@components/smart/Item.vue"
import SmartItem from "@hoppscotch/ui/src/components/smart/Item.vue"
const t = useI18n()
const toast = useToast()
@@ -127,9 +126,9 @@ const confirmRemove = ref(false)
const tippyActions = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const edit = ref<typeof SmartItem | null>(null)
const duplicate = ref<typeof SmartItem | null>(null)
const deleteAction = ref<typeof SmartItem | null>(null)
const edit = ref<typeof SmartItem>()
const duplicate = ref<typeof SmartItem>()
const deleteAction = ref<typeof SmartItem>()
const removeEnvironment = () => {
pipe(

View File

@@ -122,16 +122,7 @@
<script lang="ts">
import { defineComponent } from "vue"
import {
signInUserWithGoogle,
signInUserWithGithub,
signInUserWithMicrosoft,
setProviderInfo,
currentUser$,
signInWithEmail,
linkWithFBCredentialFromAuthError,
getGithubCredentialFromResult,
} from "~/helpers/fb/auth"
import { platform } from "~/platform"
import IconGithub from "~icons/auth/github"
import IconGoogle from "~icons/auth/google"
import IconEmail from "~icons/auth/email"
@@ -174,6 +165,8 @@ export default defineComponent({
}
},
mounted() {
const currentUser$ = platform.auth.getCurrentUserStream()
this.subscribeToStream(currentUser$, (user) => {
if (user) this.hideModal()
})
@@ -186,8 +179,7 @@ export default defineComponent({
this.signingInWithGoogle = true
try {
await signInUserWithGoogle()
this.showLoginSuccess()
await platform.auth.signInUserWithGoogle()
} catch (e) {
console.error(e)
/*
@@ -202,35 +194,32 @@ export default defineComponent({
async signInWithGithub() {
this.signingInWithGitHub = true
try {
const result = await signInUserWithGithub()
const credential = getGithubCredentialFromResult(result)!
const token = credential.accessToken
setProviderInfo(result.providerId!, token!)
const result = await platform.auth.signInUserWithGithub()
this.showLoginSuccess()
} catch (e) {
console.error(e)
// This user's email is already present in Firebase but with other providers, namely Google or Microsoft
if (
(e as any).code === "auth/account-exists-with-different-credential"
) {
this.toast.info(`${this.t("auth.account_exists")}`, {
duration: 0,
closeOnSwipe: false,
action: {
text: `${this.t("action.yes")}`,
onClick: async (_, toastObject) => {
await linkWithFBCredentialFromAuthError(e)
this.showLoginSuccess()
if (!result) {
this.signingInWithGitHub = false
return
}
toastObject.goAway(0)
},
if (result.type === "success") {
// this.showLoginSuccess()
} else if (result.type === "account-exists-with-different-cred") {
this.toast.info(`${this.t("auth.account_exists")}`, {
duration: 0,
closeOnSwipe: false,
action: {
text: `${this.t("action.yes")}`,
onClick: async (_, toastObject) => {
await result.link()
this.showLoginSuccess()
toastObject.goAway(0)
},
})
} else {
this.toast.error(`${this.t("error.something_went_wrong")}`)
}
},
})
} else {
console.log("error logging into github", result.err)
this.toast.error(`${this.t("error.something_went_wrong")}`)
}
this.signingInWithGitHub = false
@@ -239,8 +228,8 @@ export default defineComponent({
this.signingInWithMicrosoft = true
try {
await signInUserWithMicrosoft()
this.showLoginSuccess()
await platform.auth.signInUserWithMicrosoft()
// this.showLoginSuccess()
} catch (e) {
console.error(e)
/*
@@ -259,11 +248,8 @@ export default defineComponent({
async signInWithEmail() {
this.signingInWithEmail = true
const actionCodeSettings = {
url: `${import.meta.env.VITE_BASE_URL}/enter`,
handleCodeInApp: true,
}
await signInWithEmail(this.form.email, actionCodeSettings)
await platform.auth
.signInWithEmail(this.form.email)
.then(() => {
this.mode = "email-sent"
setLocalConfig("emailForSignIn", this.form.email)

View File

@@ -22,7 +22,7 @@ import { ref } from "vue"
import IconLogOut from "~icons/lucide/log-out"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { signOutUser } from "~/helpers/fb/auth"
import { platform } from "~/platform"
defineProps({
outline: {
@@ -47,7 +47,7 @@ const t = useI18n()
const logout = async () => {
try {
await signOutUser()
await platform.auth.signOutUser()
toast.success(`${t("auth.logged_out")}`)
} catch (e) {
console.error(e)

View File

@@ -99,7 +99,7 @@
:show-more="showMore"
@toggle-star="toggleStar(entry.entry)"
@delete-entry="deleteHistory(entry.entry)"
@use-entry="useHistory(entry.entry)"
@use-entry="useHistory(toRaw(entry.entry))"
/>
</details>
</div>
@@ -164,7 +164,7 @@ import IconHelpCircle from "~icons/lucide/help-circle"
import IconTrash2 from "~icons/lucide/trash-2"
import IconTrash from "~icons/lucide/trash"
import IconFilter from "~icons/lucide/filter"
import { computed, ref, Ref } from "vue"
import { computed, ref, Ref, toRaw } from "vue"
import { useColorMode } from "@composables/theming"
import {
HoppGQLRequest,
@@ -331,14 +331,25 @@ const setRestReq = (request: HoppRESTRequest | null | undefined) => {
// (That is not a really good behaviour tho ¯\_(ツ)_/¯)
const useHistory = (entry: RESTHistoryEntry) => {
const currentFullReq = getRESTRequest()
const currentReqWithNoChange = getRESTSaveContext()?.req
// checks if the current request is the same as the save context request if present
if (
currentReqWithNoChange &&
isEqualHoppRESTRequest(currentReqWithNoChange, currentFullReq)
) {
props.page === "rest" && setRestReq(entry.request)
clickedHistory.value = entry
}
// Initial state trigers a popup
if (!clickedHistory.value) {
else if (!clickedHistory.value) {
clickedHistory.value = entry
confirmChange.value = true
return
}
// Checks if there are any change done in current request and the history request
if (
else if (
!isEqualHoppRESTRequest(
currentFullReq,
clickedHistory.value.request as HoppRESTRequest
@@ -347,7 +358,7 @@ const useHistory = (entry: RESTHistoryEntry) => {
clickedHistory.value = entry
confirmChange.value = true
} else {
props.page === "rest" && setRestReq(entry.request as HoppRESTRequest)
props.page === "rest" && setRestReq(entry.request)
clickedHistory.value = entry
}
}

View File

@@ -28,7 +28,15 @@
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<ButtonSecondary
v-if="contentType && contentType.endsWith('json')"
v-if="
[
'application/json',
'application/ld+json',
'application/hal+json',
'application/vnd.api+json',
'application/xml',
].includes(contentType)
"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.prettify')"
:icon="prettifyIcon"
@@ -39,7 +47,7 @@
v-tippy="{ theme: 'tooltip' }"
:title="t('import.title')"
:icon="IconFilePlus"
@click="$refs.payload.click()"
@click="payload?.click()"
/>
</label>
<input
@@ -47,7 +55,7 @@
class="input"
name="payload"
type="file"
@change="uploadPayload"
@change="uploadPayload($event)"
/>
</div>
</div>
@@ -86,6 +94,8 @@ type PossibleContentTypes = Exclude<
const t = useI18n()
const payload = ref<HTMLInputElement | null>(null)
const props = defineProps<{
contentType: PossibleContentTypes
}>()
@@ -145,7 +155,7 @@ const clearContent = () => {
rawParamsBody.value = ""
}
const uploadPayload = async (e: InputEvent) => {
const uploadPayload = async (e: Event) => {
await pipe(
(e.target as HTMLInputElement).files?.[0],
TO.of,
@@ -161,10 +171,17 @@ const uploadPayload = async (e: InputEvent) => {
)
)()
}
const prettifyRequestBody = () => {
let prettifyBody = ""
try {
const jsonObj = JSON.parse(rawParamsBody.value)
rawParamsBody.value = JSON.stringify(jsonObj, null, 2)
if (props.contentType.endsWith("json")) {
const jsonObj = JSON.parse(rawParamsBody.value as string)
prettifyBody = JSON.stringify(jsonObj, null, 2)
} else if (props.contentType == "application/xml") {
prettifyBody = prettifyXML(rawParamsBody.value as string)
}
rawParamsBody.value = prettifyBody
prettifyIcon.value = IconCheck
} catch (e) {
console.error(e)
@@ -172,4 +189,28 @@ const prettifyRequestBody = () => {
toast.error(`${t("error.json_prettify_invalid_body")}`)
}
}
const prettifyXML = (xml: string) => {
const PADDING = " ".repeat(2) // set desired indent size here
const reg = /(>)(<)(\/*)/g
let pad = 0
xml = xml.replace(reg, "$1\r\n$2$3")
return xml
.split("\r\n")
.map((node) => {
let indent = 0
if (node.match(/.+<\/\w[^>]*>$/)) {
indent = 0
} else if (node.match(/^<\/\w/) && pad > 0) {
pad -= 1
} else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
indent = 1
} else {
indent = 0
}
pad += indent
return PADDING.repeat(pad - indent) + node
})
.join("\r\n")
}
</script>

View File

@@ -16,14 +16,15 @@
<ButtonPrimary
v-focus
:label="t('action.save')"
:loading="loading"
outline
@click="saveApiChange"
@click="saveChange"
/>
<ButtonSecondary
:label="t('action.dont_save')"
outline
filled
@click="discardApiChange"
@click="discardChange"
/>
</span>
<ButtonSecondary
@@ -43,6 +44,7 @@ const t = useI18n()
defineProps<{
show: boolean
loading?: boolean
}>()
const emit = defineEmits<{
@@ -51,11 +53,11 @@ const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const saveApiChange = () => {
const saveChange = () => {
emit("save-change")
}
const discardApiChange = () => {
const discardChange = () => {
emit("discard-change")
}

View File

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

View File

@@ -0,0 +1,184 @@
<template>
<section class="p-4">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.delete_account") }}
</h4>
<div class="my-1 mb-4 text-secondaryLight">
{{ t("settings.delete_account_description") }}
</div>
<ButtonSecondary
filled
outline
:label="t('settings.delete_account')"
type="submit"
@click="showDeleteAccountModal = true"
/>
<SmartModal
v-if="showDeleteAccountModal"
dialog
:title="t('settings.delete_account')"
@close="showDeleteAccountModal = false"
>
<template #body>
<div v-if="loading" class="flex flex-col items-center justify-center">
<SmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-else-if="myTeams.length"
class="flex flex-col p-4 space-y-2 border border-red-500 rounded-lg text-secondaryDark bg-error"
>
<h2 class="font-bold text-red-500">
{{ t("error.danger_zone") }}
</h2>
<div>
{{ t("error.delete_account") }}
<ul class="my-4 ml-8 space-y-2 list-disc">
<li v-for="team in myTeams" :key="team.id">
{{ team.name }}
</li>
</ul>
<span class="font-semibold">
{{ t("error.delete_account_description") }}
</span>
</div>
</div>
<div v-else>
<div
class="flex flex-col p-4 mb-4 space-y-2 border border-red-500 rounded-lg text-secondaryDark bg-error"
>
<h2 class="font-bold text-red-500">
{{ t("error.danger_zone") }}
</h2>
<div class="font-medium text-secondaryDark">
{{ t("settings.delete_account_description") }}
</div>
</div>
<div class="flex flex-col">
<input
id="deleteUserAccount"
v-model="userVerificationInput"
class="input floating-input"
placeholder=" "
type="text"
autocomplete="off"
/>
<label for="deleteUserAccount">
Type
<span class="font-bold"> delete my account </span>
to confirm
</label>
</div>
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<ButtonPrimary
:label="t('settings.delete_account')"
:loading="deletingUser"
filled
outline
:disabled="
loading ||
myTeams.length > 0 ||
userVerificationInput !== 'delete my account'
"
class="!bg-red-500 !hover:bg-red-600 !border-red-500 !hover:border-red-600"
@click="deleteUserAccount"
/>
<ButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="showDeleteAccountModal = false"
/>
</span>
</template>
</SmartModal>
</section>
</template>
<script setup lang="ts">
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { ref, watch } from "vue"
import { GQLError, runGQLQuery } from "~/helpers/backend/GQLClient"
import * as E from "fp-ts/Either"
import { useRouter } from "vue-router"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { GetMyTeamsDocument, GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { deleteUser } from "~/helpers/backend/mutations/Profile"
import { platform } from "~/platform"
const t = useI18n()
const toast = useToast()
const router = useRouter()
const showDeleteAccountModal = ref(false)
const userVerificationInput = ref("")
const loading = ref(true)
const myTeams = ref<GetMyTeamsQuery["myTeams"]>([])
watch(showDeleteAccountModal, (isModalOpen) => {
if (isModalOpen) {
fetchMyTeams()
}
})
const fetchMyTeams = async () => {
loading.value = true
const result = await runGQLQuery({
query: GetMyTeamsDocument,
})
loading.value = false
if (E.isLeft(result)) {
throw new Error(
`Failed fetching teams list: ${JSON.stringify(result.left)}`
)
}
myTeams.value = result.right.myTeams.filter((team) => {
return team.ownersCount === 1 && team.myRole === "OWNER"
})
}
const deletingUser = ref(false)
const deleteUserAccount = async () => {
if (deletingUser.value) return
deletingUser.value = true
pipe(
deleteUser(),
TE.match(
(err: GQLError<string>) => {
deletingUser.value = false
toast.error(getErrorMessage(err))
},
() => {
deletingUser.value = false
showDeleteAccountModal.value = false
toast.success(t("settings.account_deleted"))
platform.auth.signOutUser()
router.push(`/`)
}
)
)()
}
const getErrorMessage = (err: GQLError<string>) => {
if (err.type === "network_error") {
return t("error.network_error")
} else {
switch (err.error) {
case "shortcode/not_found":
return t("shortcodes.not_found")
default:
return t("error.something_went_wrong")
}
}
}
</script>

View File

@@ -1,3 +0,0 @@
<template>
<icon-lucide-loader class="animate-spin svg-icons" />
</template>

View File

@@ -0,0 +1,65 @@
<template>
<div class="flex flex-col flex-1">
<div
v-if="rootNodes.status === 'loaded' && rootNodes.data.length > 0"
class="flex flex-col"
>
<div
v-for="rootNode in rootNodes.data"
:key="rootNode.id"
class="flex flex-col flex-1"
>
<SmartTreeBranch
:node-item="rootNode"
:adapter="adapter as SmartTreeAdapter<T>"
>
<template #default="{ node, toggleChildren, isOpen }">
<slot
name="content"
:node="node as TreeNode<T>"
:toggle-children="toggleChildren as () => void"
:is-open="isOpen as boolean"
></slot>
</template>
<template #emptyNode="{ node }">
<slot name="emptyNode" :node="node"></slot>
</template>
</SmartTreeBranch>
</div>
</div>
<div
v-else-if="rootNodes.status === 'loading'"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<SmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-if="rootNodes.status === 'loaded' && rootNodes.data.length === 0"
class="flex flex-col flex-1"
>
<slot name="emptyNode" :node="(null as TreeNode<T> | null)"></slot>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends any">
import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
import { SmartTreeAdapter, TreeNode } from "~/helpers/treeAdapter"
const props = defineProps<{
/**
* The adapter that will be used to fetch the tree data
* @template T The type of the data that will be stored in the tree
*/
adapter: SmartTreeAdapter<T>
}>()
const t = useI18n()
/**
* Fetch the root nodes from the adapter by passing the node id as null
*/
const rootNodes = computed(() => props.adapter.getChildren(null).value)
</script>

View File

@@ -0,0 +1,103 @@
<template>
<slot
:node="nodeItem"
:toggle-children="toggleNodeChildren"
:is-open="isNodeOpen"
></slot>
<!-- This is a performance optimization trick -->
<!-- Once expanded, Vue will traverse through the children and expand the tree up
but when we collapse, the tree and the components are disposed. This is wasteful
and comes with performance issues if the children list is expensive to render.
Hence, here we render children only when first expanded, and after that, even if collapsed,
we just hide the children.
-->
<div v-if="childrenRendered" v-show="showChildren" class="flex">
<div
class="bg-dividerLight cursor-nsResize flex ml-5.5 transform transition w-1 hover:bg-dividerDark hover:scale-x-125"
@click="toggleNodeChildren"
></div>
<div
v-if="childNodes.status === 'loaded' && childNodes.data.length > 0"
class="flex flex-col flex-1 truncate"
>
<TreeBranch
v-for="childNode in childNodes.data"
:key="childNode.id"
:node-item="childNode"
:adapter="adapter"
>
<!-- The child slot is given a dynamic name in order to not break Volar -->
<template #[CHILD_SLOT_NAME]="{ node, toggleChildren, isOpen }">
<!-- Casting to help with type checking -->
<slot
:node="node as TreeNode<T>"
:toggle-children="toggleChildren as () => void"
:is-open="isOpen as boolean"
></slot>
</template>
<template #emptyNode="{ node }">
<slot name="emptyNode" :node="node"></slot>
</template>
</TreeBranch>
</div>
<div
v-if="childNodes.status === 'loading'"
class="flex flex-col items-center justify-center flex-1 p-4"
>
<SmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<div
v-if="childNodes.status === 'loaded' && childNodes.data.length === 0"
class="flex flex-col flex-1"
>
<slot name="emptyNode" :node="nodeItem"></slot>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends any">
import { computed, ref } from "vue"
import { useI18n } from "~/composables/i18n"
import { SmartTreeAdapter, TreeNode } from "~/helpers/treeAdapter"
const props = defineProps<{
/**
* The node item that will be used to render the tree branch
* @template T The type of the data passed to the tree branch
*/
adapter: SmartTreeAdapter<T>
/**
* The node item that will be used to render the tree branch content
*/
nodeItem: TreeNode<T>
}>()
const CHILD_SLOT_NAME = "default"
const t = useI18n()
/**
* Marks whether the children on this branch were ever rendered
* See the usage inside '<template>' for more info
*/
const childrenRendered = ref(false)
const showChildren = ref(false)
const isNodeOpen = ref(false)
/**
* Fetch the child nodes from the adapter by passing the node id of the current node
*/
const childNodes = computed(
() => props.adapter.getChildren(props.nodeItem.id).value
)
const toggleNodeChildren = () => {
if (!childrenRendered.value) childrenRendered.value = true
showChildren.value = !showChildren.value
isNodeOpen.value = !isNodeOpen.value
}
</script>

View File

@@ -34,12 +34,10 @@
/>
</div>
</div>
<div
v-if="teamDetails.loading"
class="flex flex-col items-center justify-center"
>
<SmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
<div v-if="teamDetails.loading" class="border rounded border-divider">
<div class="flex items-center justify-center p-4">
<SmartSpinner />
</div>
</div>
<div
v-if="

View File

@@ -108,7 +108,9 @@ const loading = computed(
)
onLoggedIn(() => {
adapter.initialize()
try {
adapter.initialize()
} catch (e) {}
})
const displayModalAdd = (shouldDisplay: boolean) => {

View File

@@ -1,18 +1,8 @@
import {
currentUser$,
HoppUser,
AuthEvent,
authEvents$,
authIdToken$,
} from "@helpers/fb/auth"
import {
map,
distinctUntilChanged,
filter,
Subscription,
combineLatestWith,
} from "rxjs"
import { onBeforeUnmount, onMounted } from "vue"
import { platform } from "~/platform"
import { AuthEvent, HoppUser } from "~/platform/auth"
import { Subscription } from "rxjs"
import { onBeforeUnmount, onMounted, watch, WatchStopHandle } from "vue"
import { useReadonlyStream } from "./stream"
/**
* A Vue composable function that is called when the auth status
@@ -21,26 +11,25 @@ import { onBeforeUnmount, onMounted } from "vue"
* was already resolved before mount.
*/
export function onLoggedIn(exec: (user: HoppUser) => void) {
let sub: Subscription | null = null
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
let watchStop: WatchStopHandle | null = null
onMounted(() => {
sub = currentUser$
.pipe(
// We don't consider the state as logged in unless we also have an id token
combineLatestWith(authIdToken$),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
filter(([_, token]) => !!token),
map((user) => !!user), // Get a logged in status (true or false)
distinctUntilChanged(), // Don't propagate unless the status updates
filter((x) => x) // Don't propagate unless it is logged in
)
.subscribe(() => {
exec(currentUser$.value!)
})
if (currentUser.value) exec(currentUser.value)
watchStop = watch(currentUser, (newVal, prev) => {
if (prev === null && newVal !== null) {
exec(newVal)
}
})
})
onBeforeUnmount(() => {
sub?.unsubscribe()
watchStop?.()
})
}
@@ -57,6 +46,8 @@ export function onLoggedIn(exec: (user: HoppUser) => void) {
* @param func A function which accepts an event
*/
export function onAuthEvent(func: (ev: AuthEvent) => void) {
const authEvents$ = platform.auth.getAuthEventsStream()
let sub: Subscription | null = null
onMounted(() => {

View File

@@ -9,6 +9,7 @@ import {
watchEffect,
WatchStopHandle,
watchSyncEffect,
watch,
} from "vue"
import {
client,
@@ -60,9 +61,6 @@ export const useGQLQuery = <DocType, DocVarType, DocErrorType extends string>(
const source: Ref<Source<OperationResult> | undefined> = ref()
// A ref used to force re-execution of the query
const updateTicker: Ref<boolean> = ref(true)
// Toggles between true and false to cause the polling operation to tick
const pollerTick: Ref<boolean> = ref(true)
@@ -96,24 +94,23 @@ export const useGQLQuery = <DocType, DocVarType, DocErrorType extends string>(
)
)
const rerunQuery = () => {
source.value = !isPaused.value
? client.value.executeQuery<DocType, DocVarType>(request.value, {
requestPolicy: "network-only",
})
: undefined
}
stops.push(
watchEffect(
watch(
pollerTick,
() => {
// Just listen to the polling ticks
// eslint-disable-next-line no-unused-expressions
pollerTick.value
// Just keep track of update ticking, but don't do anything
// eslint-disable-next-line no-unused-expressions
updateTicker.value
source.value = !isPaused.value
? client.value.executeQuery<DocType, DocVarType>(request.value, {
requestPolicy: "network-only",
})
: undefined
rerunQuery()
},
{ flush: "pre" }
{
flush: "pre",
}
)
)
@@ -192,7 +189,7 @@ export const useGQLQuery = <DocType, DocVarType, DocErrorType extends string>(
}
isPaused.value = false
updateTicker.value = !updateTicker.value
rerunQuery()
}
const pause = () => {

View File

@@ -37,6 +37,7 @@ export type HoppAction =
| "response.preview.toggle" // Toggle response preview
| "response.file.download" // Download response as file
| "response.copy" // Copy response to clipboard
| "modals.login.toggle" // Login to Hoppscotch
/**
* Defines the arguments, if present for a given type that is required to be passed on

View File

@@ -12,6 +12,7 @@ import {
CombinedError,
Operation,
OperationResult,
Client,
} from "@urql/core"
import { authExchange } from "@urql/exchange-auth"
import { devtoolsExchange } from "@urql/devtools"
@@ -21,12 +22,7 @@ import * as TE from "fp-ts/TaskEither"
import { pipe, constVoid, flow } from "fp-ts/function"
import { subscribe, pipe as wonkaPipe } from "wonka"
import { filter, map, Subject } from "rxjs"
import {
authIdToken$,
getAuthIDToken,
probableUser$,
waitProbableLoginToConfirm,
} from "~/helpers/fb/auth"
import { platform } from "~/platform"
// TODO: Implement caching
@@ -57,11 +53,7 @@ export const gqlClientError$ = new Subject<GQLClientErrorEvent>()
const createSubscriptionClient = () => {
return new SubscriptionClient(BACKEND_WS_URL, {
reconnect: true,
connectionParams: () => {
return {
authorization: `Bearer ${authIdToken$.value}`,
}
},
connectionParams: () => platform.auth.getBackendHeaders(),
connectionCallback(error) {
if (error?.length > 0) {
gqlClientError$.next({
@@ -79,7 +71,7 @@ const createHoppClient = () => {
dedupExchange,
authExchange({
addAuthToOperation({ authState, operation }) {
if (!authState || !authState.authToken) {
if (!authState) {
return operation
}
@@ -88,28 +80,29 @@ const createHoppClient = () => {
? operation.context.fetchOptions()
: operation.context.fetchOptions || {}
const authHeaders = platform.auth.getBackendHeaders()
return makeOperation(operation.kind, operation, {
...operation.context,
fetchOptions: {
...fetchOptions,
headers: {
...fetchOptions.headers,
Authorization: `Bearer ${authState.authToken}`,
...authHeaders,
},
},
})
},
willAuthError({ authState }) {
return !authState || !authState.authToken
willAuthError() {
return platform.auth.willBackendHaveAuthError()
},
getAuth: async () => {
if (!probableUser$.value) return { authToken: null }
const probableUser = platform.auth.getProbableUser()
await waitProbableLoginToConfirm()
if (probableUser !== null)
await platform.auth.waitProbableLoginToConfirm()
return {
authToken: getAuthIDToken(),
}
return {}
},
}),
fetchExchange,
@@ -137,31 +130,40 @@ const createHoppClient = () => {
return createClient({
url: BACKEND_GQL_URL,
exchanges,
...(platform.auth.getGQLClientOptions
? platform.auth.getGQLClientOptions()
: {}),
})
}
let subscriptionClient: SubscriptionClient | null
export const client = ref(createHoppClient())
authIdToken$.subscribe((idToken) => {
// triggering reconnect by closing the websocket client
if (idToken && subscriptionClient) {
subscriptionClient?.client?.close()
}
// creating new subscription
if (idToken && !subscriptionClient) {
subscriptionClient = createSubscriptionClient()
}
// closing existing subscription client.
if (!idToken && subscriptionClient) {
subscriptionClient.close()
subscriptionClient = null
}
export const client = ref<Client>()
export function initBackendGQLClient() {
client.value = createHoppClient()
})
platform.auth.onBackendGQLClientShouldReconnect(() => {
const currentUser = platform.auth.getCurrentUser()
// triggering reconnect by closing the websocket client
if (currentUser && subscriptionClient) {
subscriptionClient?.client?.close()
}
// creating new subscription
if (currentUser && !subscriptionClient) {
subscriptionClient = createSubscriptionClient()
}
// closing existing subscription client.
if (!currentUser && subscriptionClient) {
subscriptionClient.close()
subscriptionClient = null
}
client.value = createHoppClient()
})
}
type RunQueryOptions<T = any, V = object> = {
query: TypedDocumentNode<T, V>
@@ -185,7 +187,7 @@ export const runGQLQuery = <DocType, DocVarType, DocErrorType extends string>(
args: RunQueryOptions<DocType, DocVarType>
): Promise<E.Either<GQLError<DocErrorType>, DocType>> => {
const request = createRequest<DocType, DocVarType>(args.query, args.variables)
const source = client.value.executeQuery(request, {
const source = client.value!.executeQuery(request, {
requestPolicy: "network-only",
})
@@ -250,7 +252,7 @@ export const runGQLSubscription = <
) => {
const result$ = new Subject<E.Either<GQLError<DocErrorType>, DocType>>()
const source = client.value.executeSubscription(
const source = client.value!.executeSubscription(
createRequest(args.query, args.variables)
)
@@ -342,8 +344,8 @@ export const runMutation = <
pipe(
TE.tryCatch(
() =>
client.value
.mutation(mutation, variables, {
client
.value!.mutation(mutation, variables, {
requestPolicy: "cache-and-network",
...additionalConfig,
})

View File

@@ -0,0 +1,3 @@
mutation DeleteUser {
deleteUser
}

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