refactor: initial iterations

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
jamesgeorge007
2024-01-31 11:20:24 +05:30
parent f8ac6dfeb1
commit 29e25b0ead
24 changed files with 2197 additions and 514 deletions

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

@@ -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,20 @@ watch(
}
)
const newWorkspaceService = useService(NewWorkspaceService)
const activeWorkspaceName = computed(() => {
if (newWorkspaceService.activeWorkspaceHandle.value?.value.type === "ok") {
return newWorkspaceService.activeWorkspaceHandle.value?.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

@@ -35,7 +35,7 @@
:key="`method-${index}`"
:label="method"
:style="{
color: getMethodLabelColor(method),
color: getMethodLabelColorClassOf({ method }),
}"
@click="
() => {
@@ -263,7 +263,7 @@ 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 { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
import { WorkspaceService } from "~/services/workspace.service"
const t = useI18n()

View File

@@ -10,7 +10,8 @@
:icon="IconFolder"
:label="`${t('tab.collections')}`"
>
<Collections />
<!-- <Collections /> -->
<NewCollections :platform="'rest'" />
</HoppSmartTab>
<HoppSmartTab
:id="'env'"
@@ -26,13 +27,6 @@
>
<History :page="'rest'" />
</HoppSmartTab>
<HoppSmartTab
:id="'share-request'"
:icon="IconShare2"
:label="`${t('tab.shared_requests')}`"
>
<Share />
</HoppSmartTab>
</HoppSmartTabs>
</template>
@@ -40,7 +34,6 @@
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"

View File

@@ -0,0 +1,46 @@
<template>
<div v-if="!activeWorkspaceHandle">No Workspace Selected.</div>
<div v-else class="flex-1">
<div
class="sticky z-10 flex flex-shrink-0 flex-col overflow-x-auto border-b border-dividerLight bg-primary"
:style="{
top: 0,
}"
>
<WorkspaceCurrent :section="t('tab.collections')" />
<input
v-model="searchText"
type="search"
autocomplete="off"
class="flex h-8 w-full bg-transparent p-4 py-2"
:placeholder="t('action.search')"
/>
</div>
<NewCollectionsRest
v-if="platform === 'rest'"
:workspace-handle="activeWorkspaceHandle"
/>
</div>
</template>
<script setup lang="ts">
import { useService } from "dioc/vue"
import { ref } from "vue"
import { useI18n } from "~/composables/i18n"
import { NewWorkspaceService } from "~/services/new-workspace"
defineProps<{
platform: "rest" | "gql"
}>()
const t = useI18n()
const searchText = ref("")
const workspaceService = useService(NewWorkspaceService)
const activeWorkspaceHandle = workspaceService.activeWorkspaceHandle
const showModalAdd = ref(false)
const showModalImportExport = ref(false)
</script>

View File

@@ -0,0 +1,177 @@
<template>
<div class="flex flex-col">
<div class="relative flex flex-col">
<div
class="z-3 group pointer-events-auto relative flex cursor-pointer items-stretch"
@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" />
</span>
<span
class="pointer-events-none flex min-w-0 flex-1 py-2 pr-2 transition group-hover:text-secondaryDark"
>
<span class="truncate">
{{ collection.name }}
</span>
</span>
</div>
<div v-if="!collectionReadonly" class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFilePlus"
:title="t('request.new')"
class="hidden group-hover:inline-flex"
@click="emit('add-request')"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconFolderPlus"
:title="t('folder.new')"
class="hidden group-hover:inline-flex"
@click="emit('add-folder')"
/>
<span>
<tippy
ref="options"
interactive
trigger="click"
theme="popover"
:on-shown="() => tippyActions!.focus()"
>
<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.escape="hide()"
>
<HoppSmartItem
ref="requestAction"
:icon="IconFilePlus"
:label="t('request.new')"
:shortcut="['R']"
@click="
() => {
emit('add-request')
hide()
}
"
/>
<HoppSmartItem
ref="folderAction"
:icon="IconFolderPlus"
:label="t('folder.new')"
:shortcut="['N']"
@click="
() => {
emit('add-folder')
hide()
}
"
/>
<HoppSmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-collection')
hide()
}
"
/>
<HoppSmartItem
ref="exportAction"
:icon="IconDownload"
:label="t('export.title')"
:shortcut="['X']"
@click="
() => {
emit('export-data'),
collectionsType === 'my-collections' ? hide() : null
}
"
/>
<HoppSmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
emit('remove-collection')
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { RESTCollectionViewCollection } from "~/services/new-workspace/view"
import { TippyComponent } from "vue-tippy"
import { ref, computed } from "vue"
import { useI18n } from "~/composables/i18n"
import IconFolderPlus from "~icons/lucide/folder-plus"
import IconFilePlus from "~icons/lucide/file-plus"
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconDownload from "~icons/lucide/download"
import IconTrash2 from "~icons/lucide/trash-2"
import IconEdit from "~icons/lucide/edit"
import IconFolder from "~icons/lucide/folder"
import IconFolderOpen from "~icons/lucide/folder-open"
const t = useI18n()
const props = defineProps<{
collection: RESTCollectionViewCollection
collectionReadonly: boolean
isOpen: boolean
}>()
const emit = defineEmits<{
(event: "toggle-children"): void
(event: "add-request"): void
(event: "add-folder"): void
(event: "edit-collection"): void
(event: "export-data"): void
(event: "remove-collection"): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
const requestAction = ref<HTMLButtonElement | null>(null)
const folderAction = ref<HTMLButtonElement | null>(null)
const edit = ref<HTMLButtonElement | null>(null)
const deleteAction = ref<HTMLButtonElement | null>(null)
const exportAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const collectionIcon = computed(() => {
return !props.isOpen ? IconFolder : IconFolderOpen
})
</script>

View File

@@ -0,0 +1,144 @@
<template>
<div class="flex flex-col">
<div
class="group flex items-stretch"
@contextmenu.prevent="options?.tippy.show()"
>
<div
class="pointer-events-auto flex min-w-0 flex-1 cursor-pointer items-center justify-center"
>
<span
class="pointer-events-none flex w-16 items-center justify-center truncate px-2"
:class="requestLabelColor"
:style="{ color: requestLabelColor }"
>
<span class="truncate text-tiny font-semibold">
{{ 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">
{{ request.name }}
</span>
<span
v-if="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 v-if="!collectionReadonly" class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconRotateCCW"
:title="t('action.restore')"
class="hidden group-hover:inline-flex"
/>
<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.escape="hide()"
>
<HoppSmartItem
ref="edit"
:icon="IconEdit"
:label="t('action.edit')"
:shortcut="['E']"
@click="
() => {
emit('edit-request')
hide()
}
"
/>
<HoppSmartItem
ref="duplicate"
:icon="IconCopy"
:label="t('action.duplicate')"
:shortcut="['D']"
@click="
() => {
emit('duplicate-request')
hide()
}
"
/>
<HoppSmartItem
ref="deleteAction"
:icon="IconTrash2"
:label="t('action.delete')"
:shortcut="['⌫']"
@click="
() => {
emit('remove-request')
hide()
}
"
/>
</div>
</template>
</tippy>
</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import IconMoreVertical from "~icons/lucide/more-vertical"
import IconEdit from "~icons/lucide/edit"
import IconCopy from "~icons/lucide/copy"
import IconTrash2 from "~icons/lucide/trash-2"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import { useI18n } from "@composables/i18n"
import { RESTCollectionViewRequest } from "~/services/new-workspace/view"
import { computed, ref } from "vue"
import { TippyComponent } from "vue-tippy"
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
const t = useI18n()
const props = defineProps<{
request: RESTCollectionViewRequest
collectionReadonly: boolean
}>()
const tippyActions = ref<TippyComponent | null>(null)
const options = ref<TippyComponent | null>(null)
// TODO: implement
const isActive = ref(true)
const requestLabelColor = computed(() =>
getMethodLabelColorClassOf(props.request)
)
</script>

View File

@@ -0,0 +1,141 @@
<template>
<div v-if="collectionsAreReadonly !== undefined" class="flex flex-1 flex-col">
<div
class="sticky z-10 flex flex-1 justify-between border-b border-dividerLight bg-primary"
:style="'top: var(--upper-primary-sticky-fold)'"
>
<HoppButtonSecondary
v-if="collectionsAreReadonly"
v-tippy="{ theme: 'tooltip' }"
disabled
class="!rounded-none"
:icon="IconPlus"
:title="t('team.no_access')"
:label="t('add.new')"
/>
<HoppButtonSecondary
v-else
:icon="IconPlus"
:label="t('add.new')"
class="!rounded-none"
@click="showModalAdd = true"
/>
<span class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/documentation/features/collections"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconImport"
:title="t('modal.import_export')"
@click="onImportExportClick"
/>
</span>
</div>
<div class="flex flex-1 flex-col">
<HoppSmartTree :adapter="treeAdapter">
<template
#content="{ node, toggleChildren, isOpen, highlightChildren }"
>
<!-- TODO: Implement -->
<NewCollectionsRestCollection
v-if="node.data.type === 'collection'"
:collection="node.data.value"
:collection-readonly="collectionsAreReadonly"
:is-open="isOpen"
@toggle-children="toggleChildren"
/>
<div v-else @click="toggleChildren">
{{ node.data.value }}
</div>
</template>
<template #emptyNode="{ node }">
<!-- TODO: Implement -->
<div>Empty Node!</div>
</template>
</HoppSmartTree>
</div>
<CollectionsAdd
:show="showModalAdd"
:loading-state="modalLoadingState"
@submit="addNewRootCollection"
@hide-modal="showModalAdd = false"
/>
</div>
</template>
<script setup lang="ts">
import * as E from "fp-ts/lib/Either"
import { useI18n } from "~/composables/i18n"
import IconPlus from "~icons/lucide/plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconImport from "~icons/lucide/folder-down"
import { HandleRef } from "~/services/new-workspace/handle"
import { Workspace } from "~/services/new-workspace/workspace"
import { computed, markRaw, ref } from "vue"
import { WorkspaceRESTCollectionTreeAdapter } from "~/helpers/adapters/WorkspaceRESTCollectionTreeAdapter"
import { NewWorkspaceService } from "~/services/new-workspace"
import { useService } from "dioc/vue"
const t = useI18n()
const props = defineProps<{
workspaceHandle: HandleRef<Workspace>
}>()
const emit = defineEmits<{
(e: "display-modal-add"): void
(e: "display-modal-import-export"): void
}>()
const workspaceService = useService(NewWorkspaceService)
const treeAdapter = markRaw(
new WorkspaceRESTCollectionTreeAdapter(
props.workspaceHandle,
workspaceService
)
)
const collectionsAreReadonly = computed(() => {
if (props.workspaceHandle.value.type === "ok") {
return props.workspaceHandle.value.data.collectionsAreReadonly
}
return undefined
})
const showModalAdd = ref(false)
const modalLoadingState = ref(false)
async function addNewRootCollection(name: string) {
modalLoadingState.value = true
const result = await workspaceService.createRESTRootCollection(
props.workspaceHandle,
name
)
if (E.isLeft(result)) {
// TODO: Error Handling
return
}
// Workspace invalidated
if (result.right.value.type === "invalid") {
// TODO: Error Handling
return
}
modalLoadingState.value = false
showModalAdd.value = false
}
function onImportExportClick() {
// TODO: Implement
}
</script>

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,22 @@
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")
const workspaceName = computed(() => {
if (activeWorkspaceHandle.value?.value.type === "ok") {
return activeWorkspaceHandle.value.value.data.name
}
return teamWorkspaceName.value
})
const teamWorkspaceName = computed(() => {
if (workspace.value.type === "team" && workspace.value.teamName) {
return workspace.value.teamName
}
return `${t("workspace.team")}`
return undefined
})
</script>

View File

@@ -0,0 +1,53 @@
<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 activeWorkspace = workspaceService.activeWorkspaceHandle.value
if (activeWorkspace?.value.type === "ok") {
return {
provider: activeWorkspace.value.data.providerID,
workspaceID: activeWorkspace.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,64 @@
<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 activeWorkspace = workspaceService.activeWorkspaceHandle.value
if (activeWorkspace?.value.type === "ok") {
return {
provider: activeWorkspace.value.data.providerID,
workspaceID: activeWorkspace.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,129 @@
import {
ChildrenResult,
SmartTreeAdapter,
} from "@hoppscotch/ui/dist/src/helpers/treeAdapter"
import { Ref, computed, ref, watchEffect } from "vue"
import { NewWorkspaceService } from "~/services/new-workspace"
import { HandleRef } from "~/services/new-workspace/handle"
import {
RESTCollectionViewCollection,
RESTCollectionViewRequest,
} from "~/services/new-workspace/view"
import { Workspace } from "~/services/new-workspace/workspace"
import * as E from "fp-ts/Either"
type WorkspaceRESTCollectionListNode =
| { type: "collection"; value: RESTCollectionViewCollection }
| { type: "request"; value: RESTCollectionViewRequest }
export class WorkspaceRESTCollectionTreeAdapter
implements SmartTreeAdapter<WorkspaceRESTCollectionListNode>
{
constructor(
private workspaceHandle: HandleRef<Workspace>,
private workspaceService: NewWorkspaceService
) {}
public getChildren(
nodeID: string | null
): Ref<ChildrenResult<WorkspaceRESTCollectionListNode>> {
if (this.workspaceHandle.value.type !== "ok") {
throw new Error("Cannot issue children with invalid workspace handle")
}
const result = ref<ChildrenResult<WorkspaceRESTCollectionListNode>>({
status: "loading",
})
if (nodeID !== null) {
;(async () => {
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
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
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,17 +1,13 @@
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as RR from "fp-ts/ReadonlyRecord"
import { HoppRESTRequest } from "@hoppscotch/data"
export const REQUEST_METHOD_LABEL_COLORS = {
get: "var(--method-get-color)",
post: "var(--method-post-color)",
put: "var(--method-put-color)",
patch: "var(--method-patch-color)",
delete: "var(--method-delete-color)",
head: "var(--method-head-color)",
options: "var(--method-options-color)",
default: "var(--method-default-color)",
get: "var(--success-color)",
post: "var(--warning-color)",
put: "var(--blue-color)",
delete: "var(--cl-error-color)",
default: "#6b7280",
} as const
/**
@@ -19,18 +15,10 @@ export const REQUEST_METHOD_LABEL_COLORS = {
* @param request The HoppRESTRequest object to get the value for
* @returns The class value for the given HTTP VERB, if not, a generic verb class
*/
export function getMethodLabelColorClassOf(request: HoppRESTRequest) {
export function getMethodLabelColorClassOf(request: { method: string }) {
return pipe(
REQUEST_METHOD_LABEL_COLORS,
RR.lookup(request.method.toLowerCase()),
O.getOrElseW(() => REQUEST_METHOD_LABEL_COLORS.default)
)
}
export function getMethodLabelColor(method: string) {
return pipe(
REQUEST_METHOD_LABEL_COLORS,
RR.lookup(method.toLowerCase()),
O.getOrElseW(() => REQUEST_METHOD_LABEL_COLORS.default)
)
}

View File

@@ -3,18 +3,22 @@ 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 "@fontsource-variable/inter"
import "@fontsource-variable/material-symbols-rounded"
import "@fontsource-variable/roboto-mono"
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)
@@ -24,15 +28,18 @@ export function createHoppApp(el: string | Element, platformDef: PlatformDef) {
// 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))
// 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)
HOPP_MODULES.forEach((mod) => mod.onVueAppInit?.(app))
platformDef.addedHoppModules?.forEach((mod) => mod.onVueAppInit?.(app))
app.mount(el)
console.info(

View File

@@ -0,0 +1,5 @@
import { Ref } from "vue"
export type HandleRef<T, InvalidateReason = unknown> = Ref<
{ type: "ok"; data: T } | { type: "invalid"; reason: InvalidateReason }
>

View File

@@ -0,0 +1,276 @@
import { Service } from "dioc"
import {
Component,
Ref,
computed,
markRaw,
shallowReactive,
shallowRef,
watch,
} from "vue"
import { WorkspaceProvider } from "./provider"
import { HandleRef } from "./handle"
import * as E from "fp-ts/Either"
import { Workspace, WorkspaceCollection } from "./workspace"
import { RESTCollectionChildrenView, RootRESTCollectionView } from "./view"
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<HandleRef<Workspace> | undefined> =
shallowRef()
public activeWorkspaceDecor = computed(() => {
if (this.activeWorkspaceHandle.value?.value.type !== "ok") {
return undefined
}
return this.registeredProviders.get(
this.activeWorkspaceHandle.value.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
})
constructor() {
super()
// 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.value,
]
: [this.activeWorkspaceHandle.value]
},
() => {
if (!this.activeWorkspaceHandle.value) return
if (this.activeWorkspaceHandle.value.value.type === "invalid") {
this.activeWorkspaceHandle.value = undefined
}
},
{ deep: true }
)
}
public async getWorkspaceHandle(
providerID: string,
workspaceID: string
): Promise<
E.Either<WorkspaceError<"INVALID_PROVIDER">, HandleRef<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: HandleRef<Workspace>,
collectionID: string
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
HandleRef<WorkspaceCollection>
>
> {
if (workspaceHandle.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandle.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 createRESTRootCollection(
workspaceHandle: HandleRef<Workspace>,
collectionName: string
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
HandleRef<WorkspaceCollection>
>
> {
if (workspaceHandle.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandle.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.createRESTRootCollection(
workspaceHandle,
collectionName
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async createRESTChildCollection(
parentCollHandle: HandleRef<WorkspaceCollection>,
collectionName: string
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
HandleRef<WorkspaceCollection>
>
> {
if (parentCollHandle.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
parentCollHandle.value.data.providerID
)
if (!provider) {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_PROVIDER" })
}
const result = await provider.createRESTChildCollection(
parentCollHandle,
collectionName
)
if (E.isLeft(result)) {
return E.left({ type: "PROVIDER_ERROR", error: result.left })
}
return E.right(result.right)
}
public async getRESTCollectionChildrenView(
collectionHandle: HandleRef<WorkspaceCollection>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
HandleRef<RESTCollectionChildrenView>
>
> {
if (collectionHandle.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
collectionHandle.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: HandleRef<Workspace>
): Promise<
E.Either<
WorkspaceError<"INVALID_HANDLE" | "INVALID_PROVIDER">,
HandleRef<RootRESTCollectionView>
>
> {
if (workspaceHandle.value.type === "invalid") {
return E.left({ type: "SERVICE_ERROR", error: "INVALID_HANDLE" })
}
const provider = this.registeredProviders.get(
workspaceHandle.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 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,35 @@
import { Ref } from "vue"
import * as E from "fp-ts/Either"
import { HandleRef } from "./handle"
import { Workspace, WorkspaceCollection, WorkspaceDecor } from "./workspace"
import { RESTCollectionChildrenView, RootRESTCollectionView } from "./view"
export interface WorkspaceProvider {
providerID: string
workspaceDecor?: Ref<WorkspaceDecor>
getWorkspaceHandle(
workspaceID: string
): Promise<E.Either<unknown, HandleRef<Workspace>>>
getCollectionHandle(
workspaceHandle: HandleRef<Workspace>,
collectionID: string
): Promise<E.Either<unknown, HandleRef<WorkspaceCollection>>>
getRESTRootCollectionView(
workspaceHandle: HandleRef<Workspace>
): Promise<E.Either<unknown, HandleRef<RootRESTCollectionView>>>
getRESTCollectionChildrenView(
collectionHandle: HandleRef<WorkspaceCollection>
): Promise<E.Either<unknown, HandleRef<RESTCollectionChildrenView>>>
createRESTRootCollection(
workspaceHandle: HandleRef<Workspace>,
collectionName: string
): Promise<E.Either<unknown, HandleRef<WorkspaceCollection>>>
createRESTChildCollection(
parentCollHandle: HandleRef<WorkspaceCollection>,
collectionName: string
): Promise<E.Either<unknown, HandleRef<WorkspaceCollection>>>
}

View File

@@ -0,0 +1,400 @@
import {
HoppCollection,
HoppRESTRequest,
makeCollection,
} from "@hoppscotch/data"
import { Service } from "dioc"
import * as E from "fp-ts/Either"
import { get } from "lodash-es"
import { v4 as uuid } from "uuid"
import { Ref, computed, markRaw, ref, shallowReactive, shallowRef } from "vue"
import PersonalWorkspaceSelector from "~/components/workspace/PersonalWorkspaceSelector.vue"
import { useStreamStatic } from "~/composables/stream"
import { addRESTCollection, restCollectionStore } from "~/newstore/collections"
import { platform } from "~/platform"
import { HandleRef } from "~/services/new-workspace/handle"
import { WorkspaceProvider } from "~/services/new-workspace/provider"
import {
RESTCollectionChildrenView,
RESTCollectionViewItem,
RootRESTCollectionView,
} from "~/services/new-workspace/view"
import {
Workspace,
WorkspaceCollection,
WorkspaceDecor,
} from "~/services/new-workspace/workspace"
import IconUser from "~icons/lucide/user"
import { NewWorkspaceService } from ".."
export class PersonalWorkspaceProviderService
extends Service
implements WorkspaceProvider
{
public static readonly ID = "PERSONAL_WORKSPACE_PROVIDER_SERVICE"
public readonly providerID = "PERSONAL_WORKSPACE_PROVIDER"
private workspaceService = this.bind(NewWorkspaceService)
public workspaceDecor: Ref<WorkspaceDecor> = ref({
headerCurrentIcon: IconUser,
workspaceSelectorComponent: PersonalWorkspaceSelector,
workspaceSelectorPriority: 100,
})
private restCollectionState: Ref<{ state: HoppCollection[] }>
public constructor() {
super()
this.restCollectionState = useStreamStatic(
restCollectionStore.subject$,
{ state: [] },
() => {
/* noop */
}
)[0]
this.workspaceService.registerWorkspaceProvider(this)
}
private collectionIDMap = shallowReactive(
new WeakMap<HoppCollection, string>()
)
private reqIDMap = shallowReactive(new WeakMap<HoppRESTRequest, string>())
private collectionIDPathMap = shallowReactive(new Map<string, number[]>())
private generatedUUIDs = new Set<string>()
private generateUniqueUUID() {
let id = uuid()
while (this.generatedUUIDs.has(id)) {
id = uuid()
}
this.generatedUUIDs.add(id)
return id
}
private resolvePathFromCollectionID(id: string): number[] | undefined {
return this.collectionIDPathMap.get(id)
}
private resolveCollectionFromCollectionID(
id: string
): HoppCollection | undefined {
const path = this.resolvePathFromCollectionID(id)
if (path === undefined) return
const collPath = path.flatMap((x, i) =>
i === 0 ? [x.toString()] : ["folders", x.toString()]
)
const coll = get(this.restCollectionState.value.state, collPath) as
| HoppCollection
| undefined
return coll
}
private getIssuedInstanceIDForCollection(
coll: HoppCollection,
location: number[]
) {
const id = this.collectionIDMap.has(coll)
? this.collectionIDMap.get(coll)!
: this.generateUniqueUUID()
this.collectionIDPathMap.set(id, location)
this.collectionIDMap.set(coll, id)
return id
}
private getIssuedInstanceIDForRequest(req: HoppRESTRequest) {
const id = this.reqIDMap.get(req) ?? this.generateUniqueUUID()
this.reqIDMap.set(req, id)
return id
}
public createRESTChildCollection(
parentCollHandle: HandleRef<WorkspaceCollection>,
collectionName: string
): Promise<E.Either<unknown, HandleRef<WorkspaceCollection>>> {
throw new Error("TODO: Method not implemented.")
}
public createRESTRootCollection(
workspaceHandle: HandleRef<Workspace>,
collectionName: string
): Promise<E.Either<unknown, HandleRef<WorkspaceCollection>>> {
if (
workspaceHandle.value.type !== "ok" ||
workspaceHandle.value.data.providerID !== this.providerID ||
workspaceHandle.value.data.workspaceID !== "personal"
) {
return Promise.resolve(E.left("INVALID_WORKSPACE_HANDLE" as const))
}
return Promise.resolve(
E.right(
computed(() => {
if (
workspaceHandle.value.type !== "ok" ||
workspaceHandle.value.data.providerID !== this.providerID ||
workspaceHandle.value.data.workspaceID !== "personal"
) {
return {
type: "invalid",
reason: "WORKSPACE_INVALIDATED" as const,
}
}
addRESTCollection(
makeCollection({
name: collectionName,
folders: [],
requests: [],
headers: [],
auth: {
authType: "inherit",
authActive: false,
},
})
)
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
platform: "rest",
workspaceType: "personal",
isRootCollection: true,
})
return {
type: "ok",
data: {
providerID: this.providerID,
workspaceID: workspaceHandle.value.data.workspaceID,
collectionID: "",
name: collectionName,
},
}
})
)
)
}
public getCollectionHandle(
workspaceHandle: HandleRef<Workspace>,
collectionID: string
): Promise<
E.Either<"INVALID_WORKSPACE_HANDLE", HandleRef<WorkspaceCollection>>
> {
if (
workspaceHandle.value.type !== "ok" ||
workspaceHandle.value.data.providerID !== this.providerID ||
workspaceHandle.value.data.workspaceID !== "personal"
) {
return Promise.resolve(E.left("INVALID_WORKSPACE_HANDLE" as const))
}
return Promise.resolve(
E.right(
computed(() => {
if (
workspaceHandle.value.type !== "ok" ||
workspaceHandle.value.data.providerID !== this.providerID ||
workspaceHandle.value.data.workspaceID !== "personal"
) {
return {
type: "invalid",
reason: "WORKSPACE_INVALIDATED" as const,
}
}
// TODO: The way the IDs are issued, this will make it so we need a view
// before the ID is issued correctly
const coll = this.resolveCollectionFromCollectionID(collectionID)
if (coll === undefined) {
return {
type: "invalid",
reason: "INVALID_COLL_ID" as const,
}
}
return {
type: "ok",
data: {
providerID: this.providerID,
workspaceID: workspaceHandle.value.data.workspaceID,
collectionID,
name: coll.name,
},
}
})
)
)
}
public getRESTCollectionChildrenView(
collectionHandle: HandleRef<WorkspaceCollection>
): Promise<E.Either<never, HandleRef<RESTCollectionChildrenView>>> {
return Promise.resolve(
E.right(
computed(() => {
if (
collectionHandle.value.type === "invalid" ||
collectionHandle.value.data.providerID !== this.providerID ||
collectionHandle.value.data.workspaceID !== "personal"
) {
return {
type: "invalid" as const,
reason: "INVALID_COLLECTION_HANDLE" as const,
}
}
const collectionID = collectionHandle.value.data.collectionID
return markRaw({
type: "ok" as const,
data: {
providerID: this.providerID,
workspaceID: collectionHandle.value.data.workspaceID,
collectionID: collectionHandle.value.data.collectionID,
loading: ref(false),
mayHaveMoreContent: ref(false),
content: computed(() => {
const path = this.resolvePathFromCollectionID(collectionID)
const coll =
this.resolveCollectionFromCollectionID(collectionID)
if (coll === undefined || path === undefined) {
console.warn("Collection against ID was not resolvable")
return []
}
const collections = coll.folders.map((childColl, i) => {
const id = this.getIssuedInstanceIDForCollection(childColl, [
...path,
i,
])
return <RESTCollectionViewItem>{
type: "collection",
value: {
collectionID: id,
name: coll.name,
},
}
})
const requests = coll.requests.map((req, i) => {
const id = this.getIssuedInstanceIDForRequest(req)
return <RESTCollectionViewItem>{
type: "request",
value: {
requestID: id,
name: req.name,
method: req.method,
},
}
})
return [...collections, ...requests]
}),
loadMore() {
return Promise.resolve()
},
},
})
})
)
)
}
public getRESTRootCollectionView(
workspaceHandle: HandleRef<Workspace>
): Promise<E.Either<never, HandleRef<RootRESTCollectionView>>> {
return Promise.resolve(
E.right(
computed(() => {
if (
workspaceHandle.value.type === "invalid" ||
workspaceHandle.value.data.providerID !== this.providerID ||
workspaceHandle.value.data.workspaceID !== "personal"
) {
return {
type: "invalid" as const,
reason: "INVALID_WORKSPACE_HANDLE" as const,
}
}
return markRaw({
type: "ok" as const,
data: {
providerID: this.providerID,
workspaceID: workspaceHandle.value.data.workspaceID,
loading: ref(false),
mayHaveMoreContent: ref(false),
collections: computed(() => {
return this.restCollectionState.value.state.map((coll, i) => {
const id = this.getIssuedInstanceIDForCollection(coll, [i])
return {
collectionID: id,
name: coll.name,
}
})
}),
loadMore() {
return Promise.resolve()
},
},
})
})
)
)
}
public getWorkspaceHandle(
workspaceID: string
): Promise<E.Either<unknown, HandleRef<Workspace>>> {
if (workspaceID !== "personal") {
return Promise.resolve(E.left("INVALID_WORKSPACE_ID" as const))
}
return Promise.resolve(E.right(this.getPersonalWorkspaceHandle()))
}
public getPersonalWorkspaceHandle(): HandleRef<Workspace> {
return shallowRef({
type: "ok" as const,
data: {
providerID: this.providerID,
workspaceID: "personal",
name: "Personal Workspace",
collectionsAreReadonly: false,
},
})
}
}

View File

@@ -0,0 +1,321 @@
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 { 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)
constructor() {
super()
this.workspaceService.registerWorkspaceProvider(this)
}
public createRESTRootCollection(
workspaceHandle: HandleRef<Workspace>,
collectionName: string
): Promise<
E.Either<"INVALID_WORKSPACE_HANDLE", HandleRef<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, HandleRef<WorkspaceCollection>>> {
// TODO: Implement
throw new Error("Method not implemented.")
}
public getWorkspaceHandle(
workspaceID: string
): Promise<E.Either<never, HandleRef<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", HandleRef<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, HandleRef<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,
mayHaveMoreContent: ref(false),
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",
},
})),
]),
loadMore(_count: number) {
return Promise.resolve()
},
},
})
})
)
)
}
public getRESTRootCollectionView(
workspaceHandle: HandleRef<Workspace>
): Promise<E.Either<never, HandleRef<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,
mayHaveMoreContent: ref(false),
loading: ref(false),
collections: computed(() => {
return testData[
workspaceID as keyof typeof testData
].collections.map((x, i) => ({
collectionID: i.toString(),
name: x.name,
}))
}),
loadMore() {
return Promise.resolve()
},
},
})
})
)
)
}
public getWorkspaceCandidates() {
return computed(() =>
Object.keys(testData).map((workspaceID) => ({
id: workspaceID,
name: testData[workspaceID as keyof typeof testData].name,
}))
)
}
}

