Compare commits

...

28 Commits

Author SHA1 Message Date
Nivedin
1afabec3a8 fix: add @click on row instead on cell 2023-08-23 12:22:48 +05:30
Joel Jacob Stephen
c9927ef032 refactor: can receive list from table 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
d9bddb1094 refactor: updated tables to use custom slot implementations 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
14a3dad013 refactor: updated the table story book component 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
c683a5471c chore: remove unnecessary changes 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
1b215be382 chore: removed cellstyles and modified some styles 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
9e0453d3bb refactor: new slots for table and body 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
caf5d7ec0f refactor: added slots for table header and body 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
6e7db67c9b refactor: modified components to suit the changes in the table component 2023-08-23 11:13:42 +05:30
Anwarul Islam
27eb7690ba feat: prevent row click on certain cell 2023-08-23 11:13:42 +05:30
Anwarul Islam
a875ab1ca9 feat: generic table implementation 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
d5e4211347 refactor: renamed emit event and removed action slot 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
24f32d79b3 refactor: remove badges and subtitle implementation from table and used slots instead 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
f48083ca7e style: removed unnecessary styles 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
c0ee3d02ae chore: updated types 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
fb234cbf7e refactor: introduced variants to table in storybook 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
c19a8be6e9 refactor: replaced text with i18n strings 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
b0e29b2e6c feat: introduced table component in storybook 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
0962718b0c chore: updated types 2023-08-23 11:13:42 +05:30
Joel Jacob Stephen
a08760351d style: modified styles in different components of sh admin 2023-08-23 11:13:41 +05:30
Joel Jacob Stephen
55c191305c refactor: replaced table with hoppsmarttable in invited users page and code cleanup 2023-08-23 11:13:18 +05:30
Joel Jacob Stephen
dfabf7e8bc feat: added the ability to add subtitles in multiple columns 2023-08-23 11:13:18 +05:30
Joel Jacob Stephen
834efce1c2 feat: introducing badges and subtitle support for table 2023-08-23 11:13:18 +05:30
Joel Jacob Stephen
cdbc580ada feat: introducing smart table to hopp ui 2023-08-23 11:13:18 +05:30
Liyas Thomas
008eb6b77b chore: minor ui improvements (#3274) 2023-08-22 22:22:43 +05:30
Joel Jacob Stephen
ac60843183 refactor: autofocus can be disabled in smart input hopp ui component (#3273)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-08-22 20:10:41 +05:30
Anwarul Islam
3c3fb1e4a9 fix: minor spotlight related issues (#3271)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-22 17:58:32 +05:30
Anwarul Islam
88212e8cfe feat: gql revamp (#2644)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-08-22 17:43:43 +05:30
79 changed files with 3261 additions and 2366 deletions

View File

@@ -574,3 +574,11 @@ details[open] summary .indicator {
@apply rounded;
@apply border-0;
}
.gql-operation-not-highlight {
opacity: 0.5;
}
.gql-operation-highlight {
opacity: 1;
}

View File

@@ -153,6 +153,7 @@
"remove_telemetry": "Are you sure you want to opt-out of Telemetry?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
"save_unsaved_tab": "Do you want to save changes made in this tab?",
"close_unsaved_tab": "Are you sure you want to close this tab?",
"close_unsaved_tabs": "Are you sure you want to close all tabs? {count} unsaved tabs will be lost.",
"sync": "Would you like to restore your workspace from cloud? This will discard your local progress."
},
@@ -281,6 +282,10 @@
"graphql": {
"mutations": "Mutations",
"schema": "Schema",
"switch_connection": "Switch connection",
"connection_switch_url": "You're connected to a GraphQL endpoint the connection URL is",
"connection_switch_new_url": "Switching to a tab will disconnected you from the active GraphQL connection. New connection URL is",
"connection_switch_confirm": "Do you want to connect with the latest GraphQL endpoint?",
"subscriptions": "Subscriptions"
},
"group": {
@@ -473,6 +478,7 @@
"rename": "Rename Request",
"renamed": "Request renamed",
"run": "Run",
"stop": "Stop",
"save": "Save",
"save_as": "Save as",
"saved": "Request saved",
@@ -578,6 +584,10 @@
"show_all": "Keyboard shortcuts",
"title": "General"
},
"others": {
"title": "Others",
"prettify": "Prettify Editor's Content"
},
"miscellaneous": {
"invite": "Invite people to Hoppscotch",
"title": "Miscellaneous"
@@ -600,7 +610,7 @@
"head_method": "Select HEAD method",
"rename": "Rename Current Request",
"import_curl": "Import cURL",
"show_code": "Show generated code",
"show_code": "Generate code snippet",
"method": "Method",
"next_method": "Select Next method",
"post_method": "Select POST method",
@@ -685,7 +695,7 @@
},
"tab": {
"close_current": "Close current tab",
"close_others": "Close others tab",
"close_others": "Close other tabs",
"new_tab": "Open a new tab",
"title": "Tabs"
},

View File

@@ -1,218 +1,222 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
declare module "vue" {
export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
AppContextMenu: typeof import('./components/app/ContextMenu.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
AppFooter: typeof import('./components/app/Footer.vue')['default']
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
AppHeader: typeof import('./components/app/Header.vue')['default']
AppInspection: typeof import('./components/app/Inspection.vue')['default']
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
AppLogo: typeof import('./components/app/Logo.vue')['default']
AppOptions: typeof import('./components/app/Options.vue')['default']
AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default']
AppShare: typeof import('./components/app/Share.vue')['default']
AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default']
AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default']
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
AppSidenav: typeof import('./components/app/Sidenav.vue')['default']
AppSocial: typeof import('./components/app/Social.vue')['default']
AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default']
AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default']
AppSpotlightEntryGQLHistory: typeof import('./components/app/spotlight/entry/GQLHistory.vue')['default']
AppSpotlightEntryGQLRequest: typeof import('./components/app/spotlight/entry/GQLRequest.vue')['default']
AppSpotlightEntryRESTHistory: typeof import('./components/app/spotlight/entry/RESTHistory.vue')['default']
AppSpotlightEntryRESTRequest: typeof import('./components/app/spotlight/entry/RESTRequest.vue')['default']
AppSupport: typeof import('./components/app/Support.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']
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']
CollectionsGraphql: typeof import('./components/collections/graphql/index.vue')['default']
CollectionsGraphqlAdd: typeof import('./components/collections/graphql/Add.vue')['default']
CollectionsGraphqlAddFolder: typeof import('./components/collections/graphql/AddFolder.vue')['default']
CollectionsGraphqlAddRequest: typeof import('./components/collections/graphql/AddRequest.vue')['default']
CollectionsGraphqlCollection: typeof import('./components/collections/graphql/Collection.vue')['default']
CollectionsGraphqlEdit: typeof import('./components/collections/graphql/Edit.vue')['default']
CollectionsGraphqlEditFolder: typeof import('./components/collections/graphql/EditFolder.vue')['default']
CollectionsGraphqlEditRequest: typeof import('./components/collections/graphql/EditRequest.vue')['default']
CollectionsGraphqlFolder: typeof import('./components/collections/graphql/Folder.vue')['default']
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']
CollectionsMyCollections: typeof import('./components/collections/MyCollections.vue')['default']
CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsAdd: typeof import('./components/environments/Add.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
EnvironmentsMyEnvironment: typeof import('./components/environments/my/Environment.vue')['default']
EnvironmentsSelector: typeof import('./components/environments/Selector.vue')['default']
EnvironmentsTeams: typeof import('./components/environments/teams/index.vue')['default']
EnvironmentsTeamsDetails: typeof import('./components/environments/teams/Details.vue')['default']
EnvironmentsTeamsEnvironment: typeof import('./components/environments/teams/Environment.vue')['default']
FirebaseLogin: typeof import('./components/firebase/Login.vue')['default']
FirebaseLogout: typeof import('./components/firebase/Logout.vue')['default']
GraphqlAuthorization: typeof import('./components/graphql/Authorization.vue')['default']
GraphqlField: typeof import('./components/graphql/Field.vue')['default']
GraphqlRequest: typeof import('./components/graphql/Request.vue')['default']
GraphqlRequestOptions: typeof import('./components/graphql/RequestOptions.vue')['default']
GraphqlResponse: typeof import('./components/graphql/Response.vue')['default']
GraphqlSidebar: typeof import('./components/graphql/Sidebar.vue')['default']
GraphqlType: typeof import('./components/graphql/Type.vue')['default']
GraphqlTypeLink: typeof import('./components/graphql/TypeLink.vue')['default']
History: typeof import('./components/history/index.vue')['default']
HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default']
HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default']
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartIntersection: typeof import('@hoppscotch/ui')['HoppSmartIntersection']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder']
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadio: typeof import('@hoppscotch/ui')['HoppSmartRadio']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
HoppSmartWindow: typeof import('@hoppscotch/ui')['HoppSmartWindow']
HoppSmartWindows: typeof import('@hoppscotch/ui')['HoppSmartWindows']
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
HttpAuthorizationApiKey: typeof import('./components/http/authorization/ApiKey.vue')['default']
HttpAuthorizationBasic: typeof import('./components/http/authorization/Basic.vue')['default']
HttpBody: typeof import('./components/http/Body.vue')['default']
HttpBodyParameters: typeof import('./components/http/BodyParameters.vue')['default']
HttpCodegenModal: typeof import('./components/http/CodegenModal.vue')['default']
HttpHeaders: typeof import('./components/http/Headers.vue')['default']
HttpImportCurl: typeof import('./components/http/ImportCurl.vue')['default']
HttpOAuth2Authorization: typeof import('./components/http/OAuth2Authorization.vue')['default']
HttpParameters: typeof import('./components/http/Parameters.vue')['default']
HttpPreRequestScript: typeof import('./components/http/PreRequestScript.vue')['default']
HttpRawBody: typeof import('./components/http/RawBody.vue')['default']
HttpReqChangeConfirmModal: typeof import('./components/http/ReqChangeConfirmModal.vue')['default']
HttpRequest: typeof import('./components/http/Request.vue')['default']
HttpRequestOptions: typeof import('./components/http/RequestOptions.vue')['default']
HttpRequestTab: typeof import('./components/http/RequestTab.vue')['default']
HttpResponse: typeof import('./components/http/Response.vue')['default']
HttpResponseMeta: typeof import('./components/http/ResponseMeta.vue')['default']
HttpSidebar: typeof import('./components/http/Sidebar.vue')['default']
HttpTabHead: typeof import('./components/http/TabHead.vue')['default']
HttpTestResult: typeof import('./components/http/TestResult.vue')['default']
HttpTestResultEntry: typeof import('./components/http/TestResultEntry.vue')['default']
HttpTestResultEnv: typeof import('./components/http/TestResultEnv.vue')['default']
HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default']
HttpTests: typeof import('./components/http/Tests.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
IconLucideActivity: typeof import('~icons/lucide/activity')['default']
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['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']
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideInfo: typeof import('~icons/lucide/info')['default']
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['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']
LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default']
LensesRenderersHTMLLensRenderer: typeof import('./components/lenses/renderers/HTMLLensRenderer.vue')['default']
LensesRenderersImageLensRenderer: typeof import('./components/lenses/renderers/ImageLensRenderer.vue')['default']
LensesRenderersJSONLensRenderer: typeof import('./components/lenses/renderers/JSONLensRenderer.vue')['default']
LensesRenderersPDFLensRenderer: typeof import('./components/lenses/renderers/PDFLensRenderer.vue')['default']
LensesRenderersRawLensRenderer: typeof import('./components/lenses/renderers/RawLensRenderer.vue')['default']
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.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']
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.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('./../../hoppscotch-ui/src/components/smart/Checkbox.vue')['default']
SmartColorModePicker: typeof import('./components/smart/ColorModePicker.vue')['default']
SmartConfirmModal: typeof import('./../../hoppscotch-ui/src/components/smart/ConfirmModal.vue')['default']
SmartEnvInput: typeof import('./components/smart/EnvInput.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']
SmartInput: typeof import('./../../hoppscotch-ui/src/components/smart/Input.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']
SmartPicture: typeof import('./../../hoppscotch-ui/src/components/smart/Picture.vue')['default']
SmartPlaceholder: typeof import('./../../hoppscotch-ui/src/components/smart/Placeholder.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('./../../hoppscotch-ui/src/components/smart/Tree.vue')['default']
SmartTreeBranch: typeof import('./../../hoppscotch-ui/src/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']
TeamsAdd: typeof import('./components/teams/Add.vue')['default']
TeamsEdit: typeof import('./components/teams/Edit.vue')['default']
TeamsInvite: typeof import('./components/teams/Invite.vue')['default']
TeamsMemberStack: typeof import('./components/teams/MemberStack.vue')['default']
TeamsModal: typeof import('./components/teams/Modal.vue')['default']
TeamsTeam: typeof import('./components/teams/Team.vue')['default']
Tippy: typeof import('vue-tippy')['Tippy']
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
AppActionHandler: typeof import("./components/app/ActionHandler.vue")["default"]
AppAnnouncement: typeof import("./components/app/Announcement.vue")["default"]
AppContextMenu: typeof import("./components/app/ContextMenu.vue")["default"]
AppDeveloperOptions: typeof import("./components/app/DeveloperOptions.vue")["default"]
AppFooter: typeof import("./components/app/Footer.vue")["default"]
AppGitHubStarButton: typeof import("./components/app/GitHubStarButton.vue")["default"]
AppHeader: typeof import("./components/app/Header.vue")["default"]
AppInspection: typeof import("./components/app/Inspection.vue")["default"]
AppInterceptor: typeof import("./components/app/Interceptor.vue")["default"]
AppLogo: typeof import("./components/app/Logo.vue")["default"]
AppOptions: typeof import("./components/app/Options.vue")["default"]
AppPaneLayout: typeof import("./components/app/PaneLayout.vue")["default"]
AppShare: typeof import("./components/app/Share.vue")["default"]
AppShortcuts: typeof import("./components/app/Shortcuts.vue")["default"]
AppShortcutsEntry: typeof import("./components/app/ShortcutsEntry.vue")["default"]
AppShortcutsPrompt: typeof import("./components/app/ShortcutsPrompt.vue")["default"]
AppSidenav: typeof import("./components/app/Sidenav.vue")["default"]
AppSocial: typeof import("./components/app/Social.vue")["default"]
AppSpotlight: typeof import("./components/app/spotlight/index.vue")["default"]
AppSpotlightEntry: typeof import("./components/app/spotlight/Entry.vue")["default"]
AppSpotlightEntryGQLHistory: typeof import("./components/app/spotlight/entry/GQLHistory.vue")["default"]
AppSpotlightEntryGQLRequest: typeof import("./components/app/spotlight/entry/GQLRequest.vue")["default"]
AppSpotlightEntryRESTHistory: typeof import("./components/app/spotlight/entry/RESTHistory.vue")["default"]
AppSpotlightEntryRESTRequest: typeof import("./components/app/spotlight/entry/RESTRequest.vue")["default"]
AppSupport: typeof import("./components/app/Support.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"]
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"]
CollectionsGraphql: typeof import("./components/collections/graphql/index.vue")["default"]
CollectionsGraphqlAdd: typeof import("./components/collections/graphql/Add.vue")["default"]
CollectionsGraphqlAddFolder: typeof import("./components/collections/graphql/AddFolder.vue")["default"]
CollectionsGraphqlAddRequest: typeof import("./components/collections/graphql/AddRequest.vue")["default"]
CollectionsGraphqlCollection: typeof import("./components/collections/graphql/Collection.vue")["default"]
CollectionsGraphqlEdit: typeof import("./components/collections/graphql/Edit.vue")["default"]
CollectionsGraphqlEditFolder: typeof import("./components/collections/graphql/EditFolder.vue")["default"]
CollectionsGraphqlEditRequest: typeof import("./components/collections/graphql/EditRequest.vue")["default"]
CollectionsGraphqlFolder: typeof import("./components/collections/graphql/Folder.vue")["default"]
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"]
CollectionsMyCollections: typeof import("./components/collections/MyCollections.vue")["default"]
CollectionsRequest: typeof import("./components/collections/Request.vue")["default"]
CollectionsSaveRequest: typeof import("./components/collections/SaveRequest.vue")["default"]
CollectionsTeamCollections: typeof import("./components/collections/TeamCollections.vue")["default"]
Environments: typeof import("./components/environments/index.vue")["default"]
EnvironmentsAdd: typeof import("./components/environments/Add.vue")["default"]
EnvironmentsImportExport: typeof import("./components/environments/ImportExport.vue")["default"]
EnvironmentsMy: typeof import("./components/environments/my/index.vue")["default"]
EnvironmentsMyDetails: typeof import("./components/environments/my/Details.vue")["default"]
EnvironmentsMyEnvironment: typeof import("./components/environments/my/Environment.vue")["default"]
EnvironmentsSelector: typeof import("./components/environments/Selector.vue")["default"]
EnvironmentsTeams: typeof import("./components/environments/teams/index.vue")["default"]
EnvironmentsTeamsDetails: typeof import("./components/environments/teams/Details.vue")["default"]
EnvironmentsTeamsEnvironment: typeof import("./components/environments/teams/Environment.vue")["default"]
FirebaseLogin: typeof import("./components/firebase/Login.vue")["default"]
FirebaseLogout: typeof import("./components/firebase/Logout.vue")["default"]
GraphqlAuthorization: typeof import("./components/graphql/Authorization.vue")["default"]
GraphqlField: typeof import("./components/graphql/Field.vue")["default"]
GraphqlHeaders: typeof import("./components/graphql/Headers.vue")["default"]
GraphqlQuery: typeof import("./components/graphql/Query.vue")["default"]
GraphqlRequest: typeof import("./components/graphql/Request.vue")["default"]
GraphqlRequestOptions: typeof import("./components/graphql/RequestOptions.vue")["default"]
GraphqlRequestTab: typeof import("./components/graphql/RequestTab.vue")["default"]
GraphqlResponse: typeof import("./components/graphql/Response.vue")["default"]
GraphqlSidebar: typeof import("./components/graphql/Sidebar.vue")["default"]
GraphqlSubscriptionLog: typeof import("./components/graphql/SubscriptionLog.vue")["default"]
GraphqlType: typeof import("./components/graphql/Type.vue")["default"]
GraphqlTypeLink: typeof import("./components/graphql/TypeLink.vue")["default"]
GraphqlVariable: typeof import("./components/graphql/Variable.vue")["default"]
History: typeof import("./components/history/index.vue")["default"]
HistoryGraphqlCard: typeof import("./components/history/graphql/Card.vue")["default"]
HistoryRestCard: typeof import("./components/history/rest/Card.vue")["default"]
HoppButtonPrimary: typeof import("@hoppscotch/ui")["HoppButtonPrimary"]
HoppButtonSecondary: typeof import("@hoppscotch/ui")["HoppButtonSecondary"]
HoppSmartAnchor: typeof import("@hoppscotch/ui")["HoppSmartAnchor"]
HoppSmartAutoComplete: typeof import("@hoppscotch/ui")["HoppSmartAutoComplete"]
HoppSmartCheckbox: typeof import("@hoppscotch/ui")["HoppSmartCheckbox"]
HoppSmartConfirmModal: typeof import("@hoppscotch/ui")["HoppSmartConfirmModal"]
HoppSmartExpand: typeof import("@hoppscotch/ui")["HoppSmartExpand"]
HoppSmartFileChip: typeof import("@hoppscotch/ui")["HoppSmartFileChip"]
HoppSmartInput: typeof import("@hoppscotch/ui")["HoppSmartInput"]
HoppSmartIntersection: typeof import("@hoppscotch/ui")["HoppSmartIntersection"]
HoppSmartItem: typeof import("@hoppscotch/ui")["HoppSmartItem"]
HoppSmartLink: typeof import("@hoppscotch/ui")["HoppSmartLink"]
HoppSmartModal: typeof import("@hoppscotch/ui")["HoppSmartModal"]
HoppSmartPicture: typeof import("@hoppscotch/ui")["HoppSmartPicture"]
HoppSmartPlaceholder: typeof import("@hoppscotch/ui")["HoppSmartPlaceholder"]
HoppSmartProgressRing: typeof import("@hoppscotch/ui")["HoppSmartProgressRing"]
HoppSmartRadio: typeof import("@hoppscotch/ui")["HoppSmartRadio"]
HoppSmartRadioGroup: typeof import("@hoppscotch/ui")["HoppSmartRadioGroup"]
HoppSmartSlideOver: typeof import("@hoppscotch/ui")["HoppSmartSlideOver"]
HoppSmartSpinner: typeof import("@hoppscotch/ui")["HoppSmartSpinner"]
HoppSmartTab: typeof import("@hoppscotch/ui")["HoppSmartTab"]
HoppSmartTabs: typeof import("@hoppscotch/ui")["HoppSmartTabs"]
HoppSmartToggle: typeof import("@hoppscotch/ui")["HoppSmartToggle"]
HoppSmartTree: typeof import("@hoppscotch/ui")["HoppSmartTree"]
HoppSmartWindow: typeof import("@hoppscotch/ui")["HoppSmartWindow"]
HoppSmartWindows: typeof import("@hoppscotch/ui")["HoppSmartWindows"]
HttpAuthorization: typeof import("./components/http/Authorization.vue")["default"]
HttpAuthorizationApiKey: typeof import("./components/http/authorization/ApiKey.vue")["default"]
HttpAuthorizationBasic: typeof import("./components/http/authorization/Basic.vue")["default"]
HttpBody: typeof import("./components/http/Body.vue")["default"]
HttpBodyParameters: typeof import("./components/http/BodyParameters.vue")["default"]
HttpCodegenModal: typeof import("./components/http/CodegenModal.vue")["default"]
HttpHeaders: typeof import("./components/http/Headers.vue")["default"]
HttpImportCurl: typeof import("./components/http/ImportCurl.vue")["default"]
HttpOAuth2Authorization: typeof import("./components/http/OAuth2Authorization.vue")["default"]
HttpParameters: typeof import("./components/http/Parameters.vue")["default"]
HttpPreRequestScript: typeof import("./components/http/PreRequestScript.vue")["default"]
HttpRawBody: typeof import("./components/http/RawBody.vue")["default"]
HttpReqChangeConfirmModal: typeof import("./components/http/ReqChangeConfirmModal.vue")["default"]
HttpRequest: typeof import("./components/http/Request.vue")["default"]
HttpRequestOptions: typeof import("./components/http/RequestOptions.vue")["default"]
HttpRequestTab: typeof import("./components/http/RequestTab.vue")["default"]
HttpResponse: typeof import("./components/http/Response.vue")["default"]
HttpResponseMeta: typeof import("./components/http/ResponseMeta.vue")["default"]
HttpSidebar: typeof import("./components/http/Sidebar.vue")["default"]
HttpTabHead: typeof import("./components/http/TabHead.vue")["default"]
HttpTestResult: typeof import("./components/http/TestResult.vue")["default"]
HttpTestResultEntry: typeof import("./components/http/TestResultEntry.vue")["default"]
HttpTestResultEnv: typeof import("./components/http/TestResultEnv.vue")["default"]
HttpTestResultReport: typeof import("./components/http/TestResultReport.vue")["default"]
HttpTests: typeof import("./components/http/Tests.vue")["default"]
HttpURLEncodedParams: typeof import("./components/http/URLEncodedParams.vue")["default"]
IconLucideActivity: typeof import("~icons/lucide/activity")["default"]
IconLucideAlertTriangle: typeof import("~icons/lucide/alert-triangle")["default"]
IconLucideArrowLeft: typeof import("~icons/lucide/arrow-left")["default"]
IconLucideArrowUpRight: typeof import("~icons/lucide/arrow-up-right")["default"]
IconLucideCheckCircle: typeof import("~icons/lucide/check-circle")["default"]
IconLucideChevronRight: typeof import("~icons/lucide/chevron-right")["default"]
IconLucideGlobe: typeof import("~icons/lucide/globe")["default"]
IconLucideHelpCircle: typeof import("~icons/lucide/help-circle")["default"]
IconLucideInbox: typeof import("~icons/lucide/inbox")["default"]
IconLucideInfo: typeof import("~icons/lucide/info")["default"]
IconLucideLayers: typeof import("~icons/lucide/layers")["default"]
IconLucideListEnd: typeof import("~icons/lucide/list-end")["default"]
IconLucideMinus: typeof import("~icons/lucide/minus")["default"]
IconLucideSearch: typeof import("~icons/lucide/search")["default"]
IconLucideUsers: typeof import("~icons/lucide/users")["default"]
IconLucideVerified: typeof import("~icons/lucide/verified")["default"]
InterceptorsExtensionSubtitle: typeof import("./components/interceptors/ExtensionSubtitle.vue")["default"]
LensesHeadersRenderer: typeof import("./components/lenses/HeadersRenderer.vue")["default"]
LensesHeadersRendererEntry: typeof import("./components/lenses/HeadersRendererEntry.vue")["default"]
LensesRenderersAudioLensRenderer: typeof import("./components/lenses/renderers/AudioLensRenderer.vue")["default"]
LensesRenderersHTMLLensRenderer: typeof import("./components/lenses/renderers/HTMLLensRenderer.vue")["default"]
LensesRenderersImageLensRenderer: typeof import("./components/lenses/renderers/ImageLensRenderer.vue")["default"]
LensesRenderersJSONLensRenderer: typeof import("./components/lenses/renderers/JSONLensRenderer.vue")["default"]
LensesRenderersPDFLensRenderer: typeof import("./components/lenses/renderers/PDFLensRenderer.vue")["default"]
LensesRenderersRawLensRenderer: typeof import("./components/lenses/renderers/RawLensRenderer.vue")["default"]
LensesRenderersVideoLensRenderer: typeof import("./components/lenses/renderers/VideoLensRenderer.vue")["default"]
LensesRenderersXMLLensRenderer: typeof import("./components/lenses/renderers/XMLLensRenderer.vue")["default"]
LensesResponseBodyRenderer: typeof import("./components/lenses/ResponseBodyRenderer.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"]
SettingsExtension: typeof import("./components/settings/Extension.vue")["default"]
SettingsProxy: typeof import("./components/settings/Proxy.vue")["default"]
SmartAccentModePicker: typeof import("./components/smart/AccentModePicker.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("./../../hoppscotch-ui/src/components/smart/Checkbox.vue")["default"]
SmartColorModePicker: typeof import("./components/smart/ColorModePicker.vue")["default"]
SmartConfirmModal: typeof import("./../../hoppscotch-ui/src/components/smart/ConfirmModal.vue")["default"]
SmartEnvInput: typeof import("./components/smart/EnvInput.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"]
SmartInput: typeof import("./../../hoppscotch-ui/src/components/smart/Input.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"]
SmartPicture: typeof import("./../../hoppscotch-ui/src/components/smart/Picture.vue")["default"]
SmartPlaceholder: typeof import("./../../hoppscotch-ui/src/components/smart/Placeholder.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("./../../hoppscotch-ui/src/components/smart/Tree.vue")["default"]
SmartTreeBranch: typeof import("./../../hoppscotch-ui/src/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"]
TeamsAdd: typeof import("./components/teams/Add.vue")["default"]
TeamsEdit: typeof import("./components/teams/Edit.vue")["default"]
TeamsInvite: typeof import("./components/teams/Invite.vue")["default"]
TeamsMemberStack: typeof import("./components/teams/MemberStack.vue")["default"]
TeamsModal: typeof import("./components/teams/Modal.vue")["default"]
TeamsTeam: typeof import("./components/teams/Team.vue")["default"]
Tippy: typeof import("vue-tippy")["Tippy"]
WorkspaceCurrent: typeof import("./components/workspace/Current.vue")["default"]
WorkspaceSelector: typeof import("./components/workspace/Selector.vue")["default"]
}
}

View File

@@ -254,8 +254,10 @@ import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { onLoggedIn } from "~/composables/auth"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
import { useToast } from "~/composables/toast"
const t = useI18n()
const toast = useToast()
/**
* Once the PWA code is initialized, this holds a method
@@ -372,6 +374,8 @@ const handleTeamEdit = () => {
editingTeamID.value = workspace.value.teamID
editingTeamName.value = { name: selectedTeam.value.name }
displayModalEdit(true)
} else {
noPermission()
}
}
@@ -382,12 +386,7 @@ const settings = ref<any | null>(null)
const logout = ref<any | null>(null)
const accountActions = ref<any | null>(null)
defineActionHandler("modals.team.edit", () => {
// TODO: Remove this hack
setTimeout(() => {
handleTeamEdit()
}, 100)
})
defineActionHandler("modals.team.edit", handleTeamEdit)
defineActionHandler("modals.team.invite", () => {
if (
@@ -395,6 +394,8 @@ defineActionHandler("modals.team.invite", () => {
selectedTeam.value?.myRole === "EDITOR"
) {
inviteTeam({ name: selectedTeam.value.name }, selectedTeam.value.id)
} else {
noPermission()
}
})
@@ -405,4 +406,8 @@ defineActionHandler(
},
computed(() => !currentUser.value)
)
const noPermission = () => {
toast.error(`${t("profile.no_permission")}`)
}
</script>

View File

@@ -18,13 +18,18 @@
:horizontal="COLUMN_LAYOUT"
@resize="setPaneEvent($event, 'horizontal')"
>
<Pane :size="PANE_MAIN_TOP_SIZE" class="flex flex-col !overflow-auto">
<Pane
:size="PANE_MAIN_TOP_SIZE"
class="flex flex-col !overflow-auto"
min-size="25"
>
<slot name="primary" />
</Pane>
<Pane
v-if="hasSecondary"
:size="PANE_MAIN_BOTTOM_SIZE"
class="flex flex-col !overflow-auto"
min-size="25"
>
<slot name="secondary" />
</Pane>
@@ -33,7 +38,7 @@
<Pane
v-if="SIDEBAR && hasSidebar"
:size="PANE_SIDEBAR_SIZE"
min-size="20"
min-size="25"
class="flex flex-col !overflow-auto bg-primaryContrast"
>
<slot name="sidebar" />
@@ -78,10 +83,10 @@ type PaneEvent = {
size: number
}
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)
const PANE_MAIN_SIZE = ref(70)
const PANE_SIDEBAR_SIZE = ref(30)
const PANE_MAIN_TOP_SIZE = ref(35)
const PANE_MAIN_BOTTOM_SIZE = ref(65)
if (!COLUMN_LAYOUT.value) {
PANE_MAIN_TOP_SIZE.value = 50

View File

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

View File

@@ -71,7 +71,6 @@ import {
updateTeamRequest,
} from "~/helpers/backend/mutations/TeamRequest"
import { Picked } from "~/helpers/types/HoppPicked"
import { getGQLSession, useGQLRequestName } from "~/newstore/GQLSession"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import {
@@ -82,8 +81,9 @@ import {
} from "~/newstore/collections"
import { GQLError } from "~/helpers/backend/GQLClient"
import { computedWithControl } from "@vueuse/core"
import { currentActiveTab } from "~/helpers/rest/tab"
import { platform } from "~/platform"
import { currentActiveTab as activeRESTTab } from "~/helpers/rest/tab"
import { currentActiveTab as activeGQLTab } from "~/helpers/graphql/tab"
const t = useI18n()
const toast = useToast()
@@ -122,10 +122,14 @@ const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const gqlRequestName = useGQLRequestName()
const gqlRequestName = computedWithControl(
() => activeGQLTab.value,
() => activeGQLTab.value.document.request.name
)
const restRequestName = computedWithControl(
() => currentActiveTab.value,
() => currentActiveTab.value.document.request.name
() => activeRESTTab.value,
() => activeRESTTab.value.document.request.name
)
const reqName = computed(() => {
@@ -141,11 +145,13 @@ const reqName = computed(() => {
const requestName = ref(reqName.value)
watch(
() => [currentActiveTab.value, gqlRequestName.value],
() => [activeRESTTab.value, activeGQLTab.value],
() => {
if (props.mode === "rest") {
requestName.value = currentActiveTab.value?.document.request.name ?? ""
} else requestName.value = gqlRequestName.value
requestName.value = activeRESTTab.value?.document.request.name ?? ""
} else {
requestName.value = activeGQLTab.value?.document.request.name ?? ""
}
}
)
@@ -202,15 +208,10 @@ const saveRequestAs = async () => {
return
}
let requestUpdated
if (props.request) {
requestUpdated = cloneDeep(props.request)
} else if (props.mode === "rest") {
requestUpdated = cloneDeep(currentActiveTab.value.document.request)
} else {
requestUpdated = cloneDeep(getGQLSession().request)
}
const requestUpdated =
props.mode === "rest"
? cloneDeep(activeRESTTab.value.document.request)
: cloneDeep(activeGQLTab.value.document.request)
requestUpdated.name = requestName.value
@@ -223,7 +224,7 @@ const saveRequestAs = async () => {
requestUpdated
)
currentActiveTab.value.document = {
activeRESTTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
@@ -250,7 +251,7 @@ const saveRequestAs = async () => {
requestUpdated
)
currentActiveTab.value.document = {
activeRESTTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
@@ -278,7 +279,7 @@ const saveRequestAs = async () => {
requestUpdated
)
currentActiveTab.value.document = {
activeRESTTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
@@ -438,7 +439,7 @@ const updateTeamCollectionOrFolder = (
(result) => {
const { createRequestInCollection } = result
currentActiveTab.value.document = {
activeRESTTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
@@ -459,7 +460,7 @@ const updateTeamCollectionOrFolder = (
const requestSaved = () => {
toast.success(`${t("request.added")}`)
nextTick(() => {
currentActiveTab.value.document.isDirty = false
activeRESTTab.value.document.isDirty = false
})
hideModal()
}

View File

@@ -36,7 +36,7 @@
import { ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { getGQLSession } from "~/newstore/GQLSession"
import { currentActiveTab } from "~/helpers/graphql/tab"
const toast = useToast()
const t = useI18n()
@@ -63,7 +63,7 @@ watch(
() => props.show,
(show) => {
if (show) {
editingName.value = getGQLSession().request.name
editingName.value = currentActiveTab.value?.document.request.name
}
}
)

View File

@@ -132,7 +132,7 @@ import { useToast } from "@composables/toast"
import { HoppGQLRequest, makeGQLRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import { removeGraphqlRequest } from "~/newstore/collections"
import { setGQLSession } from "~/newstore/GQLSession"
import { createNewTab } from "~/helpers/graphql/tab"
// Template refs
const tippyActions = ref<any | null>(null)
@@ -179,7 +179,7 @@ const selectRequest = () => {
if (props.saveRequest) {
pick()
} else {
setGQLSession({
createNewTab({
request: cloneDeep(
makeGQLRequest({
name: props.request.name,
@@ -190,8 +190,7 @@ const selectRequest = () => {
auth: props.request.auth,
})
),
schema: "",
response: "",
isDirty: false,
})
}
}

View File

@@ -137,7 +137,6 @@ import {
addGraphqlFolder,
saveGraphqlRequestAs,
} from "~/newstore/collections"
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
@@ -146,6 +145,7 @@ import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming"
import { platform } from "~/platform"
import { createNewTab, currentActiveTab } from "~/helpers/graphql/tab"
export default defineComponent({
props: {
@@ -267,15 +267,15 @@ export default defineComponent({
},
onAddRequest({ name, path }) {
const newRequest = {
...getGQLSession().request,
...currentActiveTab.value.document.request,
name,
}
saveGraphqlRequestAs(path, newRequest)
setGQLSession({
createNewTab({
request: newRequest,
schema: "",
response: "",
isDirty: false,
})
platform.analytics?.logEvent({

View File

@@ -19,11 +19,12 @@
>
<WorkspaceCurrent :section="t('tab.collections')" />
<input
<HoppSmartInput
v-model="filterTexts"
:placeholder="t('action.search')"
class="py-2 pl-4 pr-2 bg-transparent !border-0"
input-styles="py-2 pl-4 pr-2 bg-transparent !border-0"
type="search"
:autofocus="false"
:disabled="collectionsType.type === 'team-collections'"
/>
</div>

View File

@@ -81,7 +81,6 @@
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
class="mb-4"
@click="addEnvironmentVariable"
/>
</HoppSmartPlaceholder>

View File

@@ -42,7 +42,6 @@
:label="`${t('add.new')}`"
filled
outline
class="mb-4"
@click="displayModalAdd(true)"
/>
</HoppSmartPlaceholder>

View File

@@ -86,13 +86,11 @@
disabled
:label="`${t('add.new')}`"
filled
class="mb-4"
/>
<HoppButtonSecondary
v-else
:label="`${t('add.new')}`"
filled
class="mb-4"
@click="addEnvironmentVariable"
/>
</HoppSmartPlaceholder>

View File

@@ -54,7 +54,6 @@
v-tippy="{ theme: 'tooltip' }"
disabled
filled
class="mb-4"
:icon="IconPlus"
:title="t('team.no_access')"
:label="t('action.new')"
@@ -64,7 +63,6 @@
:label="`${t('add.new')}`"
filled
outline
class="mb-4"
@click="displayModalAdd(true)"
/>
</HoppSmartPlaceholder>

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col flex-1">
<div
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight"
>
<span class="flex items-center">
<label class="font-semibold truncate text-secondaryLight">
@@ -32,7 +32,7 @@
:active="authName === 'None'"
@click="
() => {
authType = 'none'
auth.authType = 'none'
hide()
}
"
@@ -43,7 +43,7 @@
:active="authName === 'Basic Auth'"
@click="
() => {
authType = 'basic'
auth.authType = 'basic'
hide()
}
"
@@ -54,7 +54,7 @@
:active="authName === 'Bearer'"
@click="
() => {
authType = 'bearer'
auth.authType = 'bearer'
hide()
}
"
@@ -65,7 +65,7 @@
:active="authName === 'OAuth 2.0'"
@click="
() => {
authType = 'oauth-2'
auth.authType = 'oauth-2'
hide()
}
"
@@ -76,7 +76,7 @@
:active="authName === 'API key'"
@click="
() => {
authType = 'api-key'
auth.authType = 'api-key'
hide()
}
"
@@ -90,8 +90,8 @@
:on="!URLExcludes.auth"
@change="setExclude('auth', !$event)"
>
{{ t("authorization.include_in_url") }}
</HoppSmartCheckbox> -->
{{ $t("authorization.include_in_url") }}
</HoppSmartCheckbox>-->
<HoppSmartCheckbox
:on="authActive"
class="px-2"
@@ -115,7 +115,7 @@
</div>
</div>
<HoppSmartPlaceholder
v-if="authType === 'none'"
v-if="auth.authType === 'none'"
:src="`/images/states/${colorMode.value}/login.svg`"
:alt="`${t('empty.authorization')}`"
:text="t('empty.authorization')"
@@ -127,114 +127,47 @@
blank
:icon="IconExternalLink"
reverse
class="mb-4"
/>
</HoppSmartPlaceholder>
<div v-else class="flex flex-1 border-b border-dividerLight">
<div class="w-2/3 border-r border-dividerLight">
<div v-if="authType === 'basic'">
<div v-if="auth.authType === 'basic'">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="basicUsername"
v-model="auth.username"
:environment-highlights="false"
:placeholder="t('authorization.username')"
/>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="basicPassword"
v-model="auth.password"
:environment-highlights="false"
:placeholder="t('authorization.password')"
/>
</div>
</div>
<div v-if="authType === 'bearer'">
<div v-if="auth.authType === 'bearer'">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="bearerToken"
v-model="auth.token"
:environment-highlights="false"
placeholder="Token"
/>
</div>
</div>
<div v-if="authType === 'oauth-2'">
<div v-if="auth.authType === 'oauth-2'">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="oauth2Token"
v-model="auth.token"
:environment-highlights="false"
placeholder="Token"
/>
</div>
<HttpOAuth2Authorization />
<HttpOAuth2Authorization v-model="auth" />
</div>
<div v-if="authType === 'api-key'">
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="apiKey"
:environment-highlights="false"
placeholder="Key"
/>
</div>
<div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput
v-model="apiValue"
:environment-highlights="false"
placeholder="Value"
/>
</div>
<div class="flex items-center border-b border-dividerLight">
<span class="flex items-center">
<label class="ml-4 text-secondaryLight">
{{ t("authorization.pass_key_by") }}
</label>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => authTippyActions.focus()"
>
<span class="select-wrapper">
<HoppButtonSecondary
:label="addTo || t('state.none')"
class="pr-8 ml-2 rounded-none"
/>
</span>
<template #content="{ hide }">
<div
ref="authTippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="addTo === 'Headers' ? IconCircleDot : IconCircle"
:active="addTo === 'Headers'"
:label="'Headers'"
@click="
() => {
addTo = 'Headers'
hide()
}
"
/>
<HoppSmartItem
:icon="
addTo === 'Query params' ? IconCircleDot : IconCircle
"
:active="addTo === 'Query params'"
:label="'Query params'"
@click="
() => {
addTo = 'Query params'
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
<div v-if="auth.authType === 'api-key'">
<HttpAuthorizationApiKey v-model="auth" />
</div>
</div>
<div
@@ -257,55 +190,45 @@
</template>
<script setup lang="ts">
import { computed, ref, Ref } from "vue"
import {
HoppGQLAuthAPIKey,
HoppGQLAuthBasic,
HoppGQLAuthBearer,
HoppGQLAuthOAuth2,
} from "@hoppscotch/data"
import { pluckRef } from "@composables/ref"
import { useStream } from "@composables/stream"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { gqlAuth$, setGQLAuth } from "~/newstore/GQLSession"
import IconTrash2 from "~icons/lucide/trash-2"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconTrash2 from "~icons/lucide/trash-2"
import IconExternalLink from "~icons/lucide/external-link"
import IconCircleDot from "~icons/lucide/circle-dot"
import IconCircle from "~icons/lucide/circle"
import { computed, ref } from "vue"
import { HoppGQLAuth } from "@hoppscotch/data"
import { pluckRef } from "@composables/ref"
import { useI18n } from "@composables/i18n"
import { useColorMode } from "@composables/theming"
import { useVModel } from "@vueuse/core"
const t = useI18n()
const colorMode = useColorMode()
const auth = useStream(
gqlAuth$,
{ authType: "none", authActive: true },
setGQLAuth
)
const props = defineProps<{
modelValue: HoppGQLAuth
}>()
const emit = defineEmits<{
(e: "update:modelValue", value: HoppGQLAuth): void
}>()
const auth = useVModel(props, "modelValue", emit)
const AUTH_KEY_NAME = {
basic: "Basic Auth",
bearer: "Bearer",
"oauth-2": "OAuth 2.0",
"api-key": "API key",
none: "None",
} as const
const authType = pluckRef(auth, "authType")
const authName = computed(() => {
if (authType.value === "basic") return "Basic Auth"
else if (authType.value === "bearer") return "Bearer"
else if (authType.value === "oauth-2") return "OAuth 2.0"
else if (authType.value === "api-key") return "API key"
else return "None"
})
const authName = computed(() =>
AUTH_KEY_NAME[authType.value] ? AUTH_KEY_NAME[authType.value] : "None"
)
const authActive = pluckRef(auth, "authActive")
const basicUsername = pluckRef(auth as Ref<HoppGQLAuthBasic>, "username")
const basicPassword = pluckRef(auth as Ref<HoppGQLAuthBasic>, "password")
const bearerToken = pluckRef(auth as Ref<HoppGQLAuthBearer>, "token")
const oauth2Token = pluckRef(auth as Ref<HoppGQLAuthOAuth2>, "token")
const apiKey = pluckRef(auth as Ref<HoppGQLAuthAPIKey>, "key")
const apiValue = pluckRef(auth as Ref<HoppGQLAuthAPIKey>, "value")
const addTo = pluckRef(auth as Ref<HoppGQLAuthAPIKey>, "addTo")
if (typeof addTo.value === "undefined") {
addTo.value = "Headers"
apiKey.value = ""
apiValue.value = ""
}
const clearContent = () => {
auth.value = {
@@ -316,5 +239,4 @@ const clearContent = () => {
// Template refs
const tippyActions = ref<any | null>(null)
const authTippyActions = ref<any | null>(null)
</script>

View File

@@ -0,0 +1,430 @@
<template>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight"
>
<label class="font-semibold text-secondaryLight">
{{ t("tab.headers") }}
</label>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="IconTrash2"
@click="clearContent()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.bulk_mode')"
:icon="IconEdit"
:class="{ '!text-accent': bulkMode }"
@click="bulkMode = !bulkMode"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('add.new')"
:icon="IconPlus"
:disabled="bulkMode"
@click="addHeader"
/>
</div>
</div>
<div v-if="bulkMode" ref="bulkEditor" class="flex flex-col flex-1"></div>
<div v-else>
<draggable
v-model="workingHeaders"
:item-key="(header: any) => `header-${header.id}`"
animation="250"
handle=".draggable-handle"
draggable=".draggable-content"
ghost-class="cursor-move"
chosen-class="bg-primaryLight"
drag-class="cursor-grabbing"
>
<template #item="{ element: header, index }">
<div
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
>
<span>
<HoppButtonSecondary
v-tippy="{
theme: 'tooltip',
delay: [500, 20],
content:
index !== workingHeaders?.length - 1
? t('action.drag_to_reorder')
: null,
}"
:icon="IconGripVertical"
class="cursor-auto text-primary hover:text-primary"
:class="{
'draggable-handle group-hover:text-secondaryLight !cursor-grab':
index !== workingHeaders?.length - 1,
}"
tabindex="-1"
/>
</span>
<HoppSmartAutoComplete
:placeholder="`${t('count.header', { count: index + 1 })}`"
:source="commonHeaders"
:spellcheck="false"
:value="header.key"
autofocus
styles="
bg-transparent
flex
flex-1
py-1
px-4
truncate
"
class="flex-1 !flex"
@input="
updateHeader(index, {
id: header.id,
key: $event,
value: header.value,
active: header.active,
})
"
/>
<input
class="flex flex-1 px-4 py-2 bg-transparent"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:name="`value ${String(index)}`"
:value="header.value"
autofocus
@change="
updateHeader(index, {
id: header.id,
key: header.key,
value: ($event!.target! as HTMLInputElement).value,
active: header.active,
})
"
/>
<span>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
header.hasOwnProperty('active')
? header.active
? t('action.turn_off')
: t('action.turn_on')
: t('action.turn_off')
"
:icon="
header.hasOwnProperty('active')
? header.active
? IconCheckCircle
: IconCircle
: IconCheckCircle
"
color="green"
@click="
updateHeader(index, {
id: header.id,
key: header.key,
value: header.value,
active: !header.active,
})
"
/>
</span>
<span>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="deleteHeader(index)"
/>
</span>
</div>
</template>
</draggable>
<HoppSmartPlaceholder
v-if="workingHeaders.length === 0"
:src="`/images/states/${colorMode.value}/add_category.svg`"
:alt="`${t('empty.headers')}`"
:text="t('empty.headers')"
>
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
:icon="IconPlus"
@click="addHeader"
/>
</HoppSmartPlaceholder>
</div>
</template>
<script setup lang="ts">
import IconHelpCircle from "~icons/lucide/help-circle"
import IconTrash2 from "~icons/lucide/trash-2"
import IconEdit from "~icons/lucide/edit"
import IconPlus from "~icons/lucide/plus"
import IconGripVertical from "~icons/lucide/grip-vertical"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconTrash from "~icons/lucide/trash"
import IconCircle from "~icons/lucide/circle"
import IconWrapText from "~icons/lucide/wrap-text"
import { reactive, ref, watch } from "vue"
import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import * as RA from "fp-ts/ReadonlyArray"
import { pipe, flow } from "fp-ts/function"
import {
GQLHeader,
rawKeyValueEntriesToString,
parseRawKeyValueEntriesE,
RawKeyValueEntry,
HoppGQLRequest,
} from "@hoppscotch/data"
import draggable from "vuedraggable-es"
import { clone, cloneDeep, isEqual } from "lodash-es"
import { useColorMode } from "@composables/theming"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { commonHeaders } from "~/helpers/headers"
import { useCodemirror } from "@composables/codemirror"
import { objRemoveKey } from "~/helpers/functional/object"
import { useVModel } from "@vueuse/core"
const colorMode = useColorMode()
const t = useI18n()
const toast = useToast()
// v-model integration with props and emit
const props = defineProps<{ modelValue: HoppGQLRequest }>()
const emit = defineEmits<{
(e: "update:modelValue", value: HoppGQLRequest): void
}>()
const request = useVModel(props, "modelValue", emit)
const idTicker = ref(0)
const linewrapEnabled = ref(false)
const bulkMode = ref(false)
const bulkHeaders = ref("")
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
const bulkEditor = ref<any | null>(null)
useCodemirror(
bulkEditor,
bulkHeaders,
reactive({
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,
environmentHighlights: false,
})
)
// The UI representation of the headers list (has the empty end header)
const workingHeaders = ref<Array<GQLHeader & { id: number }>>([
{
id: idTicker.value++,
key: "",
value: "",
active: true,
},
])
// Rule: Working Headers always have one empty header or the last element is always an empty header
watch(workingHeaders, (headersList) => {
if (
headersList.length > 0 &&
headersList[headersList.length - 1].key !== ""
) {
workingHeaders.value.push({
id: idTicker.value++,
key: "",
value: "",
active: true,
})
}
})
// Sync logic between headers and working headers
watch(
props.modelValue.headers,
(newHeadersList) => {
// Sync should overwrite working headers
const filteredWorkingHeaders = pipe(
workingHeaders.value,
A.filterMap(
flow(
O.fromPredicate((e) => e.key !== ""),
O.map(objRemoveKey("id"))
)
)
)
const filteredBulkHeaders = pipe(
parseRawKeyValueEntriesE(bulkHeaders.value),
E.map(
flow(
RA.filter((e) => e.key !== ""),
RA.toArray
)
),
E.getOrElse(() => [] as RawKeyValueEntry[])
)
if (!isEqual(newHeadersList, filteredWorkingHeaders)) {
workingHeaders.value = pipe(
newHeadersList,
A.map((x) => ({ id: idTicker.value++, ...x }))
)
}
if (!isEqual(newHeadersList, filteredBulkHeaders)) {
bulkHeaders.value = rawKeyValueEntriesToString(newHeadersList)
}
},
{ immediate: true }
)
watch(workingHeaders, (newWorkingHeaders) => {
const fixedHeaders = pipe(
newWorkingHeaders,
A.filterMap(
flow(
O.fromPredicate((e) => e.key !== ""),
O.map(objRemoveKey("id"))
)
)
)
if (!isEqual(request.value.headers, fixedHeaders)) {
request.value.headers = cloneDeep(fixedHeaders)
}
})
// Bulk Editor Syncing with Working Headers
watch(bulkHeaders, (newBulkHeaders) => {
const filteredBulkHeaders = pipe(
parseRawKeyValueEntriesE(newBulkHeaders),
E.map(
flow(
RA.filter((e) => e.key !== ""),
RA.toArray
)
),
E.getOrElse(() => [] as RawKeyValueEntry[])
)
if (!isEqual(request.value.headers, filteredBulkHeaders)) {
request.value.headers = filteredBulkHeaders
}
})
watch(workingHeaders, (newHeadersList) => {
// If we are in bulk mode, don't apply direct changes
if (bulkMode.value) return
try {
const currentBulkHeaders = bulkHeaders.value.split("\n").map((item) => ({
key: item.substring(0, item.indexOf(":")).trimLeft().replace(/^#/, ""),
value: item.substring(item.indexOf(":") + 1).trimLeft(),
active: !item.trim().startsWith("#"),
}))
const filteredHeaders = newHeadersList.filter((x) => x.key !== "")
if (!isEqual(currentBulkHeaders, filteredHeaders)) {
bulkHeaders.value = rawKeyValueEntriesToString(filteredHeaders)
}
} catch (e) {
toast.error(`${t("error.something_went_wrong")}`)
console.error(e)
}
})
const addHeader = () => {
workingHeaders.value.push({
id: idTicker.value++,
key: "",
value: "",
active: true,
})
}
const updateHeader = (index: number, header: GQLHeader & { id: number }) => {
workingHeaders.value = workingHeaders.value.map((h, i) =>
i === index ? header : h
)
}
const deleteHeader = (index: number) => {
const headersBeforeDeletion = clone(workingHeaders.value)
if (
!(
headersBeforeDeletion.length > 0 &&
index === headersBeforeDeletion.length - 1
)
) {
if (deletionToast.value) {
deletionToast.value.goAway(0)
deletionToast.value = null
}
deletionToast.value = toast.success(`${t("state.deleted")}`, {
action: [
{
text: `${t("action.undo")}`,
onClick: (_: any, toastObject: any) => {
workingHeaders.value = headersBeforeDeletion
toastObject.goAway(0)
deletionToast.value = null
},
},
],
onComplete: () => {
deletionToast.value = null
},
})
}
workingHeaders.value.splice(index, 1)
}
const clearContent = () => {
// set headers list to the initial state
workingHeaders.value = [
{
id: idTicker.value++,
key: "",
value: "",
active: true,
},
]
bulkHeaders.value = ""
}
</script>

View File

@@ -0,0 +1,237 @@
<template>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight gqlRunQuery"
>
<label class="font-semibold text-secondaryLight">
{{ t("request.query") }}
</label>
<div class="flex">
<HoppButtonSecondary
v-if="subscriptionState === 'SUBSCRIBED'"
v-tippy="{
theme: 'tooltip',
delay: [500, 20],
allowHTML: true,
}"
:title="`${t('request.stop')}`"
:label="`${t('request.stop')}`"
:icon="IconStop"
class="rounded-none !text-accent !hover:text-accentDark"
@click="unsubscribe()"
/>
<HoppButtonSecondary
v-if="selectedOperation && subscriptionState !== 'SUBSCRIBED'"
v-tippy="{
theme: 'tooltip',
delay: [500, 20],
allowHTML: true,
}"
:title="`${t('request.run')} <kbd>${getSpecialKey()}</kbd><kbd>G</kbd>`"
:label="`${selectedOperation.name?.value ?? t('request.run')}`"
:icon="IconPlay"
:disabled="!selectedOperation"
class="rounded-none !text-accent !hover:text-accentDark"
@click="runQuery(selectedOperation)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
:title="`${t(
'request.save'
)} <kbd>${getSpecialKey()}</kbd><kbd>S</kbd>`"
:label="`${t('request.save')}`"
:icon="IconSave"
class="rounded-none"
@click="saveRequest"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="IconTrash2"
@click="clearGQLQuery()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.prettify')"
:icon="prettifyQueryIcon"
@click="prettifyQuery"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyQueryIcon"
@click="copyQuery"
/>
</div>
</div>
<div ref="queryEditor" class="flex flex-col flex-1"></div>
</template>
<script setup lang="ts">
import IconPlay from "~icons/lucide/play"
import IconStop from "~icons/lucide/stop-circle"
import IconSave from "~icons/lucide/save"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconTrash2 from "~icons/lucide/trash-2"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import IconInfo from "~icons/lucide/info"
import IconWand from "~icons/lucide/wand"
import IconWrapText from "~icons/lucide/wrap-text"
import { onMounted, reactive, ref, markRaw } from "vue"
import { copyToClipboard } from "@helpers/utils/clipboard"
import { useCodemirror } from "@composables/codemirror"
import { useI18n } from "@composables/i18n"
import { refAutoReset, useVModel } from "@vueuse/core"
import { useToast } from "~/composables/toast"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import * as gql from "graphql"
import { createGQLQueryLinter } from "~/helpers/editor/linting/gqlQuery"
import queryCompleter from "~/helpers/editor/completion/gqlQuery"
import { selectedGQLOpHighlight } from "~/helpers/editor/gql/operation"
import { debounce } from "lodash-es"
import { ViewUpdate } from "@codemirror/view"
import { defineActionHandler } from "~/helpers/actions"
import {
schema,
socketDisconnect,
subscriptionState,
} from "~/helpers/graphql/connection"
// Template refs
const queryEditor = ref<any | null>(null)
const t = useI18n()
const toast = useToast()
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: "save-request"): void
(e: "update:modelValue", val: string): void
(e: "run-query", definition: gql.OperationDefinitionNode | null): void
}>()
const copyQueryIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const prettifyQueryIcon = refAutoReset<
typeof IconWand | typeof IconCheck | typeof IconInfo
>(IconWand, 1000)
const linewrapEnabled = ref(true)
const selectedOperation = ref<gql.OperationDefinitionNode | null>(null)
const gqlQueryString = useVModel(props, "modelValue", emit)
const debouncedOnUpdateQueryState = debounce((update: ViewUpdate) => {
if (!update.selectionSet) return
const selectedPos = update.state.selection.main.head
const queryString = update.state.doc.toJSON().join(update.state.lineBreak)
try {
const operations = gql.parse(queryString)
if (operations.definitions.length === 1) {
selectedOperation.value = operations
.definitions[0] as gql.OperationDefinitionNode
return
}
selectedOperation.value =
(operations.definitions.find((def) => {
if (def.kind !== "OperationDefinition") return false
const { start, end } = def.loc!
return selectedPos >= start && selectedPos <= end
}) as gql.OperationDefinitionNode) ?? null
} catch (error) {
// console.error(error)
}
}, 300)
onMounted(() => {
try {
const operations = gql.parse(gqlQueryString.value)
if (operations.definitions.length) {
selectedOperation.value = operations
.definitions[0] as gql.OperationDefinitionNode
return
}
} catch (error) {}
})
useCodemirror(
queryEditor,
gqlQueryString,
reactive({
extendedEditorConfig: {
mode: "graphql",
placeholder: `${t("request.query")}`,
lineWrapping: linewrapEnabled,
},
linter: createGQLQueryLinter(schema),
completer: queryCompleter(schema),
environmentHighlights: false,
additionalExts: [markRaw(selectedGQLOpHighlight)],
onUpdate: debouncedOnUpdateQueryState,
})
)
// operations on graphql query string
// const operations = useReadonlyStream(props.request.operations$, [])
const prettifyQuery = () => {
try {
gqlQueryString.value = gql.print(
gql.parse(gqlQueryString.value, {
allowLegacyFragmentVariables: true,
})
)
prettifyQueryIcon.value = IconCheck
} catch (e) {
toast.error(`${t("error.gql_prettify_invalid_query")}`)
prettifyQueryIcon.value = IconInfo
}
}
const copyQuery = () => {
copyToClipboard(gqlQueryString.value)
copyQueryIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
const clearGQLQuery = () => {
gqlQueryString.value = ""
}
const runQuery = (definition: gql.OperationDefinitionNode | null = null) => {
emit("run-query", definition)
}
const unsubscribe = () => {
socketDisconnect()
}
const saveRequest = () => {
emit("save-request")
}
defineActionHandler("editor.format", prettifyQuery)
</script>

View File

@@ -17,58 +17,127 @@
<HoppButtonPrimary
id="get"
name="get"
:loading="isLoading"
:loading="connection.state === 'CONNECTING'"
:label="!connected ? t('action.connect') : t('action.disconnect')"
class="w-32"
@click="onConnectClick"
/>
</div>
</div>
<HoppSmartModal
v-if="connectionSwitchModal"
dialog
:dimissible="false"
:title="t('graphql.switch_connection')"
@close="connectionSwitchModal = false"
>
<template #body>
<p class="mb-4">
{{ t("graphql.connection_switch_url") }}:
<kbd class="shortcut-key !ml-0"> {{ lastTwoUrls.at(0) }} </kbd>
</p>
<p class="mb-4">
{{ t("graphql.connection_switch_new_url") }}:
<kbd class="shortcut-key !ml-0"> {{ lastTwoUrls.at(1) }} </kbd>
</p>
<p>{{ t("graphql.connection_switch_confirm") }}</p>
</template>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
:label="t('action.connect')"
:loading="connection.state === 'CONNECTING'"
outline
@click="switchConnection()"
/>
<HoppButtonSecondary
:label="t('action.cancel')"
outline
filled
@click="cancelSwitch()"
/>
</span>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { platform } from "~/platform"
import { GQLConnection } from "~/helpers/GQLConnection"
import { useReadonlyStream, useStream } from "@composables/stream"
import { useI18n } from "@composables/i18n"
import {
gqlAuth$,
gqlHeaders$,
gqlURL$,
setGQLURL,
} from "~/newstore/GQLSession"
import { useService } from "dioc/vue"
import { currentActiveTab } from "~/helpers/graphql/tab"
import { computed, ref, watch } from "vue"
import { connection } from "~/helpers/graphql/connection"
import { connect } from "~/helpers/graphql/connection"
import { disconnect } from "~/helpers/graphql/connection"
import { InterceptorService } from "~/services/interceptor.service"
import { useService } from "dioc/vue"
const t = useI18n()
const interceptorService = useService(InterceptorService)
const props = defineProps<{
conn: GQLConnection
}>()
const connectionSwitchModal = ref(false)
const connected = useReadonlyStream(props.conn.connected$, false)
const isLoading = useReadonlyStream(props.conn.isLoading$, false)
const headers = useReadonlyStream(gqlHeaders$, [])
const auth = useReadonlyStream(gqlAuth$, {
authType: "none",
authActive: true,
const connected = computed(() => connection.state === "CONNECTED")
const url = computed({
get: () => currentActiveTab.value?.document.request.url ?? "",
set: (value) => {
currentActiveTab.value!.document.request.url = value
},
})
const url = useStream(gqlURL$, "", setGQLURL)
const onConnectClick = () => {
if (!connected.value) {
props.conn.connect(url.value, headers.value as any, auth.value)
platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "graphql-schema",
strategy: interceptorService.currentInterceptorID.value!,
})
gqlConnect()
} else {
props.conn.disconnect()
disconnect()
}
}
const gqlConnect = () => {
connect(url.value, currentActiveTab.value?.document.request.headers)
platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "graphql-schema",
strategy: interceptorService.currentInterceptorID.value!,
})
}
const switchConnection = () => {
gqlConnect()
connectionSwitchModal.value = false
}
const lastTwoUrls = ref<string[]>([])
watch(
currentActiveTab,
(newVal) => {
if (newVal) {
lastTwoUrls.value.push(newVal.document.request.url)
if (lastTwoUrls.value.length > 2) {
lastTwoUrls.value.shift()
}
}
if (
connected.value &&
lastTwoUrls.value.length === 2 &&
lastTwoUrls.value.at(0) !== lastTwoUrls.value.at(1)
) {
disconnect()
connectionSwitchModal.value = true
}
},
{
immediate: true,
}
)
const cancelSwitch = () => {
if (connected.value) disconnect()
connectionSwitchModal.value = false
}
</script>

View File

@@ -2,311 +2,42 @@
<div class="flex flex-col flex-1 h-full">
<HoppSmartTabs
v-model="selectedOptionTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-upperPrimaryStickyFold z-10"
render-inactive-tabs
styles="sticky bg-primary z-10"
:render-inactive-tabs="true"
>
<HoppSmartTab
:id="'query'"
:label="`${t('tab.query')}`"
:indicator="gqlQueryString && gqlQueryString.length > 0 ? true : false"
:indicator="request.query && request.query.length > 0 ? true : false"
>
<div
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold gqlRunQuery"
>
<label class="font-semibold truncate text-secondaryLight">
{{ t("request.query") }}
</label>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
:title="`${t(
'request.run'
)} <kbd>${getSpecialKey()}</kbd><kbd>↩</kbd>`"
:label="`${t('request.run')}`"
:icon="IconPlay"
class="rounded-none !text-accent !hover:text-accentDark"
@click="runQuery()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
:title="`${t(
'request.save'
)} <kbd>${getSpecialKey()}</kbd><kbd>S</kbd>`"
:label="`${t('request.save')}`"
:icon="IconSave"
class="rounded-none"
@click="saveRequest"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="IconTrash2"
@click="clearGQLQuery()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabledQuery }"
:icon="IconWrapText"
@click.prevent="linewrapEnabledQuery = !linewrapEnabledQuery"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.prettify')"
:icon="prettifyQueryIcon"
@click="prettifyQuery"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyQueryIcon"
@click="copyQuery"
/>
</div>
</div>
<div ref="queryEditor" class="flex flex-col flex-1"></div>
<GraphqlQuery
v-model="request.query"
@run-query="runQuery"
@save-request="saveRequest"
/>
</HoppSmartTab>
<HoppSmartTab
:id="'variables'"
:label="`${t('tab.variables')}`"
:indicator="variableString && variableString.length > 0 ? true : false"
:indicator="
request.variables && request.variables.length > 0 ? true : false
"
>
<div
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
>
<label class="font-semibold truncate text-secondaryLight">
{{ t("request.variables") }}
</label>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="IconTrash2"
@click="clearGQLVariables()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabledVariable }"
:icon="IconWrapText"
@click.prevent="
linewrapEnabledVariable = !linewrapEnabledVariable
"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.prettify')"
:icon="prettifyVariablesIcon"
@click="prettifyVariableString"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyVariablesIcon"
@click="copyVariables"
/>
</div>
</div>
<div ref="variableEditor" class="flex flex-col flex-1"></div>
<GraphqlVariable
v-model="request.variables"
@run-query="runQuery"
@save-request="saveRequest"
/>
</HoppSmartTab>
<HoppSmartTab
:id="'headers'"
:label="`${t('tab.headers')}`"
:info="activeGQLHeadersCount === 0 ? null : `${activeGQLHeadersCount}`"
>
<div
class="sticky z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight top-upperSecondaryStickyFold"
>
<label class="font-semibold truncate text-secondaryLight">
{{ t("tab.headers") }}
</label>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="IconTrash2"
@click="clearContent()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.bulk_mode')"
:icon="IconEdit"
:class="{ '!text-accent': bulkMode }"
@click="bulkMode = !bulkMode"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('add.new')"
:icon="IconPlus"
:disabled="bulkMode"
@click="addHeader"
/>
</div>
</div>
<div
v-if="bulkMode"
ref="bulkEditor"
class="flex flex-col flex-1"
></div>
<div v-else>
<draggable
v-model="workingHeaders"
:item-key="(header) => `header-${header.id}`"
animation="250"
handle=".draggable-handle"
draggable=".draggable-content"
ghost-class="cursor-move"
chosen-class="bg-primaryLight"
drag-class="cursor-grabbing"
>
<template #item="{ element: header, index }">
<div
class="flex border-b divide-x divide-dividerLight border-dividerLight draggable-content group"
>
<span>
<HoppButtonSecondary
v-tippy="{
theme: 'tooltip',
delay: [500, 20],
content:
index !== workingHeaders?.length - 1
? t('action.drag_to_reorder')
: null,
}"
:icon="IconGripVertical"
class="cursor-auto text-primary hover:text-primary"
:class="{
'draggable-handle group-hover:text-secondaryLight !cursor-grab':
index !== workingHeaders?.length - 1,
}"
tabindex="-1"
/>
</span>
<HoppSmartAutoComplete
:placeholder="`${t('count.header', { count: index + 1 })}`"
:source="commonHeaders"
:spellcheck="false"
:value="header.key"
autofocus
styles="
bg-transparent
flex
flex-1
py-1
px-4
truncate
"
class="flex-1 !flex"
@input="
updateHeader(index, {
id: header.id,
key: $event,
value: header.value,
active: header.active,
})
"
/>
<input
class="flex flex-1 px-4 py-2 bg-transparent"
:placeholder="`${t('count.value', { count: index + 1 })}`"
:name="`value ${String(index)}`"
:value="header.value"
autofocus
@change="
updateHeader(index, {
id: header.id,
key: header.key,
value: ($event!.target! as HTMLInputElement).value,
active: header.active,
})
"
/>
<span>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
header.hasOwnProperty('active')
? header.active
? t('action.turn_off')
: t('action.turn_on')
: t('action.turn_off')
"
:icon="
header.hasOwnProperty('active')
? header.active
? IconCheckCircle
: IconCircle
: IconCheckCircle
"
color="green"
@click="
updateHeader(index, {
id: header.id,
key: header.key,
value: header.value,
active: !header.active,
})
"
/>
</span>
<span>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.remove')"
:icon="IconTrash"
color="red"
@click="deleteHeader(index)"
/>
</span>
</div>
</template>
</draggable>
<HoppSmartPlaceholder
v-if="workingHeaders.length === 0"
:src="`/images/states/${colorMode.value}/add_category.svg`"
:alt="`${t('empty.headers')}`"
:text="t('empty.headers')"
>
<HoppButtonSecondary
:label="`${t('add.new')}`"
filled
:icon="IconPlus"
class="mb-4"
@click="addHeader"
/>
</HoppSmartPlaceholder>
</div>
<GraphqlHeaders v-model="request" />
</HoppSmartTab>
<HoppSmartTab :id="'authorization'" :label="`${t('tab.authorization')}`">
<GraphqlAuthorization />
<GraphqlAuthorization v-model="request.auth" />
</HoppSmartTab>
</HoppSmartTabs>
<CollectionsSaveRequest
@@ -318,432 +49,102 @@
</template>
<script setup lang="ts">
import IconPlay from "~icons/lucide/play"
import IconSave from "~icons/lucide/save"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconTrash2 from "~icons/lucide/trash-2"
import IconEdit from "~icons/lucide/edit"
import IconPlus from "~icons/lucide/plus"
import IconGripVertical from "~icons/lucide/grip-vertical"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconTrash from "~icons/lucide/trash"
import IconCircle from "~icons/lucide/circle"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import IconInfo from "~icons/lucide/info"
import IconWand2 from "~icons/lucide/wand-2"
import IconWrapText from "~icons/lucide/wrap-text"
import { Ref, computed, reactive, ref, watch } from "vue"
import * as gql from "graphql"
import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import * as RA from "fp-ts/ReadonlyArray"
import { pipe, flow } from "fp-ts/function"
import {
GQLHeader,
makeGQLRequest,
rawKeyValueEntriesToString,
parseRawKeyValueEntriesE,
RawKeyValueEntry,
} from "@hoppscotch/data"
import draggable from "vuedraggable-es"
import { clone, cloneDeep, isEqual } from "lodash-es"
import { refAutoReset } from "@vueuse/core"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useReadonlyStream, useStream } from "@composables/stream"
import { useColorMode } from "@composables/theming"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { startPageProgress, completePageProgress } from "@modules/loadingbar"
import {
gqlAuth$,
gqlHeaders$,
gqlQuery$,
gqlResponse$,
gqlURL$,
gqlVariables$,
setGQLAuth,
setGQLHeaders,
setGQLQuery,
setGQLResponse,
setGQLVariables,
} from "~/newstore/GQLSession"
import { commonHeaders } from "~/helpers/headers"
import { GQLConnection } from "~/helpers/GQLConnection"
import { makeGQLHistoryEntry, addGraphqlHistoryEntry } from "~/newstore/history"
import { platform } from "~/platform"
import { useCodemirror } from "@composables/codemirror"
import jsonLinter from "~/helpers/editor/linting/json"
import { createGQLQueryLinter } from "~/helpers/editor/linting/gqlQuery"
import queryCompleter from "~/helpers/editor/completion/gqlQuery"
import { completePageProgress, startPageProgress } from "@modules/loadingbar"
import * as gql from "graphql"
import { clone } from "lodash-es"
import { computed, ref, watch } from "vue"
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { objRemoveKey } from "~/helpers/functional/object"
import { HoppGQLRequest } from "@hoppscotch/data"
import { platform } from "~/platform"
import { currentActiveTab } from "~/helpers/graphql/tab"
import { computedWithControl } from "@vueuse/core"
import {
GQLResponseEvent,
runGQLOperation,
gqlMessageEvent,
} from "~/helpers/graphql/connection"
import { useService } from "dioc/vue"
import { InterceptorService } from "~/services/interceptor.service"
type OptionTabs = "query" | "headers" | "variables" | "authorization"
const colorMode = useColorMode()
const selectedOptionTab = ref<OptionTabs>("query")
const t = useI18n()
const interceptorService = useService(InterceptorService)
const props = defineProps<{
conn: GQLConnection
}>()
const t = useI18n()
const toast = useToast()
const url = useReadonlyStream(gqlURL$, "")
const gqlQueryString = useStream(gqlQuery$, "", setGQLQuery)
const variableString = useStream(gqlVariables$, "", setGQLVariables)
const idTicker = ref(0)
const bulkMode = ref(false)
const bulkHeaders = ref("")
const bulkEditor = ref<any | null>(null)
const linewrapEnabled = ref(true)
const deletionToast = ref<{ goAway: (delay: number) => void } | null>(null)
useCodemirror(
bulkEditor,
bulkHeaders,
reactive({
extendedEditorConfig: {
mode: "text/x-yaml",
placeholder: `${t("state.bulk_mode_placeholder")}`,
lineWrapping: linewrapEnabled,
},
linter: null,
completer: null,
environmentHighlights: false,
})
)
// The functional headers list (the headers actually in the system)
const headers = useStream(gqlHeaders$, [], setGQLHeaders) as Ref<GQLHeader[]>
const auth = useStream(
gqlAuth$,
{ authType: "none", authActive: true },
setGQLAuth
)
// The UI representation of the headers list (has the empty end header)
const workingHeaders = ref<Array<GQLHeader & { id: number }>>([
// v-model integration with props and emit
const props = withDefaults(
defineProps<{
modelValue: HoppGQLRequest
response?: GQLResponseEvent[] | null
tabId: string
}>(),
{
id: idTicker.value++,
key: "",
value: "",
active: true,
},
])
// Rule: Working Headers always have one empty header or the last element is always an empty header
watch(workingHeaders, (headersList) => {
if (
headersList.length > 0 &&
headersList[headersList.length - 1].key !== ""
) {
workingHeaders.value.push({
id: idTicker.value++,
key: "",
value: "",
active: true,
})
response: null,
}
})
)
const emit = defineEmits(["update:modelValue", "update:response"])
const request = ref(props.modelValue)
// Sync logic between headers and working headers
watch(
headers,
(newHeadersList) => {
// Sync should overwrite working headers
const filteredWorkingHeaders = pipe(
workingHeaders.value,
A.filterMap(
flow(
O.fromPredicate((e) => e.key !== ""),
O.map(objRemoveKey("id"))
)
)
)
const filteredBulkHeaders = pipe(
parseRawKeyValueEntriesE(bulkHeaders.value),
E.map(
flow(
RA.filter((e) => e.key !== ""),
RA.toArray
)
),
E.getOrElse(() => [] as RawKeyValueEntry[])
)
if (!isEqual(newHeadersList, filteredWorkingHeaders)) {
workingHeaders.value = pipe(
newHeadersList,
A.map((x) => ({ id: idTicker.value++, ...x }))
)
}
if (!isEqual(newHeadersList, filteredBulkHeaders)) {
bulkHeaders.value = rawKeyValueEntriesToString(newHeadersList)
}
() => request.value,
(newVal) => {
emit("update:modelValue", newVal)
},
{ immediate: true }
{ deep: true }
)
watch(workingHeaders, (newWorkingHeaders) => {
const fixedHeaders = pipe(
newWorkingHeaders,
A.filterMap(
flow(
O.fromPredicate((e) => e.key !== ""),
O.map(objRemoveKey("id"))
)
)
)
if (!isEqual(headers.value, fixedHeaders)) {
headers.value = cloneDeep(fixedHeaders)
}
})
// Bulk Editor Syncing with Working Headers
watch(bulkHeaders, (newBulkHeaders) => {
const filteredBulkHeaders = pipe(
parseRawKeyValueEntriesE(newBulkHeaders),
E.map(
flow(
RA.filter((e) => e.key !== ""),
RA.toArray
)
),
E.getOrElse(() => [] as RawKeyValueEntry[])
)
if (!isEqual(headers.value, filteredBulkHeaders)) {
headers.value = filteredBulkHeaders
}
})
watch(workingHeaders, (newHeadersList) => {
// If we are in bulk mode, don't apply direct changes
if (bulkMode.value) return
try {
const currentBulkHeaders = bulkHeaders.value.split("\n").map((item) => ({
key: item.substring(0, item.indexOf(":")).trimLeft().replace(/^#/, ""),
value: item.substring(item.indexOf(":") + 1).trimLeft(),
active: !item.trim().startsWith("#"),
}))
const filteredHeaders = newHeadersList.filter((x) => x.key !== "")
if (!isEqual(currentBulkHeaders, filteredHeaders)) {
bulkHeaders.value = rawKeyValueEntriesToString(filteredHeaders)
}
} catch (e) {
toast.error(`${t("error.something_went_wrong")}`)
console.error(e)
}
})
const addHeader = () => {
workingHeaders.value.push({
id: idTicker.value++,
key: "",
value: "",
active: true,
})
}
const updateHeader = (index: number, header: GQLHeader & { id: number }) => {
workingHeaders.value = workingHeaders.value.map((h, i) =>
i === index ? header : h
)
}
const deleteHeader = (index: number) => {
const headersBeforeDeletion = clone(workingHeaders.value)
if (
!(
headersBeforeDeletion.length > 0 &&
index === headersBeforeDeletion.length - 1
)
) {
if (deletionToast.value) {
deletionToast.value.goAway(0)
deletionToast.value = null
}
deletionToast.value = toast.success(`${t("state.deleted")}`, {
action: [
{
text: `${t("action.undo")}`,
onClick: (_, toastObject) => {
workingHeaders.value = headersBeforeDeletion
toastObject.goAway(0)
deletionToast.value = null
},
},
],
onComplete: () => {
deletionToast.value = null
},
})
}
workingHeaders.value.splice(index, 1)
}
const clearContent = () => {
// set headers list to the initial state
workingHeaders.value = [
{
id: idTicker.value++,
key: "",
value: "",
active: true,
},
]
bulkHeaders.value = ""
}
const url = computedWithControl(
() => currentActiveTab.value,
() => currentActiveTab.value.document.request.url
)
const activeGQLHeadersCount = computed(
() =>
headers.value.filter((x) => x.active && (x.key !== "" || x.value !== ""))
.length
request.value.headers.filter(
(x) => x.active && (x.key !== "" || x.value !== "")
).length
)
const variableEditor = ref<any | null>(null)
const linewrapEnabledVariable = ref(true)
useCodemirror(
variableEditor,
variableString,
reactive({
extendedEditorConfig: {
mode: "application/ld+json",
placeholder: `${t("request.variables")}`,
lineWrapping: linewrapEnabledVariable,
},
linter: computed(() =>
variableString.value.length > 0 ? jsonLinter : null
),
completer: null,
environmentHighlights: false,
})
)
const queryEditor = ref<any | null>(null)
const schema = useReadonlyStream(props.conn.schema$, null, "noclone")
const linewrapEnabledQuery = ref(true)
useCodemirror(
queryEditor,
gqlQueryString,
reactive({
extendedEditorConfig: {
mode: "graphql",
placeholder: `${t("request.query")}`,
lineWrapping: linewrapEnabledQuery,
},
linter: createGQLQueryLinter(schema),
completer: queryCompleter(schema),
environmentHighlights: false,
})
)
const copyQueryIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const copyVariablesIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const prettifyQueryIcon = refAutoReset<
typeof IconWand2 | typeof IconCheck | typeof IconInfo
>(IconWand2, 1000)
const prettifyVariablesIcon = refAutoReset<
typeof IconWand2 | typeof IconCheck | typeof IconInfo
>(IconWand2, 1000)
const showSaveRequestModal = ref(false)
const copyQuery = () => {
copyToClipboard(gqlQueryString.value)
copyQueryIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
const response = useStream(gqlResponse$, "", setGQLResponse)
const runQuery = async () => {
const runQuery = async (
definition: gql.OperationDefinitionNode | null = null
) => {
const startTime = Date.now()
startPageProgress()
response.value = "loading"
try {
const runURL = clone(url.value)
const runHeaders = clone(headers.value)
const runQuery = clone(gqlQueryString.value)
const runVariables = clone(variableString.value)
const runAuth = clone(auth.value)
const runHeaders = clone(request.value.headers)
const runQuery = clone(request.value.query)
const runVariables = clone(request.value.variables)
const runAuth = clone(request.value.auth)
const responseText = await props.conn.runQuery(
runURL,
runHeaders,
runQuery,
runVariables,
runAuth
)
await runGQLOperation({
name: request.value.name,
url: runURL,
headers: runHeaders,
query: runQuery,
variables: runVariables,
auth: runAuth,
operationName: definition?.name?.value,
operationType: definition?.operation ?? "query",
})
const duration = Date.now() - startTime
completePageProgress()
response.value = JSON.stringify(JSON.parse(responseText), null, 2)
addGraphqlHistoryEntry(
makeGQLHistoryEntry({
request: makeGQLRequest({
name: "",
url: runURL,
query: runQuery,
headers: runHeaders,
variables: runVariables,
auth: runAuth,
}),
response: response.value,
star: false,
})
)
toast.success(`${t("state.finished_in", { duration })}`)
} catch (e: any) {
response.value = `${e}`
console.log(e)
// response.value = [`${e}`]
completePageProgress()
toast.error(
`${t("error.something_went_wrong")}. ${t("error.check_console_details")}`,
{}
)
console.error(e)
}
platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "graphql-query",
@@ -751,54 +152,39 @@ const runQuery = async () => {
})
}
watch(
() => gqlMessageEvent.value,
(event) => {
if (event === "reset") {
emit("update:response", [])
return
}
try {
if (event?.operationType !== "subscription") {
// response.value = [event]
emit("update:response", [event])
} else {
emit("update:response", [...(props.response ?? []), event])
// TODO: subscription indicator??
}
} catch (error) {
console.log(error)
}
},
{ deep: true }
)
const hideRequestModal = () => {
showSaveRequestModal.value = false
}
const prettifyQuery = () => {
try {
gqlQueryString.value = gql.print(
gql.parse(gqlQueryString.value, {
allowLegacyFragmentVariables: true,
})
)
prettifyQueryIcon.value = IconCheck
} catch (e) {
toast.error(`${t("error.gql_prettify_invalid_query")}`)
prettifyQueryIcon.value = IconInfo
}
}
const saveRequest = () => {
showSaveRequestModal.value = true
}
const copyVariables = () => {
copyToClipboard(variableString.value)
copyVariablesIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
const prettifyVariableString = () => {
try {
const jsonObj = JSON.parse(variableString.value)
variableString.value = JSON.stringify(jsonObj, null, 2)
prettifyVariablesIcon.value = IconCheck
} catch (e) {
console.error(e)
prettifyVariablesIcon.value = IconInfo
toast.error(`${t("error.json_prettify_invalid_body")}`)
}
}
const clearGQLQuery = () => {
gqlQueryString.value = ""
request.value.query = ""
}
const clearGQLVariables = () => {
variableString.value = ""
}
defineActionHandler("request.send-cancel", runQuery)
defineActionHandler("request.save", saveRequest)
defineActionHandler("request.reset", clearGQLQuery)

View File

@@ -0,0 +1,50 @@
<template>
<AppPaneLayout layout-id="gql-primary">
<template #primary>
<GraphqlRequestOptions
v-model="tab.document.request"
v-model:response="tab.response"
:tab-id="tab.id"
/>
</template>
<template #secondary>
<GraphqlResponse :response="tab.response" />
</template>
</AppPaneLayout>
</template>
<script setup lang="ts">
import { useVModel } from "@vueuse/core"
import { cloneDeep } from "lodash-es"
import { watch } from "vue"
import { isEqualHoppGQLRequest } from "~/helpers/graphql"
import { HoppGQLTab } from "~/helpers/graphql/tab"
// TODO: Move Response and Request execution code to over here
const props = defineProps<{ modelValue: HoppGQLTab }>()
const emit = defineEmits<{
(e: "update:modelValue", val: HoppGQLTab): void
}>()
const tab = useVModel(props, "modelValue", emit)
// TODO: Come up with a better dirty check
let oldRequest = cloneDeep(tab.value.document.request)
watch(
() => tab.value.document.request,
(updatedValue) => {
// TODO: Check equality of request
if (
!tab.value.document.isDirty &&
!isEqualHoppGQLRequest(oldRequest, updatedValue)
) {
tab.value.document.isDirty = true
}
oldRequest = cloneDeep(updatedValue)
},
{ deep: true }
)
</script>

View File

@@ -1,14 +1,6 @@
<template>
<div class="flex flex-col flex-1 overflow-auto whitespace-nowrap">
<HoppSmartPlaceholder
v-if="responseString === 'loading'"
:text="t('state.loading')"
>
<template #icon>
<HoppSmartSpinner class="my-4" />
</template>
</HoppSmartPlaceholder>
<div v-else-if="responseString" class="flex flex-col flex-1">
<div v-if="response?.length === 1" class="flex flex-col flex-1">
<div
class="sticky top-0 z-10 flex items-center justify-between flex-shrink-0 pl-4 overflow-x-auto border-b bg-primary border-dividerLight"
>
@@ -37,12 +29,18 @@
'action.copy'
)} <kbd>${getSpecialKey()}</kbd><kbd>.</kbd>`"
:icon="copyResponseIcon"
@click="copyResponse"
@click="copyResponse(response[0].data)"
/>
</div>
</div>
<div ref="schemaEditor" class="flex flex-col flex-1"></div>
</div>
<div
v-else-if="response && response?.length > 1"
class="flex flex-col flex-1"
>
<GraphqlSubscriptionLog :log="response" />
</div>
<AppShortcutsPrompt v-else class="p-4" />
</div>
</template>
@@ -52,22 +50,34 @@ import IconWrapText from "~icons/lucide/wrap-text"
import IconDownload from "~icons/lucide/download"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
import { reactive, ref } from "vue"
import { computed, reactive, ref } from "vue"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "@composables/codemirror"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useReadonlyStream } from "@composables/stream"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { gqlResponse$ } from "~/newstore/GQLSession"
import { defineActionHandler } from "~/helpers/actions"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { GQLResponseEvent } from "~/helpers/graphql/connection"
const t = useI18n()
const toast = useToast()
const responseString = useReadonlyStream(gqlResponse$, "")
const props = withDefaults(
defineProps<{
response: GQLResponseEvent[] | null
}>(),
{
response: null,
}
)
const responseString = computed(() => {
if (props.response?.length === 1) {
return JSON.stringify(JSON.parse(props.response[0].data), null, 2)
}
return ""
})
const schemaEditor = ref<any | null>(null)
const linewrapEnabled = ref(true)
@@ -95,14 +105,14 @@ const copyResponseIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
1000
)
const copyResponse = () => {
copyToClipboard(responseString.value!)
const copyResponse = (str: string) => {
copyToClipboard(str)
copyResponseIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
const downloadResponse = () => {
const dataToWrite = responseString.value
const downloadResponse = (str: string) => {
const dataToWrite = str
const file = new Blob([dataToWrite!], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
@@ -118,6 +128,10 @@ const downloadResponse = () => {
}, 1000)
}
defineActionHandler("response.file.download", () => downloadResponse())
defineActionHandler("response.copy", () => copyResponse())
defineActionHandler("response.file.download", () =>
downloadResponse.bind(responseString.value)
)
defineActionHandler("response.copy", () =>
copyResponse.bind(responseString.value)
)
</script>

View File

@@ -5,20 +5,6 @@
vertical
render-inactive-tabs
>
<HoppSmartTab
:id="'history'"
:icon="IconClock"
:label="`${t('tab.history')}`"
>
<History :page="'graphql'" @use-history="handleUseHistory" />
</HoppSmartTab>
<HoppSmartTab
:id="'collections'"
:icon="IconFolder"
:label="`${t('tab.collections')}`"
>
<CollectionsGraphql />
</HoppSmartTab>
<HoppSmartTab
:id="'docs'"
:icon="IconBookOpen"
@@ -173,6 +159,21 @@
>
</HoppSmartPlaceholder>
</HoppSmartTab>
<HoppSmartTab
:id="'collections'"
:icon="IconFolder"
:label="`${t('tab.collections')}`"
>
<CollectionsGraphql />
</HoppSmartTab>
<HoppSmartTab
:id="'history'"
:icon="IconClock"
:label="`${t('tab.history')}`"
>
<History :page="'graphql'" />
</HoppSmartTab>
</HoppSmartTabs>
</template>
@@ -188,29 +189,24 @@ import IconCopy from "~icons/lucide/copy"
import IconBox from "~icons/lucide/box"
import { computed, nextTick, reactive, ref } from "vue"
import { GraphQLField, GraphQLType } from "graphql"
import { map } from "rxjs/operators"
import { GQLHeader } from "@hoppscotch/data"
import { refAutoReset } from "@vueuse/core"
import { useCodemirror } from "@composables/codemirror"
import { GQLConnection } from "@helpers/GQLConnection"
import { copyToClipboard } from "@helpers/utils/clipboard"
import { useReadonlyStream } from "@composables/stream"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useColorMode } from "@composables/theming"
import {
setGQLAuth,
setGQLHeaders,
setGQLQuery,
setGQLResponse,
setGQLURL,
setGQLVariables,
} from "~/newstore/GQLSession"
graphqlTypes,
mutationFields,
queryFields,
schemaString,
subscriptionFields,
} from "~/helpers/graphql/connection"
type NavigationTabs = "history" | "collection" | "docs" | "schema"
type GqlTabs = "queries" | "mutations" | "subscriptions" | "types"
const selectedNavigationTab = ref<NavigationTabs>("history")
const selectedNavigationTab = ref<NavigationTabs>("docs")
const selectedGqlTab = ref<GqlTabs>("queries")
const t = useI18n()
@@ -270,40 +266,8 @@ function resolveRootType(type: GraphQLType) {
return t
}
type GQLHistoryEntry = {
url: string
headers: GQLHeader[]
query: string
response: string
variables: string
}
const props = defineProps<{
conn: GQLConnection
}>()
const toast = useToast()
const queryFields = useReadonlyStream(
props.conn.queryFields$.pipe(map((x) => x ?? [])),
[]
)
const mutationFields = useReadonlyStream(
props.conn.mutationFields$.pipe(map((x) => x ?? [])),
[]
)
const subscriptionFields = useReadonlyStream(
props.conn.subscriptionFields$.pipe(map((x) => x ?? [])),
[]
)
const graphqlTypes = useReadonlyStream(
props.conn.graphqlTypes$.pipe(map((x) => x ?? [])),
[]
)
const downloadSchemaIcon = refAutoReset<typeof IconDownload | typeof IconCheck>(
IconDownload,
1000
@@ -390,11 +354,6 @@ const handleJumpToType = async (type: GraphQLType) => {
}
}
const schemaString = useReadonlyStream(
props.conn.schemaString$.pipe(map((x) => x ?? "")),
""
)
const schemaEditor = ref<any | null>(null)
const linewrapEnabled = ref(true)
@@ -436,23 +395,4 @@ const copySchema = () => {
copyToClipboard(schemaString.value)
copySchemaIcon.value = IconCheck
}
const handleUseHistory = (entry: GQLHistoryEntry) => {
const url = entry.url
const headers = entry.headers
const gqlQueryString = entry.query
const variableString = entry.variables
const responseText = entry.response
setGQLURL(url)
setGQLHeaders(headers)
setGQLQuery(gqlQueryString)
setGQLVariables(variableString)
setGQLResponse(responseText)
setGQLAuth({
authType: "none",
authActive: true,
})
props.conn.reset()
}
</script>

View File

@@ -0,0 +1,125 @@
<template>
<div ref="container" class="flex flex-col flex-1">
<div
class="sticky top-0 z-10 flex items-center justify-between flex-none pl-4 border-b bg-primary border-dividerLight"
>
<label for="log" class="py-2 font-semibold text-secondaryLight">
{{ "Subscription Log" }}
</label>
<div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.delete')"
:icon="IconTrash"
@click="emit('delete')"
/>
<HoppButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.scroll_to_top')"
:icon="IconArrowUp"
@click="scrollTo('top')"
/>
<HoppButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.scroll_to_bottom')"
:icon="IconArrowDown"
@click="scrollTo('bottom')"
/>
<HoppButtonSecondary
id="bottompage"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.autoscroll')"
:icon="IconChevronsDown"
:class="toggleAutoscrollColor"
@click="toggleAutoscroll()"
/>
</div>
</div>
<div
v-if="log.length !== 0"
ref="logs"
class="overflow-y-auto border-b border-dividerLight"
>
<div
class="flex flex-col h-auto h-full border-r divide-y divide-dividerLight border-dividerLight"
>
<RealtimeLogEntry
v-for="(entry, index) in log"
:key="`entry-${index}`"
:is-open="log.length - 1 === index"
:entry="{ ts: entry.time, source: 'info', payload: entry.data }"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, PropType, computed, watch, Ref } from "vue"
import IconTrash from "~icons/lucide/trash"
import IconArrowUp from "~icons/lucide/arrow-up"
import IconArrowDown from "~icons/lucide/arrow-down"
import IconChevronsDown from "~icons/lucide/chevron-down"
import { useThrottleFn, useScroll } from "@vueuse/core"
import { useI18n } from "@composables/i18n"
import { GQLResponseEvent } from "~/helpers/graphql/connection"
const props = defineProps({
log: { type: Array as PropType<GQLResponseEvent[]>, default: () => [] },
title: {
type: String,
default: "",
},
})
const emit = defineEmits<{
(e: "delete"): void
}>()
const t = useI18n()
const container = ref<HTMLElement | null>(null)
const logs = ref<HTMLElement | null>(null)
const autoScrollEnabled = ref(true)
const logListScroll = useScroll(logs as Ref<HTMLElement>)
// Disable autoscroll when scrolling to top
watch(logListScroll.isScrolling, (isScrolling) => {
if (isScrolling && logListScroll.directions.top)
autoScrollEnabled.value = false
})
const scrollTo = (position: "top" | "bottom") => {
if (position === "top") {
logs.value?.scroll({
behavior: "smooth",
top: 0,
})
} else if (position === "bottom") {
logs.value?.scroll({
behavior: "smooth",
top: logs.value?.scrollHeight,
})
}
}
watch(
() => props.log,
useThrottleFn(() => {
if (autoScrollEnabled.value) scrollTo("bottom")
}, 200),
{ flush: "post" }
)
const toggleAutoscroll = () => {
autoScrollEnabled.value = !autoScrollEnabled.value
}
const toggleAutoscrollColor = computed(() =>
autoScrollEnabled.value ? "text-green-500" : "text-red-500"
)
</script>

View File

@@ -55,51 +55,48 @@
</div>
</template>
<script>
// TODO: TypeScript + Setup Script this at some point :)
import { defineComponent } from "vue"
<script setup lang="ts">
import {
GraphQLEnumType,
GraphQLInputObjectType,
GraphQLInterfaceType,
} from "graphql"
import { computed } from "vue"
export default defineComponent({
props: {
// eslint-disable-next-line vue/require-default-prop, vue/require-prop-types
gqlType: {},
gqlTypes: { type: Array, default: () => [] },
jumpTypeCallback: { type: Function, default: () => ({}) },
isHighlighted: { type: Boolean, default: false },
highlightedFields: { type: Array, default: () => [] },
},
computed: {
isInput() {
return this.gqlType instanceof GraphQLInputObjectType
},
isInterface() {
return this.gqlType instanceof GraphQLInterfaceType
},
isEnum() {
return this.gqlType instanceof GraphQLEnumType
},
interfaces() {
return (this.gqlType.getInterfaces && this.gqlType.getInterfaces()) || []
},
children() {
return this.gqlTypes.filter(
(type) =>
type.getInterfaces && type.getInterfaces().includes(this.gqlType)
)
},
},
methods: {
isFieldHighlighted({ field }) {
return !!this.highlightedFields.find(({ name }) => name === field.name)
},
const props = defineProps({
gqlType: {
type: Object,
required: true,
},
gqlTypes: { type: Array, default: () => [] },
jumpTypeCallback: { type: Function, default: () => ({}) },
isHighlighted: { type: Boolean, default: false },
highlightedFields: { type: Array, default: () => [] },
})
const isInput = computed(() => {
return props.gqlType instanceof GraphQLInputObjectType
})
const isInterface = computed(() => {
return props.gqlType instanceof GraphQLInterfaceType
})
const isEnum = computed(() => {
return props.gqlType instanceof GraphQLEnumType
})
const interfaces = computed(() => {
return (props.gqlType.getInterfaces && props.gqlType.getInterfaces()) || []
})
const children = computed(() => {
return props.gqlTypes.filter(
(type) => type.getInterfaces && type.getInterfaces().includes(props.gqlType)
)
})
const isFieldHighlighted = ({ field }) => {
return !!props.highlightedFields.find(({ name }) => name === field.name)
}
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,172 @@
<template>
<div
class="sticky z-10 flex items-center justify-between pl-4 border-b bg-primary border-dividerLight"
>
<label class="font-semibold text-secondaryLight">
{{ t("request.variables") }}
</label>
<div class="flex">
<HoppButtonSecondary
v-if="subscriptionState === 'SUBSCRIBED'"
v-tippy="{
theme: 'tooltip',
delay: [500, 20],
allowHTML: true,
}"
:title="`${t('request.stop')}`"
:label="`${t('request.stop')}`"
:icon="IconStop"
class="rounded-none !text-accent !hover:text-accentDark"
@click="unsubscribe()"
/>
<HoppButtonSecondary
v-if="selectedOperation && subscriptionState !== 'SUBSCRIBED'"
v-tippy="{
theme: 'tooltip',
delay: [500, 20],
allowHTML: true,
}"
:title="`${t('request.run')} <kbd>${getSpecialKey()}</kbd><kbd>G</kbd>`"
:label="`${selectedOperation.name?.value ?? t('request.run')}`"
:icon="IconPlay"
:disabled="!selectedOperation"
class="rounded-none !text-accent !hover:text-accentDark"
@click="runQuery(selectedOperation)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/graphql-api-testing"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.clear_all')"
:icon="IconTrash2"
@click="clearGQLVariables()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('state.linewrap')"
:class="{ '!text-accent': linewrapEnabled }"
:icon="IconWrapText"
@click.prevent="linewrapEnabled = !linewrapEnabled"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.prettify')"
:icon="prettifyVariablesIcon"
@click="prettifyVariableString"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyVariablesIcon"
@click="copyVariables"
/>
</div>
</div>
<div ref="variableEditor" class="flex flex-col flex-1"></div>
</template>
<script setup lang="ts">
import IconPlay from "~icons/lucide/play"
import IconStop from "~icons/lucide/stop-circle"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconTrash2 from "~icons/lucide/trash-2"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import IconInfo from "~icons/lucide/info"
import IconWand from "~icons/lucide/wand"
import IconWrapText from "~icons/lucide/wrap-text"
import { computed, reactive, ref } from "vue"
import jsonLinter from "~/helpers/editor/linting/json"
import { copyToClipboard } from "@helpers/utils/clipboard"
import { useCodemirror } from "@composables/codemirror"
import * as gql from "graphql"
import { useI18n } from "@composables/i18n"
import { refAutoReset, useVModel } from "@vueuse/core"
import { useToast } from "~/composables/toast"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import {
socketDisconnect,
subscriptionState,
} from "~/helpers/graphql/connection"
const t = useI18n()
const toast = useToast()
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: "save-request"): void
(e: "update:modelValue", val: string): void
(e: "run-query", definition: gql.OperationDefinitionNode | null): void
}>()
// Watch operations on graphql query string
const selectedOperation = ref<gql.OperationDefinitionNode | null>(null)
const variableString = useVModel(props, "modelValue", emit)
const variableEditor = ref<any | null>(null)
const linewrapEnabled = ref(false)
const copyVariablesIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const prettifyVariablesIcon = refAutoReset<
typeof IconWand | typeof IconCheck | typeof IconInfo
>(IconWand, 1000)
useCodemirror(
variableEditor,
variableString,
reactive({
extendedEditorConfig: {
mode: "application/ld+json",
placeholder: `${t("request.variables")}`,
lineWrapping: linewrapEnabled,
},
linter: computed(() =>
variableString.value.length > 0 ? jsonLinter : null
),
completer: null,
environmentHighlights: false,
})
)
const copyVariables = () => {
copyToClipboard(variableString.value)
copyVariablesIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
const prettifyVariableString = () => {
try {
const jsonObj = JSON.parse(variableString.value)
variableString.value = JSON.stringify(jsonObj, null, 2)
prettifyVariablesIcon.value = IconCheck
} catch (e) {
console.error(e)
prettifyVariablesIcon.value = IconInfo
toast.error(`${t("error.json_prettify_invalid_body")}`)
}
}
const clearGQLVariables = () => {
variableString.value = ""
}
const runQuery = (definition: gql.OperationDefinitionNode | null = null) => {
emit("run-query", definition)
}
const unsubscribe = () => {
socketDisconnect()
}
</script>

View File

@@ -56,9 +56,6 @@
<script setup lang="ts">
import { computed, ref } from "vue"
import { makeGQLRequest } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import { setGQLSession } from "~/newstore/GQLSession"
import { GQLHistoryEntry } from "~/newstore/history"
import { shortDateTime } from "~/helpers/utils/date"
@@ -69,6 +66,8 @@ import IconMinimize2 from "~icons/lucide/minimize-2"
import IconMaximize2 from "~icons/lucide/maximize-2"
import { useI18n } from "@composables/i18n"
import { makeGQLRequest } from "@hoppscotch/data"
import { createNewTab } from "~/helpers/graphql/tab"
const t = useI18n()
@@ -94,19 +93,16 @@ const query = computed(() =>
)
const useEntry = () => {
setGQLSession({
request: cloneDeep(
makeGQLRequest({
name: props.entry.request.name,
url: props.entry.request.url,
headers: props.entry.request.headers,
query: props.entry.request.query,
variables: props.entry.request.variables,
auth: props.entry.request.auth,
})
),
schema: "",
response: props.entry.response,
createNewTab({
request: makeGQLRequest({
name: props.entry.request.name,
url: props.entry.request.url,
headers: props.entry.request.headers,
query: props.entry.request.query,
variables: props.entry.request.variables,
auth: props.entry.request.auth,
}),
isDirty: false,
})
}
</script>

View File

@@ -126,7 +126,6 @@
blank
:icon="IconExternalLink"
reverse
class="mb-4"
/>
</HoppSmartPlaceholder>
<div v-else class="flex flex-1 border-b border-dividerLight">

View File

@@ -117,7 +117,6 @@
blank
:icon="IconExternalLink"
reverse
class="mb-4"
/>
</HoppSmartPlaceholder>
</div>

View File

@@ -162,7 +162,6 @@
:label="`${t('add.new')}`"
filled
:icon="IconPlus"
class="mb-4"
@click="addBodyParam"
/>
</HoppSmartPlaceholder>

View File

@@ -213,7 +213,6 @@
filled
:label="`${t('add.new')}`"
:icon="IconPlus"
class="mb-4"
@click="addHeader"
/>
</HoppSmartPlaceholder>

View File

@@ -33,7 +33,11 @@
<script setup lang="ts">
import { ref, watch } from "vue"
import { HoppRESTAuthOAuth2, parseTemplateString } from "@hoppscotch/data"
import {
HoppGQLAuthOAuth2,
HoppRESTAuthOAuth2,
parseTemplateString,
} from "@hoppscotch/data"
import { pluckRef } from "@composables/ref"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
@@ -44,7 +48,7 @@ const t = useI18n()
const toast = useToast()
const props = defineProps<{
modelValue: HoppRESTAuthOAuth2
modelValue: HoppRESTAuthOAuth2 | HoppGQLAuthOAuth2
}>()
const emit = defineEmits<{

View File

@@ -161,7 +161,6 @@
:label="`${t('add.new')}`"
:icon="IconPlus"
filled
class="mb-4"
@click="addParam"
/>
</HoppSmartPlaceholder>

View File

@@ -153,7 +153,6 @@
filled
:label="`${t('add.new')}`"
:icon="IconPlus"
class="mb-4"
@click="addUrlEncodedParam"
/>
</HoppSmartPlaceholder>

View File

@@ -60,7 +60,7 @@
</template>
<script setup lang="ts">
import { ref, PropType, computed, watch } from "vue"
import { ref, PropType, computed, watch, Ref } from "vue"
import IconTrash from "~icons/lucide/trash"
import IconArrowUp from "~icons/lucide/arrow-up"
import IconArrowDown from "~icons/lucide/arrow-down"
@@ -73,7 +73,7 @@ export type LogEntryData = {
ts: number | undefined
source: "info" | "client" | "server" | "disconnected"
payload: string
event: "connecting" | "connected" | "disconnected" | "error"
event?: "connecting" | "connected" | "disconnected" | "error"
}
const props = defineProps({
@@ -94,7 +94,7 @@ const logs = ref<HTMLElement>()
const autoScrollEnabled = ref(true)
const logListScroll = useScroll(logs)
const logListScroll = useScroll(logs as Ref<HTMLElement>)
// Disable autoscroll when scrolling to top
watch(logListScroll.isScrolling, (isScrolling) => {

View File

@@ -209,7 +209,7 @@ import IconWrapText from "~icons/lucide/wrap-text"
import * as LJSON from "lossless-json"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
import { ref, computed, reactive, watch, markRaw } from "vue"
import { ref, computed, reactive, watch, markRaw, PropType } from "vue"
import { refAutoReset, useTimeAgo } from "@vueuse/core"
import { LogEntryData } from "./Log.vue"
import { useI18n } from "@composables/i18n"
@@ -227,7 +227,16 @@ import { shortDateTime } from "~/helpers/utils/date"
const t = useI18n()
const props = defineProps<{ entry: LogEntryData }>()
const props = defineProps({
entry: {
type: Object as PropType<LogEntryData>,
required: true,
},
isOpen: {
type: Boolean,
default: false,
},
})
// Template refs
const tippyActions = ref<any | null>(null)
@@ -304,7 +313,7 @@ const outlinePath = computed(() =>
)
// Code for UI Changes
const minimized = ref(true)
const minimized = ref(props.isOpen ? false : true)
watch(minimized, () => {
selectedTab.value = isJSON(props.entry.payload) ? "json" : "raw"
})
@@ -342,7 +351,9 @@ const ENTRY_COLORS = {
} as const
// Assigns color based on entry event
const entryColor = computed(() => ENTRY_COLORS[props.entry.event])
const entryColor = computed(
() => props.entry.event && ENTRY_COLORS[props.entry.event]
)
const ICONS = {
info: {

View File

@@ -21,17 +21,13 @@
<div class="flex items-center py-4 space-x-2">
<HoppSmartInput
v-model="PROXY_URL"
:autofocus="false"
styles="flex-1"
placeholder=" "
:label="t('settings.proxy_url')"
input-styles="input floating-input"
:disabled="!proxyEnabled"
>
<template #label>
<label for="url">
{{ t("settings.proxy_url") }}
</label>
</template>
</HoppSmartInput>
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('settings.reset_default')"

View File

@@ -480,7 +480,7 @@ watch(editor, () => {
@apply flex;
@apply flex-1;
@apply flex-shrink-0;
@apply whitespace-nowrap;
@apply whitespace-nowrap py-4;
.suggestions {
@apply absolute;

View File

@@ -58,6 +58,11 @@ type CodeMirrorOptions = {
// NOTE: This property is not reactive
environmentHighlights: boolean
additionalExts?: Extension[]
// callback on editor update
onUpdate?: (view: ViewUpdate) => void
}
const hoppCompleterExt = (completer: Completer): Extension => {
@@ -189,6 +194,7 @@ export function useCodemirror(
): { cursor: Ref<{ line: number; ch: number }> } {
const { subscribeToStream } = useStreamSubscriber()
const additionalExts = new Compartment()
const language = new Compartment()
const lineWrapping = new Compartment()
const placeholderConfig = new Compartment()
@@ -254,12 +260,24 @@ export function useCodemirror(
el.addEventListener("mouseup", debounceFn)
el.addEventListener("keyup", debounceFn)
const cursorPos = update.state.selection.main.head
const line = update.state.doc.lineAt(cursorPos)
cachedCursor.value = {
line: line.number - 1,
ch: cursorPos - line.from,
if (options.onUpdate) {
options.onUpdate(update)
}
if (update.selectionSet) {
const cursorPos = update.state.selection.main.head
const line = update.state.doc.lineAt(cursorPos)
cachedCursor.value = {
line: line.number - 1,
ch: cursorPos - line.from,
}
cursor.value = {
line: cachedCursor.value.line,
ch: cachedCursor.value.ch,
}
}
cursor.value = {
@@ -313,6 +331,7 @@ export function useCodemirror(
},
]),
EditorView.contentAttributes.of({ "data-enable-grammarly": "false" }),
additionalExts.of(options.additionalExts ?? []),
]
if (environmentTooltip) extensions.push(environmentTooltip.extension)
@@ -388,6 +407,15 @@ export function useCodemirror(
}
)
watch(
() => options.additionalExts,
(newExts) => {
view.value?.dispatch({
effects: additionalExts.reconfigure(newExts ?? []),
})
}
)
watch(
() => options.extendedEditorConfig.lineWrapping,
(newMode) => {

View File

@@ -14,7 +14,7 @@ type CloneMode = "noclone" | "shallow" | "deep"
*/
export function useReadonlyStream<T>(
stream$: Observable<T>,
initialValue: T,
initialValue?: T,
cloneMode: CloneMode = "shallow"
): Ref<T> {
let sub: Subscription | null = null

View File

@@ -1,289 +0,0 @@
import * as E from "fp-ts/Either"
import { BehaviorSubject } from "rxjs"
import {
getIntrospectionQuery,
buildClientSchema,
GraphQLSchema,
printSchema,
GraphQLObjectType,
GraphQLInputObjectType,
GraphQLEnumType,
GraphQLInterfaceType,
} from "graphql"
import { distinctUntilChanged, map } from "rxjs/operators"
import { GQLHeader, HoppGQLAuth } from "@hoppscotch/data"
import { getService } from "~/modules/dioc"
import { InterceptorService } from "~/services/interceptor.service"
const GQL_SCHEMA_POLL_INTERVAL = 7000
/**
GQLConnection deals with all the operations (like polling, schema extraction) that runs
when a connection is made to a GraphQL server.
*/
export class GQLConnection {
public isLoading$ = new BehaviorSubject<boolean>(false)
public connected$ = new BehaviorSubject<boolean>(false)
public schema$ = new BehaviorSubject<GraphQLSchema | null>(null)
public schemaString$ = this.schema$.pipe(
distinctUntilChanged(),
map((schema) => {
if (!schema) return null
return printSchema(schema)
})
)
public queryFields$ = this.schema$.pipe(
distinctUntilChanged(),
map((schema) => {
if (!schema) return null
const fields = schema.getQueryType()?.getFields()
if (!fields) return null
return Object.values(fields)
})
)
public mutationFields$ = this.schema$.pipe(
distinctUntilChanged(),
map((schema) => {
if (!schema) return null
const fields = schema.getMutationType()?.getFields()
if (!fields) return null
return Object.values(fields)
})
)
public subscriptionFields$ = this.schema$.pipe(
distinctUntilChanged(),
map((schema) => {
if (!schema) return null
const fields = schema.getSubscriptionType()?.getFields()
if (!fields) return null
return Object.values(fields)
})
)
public graphqlTypes$ = this.schema$.pipe(
distinctUntilChanged(),
map((schema) => {
if (!schema) return null
const typeMap = schema.getTypeMap()
const queryTypeName = schema.getQueryType()?.name ?? ""
const mutationTypeName = schema.getMutationType()?.name ?? ""
const subscriptionTypeName = schema.getSubscriptionType()?.name ?? ""
return Object.values(typeMap).filter((type) => {
return (
!type.name.startsWith("__") &&
![queryTypeName, mutationTypeName, subscriptionTypeName].includes(
type.name
) &&
(type instanceof GraphQLObjectType ||
type instanceof GraphQLInputObjectType ||
type instanceof GraphQLEnumType ||
type instanceof GraphQLInterfaceType)
)
})
})
)
private timeoutSubscription: any
public connect(url: string, headers: GQLHeader[], auth: HoppGQLAuth) {
if (this.connected$.value) {
throw new Error(
"A connection is already running. Close it before starting another."
)
}
// Polling
this.connected$.next(true)
const poll = async () => {
await this.getSchema(url, headers, auth)
this.timeoutSubscription = setTimeout(() => {
poll()
}, GQL_SCHEMA_POLL_INTERVAL)
}
poll()
}
public disconnect() {
if (!this.connected$.value) {
throw new Error("No connections are running to be disconnected")
}
clearTimeout(this.timeoutSubscription)
this.connected$.next(false)
}
public reset() {
if (this.connected$.value) this.disconnect()
this.isLoading$.next(false)
this.connected$.next(false)
this.schema$.next(null)
}
private async getSchema(
url: string,
reqHeaders: GQLHeader[],
auth: HoppGQLAuth
) {
try {
this.isLoading$.next(true)
const introspectionQuery = JSON.stringify({
query: getIntrospectionQuery(),
})
const headers = reqHeaders.filter((x) => x.active && x.key !== "")
// TODO: Support a better b64 implementation than btoa ?
if (auth.authType === "basic") {
const username = auth.username
const password = auth.password
headers.push({
active: true,
key: "Authorization",
value: `Basic ${btoa(`${username}:${password}`)}`,
})
} else if (auth.authType === "bearer" || auth.authType === "oauth-2") {
headers.push({
active: true,
key: "Authorization",
value: `Bearer ${auth.token}`,
})
} else if (auth.authType === "api-key") {
const { key, value, addTo } = auth
if (addTo === "Headers") {
headers.push({
active: true,
key,
value,
})
}
}
const finalHeaders: Record<string, string> = {}
headers.forEach((x) => (finalHeaders[x.key] = x.value))
const reqOptions = {
method: "POST" as const,
url,
headers: {
...finalHeaders,
"content-type": "application/json",
},
data: introspectionQuery,
}
const interceptorService = getService(InterceptorService)
const res = await interceptorService.runRequest(reqOptions).response
if (E.isLeft(res)) {
console.error(res.left)
throw new Error(res.left.toString())
}
const data = res.right
// HACK : Temporary trailing null character issue from the extension fix
const response = new TextDecoder("utf-8")
.decode(data.data as any)
.replace(/\0+$/, "")
const introspectResponse = JSON.parse(response)
const schema = buildClientSchema(introspectResponse.data)
this.schema$.next(schema)
this.isLoading$.next(false)
} catch (e: any) {
console.error(e)
this.disconnect()
}
}
public async runQuery(
url: string,
headers: GQLHeader[],
query: string,
variables: string,
auth: HoppGQLAuth
) {
const finalHeaders: Record<string, string> = {}
const parsedVariables = JSON.parse(variables || "{}")
const params: Record<string, string> = {}
if (auth.authActive) {
if (auth.authType === "basic") {
const username = auth.username
const password = auth.password
finalHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`
} else if (auth.authType === "bearer" || auth.authType === "oauth-2") {
finalHeaders.Authorization = `Bearer ${auth.token}`
} else if (auth.authType === "api-key") {
const { key, value, addTo } = auth
if (addTo === "Headers") {
finalHeaders[key] = value
} else if (addTo === "Query params") {
params[key] = value
}
}
}
headers
.filter((item) => item.active && item.key !== "")
.forEach(({ key, value }) => (finalHeaders[key] = value))
const reqOptions = {
method: "POST" as const,
url,
headers: {
...finalHeaders,
"content-type": "application/json",
},
data: JSON.stringify({
query,
variables: parsedVariables,
}),
params: {
...params,
},
}
const interceptorService = getService(InterceptorService)
const result = await interceptorService.runRequest(reqOptions).response
if (E.isLeft(result)) {
console.error(result.left)
throw new Error(result.left.toString())
}
const res = result.right
// HACK: Temporary trailing null character issue from the extension fix
const responseText = new TextDecoder("utf-8")
.decode(res.data as any)
.replace(/\0+$/, "")
return responseText
}
}

View File

@@ -56,6 +56,7 @@ export type HoppAction =
| "history.clear" // Clear REST History
| "user.login" // Login to Hoppscotch
| "user.logout" // Log out of Hoppscotch
| "editor.format" // Format editor content
/**
* Defines the arguments, if present for a given type that is required to be passed on

View File

@@ -71,7 +71,7 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
const selectedEnvType = getSelectedEnvironmentType()
const envTypeIcon = `<span class="inline-flex -my-2 -mx-0.5 opacity-65 items-center text-base font-icon">${
const envTypeIcon = `<span class="inline-flex items-center opacity-65 -mx-0.5 text-base font-icon">${
selectedEnvType === "TEAM_ENV" ? "group" : "person"
}</span>`
@@ -88,7 +88,7 @@ const cursorTooltipField = (aggregateEnvs: AggregateEnvironment[]) =>
variableName: parsedEnvKey,
})
})
editIcon.innerHTML = `<span class="inline-flex items-center px-1 -mx-1 -my-2 text-base font-icon">edit</span>`
editIcon.innerHTML = `<span class="inline-flex items-center px-1 -mx-1 text-base font-icon">edit</span>`
tooltip.appendChild(editIcon)
}

View File

@@ -0,0 +1,58 @@
import { EditorState, Range } from "@codemirror/state"
import { Decoration, ViewPlugin } from "@codemirror/view"
import { syntaxTree } from "@codemirror/language"
function getOperationDefsPosInEditor(state: EditorState) {
const tree = syntaxTree(state)
const defs: Array<{ from: number; to: number }> = []
tree.iterate({
enter({ name, from, to }) {
if (name === "OperationDefinition") {
defs.push({ from, to })
}
},
})
return defs
}
function generateSelectedOpDecors(state: EditorState) {
const selectedPos = state.selection.main.head // Cursor Pos
const defsPositions = getOperationDefsPosInEditor(state)
if (defsPositions.length === 1) return Decoration.none
const decors = defsPositions
.map(({ from, to }) => ({
selected: selectedPos >= from && selectedPos <= to,
from,
to,
}))
.map((info) => ({
...info,
decor: Decoration.mark({
class: info.selected
? "gql-operation-highlight"
: "gql-operation-not-highlight",
inclusive: true,
}),
}))
.map(({ from, to, decor }) => <Range<Decoration>>{ from, to, value: decor }) // Convert to Range<Decoration> (Range from "@codemirror/view")
return Decoration.set(decors)
}
export const selectedGQLOpHighlight = ViewPlugin.define(
(view) => ({
decorations: generateSelectedOpDecors(view.state),
update(u) {
this.decorations = generateSelectedOpDecors(u.state)
},
}),
{
decorations: (v) => v.decorations,
}
)

View File

@@ -0,0 +1,390 @@
import { GQLHeader, HoppGQLAuth, makeGQLRequest } from "@hoppscotch/data"
import { OperationType } from "@urql/core"
import {
GraphQLEnumType,
GraphQLInputObjectType,
GraphQLInterfaceType,
GraphQLObjectType,
GraphQLSchema,
buildClientSchema,
getIntrospectionQuery,
printSchema,
} from "graphql"
import { computed, reactive, ref } from "vue"
import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history"
import { currentTabID } from "./tab"
import { getService } from "~/modules/dioc"
import { InterceptorService } from "~/services/interceptor.service"
import * as E from "fp-ts/Either"
const GQL_SCHEMA_POLL_INTERVAL = 7000
type RunQueryOptions = {
name?: string
url: string
headers: GQLHeader[]
query: string
variables: string
auth: HoppGQLAuth
operationName: string | undefined
operationType: OperationType
}
export type GQLResponseEvent = {
time: number
operationName: string | undefined
operationType: OperationType
data: string
rawQuery?: RunQueryOptions
}
export type ConnectionState = "CONNECTING" | "CONNECTED" | "DISCONNECTED"
export type SubscriptionState = "SUBSCRIBING" | "SUBSCRIBED" | "UNSUBSCRIBED"
const GQL = {
CONNECTION_INIT: "connection_init",
CONNECTION_ACK: "connection_ack",
CONNECTION_ERROR: "connection_error",
CONNECTION_KEEP_ALIVE: "ka",
START: "start",
STOP: "stop",
CONNECTION_TERMINATE: "connection_terminate",
DATA: "data",
ERROR: "error",
COMPLETE: "complete",
}
type Connection = {
state: ConnectionState
subscriptionState: Map<string, SubscriptionState>
socket: WebSocket | undefined
schema: GraphQLSchema | null
}
export const connection = reactive<Connection>({
state: "DISCONNECTED",
subscriptionState: new Map<string, SubscriptionState>(),
socket: undefined,
schema: null,
})
export const schema = computed(() => connection.schema)
export const subscriptionState = computed(() => {
return connection.subscriptionState.get(currentTabID.value)
})
export const gqlMessageEvent = ref<GQLResponseEvent | "reset">()
export const schemaString = computed(() => {
if (!connection.schema) return ""
return printSchema(connection.schema, {
commentDescriptions: true,
})
})
export const queryFields = computed(() => {
if (!connection.schema) return []
const fields = connection.schema.getQueryType()?.getFields()
if (!fields) return []
return Object.values(fields)
})
export const mutationFields = computed(() => {
if (!connection.schema) return []
const fields = connection.schema.getMutationType()?.getFields()
if (!fields) return []
return Object.values(fields)
})
export const subscriptionFields = computed(() => {
if (!connection.schema) return []
const fields = connection.schema.getSubscriptionType()?.getFields()
if (!fields) return []
return Object.values(fields)
})
export const graphqlTypes = computed(() => {
if (!connection.schema) return []
const typeMap = connection.schema.getTypeMap()
const queryTypeName = connection.schema.getQueryType()?.name ?? ""
const mutationTypeName = connection.schema.getMutationType()?.name ?? ""
const subscriptionTypeName =
connection.schema.getSubscriptionType()?.name ?? ""
return Object.values(typeMap).filter((type) => {
return (
!type.name.startsWith("__") &&
![queryTypeName, mutationTypeName, subscriptionTypeName].includes(
type.name
) &&
(type instanceof GraphQLObjectType ||
type instanceof GraphQLInputObjectType ||
type instanceof GraphQLEnumType ||
type instanceof GraphQLInterfaceType)
)
})
})
let timeoutSubscription: any
export const connect = (url: string, headers: GQLHeader[]) => {
if (connection.state === "CONNECTED") {
throw new Error(
"A connection is already running. Close it before starting another."
)
}
// Polling
connection.state = "CONNECTED"
const poll = async () => {
await getSchema(url, headers)
timeoutSubscription = setTimeout(() => {
poll()
}, GQL_SCHEMA_POLL_INTERVAL)
}
poll()
}
export const disconnect = () => {
if (connection.state !== "CONNECTED") {
throw new Error("No connections are running to be disconnected")
}
clearTimeout(timeoutSubscription)
connection.state = "DISCONNECTED"
}
export const reset = () => {
if (connection.state === "CONNECTED") disconnect()
connection.state = "DISCONNECTED"
connection.schema = null
}
const getSchema = async (url: string, headers: GQLHeader[]) => {
try {
const introspectionQuery = JSON.stringify({
query: getIntrospectionQuery(),
})
const finalHeaders: Record<string, string> = {}
headers
.filter((x) => x.active && x.key !== "")
.forEach((x) => (finalHeaders[x.key] = x.value))
const reqOptions = {
method: "POST",
url,
headers: {
...finalHeaders,
"content-type": "application/json",
},
data: introspectionQuery,
}
const interceptorService = getService(InterceptorService)
const res = await interceptorService.runRequest(reqOptions).response
if (E.isLeft(res)) {
console.error(res.left)
throw new Error(res.left.toString())
}
const data = res.right
// HACK : Temporary trailing null character issue from the extension fix
const response = new TextDecoder("utf-8")
.decode(data.data as any)
.replace(/\0+$/, "")
const introspectResponse = JSON.parse(response)
const schema = buildClientSchema(introspectResponse.data)
connection.schema = schema
} catch (e: any) {
console.error(e)
disconnect()
}
}
export const runGQLOperation = async (options: RunQueryOptions) => {
const { url, headers, query, variables, auth, operationName, operationType } =
options
const finalHeaders: Record<string, string> = {}
const parsedVariables = JSON.parse(variables || "{}")
const params: Record<string, string> = {}
if (auth.authActive) {
if (auth.authType === "basic") {
const username = auth.username
const password = auth.password
finalHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`
} else if (auth.authType === "bearer" || auth.authType === "oauth-2") {
finalHeaders.Authorization = `Bearer ${auth.token}`
} else if (auth.authType === "api-key") {
const { key, value, addTo } = auth
if (addTo === "Headers") {
finalHeaders[key] = value
} else if (addTo === "Query params") {
params[key] = value
}
}
}
headers
.filter((item) => item.active && item.key !== "")
.forEach(({ key, value }) => (finalHeaders[key] = value))
const reqOptions = {
method: "POST",
url,
headers: {
...finalHeaders,
"content-type": "application/json",
},
data: JSON.stringify({
query,
variables: parsedVariables,
operationName,
}),
params: {
...params,
},
}
if (operationType === "subscription") {
return runSubscription(options)
}
const interceptorService = getService(InterceptorService)
const result = await interceptorService.runRequest(reqOptions).response
if (E.isLeft(result)) {
console.error(result.left)
throw new Error(result.left.toString())
}
const res = result.right
// HACK: Temporary trailing null character issue from the extension fix
const responseText = new TextDecoder("utf-8")
.decode(res.data as any)
.replace(/\0+$/, "")
gqlMessageEvent.value = {
time: Date.now(),
operationName: operationName ?? "query",
data: responseText,
rawQuery: options,
operationType,
}
addQueryToHistory(options, responseText)
return responseText
}
export const runSubscription = (options: RunQueryOptions) => {
const { url, query, operationName } = options
const wsUrl = url.replace(/^http/, "ws")
connection.subscriptionState.set(currentTabID.value, "SUBSCRIBING")
connection.socket = new WebSocket(wsUrl, "graphql-ws")
connection.socket.onopen = (event) => {
console.log("WebSocket is open now.", event)
connection.socket?.send(
JSON.stringify({
type: GQL.CONNECTION_INIT,
payload: {},
})
)
connection.socket?.send(
JSON.stringify({
type: GQL.START,
id: "1",
payload: { query, operationName },
})
)
}
gqlMessageEvent.value = "reset"
connection.socket.onmessage = (event) => {
const data = JSON.parse(event.data)
switch (data.type) {
case GQL.CONNECTION_ACK: {
connection.subscriptionState.set(currentTabID.value, "SUBSCRIBED")
break
}
case GQL.CONNECTION_ERROR: {
console.error(data.payload)
break
}
case GQL.CONNECTION_KEEP_ALIVE: {
break
}
case GQL.DATA: {
gqlMessageEvent.value = {
time: Date.now(),
operationName,
data: JSON.stringify(data.payload),
operationType: "subscription",
}
break
}
case GQL.COMPLETE: {
console.log("completed", data.id)
break
}
}
}
connection.socket.onclose = (event) => {
console.log("WebSocket is closed now.", event)
connection.subscriptionState.set(currentTabID.value, "UNSUBSCRIBED")
}
addQueryToHistory(options, "")
return connection.socket
}
export const socketDisconnect = () => {
connection.socket?.close()
}
const addQueryToHistory = (options: RunQueryOptions, response: string) => {
const { name, url, headers, query, variables, auth } = options
addGraphqlHistoryEntry(
makeGQLHistoryEntry({
request: makeGQLRequest({
name: name ?? "Untitled Request",
url,
query,
headers,
variables,
auth,
}),
response,
star: false,
})
)
}

View File

@@ -0,0 +1,33 @@
import { parse, print } from "graphql"
import { HoppGQLRequest, GQL_REQ_SCHEMA_VERSION } from "@hoppscotch/data"
const DEFAULT_QUERY = print(
parse(
`
query Request {
method
url
headers {
key
value
}
}
`,
{ allowLegacyFragmentVariables: true }
)
)
export const getDefaultGQLRequest = (): HoppGQLRequest => ({
v: GQL_REQ_SCHEMA_VERSION,
name: "Untitled",
url: "https://echo.hoppscotch.io/graphql",
headers: [],
variables: `{
"id": "1"
}`,
query: DEFAULT_QUERY,
auth: {
authType: "none",
authActive: true,
},
})

View File

@@ -0,0 +1,58 @@
import { HoppGQLRequest } from "@hoppscotch/data"
export type HoppGQLSaveContext =
| {
/**
* The origin source of the request
*/
originLocation: "user-collection"
/**
* Path to the request folder
*/
folderPath: string
/**
* Index to the request
*/
requestIndex: number
}
| {
/**
* The origin source of the request
*/
originLocation: "team-collection"
/**
* ID of the request in the team
*/
requestID: string
/**
* ID of the team
*/
teamID?: string
/**
* ID of the collection loaded
*/
collectionID?: string
}
| null
/**
* Defines a live 'document' (something that is open and being edited) in the app
*/
export type HoppGQLDocument = {
/**
* The request as it is in the document
*/
request: HoppGQLRequest
/**
* Whether the request has any unsaved changes
* (atleast as far as we can say)
*/
isDirty: boolean
/**
* Info about where this request should be saved.
* This contains where the request is originated from basically.
*/
saveContext?: HoppGQLSaveContext
}

View File

@@ -0,0 +1,52 @@
import * as Eq from "fp-ts/Eq"
import * as S from "fp-ts/string"
import isEqual from "lodash-es/isEqual"
/*
* Eq-s are fp-ts an interface (type class) that defines how the equality
* of 2 values of a certain type are matched as equal
*/
/**
* Create an Eq from a non-undefinable value and makes it accept undefined
* @param eq The non nullable Eq to add to
* @returns The updated Eq which accepts undefined
*/
export const undefinedEq = <T>(eq: Eq.Eq<T>): Eq.Eq<T | undefined> => ({
equals(x: T | undefined, y: T | undefined) {
if (x !== undefined && y !== undefined) {
return eq.equals(x, y)
}
return x === undefined && y === undefined
},
})
/**
* An Eq which compares by transforming based on a mapping function and then applying the Eq to it
* @param map The mapping function to map values to
* @param eq The Eq which takes the value which the map returns
* @returns An Eq which takes the input of the mapping function
*/
export const mapThenEq = <A, B>(map: (x: A) => B, eq: Eq.Eq<B>): Eq.Eq<A> => ({
equals(x: A, y: A) {
return eq.equals(map(x), map(y))
},
})
/**
* An Eq which checks equality of 2 string in a case insensitive way
*/
export const stringCaseInsensitiveEq: Eq.Eq<string> = mapThenEq(
S.toLowerCase,
S.Eq
)
/**
* An Eq that does equality check with Lodash's isEqual function
*/
export const lodashIsEqualEq: Eq.Eq<any> = {
equals(x: any, y: any) {
return isEqual(x, y)
},
}

View File

@@ -0,0 +1,54 @@
import { HoppGQLRequest, ValidContentTypes } from "@hoppscotch/data"
import * as Eq from "fp-ts/Eq"
import * as N from "fp-ts/number"
import * as S from "fp-ts/string"
import { lodashIsEqualEq, mapThenEq, undefinedEq } from "./eq"
export type HoppGQLParam = {
key: string
value: string
active: boolean
}
export type HoppGQLHeader = {
key: string
value: string
active: boolean
}
export type FormDataKeyValue = {
key: string
active: boolean
} & ({ isFile: true; value: Blob[] } | { isFile: false; value: string })
export type HoppGQLReqBodyFormData = {
contentType: "multipart/form-data"
body: FormDataKeyValue[]
}
export type HoppGQLReqBody =
| {
contentType: Exclude<ValidContentTypes, "multipart/form-data">
body: string
}
| HoppGQLReqBodyFormData
| {
contentType: null
body: null
}
export const HoppGQLRequestEq = Eq.struct<HoppGQLRequest>({
id: undefinedEq(S.Eq),
v: N.Eq,
name: S.Eq,
url: S.Eq,
headers: mapThenEq(
(arr) => arr.filter((h) => h.key !== "" && h.value !== ""),
lodashIsEqualEq
),
query: S.Eq,
variables: S.Eq,
auth: lodashIsEqualEq,
})
export const isEqualHoppGQLRequest = HoppGQLRequestEq.equals

View File

@@ -0,0 +1,199 @@
import { refWithControl } from "@vueuse/core"
import { isEqual } from "lodash-es"
import { v4 as uuidV4 } from "uuid"
import { computed, reactive, ref, shallowReadonly, watch } from "vue"
import { HoppTestResult } from "../types/HoppTestResult"
import { GQLResponseEvent } from "./connection"
import { getDefaultGQLRequest } from "./default"
import { HoppGQLDocument, HoppGQLSaveContext } from "./document"
export type HoppGQLTab = {
id: string
document: HoppGQLDocument
response?: GQLResponseEvent[] | null
testResults?: HoppTestResult | null
}
export type PersistableGQLTabState = {
lastActiveTabID: string
orderedDocs: Array<{
tabID: string
doc: HoppGQLDocument
}>
}
export const currentTabID = refWithControl("test", {
onBeforeChange(newTabID) {
if (!newTabID || !tabMap.has(newTabID)) {
console.warn(
`Tried to set current tab id to an invalid value. (value: ${newTabID})`
)
// Don't allow change
return false
}
},
})
const tabMap = reactive(
new Map<string, HoppGQLTab>([
[
"test",
{
id: "test",
document: {
request: getDefaultGQLRequest(),
isDirty: false,
},
},
],
])
)
const tabOrdering = ref<string[]>(["test"])
watch(
tabOrdering,
(newOrdering) => {
if (!currentTabID.value || !newOrdering.includes(currentTabID.value)) {
currentTabID.value = newOrdering[newOrdering.length - 1] // newOrdering should always be non-empty
}
},
{ deep: true }
)
export const persistableTabState = computed<PersistableGQLTabState>(() => ({
lastActiveTabID: currentTabID.value,
orderedDocs: tabOrdering.value.map((tabID) => {
const tab = tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: tab.document,
}
}),
}))
export const currentActiveTab = computed(() => tabMap.get(currentTabID.value)!) // Guaranteed to not be undefined
// TODO: Mark this unknown and do validations
export function loadTabsFromPersistedState(data: PersistableGQLTabState) {
if (data) {
tabMap.clear()
tabOrdering.value = []
for (const doc of data.orderedDocs) {
tabMap.set(doc.tabID, {
id: doc.tabID,
document: doc.doc,
})
tabOrdering.value.push(doc.tabID)
}
currentTabID.value = data.lastActiveTabID
}
}
/**
* Returns all the active Tab IDs in order
*/
export function getActiveTabs() {
return shallowReadonly(
computed(() => tabOrdering.value.map((x) => tabMap.get(x)!))
)
}
export function getTabRef(tabID: string) {
return computed({
get() {
const result = tabMap.get(tabID)
if (result === undefined) throw new Error(`Invalid tab id: ${tabID}`)
return result
},
set(value) {
return tabMap.set(tabID, value)
},
})
}
function generateNewTabID() {
while (true) {
const id = uuidV4()
if (!tabMap.has(id)) return id
}
}
export function updateTab(tabUpdate: HoppGQLTab) {
if (!tabMap.has(tabUpdate.id)) {
console.warn(
`Cannot update tab as tab with that tab id does not exist (id: ${tabUpdate.id})`
)
}
tabMap.set(tabUpdate.id, tabUpdate)
}
export function createNewTab(document: HoppGQLDocument, switchToIt = true) {
const id = generateNewTabID()
const tab: HoppGQLTab = { id, document }
tabMap.set(id, tab)
tabOrdering.value.push(id)
if (switchToIt) {
currentTabID.value = id
}
return tab
}
export function updateTabOrdering(fromIndex: number, toIndex: number) {
tabOrdering.value.splice(
toIndex,
0,
tabOrdering.value.splice(fromIndex, 1)[0]
)
}
export function closeTab(tabID: string) {
if (!tabMap.has(tabID)) {
console.warn(`Tried to close a tab which does not exist (tab id: ${tabID})`)
return
}
if (tabOrdering.value.length === 1) {
console.warn(
`Tried to close the only tab open, which is not allowed. (tab id: ${tabID})`
)
return
}
tabOrdering.value.splice(tabOrdering.value.indexOf(tabID), 1)
tabMap.delete(tabID)
}
export function getTabRefWithSaveContext(ctx: HoppGQLSaveContext) {
for (const tab of tabMap.values()) {
// For `team-collection` request id can be considered unique
if (ctx && ctx.originLocation === "team-collection") {
if (
tab.document.saveContext?.originLocation === "team-collection" &&
tab.document.saveContext.requestID === ctx.requestID
) {
return getTabRef(tab.id)
}
} else if (isEqual(ctx, tab.document.saveContext)) return getTabRef(tab.id)
}
return null
}
export function getTabsRefTo(func: (tab: HoppGQLTab) => boolean) {
return Array.from(tabMap.values())
.filter(func)
.map((tab) => getTabRef(tab.id))
}

View File

@@ -67,6 +67,7 @@ export const bindings: {
"ctrl-shift-p": "response.preview.toggle",
"ctrl-j": "response.file.download",
"ctrl-.": "response.copy",
"ctrl-shift-l": "editor.format",
}
/**

View File

@@ -1,278 +0,0 @@
import { distinctUntilChanged, pluck } from "rxjs/operators"
import {
GQLHeader,
HoppGQLRequest,
makeGQLRequest,
HoppGQLAuth,
} from "@hoppscotch/data"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
import { useStream } from "@composables/stream"
type GQLSession = {
request: HoppGQLRequest
schema: string
response: string
}
export const defaultGQLSession: GQLSession = {
request: makeGQLRequest({
name: "Untitled request",
url: "https://echo.hoppscotch.io/graphql",
headers: [],
variables: `{
"id": "1"
}`,
query: `query Request {
method
url
headers {
key
value
}
}
`,
auth: {
authType: "none",
authActive: true,
},
}),
schema: "",
response: "",
}
const dispatchers = defineDispatchers({
setSession(_: GQLSession, { session }: { session: GQLSession }) {
return session
},
setName(curr: GQLSession, { newName }: { newName: string }) {
return {
request: {
...curr.request,
name: newName,
},
}
},
setURL(curr: GQLSession, { newURL }: { newURL: string }) {
return {
request: {
...curr.request,
url: newURL,
},
}
},
setHeaders(curr: GQLSession, { headers }: { headers: GQLHeader[] }) {
return {
request: {
...curr.request,
headers,
},
}
},
addHeader(curr: GQLSession, { header }: { header: GQLHeader }) {
return {
request: {
...curr.request,
headers: [...curr.request.headers, header],
},
}
},
removeHeader(curr: GQLSession, { headerIndex }: { headerIndex: number }) {
return {
request: {
...curr.request,
headers: curr.request.headers.filter((_x, i) => i !== headerIndex),
},
}
},
updateHeader(
curr: GQLSession,
{
headerIndex,
updatedHeader,
}: { headerIndex: number; updatedHeader: GQLHeader }
) {
return {
request: {
...curr.request,
headers: curr.request.headers.map((x, i) =>
i === headerIndex ? updatedHeader : x
),
},
}
},
setQuery(curr: GQLSession, { newQuery }: { newQuery: string }) {
return {
request: {
...curr.request,
query: newQuery,
},
}
},
setVariables(curr: GQLSession, { newVariables }: { newVariables: string }) {
return {
request: {
...curr.request,
variables: newVariables,
},
}
},
setResponse(_: GQLSession, { newResponse }: { newResponse: string }) {
return {
response: newResponse,
}
},
setAuth(curr: GQLSession, { newAuth }: { newAuth: HoppGQLAuth }) {
return {
request: {
...curr.request,
auth: newAuth,
},
}
},
})
export const gqlSessionStore = new DispatchingStore(
defaultGQLSession,
dispatchers
)
export function setGQLURL(newURL: string) {
gqlSessionStore.dispatch({
dispatcher: "setURL",
payload: {
newURL,
},
})
}
export function setGQLHeaders(headers: GQLHeader[]) {
gqlSessionStore.dispatch({
dispatcher: "setHeaders",
payload: {
headers,
},
})
}
export function addGQLHeader(header: GQLHeader) {
gqlSessionStore.dispatch({
dispatcher: "addHeader",
payload: {
header,
},
})
}
export function updateGQLHeader(headerIndex: number, updatedHeader: GQLHeader) {
gqlSessionStore.dispatch({
dispatcher: "updateHeader",
payload: {
headerIndex,
updatedHeader,
},
})
}
export function removeGQLHeader(headerIndex: number) {
gqlSessionStore.dispatch({
dispatcher: "removeHeader",
payload: {
headerIndex,
},
})
}
export function clearGQLHeaders() {
gqlSessionStore.dispatch({
dispatcher: "setHeaders",
payload: {
headers: [],
},
})
}
export function setGQLQuery(newQuery: string) {
gqlSessionStore.dispatch({
dispatcher: "setQuery",
payload: {
newQuery,
},
})
}
export function setGQLVariables(newVariables: string) {
gqlSessionStore.dispatch({
dispatcher: "setVariables",
payload: {
newVariables,
},
})
}
export function setGQLResponse(newResponse: string) {
gqlSessionStore.dispatch({
dispatcher: "setResponse",
payload: {
newResponse,
},
})
}
export function getGQLSession() {
return gqlSessionStore.value
}
export function setGQLSession(session: GQLSession) {
gqlSessionStore.dispatch({
dispatcher: "setSession",
payload: {
session,
},
})
}
export function useGQLRequestName() {
return useStream(gqlName$, gqlSessionStore.value.request.name, (newName) => {
gqlSessionStore.dispatch({
dispatcher: "setName",
payload: { newName },
})
})
}
export function setGQLAuth(newAuth: HoppGQLAuth) {
gqlSessionStore.dispatch({
dispatcher: "setAuth",
payload: {
newAuth,
},
})
}
export const gqlName$ = gqlSessionStore.subject$.pipe(
pluck("request", "name"),
distinctUntilChanged()
)
export const gqlURL$ = gqlSessionStore.subject$.pipe(
pluck("request", "url"),
distinctUntilChanged()
)
export const gqlQuery$ = gqlSessionStore.subject$.pipe(
pluck("request", "query"),
distinctUntilChanged()
)
export const gqlVariables$ = gqlSessionStore.subject$.pipe(
pluck("request", "variables"),
distinctUntilChanged()
)
export const gqlHeaders$ = gqlSessionStore.subject$.pipe(
pluck("request", "headers"),
distinctUntilChanged()
)
export const gqlResponse$ = gqlSessionStore.subject$.pipe(
pluck("response"),
distinctUntilChanged()
)
export const gqlAuth$ = gqlSessionStore.subject$.pipe(pluck("request", "auth"))

View File

@@ -48,8 +48,10 @@ import {
loadTabsFromPersistedState,
persistableTabState,
} from "~/helpers/rest/tab"
import { debounceTime } from "rxjs"
import { gqlSessionStore, setGQLSession } from "./GQLSession"
import {
loadTabsFromPersistedState as loadGQLTabsFromPersistedState,
persistableTabState as persistableGQLTabState,
} from "~/helpers/graphql/tab"
function checkAndMigrateOldSettings() {
if (window.localStorage.getItem("selectedEnvIndex")) {
@@ -340,26 +342,27 @@ export function setupRESTTabsPersistence() {
)
}
// temporary persistence for GQL session
export function setupGQLPersistence() {
function setupGQLTabsPersistence() {
try {
const state = window.localStorage.getItem("gqlState")
const state = window.localStorage.getItem("gqlTabState")
if (state) {
const data = JSON.parse(state)
data["schema"] = ""
data["response"] = ""
setGQLSession(data)
loadGQLTabsFromPersistedState(data)
}
} catch (e) {
console.error(
`Failed parsing persisted GraphQL state, state:`,
window.localStorage.getItem("gqlState")
`Failed parsing persisted tab state, state:`,
window.localStorage.getItem("gqlTabState")
)
}
gqlSessionStore.subject$.pipe(debounceTime(500)).subscribe((state) => {
window.localStorage.setItem("gqlState", JSON.stringify(state))
})
watchDebounced(
persistableGQLTabState,
(state) => {
window.localStorage.setItem("gqlTabState", JSON.stringify(state))
},
{ debounce: 500, deep: true }
)
}
export function setupLocalPersistence() {
@@ -368,7 +371,9 @@ export function setupLocalPersistence() {
setupLocalStatePersistence()
setupSettingsPersistence()
setupRESTTabsPersistence()
setupGQLPersistence()
setupGQLTabsPersistence()
setupHistoryPersistence()
setupCollectionsPersistence()
setupGlobalEnvsPersistence()

View File

@@ -1,48 +1,161 @@
<template>
<AppPaneLayout layout-id="graphql">
<template #primary>
<GraphqlRequest :conn="gqlConn" />
<GraphqlRequestOptions :conn="gqlConn" />
</template>
<template #secondary>
<GraphqlResponse :conn="gqlConn" />
</template>
<template #sidebar>
<GraphqlSidebar :conn="gqlConn" />
</template>
</AppPaneLayout>
<div>
<AppPaneLayout layout-id="graphql">
<template #primary>
<GraphqlRequest />
<HoppSmartWindows
v-if="currentTabID"
:id="'gql_windows'"
v-model="currentTabID"
@remove-tab="removeTab"
@add-tab="addNewTab"
@sort="sortTabs"
>
<HoppSmartWindow
v-for="tab in tabs"
:id="tab.id"
:key="'removable_tab_' + tab.id"
:label="tab.document.request.name"
:is-removable="tabs.length > 1"
:close-visibility="'hover'"
>
<template #tabhead>
<div
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
:title="tab.document.request.name"
class="truncate px-2"
>
<span class="leading-8 px-2">
{{ tab.document.request.name }}
</span>
</div>
</template>
<template #suffix>
<span
v-if="tab.document.isDirty"
class="flex items-center justify-center text-secondary group-hover:hidden w-4"
>
<svg
viewBox="0 0 24 24"
width="1.2em"
height="1.2em"
class="h-1.5 w-1.5"
>
<circle cx="12" cy="12" r="12" fill="currentColor"></circle>
</svg>
</span>
</template>
<GraphqlRequestTab
:model-value="tab"
@update:model-value="onTabUpdate"
/>
</HoppSmartWindow>
</HoppSmartWindows>
</template>
<template #sidebar>
<GraphqlSidebar />
</template>
</AppPaneLayout>
<HoppSmartConfirmModal
:show="confirmingCloseForTabID !== null"
:confirm="t('modal.close_unsaved_tab')"
:title="t('confirm.close_unsaved_tab')"
@hide-modal="onCloseConfirm"
@resolve="onResolveConfirm"
/>
</div>
</template>
<script setup lang="ts">
import { usePageHead } from "@composables/head"
import { useI18n } from "@composables/i18n"
import { GQLConnection } from "@helpers/GQLConnection"
import { cloneDeep } from "lodash-es"
import { computed, onBeforeUnmount } from "vue"
import { useService } from "dioc/vue"
import { computed, onBeforeUnmount, ref } from "vue"
import { defineActionHandler } from "~/helpers/actions"
import { getGQLSession, setGQLSession } from "~/newstore/GQLSession"
import { connection, disconnect } from "~/helpers/graphql/connection"
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
import {
HoppGQLTab,
closeTab,
createNewTab,
currentTabID,
getActiveTabs,
getTabRef,
updateTab,
updateTabOrdering,
} from "~/helpers/graphql/tab"
import { InspectionService } from "~/services/inspection"
const t = useI18n()
const inspectionService = useService(InspectionService)
const confirmingCloseForTabID = ref<string | null>(null)
usePageHead({
title: computed(() => t("navigation.graphql")),
})
const gqlConn = new GQLConnection()
const tabs = getActiveTabs()
const addNewTab = () => {
const tab = createNewTab({
request: getDefaultGQLRequest(),
isDirty: false,
})
currentTabID.value = tab.id
}
const sortTabs = (e: { oldIndex: number; newIndex: number }) => {
updateTabOrdering(e.oldIndex, e.newIndex)
}
const removeTab = (tabID: string) => {
const tabState = getTabRef(tabID).value
if (tabState.document.isDirty) {
confirmingCloseForTabID.value = tabID
} else {
closeTab(tabState.id)
inspectionService.deleteTabInspectorResult(tabState.id)
}
}
/**
* This function is closed when the confirm tab is closed by some means (even saving triggers close)
*/
const onCloseConfirm = () => {
confirmingCloseForTabID.value = null
}
/**
* Called when the user confirms they want to save the tab
*/
const onResolveConfirm = () => {
if (confirmingCloseForTabID.value) {
closeTab(confirmingCloseForTabID.value)
confirmingCloseForTabID.value = null
}
}
const onTabUpdate = (tab: HoppGQLTab) => {
updateTab(tab)
}
onBeforeUnmount(() => {
if (gqlConn.connected$.value) {
gqlConn.disconnect()
if (connection.state === "CONNECTED") {
disconnect()
}
})
defineActionHandler("gql.request.open", ({ request }) => {
const session = getGQLSession()
setGQLSession({
request: cloneDeep(request),
schema: session.schema,
response: session.response,
createNewTab({
request: request,
isDirty: false,
})
})
</script>

View File

@@ -458,12 +458,7 @@ defineActionHandler("rest.request.open", ({ doc }) => {
createNewTab(doc)
})
defineActionHandler("rest.request.rename", () => {
// TODO: Fix this hack to open the modal
setTimeout(() => {
openReqRenameModal()
}, 100)
})
defineActionHandler("rest.request.rename", openReqRenameModal)
const inspectionService = useService(InspectionService)
useService(HeaderInspectorService)

View File

@@ -16,14 +16,13 @@
>
<HoppButtonPrimary
:label="t('auth.login')"
class="mb-4"
@click="invokeAction('modals.login.toggle')"
/>
</HoppSmartPlaceholder>
<div v-else class="space-y-8">
<div
class="h-24 rounded bg-primaryLight -mb-11 md:h-32"
style="background-image: url(&quot;/images/cover.svg&quot;)"
style="background-image: url(/images/cover.svg)"
></div>
<div class="flex flex-col justify-between px-4 space-y-8 md:flex-row">
<div class="flex items-end">
@@ -102,6 +101,7 @@
</label>
<HoppSmartInput
v-model="displayName"
:autofocus="false"
styles="mt-2 md:max-w-sm"
:placeholder="`${t('settings.profile_name')}`"
>
@@ -124,6 +124,7 @@
</label>
<HoppSmartInput
v-model="emailAddress"
:autofocus="false"
styles="flex mt-2 md:max-w-sm"
:placeholder="`${t('settings.profile_name')}`"
>

View File

@@ -98,7 +98,7 @@
:label="tab.name"
:is-removable="tab.removable"
>
<template #icon>
<template #prefix>
<icon-lucide-rss
:style="{
color: tab.color,

View File

@@ -203,7 +203,6 @@
blank
:icon="IconExternalLink"
reverse
class="mb-4"
/>
</HoppSmartPlaceholder>
<div

View File

@@ -7,6 +7,7 @@
<HoppSmartInput
v-model="url"
type="url"
:autofocus="false"
styles="!inline-flex flex-1 space-x-2"
input-styles="w-full px-4 py-2 border rounded !bg-primaryLight border-divider text-secondaryDark"
:placeholder="`${t('websocket.url')}`"

View File

@@ -6,7 +6,7 @@ import { SpotlightService } from "../.."
import { GQLHistoryEntry, RESTHistoryEntry } from "~/newstore/history"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppAction, HoppActionWithArgs } from "~/helpers/actions"
import { defaultGQLSession } from "~/newstore/GQLSession"
import { getDefaultGQLRequest } from "~/helpers/graphql/default"
async function flushPromises() {
return await new Promise((r) => setTimeout(r))
@@ -230,7 +230,7 @@ describe("HistorySpotlightSearcherService", () => {
historyMock.gqlEntries.push({
request: {
...defaultGQLSession.request,
...getDefaultGQLRequest(),
url: "bla.com",
},
response: "{}",
@@ -267,7 +267,7 @@ describe("HistorySpotlightSearcherService", () => {
const historyEntry: GQLHistoryEntry = {
request: {
...defaultGQLSession.request,
...getDefaultGQLRequest(),
url: "bla.com",
},
response: "{}",
@@ -302,7 +302,7 @@ describe("HistorySpotlightSearcherService", () => {
historyMock.gqlEntries.push({
request: {
...defaultGQLSession.request,
...getDefaultGQLRequest(),
url: "bla.com",
},
response: "{}",
@@ -351,7 +351,7 @@ describe("HistorySpotlightSearcherService", () => {
historyMock.gqlEntries.push({
request: {
...defaultGQLSession.request,
...getDefaultGQLRequest(),
url: "bla.com",
},
response: "{}",
@@ -398,7 +398,7 @@ describe("HistorySpotlightSearcherService", () => {
it("none of the history entries are show when neither of the open actions are registered", async () => {
historyMock.gqlEntries.push({
request: {
...defaultGQLSession.request,
...getDefaultGQLRequest(),
url: "bla.com",
},
response: "{}",

View File

@@ -16,6 +16,7 @@ import IconFolder from "~icons/lucide/folder"
import RESTRequestSpotlightEntry from "~/components/app/spotlight/entry/RESTRequest.vue"
import GQLRequestSpotlightEntry from "~/components/app/spotlight/entry/GQLRequest.vue"
import { createNewTab } from "~/helpers/rest/tab"
import { createNewTab as createNewGQLTab } from "~/helpers/graphql/tab"
import { getTabRefWithSaveContext } from "~/helpers/rest/tab"
import { currentTabID } from "~/helpers/rest/tab"
import {
@@ -23,8 +24,6 @@ import {
HoppGQLRequest,
HoppRESTRequest,
} from "@hoppscotch/data"
import { setGQLSession } from "~/newstore/GQLSession"
import { cloneDeep } from "lodash-es"
import { hoppWorkspaceStore } from "~/newstore/workspace"
import { changeWorkspace } from "~/newstore/workspace"
@@ -305,11 +304,13 @@ export class CollectionsSpotlightSearcherService
if (!req) return
setGQLSession({
request: cloneDeep(req),
schema: "",
response: "",
})
createNewGQLTab(
{
request: req,
isDirty: false,
},
true
)
}
}
}

View File

@@ -71,7 +71,9 @@ export class EnvironmentsSpotlightSearcherService extends StaticSpotlightSearche
private selectedEnvIndex = useStreamStatic(
selectedEnvironmentIndex$,
null,
{
type: "NO_ENV_SELECTED",
},
() => {
/* noop */
}

View File

@@ -43,7 +43,7 @@ export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<
alternates: ["tab", "close", "close tab"],
icon: markRaw(IconWindow),
},
close_others_tab: {
close_other_tabs: {
text: this.t("spotlight.tab.close_others"),
alternates: ["tab", "close", "close all"],
icon: markRaw(IconWindow),
@@ -81,7 +81,7 @@ export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<
public onDocSelected(id: string): void {
if (id === "close_current_tab") closeTab(currentTabID.value)
if (id === "close_others_tab") closeOtherTabs(currentTabID.value)
if (id === "close_other_tabs") closeOtherTabs(currentTabID.value)
if (id === "open_new_tab")
createNewTab({
request: getDefaultRESTRequest(),

View File

@@ -8,7 +8,7 @@ import {
ref,
watch,
} from "vue"
import { invokeAction } from "~/helpers/actions"
import { activeActions$, invokeAction } from "~/helpers/actions"
import { getI18n } from "~/modules/i18n"
import {
SpotlightSearcher,
@@ -66,6 +66,14 @@ export class WorkspaceSpotlightSearcherService extends StaticSpotlightSearcherSe
}
)[0]
private activeActions = useStreamStatic(activeActions$, [], () => {
/* noop */
})[0]
private isLoggedInUser = computed(() =>
this.activeActions.value.includes("user.logout")
)
private isTeamSelected = computed(
() =>
this.workspace.value.type === "team" &&
@@ -77,6 +85,7 @@ export class WorkspaceSpotlightSearcherService extends StaticSpotlightSearcherSe
text: this.t("spotlight.workspace.new"),
alternates: ["new", "team", "workspace"],
icon: markRaw(IconUsers),
excludeFromSearch: computed(() => !this.isLoggedInUser.value),
},
edit_team: {
text: this.t("spotlight.workspace.edit"),

View File

@@ -96,9 +96,10 @@
"load_info_error": "Unable to load team info",
"load_list_error": "Unable to Load Teams List",
"members": "Number of members",
"name": "Team name",
"name": "Team Name",
"no_members": "No members in this team. Add members to this team to collaborate",
"no_pending_invites": "No pending invites",
"no_teams": "No teams found",
"pending_invites": "Pending invites",
"remove": "Remove",
"rename": "Rename",
@@ -110,6 +111,7 @@
"team_members_tab": "Team members",
"teams": "Teams",
"uid": "UID",
"unnamed": "(Unnamed Team)",
"valid_name": "Please enter a valid team name",
"valid_owner_email": "Please enter a valid owner email"
},
@@ -123,6 +125,7 @@
"created_on": "Created On",
"date": "Date",
"delete": "Delete",
"delete_user": "Delete User",
"email": "Email",
"email_address": "Email Address",
"id": "User ID",

View File

@@ -1,43 +1,44 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core';
import '@vue/runtime-core'
export {};
export {}
declare module '@vue/runtime-core' {
export interface GlobalComponents {
AppHeader: typeof import('./components/app/Header.vue')['default'];
AppLogin: typeof import('./components/app/Login.vue')['default'];
AppLogout: typeof import('./components/app/Logout.vue')['default'];
AppModal: typeof import('./components/app/Modal.vue')['default'];
AppSidebar: typeof import('./components/app/Sidebar.vue')['default'];
AppToast: typeof import('./components/app/Toast.vue')['default'];
DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default'];
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary'];
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary'];
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor'];
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete'];
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal'];
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput'];
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem'];
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'];
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'];
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner'];
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab'];
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default'];
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default'];
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default'];
IconLucideInbox: typeof import('~icons/lucide/inbox')['default'];
IconLucideUser: typeof import('~icons/lucide/user')['default'];
TeamsAdd: typeof import('./components/teams/Add.vue')['default'];
TeamsDetails: typeof import('./components/teams/Details.vue')['default'];
TeamsInvite: typeof import('./components/teams/Invite.vue')['default'];
TeamsMembers: typeof import('./components/teams/Members.vue')['default'];
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default'];
TeamsTable: typeof import('./components/teams/Table.vue')['default'];
Tippy: typeof import('vue-tippy')['Tippy'];
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default'];
UsersTable: typeof import('./components/users/Table.vue')['default'];
AppHeader: typeof import('./components/app/Header.vue')['default']
AppLogin: typeof import('./components/app/Login.vue')['default']
AppLogout: typeof import('./components/app/Logout.vue')['default']
AppModal: typeof import('./components/app/Modal.vue')['default']
AppSidebar: typeof import('./components/app/Sidebar.vue')['default']
AppToast: typeof import('./components/app/Toast.vue')['default']
DashboardMetricsCard: typeof import('./components/dashboard/MetricsCard.vue')['default']
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTable: typeof import('@hoppscotch/ui')['HoppSmartTable']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideUser: typeof import('~icons/lucide/user')['default']
TeamsAdd: typeof import('./components/teams/Add.vue')['default']
TeamsDetails: typeof import('./components/teams/Details.vue')['default']
TeamsInvite: typeof import('./components/teams/Invite.vue')['default']
TeamsMembers: typeof import('./components/teams/Members.vue')['default']
TeamsPendingInvites: typeof import('./components/teams/PendingInvites.vue')['default']
Tippy: typeof import('vue-tippy')['Tippy']
UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default']
}
}

View File

@@ -1,102 +0,0 @@
<template>
<table class="w-full">
<thead>
<tr class="text-secondary border-b border-dividerDark text-sm text-left">
<th class="px-3 pb-3">Team ID</th>
<th class="px-3 pb-3">Team Name</th>
<th class="px-3 pb-3">Number of Members</th>
<th class="px-3 pb-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-divider">
<tr v-if="teamList.length === 0">
<div class="py-6 px-3">No teams found ...</div>
</tr>
<tr
v-else
v-for="team in teamList"
:key="team.id"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
>
<td
@click="$emit('go-to-team-details', team.id)"
class="py-4 px-3 max-w-50"
>
<div class="flex">
<span class="truncate">
{{ team.id }}
</span>
</div>
</td>
<td
@click="$emit('go-to-team-details', team.id)"
class="py-4 px-3 min-w-80"
>
<span v-if="team.name" class="flex items-center ml-4 truncate">
{{ team.name }}
</span>
<span v-else class="flex items-center ml-4"> (Unnamed team) </span>
</td>
<td @click="$emit('go-to-team-details', team.id)" class="py-4 px-3">
<span class="ml-7">
{{ team.members?.length }}
</span>
</td>
<td>
<div class="relative">
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconMoreHorizontal"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="IconTrash"
:label="'Delete Team'"
class="!hover:bg-red-600 w-full"
@click="
() => {
$emit('delete-team', team.id);
hide();
}
"
/>
</div>
</template>
</tippy>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<script setup lang="ts">
import { TippyComponent } from 'vue-tippy';
import { ref } from 'vue';
import IconTrash from '~icons/lucide/trash';
import IconMoreHorizontal from '~icons/lucide/more-horizontal';
import { TeamListQuery } from '~/helpers/backend/graphql';
// Template refs
const tippyActions = ref<TippyComponent | null>(null);
defineProps<{
teamList: TeamListQuery['admin']['allTeams'];
}>();
defineEmits<{
(event: 'go-to-team-details', teamID: string): void;
(event: 'delete-team', teamID: string): void;
}>();
</script>

View File

@@ -1,168 +0,0 @@
<template>
<table class="w-full">
<thead>
<tr class="text-secondary border-b border-dividerDark text-sm text-left">
<th class="px-3 pb-3">{{ t('users.id') }}</th>
<th class="px-3 pb-3">{{ t('users.name') }}</th>
<th class="px-3 pb-3">{{ t('users.email') }}</th>
<th class="px-3 pb-3">{{ t('users.date') }}</th>
<th class="px-3 pb-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-divider">
<tr
v-for="user in usersList"
:key="user.uid"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
>
<td
@click="$emit('goToUserDetails', user.uid)"
class="py-2 px-3 max-w-30"
>
<div class="flex">
<span class="truncate">
{{ user.uid }}
</span>
</div>
</td>
<td @click="$emit('goToUserDetails', user.uid)" class="py-2 px-3">
<div v-if="user.displayName" class="flex items-center space-x-3">
<span>
{{ user.displayName }}
</span>
<span
v-if="user.isAdmin"
class="text-xs font-medium px-3 py-0.5 rounded-full bg-green-900 text-green-300"
>
{{ t('users.admin') }}
</span>
</div>
<div v-else class="flex items-center space-x-3">
<span> {{ t('users.unnamed') }} </span>
<span
v-if="user.isAdmin"
class="text-xs font-medium px-3 py-0.5 rounded-full bg-green-900 text-green-300"
>
{{ t('users.admin') }}
</span>
</div>
</td>
<td @click="$emit('goToUserDetails', user.uid)" class="py-2 px-3">
<span>
{{ user.email }}
</span>
</td>
<td @click="$emit('goToUserDetails', user.uid)" class="py-2 px-3">
<div class="flex items-center">
<div class="flex flex-col">
{{ getCreatedDate(user.createdOn) }}
<div class="text-gray-400 text-tiny">
{{ getCreatedTime(user.createdOn) }}
</div>
</div>
</div>
</td>
<td>
<div class="relative">
<span>
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconMoreHorizontal"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
v-if="!user.isAdmin"
:icon="IconUserCheck"
:label="'Make Admin'"
class="!hover:bg-emerald-600"
@click="
() => {
$emit('makeUserAdmin', user.uid);
hide();
}
"
/>
<HoppSmartItem
v-else
:icon="IconUserMinus"
:label="'Remove Admin Status'"
class="!hover:bg-emerald-600"
@click="
() => {
$emit('makeAdminToUser', user.uid);
hide();
}
"
/>
<HoppSmartItem
v-if="!user.isAdmin"
:icon="IconTrash"
:label="'Delete User'"
class="!hover:bg-red-600"
@click="
() => {
$emit('deleteUser', user.uid);
hide();
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<script lang="ts" setup>
import { format } from 'date-fns';
import { ref } from 'vue';
import IconTrash from '~icons/lucide/trash';
import IconUserMinus from '~icons/lucide/user-minus';
import IconUserCheck from '~icons/lucide/user-check';
import IconMoreHorizontal from '~icons/lucide/more-horizontal';
import { UsersListQuery } from '~/helpers/backend/graphql';
import { TippyComponent } from 'vue-tippy';
import { useI18n } from '~/composables/i18n';
const t = useI18n();
defineProps<{
usersList: UsersListQuery['admin']['allUsers'];
}>();
defineEmits<{
(event: 'goToUserDetails', uid: string): void;
(event: 'makeUserAdmin', uid: string): void;
(event: 'makeAdminToUser', uid: string): void;
(event: 'deleteUser', uid: string): void;
}>();
// Get Proper Date Formats
const getCreatedDate = (date: string) => format(new Date(date), 'dd-MM-yyyy');
const getCreatedTime = (date: string) => format(new Date(date), 'hh:mm a');
// Template refs
const tippyActions = ref<TippyComponent | null>(null);
</script>
<style scoped>
.tippy-box[data-theme~='popover'] .tippy-content {
padding: 0;
}
</style>

View File

@@ -13,7 +13,7 @@
<div class="overflow-x-auto">
<div
v-if="fetching && !error && teamList.length === 0"
v-if="fetching && !error && teamsList.length === 0"
class="flex justify-center"
>
<HoppSmartSpinner />
@@ -21,16 +21,94 @@
<div v-else-if="error">{{ t('teams.load_list_error') }}</div>
<TeamsTable
v-else
:teamList="teamList"
@goToTeamDetails="goToTeamDetails"
@deleteTeam="deleteTeam"
class=""
/>
<div v-else-if="teamsList.length == 0" class="px-2">
<p class="text-lg">
{{ t('teams.no_teams') }}
</p>
</div>
<div v-else>
<HoppSmartTable :list="teamsList">
<template #head>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th class="px-6 py-2">{{ t('teams.id') }}</th>
<th class="px-6 py-3">{{ t('teams.name') }}</th>
<th class="px-6 py-2">{{ t('teams.members') }}</th>
<th class="px-6 py-2"></th>
</tr>
</template>
<template #body="{ list }">
<tr
v-for="team in list"
:key="team.id"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
@click="goToTeamDetails(team.id)"
>
<td class="py-4 px-3 max-w-50">
<div class="flex">
<span class="truncate">
{{ team.id }}
</span>
</div>
</td>
<td class="py-4 px-3 min-w-80">
<span
v-if="team.name"
class="flex items-center ml-4 truncate"
>
{{ team.name }}
</span>
<span v-else class="flex items-center ml-4">
{{ t('teams.unnamed') }}
</span>
</td>
<td class="py-4 px-3">
<span class="ml-7">
{{ team.members?.length }}
</span>
</td>
<td @click.stop>
<div class="relative">
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconMoreHorizontal"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
:icon="IconTrash"
:label="t('teams.delete_team')"
class="!hover:bg-red-600 w-full"
@click="
() => {
deleteTeam(team.id);
hide();
}
"
/>
</div>
</template>
</tippy>
</div>
</td>
</tr>
</template>
</HoppSmartTable>
</div>
<div
v-if="hasNextPage && teamList.length >= teamsPerPage"
v-if="hasNextPage && teamsList.length >= teamsPerPage"
class="flex justify-center my-5 px-3 py-2 cursor-pointer font-semibold rounded-3xl bg-dividerDark hover:bg-divider transition mx-auto w-38 text-secondaryDark"
@click="fetchNextTeams"
>
@@ -66,10 +144,12 @@ import {
UsersListDocument,
} from '../../helpers/backend/graphql';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { ref, watch, computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { useMutation, useQuery } from '@urql/vue';
import { useToast } from '~/composables/toast';
import IconAddUsers from '~icons/lucide/plus';
import IconTrash from '~icons/lucide/trash';
import IconMoreHorizontal from '~icons/lucide/more-horizontal';
import { useI18n } from '~/composables/i18n';
const t = useI18n();
@@ -96,7 +176,7 @@ const {
error,
goToNextPage: fetchNextTeams,
refetch,
list: teamList,
list: teamsList,
hasNextPage,
} = usePagedQuery(
TeamListDocument,
@@ -143,9 +223,7 @@ const createTeam = async (newTeamName: string, ownerEmail: string) => {
// Go To Individual Team Details Page
const router = useRouter();
const goToTeamDetails = (teamId: string) => {
router.push('/teams/' + teamId);
};
const goToTeamDetails = (teamId: string) => router.push('/teams/' + teamId);
// Reload Teams Page when routed back to the teams page
const route = useRoute();
@@ -175,7 +253,7 @@ const deleteTeamMutation = async (id: string | null) => {
if (result.error) {
toast.error(`${t('state.delete_team_failure')}`);
} else {
teamList.value = teamList.value.filter((team) => team.id !== id);
teamsList.value = teamsList.value.filter((team) => team.id !== id);
toast.success(`${t('state.delete_team_success')}`);
}
});

View File

@@ -37,7 +37,7 @@
t('users.uid')
}}</label>
<div
class="w-full p-3 mt-2 bg-zinc-800 border-gray-600 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
class="w-full p-3 mt-2 bg-divider border-gray-600 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
>
{{ user.uid }}
</div>
@@ -47,7 +47,7 @@
t('users.name')
}}</label>
<div
class="w-full p-3 mt-2 bg-zinc-800 border-gray-600 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
class="w-full p-3 mt-2 bg-divider border-gray-600 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
>
<span v-if="user.displayName">
{{ user.displayName }}
@@ -60,7 +60,7 @@
t('users.email')
}}</label>
<div
class="w-full p-3 mt-2 bg-zinc-800 border-gray-200 border-gray-600 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
class="w-full p-3 mt-2 bg-divider border-gray-200 border-gray-600 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
>
{{ user.email }}
</div>
@@ -70,7 +70,7 @@
t('users.created_on')
}}</label>
<div
class="w-full p-3 mt-2 bg-zinc-800 border-gray-600 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
class="w-full p-3 mt-2 bg-divider border-gray-600 rounded-md focus:border-emerald-600 focus:ring focus:ring-opacity-40 focus:ring-emerald-500"
>
{{ getCreatedDateAndTime(user.createdOn) }}
</div>

View File

@@ -31,16 +31,139 @@
<div v-else-if="error">{{ t('users.load_list_error') }}</div>
<UsersTable
v-else-if="usersList.length >= 1"
:usersList="usersList"
:fetching="fetching"
:error="error"
@goToUserDetails="goToUserDetails"
@makeUserAdmin="makeUserAdmin"
@makeAdminToUser="makeAdminToUser"
@deleteUser="deleteUser"
/>
<div v-else-if="usersList.length > 0">
<HoppSmartTable :list="usersList">
<template #head>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th class="px-6 py-2">{{ t('users.id') }}</th>
<th class="px-6 py-2">{{ t('users.name') }}</th>
<th class="px-6 py-2">{{ t('users.email') }}</th>
<th class="px-6 py-2">{{ t('users.date') }}</th>
<th class="px-6 py-2"></th>
</tr>
</template>
<template #body="{ list }">
<tr
v-for="user in list"
:key="user.uid"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
@click="goToUserDetails(user.uid)"
>
<td class="py-2 px-3 max-w-30">
<div class="flex">
<span class="truncate">
{{ user.uid }}
</span>
</div>
</td>
<td class="py-2 px-3">
<div
v-if="user.displayName"
class="flex items-center space-x-3"
>
<span>
{{ user.displayName }}
</span>
<span
v-if="user.isAdmin"
class="text-xs font-medium px-3 py-0.5 rounded-full bg-green-900 text-green-300"
>
{{ t('users.admin') }}
</span>
</div>
<div v-else class="flex items-center space-x-3">
<span> {{ t('users.unnamed') }} </span>
<span
v-if="user.isAdmin"
class="text-xs font-medium px-3 py-0.5 rounded-full bg-green-900 text-green-300"
>
{{ t('users.admin') }}
</span>
</div>
</td>
<td class="py-2 px-3">
<span>
{{ user.email }}
</span>
</td>
<td class="py-2 px-3">
<div class="flex items-center">
<div class="flex flex-col">
{{ getCreatedDate(user.createdOn) }}
<div class="text-gray-400 text-tiny">
{{ getCreatedTime(user.createdOn) }}
</div>
</div>
</div>
</td>
<td @click.stop>
<div class="relative">
<span>
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconMoreHorizontal"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
v-if="!user.isAdmin"
:icon="IconUserCheck"
:label="t('users.make_admin')"
class="!hover:bg-emerald-600"
@click="
() => {
makeUserAdmin(user.uid);
hide();
}
"
/>
<HoppSmartItem
v-else
:icon="IconUserMinus"
:label="t('users.remove_admin_status')"
class="!hover:bg-emerald-600"
@click="
() => {
makeAdminToUser(user.uid);
hide();
}
"
/>
<HoppSmartItem
v-if="!user.isAdmin"
:icon="IconTrash"
:label="t('users.delete_user')"
class="!hover:bg-red-600"
@click="
() => {
deleteUser(user.uid);
hide();
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</td>
</tr>
</template>
</HoppSmartTable>
</div>
<div v-else class="flex justify-center">{{ t('users.no_users') }}</div>
@@ -82,6 +205,7 @@
</template>
<script setup lang="ts">
import { format } from 'date-fns';
import { ref, watch } from 'vue';
import { useMutation } from '@urql/vue';
import {
@@ -96,8 +220,16 @@ import { useRoute, useRouter } from 'vue-router';
import { useToast } from '~/composables/toast';
import { HoppButtonSecondary } from '@hoppscotch/ui';
import IconAddUser from '~icons/lucide/user-plus';
import IconTrash from '~icons/lucide/trash';
import IconUserMinus from '~icons/lucide/user-minus';
import IconUserCheck from '~icons/lucide/user-check';
import IconMoreHorizontal from '~icons/lucide/more-horizontal';
import { useI18n } from '~/composables/i18n';
// Get Proper Date Formats
const getCreatedDate = (date: string) => format(new Date(date), 'dd-MM-yyyy');
const getCreatedTime = (date: string) => format(new Date(date), 'hh:mm a');
const t = useI18n();
const toast = useToast();
@@ -141,9 +273,7 @@ const sendInvite = async (email: string) => {
// Go to Individual User Details Page
const route = useRoute();
const router = useRouter();
const goToUserDetails = (uid: string) => {
router.push('/users/' + uid);
};
const goToUserDetails = (uid: string) => router.push('/users/' + uid);
watch(
() => route.params.id,

View File

@@ -16,64 +16,36 @@
<div v-if="fetching" class="flex justify-center">
<HoppSmartSpinner />
</div>
<div v-else-if="error || invitedUsers === undefined">
<div
v-else-if="
error || invitedUsers === undefined || invitedUsers.length === 0
"
>
<p class="text-xl">{{ t('users.no_invite') }}</p>
</div>
<table v-else class="w-full text-left">
<thead>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left"
>
<th class="px-3 pb-3">{{ t('users.admin_id') }}</th>
<th class="px-3 pb-3">{{ t('users.admin_email') }}</th>
<th class="px-3 pb-3">{{ t('users.invitee_email') }}</th>
<th class="px-3 pb-3">{{ t('users.invited_on') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-divider">
<tr
v-if="invitedUsers.length === 0"
class="text-secondaryDark py-4"
>
<div class="py-6 px-3">{{ t('users.no_invite') }}</div>
</tr>
<tr
v-else
v-for="(user, index) in invitedUsers"
:key="index"
class="text-secondaryDark hover:bg-zinc-800 hover:cursor-pointer rounded-xl"
>
<td class="py-2 px-3 max-w-30">
<div v-else>
<HoppSmartTable :list="newInvitedUsersList" :headings="headings">
<template #invitedOn="{ item }">
<div class="flex flex-col truncate">
<span v-if="item" class="truncate">
{{ getCreatedDate(item.invitedOn) }}
</span>
<span v-else> - </span>
<div>
<span class="truncate">
{{ user?.adminUid }}
</span>
</div>
</td>
<td class="py-2 px-3">
<span class="flex items-center">
{{ user?.adminEmail }}
</span>
</td>
<td class="py-2 px-3">
<span>
{{ user?.inviteeEmail }}
</span>
</td>
<td class="py-2 px-3">
<div class="flex items-center">
<div class="flex flex-col">
{{ getCreatedDate(user?.invitedOn) }}
<div class="text-gray-400 text-xs">
{{ getCreatedTime(user?.invitedOn) }}
</div>
<div class="text-gray-400 text-tiny">
<span>
<span>
{{ getCreatedTime(item.invitedOn) }}
</span>
</span>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</HoppSmartTable>
</div>
</div>
</div>
</div>
@@ -100,4 +72,24 @@ const getCreatedTime = (date: string) => format(new Date(date), 'hh:mm a');
// Get Invited Users
const { fetching, error, data } = useQuery({ query: InvitedUsersDocument });
const invitedUsers = computed(() => data?.value?.admin.invitedUsers);
// The new invited users list that is used in the table
const newInvitedUsersList = computed(() => {
return invitedUsers.value?.map((user) => {
return {
adminUid: user.adminUid,
adminEmail: user.adminEmail,
inviteeEmail: user.inviteeEmail,
invitedOn: user.invitedOn,
};
});
});
// Table Headings
const headings = [
{ key: 'adminUid', label: t('users.admin_id') },
{ key: 'adminEmail', label: t('users.admin_email') },
{ key: 'inviteeEmail', label: t('users.invitee_email') },
{ key: 'invitedOn', label: t('users.invited_on') },
];
</script>

View File

@@ -3,12 +3,11 @@
<input
:id="inputID"
class="input"
ref="inputRef"
:class="inputStyles"
v-model="inputText"
v-focus
:placeholder="placeholder"
:type="type"
@keyup.enter="emit('submit')"
autocomplete="off"
required
:disabled="disabled"
@@ -31,12 +30,21 @@ let inputIDCounter = 564275
</script>
<script setup lang="ts">
import { useVModel } from "@vueuse/core"
import { defineProps } from "vue"
import { onKeyStroke, useVModel } from "@vueuse/core"
import { defineProps, onMounted, ref, nextTick } from "vue"
// Unique ID for input
const inputID = `input-${inputIDCounter++}`
const inputRef = ref()
onMounted(async () => {
if (props.autofocus) {
await nextTick()
inputRef.value?.focus()
}
})
const props = withDefaults(
defineProps<{
id: string
@@ -47,6 +55,7 @@ const props = withDefaults(
type: string
label: string
disabled: boolean
autofocus: boolean
}>(),
{
id: "",
@@ -57,6 +66,7 @@ const props = withDefaults(
type: "text",
label: "",
disabled: false,
autofocus: true,
}
)
@@ -66,4 +76,14 @@ const emit = defineEmits<{
}>()
const inputText = useVModel(props, "modelValue", emit)
onKeyStroke(
"Enter",
(e) => {
if (!e.repeat) {
return emit("submit")
}
},
{ target: inputRef, eventName: "keydown" }
)
</script>

View File

@@ -44,7 +44,6 @@
</h3>
<span class="flex items-center">
<slot name="actions"></slot>
<kbd class="mr-2 shortcut-key">ESC</kbd>
<HoppButtonSecondary
v-if="dimissible"
v-tippy="{ theme: 'tooltip', delay: [500, 20] }"
@@ -109,21 +108,22 @@ const { t, onModalOpen, onModalClose } =
withDefaults(
defineProps<{
dialog: boolean,
title: string,
dimissible: boolean,
placement: string,
fullWidth: boolean,
styles: string,
closeText: string | null,
}>(), {
dialog: boolean
title: string
dimissible: boolean
placement: string
fullWidth: boolean
styles: string
closeText: string | null
}>(),
{
dialog: false,
title: "",
dimissible: true,
placement: "top",
fullWidth: false,
styles: "sm:max-w-lg",
closeText: null
closeText: null,
}
)

View File

@@ -4,7 +4,7 @@
v-if="src"
:src="src"
loading="lazy"
class="inline-flex flex-col object-contain object-center my-4"
class="inline-flex flex-col object-contain object-center mb-4"
:class="large ? 'w-32 h-32' : 'w-16 h-16'"
:alt="alt"
/>

View File

@@ -1,17 +1,28 @@
<template>
<div>
<Transition name="fade" appear>
<div v-if="show" class="fixed inset-0 z-20 transition-opacity" @keydown.esc="close()">
<div class="absolute inset-0 bg-primaryLight opacity-90 focus:outline-none" tabindex="0" @click="close()"></div>
<div
v-if="show"
class="fixed inset-0 z-20 transition-opacity"
@keydown.esc="close()"
>
<div
class="absolute inset-0 bg-primaryLight opacity-90 focus:outline-none"
tabindex="0"
@click="close()"
></div>
</div>
</Transition>
<Transition name="slide" appear>
<aside v-if="show"
class="fixed top-0 right-0 z-30 flex flex-col h-full max-w-full overflow-auto border-l shadow-xl border-dividerDark bg-primary w-96">
<div class="flex items-center justify-between p-2 border-b border-dividerLight">
<aside
v-if="show"
class="fixed top-0 right-0 z-30 flex flex-col h-full max-w-full overflow-auto border-l shadow-xl border-dividerDark bg-primary w-96"
>
<div
class="flex items-center justify-between p-2 border-b border-dividerLight"
>
<h3 class="ml-4 heading">{{ title }}</h3>
<span class="flex items-center">
<kbd class="mr-2 shortcut-key">ESC</kbd>
<HoppButtonSecondary :icon="IconX" @click="close()" />
</span>
</div>

View File

@@ -0,0 +1,68 @@
<template>
<div class="overflow-auto rounded-md border border-dividerDark shadow-md">
<table class="w-full">
<thead>
<slot name="head">
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th v-for="th in headings" scope="col" class="px-6 py-3">
{{ th.label ?? th.key }}
</th>
</tr>
</slot>
</thead>
<tbody class="divide-y divide-divider">
<slot name="body" :list="list">
<tr
v-for="(rowData, rowIndex) in list"
:key="rowIndex"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
:class="yBorder ? 'divide-x divide-divider' : ''"
>
<td
v-for="cellHeading in headings"
:key="cellHeading.key"
@click="!cellHeading.preventClick && onRowClicked(rowData)"
class="max-w-40 pl-6 py-1"
>
<!-- Dynamic column slot -->
<slot :name="cellHeading.key" :item="rowData">
<!-- Generic implementation of the column -->
<div class="flex flex-col truncate">
<span class="truncate">
{{ rowData[cellHeading.key] ?? "-" }}
</span>
</div>
</slot>
</td>
</tr>
</slot>
</tbody>
</table>
</div>
</template>
<script lang="ts" setup generic="Item extends Record<string, unknown>">
export type CellHeading = {
key: string
label?: string
preventClick?: boolean
}
defineProps<{
/** Whether to show the vertical border between columns */
yBorder?: boolean
/** The list of items to be displayed in the table */
list?: Item[]
/** The headings of the table */
headings?: CellHeading[]
}>()
const emit = defineEmits<{
(event: "onRowClicked", item: Item): void
}>()
const onRowClicked = (item: Item) => emit("onRowClicked", item)
</script>

View File

@@ -16,6 +16,7 @@ export { default as HoppSmartSlideOver } from "./SlideOver.vue"
export { default as HoppSmartSpinner } from "./Spinner.vue"
export { default as HoppSmartTab } from "./Tab.vue"
export { default as HoppSmartTabs } from "./Tabs.vue"
export { default as HoppSmartTable } from "./Table.vue"
export { default as HoppSmartToggle } from "./Toggle.vue"
export { default as HoppSmartWindow } from "./Window.vue"
export { default as HoppSmartWindows } from "./Windows.vue"

View File

@@ -0,0 +1,58 @@
<template>
<Story title="Table">
<Variant title="General">
<HoppSmartTable :list="list" :headings="headings" />
</Variant>
<Variant title="Custom">
<HoppSmartTable>
<template #head>
<th>ID</th>
<th>Name</th>
<th>Members</th>
<th>Role</th>
</template>
<template #body>
<tr>
<td>123454</td>
<td>Joel</td>
<td>10</td>
<td>Frontend Engineer</td>
</tr>
<tr>
<td>123456</td>
<td>Anwar</td>
<td>12</td>
<td>Frontend Engineer</td>
</tr>
</template>
</HoppSmartTable>
</Variant>
</Story>
</template>
<script setup lang="ts">
import { HoppSmartTable } from "../components/smart"
// Table Headings
const headings = [
{ key: "id", label: "ID" },
{ key: "name", label: "Name" },
{ key: "members", label: "Members" },
{ key: "role", label: "Role" },
]
const list = [
{
id: "123455",
name: "Joel",
members: 10,
role: "Frontend Engineer",
},
{
id: "123456",
name: "Anwar",
members: 12,
role: "Frontend Engineer",
},
]
</script>