Compare commits

...

89 Commits

Author SHA1 Message Date
jamesgeorge007
73a5f4657c refactor: cleanup CollectionsProperties component 2024-05-28 19:50:13 +05:30
jamesgeorge007
4ba53a8bf3 refactor: remove redundancy from provider API method signatures 2024-05-28 19:31:41 +05:30
jamesgeorge007
2374ceb808 refactor: update exportRESTCollections method signature
- Drop the `collections` parameter since the it is already available in the `PersonalWorkspaceProviderService` context.
- Make the above method return a left error of `NO_COLLECTIONS_TO_EXPORT` when the collections list is empty.
- Error handling updates.
2024-05-27 11:34:50 +05:30
jamesgeorge007
17169e1c46 chore: bump @hoppscotch/ui 2024-05-23 22:30:11 +05:30
jamesgeorge007
4c74d0f865 chore: cleanup 2024-05-23 14:20:42 +05:30
jamesgeorge007
8b930a6d3d fix: resolve edge cases about moving collections under its sibling
- Increase test coverage.
- Move store mock data under `__tests__/__mocks__`.
- Rephrase test descriptions.
2024-05-22 21:00:49 +05:30
jamesgeorge007
f4a37f19c9 fix: empty state primary action for root collections
Ensure child collections are created instead of the action resulting in new root collections.
2024-05-22 21:00:49 +05:30
jamesgeorge007
86b17e2bd3 test: add test suite for personal workspace provider service 2024-05-22 21:00:49 +05:30
jamesgeorge007
e0083aa70d fix: handle based updates post collection move to a sibling level collection below it 2024-05-22 21:00:49 +05:30
jamesgeorge007
25b0818016 refactor: update inherited properties for affected requests flow updates 2024-05-22 21:00:46 +05:30
jamesgeorge007
648cc8f5bd refactor: handle based updates for affected requests post collection deletion 2024-05-22 20:59:59 +05:30
jamesgeorge007
6032cbb17b refactor: handle based updates for affected requests post request deletion 2024-05-22 20:59:59 +05:30
jamesgeorge007
52bff8ee68 refactor: handle based updates post collection reorder 2024-05-22 20:59:59 +05:30
jamesgeorge007
e342e53db1 refactor: handle based updates post collection move 2024-05-22 20:59:58 +05:30
jamesgeorge007
141652ffb6 fix: affected request indices computation post request reorder 2024-05-22 20:59:38 +05:30
jamesgeorge007
bb57b2248c fix: affected request indices computation post request move 2024-05-22 20:59:21 +05:30
jamesgeorge007
5d8da5fe49 chore: resolve type errors 2024-05-22 20:59:21 +05:30
Andrew Bastin
c8f0142b16 refactor: move more things to handles instead of handleref 2024-05-22 20:59:20 +05:30
jamesgeorge007
2f2273ee2c refactor: move to inert handles 2024-05-22 20:58:28 +05:30
jamesgeorge007
b239b6b4a6 refactor: handle updates post move request action
- Filter out duplicate issued handle entries.
- Move from `getAffectedIndexes` helper function to a custom implementation for updating affected request indices.
2024-05-22 20:57:48 +05:30
jamesgeorge007
bbac317b71 refactor: move to handle based updates with request move action 2024-05-22 20:57:48 +05:30
jamesgeorge007
412daa4d17 refactor: tab saveContext resolution post collection remove action 2024-05-22 20:57:48 +05:30
jamesgeorge007
0abdc63f0e refactor: better tab dirty check
Mark the tab (saved request under a collection) as not dirty if the request contents are reset to the value since previous save.
2024-05-22 20:57:47 +05:30
jamesgeorge007
9e8112a4e5 fix: make close all tabs action account for tabs with invalid request handles 2024-05-22 20:56:47 +05:30
jamesgeorge007
5aa57fce3f refactor: add save context resolution logic post request deletion 2024-05-22 20:56:47 +05:30
jamesgeorge007
8b65090dfb refactor: persist only request handles under tab saveContext at runtime
Remove provider, workspace and request IDs.
2024-05-22 20:56:47 +05:30
jamesgeorge007
7ca94a99b7 refactor: move tab saveContext resolution associated with actions on collections to be based on request handles 2024-05-22 20:56:46 +05:30
jamesgeorge007
fe3adeeb17 refactor: consider request handles with tab saveContext resolution for collection move/reorder actions 2024-05-22 20:56:17 +05:30
jamesgeorge007
3a195711a4 refactor: update data under request handles during tab save context resolution 2024-05-22 20:56:17 +05:30
jamesgeorge007
6cde6200ae refactor: signify updates via handle reference mutation post request move action 2024-05-22 20:56:17 +05:30
jamesgeorge007
e5e1260632 fix: ensure request name updates reflect immediately on the tabs 2024-05-22 20:56:17 +05:30
jamesgeorge007
90c9f2a9b1 fix: make writable handle operate on refs within the createRESTRequest method
Wrap the request handle data in a `ref` and make the writable handle operate over it ensuring reactive updates are received.
2024-05-22 20:56:17 +05:30
jamesgeorge007
db9ba17529 refactor: convey updates via handle mutation for update request action 2024-05-22 20:56:17 +05:30
jamesgeorge007
197d253e8b refactor: keep tab dirty status logic at the page level 2024-05-22 20:56:17 +05:30
jamesgeorge007
cd92dfec47 refactor: introduce writable handles to signify updates to handle references
A special list of writable handles is compiled in a list while issuing handles (request/collection creation, etc). Instead of manually computing the tab and toggling the dirty state, the writable handle is updated (changing the type to invalid on request deletion) and the tab with the request open can infer it via the update reflected in the request handle under the tab save context (reactive update trigger).
2024-05-22 20:53:49 +05:30
jamesgeorge007
8467417e7a refactor: persist request handles under tab saveContext
Only the IDs (workspace, provider & request IDs) to restore the handle are stored under `localStorage` and the handle is restored back at runtime.
2024-05-22 20:53:15 +05:30
jamesgeorge007
f6067f14aa fix: prevent infinite spinner state while expanding tree nodes 2024-05-22 20:52:57 +05:30
jamesgeorge007
3fd85df84b refactor: view implementation to retrieve collections for exporting 2024-05-22 20:52:57 +05:30
jamesgeorge007
01573cc51c chore: keep existing implementation for save context resolution 2024-05-22 20:52:57 +05:30
jamesgeorge007
b19486ea03 refactor: update provider method signatures + cleanup 2024-05-22 20:52:57 +05:30
jamesgeorge007
a729dfcacb fix: ensure tree nodes are not computed for requests 2024-05-22 20:52:57 +05:30
jamesgeorge007
62612e6c51 refactor: remove unnecessary safeguards + cleanup 2024-05-22 20:52:57 +05:30
jamesgeorge007
f87a4c81d8 refactor: leverage helpers 2024-05-22 20:52:57 +05:30
jamesgeorge007
116f2fd279 refactor: update provider method signatures 2024-05-22 20:52:57 +05:30
jamesgeorge007
7e2deaac5b fix: duplicate collection in search results
Ensure the entire collection tree is rendered if the search query matches a collection name.
2024-05-22 20:52:57 +05:30
jamesgeorge007
240b131e06 feat: support search at n level depth 2024-05-22 20:52:57 +05:30
jamesgeorge007
5b3986a53f fix: ensure the collection tree for search immediately reflects actions performed over it 2024-05-22 20:52:57 +05:30
jamesgeorge007
84fc31e8a9 refactor: port collection tree empty states 2024-05-22 20:52:56 +05:30
jamesgeorge007
c0978c3b20 refactor: eliminate parentCollectionID field from RESTCollectionViewRequest type
Collection ID serves the purpose.
2024-05-22 20:52:56 +05:30
jamesgeorge007
d70d5bdb16 refactor: eliminate collectionID from tab saveContext
Collection ID can be inferred from request ID by removing last index from the path.
2024-05-22 20:52:56 +05:30
jamesgeorge007
c30ee5becc refactor: session based search results view implementation 2024-05-22 20:50:32 +05:30
jamesgeorge007
5a64cdb7bc fix: update save context for affected requests with collection move/reorder 2024-05-22 20:50:32 +05:30
jamesgeorge007
89f2479845 refactor: integrate REST search collection adapter 2024-05-22 20:50:32 +05:30
jamesgeorge007
46654853f0 refactor: add new tree adapter corresponding to search 2024-05-22 20:50:32 +05:30
jamesgeorge007
af7e6b70cd refactor: view based implementation for search in personal workspace 2024-05-22 20:50:32 +05:30
jamesgeorge007
7c52c6b79d fix: associate requests under tabs while reordering collections 2024-05-22 20:50:32 +05:30
jamesgeorge007
fe01322bf7 refactor: integrate provider API methods for collection move/reorder 2024-05-22 20:50:32 +05:30
jamesgeorge007
0a0f441da1 refactor: unify markup 2024-05-22 20:50:32 +05:30
jamesgeorge007
d0c7c4a245 refactor: provider method definitions for collection reorder/move 2024-05-22 20:50:32 +05:30
jamesgeorge007
076006c4a6 refactor: port collection move/reorder 2024-05-22 20:50:32 +05:30
jamesgeorge007
6ed9c09f06 refactor: port import/export functionality 2024-05-22 20:50:31 +05:30
jamesgeorge007
8483339005 feat: add keypress actions 2024-05-22 20:49:49 +05:30
jamesgeorge007
cd23bb63c1 refactor: remove fields associated with pagination
Fix lint errors
2024-05-22 20:49:13 +05:30
James George
f4ea999d2d refactor: remove unnecessary imports and local state variable 2024-05-22 20:49:13 +05:30
jamesgeorge007
c43e4fcefd fix: open request straightaway after creating via save-as 2024-05-22 20:49:13 +05:30
jamesgeorge007
316dc8f759 refactor: persist IDs under tab save context 2024-05-22 20:49:12 +05:30
jamesgeorge007
89f7c2ce5e refactor: port share requests 2024-05-22 20:41:24 +05:30
jamesgeorge007
0d00826019 fix: ensure removing collection level headers persists 2024-05-22 20:41:24 +05:30
jamesgeorge007
00285df348 refactor: remove side effects from computed properties 2024-05-22 20:41:24 +05:30
jamesgeorge007
b821f452cf fix: ensure the reference is not kept while overwriting requests 2024-05-22 20:41:24 +05:30
jamesgeorge007
faa0bf7714 fix: updates to collection level authorization and headers reflect at the request level straightaway 2024-05-22 20:41:23 +05:30
jamesgeorge007
3a176f6620 fix: invalidate requests opened under tabs on deleting parent collection 2024-05-22 20:33:04 +05:30
jamesgeorge007
7549e456c8 fix: specify correct request index with update action 2024-05-22 20:33:04 +05:30
jamesgeorge007
68795a5017 refactor: port collection tree rendered in the save request modal to the new implementation 2024-05-22 20:33:03 +05:30
jamesgeorge007
b0c72fd295 feat: indicate request opened in the tab from the collection tree 2024-05-22 20:32:18 +05:30
jamesgeorge007
63eca80ff6 fix: prevent the need for an explicit save while editing request name 2024-05-22 20:32:18 +05:30
jamesgeorge007
30b6a67505 fix: prevent duplicate request creation and invalidate tabs with request deletion 2024-05-22 20:32:18 +05:30
jamesgeorge007
97899ec023 chore: cleanup 2024-05-22 20:32:18 +05:30
jamesgeorge007
2c47a63ca0 refactor: update provider method signature 2024-05-22 20:32:18 +05:30
jamesgeorge007
a0e373a4f3 refactor: persist request handle under tab saveContext
Bump vue version.
2024-05-22 20:32:16 +05:30
jamesgeorge007
392b2fc48d refactor: updates based on the provider methods signature changes 2024-05-22 20:30:55 +05:30
jamesgeorge007
c1a8a871d2 refactor: save request handle in tabs and remove tabs related logic from personal provider definition 2024-05-22 20:28:56 +05:30
jamesgeorge007
1abbdb0fe0 refactor: consistent return formats 2024-05-22 20:21:43 +05:30
jamesgeorge007
f0f504d10e refactor: unify edit collection API methods and ensure consistent naming convention 2024-05-22 20:21:42 +05:30
jamesgeorge007
f0dab55c99 refactor: prevent storing entire collection data in the respective handle 2024-05-22 20:21:15 +05:30
jamesgeorge007
d6a8e60239 refactor: finalize API methods 2024-05-22 20:21:14 +05:30
jamesgeorge007
89bcc58de6 refactor: compile data in handles
Introduce a handle for requests.
2024-05-22 20:16:54 +05:30
jamesgeorge007
ab7df212c2 refactor: iterations 2024-05-22 20:11:39 +05:30
jamesgeorge007
29e25b0ead refactor: initial iterations
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2024-05-22 20:10:50 +05:30
55 changed files with 12110 additions and 1475 deletions

View File