View File

@@ -0,0 +1,42 @@
import { Ref } from "vue"
export type RESTCollectionViewCollection = {
collectionID: string
name: string
}
export type RESTCollectionViewRequest = {
requestID: string
name: string
method: string
}
export type RESTCollectionViewItem =
| { type: "collection"; value: RESTCollectionViewCollection }
| { type: "request"; value: RESTCollectionViewRequest }
export interface RootRESTCollectionView {
providerID: string
workspaceID: string
mayHaveMoreContent: Ref<boolean>
loading: Ref<boolean>
collections: Ref<RESTCollectionViewCollection[]>
loadMore(count: number): Promise<void>
}
export interface RESTCollectionChildrenView {
providerID: string
workspaceID: string
collectionID: string
mayHaveMoreContent: Ref<boolean>
loading: Ref<boolean>
content: Ref<RESTCollectionViewItem[]>
loadMore(count: number): Promise<void>
}

View File

@@ -0,0 +1,27 @@
import { Component } from "vue"
export type Workspace = {
providerID: string
workspaceID: string
name: string
collectionsAreReadonly: boolean
}
export type WorkspaceCollection = {
providerID: string
workspaceID: string
collectionID: string
name: string
}
export type WorkspaceDecor = {
headerComponent?: Component
headerCurrentIcon?: Component | object
workspaceSelectorComponent?: Component
workspaceSelectorPriority?: number
}