@@ -34,7 +34,7 @@
},
"pnpm": {
"overrides": {
"vue": "3.3.9"
"vue": "3.4.27"
},
"packageExtensions": {
"httpsnippet@3.0.1": {

View File

@@ -1027,7 +1027,8 @@
"personal": "Personal Workspace",
"other_workspaces": "My Workspaces",
"team": "Workspace",
"title": "Workspaces"
"title": "Workspaces",
"no_workspace": "No Workspace"
},
"site_protection": {
"login_to_continue": "Login to continue",

View File

@@ -36,7 +36,7 @@
"@hoppscotch/codemirror-lang-graphql": "workspace:^",
"@hoppscotch/data": "workspace:^",
"@hoppscotch/js-sandbox": "workspace:^",
"@hoppscotch/ui": "0.1.0",
"@hoppscotch/ui": "0.1.4",
"@hoppscotch/vue-toasted": "0.1.0",
"@lezer/highlight": "1.2.0",
"@unhead/vue": "1.8.8",
@@ -90,7 +90,7 @@
"util": "0.12.5",
"uuid": "9.0.1",
"verzod": "0.2.2",
"vue": "3.3.9",
"vue": "3.4.27",
"vue-i18n": "9.8.0",
"vue-pdf-embed": "1.2.1",
"vue-router": "4.2.5",

View File

@@ -181,6 +181,10 @@ declare module 'vue' {
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']
NewCollections: typeof import('./components/new-collections/index.vue')['default']
NewCollectionsRest: typeof import('./components/new-collections/rest/index.vue')['default']
NewCollectionsRestCollection: typeof import('./components/new-collections/rest/Collection.vue')['default']
NewCollectionsRestRequest: typeof import('./components/new-collections/rest/Request.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']
@@ -212,6 +216,8 @@ declare module 'vue' {
TeamsTeam: typeof import('./components/teams/Team.vue')['default']
Tippy: typeof import('vue-tippy')['Tippy']
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspacePersonalWorkspaceSelector: typeof import('./components/workspace/PersonalWorkspaceSelector.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
WorkspaceTestWorkspaceSelector: typeof import('./components/workspace/TestWorkspaceSelector.vue')['default']
}
}

View File

@@ -2,74 +2,73 @@
<div>
<header
ref="headerRef"
class="grid grid-cols-5 grid-rows-1 gap-2 overflow-x-auto overflow-y-hidden p-2"
class="flex flex-1 flex-shrink-0 items-center justify-between space-x-2 overflow-x-auto overflow-y-hidden px-2 py-2"
@mousedown.prevent="platform.ui?.appHeader?.onHeaderAreaClick?.()"
>
<div
class="col-span-2 flex items-center justify-between space-x-2"
class="inline-flex flex-1 items-center justify-start space-x-2"
:style="{
paddingTop: platform.ui?.appHeader?.paddingTop?.value,
paddingLeft: platform.ui?.appHeader?.paddingLeft?.value,
}"
>
<div class="flex">
<HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="/"
/>
</div>
<HoppButtonSecondary
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="/"
/>
</div>
<div class="col-span-1 flex items-center justify-between space-x-2">
<AppSpotlightSearch />
</div>
<div class="col-span-2 flex items-center justify-between space-x-2">
<div class="flex">
<HoppButtonSecondary
v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }"
:title="t('header.install_pwa')"
:icon="IconDownload"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="installPWA()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${
mdAndLarger ? t('support.title') : t('app.options')
} <kbd>?</kbd>`"
:icon="IconLifeBuoy"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')"
/>
</div>
<div
class="flex"
:class="{
'flex-row-reverse gap-2':
workspaceSelectorFlagEnabled && !currentUser,
}"
<div class="inline-flex flex-1 items-center justify-center space-x-2">
<button
class="flex max-w-[15rem] flex-1 cursor-text items-center justify-between self-stretch rounded border border-dividerDark bg-primaryDark px-2 py-1 text-secondaryLight transition hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary"
@click="invokeAction('modals.search.toggle')"
>
<div
v-if="currentUser === null"
class="inline-flex items-center space-x-2"
>
<HoppButtonSecondary
v-if="!workspaceSelectorFlagEnabled"
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 hidden h-8 border border-emerald-600/25 bg-emerald-500/10 !text-emerald-500 hover:border-emerald-600/20 hover:bg-emerald-600/20 focus-visible:border-emerald-600/20 focus-visible:bg-emerald-600/20 md:flex"
@click="invokeAction('modals.login.toggle')"
/>
<HoppButtonPrimary
:label="t('header.login')"
class="h-8"
@click="invokeAction('modals.login.toggle')"
/>
</div>
<span class="inline-flex flex-1 items-center">
<icon-lucide-search class="svg-icons mr-2" />
{{ t("app.search") }}
</span>
<span class="flex space-x-1">
<kbd class="shortcut-key">{{ getPlatformSpecialKey() }}</kbd>
<kbd class="shortcut-key">K</kbd>
</span>
</button>
<HoppButtonSecondary
v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }"
:title="t('header.install_pwa')"
:icon="IconDownload"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="installPWA()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${
mdAndLarger ? t('support.title') : t('app.options')
} <kbd>?</kbd>`"
:icon="IconLifeBuoy"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')"
/>
</div>
<div class="inline-flex flex-1 items-center justify-end space-x-2">
<div
v-if="currentUser === null"
class="inline-flex items-center space-x-2"
>
<HoppButtonSecondary
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 hidden border border-green-600/25 bg-green-500/[.15] !text-green-500 hover:border-green-800/50 hover:bg-green-400/10 focus-visible:border-green-800/50 focus-visible:bg-green-400/10 md:flex"
@click="invokeAction('modals.login.toggle')"
/>
<HoppButtonPrimary
:label="t('header.login')"
@click="invokeAction('modals.login.toggle')"
/>
</div>
<div v-else class="inline-flex items-center space-x-2">
<TeamsMemberStack
v-else-if="
currentUser !== null &&
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam.teamMembers.length > 1
@@ -80,142 +79,145 @@
@handle-click="handleTeamEdit()"
/>
<div
v-if="workspaceSelectorFlagEnabled || currentUser"
class="inline-flex items-center space-x-2"
class="flex divide-x divide-green-600/25 rounded border border-green-600/25 bg-green-500/[.15] focus-within:divide-green-800/50 focus-within:border-green-800/50 focus-within:bg-green-400/10 hover:divide-green-800/50 hover:border-green-800/50 hover:bg-green-400/10"
>
<div
class="flex h-8 divide-x divide-emerald-600/25 rounded border border-emerald-600/25 bg-emerald-500/10 focus-within:divide-emerald-600/20 focus-within:border-emerald-600/20 focus-within:bg-emerald-600/20 hover:divide-emerald-600/20 hover:border-emerald-600/20 hover:bg-emerald-600/20"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')"
:icon="IconUserPlus"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 !text-emerald-500"
@click="handleInvite()"
/>
<HoppButtonSecondary
v-if="
currentUser &&
workspace.type === 'team' &&
selectedTeam &&
selectedTeam?.myRole === 'OWNER'
"
v-tippy="{ theme: 'tooltip' }"
:title="t('team.edit')"
:icon="IconSettings"
class="!focus-visible:text-emerald-600 !hover:text-emerald-600 !text-emerald-500"
@click="handleTeamEdit()"
/>
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')"
:icon="IconUserPlus"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500"
@click="handleInvite()"
/>
<HoppButtonSecondary
v-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam?.myRole === 'OWNER'
"
v-tippy="{ theme: 'tooltip' }"
:title="t('team.edit')"
:icon="IconSettings"
class="py-1.75 !focus-visible:text-green-600 !hover:text-green-600 !text-green-500"
@click="handleTeamEdit()"
/>
</div>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => accountActions.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('workspace.change')"
:label="mdAndLarger ? activeWorkspaceName : ``"
:icon="activeWorkspaceIcon"
class="select-wrapper !focus-visible:text-blue-600 !hover:text-blue-600 rounded border border-blue-600/25 bg-blue-500/[.15] py-[0.4375rem] pr-8 !text-blue-500 hover:border-blue-800/50 hover:bg-blue-400/10 focus-visible:border-blue-800/50 focus-visible:bg-blue-400/10"
/>
<template #content="{ hide }">
<div
ref="accountActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
@click="hide()"
>
<WorkspaceSelector />
</div>
</template>
</tippy>
<span class="px-2">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => accountActions.focus()"
:on-shown="() => tippyActions.focus()"
>
<HoppSmartSelectWrapper
class="!text-blue-500 !focus-visible:text-blue-600 !hover:text-blue-600"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('workspace.change')"
:label="mdAndLarger ? workspaceName : ``"
:icon="workspace.type === 'personal' ? IconUser : IconUsers"
class="!focus-visible:text-blue-600 !hover:text-blue-600 h-8 rounded border border-blue-600/25 bg-blue-500/10 pr-8 !text-blue-500 hover:border-blue-600/20 hover:bg-blue-600/20 focus-visible:border-blue-600/20 focus-visible:bg-blue-600/20"
/>
</HoppSmartSelectWrapper>
<HoppSmartPicture
v-if="currentUser.photoURL"
v-tippy="{
theme: 'tooltip',
}"
:url="currentUser.photoURL"
:alt="
currentUser.displayName ||
t('profile.default_hopp_displayname')
"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<HoppSmartPicture
v-else
v-tippy="{ theme: 'tooltip' }"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
:initial="currentUser.displayName || currentUser.email"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<template #content="{ hide }">
<div
ref="accountActions"
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.p="profile.$el.click()"
@keyup.s="settings.$el.click()"
@keyup.l="logout.$el.click()"
@keyup.escape="hide()"
@click="hide()"
>
<WorkspaceSelector />
<div class="flex flex-col px-2 text-tiny">
<span class="inline-flex truncate font-semibold">
{{
currentUser.displayName ||
t("profile.default_hopp_displayname")
}}
</span>
<span class="inline-flex truncate text-secondaryLight">
{{ currentUser.email }}
</span>
</div>
<hr />
<HoppSmartItem
ref="profile"
to="/profile"
:icon="IconUser"
:label="t('navigation.profile')"
:shortcut="['P']"
@click="hide()"
/>
<HoppSmartItem
ref="settings"
to="/settings"
:icon="IconSettings"
:label="t('navigation.settings')"
:shortcut="['S']"
@click="hide()"
/>
<FirebaseLogout
ref="logout"
:shortcut="['L']"
@confirm-logout="hide()"
/>
</div>
</template>
</tippy>
<span v-if="currentUser" class="px-2">
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions.focus()"
>
<HoppSmartPicture
v-tippy="{
theme: 'tooltip',
}"
:name="currentUser.uid"
:title="
currentUser.displayName ||
currentUser.email ||
t('profile.default_hopp_displayname')
"
indicator
:indicator-styles="
network.isOnline ? 'bg-green-500' : 'bg-red-500'
"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.p="profile.$el.click()"
@keyup.s="settings.$el.click()"
@keyup.l="logout.$el.click()"
@keyup.escape="hide()"
>
<div class="flex flex-col px-2">
<span class="inline-flex truncate font-semibold">
{{
currentUser.displayName ||
t("profile.default_hopp_displayname")
}}
</span>
<span
class="inline-flex truncate text-secondaryLight text-tiny"
>
{{ currentUser.email }}
</span>
</div>
<hr />
<HoppSmartItem
ref="profile"
to="/profile"
:icon="IconUser"
:label="t('navigation.profile')"
:shortcut="['P']"
@click="hide()"
/>
<HoppSmartItem
ref="settings"
to="/settings"
:icon="IconSettings"
:label="t('navigation.settings')"
:shortcut="['S']"
@click="hide()"
/>
<FirebaseLogout
ref="logout"
:shortcut="['L']"
@confirm-logout="hide()"
/>
</div>
</template>
</tippy>
</span>
</div>
</span>
</div>
</div>
</header>
<AppBanner
v-if="bannerContent"
:banner="bannerContent"
@dismiss="dismissOfflineBanner"
/>
<AppBanner v-if="bannerContent" :banner="bannerContent" />
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
<TeamsInvite
v-if="workspace.type === 'team' && workspace.teamID"
@@ -231,6 +233,7 @@
@invite-team="inviteTeam(editingTeamName, editingTeamID)"
@refetch-teams="refetchTeams"
/>
<HoppSmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_team')"
@@ -244,40 +247,35 @@
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { defineActionHandler, invokeAction } from "@helpers/actions"
import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { installPWA, pwaDefferedPrompt } from "@modules/pwa"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { useService } from "dioc/vue"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { computed, reactive, ref, watch } from "vue"
import { useToast } from "~/composables/toast"
import { GetMyTeamsQuery, TeamMemberRole } from "~/helpers/backend/graphql"
import { deleteTeam as backendDeleteTeam } from "~/helpers/backend/mutations/Team"
import { getPlatformSpecialKey } from "~/helpers/platformutils"
import { platform } from "~/platform"
import {
BANNER_PRIORITY_HIGH,
BannerContent,
BannerService,
} from "~/services/banner.service"
import { NewWorkspaceService } from "~/services/new-workspace"
import { WorkspaceService } from "~/services/workspace.service"
import IconDownload from "~icons/lucide/download"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSettings from "~icons/lucide/settings"
import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUser from "~icons/lucide/user"
import IconUserPlus from "~icons/lucide/user-plus"
import IconUsers from "~icons/lucide/users"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { deleteTeam as backendDeleteTeam } from "~/helpers/backend/mutations/Team"
import {
BannerService,
BannerContent,
BANNER_PRIORITY_HIGH,
} from "~/services/banner.service"
const t = useI18n()
const toast = useToast()
/**
* Feature flag to enable the workspace selector login conversion
*/
const workspaceSelectorFlagEnabled = computed(
() => !!platform.platformFeatureFlags.workspaceSwitcherLogin?.value
)
/**
* Once the PWA code is initialized, this holds a method
* that can be called to show the user the installation
@@ -296,11 +294,10 @@ const bannerContent = computed(() => banner.content.value?.content)
let bannerID: number | null = null
const offlineBanner: BannerContent = {
type: "warning",
type: "info",
text: (t) => t("helpers.offline"),
alternateText: (t) => t("helpers.offline_short"),
score: BANNER_PRIORITY_HIGH,
dismissible: true,
}
const network = reactive(useNetwork())
@@ -317,8 +314,6 @@ watch(isOnline, () => {
}
})
const dismissOfflineBanner = () => banner.removeBanner(bannerID!)
const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
@@ -336,12 +331,6 @@ const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const workspace = workspaceService.currentWorkspace
const workspaceName = computed(() => {
return workspace.value.type === "personal"
? t("workspace.personal")
: workspace.value.teamName
})
const refetchTeams = () => {
teamListAdapter.fetchList()
}
@@ -376,6 +365,23 @@ watch(
}
)
const newWorkspaceService = useService(NewWorkspaceService)
const activeWorkspaceName = computed(() => {
const activeWorkspaceHandleRef =
newWorkspaceService.activeWorkspaceHandle.value?.get()
if (activeWorkspaceHandleRef?.value.type === "ok") {
return activeWorkspaceHandleRef.value.data.name
}
return t("workspace.no_workspace")
})
const activeWorkspaceIcon = computed(() => {
return newWorkspaceService.activeWorkspaceDecor.value?.value.headerCurrentIcon
})
const showModalInvite = ref(false)
const showModalEdit = ref(false)

View File

@@ -19,41 +19,41 @@ import { UrlSource } from "~/helpers/import-export/import/import-sources/UrlSour
import IconFile from "~icons/lucide/file"
import {
hoppRESTImporter,
hoppInsomniaImporter,
hoppPostmanImporter,
toTeamsImporter,
hoppOpenAPIImporter,
hoppPostmanImporter,
hoppRESTImporter,
toTeamsImporter,
} from "~/helpers/import-export/import/importers"
import { defineStep } from "~/composables/step-components"
import MyCollectionImport from "~/components/importExport/ImportExportSteps/MyCollectionImport.vue"
import { useI18n } from "~/composables/i18n"
import { useToast } from "~/composables/toast"
import { appendRESTCollections, restCollections$ } from "~/newstore/collections"
import MyCollectionImport from "~/components/importExport/ImportExportSteps/MyCollectionImport.vue"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconOpenAPI from "~icons/lucide/file"
import IconPostman from "~icons/hopp/postman"
import IconInsomnia from "~icons/hopp/insomnia"
import IconPostman from "~icons/hopp/postman"
import IconOpenAPI from "~icons/lucide/file"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconGithub from "~icons/lucide/github"
import IconLink from "~icons/lucide/link"
import IconUser from "~icons/lucide/user"
import { useReadonlyStream } from "~/composables/stream"
import IconUser from "~icons/lucide/user"
import { getTeamCollectionJSON } from "~/helpers/backend/helpers"
import { platform } from "~/platform"
import { initializeDownloadCollection } from "~/helpers/import-export/export"
import { initializeDownloadFile } from "~/helpers/import-export/export"
import { gistExporter } from "~/helpers/import-export/export/gist"
import { myCollectionsExporter } from "~/helpers/import-export/export/myCollections"
import { teamCollectionsExporter } from "~/helpers/import-export/export/teamCollections"
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
import { useService } from "dioc/vue"
import { ImporterOrExporter } from "~/components/importExport/types"
import { GistSource } from "~/helpers/import-export/import/import-sources/GistSource"
import { NewWorkspaceService } from "~/services/new-workspace"
import { TeamWorkspace } from "~/services/workspace.service"
const t = useI18n()
@@ -82,17 +82,45 @@ const currentUser = useReadonlyStream(
platform.auth.getCurrentUser()
)
const myCollections = useReadonlyStream(restCollections$, [])
const workspaceService = useService(NewWorkspaceService)
const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle
const showImportFailedError = () => {
toast.error(t("import.failed"))
}
const handleImportToStore = async (collections: HoppCollection[]) => {
const importResult =
props.collectionsType.type === "my-collections"
? await importToPersonalWorkspace(collections)
: await importToTeamsWorkspace(collections)
if (props.collectionsType.type === "my-collections") {
if (!activeWorkspaceHandle.value) {
return E.left("INVALID_WORKSPACE_HANDLE")
}
const collectionHandleResult = await workspaceService.importRESTCollections(
activeWorkspaceHandle.value,
collections
)
if (E.isLeft(collectionHandleResult)) {
// INVALID_WORKSPACE_HANDLE
return toast.error(t("import.failed"))
}
const resultHandle = collectionHandleResult.right
const requestHandleRef = resultHandle.get()
if (requestHandleRef.value.type === "invalid") {
// WORKSPACE_INVALIDATED
}
toast.success(t("state.file_imported"))
emit("hide-modal")
return
}
const importResult = await importToTeamsWorkspace(collections)
if (E.isRight(importResult)) {
toast.success(t("state.file_imported"))
@@ -102,13 +130,6 @@ const handleImportToStore = async (collections: HoppCollection[]) => {
}
}
const importToPersonalWorkspace = (collections: HoppCollection[]) => {
appendRESTCollections(collections)
return E.right({
success: true,
})
}
function translateToTeamCollectionFormat(x: HoppCollection) {
const folders: HoppCollection[] = (x.folders ?? []).map(
translateToTeamCollectionFormat
@@ -388,28 +409,36 @@ const HoppMyCollectionsExporter: ImporterOrExporter = {
applicableTo: ["personal-workspace"],
isLoading: isHoppMyCollectionExporterInProgress,
},
action: () => {
if (!myCollections.value.length) {
return toast.error(t("error.no_collections_to_export"))
action: async () => {
if (!activeWorkspaceHandle.value) {
return toast.error("error.something_went_wrong")
}
isHoppMyCollectionExporterInProgress.value = true
const message = initializeDownloadCollection(
myCollectionsExporter(myCollections.value),
"Collections"
const result = await workspaceService.exportRESTCollections(
activeWorkspaceHandle.value
)
if (E.isRight(message)) {
toast.success(t(message.right))
// INVALID_COLLECTION_HANDLE | NO_COLLECTIONS_TO_EXPORT
if (E.isLeft(result)) {
isHoppMyCollectionExporterInProgress.value = false
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "rest",
})
if (result.left.error === "NO_COLLECTIONS_TO_EXPORT") {
return toast.error(t("error.no_collections_to_export"))
}
return toast.error(t("error.something_went_wrong"))
}
toast.success(t("state.download_started"))
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "rest",
})
isHoppMyCollectionExporterInProgress.value = false
},
}
@@ -443,10 +472,7 @@ const HoppTeamCollectionsExporter: ImporterOrExporter = {
return toast.error(t("error.no_collections_to_export"))
}
initializeDownloadCollection(
exportCollectionsToJSON,
"team-collections"
)
initializeDownloadFile(exportCollectionsToJSON, "team-collections")
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
@@ -485,7 +511,7 @@ const HoppGistCollectionsExporter: ImporterOrExporter = {
const collectionJSON = await getCollectionJSON()
const accessToken = currentUser.value?.accessToken
if (!accessToken) {
if (!accessToken || E.isLeft(collectionJSON)) {
toast.error(t("error.something_went_wrong"))
isHoppGistCollectionExporterInProgress.value = false
return
@@ -581,6 +607,7 @@ const selectedTeamID = computed(() => {
})
const getCollectionJSON = async () => {
// TODO: Implement `getRESTCollectionJSONView` for team workspace
if (
props.collectionsType.type === "team-collections" &&
props.collectionsType.selectedTeam?.teamID
@@ -591,11 +618,33 @@ const getCollectionJSON = async () => {
return E.isRight(res)
? E.right(res.right.exportCollectionsToJSON)
: E.left(res.left)
: E.left(res.left.error.toString())
}
if (props.collectionsType.type === "my-collections") {
return E.right(JSON.stringify(myCollections.value, null, 2))
if (!activeWorkspaceHandle.value) {
return E.left("INVALID_WORKSPACE_HANDLE")
}
const collectionJSONHandleResult =
await workspaceService.getRESTCollectionJSONView(
activeWorkspaceHandle.value
)
if (E.isLeft(collectionJSONHandleResult)) {
return E.left(collectionJSONHandleResult.left.error)
}
const collectionJSONHandle = collectionJSONHandleResult.right
const collectionJSONHandleRef = collectionJSONHandle.get()
if (collectionJSONHandleRef.value.type === "invalid") {
// WORKSPACE_INVALIDATED
return E.left("WORKSPACE_INVALIDATED")
}
return E.right(collectionJSONHandleRef.value.data.content)
}
return E.left("INVALID_SELECTED_TEAM_OR_INVALID_COLLECTION_TYPE")

View File

@@ -100,11 +100,15 @@ const props = withDefaults(
editingProperties: EditingProperties | null
source: "REST" | "GraphQL"
modelValue: string
// TODO: Purpose of this prop is to maintain backwards compatibility
// To be removed after porting all usages of this component
emitWithFullCollection: boolean
}>(),
{
show: false,
loadingState: false,
editingProperties: null,
emitWithFullCollection: true,
}
)

View File

@@ -20,19 +20,25 @@
<label class="p-4">
{{ t("collection.select_location") }}
</label>
<CollectionsGraphql
<!-- <CollectionsGraphql
v-if="mode === 'graphql'"
:picked="picked"
:save-request="true"
@select="onSelect"
/>
<Collections
/> -->
<!-- <Collections
v-else
:picked="picked"
:save-request="true"
@select="onSelect"
@update-team="updateTeam"
@update-collection-type="updateCollectionType"
/> -->
<NewCollections
:picked="picked"
:save-request="true"
platform="rest"
@select="onSelect"
/>
</div>
</template>
@@ -65,40 +71,31 @@ import {
} from "@hoppscotch/data"
import { computedWithControl } from "@vueuse/core"
import { useService } from "dioc/vue"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import * as E from "fp-ts/Either"
import { cloneDeep } from "lodash-es"
import { computed, nextTick, reactive, ref, watch } from "vue"
import { GQLError } from "~/helpers/backend/GQLClient"
import {
createRequestInCollection,
updateTeamRequest,
} from "~/helpers/backend/mutations/TeamRequest"
import { Picked } from "~/helpers/types/HoppPicked"
import {
cascadeParentCollectionForHeaderAuth,
editGraphqlRequest,
editRESTRequest,
saveGraphqlRequestAs,
saveRESTRequestAs,
} from "~/newstore/collections"
import { platform } from "~/platform"
import { cascadeParentCollectionForHeaderAuth } from "~/newstore/collections"
import { NewWorkspaceService } from "~/services/new-workspace"
import { GQLTabService } from "~/services/tab/graphql"
import { RESTTabService } from "~/services/tab/rest"
import { TeamWorkspace } from "~/services/workspace.service"
const t = useI18n()
const toast = useToast()
const RESTTabs = useService(RESTTabService)
const GQLTabs = useService(GQLTabService)
const workspaceService = useService(NewWorkspaceService)
type CollectionType =
| {
type: "team-collections"
selectedTeam: TeamWorkspace
}
| { type: "my-collections"; selectedTeam: undefined }
// type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
// type CollectionType =
// | {
// type: "team-collections"
// selectedTeam: SelectedTeam
// }
// | { type: "my-collections"; selectedTeam: undefined }
const props = withDefaults(
defineProps<{
@@ -166,10 +163,10 @@ const requestData = reactive({
requestIndex: undefined as number | undefined,
})
const collectionsType = ref<CollectionType>({
type: "my-collections",
selectedTeam: undefined,
})
// const collectionsType = ref<CollectionType>({
// type: "my-collections",
// selectedTeam: undefined,
// })
const picked = ref<Picked | null>(null)
@@ -190,13 +187,14 @@ watch(
}
)
const updateTeam = (newTeam: TeamWorkspace) => {
collectionsType.value.selectedTeam = newTeam
}
// TODO: To be removed
// const updateTeam = (newTeam: SelectedTeam) => {
// collectionsType.value.selectedTeam = newTeam
// }
const updateCollectionType = (type: CollectionType["type"]) => {
collectionsType.value.type = type
}
// const updateCollectionType = (type: CollectionType["type"]) => {
// collectionsType.value.type = type
// }
const onSelect = (pickedVal: Picked | null) => {
picked.value = pickedVal
@@ -212,104 +210,109 @@ const saveRequestAs = async () => {
return
}
const requestUpdated =
const updatedRequest =
props.mode === "rest"
? cloneDeep(RESTTabs.currentActiveTab.value.document.request)
: cloneDeep(GQLTabs.currentActiveTab.value.document.request)
requestUpdated.name = requestName.value
updatedRequest.name = requestName.value
if (picked.value.pickedType === "my-collection") {
if (!isHoppRESTRequest(requestUpdated))
if (!workspaceService.activeWorkspaceHandle.value) {
return
}
if (
picked.value.pickedType === "my-collection" ||
picked.value.pickedType === "my-folder"
) {
if (!isHoppRESTRequest(updatedRequest))
throw new Error("requestUpdated is not a REST Request")
const insertionIndex = saveRESTRequestAs(
`${picked.value.collectionIndex}`,
requestUpdated
const collectionPathIndex =
picked.value.pickedType === "my-collection"
? picked.value.collectionIndex.toString()
: picked.value.folderPath
const collectionHandleResult = await workspaceService.getCollectionHandle(
workspaceService.activeWorkspaceHandle.value,
collectionPathIndex
)
if (E.isLeft(collectionHandleResult)) {
// INVALID_WORKSPACE_HANDLE | INVALID_COLLECTION_ID | INVALID_PATH
return
}
const collectionHandle = collectionHandleResult.right
const requestHandleResult = await workspaceService.createRESTRequest(
collectionHandle,
updatedRequest
)
if (E.isLeft(requestHandleResult)) {
// WORKSPACE_INVALIDATED | INVALID_COLLECTION_HANDLE
return
}
const requestHandle = requestHandleResult.right
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
// WORKSPACE_INVALIDATED | INVALID_COLLECTION_HANDLE
return
}
RESTTabs.currentActiveTab.value.document = {
request: requestUpdated,
request: updatedRequest,
isDirty: false,
saveContext: {
originLocation: "user-collection",
folderPath: `${picked.value.collectionIndex}`,
requestIndex: insertionIndex,
originLocation: "workspace-user-collection",
requestHandle,
},
}
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
`${picked.value.collectionIndex}`,
"rest"
)
RESTTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "personal",
})
requestSaved()
} else if (picked.value.pickedType === "my-folder") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
const insertionIndex = saveRESTRequestAs(
picked.value.folderPath,
requestUpdated
)
RESTTabs.currentActiveTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
originLocation: "user-collection",
folderPath: picked.value.folderPath,
requestIndex: insertionIndex,
},
}
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
picked.value.folderPath,
"rest"
)
RESTTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "personal",
})
requestSaved()
} else if (picked.value.pickedType === "my-request") {
if (!isHoppRESTRequest(requestUpdated))
if (!isHoppRESTRequest(updatedRequest))
throw new Error("requestUpdated is not a REST Request")
editRESTRequest(
picked.value.folderPath,
picked.value.requestIndex,
requestUpdated
const requestHandleResult = await workspaceService.getRequestHandle(
workspaceService.activeWorkspaceHandle.value,
`${picked.value.folderPath}/${picked.value.requestIndex.toString()}`
)
if (E.isLeft(requestHandleResult)) {
// INVALID_COLLECTION_HANDLE | INVALID_REQUEST_ID | REQUEST_NOT_FOUND
return
}
const requestHandle = requestHandleResult.right
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
// WORKSPACE_INVALIDATED
return
}
const updateRequestResult = await workspaceService.updateRESTRequest(
requestHandle,
updatedRequest
)
if (E.isLeft(updateRequestResult)) {
// WORKSPACE_INVALIDATED | INVALID_REQUEST_HANDLE
return
}
RESTTabs.currentActiveTab.value.document = {
request: requestUpdated,
request: updatedRequest,
isDirty: false,
saveContext: {
originLocation: "user-collection",
folderPath: picked.value.folderPath,
requestIndex: picked.value.requestIndex,
originLocation: "workspace-user-collection",
requestHandle,
},
}
@@ -323,152 +326,147 @@ const saveRequestAs = async () => {
headers,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "rest",
workspaceType: "personal",
})
requestSaved()
} else if (picked.value.pickedType === "teams-collection") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
updateTeamCollectionOrFolder(picked.value.collectionID, requestUpdated)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "team",
})
} else if (picked.value.pickedType === "teams-folder") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
updateTeamCollectionOrFolder(picked.value.folderID, requestUpdated)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "team",
})
} else if (picked.value.pickedType === "teams-request") {
if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request")
if (
collectionsType.value.type !== "team-collections" ||
!collectionsType.value.selectedTeam
)
throw new Error("Collections Type mismatch")
modalLoadingState.value = true
const data = {
request: JSON.stringify(requestUpdated),
title: requestUpdated.name,
}
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "rest",
workspaceType: "team",
})
pipe(
updateTeamRequest(picked.value.requestID, data),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
modalLoadingState.value = false
},
() => {
modalLoadingState.value = false
requestSaved()
}
)
)()
} else if (picked.value.pickedType === "gql-my-request") {
// TODO: Check for GQL request ?
editGraphqlRequest(
picked.value.folderPath,
picked.value.requestIndex,
requestUpdated as HoppGQLRequest
)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "gql",
workspaceType: "team",
})
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
picked.value.folderPath,
"graphql"
)
GQLTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
requestSaved()
} else if (picked.value.pickedType === "gql-my-folder") {
// TODO: Check for GQL request ?
saveGraphqlRequestAs(
picked.value.folderPath,
requestUpdated as HoppGQLRequest
)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "gql",
workspaceType: "team",
})
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
picked.value.folderPath,
"graphql"
)
GQLTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
requestSaved()
} else if (picked.value.pickedType === "gql-my-collection") {
// TODO: Check for GQL request ?
saveGraphqlRequestAs(
`${picked.value.collectionIndex}`,
requestUpdated as HoppGQLRequest
)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "gql",
workspaceType: "team",
})
const { auth, headers } = cascadeParentCollectionForHeaderAuth(
`${picked.value.collectionIndex}`,
"graphql"
)
GQLTabs.currentActiveTab.value.document.inheritedProperties = {
auth,
headers,
}
requestSaved()
}
// TODO: To be removed
// else if (picked.value.pickedType === "teams-collection") {
// if (!isHoppRESTRequest(updatedRequest))
// throw new Error("requestUpdated is not a REST Request")
// updateTeamCollectionOrFolder(picked.value.collectionID, updatedRequest)
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: true,
// platform: "rest",
// workspaceType: "team",
// })
// } else if (picked.value.pickedType === "teams-folder") {
// if (!isHoppRESTRequest(updatedRequest))
// throw new Error("requestUpdated is not a REST Request")
// updateTeamCollectionOrFolder(picked.value.folderID, updatedRequest)
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: true,
// platform: "rest",
// workspaceType: "team",
// })
// } else if (picked.value.pickedType === "teams-request") {
// if (!isHoppRESTRequest(updatedRequest))
// throw new Error("requestUpdated is not a REST Request")
// if (
// collectionsType.value.type !== "team-collections" ||
// !collectionsType.value.selectedTeam
// )
// throw new Error("Collections Type mismatch")
// modalLoadingState.value = true
// const data = {
// request: JSON.stringify(updatedRequest),
// title: updatedRequest.name,
// }
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: false,
// platform: "rest",
// workspaceType: "team",
// })
// pipe(
// updateTeamRequest(picked.value.requestID, data),
// TE.match(
// (err: GQLError<string>) => {
// toast.error(`${getErrorMessage(err)}`)
// modalLoadingState.value = false
// },
// () => {
// modalLoadingState.value = false
// requestSaved()
// }
// )
// )()
// } else if (picked.value.pickedType === "gql-my-request") {
// // TODO: Check for GQL request ?
// editGraphqlRequest(
// picked.value.folderPath,
// picked.value.requestIndex,
// updatedRequest as HoppGQLRequest
// )
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: false,
// platform: "gql",
// workspaceType: "team",
// })
// const { auth, headers } = cascadeParentCollectionForHeaderAuth(
// picked.value.folderPath,
// "graphql"
// )
// GQLTabs.currentActiveTab.value.document.inheritedProperties = {
// auth,
// headers,
// }
// requestSaved()
// } else if (picked.value.pickedType === "gql-my-folder") {
// // TODO: Check for GQL request ?
// saveGraphqlRequestAs(
// picked.value.folderPath,
// updatedRequest as HoppGQLRequest
// )
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: true,
// platform: "gql",
// workspaceType: "team",
// })
// const { auth, headers } = cascadeParentCollectionForHeaderAuth(
// picked.value.folderPath,
// "graphql"
// )
// GQLTabs.currentActiveTab.value.document.inheritedProperties = {
// auth,
// headers,
// }
// requestSaved()
// } else if (picked.value.pickedType === "gql-my-collection") {
// // TODO: Check for GQL request ?
// saveGraphqlRequestAs(
// `${picked.value.collectionIndex}`,
// updatedRequest as HoppGQLRequest
// )
// platform.analytics?.logEvent({
// type: "HOPP_SAVE_REQUEST",
// createdNow: true,
// platform: "gql",
// workspaceType: "team",
// })
// const { auth, headers } = cascadeParentCollectionForHeaderAuth(
// `${picked.value.collectionIndex}`,
// "graphql"
// )
// GQLTabs.currentActiveTab.value.document.inheritedProperties = {
// auth,
// headers,
// }
// requestSaved()
// }
}
/**
@@ -476,50 +474,50 @@ const saveRequestAs = async () => {
* @param collectionID - ID of the collection or folder
* @param requestUpdated - Updated request
*/
const updateTeamCollectionOrFolder = (
collectionID: string,
requestUpdated: HoppRESTRequest
) => {
if (
collectionsType.value.type !== "team-collections" ||
!collectionsType.value.selectedTeam
)
throw new Error("Collections Type mismatch")
// const updateTeamCollectionOrFolder = (
// collectionID: string,
// requestUpdated: HoppRESTRequest
// ) => {
// if (
// collectionsType.value.type !== "team-collections" ||
// !collectionsType.value.selectedTeam
// )
// throw new Error("Collections Type mismatch")
modalLoadingState.value = true
// modalLoadingState.value = true
const data = {
title: requestUpdated.name,
request: JSON.stringify(requestUpdated),
teamID: collectionsType.value.selectedTeam.teamID,
}
pipe(
createRequestInCollection(collectionID, data),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
modalLoadingState.value = false
},
(result) => {
const { createRequestInCollection } = result
// const data = {
// title: requestUpdated.name,
// request: JSON.stringify(requestUpdated),
// teamID: collectionsType.value.selectedTeam.id,
// }
// pipe(
// createRequestInCollection(collectionID, data),
// TE.match(
// (err: GQLError<string>) => {
// toast.error(`${getErrorMessage(err)}`)
// modalLoadingState.value = false
// },
// (result) => {
// const { createRequestInCollection } = result
RESTTabs.currentActiveTab.value.document = {
request: requestUpdated,
isDirty: false,
saveContext: {
originLocation: "team-collection",
requestID: createRequestInCollection.id,
collectionID: createRequestInCollection.collection.id,
teamID: createRequestInCollection.collection.team.id,
},
}
// RESTTabs.currentActiveTab.value.document = {
// request: requestUpdated,
// isDirty: false,
// saveContext: {
// originLocation: "team-collection",
// requestID: createRequestInCollection.id,
// collectionID: createRequestInCollection.collection.id,
// teamID: createRequestInCollection.collection.team.id,
// },
// }
modalLoadingState.value = false
requestSaved()
}
)
)()
}
// modalLoadingState.value = false
// requestSaved()
// }
// )
// )()
// }
const requestSaved = () => {
toast.success(`${t("request.added")}`)
@@ -534,24 +532,24 @@ const hideModal = () => {
emit("hide-modal")
}
const getErrorMessage = (err: GQLError<string>) => {
console.error(err)
if (err.type === "network_error") {
return t("error.network_error")
}
switch (err.error) {
case "team_coll/short_title":
return t("collection.name_length_insufficient")
case "team/invalid_coll_id":
return t("team.invalid_id")
case "team/not_required_role":
return t("profile.no_permission")
case "team_req/not_required_role":
return t("profile.no_permission")
case "Forbidden resource":
return t("profile.no_permission")
default:
return t("error.something_went_wrong")
}
}
// const getErrorMessage = (err: GQLError<string>) => {
// console.error(err)
// if (err.type === "network_error") {
// return t("error.network_error")
// }
// switch (err.error) {
// case "team_coll/short_title":
// return t("collection.name_length_insufficient")
// case "team/invalid_coll_id":
// return t("team.invalid_id")
// case "team/not_required_role":
// return t("profile.no_permission")
// case "team_req/not_required_role":
// return t("profile.no_permission")
// case "Forbidden resource":
// return t("profile.no_permission")
// default:
// return t("error.something_went_wrong")
// }
// }
</script>

View File

@@ -21,7 +21,7 @@ import { GistSource } from "~/helpers/import-export/import/import-sources/GistSo
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconUser from "~icons/lucide/user"
import { initializeDownloadCollection } from "~/helpers/import-export/export"
import { initializeDownloadFile } from "~/helpers/import-export/export"
import { useReadonlyStream } from "~/composables/stream"
import { platform } from "~/platform"
@@ -133,12 +133,12 @@ const GqlCollectionsHoppExporter: ImporterOrExporter = {
disabled: false,
applicableTo: ["personal-workspace", "team-workspace"],
},
action: () => {
action: async () => {
if (!gqlCollections.value.length) {
return toast.error(t("error.no_collections_to_export"))
}
const message = initializeDownloadCollection(
const message = await initializeDownloadFile(
gqlCollectionsExporter(gqlCollections.value),
"GQLCollections"
)

View File

@@ -37,7 +37,7 @@ import IconFolderPlus from "~icons/lucide/folder-plus"
import IconPostman from "~icons/hopp/postman"
import IconInsomnia from "~icons/hopp/insomnia"
import IconUser from "~icons/lucide/user"
import { initializeDownloadCollection } from "~/helpers/import-export/export"
import { initializeDownloadFile } from "~/helpers/import-export/export"
import { computed } from "vue"
import { useReadonlyStream } from "~/composables/stream"
import { environmentsExporter } from "~/helpers/import-export/export/environments"
@@ -230,12 +230,12 @@ const HoppEnvironmentsExport: ImporterOrExporter = {
disabled: false,
applicableTo: ["personal-workspace", "team-workspace"],
},
action: () => {
action: async () => {
if (!environmentJson.value.length) {
return toast.error(t("error.no_environments_to_export"))
}
const message = initializeDownloadCollection(
const message = await initializeDownloadFile(
environmentsExporter(environmentJson.value),
"Environments"
)

View File

@@ -236,16 +236,28 @@ import { useI18n } from "@composables/i18n"
import { useSetting } from "@composables/settings"
import { useReadonlyStream, useStreamSubscriber } from "@composables/stream"
import { useToast } from "@composables/toast"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core"
import { useService } from "dioc/vue"
import * as E from "fp-ts/Either"
import { Ref, computed, ref, onUnmounted } from "vue"
import { Ref, computed, onUnmounted, ref } from "vue"
import { runRESTRequest$ } from "~/helpers/RequestRunner"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { runMutation } from "~/helpers/backend/GQLClient"
import { UpdateRequestDocument } from "~/helpers/backend/graphql"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import { runRESTRequest$ } from "~/helpers/RequestRunner"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
import { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import { editRESTRequest } from "~/newstore/collections"
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
import { platform } from "~/platform"
import { InspectionService } from "~/services/inspection"
import { InterceptorService } from "~/services/interceptor.service"
import { NewWorkspaceService } from "~/services/new-workspace"
import { HoppTab } from "~/services/tab"
import { RESTTabService } from "~/services/tab/rest"
import { WorkspaceService } from "~/services/workspace.service"
import IconChevronDown from "~icons/lucide/chevron-down"
import IconCode2 from "~icons/lucide/code-2"
import IconFileCode from "~icons/lucide/file-code"
@@ -253,21 +265,10 @@ import IconFolderPlus from "~icons/lucide/folder-plus"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconSave from "~icons/lucide/save"
import IconShare2 from "~icons/lucide/share-2"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { RESTHistoryEntry, restHistory$ } from "~/newstore/history"
import { platform } from "~/platform"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { InterceptorService } from "~/services/interceptor.service"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { RESTTabService } from "~/services/tab/rest"
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
import { WorkspaceService } from "~/services/workspace.service"
const t = useI18n()
const interceptorService = useService(InterceptorService)
const newWorkspaceService = useService(NewWorkspaceService)
const methods = [
"GET",
@@ -506,34 +507,61 @@ const cycleDownMethod = () => {
}
}
const saveRequest = () => {
const saveCtx = tab.value.document.saveContext
const saveRequest = async () => {
const { saveContext } = tab.value.document
if (!saveCtx) {
if (!saveContext) {
showSaveRequestModal.value = true
return
}
if (saveCtx.originLocation === "user-collection") {
const req = tab.value.document.request
try {
editRESTRequest(saveCtx.folderPath, saveCtx.requestIndex, req)
if (saveContext.originLocation === "workspace-user-collection") {
const updatedRequest = tab.value.document.request
tab.value.document.isDirty = false
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
platform: "rest",
createdNow: false,
workspaceType: "personal",
})
toast.success(`${t("request.saved")}`)
} catch (e) {
tab.value.document.saveContext = undefined
saveRequest()
if (
!newWorkspaceService.activeWorkspaceHandle.value ||
!saveContext.requestHandle
) {
return
}
} else if (saveCtx.originLocation === "team-collection") {
const { requestHandle } = saveContext
const requestHandleRef = requestHandle.get()
if (!requestHandleRef.value) {
return
}
if (requestHandleRef.value.type === "invalid") {
showSaveRequestModal.value = true
return
}
const updateRequestResult = await newWorkspaceService.updateRESTRequest(
requestHandle,
updatedRequest
)
if (E.isLeft(updateRequestResult)) {
// INVALID_REQUEST_HANDLE
showSaveRequestModal.value = true
if (!tab.value.document.isDirty) {
tab.value.document.isDirty = true
}
return
}
tab.value.document.isDirty = false
tab.value.document.saveContext = {
...saveContext,
requestHandle,
}
toast.success(`${t("request.saved")}`)
} else if (saveContext.originLocation === "team-collection") {
const req = tab.value.document.request
// TODO: handle error case (NOTE: overwriteRequestTeams is async)
@@ -546,7 +574,7 @@ const saveRequest = () => {
})
runMutation(UpdateRequestDocument, {
requestID: saveCtx.requestID,
requestID: saveContext.requestID,
data: {
title: req.name,
request: JSON.stringify(req),

View File

@@ -17,8 +17,7 @@
<script setup lang="ts">
import { watch } from "vue"
import { useVModel } from "@vueuse/core"
import { cloneDeep } from "lodash-es"
import { isEqualHoppRESTRequest } from "@hoppscotch/data"
import { cloneDeep, isEqual } from "lodash-es"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
@@ -32,15 +31,42 @@ const emit = defineEmits<{
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) => {
// Request from the collection tree
if (
!tab.value.document.isDirty &&
!isEqualHoppRESTRequest(oldRequest, updatedValue)
tab.value.document.saveContext?.originLocation ===
"workspace-user-collection"
) {
const requestHandleRef =
tab.value.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return
}
if (
!tab.value.document.isDirty &&
!isEqual(oldRequest, requestHandleRef?.value.data.request)
) {
tab.value.document.isDirty = true
}
if (
tab.value.document.isDirty &&
isEqual(oldRequest, requestHandleRef?.value.data.request)
) {
tab.value.document.isDirty = false
}
return
}
// Unsaved request
if (!tab.value.document.isDirty && !isEqual(oldRequest, updatedValue)) {
tab.value.document.isDirty = true
}

View File

@@ -10,7 +10,8 @@
:icon="IconFolder"
:label="`${t('tab.collections')}`"
>
<Collections />
<!-- <Collections /> -->
<NewCollections :platform="'rest'" />
</HoppSmartTab>
<HoppSmartTab
:id="'env'"
@@ -37,12 +38,13 @@
</template>
<script setup lang="ts">
import IconClock from "~icons/lucide/clock"
import IconLayers from "~icons/lucide/layers"
import IconFolder from "~icons/lucide/folder"
import IconShare2 from "~icons/lucide/share-2"
import { ref } from "vue"
import { useI18n } from "@composables/i18n"
import { ref } from "vue"
import IconClock from "~icons/lucide/clock"
import IconFolder from "~icons/lucide/folder"
import IconLayers from "~icons/lucide/layers"
import IconShare2 from "~icons/lucide/share-2"
const t = useI18n()

View File

@@ -106,15 +106,15 @@
<script setup lang="ts">
import { ref } from "vue"
import { TippyComponent } from "vue-tippy"
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
import { useI18n } from "~/composables/i18n"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
import { HoppTab } from "~/services/tab"
import IconCopy from "~icons/lucide/copy"
import IconFileEdit from "~icons/lucide/file-edit"
import IconShare2 from "~icons/lucide/share-2"
import IconXCircle from "~icons/lucide/x-circle"
import IconXSquare from "~icons/lucide/x-square"
import IconFileEdit from "~icons/lucide/file-edit"
import IconCopy from "~icons/lucide/copy"
import IconShare2 from "~icons/lucide/share-2"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
const t = useI18n()

View File

@@ -0,0 +1,32 @@
<template>
<div v-if="!activeWorkspaceHandle">No Workspace Selected.</div>
<NewCollectionsRest
v-else-if="platform === 'rest'"
:picked="picked"
:save-request="saveRequest"
:workspace-handle="activeWorkspaceHandle"
@select="(payload) => emit('select', payload)"
/>
</template>
<script setup lang="ts">
import { useService } from "dioc/vue"
import { Picked } from "~/helpers/types/HoppPicked"
import { NewWorkspaceService } from "~/services/new-workspace"
defineProps<{
picked?: Picked | null
platform: "rest" | "gql"
saveRequest?: boolean
}>()
const emit = defineEmits<{
(event: "select", payload: Picked | null): void
}>()
const workspaceService = useService(NewWorkspaceService)
const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle
</script>

View File

@@ -0,0 +1,450 @@
<template>
<div class="flex flex-col">
<div
class="h-1 w-full transition"
:class="[
{
'bg-accentDark': isReorderable,
},
]"
@drop="orderUpdateCollectionEvent"
@dragover.prevent="ordering = true"
@dragleave="ordering = false"
@dragend="resetDragState"
></div>
<div class="relative flex flex-col">
<div
class="z-[1] pointer-events-none absolute inset-0 bg-accent opacity-0 transition"
:class="{
'opacity-25':
dragging && notSameDestination && notSameParentDestination,
}"
></div>
<div
class="z-[3] group pointer-events-auto relative flex cursor-pointer items-stretch"
:draggable="true"
@dragstart="dragStart"
@dragover="handleDragOver($event)"
@dragleave="resetDragState"
@dragend="
() => {
resetDragState()
dropItemID = ''
}
"
@drop="handleDrop($event)"
@contextmenu.prevent="options?.tippy?.show()"
>
<div
class="flex min-w-0 flex-1 items-center justify-center"
@click="emit('toggle-children')"
>
<span
class="pointer-events-none flex items-center justify-center px-4"
>
<component
:is="collectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="pointer-events-none flex min-w-0 flex-1 py-2 pr-2 transition group-hover:text-secondaryDark"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collectionView.name }}
</span>
</span>
</div>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="addRequest"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="addChildCollection"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.r="requestAction?.$el.click()"
@keyup.n="folderAction?.$el.click()"
@keyup.e="edit?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.x="exportAction?.$el.click()"
@keyup.p="propertiesAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
addRequest()
hide()
}
"
/>
<HoppSmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
addChildCollection()
hide()
}
"
/>
<HoppSmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
editCollection()
hide()
}
"
/>
<HoppSmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
@click="
() => {
emit('export-collection', collectionView.collectionID)
hide()
}
"
/>
<HoppSmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
removeCollection()
hide()
}
"
/>
<HoppSmartItem
ref="propertiesAction"
:icon="IconSettings2"
:label="t('action.properties')"
:shortcut="['P']"
@click="
() => {
emit(
'edit-collection-properties',
collectionView.collectionID
)
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
<div
v-if="collectionView.isLastItem"
class="w-full transition"
:class="[
{
'bg-accentDark': isLastItemReorderable,
'h-1 ': collectionView.isLastItem,
},
]"
@drop="updateLastItemOrder"
@dragover.prevent="orderingLastItem = true"
@dragleave="orderingLastItem = false"
@dragend="resetDragState"
></div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { TippyComponent } from "vue-tippy"
import { useI18n } from "~/composables/i18n"
import { useReadonlyStream } from "~/composables/stream"
import {
currentReorderingStatus$,
changeCurrentReorderStatus,
} from "~/newstore/reordering"
import { RESTCollectionViewCollection } from "~/services/new-workspace/view"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconDownload from "~icons/lucide/download"
import IconEdit from "~icons/lucide/edit"
import IconFilePlus from "~icons/lucide/file-plus"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconSettings2 from "~icons/lucide/settings-2"
import IconTrash2 from "~icons/lucide/trash-2"
const t = useI18n()
const props = defineProps<{
collectionView: RESTCollectionViewCollection
isOpen: boolean
isSelected?: boolean | null
}>()
const emit = defineEmits<{
(event: "add-child-collection", parentCollectionIndexPath: string): void
(event: "add-request", parentCollectionIndexPath: string): void
(event: "dragging", payload: boolean): void
(event: "drop-event", payload: DataTransfer): void
(event: "drag-event", payload: DataTransfer): void
(
event: "edit-child-collection",
payload: { collectionIndexPath: string; collectionName: string }
): void
(event: "edit-collection-properties", collectionIndexPath: string): void
(
event: "edit-root-collection",
payload: { collectionIndexPath: string; collectionName: string }
): void
(event: "export-collection", collectionIndexPath: string): void
(event: "remove-child-collection", collectionIndexPath: string): void
(event: "remove-root-collection", collectionIndexPath: string): void
(event: "toggle-children"): void
(event: "update-collection-order", payload: DataTransfer): void
(event: "update-last-collection-order", payload: DataTransfer): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const requestAction = ref<HTMLButtonElement | null>(null)
const folderAction = ref<HTMLButtonElement | null>(null)
const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const exportAction = ref<HTMLButtonElement | null>(null)
const propertiesAction = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
const dragging = ref(false)
const ordering = ref(false)
const orderingLastItem = ref(false)
const dropItemID = ref("")
const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
type: "collection",
id: "",
parentID: "",
})
// Used to determine if the collection is being dragged to a different destination
// This is used to make the highlight effect work
watch(
() => dragging.value,
(val) => {
if (val && notSameDestination.value && notSameParentDestination.value) {
emit("dragging", true)
} else {
emit("dragging", false)
}
}
)
const collectionIcon = computed(() => {
if (props.isSelected) {
return IconCheckCircle
}
return props.isOpen ? IconFolderOpen : IconFolder
})
const notSameParentDestination = computed(() => {
return (
currentReorderingStatus.value.parentID !== props.collectionView.collectionID
)
})
const isRequestDragging = computed(() => {
return currentReorderingStatus.value.type === "request"
})
const isSameParent = computed(() => {
return (
currentReorderingStatus.value.parentID ===
props.collectionView.parentCollectionID
)
})
const isReorderable = computed(() => {
return (
ordering.value &&
notSameDestination.value &&
!isRequestDragging.value &&
isSameParent.value
)
})
const isLastItemReorderable = computed(() => {
return (
orderingLastItem.value &&
notSameDestination.value &&
!isRequestDragging.value &&
isSameParent.value
)
})
const addChildCollection = () => {
emit("add-child-collection", props.collectionView.collectionID)
}
const addRequest = () => {
emit("add-request", props.collectionView.collectionID)
}
const editCollection = () => {
const { collectionID: collectionIndexPath, name: collectionName } =
props.collectionView
const data = {
collectionIndexPath,
collectionName,
}
collectionIndexPath.split("/").length > 1
? emit("edit-child-collection", data)
: emit("edit-root-collection", data)
}
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
emit("drag-event", dataTransfer)
dropItemID.value = dataTransfer.getData("collectionIndex")
dragging.value = !dragging.value
changeCurrentReorderStatus({
type: "collection",
id: props.collectionView.collectionID,
parentID: props.collectionView.parentCollectionID,
})
}
}
// Trigger the re-ordering event when a collection is dragged over another collection's top section
const handleDragOver = (e: DragEvent) => {
dragging.value = true
if (
e.offsetY < 10 &&
notSameDestination.value &&
!isRequestDragging.value &&
isSameParent.value
) {
ordering.value = true
dragging.value = false
orderingLastItem.value = false
} else if (
e.offsetY > 18 &&
notSameDestination.value &&
!isRequestDragging.value &&
isSameParent.value &&
props.collectionView.isLastItem
) {
orderingLastItem.value = true
dragging.value = false
ordering.value = false
} else {
ordering.value = false
orderingLastItem.value = false
}
}
const handleDrop = (e: DragEvent) => {
if (ordering.value) {
orderUpdateCollectionEvent(e)
} else if (orderingLastItem.value) {
updateLastItemOrder(e)
} else {
notSameParentDestination.value ? dropEvent(e) : e.stopPropagation()
}
}
const dropEvent = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
emit("drop-event", e.dataTransfer)
resetDragState()
}
}
const orderUpdateCollectionEvent = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
emit("update-collection-order", e.dataTransfer)
resetDragState()
}
}
const updateLastItemOrder = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
emit("update-last-collection-order", e.dataTransfer)
resetDragState()
}
}
const notSameDestination = computed(() => {
return dropItemID.value !== props.collectionView.collectionID
})
const removeCollection = () => {
const { collectionID } = props.collectionView
collectionID.split("/").length > 1
? emit("remove-child-collection", collectionID)
: emit("remove-root-collection", collectionID)
}
const resetDragState = () => {
dragging.value = false
ordering.value = false
orderingLastItem.value = false
}
</script>

View File

@@ -0,0 +1,334 @@
<template>
<div class="flex flex-col">
<div
class="h-1 w-full transition"
:class="[
{
'bg-accentDark': isReorderable,
},
]"
@drop="updateRequestOrder"
@dragover.prevent="ordering = true"
@dragleave="resetDragState"
@dragend="resetDragState"
></div>
<div
class="group flex items-stretch"
:draggable="true"
@dragstart="dragStart"
@dragover="handleDragOver($event)"
@dragleave="resetDragState"
@dragend="resetDragState"
@drop="handleDrop"
@contextmenu.prevent="options?.tippy?.show()"
>
<div
class="pointer-events-auto flex min-w-0 flex-1 cursor-pointer items-center justify-center"
@click="selectRequest"
>
<span
class="pointer-events-none flex w-16 items-center justify-center truncate px-2"
:class="requestLabelColor"
:style="{ color: requestLabelColor }"
>
<component
:is="IconCheckCircle"
v-if="isSelected"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
<span v-else class="truncate text-tiny font-semibold">
{{ requestView.request.method }}
</span>
</span>
<span
class="pointer-events-none flex min-w-0 flex-1 items-center py-2 pr-2 transition group-hover:text-secondaryDark"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ requestView.request.name }}
</span>
<span
v-if="props.isActive"
v-tippy="{ theme: 'tooltip' }"
class="relative mx-3 flex h-1.5 w-1.5 flex-shrink-0"
:title="`${t('collection.request_in_use')}`"
>
<span
class="absolute inline-flex h-full w-full flex-shrink-0 animate-ping rounded-full bg-green-500 opacity-75"
>
</span>
<span
class="relative inline-flex h-1.5 w-1.5 flex-shrink-0 rounded-full bg-green-500"
></span>
</span>
</span>
</div>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
class="hidden group-hover:inline-flex"
@click="selectRequest"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions?.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.more')"
:icon="IconMoreVertical"
/>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.e="edit?.$el.click()"
@keyup.d="duplicate?.$el.click()"
@keyup.delete="deleteAction?.$el.click()"
@keyup.s="shareAction?.$el.click()"
@keyup.escape="hide()"
>
<HoppSmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-request', {
requestIndexPath: requestView.requestID,
requestName: requestView.request.name,
})
hide()
}
"
/>
<HoppSmartItem
ref="duplicate"
:icon="IconCopy"
:label="t('action.duplicate')"
:shortcut="['D']"
@click="
() => {
emit('duplicate-request', requestView.requestID)
hide()
}
"
/>
<HoppSmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
emit('remove-request', requestView.requestID)
hide()
}
"
/>
<HoppSmartItem
ref="shareAction"
:icon="IconShare2"
:label="t('action.share')"
:shortcut="['S']"
@click="
() => {
emit('share-request', requestView.request)
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
<div
class="w-full transition"
:class="[
{
'bg-accentDark': isLastItemReorderable,
'h-1 ': props.requestView.isLastItem,
},
]"
@drop="handleDrop"
@dragover.prevent="orderingLastItem = true"
@dragleave="resetDragState"
@dragend="resetDragState"
></div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { computed, ref } from "vue"
import { TippyComponent } from "vue-tippy"
import { useReadonlyStream } from "~/composables/stream"
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
import {
currentReorderingStatus$,
changeCurrentReorderStatus,
} from "~/newstore/reordering"
import { RESTCollectionViewRequest } from "~/services/new-workspace/view"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCopy from "~icons/lucide/copy"
import IconEdit from "~icons/lucide/edit"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconShare2 from "~icons/lucide/share-2"
import IconTrash2 from "~icons/lucide/trash-2"
const t = useI18n()
const currentReorderingStatus = useReadonlyStream(currentReorderingStatus$, {
type: "collection",
id: "",
parentID: "",
})
const props = defineProps<{
isActive: boolean
requestView: RESTCollectionViewRequest
isSelected: boolean | null | undefined
}>()
const emit = defineEmits<{
(event: "duplicate-request", requestIndexPath: string): void
(
event: "edit-request",
payload: {
requestIndexPath: string
requestName: string
}
): void
(event: "remove-request", requestIndexPath: string): void
(event: "select-request", requestIndexPath: string): void
(event: "share-request", request: HoppRESTRequest): void
(event: "drag-request", payload: DataTransfer): void
(event: "update-request-order", payload: DataTransfer): void
(event: "update-last-request-order", payload: DataTransfer): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const duplicate = ref<HTMLButtonElement | null>(null)
const shareAction = ref<HTMLButtonElement | null>(null)
const dragging = ref(false)
const ordering = ref(false)
const orderingLastItem = ref(false)
const isCollectionDragging = computed(() => {
return currentReorderingStatus.value.type === "collection"
})
const isLastItemReorderable = computed(() => {
return (
orderingLastItem.value && isSameParent.value && !isCollectionDragging.value
)
})
const isReorderable = computed(() => {
return (
ordering.value &&
!isCollectionDragging.value &&
isSameParent.value &&
!isSameRequest.value
)
})
const isSameParent = computed(() => {
return (
currentReorderingStatus.value.parentID === props.requestView.collectionID
)
})
const isSameRequest = computed(() => {
return currentReorderingStatus.value.id === props.requestView.requestID
})
const requestLabelColor = computed(() =>
getMethodLabelColorClassOf(props.requestView.request)
)
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
emit("drag-request", dataTransfer)
dragging.value = !dragging.value
changeCurrentReorderStatus({
type: "request",
id: props.requestView.requestID,
parentID: props.requestView.collectionID,
})
}
}
const handleDrop = (e: DragEvent) => {
if (ordering.value) {
updateRequestOrder(e)
} else if (orderingLastItem.value) {
updateLastItemOrder(e)
} else {
updateRequestOrder(e)
}
}
// Trigger the re-ordering event when a request is dragged over another request's top section
const handleDragOver = (e: DragEvent) => {
dragging.value = true
if (e.offsetY < 10) {
ordering.value = true
dragging.value = false
orderingLastItem.value = false
} else if (e.offsetY > 18) {
orderingLastItem.value = true
dragging.value = false
ordering.value = false
} else {
ordering.value = false
orderingLastItem.value = false
}
}
const resetDragState = () => {
dragging.value = false
ordering.value = false
orderingLastItem.value = false
}
const selectRequest = () => emit("select-request", props.requestView.requestID)
const updateRequestOrder = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
resetDragState()
emit("update-request-order", e.dataTransfer)
}
}
const updateLastItemOrder = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
resetDragState()
emit("update-last-request-order", e.dataTransfer)
}
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
class="flex items-center overflow-x-auto whitespace-nowrap border-b border-dividerLight px-4 py-2 text-tiny text-secondaryLight"
>
<span class="truncate">
{{ currentWorkspace }}
{{ workspaceName ?? t("workspace.no_workspace") }}
</span>
<icon-lucide-chevron-right v-if="section" class="mx-2" />
{{ section }}
@@ -14,29 +14,24 @@
import { computed } from "vue"
import { useI18n } from "~/composables/i18n"
import { useService } from "dioc/vue"
import { WorkspaceService } from "~/services/workspace.service"
import { NewWorkspaceService } from "~/services/new-workspace"
const props = defineProps<{
defineProps<{
section?: string
isOnlyPersonal?: boolean
}>()
const t = useI18n()
const workspaceService = useService(WorkspaceService)
const workspace = workspaceService.currentWorkspace
const workspaceService = useService(NewWorkspaceService)
const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle
const currentWorkspace = computed(() => {
if (props.isOnlyPersonal || workspace.value.type === "personal") {
return t("workspace.personal")
}
return teamWorkspaceName.value
})
const workspaceName = computed(() => {
const activeWorkspaceHandleRef = activeWorkspaceHandle.value?.get()
const teamWorkspaceName = computed(() => {
if (workspace.value.type === "team" && workspace.value.teamName) {
return workspace.value.teamName
if (activeWorkspaceHandleRef?.value.type === "ok") {
return activeWorkspaceHandleRef.value.data.name
}
return `${t("workspace.team")}`
return undefined
})
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div>
<div class="flex flex-col">
<HoppSmartItem
:label="'Personal Workspace'"
:info-icon="
activeWorkspaceInfo?.provider ===
personalWorkspaceProviderService.providerID &&
activeWorkspaceInfo.workspaceID === 'personal'
? IconCheck
: undefined
"
:active-info-icon="
activeWorkspaceInfo?.provider ===
personalWorkspaceProviderService.providerID &&
activeWorkspaceInfo.workspaceID === 'personal'
"
@click="selectWorkspace"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useService } from "dioc/vue"
import { NewWorkspaceService } from "~/services/new-workspace"
import { computed } from "vue"
import { PersonalWorkspaceProviderService } from "~/services/new-workspace/providers/personal.workspace"
import IconCheck from "~icons/lucide/check"
const workspaceService = useService(NewWorkspaceService)
const personalWorkspaceProviderService = useService(
PersonalWorkspaceProviderService
)
const activeWorkspaceInfo = computed(() => {
const activeWorkspaceHandleRef =
workspaceService.activeWorkspaceHandle.value?.get()
if (activeWorkspaceHandleRef?.value.type === "ok") {
return {
provider: activeWorkspaceHandleRef.value.data.providerID,
workspaceID: activeWorkspaceHandleRef.value.data.workspaceID,
}
}
return undefined
})
function selectWorkspace() {
workspaceService.activeWorkspaceHandle.value =
personalWorkspaceProviderService.getPersonalWorkspaceHandle()
}
</script>

View File

@@ -1,198 +1,36 @@
<template>
<div ref="rootEl">
<div class="flex flex-col">
<div class="flex flex-col">
<HoppSmartItem
:label="t('workspace.personal')"
:icon="IconUser"
:info-icon="workspace.type === 'personal' ? IconDone : undefined"
:active-info-icon="workspace.type === 'personal'"
@click="switchToPersonalWorkspace"
/>
<div
v-for="(selectorComponent, index) in workspaceSelectorComponents"
:key="index"
class="flex flex-col"
>
<component :is="selectorComponent" />
<hr />
</div>
<div v-if="loading" class="flex flex-col items-center justify-center p-4">
<HoppSmartSpinner class="mb-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div>
<HoppSmartPlaceholder
v-if="!loading && myTeams.length === 0"
:src="`/images/states/${colorMode.value}/add_group.svg`"
:alt="`${t('empty.teams')}`"
:text="`${t('empty.teams')}`"
>
<template #body>
<HoppButtonSecondary
:label="t('team.create_new')"
filled
outline
:icon="IconPlus"
@click="displayModalAdd(true)"
/>
</template>
</HoppSmartPlaceholder>
<div v-else-if="!loading" class="flex flex-col">
<div
class="sticky top-0 z-10 mb-2 flex items-center justify-between bg-popover py-2 pl-2"
>
<div class="flex items-center px-2 font-semibold text-secondaryLight">
{{ t("workspace.other_workspaces") }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="`${t('team.create_new')}`"
outline
filled
class="ml-8 rounded !p-0.75"
@click="displayModalAdd(true)"
/>
</div>
<HoppSmartItem
v-for="(team, index) in myTeams"
:key="`team-${String(index)}`"
:icon="IconUsers"
:label="team.name"
:info-icon="isActiveWorkspace(team.id) ? IconDone : undefined"
:active-info-icon="isActiveWorkspace(team.id)"
@click="switchToTeamWorkspace(team)"
/>
</div>
<div
v-else-if="teamListAdapterError"
class="flex flex-col items-center py-4"
>
<icon-lucide-help-circle class="svg-icons mb-4" />
{{ t("error.something_went_wrong") }}
</div>
</div>
<TeamsAdd
:show="showModalAdd"
:switch-workspace-after-creation="true"
@hide-modal="displayModalAdd(false)"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { useReadonlyStream } from "~/composables/stream"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import IconUser from "~icons/lucide/user"
import IconUsers from "~icons/lucide/users"
import IconPlus from "~icons/lucide/plus"
import { useColorMode } from "@composables/theming"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import IconDone from "~icons/lucide/check"
import { useLocalState } from "~/newstore/localstate"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { WorkspaceService } from "~/services/workspace.service"
import { useService } from "dioc/vue"
import { useElementVisibility, useIntervalFn } from "@vueuse/core"
import { NewWorkspaceService } from "~/services/new-workspace"
import { TestWorkspaceProviderService } from "~/services/new-workspace/providers/test.workspace"
const t = useI18n()
const colorMode = useColorMode()
useService(TestWorkspaceProviderService)
const showModalAdd = ref(false)
const newWorkspaceService = useService(NewWorkspaceService)
const workspaceSelectorComponents =
newWorkspaceService.workspaceSelectorComponents
const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
)
const workspaceService = useService(WorkspaceService)
const teamListadapter = workspaceService.acquireTeamListAdapter(null)
const myTeams = useReadonlyStream(teamListadapter.teamList$, [])
const isTeamListLoading = useReadonlyStream(teamListadapter.loading$, false)
const teamListAdapterError = useReadonlyStream(teamListadapter.error$, null)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
const teamListFetched = ref(false)
const rootEl = ref<HTMLElement>()
const elVisible = useElementVisibility(rootEl)
const { pause: pauseListPoll, resume: resumeListPoll } = useIntervalFn(() => {
if (teamListadapter.isInitialized) {
teamListadapter.fetchList()
}
}, 10000)
watch(
elVisible,
() => {
if (elVisible.value) {
teamListadapter.fetchList()
resumeListPoll()
} else {
pauseListPoll()
}
},
{ immediate: true }
)
watch(myTeams, (teams) => {
if (teams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value && currentUser.value) {
const team = teams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) switchToTeamWorkspace(team)
}
}
})
const loading = computed(
() => isTeamListLoading.value && myTeams.value.length === 0
)
const workspace = workspaceService.currentWorkspace
const isActiveWorkspace = computed(() => (id: string) => {
if (workspace.value.type === "personal") return false
return workspace.value.teamID === id
})
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
REMEMBERED_TEAM_ID.value = team.id
workspaceService.changeWorkspace({
teamID: team.id,
teamName: team.name,
type: "team",
role: team.myRole,
})
}
const switchToPersonalWorkspace = () => {
REMEMBERED_TEAM_ID.value = undefined
workspaceService.changeWorkspace({
type: "personal",
})
}
watch(
() => currentUser.value,
(user) => {
if (!user) {
switchToPersonalWorkspace()
teamListadapter.dispose()
}
}
)
const displayModalAdd = (shouldDisplay: boolean) => {
if (!currentUser.value) return invokeAction("modals.login.toggle")
showModalAdd.value = shouldDisplay
teamListadapter.fetchList()
}
defineActionHandler("modals.team.new", () => {
displayModalAdd(true)
})
defineActionHandler("workspace.switch.personal", switchToPersonalWorkspace)
defineActionHandler("workspace.switch", ({ teamId }) => {
const team = myTeams.value.find((t) => t.id === teamId)
if (team) switchToTeamWorkspace(team)
})
// TODO: Handle the updates to these actions
// defineActionHandler("modals.team.new", () => {
// displayModalAdd(true)
// })
//
// defineActionHandler("workspace.switch.personal", switchToPersonalWorkspace)
// defineActionHandler("workspace.switch", ({ teamId }) => {
// const team = myTeams.value.find((t) => t.id === teamId)
// if (team) switchToTeamWorkspace(team)
// })
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div>
<div class="flex flex-col">
<HoppSmartItem
v-for="candidate in candidates"
:key="candidate.id"
:label="candidate.name"
:info-icon="
activeWorkspaceInfo?.provider ===
testWorkspaceProviderService.providerID &&
activeWorkspaceInfo.workspaceID === candidate.id
? IconCheck
: undefined
"
:active-info-icon="
activeWorkspaceInfo?.provider ===
testWorkspaceProviderService.providerID &&
activeWorkspaceInfo.workspaceID === candidate.id
"
@click="selectWorkspace(candidate.id)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useService } from "dioc/vue"
import { computed } from "vue"
import { NewWorkspaceService } from "~/services/new-workspace"
import { TestWorkspaceProviderService } from "~/services/new-workspace/providers/test.workspace"
import IconCheck from "~icons/lucide/check"
import * as E from "fp-ts/Either"
const workspaceService = useService(NewWorkspaceService)
const testWorkspaceProviderService = useService(TestWorkspaceProviderService)
const candidates = testWorkspaceProviderService.getWorkspaceCandidates()
const activeWorkspaceInfo = computed(() => {
const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle.value
const activeWorkspaceHandleRef = activeWorkspaceHandle?.get()
if (activeWorkspaceHandleRef?.value.type === "ok") {
return {
provider: activeWorkspaceHandleRef.value.data.providerID,
workspaceID: activeWorkspaceHandleRef.value.data.workspaceID,
}
}
return undefined
})
async function selectWorkspace(workspaceID: string) {
const result =
await testWorkspaceProviderService.getWorkspaceHandle(workspaceID)
// TODO: Re-evaluate this ?
if (E.isLeft(result)) {
console.error(result)
return
}
workspaceService.activeWorkspaceHandle.value = result.right
}
</script>

View File

@@ -0,0 +1,92 @@
import { HoppCollection } from "@hoppscotch/data"
import { ChildrenResult, SmartTreeAdapter } from "@hoppscotch/ui"
import { Ref, computed, ref } from "vue"
import { navigateToFolderWithIndexPath } from "~/newstore/collections"
import { RESTCollectionViewItem } from "~/services/new-workspace/view"
export class WorkspaceRESTSearchCollectionTreeAdapter
implements SmartTreeAdapter<RESTCollectionViewItem>
{
constructor(public data: Ref<HoppCollection[]>) {}
getChildren(
nodeID: string | null
): Ref<ChildrenResult<RESTCollectionViewItem>> {
const result = ref<ChildrenResult<RESTCollectionViewItem>>({
status: "loading",
})
return computed(() => {
if (nodeID === null) {
result.value = {
status: "loaded",
data: this.data.value.map((item, index) => ({
id: index.toString(),
data: <RESTCollectionViewItem>{
type: "collection",
value: {
collectionID: index.toString(),
isLastItem: index === this.data.value.length - 1,
name: item.name,
parentCollectionID: null,
},
},
})),
}
} else {
const indexPath = nodeID.split("/").map((x) => parseInt(x))
const item = navigateToFolderWithIndexPath(this.data.value, indexPath)
if (item) {
const collections = item.folders.map(
(childCollection, childCollectionID) => {
return {
id: `${nodeID}/${childCollectionID}`,
data: <RESTCollectionViewItem>{
type: "collection",
value: {
isLastItem:
item.folders?.length > 1
? childCollectionID === item.folders.length - 1
: false,
collectionID: `${nodeID}/${childCollectionID}`,
name: childCollection.name,
parentCollectionID: nodeID,
},
},
}
}
)
const requests = item.requests.map((request, requestID) => {
return {
id: `${nodeID}/${requestID}`,
data: <RESTCollectionViewItem>{
type: "request",
value: {
isLastItem:
item.requests?.length > 1
? requestID === item.requests.length - 1
: false,
parentCollectionID: nodeID,
collectionID: nodeID,
requestID: `${nodeID}/${requestID}`,
request,
},
},
}
})
result.value = {
status: "loaded",
data: [...collections, ...requests],
}
}
}
return result.value
})
}
}

View File

@@ -0,0 +1,134 @@
import {
ChildrenResult,
SmartTreeAdapter,
} from "@hoppscotch/ui/dist/src/helpers/treeAdapter"
import * as E from "fp-ts/Either"
import { Ref, ref, watchEffect } from "vue"
import { NewWorkspaceService } from "~/services/new-workspace"
import { Handle } from "~/services/new-workspace/handle"
import { RESTCollectionViewItem } from "~/services/new-workspace/view"
import { Workspace } from "~/services/new-workspace/workspace"
export class WorkspaceRESTCollectionTreeAdapter
implements SmartTreeAdapter<RESTCollectionViewItem>
{
constructor(
private workspaceHandle: Handle<Workspace>,
private workspaceService: NewWorkspaceService
) {}
public getChildren(
nodeID: string | null,
nodeType?: string
): Ref<ChildrenResult<RESTCollectionViewItem>> {
const workspaceHandleRef = this.workspaceHandle.get()
if (workspaceHandleRef.value.type !== "ok") {
throw new Error("Cannot issue children with invalid workspace handle")
}
const result = ref<ChildrenResult<RESTCollectionViewItem>>({
status: "loading",
})
if (nodeID !== null) {
;(async () => {
if (nodeType === "request") {
result.value = {
status: "loaded",
data: [],
}
return
}
const collectionHandleResult =
await this.workspaceService.getCollectionHandle(
this.workspaceHandle,
nodeID
)
// TODO: Better error handling
if (E.isLeft(collectionHandleResult)) {
throw new Error(JSON.stringify(collectionHandleResult.left.error))
}
const collectionHandle = collectionHandleResult.right
const collectionChildrenResult =
await this.workspaceService.getRESTCollectionChildrenView(
collectionHandle
)
// TODO: Better error handling
if (E.isLeft(collectionChildrenResult)) {
throw new Error(JSON.stringify(collectionChildrenResult.left.error))
}
const collectionChildrenViewHandle =
collectionChildrenResult.right.get()
watchEffect(() => {
if (collectionChildrenViewHandle.value.type !== "ok") return
if (collectionChildrenViewHandle.value.data.loading.value) {
result.value = {
status: "loading",
}
} else {
result.value = {
status: "loaded",
data: collectionChildrenViewHandle.value.data.content.value.map(
(item) => ({
id:
item.type === "request"
? item.value.requestID
: item.value.collectionID,
data: item,
})
),
}
}
})
})()
} else {
;(async () => {
const viewResult =
await this.workspaceService.getRESTRootCollectionView(
this.workspaceHandle
)
// TODO: Better error handling
if (E.isLeft(viewResult)) {
throw new Error(JSON.stringify(viewResult.left.error))
}
const viewHandle = viewResult.right.get()
watchEffect(() => {
if (viewHandle.value.type !== "ok") return
if (viewHandle.value.data.loading.value) {
result.value = {
status: "loading",
}
} else {
result.value = {
status: "loaded",
data: viewHandle.value.data.collections.value.map((coll) => ({
id: coll.collectionID,
data: {
type: "collection",
value: coll,
},
})),
}
}
})
})()
}
return result
}
}

View File

@@ -1,12 +1,15 @@
import { HoppCollection } from "@hoppscotch/data"
import { getAffectedIndexes } from "./affectedIndex"
import { GetSingleRequestDocument } from "../backend/graphql"
import { runGQLQuery } from "../backend/GQLClient"
import * as E from "fp-ts/Either"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
import { GQLTabService } from "~/services/tab/graphql"
import { RESTTabService } from "~/services/tab/rest"
import { runGQLQuery } from "../backend/GQLClient"
import { GetSingleRequestDocument } from "../backend/graphql"
import { HoppGQLSaveContext } from "../graphql/document"
import { HoppRESTSaveContext } from "../rest/document"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
import { getAffectedIndexes } from "./affectedIndex"
/**
* Resolve save context on reorder
@@ -18,15 +21,12 @@ import { GQLTabService } from "~/services/tab/graphql"
* @returns
*/
export function resolveSaveContextOnCollectionReorder(
payload: {
lastIndex: number
newIndex: number
folderPath: string
length?: number // better way to do this? now it could be undefined
},
type: "remove" | "drop" = "remove"
) {
export function resolveSaveContextOnCollectionReorder(payload: {
lastIndex: number
newIndex: number
folderPath: string
length?: number // better way to do this? now it could be undefined
}) {
const { lastIndex, folderPath, length } = payload
let { newIndex } = payload
@@ -41,12 +41,6 @@ export function resolveSaveContextOnCollectionReorder(
if (newIndex === -1) {
// if (newIndex === -1) remove it from the map because it will be deleted
affectedIndexes.delete(lastIndex)
// when collection deleted opended requests from that collection be affected
if (type === "remove") {
resetSaveContextForAffectedRequests(
folderPath ? `${folderPath}/${lastIndex}` : lastIndex.toString()
)
}
}
// add folder path as prefix to the affected indexes
@@ -62,10 +56,27 @@ export function resolveSaveContextOnCollectionReorder(
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
affectedPaths.has(tab.document.saveContext.folderPath)
)
if (tab.document.saveContext?.originLocation === "user-collection") {
return affectedPaths.has(tab.document.saveContext.folderPath)
}
if (
tab.document.saveContext?.originLocation !== "workspace-user-collection"
) {
return false
}
const requestHandleRef = tab.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return false
}
const { requestID } = requestHandleRef.value.data
const collectionID = requestID.split("/").slice(0, -1).join("/")
return affectedPaths.has(collectionID)
})
for (const tab of tabs) {
@@ -75,6 +86,34 @@ export function resolveSaveContextOnCollectionReorder(
)!
tab.value.document.saveContext.folderPath = newPath
}
if (
tab.value.document.saveContext?.originLocation !==
"workspace-user-collection"
) {
return false
}
const requestHandleRef = tab.value.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return false
}
const { requestID } = requestHandleRef.value.data
const collectionID = requestID.split("/").slice(0, -1).join("/")
const newCollectionID = affectedPaths.get(collectionID)
const newRequestID = `${newCollectionID}/${
requestID.split("/").slice(-1)[0]
}`
requestHandleRef.value.data = {
...requestHandleRef.value.data,
collectionID: newCollectionID!,
requestID: newRequestID,
}
}
}
@@ -86,25 +125,63 @@ export function resolveSaveContextOnCollectionReorder(
*/
export function updateSaveContextForAffectedRequests(
oldFolderPath: string,
newFolderPath: string
draggedCollectionIndex: string,
destinationCollectionIndex: string
) {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath.startsWith(oldFolderPath)
)
})
for (const tab of tabs) {
if (tab.value.document.saveContext?.originLocation === "user-collection") {
tab.value.document.saveContext = {
...tab.value.document.saveContext,
folderPath: tab.value.document.saveContext.folderPath.replace(
oldFolderPath,
newFolderPath
),
const activeTabs = tabService.getActiveTabs()
for (const tab of activeTabs.value) {
if (tab.document.saveContext?.originLocation === "user-collection") {
const { folderPath } = tab.document.saveContext
if (folderPath.startsWith(draggedCollectionIndex)) {
const newFolderPath = folderPath.replace(
draggedCollectionIndex,
destinationCollectionIndex
)
tab.document.saveContext = {
...tab.document.saveContext,
folderPath: newFolderPath,
}
}
return
}
if (
tab.document.saveContext?.originLocation === "workspace-user-collection"
) {
const requestHandleRef = tab.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return false
}
const { requestID } = requestHandleRef.value.data
const collectionID = requestID.split("/").slice(0, -1).join("/")
const requestIndex = requestID.split("/").slice(-1)[0]
if (collectionID.startsWith(draggedCollectionIndex)) {
const newCollectionID = collectionID.replace(
draggedCollectionIndex,
destinationCollectionIndex
)
const newRequestID = `${newCollectionID}/${requestIndex}`
tab.document.saveContext = {
...tab.document.saveContext,
requestID: newRequestID,
}
requestHandleRef.value.data = {
...requestHandleRef.value.data,
collectionID: newCollectionID,
requestID: newRequestID,
}
}
}
}
@@ -166,6 +243,33 @@ function removeDuplicatesAndKeepLast(arr: HoppInheritedProperty["headers"]) {
return result
}
function getSaveContextCollectionID(
saveContext: HoppRESTSaveContext | HoppGQLSaveContext | undefined
): string | undefined {
if (!saveContext) {
return
}
const { originLocation } = saveContext
if (originLocation === "team-collection") {
return saveContext.collectionID
}
if (originLocation === "user-collection") {
return saveContext.folderPath
}
const requestHandleRef = saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return
}
// TODO: Remove `collectionID` and obtain it from `requestID`
return requestHandleRef.value.data.collectionID
}
export function updateInheritedPropertiesForAffectedRequests(
path: string,
inheritedProperties: HoppInheritedProperty,
@@ -177,22 +281,17 @@ export function updateInheritedPropertiesForAffectedRequests(
const effectedTabs = tabService.getTabsRefTo((tab) => {
const saveContext = tab.document.saveContext
const saveContextPath =
saveContext?.originLocation === "team-collection"
? saveContext.collectionID
: saveContext?.folderPath
return saveContextPath?.startsWith(path) ?? false
const collectionID = getSaveContextCollectionID(saveContext)
return collectionID?.startsWith(path) ?? false
})
effectedTabs.map((tab) => {
const inheritedParentID =
tab.value.document.inheritedProperties?.auth.parentID
const contextPath =
tab.value.document.saveContext?.originLocation === "team-collection"
? tab.value.document.saveContext.collectionID
: tab.value.document.saveContext?.folderPath
const contextPath = getSaveContextCollectionID(
tab.value.document.saveContext
)
const effectedPath = folderPathCloseToSaveContext(
inheritedParentID,
@@ -227,21 +326,6 @@ export function updateInheritedPropertiesForAffectedRequests(
})
}
function resetSaveContextForAffectedRequests(folderPath: string) {
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath.startsWith(folderPath)
)
})
for (const tab of tabs) {
tab.value.document.saveContext = null
tab.value.document.isDirty = true
}
}
/**
* Reset save context to null if requests are deleted from the team collection or its folder
* only runs when collection or folder is deleted

View File

@@ -3,9 +3,10 @@ import {
HoppGQLRequest,
HoppRESTRequest,
} from "@hoppscotch/data"
import { getAffectedIndexes } from "./affectedIndex"
import { RESTTabService } from "~/services/tab/rest"
import { getService } from "~/modules/dioc"
import { RESTTabService } from "~/services/tab/rest"
import { getAffectedIndexes } from "./affectedIndex"
/**
* Resolve save context on reorder
@@ -29,30 +30,76 @@ export function resolveSaveContextOnRequestReorder(payload: {
if (newIndex > lastIndex) newIndex-- // there is a issue when going down? better way to resolve this?
if (lastIndex === newIndex) return
const affectedIndexes = getAffectedIndexes(
const affectedIndices = getAffectedIndexes(
lastIndex,
newIndex === -1 ? length! : newIndex
)
// if (newIndex === -1) remove it from the map because it will be deleted
if (newIndex === -1) affectedIndexes.delete(lastIndex)
if (newIndex === -1) affectedIndices.delete(lastIndex)
const tabService = getService(RESTTabService)
const tabs = tabService.getTabsRefTo((tab) => {
return (
tab.document.saveContext?.originLocation === "user-collection" &&
tab.document.saveContext.folderPath === folderPath &&
affectedIndexes.has(tab.document.saveContext.requestIndex)
)
if (tab.document.saveContext?.originLocation === "user-collection") {
return (
tab.document.saveContext.folderPath === folderPath &&
affectedIndices.has(tab.document.saveContext.requestIndex)
)
}
if (
tab.document.saveContext?.originLocation !== "workspace-user-collection"
) {
return false
}
const requestHandleRef = tab.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return false
}
const { requestID } = requestHandleRef.value.data
const collectionID = requestID.split("/").slice(0, -1).join("/")
const requestIndex = parseInt(requestID.split("/").slice(-1)[0])
return collectionID === folderPath && affectedIndices.has(requestIndex)
})
for (const tab of tabs) {
if (tab.value.document.saveContext?.originLocation === "user-collection") {
const newIndex = affectedIndexes.get(
const newIndex = affectedIndices.get(
tab.value.document.saveContext?.requestIndex
)!
tab.value.document.saveContext.requestIndex = newIndex
}
if (
tab.value.document.saveContext?.originLocation !==
"workspace-user-collection"
) {
return
}
const requestHandleRef = tab.value.document.saveContext.requestHandle?.get()
if (!requestHandleRef || requestHandleRef.value.type === "invalid") {
return
}
const { requestID } = requestHandleRef.value.data
const requestIDArr = requestID.split("/")
const requestIndex = affectedIndices.get(
parseInt(requestIDArr[requestIDArr.length - 1])
)!
requestIDArr[requestIDArr.length - 1] = requestIndex.toString()
requestHandleRef.value.data.requestID = requestIDArr.join("/")
requestHandleRef.value.data.collectionID = requestIDArr
.slice(0, -1)
.join("/")
}
}
@@ -67,7 +114,7 @@ export function getRequestsByPath(
if (pathArray.length === 1) {
const latestVersionedRequests = currentCollection.requests.filter(
(req): req is HoppRESTRequest => req.v === "3"
(req): req is HoppRESTRequest => req.v === "4"
)
return latestVersionedRequests
@@ -78,7 +125,7 @@ export function getRequestsByPath(
}
const latestVersionedRequests = currentCollection.requests.filter(
(req): req is HoppRESTRequest => req.v === "3"
(req): req is HoppRESTRequest => req.v === "4"
)
return latestVersionedRequests

View File

@@ -1,32 +1,31 @@
import * as E from "fp-ts/Either"
import { platform } from "~/platform"
/**
* Create a downloadable file from a collection and prompts the user to download it.
* @param collectionJSON - JSON string of the collection
* @param name - Name of the collection set as the file name
*/
export const initializeDownloadCollection = (
export const initializeDownloadFile = async (
collectionJSON: string,
name: string | null
) => {
const file = new Blob([collectionJSON], { type: "application/json" })
const a = document.createElement("a")
const url = URL.createObjectURL(file)
a.href = url
const result = await platform.io.saveFileWithDialog({
data: collectionJSON,
contentType: "application/json",
suggestedFilename: `${name ?? "collection"}.json`,
filters: [
{
name: "Hoppscotch Collection/Environment JSON file",
extensions: ["json"],
},
],
})
if (name) {
a.download = `${name}.json`
} else {
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
if (result.type === "unknown" || result.type === "saved") {
return E.right("state.download_started")
}
document.body.appendChild(a)
a.click()
setTimeout(() => {
document.body.removeChild(a)
URL.revokeObjectURL(url)
}, 1000)
return E.right("state.download_started")
return E.left("state.download_failed")
}

View File

@@ -1,5 +0,0 @@
import { HoppCollection } from "@hoppscotch/data"
export const myCollectionsExporter = (myCollections: HoppCollection[]) => {
return JSON.stringify(myCollections, null, 2)
}

View File

@@ -3,8 +3,37 @@ import { HoppRESTResponse } from "../types/HoppRESTResponse"
import { HoppTestResult } from "../types/HoppTestResult"
import { RESTOptionTabs } from "~/components/http/RequestOptions.vue"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
import { Handle } from "~/services/new-workspace/handle"
import { WorkspaceRequest } from "~/services/new-workspace/workspace"
export type HoppRESTSaveContext =
| {
/**
* The origin source of the request
*/
// TODO: Make this `user-collection` after porting all usages
// Future TODO: Keep separate types for the IDs (specific to persistence) & `requestHandle` (only existing at runtime)
originLocation: "workspace-user-collection"
/**
* ID of the workspace
*/
workspaceID?: string
/**
* ID of the provider
*/
providerID?: string
/**
* Path to the request in the collection tree
*/
requestID?: string
/**
* Handle to the request open in the tab
*/
requestHandle?: Handle<WorkspaceRequest>
}
| {
/**
* The origin source of the request

View File

@@ -17,3 +17,24 @@ export type HoppInheritedProperty = {
inheritedHeader: HoppRESTHeader | GQLHeader
}[]
}
type ModifiedAuth<T, AuthType> = {
[K in keyof T]: K extends "inheritedAuth" ? Extract<T[K], AuthType> : T[K]
}
type ModifiedHeaders<T, HeaderType> = {
[K in keyof T]: K extends "inheritedHeader" ? Extract<T[K], HeaderType> : T[K]
}
type ModifiedHoppInheritedProperty<AuthType, HeaderType> = {
auth: ModifiedAuth<HoppInheritedProperty["auth"], AuthType>
headers: ModifiedHeaders<
HoppInheritedProperty["headers"][number],
HeaderType
>[]
}
export type HoppInheritedRESTProperty = ModifiedHoppInheritedProperty<
HoppRESTAuth,
HoppRESTHeader
>

View File

@@ -20,28 +20,29 @@ export type Picked =
pickedType: "my-collection"
collectionIndex: number
}
| {
pickedType: "teams-request"
requestID: string
}
| {
pickedType: "teams-folder"
folderID: string
}
| {
pickedType: "teams-collection"
collectionID: string
}
| {
pickedType: "gql-my-request"
folderPath: string
requestIndex: number
}
| {
pickedType: "gql-my-folder"
folderPath: string
}
| {
pickedType: "gql-my-collection"
collectionIndex: number
}
// TODO: Enable this when rest of the implementation is in place
// | {
// pickedType: "teams-request"
// requestID: string
// }
// | {
// pickedType: "teams-folder"
// folderID: string
// }
// | {
// pickedType: "teams-collection"
// collectionID: string
// }
// | {
// pickedType: "gql-my-request"
// folderPath: string
// requestIndex: number
// }
// | {
// pickedType: "gql-my-folder"
// folderPath: string
// }
// | {
// pickedType: "gql-my-collection"
// collectionIndex: number
// }

View File

@@ -0,0 +1,17 @@
/**
* Create a function that will only run the given function once and caches the result.
*/
export function lazy<T>(fn: () => T): () => T {
let funcRan = false
let result: T | null = null
return () => {
if (!funcRan) {
result = fn()
funcRan = true
return result
}
return result!
}
}

View File

@@ -3,36 +3,40 @@ import { createApp } from "vue"
import { initializeApp } from "./helpers/app"
import { initBackendGQLClient } from "./helpers/backend/GQLClient"
import { performMigrations } from "./helpers/migrations"
import { getService } from "./modules/dioc"
import { PlatformDef, setPlatformDef } from "./platform"
import { PersonalWorkspaceProviderService } from "./services/new-workspace/providers/personal.workspace"
import { TestWorkspaceProviderService } from "./services/new-workspace/providers/test.workspace"
import { PersistenceService } from "./services/persistence"
import "nprogress/nprogress.css"
import "../assets/scss/styles.scss"
import "../assets/scss/tailwind.scss"
import "../assets/themes/themes.scss"
import "../assets/scss/styles.scss"
import "nprogress/nprogress.css"
import "unfonts.css"
import App from "./App.vue"
import { getService } from "./modules/dioc"
import { PersistenceService } from "./services/persistence"
export function createHoppApp(el: string | Element, platformDef: PlatformDef) {
setPlatformDef(platformDef)
const app = createApp(App)
// Some basic work that needs to be done before module inits even
initBackendGQLClient()
initializeApp()
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))
platformDef.addedHoppModules?.forEach((mod) => mod.onVueAppInit?.(app))
// Some basic work that needs to be done before module inits even
initBackendGQLClient()
initializeApp()
// TODO: Explore possibilities of moving this invocation to the service constructor
// `toast` was coming up as `null` in the previous attempts
getService(PersistenceService).setupLocalPersistence()
performMigrations()
// TODO: Remove this
getService(TestWorkspaceProviderService)
getService(PersonalWorkspaceProviderService)
app.mount(el)
console.info(

View File

@@ -319,6 +319,7 @@ const restCollectionDispatchers = defineDispatchers({
)
return {}
}
// We get the index path to the folder itself,
// we have to find the folder containing the target folder,
// so we pop the last path index
@@ -690,17 +691,11 @@ const restCollectionDispatchers = defineDispatchers({
// if the destination is null, we are moving to the end of the list
if (destinationRequestIndex === null) {
// move to the end of the list
// TODO: Verify if this can be safely removed
targetLocation.requests.push(
targetLocation.requests.splice(requestIndex, 1)[0]
)
resolveSaveContextOnRequestReorder({
lastIndex: requestIndex,
newIndex: targetLocation.requests.length,
folderPath: destinationCollectionPath,
})
return {
state: newState,
}
@@ -708,12 +703,6 @@ const restCollectionDispatchers = defineDispatchers({
reorderItems(targetLocation.requests, requestIndex, destinationRequestIndex)
resolveSaveContextOnRequestReorder({
lastIndex: requestIndex,
newIndex: destinationRequestIndex,
folderPath: destinationCollectionPath,
})
return {
state: newState,
}

View File

@@ -31,7 +31,7 @@
</template>
<template #suffix>
<span
v-if="tab.document.isDirty"
v-if="getTabDirtyStatus(tab)"
class="flex w-4 items-center justify-center text-secondary group-hover:hidden"
>
<svg
@@ -64,6 +64,13 @@
@submit="renameReqName"
@hide-modal="showRenamingReqNameModal = false"
/>
<HoppSmartConfirmModal
:show="confirmingCloseForTabID !== null"
:confirm="t('modal.close_unsaved_tab')"
:title="t('confirm.save_unsaved_tab')"
@hide-modal="onCloseConfirmSaveTab"
@resolve="onResolveConfirmSaveTab"
/>
<HoppSmartConfirmModal
:show="confirmingCloseAllTabs"
:confirm="t('modal.close_unsaved_tab')"
@@ -71,36 +78,6 @@
@hide-modal="confirmingCloseAllTabs = false"
@resolve="onResolveConfirmCloseAllTabs"
/>
<HoppSmartModal
v-if="confirmingCloseForTabID !== null"
dialog
role="dialog"
aria-modal="true"
:title="t('modal.close_unsaved_tab')"
@close="confirmingCloseForTabID = null"
>
<template #body>
<div class="text-center">
{{ t("confirm.save_unsaved_tab") }}
</div>
</template>
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
v-focus
:label="t?.('action.yes')"
outline
@click="onResolveConfirmSaveTab"
/>
<HoppButtonSecondary
:label="t?.('action.no')"
filled
outline
@click="onCloseConfirmSaveTab"
/>
</span>
</template>
</HoppSmartModal>
<CollectionsSaveRequest
v-if="savingRequest"
mode="rest"
@@ -118,24 +95,24 @@
</template>
<script lang="ts" setup>
import { ref, onMounted } from "vue"
import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { useRoute } from "vue-router"
import { useI18n } from "@composables/i18n"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { platform } from "~/platform"
import { useReadonlyStream } from "~/composables/stream"
import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
import { cloneDeep } from "lodash-es"
import { RESTTabService } from "~/services/tab/rest"
import { HoppTab } from "~/services/tab"
import { onMounted, ref } from "vue"
import { useRoute } from "vue-router"
import { useReadonlyStream } from "~/composables/stream"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTDocument } from "~/helpers/rest/document"
import { platform } from "~/platform"
import { InspectionService } from "~/services/inspection"
import { EnvironmentInspectorService } from "~/services/inspection/inspectors/environment.inspector"
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
import { HoppTab } from "~/services/tab"
import { RESTTabService } from "~/services/tab/rest"
const savingRequest = ref(false)
const confirmingCloseForTabID = ref<string | null>(null)
@@ -216,7 +193,7 @@ const inspectionService = useService(InspectionService)
const removeTab = (tabID: string) => {
const tabState = tabs.getTabRef(tabID).value
if (tabState.document.isDirty) {
if (getTabDirtyStatus(tabState)) {
confirmingCloseForTabID.value = tabID
} else {
tabs.closeTab(tabState.id)
@@ -225,8 +202,10 @@ const removeTab = (tabID: string) => {
}
const closeOtherTabsAction = (tabID: string) => {
const isTabDirty = tabs.getTabRef(tabID).value?.document.isDirty
const dirtyTabCount = tabs.getDirtyTabsCount()
const isTabDirty = getTabDirtyStatus(tabs.getTabRef(tabID).value)
// If current tab is dirty, so we need to subtract 1 from the dirty tab count
const balanceDirtyTabCount = isTabDirty ? dirtyTabCount - 1 : dirtyTabCount
@@ -291,15 +270,24 @@ const onCloseConfirmSaveTab = () => {
* Called when the user confirms they want to save the tab
*/
const onResolveConfirmSaveTab = () => {
if (tabs.currentActiveTab.value.document.saveContext) {
invokeAction("request.save")
const { saveContext } = tabs.currentActiveTab.value.document
if (confirmingCloseForTabID.value) {
tabs.closeTab(confirmingCloseForTabID.value)
confirmingCloseForTabID.value = null
}
} else {
savingRequest.value = true
// There're two cases where the save request under a collection modal should open
// 1. Attempting to save a request that is not under a collection (When the save context is not available)
// 2. Deleting a request from the collection tree and attempting to save it while closing the respective tab (When the request handle is invalid)
if (
!saveContext ||
(saveContext.originLocation === "workspace-user-collection" &&
saveContext.requestHandle?.get().value.type === "invalid")
) {
return (savingRequest.value = true)
}
invokeAction("request.save")
if (confirmingCloseForTabID.value) {
tabs.closeTab(confirmingCloseForTabID.value)
confirmingCloseForTabID.value = null
}
}
@@ -327,6 +315,17 @@ const shareTabRequest = (tabID: string) => {
}
}
const getTabDirtyStatus = (tab: HoppTab<HoppRESTDocument>) => {
if (tab.document.isDirty) {
return true
}
return (
tab.document.saveContext?.originLocation === "workspace-user-collection" &&
tab.document.saveContext.requestHandle?.get().value.type === "invalid"
)
}
defineActionHandler("contextmenu.open", ({ position, text }) => {
if (text) {
contextMenu.value = {

View File

@@ -13,15 +13,17 @@ export const browserIODef: IOPlatformDef = {
const url = URL.createObjectURL(file)
a.href = url
a.download = pipe(
url,
S.split("/"),
RNEA.last,
S.split("#"),
RNEA.head,
S.split("?"),
RNEA.head
)
a.download =
opts.suggestedFilename ??
pipe(
url,
S.split("/"),
RNEA.last,
S.split("#"),
RNEA.head,
S.split("?"),
RNEA.head
)
document.body.appendChild(a)
a.click()

View File

@@ -121,6 +121,11 @@ export class InspectionService extends Service {
}
private initializeListeners() {
console.log(
`Current active tab from inspection service is `,
this.restTab.currentActiveTab.value
)
watch(
() => [this.inspectors.entries(), this.restTab.currentActiveTab.value.id],
() => {

View File

@@ -0,0 +1,18 @@
import { Ref, WritableComputedRef } from "vue"
export type Handle<T, InvalidateReason = unknown> = {
get: () => HandleRef<T, InvalidateReason>
}
export type HandleRef<T, InvalidateReason = unknown> = Ref<
HandleState<T, InvalidateReason>
>
export type HandleState<T, InvalidateReason> =
| { type: "ok"; data: T }
| { type: "invalid"; reason: InvalidateReason }
export type WritableHandleRef<
T,
InvalidateReason = unknown,
> = WritableComputedRef<HandleState<T, InvalidateReason>>

View File

@@ -0,0 +1,48 @@
import { Ref } from "vue"
import { HandleRef } from "./handle"
import { Workspace, WorkspaceCollection, WorkspaceRequest } from "./workspace"
export const isValidWorkspaceHandle = (
workspaceHandle: HandleRef<Workspace>,
providerID: string,
workspaceID: string
): workspaceHandle is Ref<{
data: Workspace
type: "ok"
}> => {
return (
workspaceHandle.value.type === "ok" &&
workspaceHandle.value.data.providerID === providerID &&
workspaceHandle.value.data.workspaceID === workspaceID
)
}
export const isValidCollectionHandle = (
collectionHandle: HandleRef<WorkspaceCollection>,
providerID: string,
workspaceID: string
): collectionHandle is Ref<{
data: WorkspaceCollection
type: "ok"
}> => {
return (
collectionHandle.value.type === "ok" &&
collectionHandle.value.data.providerID === providerID &&
collectionHandle.value.data.workspaceID === workspaceID
)
}
export const isValidRequestHandle = (
requestHandle: HandleRef<WorkspaceRequest>,
providerID: string,
workspaceID: string
): requestHandle is Ref<{
data: WorkspaceRequest
type: "ok"
}> => {
return (
requestHandle.value.type === "ok" &&
requestHandle.value.data.providerID === providerID &&
requestHandle.value.data.workspaceID === workspaceID
)
}

View File

@@ -0,0 +1,797 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { Service } from "dioc"
import * as E from "fp-ts/Either"
import {
Component,
Ref,
computed,
markRaw,
shallowReactive,
shallowRef,
watch,
} from "vue"
import { Handle } from "./handle"
import { WorkspaceProvider } from "./provider"
import {
RESTCollectionChildrenView,
RESTCollectionJSONView,
RESTCollectionLevelAuthHeadersView,
RESTSearchResultsView,
RootRESTCollectionView,
} from "./view"
import { Workspace, WorkspaceCollection, WorkspaceRequest } from "./workspace"
export type WorkspaceError<ServiceErr> =
| { type: "SERVICE_ERROR"; error: ServiceErr }
| { type: "PROVIDER_ERROR"; error: unknown }
export class NewWorkspaceService extends Service {
public static readonly ID = "NEW_WORKSPACE_SERVICE"
private registeredProviders = shallowReactive(
new Map<string, WorkspaceProvider>()
)
public activeWorkspaceHandle: Ref<Handle<Workspace> | undefined> =
shallowRef()
public activeWorkspaceDecor = computed(() => {
const activeWorkspaceHandleRef = this.activeWorkspaceHandle.value?.get()
if (activeWorkspaceHandleRef?.value.type !== "ok") {
return undefined
}
return this.registeredProviders.get(
activeWorkspaceHandleRef.value.data.providerID
)!.workspaceDecor
})
public workspaceSelectorComponents = computed(() => {
const items: Component[] = []
const sortedProviders = Array.from(this.registeredProviders.values()).sort(
(a, b) =>
(b.workspaceDecor?.value.workspaceSelectorPriority ?? 0) -
(a.workspaceDecor?.value.workspaceSelectorPriority ?? 0)
)
for (const workspace of sortedProviders) {
if (workspace.workspaceDecor?.value?.workspaceSelectorComponent) {
items.push(workspace.workspaceDecor.value.workspaceSelectorComponent)
}
}
return items
})
override onServiceInit() {
// Watch for situations where the handle is invalidated
// so the active workspace handle definition can be invalidated
watch(
() => {
return this.activeWorkspaceHandle.value
? [
this.activeWorkspaceHandle.value,
this.activeWorkspaceHandle.value?.get(),
]
: [this.activeWorkspaceHandle.value]
},
() => {
if (!this.activeWorkspaceHandle.value) return
const activeWorkspaceHandleRef = this.activeWorkspaceHandle.value.get()
if (activeWorkspaceHandleRef.value.type === "invalid") {
this.activeWorkspaceHandle.value = undefined
}
},
{ deep: true }
)
}
public async getWorkspaceHandle(
providerID: string,
workspaceID: string
): Promise<E.Either<WorkspaceError<"INVALID_PROVIDER">, Handle<Workspace>>> {
const provider = this.registeredProviders.get(providerID)
if (!provider) {
return Promise.resolve(
E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" as const })
)
}
const handleResult = await provider.getWorkspaceHandle(workspaceID)
if (E.isLeft(handleResult)) {
return E.left({ type: "PROVIDER_ERROR", error: handleResult.left })
}
return E.right(handleResult.right)
}
public async getCollectionHandle(
workspaceHandle: Handle<Workspace>,
collectionID: string
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceCollection>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.getCollectionHandle(
workspaceHandle,
collectionID
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRequestHandle(
workspaceHandle: Handle<Workspace>,
requestID: string
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceRequest>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.getRequestHandle(workspaceHandle, requestID)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async createRESTRootCollection(
workspaceHandle: Handle<Workspace>,
newCollection: Partial<Exclude<HoppCollection, "id">> & { name: string }
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceCollection>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.createRESTRootCollection(
workspaceHandle,
newCollection
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async createRESTChildCollection(
parentCollectionHandle: Handle<WorkspaceCollection>,
newChildCollection: Partial<HoppCollection> & { name: string }
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceCollection>
>
> {
const parentCollectionHandleRef = parentCollectionHandle.get()
if (parentCollectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
parentCollectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.createRESTChildCollection(
parentCollectionHandle,
newChildCollection
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async updateRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
updatedCollection: Partial<HoppCollection>
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.updateRESTCollection(
collectionHandle,
updatedCollection
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(undefined)
}
public async removeRESTCollection(
collectionHandle: Handle<WorkspaceCollection>
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.removeRESTCollection(collectionHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(undefined)
}
public async createRESTRequest(
parentCollectionHandle: Handle<WorkspaceCollection>,
newRequest: HoppRESTRequest
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceRequest>
>
> {
const parentCollectionHandleRef = parentCollectionHandle.get()
if (parentCollectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
parentCollectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.createRESTRequest(
parentCollectionHandle,
newRequest
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async removeRESTRequest(
requestHandle: Handle<WorkspaceRequest>
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
requestHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.removeRESTRequest(requestHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(undefined)
}
public async updateRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
updatedRequest: Partial<HoppRESTRequest>
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
requestHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.updateRESTRequest(
requestHandle,
updatedRequest
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async importRESTCollections(
workspaceHandle: Handle<Workspace>,
collections: HoppCollection[]
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<WorkspaceCollection>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.importRESTCollections(
workspaceHandle,
collections
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async exportRESTCollections(
workspaceHandle: Handle<Workspace>
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.exportRESTCollections(workspaceHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async exportRESTCollection(
collectionHandle: Handle<WorkspaceCollection>
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.exportRESTCollection(collectionHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async reorderRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
destinationCollectionID: string | null
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.reorderRESTCollection(
collectionHandle,
destinationCollectionID
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async moveRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
destinationCollectionID: string | null
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.moveRESTCollection(
collectionHandle,
destinationCollectionID
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async reorderRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
destinationRequestID: string | null
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
requestHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.reorderRESTRequest(
requestHandle,
destinationRequestID
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async moveRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
destinationCollectionID: string
): Promise<
E.Either<WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">, void>
> {
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
requestHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.moveRESTRequest(
requestHandle,
destinationCollectionID
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTCollectionChildrenView(
collectionHandle: Handle<WorkspaceCollection>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<RESTCollectionChildrenView>
>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result =
await provider.getRESTCollectionChildrenView(collectionHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTRootCollectionView(
workspaceHandle: Handle<Workspace>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<RootRESTCollectionView>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.getRESTRootCollectionView(workspaceHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTCollectionLevelAuthHeadersView(
collectionHandle: Handle<WorkspaceCollection>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<RESTCollectionLevelAuthHeadersView>
>
> {
const collectionHandleRef = collectionHandle.get()
if (collectionHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result =
await provider.getRESTCollectionLevelAuthHeadersView(collectionHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTSearchResultsView(
workspaceHandle: Handle<Workspace>,
searchQuery: Ref<string>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<RESTSearchResultsView>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.getRESTSearchResultsView(
workspaceHandle,
searchQuery
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTCollectionJSONView(
workspaceHandle: Handle<Workspace>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
Handle<RESTCollectionJSONView>
>
> {
const workspaceHandleRef = workspaceHandle.get()
if (workspaceHandleRef.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandleRef.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.getRESTCollectionJSONView(workspaceHandle)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public registerWorkspaceProvider(provider: WorkspaceProvider) {
if (this.registeredProviders.has(provider.providerID)) {
console.warn(
"Ignoring attempt to re-register workspace provider that is already existing:",
provider
)
return
}
this.registeredProviders.set(provider.providerID, markRaw(provider))
}
}

View File

@@ -0,0 +1,108 @@
import * as E from "fp-ts/Either"
import { Ref } from "vue"
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { Handle } from "./handle"
import {
RESTCollectionChildrenView,
RESTCollectionJSONView,
RESTCollectionLevelAuthHeadersView,
RESTSearchResultsView,
RootRESTCollectionView,
} from "./view"
import {
Workspace,
WorkspaceCollection,
WorkspaceDecor,
WorkspaceRequest,
} from "./workspace"
export interface WorkspaceProvider {
providerID: string
workspaceDecor?: Ref<WorkspaceDecor>
getWorkspaceHandle(
workspaceID: string
): Promise<E.Either<unknown, Handle<Workspace>>>
getCollectionHandle(
workspaceHandle: Handle<Workspace>,
collectionID: string
): Promise<E.Either<unknown, Handle<WorkspaceCollection>>>
getRequestHandle(
workspaceHandle: Handle<Workspace>,
requestID: string
): Promise<E.Either<unknown, Handle<WorkspaceRequest>>>
getRESTRootCollectionView(
workspaceHandle: Handle<Workspace>
): Promise<E.Either<never, Handle<RootRESTCollectionView>>>
getRESTCollectionChildrenView(
collectionHandle: Handle<WorkspaceCollection>
): Promise<E.Either<never, Handle<RESTCollectionChildrenView>>>
getRESTCollectionLevelAuthHeadersView(
collectionHandle: Handle<WorkspaceCollection>
): Promise<E.Either<never, Handle<RESTCollectionLevelAuthHeadersView>>>
getRESTSearchResultsView(
workspaceHandle: Handle<Workspace>,
searchQuery: Ref<string>
): Promise<E.Either<never, Handle<RESTSearchResultsView>>>
getRESTCollectionJSONView(
workspaceHandle: Handle<Workspace>
): Promise<E.Either<never, Handle<RESTCollectionJSONView>>>
createRESTRootCollection(
workspaceHandle: Handle<Workspace>,
newCollection: Partial<Exclude<HoppCollection, "id">> & { name: string }
): Promise<E.Either<unknown, Handle<WorkspaceCollection>>>
createRESTChildCollection(
parentCollectionHandle: Handle<WorkspaceCollection>,
newChildCollection: Partial<HoppCollection> & { name: string }
): Promise<E.Either<unknown, Handle<WorkspaceCollection>>>
updateRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
updatedCollection: Partial<HoppCollection>
): Promise<E.Either<unknown, void>>
removeRESTCollection(
collectionHandle: Handle<WorkspaceCollection>
): Promise<E.Either<unknown, void>>
createRESTRequest(
parentCollectionHandle: Handle<WorkspaceCollection>,
newRequest: HoppRESTRequest
): Promise<E.Either<unknown, Handle<WorkspaceRequest>>>
updateRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
updatedRequest: Partial<HoppRESTRequest>
): Promise<E.Either<unknown, void>>
removeRESTRequest(
requestHandle: Handle<WorkspaceRequest>
): Promise<E.Either<unknown, void>>
importRESTCollections(
workspaceHandle: Handle<Workspace>,
collections: HoppCollection[]
): Promise<E.Either<unknown, Handle<WorkspaceCollection>>>
exportRESTCollections(
workspaceHandle: Handle<Workspace>
): Promise<E.Either<unknown, void>>
exportRESTCollection(
collectionHandle: Handle<WorkspaceCollection>
): Promise<E.Either<unknown, void>>
reorderRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
destinationCollectionID: string | null
): Promise<E.Either<unknown, void>>
moveRESTCollection(
collectionHandle: Handle<WorkspaceCollection>,
destinationCollectionID: string | null
): Promise<E.Either<unknown, void>>
reorderRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
destinationRequestID: string | null
): Promise<E.Either<unknown, void>>
moveRESTRequest(
requestHandle: Handle<WorkspaceRequest>,
destinationCollectionID: string
): Promise<E.Either<unknown, void>>
}

View File

@@ -0,0 +1,52 @@
import { getDefaultRESTRequest } from "@hoppscotch/data"
import { ref, computed } from "vue"
import { HandleState } from "../../handle"
import { WorkspaceRequest } from "../../workspace"
export const generateIssuedHandleValues = (
collectionsAndRequests: { collectionID: string; requestCount: number }[]
) => {
const providerID = "PERSONAL_WORKSPACE_PROVIDER"
const workspaceID = "personal"
const issuedHandleValues: HandleState<WorkspaceRequest, unknown>[] = []
collectionsAndRequests.forEach(({ collectionID, requestCount }) => {
for (let i = 0; i < requestCount; i++) {
const requestID = `${collectionID}/${i}`
issuedHandleValues.push({
type: "ok" as const,
data: {
providerID: providerID,
workspaceID: workspaceID,
collectionID,
requestID,
request: {
...getDefaultRESTRequest(),
name: `req-${requestID}`,
},
},
})
}
})
return issuedHandleValues
}
export const getWritableHandle = (
value: HandleState<WorkspaceRequest, unknown>
) => {
const handleRefData = ref(value)
const writableHandle = computed({
get() {
return handleRefData.value
},
set(newValue) {
handleRefData.value = newValue
},
})
return writableHandle
}

View File

@@ -0,0 +1,309 @@
import { computed, markRaw, reactive, ref } from "vue"
import { useTimestamp } from "@vueuse/core"
import { Service } from "dioc"
import { WorkspaceProvider } from "../provider"
import * as E from "fp-ts/Either"
import { Handle, HandleRef } from "../handle"
import { Workspace, WorkspaceCollection } from "../workspace"
import { NewWorkspaceService } from ".."
import TestWorkspaceSelector from "~/components/workspace/TestWorkspaceSelector.vue"
import { RESTCollectionChildrenView, RootRESTCollectionView } from "../view"
import IconUser from "~icons/lucide/user"
import { get } from "lodash-es"
type TestReqDef = {
name: string
}
type TestCollDef = {
name: string
collections: TestCollDef[]
requests: TestReqDef[]
}
const timestamp = useTimestamp({ interval: 3000 })
// const timestamp = ref(Date.now())
const testData = reactive({
workspaceA: {
name: computed(() => `Workspace A: ${timestamp.value}`),
collections: [
<TestCollDef>{
name: "Collection A",
collections: [
{
name: "Collection B",
collections: [
{ name: "Collection C", collections: [], requests: [] },
],
requests: [],
},
],
requests: [{ name: "Request C" }],
},
],
},
workspaceB: {
name: "Workspace B",
collections: [
<TestCollDef>{
name: "Collection D",
collections: [{ name: "Collection E", collections: [], requests: [] }],
requests: [{ name: "Request F" }],
},
],
},
})
;(window as any).testData = testData
export class TestWorkspaceProviderService
extends Service
implements WorkspaceProvider
{
public static readonly ID = "TEST_WORKSPACE_PROVIDER_SERVICE"
public providerID = "TEST_WORKSPACE_PROVIDER"
public workspaceDecor = ref({
workspaceSelectorComponent: markRaw(TestWorkspaceSelector),
headerCurrentIcon: markRaw(IconUser),
workspaceSelectorPriority: 10,
})
private readonly workspaceService = this.bind(NewWorkspaceService)
override onServiceInit() {
this.workspaceService.registerWorkspaceProvider(this)
}
public createRESTRootCollection(
workspaceHandle: HandleRef<Workspace>,
collectionName: string
): Promise<
E.Either<"INVALID_WORKSPACE_HANDLE", Handle<WorkspaceCollection>>
> {
if (workspaceHandle.value.type !== "ok") {
return Promise.resolve(E.left("INVALID_WORKSPACE_HANDLE" as const))
}
const workspaceID = workspaceHandle.value.data.workspaceID
const newCollID =
testData[workspaceID as keyof typeof testData].collections.length
testData[workspaceID as keyof typeof testData].collections.push({
name: collectionName,
collections: [],
requests: [],
})
return this.getCollectionHandle(workspaceHandle, newCollID.toString())
}
public createRESTChildCollection(
parentCollHandle: HandleRef<WorkspaceCollection>,
collectionName: string
): Promise<E.Either<unknown, Handle<WorkspaceCollection>>> {
// TODO: Implement
throw new Error("Method not implemented.")
}
public getWorkspaceHandle(
workspaceID: string
): Promise<E.Either<never, Handle<Workspace>>> {
return Promise.resolve(
E.right(
computed(() => {
if (!(workspaceID in testData)) {
return {
type: "invalid",
reason: "WORKSPACE_WENT_OUT" as const,
}
}
return {
type: "ok",
data: {
providerID: this.providerID,
workspaceID,
name: testData[workspaceID as keyof typeof testData].name,
collectionsAreReadonly: false,
},
}
})
)
)
}
public getCollectionHandle(
workspaceHandle: HandleRef<Workspace>,
collectionID: string
): Promise<
E.Either<"INVALID_WORKSPACE_HANDLE", Handle<WorkspaceCollection>>
> {
return Promise.resolve(
E.right(
computed(() => {
if (workspaceHandle.value.type !== "ok") {
return {
type: "invalid",
reason: "WORKSPACE_INVALIDATED" as const,
}
}
const workspaceID = workspaceHandle.value.data.workspaceID
const collectionPath = collectionID
.split("/")
.flatMap((x) => ["collections", x])
const result: TestCollDef | undefined = get(
testData[workspaceID as keyof typeof testData],
collectionPath
)
if (!result) {
return {
type: "invalid",
reason: "INVALID_COLL_ID",
}
}
return {
type: "ok",
data: {
providerID: this.providerID,
workspaceID,
collectionID,
name: result.name,
},
}
})
)
)
}
public getRESTCollectionChildrenView(
collectionHandle: HandleRef<WorkspaceCollection>
): Promise<E.Either<never, Handle<RESTCollectionChildrenView>>> {
return Promise.resolve(
E.right(
computed(() => {
if (collectionHandle.value.type === "invalid") {
return {
type: "invalid",
reason: "COLL_HANDLE_IS_INVALID" as const,
}
}
const workspaceID = collectionHandle.value.data.workspaceID
const collectionID = collectionHandle.value.data.collectionID
if (!(workspaceID in testData)) {
return {
type: "invalid",
reason: "WORKSPACE_NOT_PRESENT" as const,
}
}
const collectionPath = collectionID
.split("/")
.flatMap((x) => ["collections", x])
return markRaw({
type: "ok",
data: {
providerID: this.providerID,
workspaceID,
collectionID,
loading: ref(false),
content: computed(() => [
...(
get(testData[workspaceID as keyof typeof testData], [
...collectionPath,
"collections",
]) as TestCollDef[]
).map((item, i) => ({
type: "collection" as const,
value: {
collectionID: `${collectionID}/${i}`,
name: item.name,
},
})),
...(
get(testData[workspaceID as keyof typeof testData], [
...collectionPath,
"requests",
]) as TestReqDef[]
).map((item, i) => ({
type: "request" as const,
value: {
requestID: `${collectionID}/${i}`,
name: item.name,
method: "get",
},
})),
]),
},
})
})
)
)
}
public getRESTRootCollectionView(
workspaceHandle: HandleRef<Workspace>
): Promise<E.Either<never, Handle<RootRESTCollectionView>>> {
return Promise.resolve(
E.right(
computed(() => {
if (workspaceHandle.value.type === "invalid") {
return {
type: "invalid",
reason: "WORKSPACE_IS_INVALID" as const,
}
}
const workspaceID = workspaceHandle.value.data.workspaceID
if (!(workspaceID in testData)) {
return {
type: "invalid",
reason: "WORKSPACE_NOT_PRESENT" as const,
}
}
return markRaw({
type: "ok",
data: {
providerID: this.providerID,
workspaceID,
loading: ref(false),
collections: computed(() => {
return testData[
workspaceID as keyof typeof testData
].collections.map((x, i) => ({
collectionID: i.toString(),
name: x.name,
}))
}),
},
})
})
)
)
}
public getWorkspaceCandidates() {
return computed(() =>
Object.keys(testData).map((workspaceID) => ({
id: workspaceID,
name: testData[workspaceID as keyof typeof testData].name,
}))
)
}
}

View File

@@ -0,0 +1,64 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import { Ref } from "vue"
import { HoppInheritedRESTProperty } from "~/helpers/types/HoppInheritedProperties"
export type RESTCollectionLevelAuthHeadersView = {
auth: HoppInheritedRESTProperty["auth"]
headers: HoppInheritedRESTProperty["headers"]
}
export type RESTCollectionViewCollection = {
collectionID: string
isLastItem: boolean
name: string
parentCollectionID: string | null
}
export type RESTCollectionViewRequest = {
collectionID: string
requestID: string
request: HoppRESTRequest
isLastItem: boolean
}
export type RESTCollectionViewItem =
| { type: "collection"; value: RESTCollectionViewCollection }
| { type: "request"; value: RESTCollectionViewRequest }
export interface RootRESTCollectionView {
providerID: string
workspaceID: string
loading: Ref<boolean>
collections: Ref<RESTCollectionViewCollection[]>
}
export interface RESTCollectionChildrenView {
providerID: string
workspaceID: string
collectionID: string
loading: Ref<boolean>
content: Ref<RESTCollectionViewItem[]>
}
export interface RESTSearchResultsView {
providerID: string
workspaceID: string
loading: Ref<boolean>
results: Ref<HoppCollection[]>
onSessionEnd: () => void
}
export interface RESTCollectionJSONView {
providerID: string
workspaceID: string
content: string
}

View File

@@ -0,0 +1,35 @@
import { HoppRESTRequest } from "@hoppscotch/data"
import { Component } from "vue"
export type Workspace = {
providerID: string
workspaceID: string
name: string
}
export type WorkspaceCollection = {
providerID: string
workspaceID: string
collectionID: string
name: string
}
export type WorkspaceRequest = {
providerID: string
workspaceID: string
collectionID: string
requestID: string
request: HoppRESTRequest
}
export type WorkspaceDecor = {
headerComponent?: Component
headerCurrentIcon?: Component | object
workspaceSelectorComponent?: Component
workspaceSelectorPriority?: number
}

View File

@@ -580,8 +580,6 @@ describe("PersistenceService", () => {
invokeSetupLocalPersistence()
// toastErrorFn = vi.fn()
expect(getItemSpy).toHaveBeenCalledWith(settingsKey)
expect(toastErrorFn).not.toHaveBeenCalledWith(settingsKey)

View File

@@ -492,6 +492,15 @@ const HoppRESTResponseSchema = z.discriminatedUnion("type", [
const HoppRESTSaveContextSchema = z.nullable(
z.discriminatedUnion("originLocation", [
z
.object({
originLocation: z.literal("workspace-user-collection"),
workspaceID: z.optional(z.string()),
providerID: z.optional(z.string()),
requestID: z.optional(z.string()),
requestHandle: z.optional(z.record(z.unknown())),
})
.strict(),
z
.object({
originLocation: z.literal("user-collection"),

View File

@@ -1,9 +1,9 @@
import { Container } from "dioc"
import { isEqual } from "lodash-es"
import { computed } from "vue"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { HoppRESTDocument, HoppRESTSaveContext } from "~/helpers/rest/document"
import { TabService } from "./tab"
import { Container } from "dioc"
export class RESTTabService extends TabService<HoppRESTDocument> {
public static readonly ID = "REST_TAB_SERVICE"
@@ -30,17 +30,18 @@ export class RESTTabService extends TabService<HoppRESTDocument> {
lastActiveTabID: this.currentTabID.value,
orderedDocs: this.tabOrdering.value.map((tabID) => {
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: {
...tab.document,
...this.getPersistedDocument(tab.document),
response: null,
},
}
}),
}))
public getTabRefWithSaveContext(ctx: HoppRESTSaveContext) {
public getTabRefWithSaveContext(ctx: Partial<HoppRESTSaveContext>) {
for (const tab of this.tabMap.values()) {
// For `team-collection` request id can be considered unique
if (ctx?.originLocation === "team-collection") {
@@ -50,8 +51,40 @@ export class RESTTabService extends TabService<HoppRESTDocument> {
) {
return this.getTabRef(tab.id)
}
} else if (isEqual(ctx, tab.document.saveContext)) {
return this.getTabRef(tab.id)
} else if (ctx?.originLocation === "user-collection") {
if (isEqual(ctx, tab.document.saveContext)) {
return this.getTabRef(tab.id)
}
} else if (
ctx?.originLocation === "workspace-user-collection" &&
tab.document.saveContext?.originLocation === "workspace-user-collection"
) {
const requestHandle = tab.document.saveContext.requestHandle
if (!ctx.requestHandle || !requestHandle) {
continue
}
const tabRequestHandleRef = requestHandle.get()
const requestHandleRef = ctx.requestHandle.get()
if (
requestHandleRef.value.type === "invalid" ||
tabRequestHandleRef.value.type === "invalid"
) {
continue
}
if (
requestHandleRef.value.data.providerID ===
tabRequestHandleRef.value.data.providerID &&
requestHandleRef.value.data.workspaceID ===
tabRequestHandleRef.value.data.workspaceID &&
requestHandleRef.value.data.requestID ===
tabRequestHandleRef.value.data.requestID
) {
return this.getTabRef(tab.id)
}
}
}
@@ -62,7 +95,20 @@ export class RESTTabService extends TabService<HoppRESTDocument> {
let count = 0
for (const tab of this.tabMap.values()) {
if (tab.document.isDirty) count++
if (tab.document.isDirty) {
count++
continue
}
if (
tab.document.saveContext?.originLocation === "workspace-user-collection"
) {
const requestHandle = tab.document.saveContext.requestHandle
if (requestHandle?.get().value.type === "invalid") {
count++
}
}
}
return count

View File

@@ -1,5 +1,6 @@
import { refWithControl } from "@vueuse/core"
import { Service } from "dioc"
import * as E from "fp-ts/Either"
import { v4 as uuidV4 } from "uuid"
import {
ComputedRef,
@@ -10,16 +11,23 @@ import {
shallowReadonly,
watch,
} from "vue"
import { HoppRESTDocument } from "~/helpers/rest/document"
import {
HoppTab,
PersistableTabState,
TabService as TabServiceInterface,
} from "."
import { NewWorkspaceService } from "../new-workspace"
import { Handle } from "../new-workspace/handle"
import { WorkspaceRequest } from "../new-workspace/workspace"
export abstract class TabService<Doc>
extends Service
implements TabServiceInterface<Doc>
{
private workspaceService = this.bind(NewWorkspaceService)
protected tabMap = reactive(new Map<string, HoppTab<Doc>>())
protected tabOrdering = ref<string[]>(["test"])
@@ -36,9 +44,9 @@ export abstract class TabService<Doc>
},
})
public currentActiveTab = computed(
() => this.tabMap.get(this.currentTabID.value)!
) // Guaranteed to not be undefined
public currentActiveTab = computed(() => {
return this.tabMap.get(this.currentTabID.value)!
}) // Guaranteed to not be undefined
protected watchCurrentTabID() {
watch(
@@ -82,15 +90,63 @@ export abstract class TabService<Doc>
this.currentTabID.value = tabID
}
public loadTabsFromPersistedState(data: PersistableTabState<Doc>): void {
public async loadTabsFromPersistedState(
data: PersistableTabState<Doc>
): Promise<void> {
if (data) {
this.tabMap.clear()
this.tabOrdering.value = []
for (const doc of data.orderedDocs) {
let requestHandle: Handle<WorkspaceRequest> | null = null
let resolvedTabDoc = doc.doc
// TODO: Account for GQL
const { saveContext } = doc.doc as HoppRESTDocument
if (saveContext?.originLocation === "workspace-user-collection") {
const { providerID, requestID, workspaceID } = saveContext
if (!providerID || !workspaceID || !requestID) {
continue
}
const workspaceHandleResult =
await this.workspaceService.getWorkspaceHandle(
providerID!,
workspaceID!
)
if (E.isLeft(workspaceHandleResult)) {
continue
}
const workspaceHandle = workspaceHandleResult.right
const requestHandleResult =
await this.workspaceService.getRequestHandle(
workspaceHandle,
requestID!
)
if (E.isRight(requestHandleResult)) {
requestHandle = requestHandleResult.right
const { originLocation } = saveContext
resolvedTabDoc = {
...resolvedTabDoc,
saveContext: {
originLocation,
requestHandle,
},
}
}
}
this.tabMap.set(doc.tabID, {
id: doc.tabID,
document: doc.doc,
document: resolvedTabDoc,
})
this.tabOrdering.value.push(doc.tabID)
@@ -99,10 +155,11 @@ export abstract class TabService<Doc>
this.setActiveTab(data.lastActiveTabID)
}
}
public getActiveTabs(): Readonly<ComputedRef<HoppTab<Doc>[]>> {
return shallowReadonly(
computed(() => this.tabOrdering.value.map((x) => this.tabMap.get(x)!))
computed(() =>
this.tabOrdering.value.map((x) => this.tabMap.get(x) as HoppTab<Doc>)
)
)
}
@@ -180,13 +237,55 @@ export abstract class TabService<Doc>
this.currentTabID.value = tabID
}
public getPersistedDocument(tabDoc: Doc): Doc {
const { saveContext } = tabDoc as HoppRESTDocument
if (saveContext?.originLocation !== "workspace-user-collection") {
return tabDoc
}
// TODO: Investigate why requestHandle is available unwrapped here
const requestHandle = saveContext.requestHandle
if (!requestHandle) {
return tabDoc
}
const requestHandleRef = requestHandle.get()
if (requestHandleRef.value.type === "invalid") {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { requestHandle, ...rest } = saveContext
// Return the document without the handle
return {
...tabDoc,
saveContext: rest,
}
}
const { providerID, workspaceID, requestID } = requestHandleRef.value.data
// Return the document without the handle
return {
...tabDoc,
saveContext: {
originLocation: "workspace-user-collection",
requestID,
providerID,
workspaceID,
},
}
}
public persistableTabState = computed<PersistableTabState<Doc>>(() => ({
lastActiveTabID: this.currentTabID.value,
orderedDocs: this.tabOrdering.value.map((tabID) => {
const tab = this.tabMap.get(tabID)! // tab ordering is guaranteed to have value for this key
return {
tabID: tab.id,
doc: tab.document,
doc: this.getPersistedDocument(tab.document),
}
}),
}))

View File

@@ -28,7 +28,7 @@
"@fontsource-variable/roboto-mono": "5.0.16",
"@hoppscotch/common": "workspace:^",
"@hoppscotch/data": "workspace:^",
"@hoppscotch/ui": "0.1.0",
"@hoppscotch/ui": "0.1.4",
"@import-meta-env/unplugin": "0.4.10",
"axios": "1.6.2",
"buffer": "6.0.3",
@@ -37,7 +37,7 @@
"rxjs": "7.8.1",
"stream-browserify": "3.0.0",
"util": "0.12.5",
"vue": "3.3.9",
"vue": "3.4.27",
"workbox-window": "7.0.0",
"zod": "3.22.4"
},

View File

@@ -17,7 +17,7 @@
"@fontsource-variable/material-symbols-rounded": "5.0.5",
"@fontsource-variable/roboto-mono": "5.0.6",
"@graphql-typed-document-node/core": "3.1.1",
"@hoppscotch/ui": "0.1.3",
"@hoppscotch/ui": "0.1.4",
"@hoppscotch/vue-toasted": "0.1.0",
"@intlify/unplugin-vue-i18n": "1.2.0",
"@types/cors": "2.8.13",

863
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff