chore: merge hoppscotch/hoppscotch/staging into main

This commit is contained in:
Andrew Bastin
2023-03-02 18:55:55 +05:30
64 changed files with 2619 additions and 938 deletions

View File

@@ -16,6 +16,7 @@ declare module '@vue/runtime-core' {
AppHeader: typeof import('./components/app/Header.vue')['default']
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
AppLogo: typeof import('./components/app/Logo.vue')['default']
AppNavigation: typeof import('./components/app/Navigation.vue')['default']
AppOptions: typeof import('./components/app/Options.vue')['default']
AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default']
AppPowerSearch: typeof import('./components/app/PowerSearch.vue')['default']
@@ -26,8 +27,6 @@ declare module '@vue/runtime-core' {
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
AppSidenav: typeof import('./components/app/Sidenav.vue')['default']
AppSupport: typeof import('./components/app/Support.vue')['default']
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default']
Collections: typeof import('./components/collections/index.vue')['default']
CollectionsAdd: typeof import('./components/collections/Add.vue')['default']
CollectionsAddFolder: typeof import('./components/collections/AddFolder.vue')['default']
@@ -52,9 +51,7 @@ declare module '@vue/runtime-core' {
CollectionsRequest: typeof import('./components/collections/Request.vue')['default']
CollectionsSaveRequest: typeof import('./components/collections/SaveRequest.vue')['default']
CollectionsTeamCollections: typeof import('./components/collections/TeamCollections.vue')['default']
CollectionsTeamSelect: typeof import('./components/collections/TeamSelect.vue')['default']
Environments: typeof import('./components/environments/index.vue')['default']
EnvironmentsChooseType: typeof import('./components/environments/ChooseType.vue')['default']
EnvironmentsImportExport: typeof import('./components/environments/ImportExport.vue')['default']
EnvironmentsMy: typeof import('./components/environments/my/index.vue')['default']
EnvironmentsMyDetails: typeof import('./components/environments/my/Details.vue')['default']
@@ -75,6 +72,24 @@ declare module '@vue/runtime-core' {
History: typeof import('./components/history/index.vue')['default']
HistoryGraphqlCard: typeof import('./components/history/graphql/Card.vue')['default']
HistoryRestCard: typeof import('./components/history/rest/Card.vue')['default']
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
HoppSmartFileChip: typeof import('@hoppscotch/ui')['HoppSmartFileChip']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
HttpAuthorization: typeof import('./components/http/Authorization.vue')['default']
HttpBody: typeof import('./components/http/Body.vue')['default']
HttpBodyParameters: typeof import('./components/http/BodyParameters.vue')['default']
@@ -97,6 +112,18 @@ declare module '@vue/runtime-core' {
HttpTestResultReport: typeof import('./components/http/TestResultReport.vue')['default']
HttpTests: typeof import('./components/http/Tests.vue')['default']
HttpURLEncodedParams: typeof import('./components/http/URLEncodedParams.vue')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
IconLucideInfo: typeof import('~icons/lucide/info')['default']
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUser: typeof import('~icons/lucide/user')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
LensesRenderersHTMLLensRenderer: typeof import('./components/lenses/renderers/HTMLLensRenderer.vue')['default']
@@ -116,41 +143,24 @@ declare module '@vue/runtime-core' {
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SmartAccentModePicker: typeof import('./components/smart/AccentModePicker.vue')['default']
SmartAnchor: typeof import('./../../hoppscotch-ui/src/components/smart/Anchor.vue')['default']
SmartAutoComplete: typeof import('./../../hoppscotch-ui/src/components/smart/AutoComplete.vue')['default']
SmartChangeLanguage: typeof import('./components/smart/ChangeLanguage.vue')['default']
SmartCheckbox: typeof import('./../../hoppscotch-ui/src/components/smart/Checkbox.vue')['default']
SmartColorModePicker: typeof import('./components/smart/ColorModePicker.vue')['default']
SmartConfirmModal: typeof import('./../../hoppscotch-ui/src/components/smart/ConfirmModal.vue')['default']
SmartEnvInput: typeof import('./components/smart/EnvInput.vue')['default']
SmartExpand: typeof import('./../../hoppscotch-ui/src/components/smart/Expand.vue')['default']
SmartFileChip: typeof import('./../../hoppscotch-ui/src/components/smart/FileChip.vue')['default']
SmartFontSizePicker: typeof import('./components/smart/FontSizePicker.vue')['default']
SmartIntersection: typeof import('./../../hoppscotch-ui/src/components/smart/Intersection.vue')['default']
SmartItem: typeof import('./../../hoppscotch-ui/src/components/smart/Item.vue')['default']
SmartLink: typeof import('./../../hoppscotch-ui/src/components/smart/Link.vue')['default']
SmartModal: typeof import('./../../hoppscotch-ui/src/components/smart/Modal.vue')['default']
SmartProgressRing: typeof import('./../../hoppscotch-ui/src/components/smart/ProgressRing.vue')['default']
SmartRadio: typeof import('./../../hoppscotch-ui/src/components/smart/Radio.vue')['default']
SmartRadioGroup: typeof import('./../../hoppscotch-ui/src/components/smart/RadioGroup.vue')['default']
SmartSlideOver: typeof import('./../../hoppscotch-ui/src/components/smart/SlideOver.vue')['default']
SmartSpinner: typeof import('./../../hoppscotch-ui/src/components/smart/Spinner.vue')['default']
SmartTab: typeof import('./../../hoppscotch-ui/src/components/smart/Tab.vue')['default']
SmartTabs: typeof import('./../../hoppscotch-ui/src/components/smart/Tabs.vue')['default']
SmartToggle: typeof import('./../../hoppscotch-ui/src/components/smart/Toggle.vue')['default']
SmartTree: typeof import('./components/smart/Tree.vue')['default']
SmartTreeBranch: typeof import('./components/smart/TreeBranch.vue')['default']
SmartWindow: typeof import('./../../hoppscotch-ui/src/components/smart/Window.vue')['default']
SmartWindows: typeof import('./../../hoppscotch-ui/src/components/smart/Windows.vue')['default']
TabPrimary: typeof import('./components/tab/Primary.vue')['default']
TabSecondary: typeof import('./components/tab/Secondary.vue')['default']
Teams: typeof import('./components/teams/index.vue')['default']
TeamsAdd: typeof import('./components/teams/Add.vue')['default']
TeamsEdit: typeof import('./components/teams/Edit.vue')['default']
TeamsInvite: typeof import('./components/teams/Invite.vue')['default']
TeamsMemberStack: typeof import('./components/teams/MemberStack.vue')['default']
TeamsModal: typeof import('./components/teams/Modal.vue')['default']
TeamsTeam: typeof import('./components/teams/Team.vue')['default']
Tippy: typeof import('vue-tippy')['Tippy']
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
}
}

View File

@@ -41,7 +41,7 @@
:on-shown="() => tippyActions!.focus()"
>
<HoppButtonSecondary
:icon="IconHelpCircle"
:icon="IconLifeBuoy"
class="!rounded-none"
:label="`${t('app.help')}`"
/>
@@ -206,7 +206,6 @@ import IconShare2 from "~icons/lucide/share-2"
import IconColumns from "~icons/lucide/columns"
import IconSidebarOpen from "~icons/lucide/sidebar-open"
import IconShieldCheck from "~icons/lucide/shield-check"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconBook from "~icons/lucide/book"
import IconMessageCircle from "~icons/lucide/message-circle"
import IconGift from "~icons/lucide/gift"
@@ -215,6 +214,7 @@ import IconGithub from "~icons/lucide/github"
import IconTwitter from "~icons/lucide/twitter"
import IconUserPlus from "~icons/lucide/user-plus"
import IconLock from "~icons/lucide/lock"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import { showChat } from "@modules/crisp"
import { useSetting } from "@composables/settings"
import { useI18n } from "@composables/i18n"

View File

@@ -4,7 +4,7 @@
class="flex items-center justify-between flex-1 flex-shrink-0 px-2 py-2 space-x-2 overflow-x-auto overflow-y-hidden"
>
<div
class="inline-flex items-center space-x-2"
class="inline-flex items-center justify-start flex-1 space-x-2"
:style="{
paddingTop: platform.ui?.appHeader?.paddingTop?.value,
paddingLeft: platform.ui?.appHeader?.paddingLeft?.value,
@@ -15,9 +15,30 @@
:label="t('app.name')"
to="/"
/>
<AppGitHubStarButton class="mt-1.5 transition <sm:hidden" />
<!-- <AppGitHubStarButton class="mt-1.5 transition" /> -->
</div>
<div class="inline-flex items-center space-x-2">
<div class="inline-flex items-center justify-center flex-1 space-x-2">
<AppNavigation v-if="mdAndLarger" />
<div
class="bg-primaryDark max-w-128 text-secondaryLight justify-between cursor-pointer rounded border border-dividerDark hover:border-dividerDark hover:bg-primaryLight hover:text-secondary focus-visible:border-dividerDark focus-visible:bg-primaryLight focus-visible:text-secondary focus:outline-none transition flex flex-1 items-center px-2 py-1.25"
tabindex="0"
@click="invokeAction('modals.search.toggle')"
>
<span class="inline-flex">
<icon-lucide-search class="mr-2 svg-icons" />
{{ t("app.search") }}
</span>
<kbd class="shortcut-key">/</kbd>
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${
mdAndLarger ? t('support.title') : t('app.options')
} <kbd>?</kbd>`"
:icon="IconHelpCircle"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.support.toggle')"
/>
<HoppButtonSecondary
v-if="showInstallButton"
v-tippy="{ theme: 'tooltip' }"
@@ -26,44 +47,83 @@
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="installPWA()"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="`${t('app.search')} <kbd>/</kbd>`"
:icon="IconSearch"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="invokeAction('modals.search.toggle')"
/>
<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')"
/>
<HoppButtonSecondary
</div>
<div class="inline-flex items-center justify-end flex-1 space-x-2">
<div
v-if="currentUser === null"
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
filled
class="hidden md:flex"
@click="invokeAction('modals.login.toggle')"
/>
<HoppButtonPrimary
v-if="currentUser === null"
:label="t('header.login')"
@click="invokeAction('modals.login.toggle')"
/>
<div v-else class="inline-flex items-center space-x-2">
<HoppButtonPrimary
v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')"
:label="t('team.invite')"
:icon="IconUserPlus"
class="!bg-green-500 !bg-opacity-15 !text-green-500 !hover:bg-opacity-10 !hover:bg-green-400 !hover:text-green-600"
@click="showTeamsModal = true"
class="inline-flex items-center space-x-2"
>
<HoppButtonSecondary
:icon="IconUploadCloud"
:label="t('header.save_workspace')"
class="hidden md:flex bg-green-500/15 py-1.75 border border-green-600/25 !text-green-500 hover:bg-green-400/10 focus-visible:bg-green-400/10 focus-visible:border-green-800/50 !focus-visible:text-green-600 hover:border-green-800/50 !hover:text-green-600"
@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-if="
workspace.type === 'team' &&
selectedTeam &&
selectedTeam.teamMembers.length > 1
"
:team-members="selectedTeam.teamMembers"
show-count
class="mx-2"
@handle-click="handleTeamEdit()"
/>
<div
class="flex border divide-x rounded bg-green-500/15 divide-green-600/25 border-green-600/25 focus-within:bg-green-400/10 focus-within:border-green-800/50 focus-within:divide-green-800/50 hover:bg-green-400/10 hover:border-green-800/50 hover:divide-green-800/50"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('team.invite_tooltip')"
:icon="IconUserPlus"
class="py-1.75 !text-green-500 !focus-visible:text-green-600 !hover:text-green-600"
@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 !text-green-500 !focus-visible:text-green-600 !hover:text-green-600"
@click="handleTeamEdit()"
/>
</div>
<tippy
interactive
trigger="click"
theme="popover"
:on-shown="() => accountActions.focus()"
>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('workspace.change')"
:label="mdAndLarger ? workspaceName : ``"
:icon="workspace.type === 'personal' ? IconUser : IconUsers"
class="pr-8 select-wrapper rounded bg-blue-500/15 py-1.75 border border-blue-600/25 !text-blue-500 focus-visible:bg-blue-400/10 focus-visible:border-blue-800/50 !focus-visible:text-blue-600 hover:bg-blue-400/10 hover:border-blue-800/50 !hover:text-blue-600"
/>
<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
@@ -157,24 +217,42 @@
</header>
<AppAnnouncement v-if="!network.isOnline" />
<TeamsModal :show="showTeamsModal" @hide-modal="showTeamsModal = false" />
<TeamsInvite
v-if="workspace.type === 'team' && workspace.teamID"
:show="showModalInvite"
:editing-team-i-d="editingTeamID"
@hide-modal="displayModalInvite(false)"
/>
<TeamsEdit
:show="showModalEdit"
:editing-team="editingTeamName"
:editing-team-i-d="editingTeamID"
@hide-modal="displayModalEdit(false)"
@invite-team="inviteTeam(editingTeamName, editingTeamID)"
@refetch-teams="refetchTeams"
/>
</div>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from "vue"
import { computed, reactive, ref, watch } from "vue"
import IconUser from "~icons/lucide/user"
import IconUsers from "~icons/lucide/users"
import IconSettings from "~icons/lucide/settings"
import IconDownload from "~icons/lucide/download"
import IconSearch from "~icons/lucide/search"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUserPlus from "~icons/lucide/user-plus"
import IconHelpCircle from "~icons/lucide/help-circle"
import { breakpointsTailwind, useBreakpoints, useNetwork } from "@vueuse/core"
import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { invokeAction } from "@helpers/actions"
import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { onLoggedIn } from "~/composables/auth"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
const t = useI18n()
@@ -198,9 +276,108 @@ const currentUser = useReadonlyStream(
platform.auth.getProbableUser()
)
const selectedTeam = ref<GetMyTeamsQuery["myTeams"][number] | undefined>()
// TeamList-Adapter
const teamListAdapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
const workspaceName = computed(() =>
workspace.value.type === "personal"
? t("workspace.personal")
: workspace.value.teamName
)
const refetchTeams = () => {
teamListAdapter.fetchList()
}
onLoggedIn(() => {
!teamListAdapter.isInitialized && teamListAdapter.initialize()
})
watch(
() => myTeams.value,
(newTeams) => {
if (newTeams && workspace.value.type === "team" && workspace.value.teamID) {
const team = newTeams.find((team) => team.id === workspace.value.teamID)
if (team) {
selectedTeam.value = team
// Update the workspace name if it's not the same as the updated team name
if (team.name !== workspace.value.teamName) {
updateWorkspaceTeamName(workspace.value, team.name)
}
}
}
}
)
watch(
() => workspace.value,
(newWorkspace) => {
if (newWorkspace.type === "team") {
const team = myTeams.value?.find((t) => t.id === newWorkspace.teamID)
if (team) {
selectedTeam.value = team
}
}
}
)
const showModalInvite = ref(false)
const showModalEdit = ref(false)
const editingTeamName = ref<{ name: string }>({ name: "" })
const editingTeamID = ref("")
const displayModalInvite = (show: boolean) => {
showModalInvite.value = show
}
const displayModalEdit = (show: boolean) => {
showModalEdit.value = show
teamListAdapter.fetchList()
}
const inviteTeam = (team: { name: string }, teamID: string) => {
editingTeamName.value = team
editingTeamID.value = teamID
displayModalInvite(true)
}
// Show the workspace selected team invite modal if the user is an owner of the team else show the default invite modal
const handleInvite = () => {
if (
workspace.value.type === "team" &&
workspace.value.teamID &&
selectedTeam.value?.myRole === "OWNER"
) {
editingTeamID.value = workspace.value.teamID
displayModalInvite(true)
} else {
showTeamsModal.value = true
}
}
// Show the workspace selected team edit modal if the user is an owner of the team
const handleTeamEdit = () => {
if (
workspace.value.type === "team" &&
workspace.value.teamID &&
selectedTeam.value?.myRole === "OWNER"
) {
editingTeamID.value = workspace.value.teamID
editingTeamName.value = { name: selectedTeam.value.name }
displayModalEdit(true)
}
}
// Template refs
const tippyActions = ref<any | null>(null)
const profile = ref<any | null>(null)
const settings = ref<any | null>(null)
const logout = ref<any | null>(null)
const accountActions = ref<any | null>(null)
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex items-center justify-between">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
:title="`${t(
'action.go_back'
)} <kbd>${getSpecialKey()}</kbd><kbd>←</kbd>`"
:icon="IconArrowLeft"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="router.go(-1)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', delay: [500, 20], allowHTML: true }"
:title="`${t(
'action.go_forward'
)} <kbd>${getSpecialKey()}</kbd><kbd>→</kbd>`"
:icon="IconArrowRight"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
@click="router.go(1)"
/>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useRouter } from "vue-router"
import { getPlatformSpecialKey as getSpecialKey } from "~/helpers/platformutils"
import IconArrowLeft from "~icons/lucide/arrow-left"
import IconArrowRight from "~icons/lucide/arrow-right"
const t = useI18n()
const router = useRouter()
</script>

View File

@@ -145,7 +145,7 @@ import IconActivity from "~icons/lucide/activity"
import IconLock from "~icons/lucide/lock"
import IconDiscord from "~icons/brands/discord"
import IconTwitter from "~icons/brands/twitter"
import IconGithub from "~icons/hopp/github"
import IconGithub from "~icons/lucide/github"
import IconMessageCircle from "~icons/lucide/message-circle"
import IconUserPlus from "~icons/lucide/user-plus"
import IconShare2 from "~icons/lucide/share-2"

View File

@@ -82,7 +82,7 @@
<script setup lang="ts">
import IconTwitter from "~icons/brands/twitter"
import IconDiscord from "~icons/brands/discord"
import IconGitHub from "~icons/hopp/github"
import IconGitHub from "~icons/lucide/github"
import IconMessageCircle from "~icons/lucide/message-circle"
import IconGift from "~icons/lucide/gift"
import IconZap from "~icons/lucide/zap"

View File

@@ -1,138 +1,160 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div class="flex flex-col">
<div
class="flex items-stretch group"
@dragover.prevent
@drop.prevent="dropEvent"
@dragover="dragging = true"
@drop="dragging = false"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options?.tippy.show()"
>
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="emit('toggle-children')"
class="h-1 w-full transition"
:class="[
{
'bg-accentDark': ordering && notSameDestination,
},
]"
@drop="orderUpdateCollectionEvent"
@dragover.prevent="ordering = true"
@dragleave="ordering = false"
@dragend="resetDragState"
></div>
<div class="flex flex-col relative">
<div
class="absolute bg-accent opacity-0 pointer-events-none inset-0 z-1 transition"
:class="{
'opacity-25': dragging && notSameDestination,
}"
></div>
<div
class="flex items-stretch group relative z-3"
:draggable="!hasNoTeamAccess"
@dragstart="dragStart"
@drop="dropEvent"
@dragover="dragging = true"
@dragleave="dragging = false"
@dragend="resetDragState"
@contextmenu.prevent="options?.tippy.show()"
>
<component
:is="collectionIcon"
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
<span
class="flex flex-1 min-w-0 py-2 pr-2 transition cursor-pointer group-hover:text-secondaryDark"
@click="emit('toggle-children')"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collectionName }}
<span
class="flex items-center justify-center px-4 cursor-pointer"
@click="emit('toggle-children')"
>
<HoppSmartSpinner v-if="isCollLoading" />
<component
:is="collectionIcon"
v-else
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
</span>
</span>
<div v-if="!hasNoTeamAccess" 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']"
:loading="exportLoading"
@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
class="flex flex-1 min-w-0 py-2 pr-2 transition cursor-pointer group-hover:text-secondaryDark"
@click="emit('toggle-children')"
>
<span class="truncate" :class="{ 'text-accent': isSelected }">
{{ collectionName }}
</span>
</span>
<div v-if="!hasNoTeamAccess" class="flex">
<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']"
:loading="exportLoading"
@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>
@@ -160,6 +182,11 @@ type FolderType = "collection" | "folder"
const t = useI18n()
const props = defineProps({
id: {
type: String,
default: "",
required: true,
},
data: {
type: Object as PropType<HoppCollection<HoppRESTRequest> | TeamCollection>,
default: () => ({}),
@@ -185,7 +212,7 @@ const props = defineProps({
required: true,
},
isSelected: {
type: Boolean,
type: Boolean as PropType<boolean | null>,
default: false,
required: false,
},
@@ -199,6 +226,11 @@ const props = defineProps({
default: false,
required: false,
},
collectionMoveLoading: {
type: Array as PropType<string[]>,
default: () => [],
required: false,
},
})
const emit = defineEmits<{
@@ -209,6 +241,9 @@ const emit = defineEmits<{
(event: "export-data"): void
(event: "remove-collection"): void
(event: "drop-event", payload: DataTransfer): void
(event: "drag-event", payload: DataTransfer): void
(event: "dragging", payload: boolean): void
(event: "update-collection-order", payload: DataTransfer): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
@@ -220,6 +255,21 @@ const exportAction = ref<HTMLButtonElement | null>(null)
const options = ref<TippyComponent | null>(null)
const dragging = ref(false)
const ordering = ref(false)
const dropItemID = ref("")
// 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) {
emit("dragging", true)
} else {
emit("dragging", false)
}
}
)
const collectionIcon = computed(() => {
if (props.isSelected) return IconCheckCircle
@@ -243,10 +293,47 @@ watch(
}
)
const dropEvent = ({ dataTransfer }: DragEvent) => {
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
emit("drag-event", dataTransfer)
dropItemID.value = dataTransfer.getData("collectionIndex")
dragging.value = !dragging.value
emit("drop-event", dataTransfer)
}
}
const dropEvent = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
emit("drop-event", e.dataTransfer)
dragging.value = !dragging.value
dropItemID.value = ""
}
}
const orderUpdateCollectionEvent = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
emit("update-collection-order", e.dataTransfer)
ordering.value = !ordering.value
dropItemID.value = ""
}
}
const notSameDestination = computed(() => {
return dropItemID.value !== props.id
})
const isCollLoading = computed(() => {
if (props.collectionMoveLoading.length > 0 && props.data.id) {
return props.collectionMoveLoading.includes(props.data.id)
} else {
return false
}
})
const resetDragState = () => {
dragging.value = false
ordering.value = false
dropItemID.value = ""
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col flex-1">
<div class="flex flex-col flex-1 bg-primary">
<div
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
:style="
@@ -33,9 +33,12 @@
</div>
<div class="flex flex-col flex-1">
<SmartTree :adapter="myAdapter">
<template #content="{ node, toggleChildren, isOpen }">
<template
#content="{ node, toggleChildren, isOpen, highlightChildren }"
>
<CollectionsCollection
v-if="node.data.type === 'collections'"
:id="node.id"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
@@ -72,6 +75,11 @@
"
@remove-collection="emit('remove-collection', node.id)"
@drop-event="dropEvent($event, node.id)"
@drag-event="dragEvent($event, node.id)"
@update-collection-order="updateCollectionOrder($event, node.id)"
@dragging="
(isDraging) => highlightChildren(isDraging ? node.id : null)
"
@toggle-children="
() => {
toggleChildren(),
@@ -85,6 +93,7 @@
/>
<CollectionsCollection
v-if="node.data.type === 'folders'"
:id="node.id"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
@@ -121,6 +130,11 @@
"
@remove-collection="emit('remove-folder', node.id)"
@drop-event="dropEvent($event, node.id)"
@drag-event="dragEvent($event, node.id)"
@update-collection-order="updateCollectionOrder($event, node.id)"
@dragging="
(isDraging) => highlightChildren(isDraging ? node.id : null)
"
@toggle-children="
() => {
toggleChildren(),
@@ -182,7 +196,13 @@
@drag-request="
dragRequest($event, {
folderPath: node.data.data.parentIndex,
requestIndex: pathToIndex(node.id),
requestIndex: node.id,
})
"
@update-request-order="
updateRequestOrder($event, {
folderPath: node.data.data.parentIndex,
requestIndex: node.id,
})
"
/>
@@ -413,7 +433,29 @@ const emit = defineEmits<{
payload: {
folderPath: string
requestIndex: string
collectionIndex: string
destinationCollectionIndex: string
}
): void
(
event: "drop-collection",
payload: {
collectionIndexDragged: string
destinationCollectionIndex: string
}
): void
(
event: "update-request-order",
payload: {
dragedRequestIndex: string
destinationRequestIndex: string
destinationCollectionIndex: string
}
): void
(
event: "update-collection-order",
payload: {
dragedCollectionIndex: string
destinationCollectionIndex: string
}
): void
(event: "select", payload: Picked | null): void
@@ -502,6 +544,10 @@ const selectRequest = (data: {
}
}
const dragEvent = (dataTransfer: DataTransfer, collectionIndex: string) => {
dataTransfer.setData("collectionIndex", collectionIndex)
}
const dragRequest = (
dataTransfer: DataTransfer,
{
@@ -514,13 +560,56 @@ const dragRequest = (
dataTransfer.setData("requestIndex", requestIndex)
}
const dropEvent = (dataTransfer: DataTransfer, collectionIndex: string) => {
const dropEvent = (
dataTransfer: DataTransfer,
destinationCollectionIndex: string
) => {
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
emit("drop-request", {
const collectionIndexDragged = dataTransfer.getData("collectionIndex")
if (folderPath && requestIndex) {
emit("drop-request", {
folderPath,
requestIndex,
destinationCollectionIndex,
})
} else {
emit("drop-collection", {
collectionIndexDragged,
destinationCollectionIndex,
})
}
}
const updateRequestOrder = (
dataTransfer: DataTransfer,
{
folderPath,
requestIndex,
collectionIndex,
}: { folderPath: string | null; requestIndex: string }
) => {
if (!folderPath) return
const dragedRequestIndex = dataTransfer.getData("requestIndex")
const destinationRequestIndex = requestIndex
const destinationCollectionIndex = folderPath
emit("update-request-order", {
dragedRequestIndex,
destinationRequestIndex,
destinationCollectionIndex,
})
}
const updateCollectionOrder = (
dataTransfer: DataTransfer,
destinationCollectionIndex: string
) => {
const dragedCollectionIndex = dataTransfer.getData("collectionIndex")
emit("update-collection-order", {
dragedCollectionIndex,
destinationCollectionIndex,
})
}

View File

@@ -1,10 +1,22 @@
<template>
<div class="flex flex-col" :class="[{ 'bg-primaryLight': dragging }]">
<div class="flex flex-col">
<div
class="h-1"
:class="[
{
'bg-accentDark': ordering,
},
]"
@drop="dropEvent"
@dragover.prevent="ordering = true"
@dragleave="ordering = false"
@dragend="ordering = false"
></div>
<div
class="flex items-stretch group"
draggable="true"
:draggable="!hasNoTeamAccess"
@dragstart="dragStart"
@dragover.stop
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@dragend="dragging = false"
@contextmenu.prevent="options?.tippy.show()"
@@ -20,6 +32,7 @@
class="svg-icons"
:class="{ 'text-accent': isSelected }"
/>
<HoppSmartSpinner v-else-if="isRequestLoading" />
<span v-else class="font-semibold truncate text-tiny">
{{ request.method }}
</span>
@@ -149,6 +162,11 @@ const props = defineProps({
default: () => ({}),
required: true,
},
requestID: {
type: String,
default: "",
required: false,
},
collectionsType: {
type: String as PropType<CollectionType>,
default: "my-collections",
@@ -175,10 +193,15 @@ const props = defineProps({
required: false,
},
isSelected: {
type: Boolean,
type: Boolean as PropType<boolean | null>,
default: false,
required: false,
},
requestMoveLoading: {
type: Array as PropType<string[]>,
default: () => [],
required: false,
},
})
const emit = defineEmits<{
@@ -187,6 +210,7 @@ const emit = defineEmits<{
(event: "remove-request"): void
(event: "select-request"): void
(event: "drag-request", payload: DataTransfer): void
(event: "update-request-order", payload: DataTransfer): void
}>()
const tippyActions = ref<TippyComponent | null>(null)
@@ -196,6 +220,7 @@ const options = ref<TippyComponent | null>(null)
const duplicate = ref<HTMLButtonElement | null>(null)
const dragging = ref(false)
const ordering = ref(false)
const requestMethodLabels = {
get: "text-green-500",
@@ -228,8 +253,24 @@ const selectRequest = () => {
const dragStart = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
dragging.value = !dragging.value
emit("drag-request", dataTransfer)
dragging.value = !dragging.value
}
}
const dropEvent = (e: DragEvent) => {
if (e.dataTransfer) {
e.stopPropagation()
ordering.value = !ordering.value
emit("update-request-order", e.dataTransfer)
}
}
const isRequestLoading = computed(() => {
if (props.requestMoveLoading.length > 0 && props.requestID) {
return props.requestMoveLoading.includes(props.requestID)
} else {
return false
}
})
</script>

View File

@@ -1,11 +1,11 @@
<template>
<div class="flex flex-col flex-1">
<div class="flex flex-col flex-1 bg-primary">
<div
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
:style="
saveRequest
? 'top: calc(var(--upper-secondary-sticky-fold) - var(--line-height-body))'
: 'top: var(--upper-secondary-sticky-fold)'
? 'top: calc(var(--upper-primary-sticky-fold) - var(--line-height-body))'
: 'top: var(--upper-primary-sticky-fold)'
"
>
<HoppButtonSecondary
@@ -47,14 +47,18 @@
</div>
<div class="flex flex-col overflow-hidden">
<SmartTree :adapter="teamAdapter">
<template #content="{ node, toggleChildren, isOpen }">
<template
#content="{ node, toggleChildren, isOpen, highlightChildren }"
>
<CollectionsCollection
v-if="node.data.type === 'collections'"
:id="node.data.data.data.id"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess"
:collection-move-loading="collectionMoveLoading"
:is-selected="
isSelected({
collectionID: node.id,
@@ -87,6 +91,15 @@
emit('export-data', node.data.data.data)
"
@remove-collection="emit('remove-collection', node.id)"
@drop-event="dropEvent($event, node.id)"
@drag-event="dragEvent($event, node.id)"
@update-collection-order="
updateCollectionOrder($event, node.data.data.data.id)
"
@dragging="
(isDraging) =>
highlightChildren(isDraging ? node.data.data.data.id : null)
"
@toggle-children="
() => {
toggleChildren(),
@@ -100,11 +113,13 @@
/>
<CollectionsCollection
v-if="node.data.type === 'folders'"
:id="node.data.data.data.id"
:data="node.data.data.data"
:collections-type="collectionsType.type"
:is-open="isOpen"
:export-loading="exportLoading"
:has-no-team-access="hasNoTeamAccess"
:collection-move-loading="collectionMoveLoading"
:is-selected="
isSelected({
folderID: node.data.data.data.id,
@@ -139,6 +154,15 @@
node.data.type === 'folders' &&
emit('remove-folder', node.data.data.data.id)
"
@drop-event="dropEvent($event, node.data.data.data.id)"
@drag-event="dragEvent($event, node.data.data.data.id)"
@update-collection-order="
updateCollectionOrder($event, node.data.data.data.id)
"
@dragging="
(isDraging) =>
highlightChildren(isDraging ? node.data.data.data.id : null)
"
@toggle-children="
() => {
toggleChildren(),
@@ -153,10 +177,12 @@
<CollectionsRequest
v-if="node.data.type === 'requests'"
:request="node.data.data.data.request"
:request-i-d="node.data.data.data.id"
:collections-type="collectionsType.type"
:duplicate-loading="duplicateLoading"
:is-active="isActiveRequest(node.data.data.data.id)"
:has-no-team-access="hasNoTeamAccess"
:request-move-loading="requestMoveLoading"
:is-selected="
isSelected({
requestID: node.data.data.data.id,
@@ -190,18 +216,31 @@
requestIndex: node.data.data.data.id,
})
"
@drag-request="
dragRequest($event, {
folderPath: node.data.data.parentIndex,
requestIndex: node.data.data.data.id,
})
"
@update-request-order="
updateRequestOrder($event, {
folderPath: node.data.data.parentIndex,
requestIndex: node.data.data.data.id,
})
"
/>
</template>
<template #emptyNode="{ node }">
<div v-if="node === null">
<div
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
@drop="(e) => e.stopPropagation()"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-4"
:alt="`${t('empty.collections')}`"
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collections") }}
@@ -213,11 +252,12 @@
filled
outline
:title="t('team.no_access')"
:label="t('add.new')"
:label="t('action.new')"
/>
<HoppButtonSecondary
v-else
:label="t('add.new')"
:icon="IconPlus"
:label="t('action.new')"
filled
outline
@click="emit('display-modal-add')"
@@ -227,6 +267,7 @@
<div
v-else-if="node.data.type === 'collections'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
@drop="(e) => e.stopPropagation()"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
@@ -235,34 +276,13 @@
:alt="`${t('empty.collection')}`"
/>
<span class="pb-4 text-center">
{{ t("empty.collection") }}
{{ t("empty.collections") }}
</span>
<HoppButtonSecondary
v-if="hasNoTeamAccess"
v-tippy="{ theme: 'tooltip' }"
disabled
filled
outline
:title="t('team.no_access')"
:label="t('add.new')"
/>
<HoppButtonSecondary
v-else
:label="t('add.new')"
filled
outline
@click="
node.data.type === 'collections' &&
emit('add-folder', {
path: node.id,
folder: node.data.data.data,
})
"
/>
</div>
<div
v-else-if="node.data.type === 'folders'"
class="flex flex-col items-center justify-center p-4 text-secondaryLight"
@drop="(e) => e.stopPropagation()"
>
<img
:src="`/images/states/${colorMode.value}/pack.svg`"
@@ -347,6 +367,16 @@ const props = defineProps({
default: null,
required: false,
},
collectionMoveLoading: {
type: Array as PropType<string[]>,
default: () => [],
required: false,
},
requestMoveLoading: {
type: Array as PropType<string[]>,
default: () => [],
required: false,
},
})
const emit = defineEmits<{
@@ -410,6 +440,36 @@ const emit = defineEmits<{
folderPath?: string | undefined
}
): void
(
event: "drop-request",
payload: {
folderPath: string
requestIndex: string
destinationCollectionIndex: string
}
): void
(
event: "drop-collection",
payload: {
collectionIndexDragged: string
destinationCollectionIndex: string
}
): void
(
event: "update-request-order",
payload: {
dragedRequestIndex: string
destinationRequestIndex: string
destinationCollectionIndex: string
}
): void
(
event: "update-collection-order",
payload: {
dragedCollectionIndex: string
destinationCollectionIndex: string
}
): void
(event: "select", payload: Picked | null): void
(event: "expand-team-collection", payload: string): void
(event: "display-modal-add"): void
@@ -493,6 +553,74 @@ const selectRequest = (data: {
}
}
const dragRequest = (
dataTransfer: DataTransfer,
{
folderPath,
requestIndex,
}: { folderPath: string | null; requestIndex: string }
) => {
if (!folderPath) return
dataTransfer.setData("folderPath", folderPath)
dataTransfer.setData("requestIndex", requestIndex)
}
const dragEvent = (dataTransfer: DataTransfer, collectionIndex: string) => {
dataTransfer.setData("collectionIndex", collectionIndex)
}
const dropEvent = (
dataTransfer: DataTransfer,
destinationCollectionIndex: string
) => {
const folderPath = dataTransfer.getData("folderPath")
const requestIndex = dataTransfer.getData("requestIndex")
const collectionIndexDragged = dataTransfer.getData("collectionIndex")
if (folderPath && requestIndex) {
emit("drop-request", {
folderPath,
requestIndex,
destinationCollectionIndex,
})
} else {
emit("drop-collection", {
collectionIndexDragged,
destinationCollectionIndex,
})
}
}
const updateRequestOrder = (
dataTransfer: DataTransfer,
{
folderPath,
requestIndex,
}: { folderPath: string | null; requestIndex: string }
) => {
if (!folderPath) return
const dragedRequestIndex = dataTransfer.getData("requestIndex")
const destinationRequestIndex = requestIndex
const destinationCollectionIndex = folderPath
emit("update-request-order", {
dragedRequestIndex,
destinationRequestIndex,
destinationCollectionIndex,
})
}
const updateCollectionOrder = (
dataTransfer: DataTransfer,
destinationCollectionIndex: string
) => {
const dragedCollectionIndex = dataTransfer.getData("collectionIndex")
emit("update-collection-order", {
dragedCollectionIndex,
destinationCollectionIndex,
})
}
type TeamCollections = {
type: "collections"
data: {

View File

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

View File

@@ -1,11 +1,22 @@
<template>
<div :class="{ 'rounded border border-divider': saveRequest }">
<div
:class="{
'rounded border border-divider': saveRequest,
'bg-primaryDark': draggingToRoot,
}"
class="flex-1"
@drop.prevent="dropToRoot"
@dragover.prevent="draggingToRoot = true"
@dragend="draggingToRoot = false"
>
<div
class="sticky z-10 flex flex-col flex-shrink-0 overflow-x-auto rounded-t bg-primary"
class="sticky z-10 flex flex-col flex-shrink-0 overflow-x-auto border-b bg-primary border-dividerLight"
:class="{ 'rounded-t': saveRequest }"
:style="
saveRequest ? 'top: calc(-1 * var(--line-height-body))' : 'top: 0'
"
>
<WorkspaceCurrent :section="t('tab.collections')" />
<input
v-model="filterTexts"
type="search"
@@ -15,92 +26,69 @@
:disabled="collectionsType.type === 'team-collections'"
/>
</div>
<HoppSmartTabs
v-model="selectedCollectionTab"
render-inactive-tabs
:styles="`
sticky overflow-x-auto border-y bg-primary border-dividerLight flex-shrink-0 z-10
${
saveRequest
? 'top-sidebarSecondaryStickyFold'
: 'top-sidebarPrimaryStickyFold'
}
`"
<CollectionsMyCollections
v-if="collectionsType.type === 'my-collections'"
:collections-type="collectionsType"
:filtered-collections="filteredCollections"
:filter-text="filterTexts"
:save-request="saveRequest"
:picked="picked"
@add-folder="addFolder"
@add-request="addRequest"
@edit-collection="editCollection"
@edit-folder="editFolder"
@export-data="exportData"
@remove-collection="removeCollection"
@remove-folder="removeFolder"
@drop-collection="dropCollection"
@update-request-order="updateRequestOrder"
@update-collection-order="updateCollectionOrder"
@edit-request="editRequest"
@duplicate-request="duplicateRequest"
@remove-request="removeRequest"
@select-request="selectRequest"
@select="selectPicked"
@drop-request="dropRequest"
@display-modal-add="displayModalAdd(true)"
@display-modal-import-export="displayModalImportExport(true)"
/>
<CollectionsTeamCollections
v-else
:collections-type="collectionsType"
:team-collection-list="teamCollectionList"
:team-loading-collections="teamLoadingCollections"
:export-loading="exportLoading"
:duplicate-loading="duplicateLoading"
:save-request="saveRequest"
:picked="picked"
:collection-move-loading="collectionMoveLoading"
:request-move-loading="requestMoveLoading"
@add-request="addRequest"
@add-folder="addFolder"
@edit-collection="editCollection"
@edit-folder="editFolder"
@export-data="exportData"
@remove-collection="removeCollection"
@remove-folder="removeFolder"
@edit-request="editRequest"
@duplicate-request="duplicateRequest"
@remove-request="removeRequest"
@select-request="selectRequest"
@select="selectPicked"
@drop-request="dropRequest"
@drop-collection="dropCollection"
@update-request-order="updateRequestOrder"
@update-collection-order="updateCollectionOrder"
@expand-team-collection="expandTeamCollection"
@display-modal-add="displayModalAdd(true)"
@display-modal-import-export="displayModalImportExport(true)"
/>
<div
class="hidden bg-primaryDark flex-col flex-1 items-center py-15 justify-center px-4 text-secondaryLight"
:class="{ '!flex': draggingToRoot }"
>
<HoppSmartTab
:id="'my-collections'"
:label="`${t('collection.my_collections')}`"
>
<CollectionsMyCollections
:collections-type="collectionsType"
:filtered-collections="filteredCollections"
:filter-text="filterTexts"
:save-request="saveRequest"
:picked="picked"
@add-folder="addFolder"
@add-request="addRequest"
@edit-collection="editCollection"
@edit-folder="editFolder"
@export-data="exportData"
@remove-collection="removeCollection"
@remove-folder="removeFolder"
@edit-request="editRequest"
@duplicate-request="duplicateRequest"
@remove-request="removeRequest"
@select-request="selectRequest"
@select="selectPicked"
@drop-request="dropRequest"
@display-modal-add="displayModalAdd(true)"
@display-modal-import-export="displayModalImportExport(true)"
/>
</HoppSmartTab>
<HoppSmartTab
:id="'team-collections'"
:label="`${t('collection.team_collections')}`"
>
<div
class="sticky z-10 flex flex-1 bg-primary"
:style="
saveRequest
? 'top: calc(var(--upper-primary-sticky-fold) - var(--line-height-body))'
: 'top: var(--upper-primary-sticky-fold)'
"
>
<CollectionsTeamSelect
:collections-type="collectionsType"
:my-teams="myTeams"
:is-team-list-loading="isTeamListLoading"
@update-selected-team="updateSelectedTeam"
@team-select-intersect="onTeamSelectIntersect"
@display-team-modal-add="displayTeamModalAdd(true)"
/>
</div>
<CollectionsTeamCollections
:collections-type="collectionsType"
:team-collection-list="teamCollectionList"
:team-loading-collections="teamLoadingCollections"
:export-loading="exportLoading"
:duplicate-loading="duplicateLoading"
:save-request="saveRequest"
:picked="picked"
@add-request="addRequest"
@add-folder="addFolder"
@edit-collection="editCollection"
@edit-folder="editFolder"
@export-data="exportData"
@remove-collection="removeCollection"
@remove-folder="removeFolder"
@edit-request="editRequest"
@duplicate-request="duplicateRequest"
@remove-request="removeRequest"
@select-request="selectRequest"
@select="selectPicked"
@expand-team-collection="expandTeamCollection"
@display-modal-add="displayModalAdd(true)"
@display-modal-import-export="displayModalImportExport(true)"
/>
</HoppSmartTab>
</HoppSmartTabs>
<component :is="IconListEnd" class="svg-icons !w-8 !h-8" />
</div>
<CollectionsAdd
:show="showModalAdd"
:loading-state="modalLoadingState"
@@ -178,7 +166,7 @@
</template>
<script setup lang="ts">
import { computed, PropType, reactive, ref, watch, nextTick } from "vue"
import { computed, PropType, reactive, ref, watch } from "vue"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { Picked } from "~/helpers/types/HoppPicked"
@@ -195,12 +183,15 @@ import {
editRESTCollection,
editRESTFolder,
editRESTRequest,
moveRESTFolder,
moveRESTRequest,
removeRESTCollection,
removeRESTFolder,
removeRESTRequest,
restCollections$,
saveRESTRequestAs,
updateRESTRequestOrder,
updateRESTCollectionOrder,
} from "~/newstore/collections"
import TeamCollectionAdapter from "~/helpers/teams/TeamCollectionAdapter"
import {
@@ -226,11 +217,15 @@ import {
renameCollection,
deleteCollection,
importJSONToTeam,
moveRESTTeamCollection,
updateOrderRESTTeamCollection,
} from "~/helpers/backend/mutations/TeamCollection"
import {
updateTeamRequest,
createRequestInCollection,
deleteTeamRequest,
moveRESTTeamRequest,
updateOrderRESTTeamRequest,
} from "~/helpers/backend/mutations/TeamRequest"
import { TeamCollection } from "~/helpers/teams/TeamCollection"
import { Collection as NodeCollection } from "./MyCollections.vue"
@@ -243,7 +238,8 @@ import { HoppRequestSaveContext } from "~/helpers/types/HoppRequestSaveContext"
import * as E from "fp-ts/Either"
import { platform } from "~/platform"
import { createCollectionGists } from "~/helpers/gist"
import { invokeAction } from "~/helpers/actions"
import { workspaceStatus$ } from "~/newstore/workspace"
import IconListEnd from "~icons/lucide/list-end"
const t = useI18n()
const toast = useToast()
@@ -267,8 +263,6 @@ const emit = defineEmits<{
(event: "update-collection-type", type: CollectionType["type"]): void
}>()
type CollectionTabs = "my-collections" | "team-collections"
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type CollectionType =
@@ -278,26 +272,11 @@ type CollectionType =
}
| { type: "my-collections"; selectedTeam: undefined }
const selectedCollectionTab = ref<CollectionTabs>("my-collections")
const collectionsType = ref<CollectionType>({
type: "my-collections",
selectedTeam: undefined,
})
watch(
() => selectedCollectionTab.value,
(tab) => {
if (tab === "team-collections" && !currentUser.value) {
invokeAction("modals.login.toggle")
nextTick(() => (selectedCollectionTab.value = "my-collections"))
} else {
collectionsType.value.type = tab
emit("update-collection-type", tab)
}
}
)
// Collection Data
const editingCollection = ref<
HoppCollection<HoppRESTRequest> | TeamCollection | null
@@ -324,6 +303,11 @@ const currentUser = useReadonlyStream(
)
const myCollections = useReadonlyStream(restCollections$, [], "deep")
// Draging
const draggingToRoot = ref(false)
const collectionMoveLoading = ref<string[]>([])
const requestMoveLoading = ref<string[]>([])
// Export - Import refs
const collectionJSON = ref("")
const exportingTeamCollections = ref(false)
@@ -342,7 +326,6 @@ const clickedRequest = reactive({
// TeamList-Adapter
const teamListAdapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const isTeamListLoading = useReadonlyStream(teamListAdapter.loading$, false)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
const teamListFetched = ref(false)
@@ -357,6 +340,19 @@ const teamLoadingCollections = useReadonlyStream(
[]
)
watch(
() => myTeams.value,
(newTeams) => {
if (newTeams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value && currentUser.value) {
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) updateSelectedTeam(team)
}
}
}
)
watch(
() => collectionsType.value.selectedTeam,
(newTeam) => {
@@ -366,45 +362,53 @@ watch(
}
)
const switchToMyCollections = () => {
collectionsType.value.type = "my-collections"
collectionsType.value.selectedTeam = undefined
teamCollectionAdapter.changeTeamID(null)
}
const expandTeamCollection = (collectionID: string) => {
teamCollectionAdapter.expandCollection(collectionID)
}
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) updateSelectedTeam(team)
}
}
})
const updateSelectedTeam = (team: SelectedTeam) => {
if (team) {
collectionsType.value.type = "team-collections"
collectionsType.value.selectedTeam = team
REMEMBERED_TEAM_ID.value = team.id
emit("update-team", team)
emit("update-collection-type", "team-collections")
}
}
onLoggedIn(() => {
teamListAdapter.initialize()
!teamListAdapter.isInitialized && teamListAdapter.initialize()
})
const onTeamSelectIntersect = () => {
// Load team data as soon as intersection
teamListAdapter.fetchList()
}
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
// Used to switch collection type and team when user switch workspace in the global workspace switcher
// Check if there is a teamID in the workspace, if yes, switch to team collection and select the team
// If there is no teamID, switch to my environment
watch(
() => workspace.value.teamID,
(teamID) => {
if (!teamID) {
switchToMyCollections()
} else if (teamID) {
const team = myTeams.value?.find((t) => t.id === teamID)
if (team) updateSelectedTeam(team)
}
}
)
// Switch to my-collections and reset the team collection when user logout
watch(
() => currentUser.value,
(user) => {
if (!user) {
selectedCollectionTab.value = "my-collections"
collectionsType.value.selectedTeam = undefined
teamCollectionAdapter.changeTeamID(null)
switchToMyCollections()
}
}
)
@@ -1333,16 +1337,314 @@ const discardRequestChange = () => {
confirmChangeToRequest.value = false
}
// Drag and drop functions
/**
* Used to get the index of the request from the path
* @param path The path of the request
* @returns The index of the request
*/
const pathToIndex = computed(() => {
return (path: string) => {
const pathArr = path.split("/")
return parseInt(pathArr[pathArr.length - 1])
}
})
/**
* This function is called when the user drops the request inside a collection
* @param payload Object that contains the folder path, request index and the destination collection index
*/
const dropRequest = (payload: {
folderPath: string
folderPath?: string | undefined
requestIndex: string
collectionIndex: string
destinationCollectionIndex: string
}) => {
const { folderPath, requestIndex, collectionIndex } = payload
moveRESTRequest(folderPath, parseInt(requestIndex), collectionIndex)
const { folderPath, requestIndex, destinationCollectionIndex } = payload
if (!requestIndex || !destinationCollectionIndex) return
if (collectionsType.value.type === "my-collections" && folderPath) {
moveRESTRequest(
folderPath,
pathToIndex.value(requestIndex),
destinationCollectionIndex
)
toast.success(`${t("request.moved")}`)
draggingToRoot.value = false
} else if (hasTeamWriteAccess.value) {
// add the request index to the loading array
requestMoveLoading.value.push(requestIndex)
pipe(
moveRESTTeamRequest(destinationCollectionIndex, requestIndex),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
requestMoveLoading.value.splice(
requestMoveLoading.value.indexOf(requestIndex),
1
)
},
() => {
// remove the request index from the loading array
requestMoveLoading.value.splice(
requestMoveLoading.value.indexOf(requestIndex),
1
)
toast.success(`${t("request.moved")}`)
}
)
)()
}
}
/**
* This function is called when the user moves the collection
* to a different collection or folder
* @param payload - object containing the collection index dragged and the destination collection index
*/
const dropCollection = (payload: {
collectionIndexDragged: string
destinationCollectionIndex: string
}) => {
const { collectionIndexDragged, destinationCollectionIndex } = payload
if (!collectionIndexDragged || !destinationCollectionIndex) return
if (collectionIndexDragged === destinationCollectionIndex) return
if (collectionsType.value.type === "my-collections") {
moveRESTFolder(collectionIndexDragged, destinationCollectionIndex)
draggingToRoot.value = false
toast.success(`${t("collection.moved")}`)
} else if (hasTeamWriteAccess.value) {
// add the collection index to the loading array
collectionMoveLoading.value.push(collectionIndexDragged)
pipe(
moveRESTTeamCollection(
collectionIndexDragged,
destinationCollectionIndex
),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
collectionMoveLoading.value.splice(
collectionMoveLoading.value.indexOf(collectionIndexDragged),
1
)
},
() => {
toast.success(`${t("collection.moved")}`)
// remove the collection index from the loading array
collectionMoveLoading.value.splice(
collectionMoveLoading.value.indexOf(collectionIndexDragged),
1
)
}
)
)()
}
}
/**
* Checks if the collection is already in the root
* @param id - path of the collection
* @returns boolean - true if the collection is already in the root
*/
const isAlreadyInRoot = computed(() => {
return (id: string) => {
const indexPath = id.split("/").map((i) => parseInt(i))
return indexPath.length === 1
}
})
/**
* This function is called when the user drops the collection
* to the root
* @param payload - object containing the collection index dragged
*/
const dropToRoot = ({ dataTransfer }: DragEvent) => {
if (dataTransfer) {
const collectionIndexDragged = dataTransfer.getData("collectionIndex")
if (!collectionIndexDragged) return
if (collectionsType.value.type === "my-collections") {
// check if the collection is already in the root
if (isAlreadyInRoot.value(collectionIndexDragged)) {
toast.error(`${t("collection.invalid_root_move")}`)
} else {
moveRESTFolder(collectionIndexDragged, null)
toast.success(`${t("collection.moved")}`)
}
draggingToRoot.value = false
} else if (hasTeamWriteAccess.value) {
// add the collection index to the loading array
collectionMoveLoading.value.push(collectionIndexDragged)
// destination collection index is null since we are moving to root
pipe(
moveRESTTeamCollection(collectionIndexDragged, null),
TE.match(
(err: GQLError<string>) => {
collectionMoveLoading.value.splice(
collectionMoveLoading.value.indexOf(collectionIndexDragged),
1
)
toast.error(`${getErrorMessage(err)}`)
},
() => {
// remove the collection index from the loading array
collectionMoveLoading.value.splice(
collectionMoveLoading.value.indexOf(collectionIndexDragged),
1
)
toast.success(`${t("collection.moved")}`)
}
)
)()
}
}
}
/**
* Used to check if the request/collection is being moved to the same parent since reorder is only allowed within the same parent
* @param draggedReq - path index of the dragged request
* @param destinationReq - path index of the destination request
* @returns boolean - true if the request is being moved to the same parent
*/
const isSameSameParent = computed(
() => (draggedReq: string, destinationReq: string) => {
const draggedReqIndex = draggedReq.split("/").map((i) => parseInt(i))
const destinationReqIndex = destinationReq
.split("/")
.map((i) => parseInt(i))
// length of 1 means the request is in the root
if (draggedReqIndex.length === 1 && destinationReqIndex.length === 1) {
return true
} else if (
draggedReqIndex[draggedReqIndex.length - 2] ===
destinationReqIndex[destinationReqIndex.length - 2]
) {
return true
} else {
return false
}
}
)
/**
* This function is called when the user updates the request order in a collection
* @param payload - object containing the request index dragged and the destination request index
* with the destination collection index
*/
const updateRequestOrder = (payload: {
dragedRequestIndex: string
destinationRequestIndex: string
destinationCollectionIndex: string
}) => {
const {
dragedRequestIndex,
destinationRequestIndex,
destinationCollectionIndex,
} = payload
if (
!dragedRequestIndex ||
!destinationRequestIndex ||
!destinationCollectionIndex
)
return
if (dragedRequestIndex === destinationRequestIndex) return
if (collectionsType.value.type === "my-collections") {
if (!isSameSameParent.value(dragedRequestIndex, destinationRequestIndex)) {
toast.error(`${t("collection.different_parent")}`)
} else {
updateRESTRequestOrder(
pathToIndex.value(dragedRequestIndex),
pathToIndex.value(destinationRequestIndex),
destinationCollectionIndex
)
toast.success(`${t("request.order_changed")}`)
}
} else if (hasTeamWriteAccess.value) {
// add the request index to the loading array
requestMoveLoading.value.push(dragedRequestIndex)
pipe(
updateOrderRESTTeamRequest(
dragedRequestIndex,
destinationRequestIndex,
destinationCollectionIndex
),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
requestMoveLoading.value.splice(
requestMoveLoading.value.indexOf(dragedRequestIndex),
1
)
},
() => {
toast.success(`${t("request.order_changed")}`)
// remove the request index from the loading array
requestMoveLoading.value.splice(
requestMoveLoading.value.indexOf(dragedRequestIndex),
1
)
}
)
)()
}
}
/**
* This function is called when the user updates the collection or folder order
* @param payload - object containing the collection index dragged and the destination collection index
*/
const updateCollectionOrder = (payload: {
dragedCollectionIndex: string
destinationCollectionIndex: string
}) => {
const { dragedCollectionIndex, destinationCollectionIndex } = payload
if (!dragedCollectionIndex || !destinationCollectionIndex) return
if (dragedCollectionIndex === destinationCollectionIndex) return
if (collectionsType.value.type === "my-collections") {
if (
!isSameSameParent.value(dragedCollectionIndex, destinationCollectionIndex)
) {
toast.error(`${t("collection.different_parent")}`)
} else {
updateRESTCollectionOrder(
dragedCollectionIndex,
destinationCollectionIndex
)
toast.success(`${t("collection.order_changed")}`)
}
} else if (hasTeamWriteAccess.value) {
collectionMoveLoading.value.push(dragedCollectionIndex)
pipe(
updateOrderRESTTeamCollection(
dragedCollectionIndex,
destinationCollectionIndex
),
TE.match(
(err: GQLError<string>) => {
toast.error(`${getErrorMessage(err)}`)
collectionMoveLoading.value.splice(
collectionMoveLoading.value.indexOf(dragedCollectionIndex),
1
)
},
() => {
toast.success(`${t("collection.order_changed")}`)
collectionMoveLoading.value.splice(
collectionMoveLoading.value.indexOf(dragedCollectionIndex),
1
)
}
)
)()
}
}
// Import - Export Collection functions
/**
* Export the whole my collection or specific team collection to JSON
@@ -1525,28 +1827,38 @@ const resetSelectedData = () => {
}
const getErrorMessage = (err: GQLError<string>) => {
console.error(err)
if (err.type === "network_error") {
console.error(err)
return t("error.network_error")
} else {
switch (err.error) {
case "team_coll/short_title":
console.error(err)
return t("collection.name_length_insufficient")
case "team/invalid_coll_id":
console.error(err)
return t("team.invalid_id")
case "bug/team_coll/no_coll_id":
case "team_req/invalid_target_id":
return t("team.invalid_coll_id")
case "team/not_required_role":
console.error(err)
return t("profile.no_permission")
case "team_req/not_required_role":
console.error(err)
return t("profile.no_permission")
case "Forbidden resource":
console.error(err)
return t("profile.no_permission")
case "team_req/not_found":
return t("team.no_request_found")
case "bug/team_req/no_req_id":
return t("team.no_request_found")
case "team/collection_is_parent_coll":
return t("team.parent_coll_move")
case "team/target_and_destination_collection_are_same":
return t("team.same_target_destination")
case "team/target_collection_is_already_root_collection":
return t("collection.invalid_root_move")
case "team_req/requests_not_from_same_collection":
return t("request.different_collection")
case "team/team_collections_have_different_parents":
return t("collection.different_parent")
default:
console.error(err)
return t("error.something_went_wrong")
}
}

View File

@@ -1,169 +0,0 @@
<template>
<div>
<HoppSmartTabs
:id="'environments_tab'"
v-model="selectedEnvironmentTab"
render-inactive-tabs
>
<HoppSmartTab
:id="'my-environments'"
:label="`${t('environment.my_environments')}`"
/>
<HoppSmartTab
:id="'team-environments'"
:label="`${t('environment.team_environments')}`"
>
<HoppSmartIntersection @intersecting="onTeamSelectIntersect">
<tippy
interactive
trigger="click"
theme="popover"
placement="bottom"
:on-shown="() => tippyActions.focus()"
>
<span
v-tippy="{ theme: 'tooltip' }"
:title="`${t('collection.select_team')}`"
class="bg-transparent border-b border-dividerLight select-wrapper"
>
<HoppButtonSecondary
v-if="environmentType.selectedTeam"
:icon="IconUsers"
:label="environmentType.selectedTeam.name"
class="flex-1 !justify-start pr-8 rounded-none"
/>
<HoppButtonSecondary
v-else
:label="`${t('collection.select_team')}`"
class="flex-1 !justify-start pr-8 rounded-none"
/>
</span>
<template #content="{ hide }">
<div
ref="tippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.escape="hide()"
>
<HoppSmartItem
v-for="(team, index) in myTeams"
:key="`team-${index}`"
:label="team.name"
:info-icon="
team.id === environmentType.selectedTeam?.id
? IconDone
: undefined
"
:active-info-icon="
team.id === environmentType.selectedTeam?.id
"
:icon="IconUsers"
@click="
() => {
updateSelectedTeam(team)
hide()
}
"
/>
</div>
</template>
</tippy>
</HoppSmartIntersection>
</HoppSmartTab>
</HoppSmartTabs>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref, watch } from "vue"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { onLoggedIn } from "@composables/auth"
import { platform } from "~/platform"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useReadonlyStream } from "@composables/stream"
import { useLocalState } from "~/newstore/localstate"
import { useI18n } from "@composables/i18n"
import IconDone from "~icons/lucide/check"
import IconUsers from "~icons/lucide/users"
import { invokeAction } from "~/helpers/actions"
const t = useI18n()
type TeamData = GetMyTeamsQuery["myTeams"][number]
type SelectedTeam = TeamData | undefined
type EnvironmentTabs = "my-environments" | "team-environments"
// Template refs
const tippyActions = ref<any | null>(null)
const selectedEnvironmentTab = ref<EnvironmentTabs>("my-environments")
defineProps<{
environmentType: {
type: "my-environments" | "team-environments"
selectedTeam: SelectedTeam
}
}>()
const emit = defineEmits<{
(e: "update-environment-type", tabID: EnvironmentTabs): void
(e: "update-selected-team", team: SelectedTeam): void
}>()
const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
const adapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(adapter.teamList$, null)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
let teamListFetched = false
watch(myTeams, (teams) => {
if (teams && !teamListFetched) {
teamListFetched = true
if (REMEMBERED_TEAM_ID.value && currentUser.value) {
const team = teams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) updateSelectedTeam(team)
}
}
})
watch(
() => currentUser.value,
(user) => {
if (!user) {
selectedEnvironmentTab.value = "my-environments"
}
}
)
onLoggedIn(() => {
try {
adapter.initialize()
} catch (e) {}
})
const onTeamSelectIntersect = () => {
// Load team data as soon as intersection
adapter.fetchList()
}
const updateEnvironmentType = (tabID: EnvironmentTabs) => {
emit("update-environment-type", tabID)
}
const updateSelectedTeam = (team: SelectedTeam) => {
REMEMBERED_TEAM_ID.value = team?.id
emit("update-selected-team", team)
}
watch(selectedEnvironmentTab, (newValue: EnvironmentTabs) => {
if (newValue === "team-environments" && !currentUser.value) {
invokeAction("modals.login.toggle")
nextTick(() => (selectedEnvironmentTab.value = "my-environments"))
} else updateEnvironmentType(newValue)
})
</script>

View File

@@ -1,8 +1,9 @@
<template>
<div>
<div
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto rounded-t bg-primary"
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto bg-primary"
>
<WorkspaceCurrent :section="t('tab.environments')" />
<tippy
v-if="environmentType.type === 'my-environments'"
interactive
@@ -144,7 +145,7 @@
v-if="!loading && adapterError"
class="flex flex-col items-center py-4"
>
<i class="mb-4 material-icons">help_outline</i>
<icon-lucide-help-circle class="mb-4 svg-icons" />
{{ getErrorMessage(adapterError) }}
</div>
</div>
@@ -156,11 +157,6 @@
class="border-b border-dividerLight"
@edit-environment="editEnvironment('Global')"
/>
<EnvironmentsChooseType
:environment-type="environmentType"
@update-environment-type="updateEnvironmentType"
@update-selected-team="updateSelectedTeam"
/>
</div>
<EnvironmentsMy v-if="environmentType.type === 'my-environments'" />
<EnvironmentsTeams
@@ -184,7 +180,7 @@
import { computed, ref, watch } from "vue"
import { isEqual } from "lodash-es"
import { platform } from "~/platform"
import { Team } from "~/helpers/backend/graphql"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { useReadonlyStream, useStream } from "@composables/stream"
import { useI18n } from "~/composables/i18n"
import {
@@ -198,12 +194,16 @@ import { GQLError } from "~/helpers/backend/GQLClient"
import IconCheck from "~icons/lucide/check"
import { TippyComponent } from "vue-tippy"
import { defineActionHandler } from "~/helpers/actions"
import { workspaceStatus$ } from "~/newstore/workspace"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useLocalState } from "~/newstore/localstate"
import { onLoggedIn } from "~/composables/auth"
const t = useI18n()
type EnvironmentType = "my-environments" | "team-environments"
type SelectedTeam = Team | undefined
type SelectedTeam = GetMyTeamsQuery["myTeams"][number] | undefined
type EnvironmentsChooseType = {
type: EnvironmentType
@@ -227,12 +227,11 @@ const currentUser = useReadonlyStream(
platform.auth.getCurrentUser()
)
const updateSelectedTeam = (newSelectedTeam: SelectedTeam) => {
environmentType.value.selectedTeam = newSelectedTeam
}
const updateEnvironmentType = (newEnvironmentType: EnvironmentType) => {
environmentType.value.type = newEnvironmentType
}
// TeamList-Adapter
const teamListAdapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const teamListFetched = ref(false)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
const adapter = new TeamEnvironmentAdapter(undefined)
const adapterLoading = useReadonlyStream(adapter.loading$, false)
@@ -243,6 +242,45 @@ const loading = computed(
() => adapterLoading.value && teamEnvironmentList.value.length === 0
)
watch(
() => myTeams.value,
(newTeams) => {
if (newTeams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value && currentUser.value) {
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) updateSelectedTeam(team)
}
}
}
)
watch(
() => environmentType.value.selectedTeam,
(newTeam) => {
if (newTeam) {
adapter.changeTeamID(newTeam.id)
}
}
)
const switchToMyEnvironments = () => {
environmentType.value.selectedTeam = undefined
updateEnvironmentType("my-environments")
adapter.changeTeamID(undefined)
}
const updateSelectedTeam = (newSelectedTeam: SelectedTeam) => {
if (newSelectedTeam) {
environmentType.value.selectedTeam = newSelectedTeam
REMEMBERED_TEAM_ID.value = newSelectedTeam.id
updateEnvironmentType("team-environments")
}
}
const updateEnvironmentType = (newEnvironmentType: EnvironmentType) => {
environmentType.value.type = newEnvironmentType
}
watch(
() => environmentType.value.selectedTeam?.id,
(newTeamID) => {
@@ -254,7 +292,30 @@ watch(
() => currentUser.value,
(newValue) => {
if (!newValue) {
updateEnvironmentType("my-environments")
switchToMyEnvironments()
}
}
)
onLoggedIn(() => {
!teamListAdapter.isInitialized && teamListAdapter.initialize()
})
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
// Used to switch environment type and team when user switch workspace in the global workspace switcher
// Check if there is a teamID in the workspace, if yes, switch to team environment and select the team
// If there is no teamID, switch to my environment
watch(
() => workspace.value.teamID,
(teamID) => {
if (!teamID) {
switchToMyEnvironments()
} else if (teamID) {
const team = myTeams.value?.find((t) => t.id === teamID)
if (team) {
updateSelectedTeam(team)
}
}
}
)

View File

@@ -94,7 +94,7 @@
v-if="!loading && adapterError"
class="flex flex-col items-center py-4"
>
<i class="mb-4 material-icons">help_outline</i>
<icon-lucide-help-circle class="mb-4 svg-icons" />
{{ getErrorMessage(adapterError) }}
</div>
<EnvironmentsTeamsDetails

View File

@@ -1,59 +1,62 @@
<template>
<div>
<div
class="sticky top-0 z-10 flex flex-shrink-0 overflow-x-auto border-b bg-primary border-dividerLight"
class="sticky top-0 z-10 flex flex-col flex-shrink-0 overflow-x-auto border-b bg-primary border-dividerLight"
>
<input
v-model="filterText"
type="search"
autocomplete="off"
class="flex flex-1 p-4 py-2 bg-transparent"
:placeholder="`${t('action.search')}`"
/>
<WorkspaceCurrent :section="t('tab.history')" />
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
to="https://docs.hoppscotch.io/features/history"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
<input
v-model="filterText"
type="search"
autocomplete="off"
class="flex flex-1 p-4 py-2 bg-transparent"
:placeholder="`${t('action.search')}`"
/>
<tippy interactive trigger="click" theme="popover">
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.filter')"
:icon="IconFilter"
to="https://docs.hoppscotch.io/features/history"
blank
:title="t('app.wiki')"
:icon="IconHelpCircle"
/>
<template #content="{ hide }">
<div ref="tippyActions" class="flex flex-col focus:outline-none">
<div class="pb-2 pl-4 text-tiny text-secondaryLight">
{{ t("action.filter") }}
<tippy interactive trigger="click" theme="popover">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.filter')"
:icon="IconFilter"
/>
<template #content="{ hide }">
<div ref="tippyActions" class="flex flex-col focus:outline-none">
<div class="pb-2 pl-4 text-tiny text-secondaryLight">
{{ t("action.filter") }}
</div>
<HoppSmartRadioGroup
v-model="filterSelection"
:radios="filters"
@update:model-value="hide()"
/>
<hr />
<div class="pb-2 pl-4 text-tiny text-secondaryLight">
{{ t("action.group_by") }}
</div>
<HoppSmartRadioGroup
v-model="groupSelection"
:radios="groups"
@update:model-value="hide()"
/>
</div>
<HoppSmartRadioGroup
v-model="filterSelection"
:radios="filters"
@update:model-value="hide()"
/>
<hr />
<div class="pb-2 pl-4 text-tiny text-secondaryLight">
{{ t("action.group_by") }}
</div>
<HoppSmartRadioGroup
v-model="groupSelection"
:radios="groups"
@update:model-value="hide()"
/>
</div>
</template>
</tippy>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
data-testid="clear_history"
:disabled="history.length === 0"
:icon="IconTrash2"
:title="t('action.clear_all')"
@click="confirmRemove = true"
/>
</template>
</tippy>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
data-testid="clear_history"
:disabled="history.length === 0"
:icon="IconTrash2"
:title="t('action.clear_all')"
@click="confirmRemove = true"
/>
</div>
</div>
</div>
<div class="flex flex-col">

View File

@@ -28,7 +28,7 @@
</span>
<template #content="{ hide }">
<div class="flex flex-col space-y-2">
<div class="sticky top-0 flex-shrink-0 overflow-x-auto">
<div class="sticky z-10 top-0 flex-shrink-0 overflow-x-auto">
<input
v-model="searchQuery"
type="search"

View File

@@ -161,7 +161,6 @@
ref="saveTippyActions"
class="flex flex-col focus:outline-none"
tabindex="0"
@keyup.c="copyRequestAction.$el.click()"
@keyup.s="saveRequestAction.$el.click()"
@keyup.escape="hide()"
>
@@ -180,7 +179,6 @@
:label="shareButtonText"
:icon="copyLinkIcon"
:loading="fetchingShareLink"
:shortcut="['C']"
@click="
() => {
copyRequest()

View File

@@ -22,7 +22,7 @@
<HoppSmartTab
:id="'env'"
:icon="IconLayers"
:label="`${t('environment.title')}`"
:label="`${t('tab.environments')}`"
>
<Environments />
</HoppSmartTab>

View File

@@ -1,7 +1,7 @@
<template>
<div
<button
tabindex="0"
class="relative flex items-center justify-center cursor-pointer focus:outline-none focus-visible:ring focus-visible:ring-primaryDark"
class="relative flex items-center justify-center overflow-visible cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primaryDark"
:class="[`rounded-${rounded}`, `w-${size} h-${size}`]"
>
<img
@@ -30,7 +30,7 @@
:class="[`rounded-${rounded}`, indicatorStyles]"
></span>
<!-- w-5 h-5 rounded-lg -->
</div>
</button>
</template>
<script lang="ts">

View File

@@ -18,7 +18,7 @@
</span>
<template #content="{ hide }">
<div class="flex flex-col space-y-2">
<div class="sticky top-0 flex-shrink-0 overflow-x-auto">
<div class="sticky z-10 top-0 flex-shrink-0 overflow-x-auto">
<input
v-model="searchQuery"
type="search"

View File

@@ -13,12 +13,15 @@
:node-item="rootNode"
:adapter="adapter as SmartTreeAdapter<T>"
>
<template #default="{ node, toggleChildren, isOpen }">
<template
#default="{ node, toggleChildren, isOpen, highlightChildren }"
>
<slot
name="content"
:node="node as TreeNode<T>"
:toggle-children="toggleChildren as () => void"
:is-open="isOpen as boolean"
:highlight-children="(id:string|null) => highlightChildren(id)"
></slot>
</template>
<template #emptyNode="{ node }">

View File

@@ -3,6 +3,7 @@
:node="nodeItem"
:toggle-children="toggleNodeChildren"
:is-open="isNodeOpen"
:highlight-children="(id:string|null) => highlightNodeChildren(id)"
></slot>
<!-- This is a performance optimization trick -->
@@ -20,6 +21,9 @@
<div
v-if="childNodes.status === 'loaded' && childNodes.data.length > 0"
class="flex flex-col flex-1 truncate"
:class="{
'bg-divider': highlightNode,
}"
>
<TreeBranch
v-for="childNode in childNodes.data"
@@ -28,12 +32,20 @@
:adapter="adapter"
>
<!-- The child slot is given a dynamic name in order to not break Volar -->
<template #[CHILD_SLOT_NAME]="{ node, toggleChildren, isOpen }">
<template
#[CHILD_SLOT_NAME]="{
node,
toggleChildren,
isOpen,
highlightChildren,
}"
>
<!-- Casting to help with type checking -->
<slot
:node="node as TreeNode<T>"
:toggle-children="toggleChildren as () => void"
:is-open="isOpen as boolean"
:highlight-children="(id:string|null) => highlightChildren(id) as void"
></slot>
</template>
<template #emptyNode="{ node }">
@@ -87,6 +99,8 @@ const childrenRendered = ref(false)
const showChildren = ref(false)
const isNodeOpen = ref(false)
const highlightNode = ref(false)
/**
* Fetch the child nodes from the adapter by passing the node id of the current node
*/
@@ -100,4 +114,12 @@ const toggleNodeChildren = () => {
showChildren.value = !showChildren.value
isNodeOpen.value = !isNodeOpen.value
}
const highlightNodeChildren = (id: string | null) => {
if (id) {
highlightNode.value = true
} else {
highlightNode.value = false
}
}
</script>

View File

@@ -3,7 +3,7 @@
:to="to"
:exact="exact"
:blank="blank"
class="inline-flex items-center px-4 py-2 truncate rounded transition focus:outline-none"
class="inline-flex items-center px-4 py-2 truncate transition rounded focus:outline-none"
:class="[
color
? `text-${color}-500 hover:text-${color}-600 focus-visible:text-${color}-600`
@@ -19,11 +19,13 @@
class="opacity-75 svg-icons"
:class="label ? (reverse ? 'ml-4' : 'mr-4') : ''"
/>
{{ label }}
<div class="truncate max-w-54">
{{ label }}
</div>
</HoppSmartLink>
</template>
<script>
<script lang="ts">
import { defineComponent } from "vue"
export default defineComponent({

View File

@@ -25,9 +25,9 @@
class="svg-icons"
:class="label ? 'mr-4 opacity-75' : ''"
/>
<span class="truncate">
<div class="truncate max-w-54">
{{ label }}
</span>
</div>
</HoppSmartLink>
</template>

View File

@@ -0,0 +1,94 @@
<template>
<div class="flex items-center -space-x-1">
<div
v-for="(member, index) in slicedTeamMembers"
:key="`member-${index}`"
class="inline-flex"
>
<ProfilePicture
v-if="member.user.photoURL"
v-tippy="{ theme: 'tooltip' }"
:url="member.user.photoURL"
:title="getUserName(member)"
:alt="getUserName(member)"
class="ring-primary ring-2"
@click="handleClick()"
/>
<ProfilePicture
v-else
v-tippy="{ theme: 'tooltip' }"
:title="getUserName(member)"
:initial="getUserName(member)"
class="ring-primary ring-2"
@click="handleClick()"
/>
</div>
<button
v-if="props.showCount && props.teamMembers.length > maxMembersSoftLimit"
v-tippy="{ theme: 'tooltip', allowHTML: true }"
:title="remainingSlicedMembers"
class="z-10 inline-flex items-center justify-center w-5 h-5 rounded-full cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-primaryDark font- text-8px text-secondaryDark bg-dividerDark ring-2 ring-primary"
tabindex="0"
@click="handleClick()"
>
{{
teamMembers.length > 0
? `+${teamMembers.length - maxMembersSoftLimit}`
: ""
}}
</button>
</div>
</template>
<script setup lang="ts">
import { GetMyTeamsQuery, TeamMember } from "~/helpers/backend/graphql"
import { useI18n } from "@composables/i18n"
import { computed } from "vue"
const t = useI18n()
const props = defineProps<{
teamMembers: GetMyTeamsQuery["myTeams"][number]["teamMembers"]
showCount?: boolean
}>()
const emit = defineEmits<{
(e: "handle-click"): void
}>()
const getUserName = (member: TeamMember): string =>
member.user.displayName ||
member.user.email ||
t("profile.default_hopp_displayName")
const maxMembersSoftLimit = 4
const maxMembersHardLimit = 6
const slicedTeamMembers = computed(() => {
if (props.showCount && props.teamMembers.length > maxMembersSoftLimit) {
return props.teamMembers.slice(0, maxMembersSoftLimit)
} else {
return props.teamMembers
}
})
const remainingSlicedMembers = computed(
() =>
props.teamMembers
.slice(maxMembersSoftLimit)
.slice(0, maxMembersHardLimit)
.map((member) => getUserName(member))
.join(`,<br>`) +
(props.teamMembers.length - (maxMembersSoftLimit + maxMembersHardLimit) > 0
? `,<br>${t("team.more_members", {
count:
props.teamMembers.length -
(maxMembersSoftLimit + maxMembersHardLimit),
})}`
: ``)
)
const handleClick = () => {
emit("handle-click")
}
</script>

View File

@@ -27,31 +27,7 @@
>
{{ team.name || t("state.nothing_found") }}
</label>
<div class="flex mt-2 overflow-hidden -space-x-1">
<div
v-for="(member, index) in team.teamMembers"
:key="`member-${index}`"
v-tippy="{ theme: 'tooltip' }"
:title="
member.user.displayName ||
member.user.email ||
t('default_hopp_displayName')
"
class="inline-flex"
>
<ProfilePicture
v-if="member.user.photoURL"
:url="member.user.photoURL"
:alt="member.user.displayName"
class="ring-primary ring-2"
/>
<ProfilePicture
v-else
:initial="member.user.displayName || member.user.email"
class="ring-primary ring-2"
/>
</div>
</div>
<TeamsMemberStack :team-members="team.teamMembers" class="mt-4" />
</div>
</div>
<div v-if="!compact" class="flex items-end justify-between flex-shrink-0">
@@ -171,7 +147,7 @@
import { ref } from "vue"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { TeamMemberRole } from "~/helpers/backend/graphql"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import {
deleteTeam as backendDeleteTeam,
leaveTeam,
@@ -189,18 +165,7 @@ import IconTrash2 from "~icons/lucide/trash-2"
const t = useI18n()
const props = defineProps<{
team: {
name: string
myRole: TeamMemberRole
ownersCount: number
teamMembers: Array<{
user: {
displayName: string
photoURL: string | null
email: string | null
}
}>
}
team: GetMyTeamsQuery["myTeams"][number]
teamID: string
compact: boolean
}>()

View File

@@ -0,0 +1,38 @@
<template>
<div
class="flex items-center px-4 py-2 overflow-x-auto border-b whitespace-nowrap border-dividerLight text-tiny text-secondaryLight"
>
<span class="truncate">
{{
workspace.type === "personal"
? t("workspace.personal")
: teamWorkspaceName
}}
</span>
<icon-lucide-chevron-right v-if="section" class="mx-2" />
{{ section }}
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { useReadonlyStream } from "~/composables/stream"
import { workspaceStatus$ } from "~/newstore/workspace"
import { useI18n } from "~/composables/i18n"
defineProps<{
section?: string
}>()
const t = useI18n()
const workspace = useReadonlyStream(workspaceStatus$, { type: "personal" })
const teamWorkspaceName = computed(() => {
if (workspace.value.type === "team" && workspace.value.teamName) {
return workspace.value.teamName
} else {
return `${t("workspace.team")}`
}
})
</script>

View File

@@ -0,0 +1,156 @@
<template>
<div>
<div class="flex flex-col">
<div class="flex flex-col">
<HoppSmartItem
label="My Workspace"
:icon="IconUser"
:info-icon="workspace.type === 'personal' ? IconDone : undefined"
:active-info-icon="workspace.type === 'personal'"
@click="switchToPersonalWorkspace"
/>
<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>
<div
v-if="!loading && myTeams.length === 0"
class="flex flex-col items-center justify-center flex-1 p-4 text-secondaryLight"
>
<span class="mb-4 text-center">
{{ t("empty.teams") }}
</span>
<HoppButtonSecondary
:label="t('team.create_new')"
filled
outline
:icon="IconPlus"
@click="displayModalAdd(true)"
/>
</div>
<div v-else-if="!loading" class="flex flex-col">
<div
class="sticky top-0 z-10 flex items-center justify-between py-2 pl-2 mb-2 -top-2 bg-popover"
>
<div class="flex items-center px-2 font-semibold text-secondaryLight">
{{ t("team.title") }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconPlus"
:title="`${t('team.create_new')}`"
outline
filled
class="!p-0.75 rounded ml-8"
@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-if="!loading && teamListAdapterError"
class="flex flex-col items-center py-4"
>
<i class="mb-4 material-icons">help_outline</i>
{{ t("error.something_went_wrong") }}
</div>
</div>
<TeamsAdd :show="showModalAdd" @hide-modal="displayModalAdd(false)" />
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { onLoggedIn } from "~/composables/auth"
import { useReadonlyStream } from "~/composables/stream"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
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 { changeWorkspace, workspaceStatus$ } from "~/newstore/workspace"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import IconDone from "~icons/lucide/check"
import { useLocalState } from "~/newstore/localstate"
const t = useI18n()
const showModalAdd = ref(false)
const currentUser = useReadonlyStream(
platform.auth.getProbableUserStream(),
platform.auth.getProbableUser()
)
const teamListadapter = new TeamListAdapter(true)
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)
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 = useReadonlyStream(workspaceStatus$, { type: "personal" })
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
changeWorkspace({
teamID: team.id,
teamName: team.name,
type: "team",
})
}
const switchToPersonalWorkspace = () => {
REMEMBERED_TEAM_ID.value = undefined
changeWorkspace({
type: "personal",
})
}
onLoggedIn(() => {
teamListadapter.initialize()
})
watch(
() => currentUser.value,
(user) => {
if (!user) {
switchToPersonalWorkspace()
}
}
)
const displayModalAdd = (shouldDisplay: boolean) => {
showModalAdd.value = shouldDisplay
teamListadapter.fetchList()
}
</script>

View File

@@ -0,0 +1,8 @@
mutation MoveRESTTeamCollection($collectionID: ID!, $parentCollectionID: ID) {
moveCollection(
collectionID: $collectionID
parentCollectionID: $parentCollectionID
) {
id
}
}

View File

@@ -0,0 +1,5 @@
mutation MoveRESTTeamRequest($collectionID: ID!, $requestID: ID!) {
moveRequest(destCollID: $collectionID, requestID: $requestID) {
id
}
}

View File

@@ -1,5 +0,0 @@
mutation MoveRESTTeamRequest($requestID: ID!, $collectionID: ID!) {
moveRequest(requestID: $requestID, destCollID: $collectionID) {
id
}
}

View File

@@ -0,0 +1,3 @@
mutation UpdateCollectionOrder($collectionID: ID!, $destCollID: ID!) {
updateCollectionOrder(collectionID: $collectionID, destCollID: $destCollID)
}

View File

@@ -0,0 +1,11 @@
mutation UpdateLookUpRequestOrder(
$requestID: ID!
$nextRequestID: ID
$collectionID: ID!
) {
updateLookUpRequestOrder(
requestID: $requestID
nextRequestID: $nextRequestID
collectionID: $collectionID
)
}

View File

@@ -0,0 +1,9 @@
query GetSingleCollection($collectionID: ID!) {
collection(collectionID: $collectionID) {
id
title
parent {
id
}
}
}

View File

@@ -0,0 +1,8 @@
query GetSingleRequest($requestID: ID!) {
request(requestID: $requestID) {
id
collectionID
title
request
}
}

View File

@@ -0,0 +1,9 @@
subscription TeamCollectionMoved($teamID: ID!) {
teamCollectionMoved(teamID: $teamID) {
id
title
parent {
id
}
}
}

View File

@@ -0,0 +1,18 @@
subscription TeamCollectionOrderUpdated($teamID: ID!) {
collectionOrderUpdated(teamID: $teamID) {
collection {
id
title
parent {
id
}
}
nextCollection {
id
title
parent {
id
}
}
}
}

View File

@@ -0,0 +1,8 @@
subscription TeamRequestMoved($teamID: ID!) {
requestMoved(teamID: $teamID) {
id
collectionID
request
title
}
}

View File

@@ -0,0 +1,16 @@
subscription TeamRequestOrderUpdated($teamID: ID!) {
requestOrderUpdated(teamID: $teamID) {
request {
id
collectionID
request
title
}
nextRequest {
id
collectionID
request
title
}
}
}

View File

@@ -12,16 +12,37 @@ import {
ImportFromJsonDocument,
ImportFromJsonMutation,
ImportFromJsonMutationVariables,
MoveRestTeamCollectionDocument,
MoveRestTeamCollectionMutation,
MoveRestTeamCollectionMutationVariables,
RenameCollectionDocument,
RenameCollectionMutation,
RenameCollectionMutationVariables,
UpdateCollectionOrderDocument,
UpdateCollectionOrderMutation,
UpdateCollectionOrderMutationVariables,
} from "../graphql"
type CreateNewRootCollectionError = "team_coll/short_title"
type CreateChildCollectionError = "team_coll/short_title"
type RenameCollectionError = "team_coll/short_title"
type DeleteCollectionError = "team/invalid_coll_id"
type MoveRestTeamCollectionError =
| "team/invalid_coll_id"
| "team_coll/invalid_target_id"
| "team/collection_is_parent_coll"
| "team/target_and_destination_collection_are_same"
| "team/target_collection_is_already_root_collection"
type UpdateCollectionOrderError =
| "team/invalid_coll_id"
| "team/collection_and_next_collection_are_same"
| "team/team_collections_have_different_parents"
export const createNewRootCollection = (title: string, teamID: string) =>
runMutation<
CreateNewRootCollectionMutation,
@@ -66,6 +87,33 @@ export const deleteCollection = (collectionID: string) =>
collectionID,
})
/** Can be used to move both collection and folder (considered same in BE) */
export const moveRESTTeamCollection = (
collectionID: string,
destinationCollectionID: string | null
) =>
runMutation<
MoveRestTeamCollectionMutation,
MoveRestTeamCollectionMutationVariables,
MoveRestTeamCollectionError
>(MoveRestTeamCollectionDocument, {
collectionID,
parentCollectionID: destinationCollectionID,
})
export const updateOrderRESTTeamCollection = (
collectionID: string,
destCollID: string
) =>
runMutation<
UpdateCollectionOrderMutation,
UpdateCollectionOrderMutationVariables,
UpdateCollectionOrderError
>(UpdateCollectionOrderDocument, {
collectionID,
destCollID,
})
export const importJSONToTeam = (collectionJSON: string, teamID: string) =>
runMutation<ImportFromJsonMutation, ImportFromJsonMutationVariables, "">(
ImportFromJsonDocument,

View File

@@ -9,16 +9,27 @@ import {
MoveRestTeamRequestDocument,
MoveRestTeamRequestMutation,
MoveRestTeamRequestMutationVariables,
UpdateLookUpRequestOrderDocument,
UpdateLookUpRequestOrderMutation,
UpdateLookUpRequestOrderMutationVariables,
UpdateRequestDocument,
UpdateRequestMutation,
UpdateRequestMutationVariables,
} from "../graphql"
type DeleteRequestErrors = "team_req/not_found"
type MoveRestTeamRequestErrors =
| "team_req/not_found"
| "team_req/invalid_target_id"
| "team/invalid_coll_id"
| "team_req/not_required_role"
| "bug/team_req/no_req_id"
type DeleteRequestErrors = "team_req/not_found"
type UpdateLookUpRequestOrderErrors =
| "team_req/not_found"
| "team/request_and_next_request_are_same"
| "team_req/requests_not_from_same_collection"
export const createRequestInCollection = (
collectionID: string,
@@ -61,12 +72,27 @@ export const deleteTeamRequest = (requestID: string) =>
requestID,
})
export const moveRESTTeamRequest = (requestID: string, collectionID: string) =>
export const moveRESTTeamRequest = (collectionID: string, requestID: string) =>
runMutation<
MoveRestTeamRequestMutation,
MoveRestTeamRequestMutationVariables,
MoveRestTeamRequestErrors
>(MoveRestTeamRequestDocument, {
collectionID,
requestID,
})
export const updateOrderRESTTeamRequest = (
requestID: string,
nextRequestID: string,
collectionID: string
) =>
runMutation<
UpdateLookUpRequestOrderMutation,
UpdateLookUpRequestOrderMutationVariables,
UpdateLookUpRequestOrderErrors
>(UpdateLookUpRequestOrderDocument, {
requestID,
nextRequestID,
collectionID,
})

View File

@@ -1,178 +0,0 @@
import { Environment } from "@hoppscotch/data"
import {
collection,
doc,
getFirestore,
onSnapshot,
setDoc,
} from "firebase/firestore"
import { platform } from "~/platform"
import {
environments$,
globalEnv$,
replaceEnvironments,
setGlobalEnvVariables,
} from "~/newstore/environments"
import { getSettingSubject, settingsStore } from "~/newstore/settings"
/**
* Used locally to prevent infinite loop when environment sync update
* is applied to the store which then fires the store sync listener.
* When you want to update environments and not want to fire the update listener,
* set this to true and then set it back to false once it is done
*/
let loadedEnvironments = false
/**
* Used locally to prevent infinite loop when global env sync update
* is applied to the store which then fires the store sync listener.
* When you want to update global env and not want to fire the update listener,
* set this to true and then set it back to false once it is done
*/
let loadedGlobals = true
async function writeEnvironments(environment: Environment[]) {
const currentUser = platform.auth.getCurrentUser()
if (currentUser === null)
throw new Error("Cannot write environments when signed out")
const ev = {
updatedOn: new Date(),
author: currentUser.uid,
author_name: currentUser.displayName,
author_image: currentUser.photoURL,
environment,
}
try {
await setDoc(
doc(getFirestore(), "users", currentUser.uid, "environments", "sync"),
ev
)
} catch (e) {
console.error("error updating", ev, e)
throw e
}
}
async function writeGlobalEnvironment(variables: Environment["variables"]) {
const currentUser = platform.auth.getCurrentUser()
if (currentUser === null)
throw new Error("Cannot write global environment when signed out")
const ev = {
updatedOn: new Date(),
author: currentUser.uid,
author_name: currentUser.displayName,
author_image: currentUser.photoURL,
variables,
}
try {
await setDoc(
doc(getFirestore(), "users", currentUser.uid, "globalEnv", "sync"),
ev
)
} catch (e) {
console.error("error updating", ev, e)
throw e
}
}
export function initEnvironments() {
const currentUser$ = platform.auth.getCurrentUserStream()
const envListenSub = environments$.subscribe((envs) => {
const currentUser = platform.auth.getCurrentUser()
if (
currentUser &&
settingsStore.value.syncEnvironments &&
loadedEnvironments
) {
writeEnvironments(envs)
}
})
const globalListenSub = globalEnv$.subscribe((vars) => {
const currentUser = platform.auth.getCurrentUser()
if (currentUser && settingsStore.value.syncEnvironments && loadedGlobals) {
writeGlobalEnvironment(vars)
}
})
let envSnapshotStop: (() => void) | null = null
let globalsSnapshotStop: (() => void) | null = null
const currentUserSub = currentUser$.subscribe((user) => {
if (!user) {
// User logged out, clean up snapshot listener
if (envSnapshotStop) {
envSnapshotStop()
envSnapshotStop = null
}
if (globalsSnapshotStop) {
globalsSnapshotStop()
globalsSnapshotStop = null
}
} else if (user) {
envSnapshotStop = onSnapshot(
collection(getFirestore(), "users", user.uid, "environments"),
(environmentsRef) => {
const environments: any[] = []
environmentsRef.forEach((doc) => {
const environment = doc.data()
environment.id = doc.id
environments.push(environment)
})
loadedEnvironments = false
if (environments.length > 0 && settingsStore.value.syncEnvironments) {
replaceEnvironments(environments[0].environment)
}
loadedEnvironments = true
}
)
globalsSnapshotStop = onSnapshot(
collection(getFirestore(), "users", user.uid, "globalEnv"),
(globalsRef) => {
if (globalsRef.docs.length === 0) {
loadedGlobals = true
return
}
const doc = globalsRef.docs[0].data()
loadedGlobals = false
if (settingsStore.value.syncEnvironments)
setGlobalEnvVariables(doc.variables)
loadedGlobals = true
}
)
}
})
let oldSyncStatus = settingsStore.value.syncEnvironments
const syncStop = getSettingSubject("syncEnvironments").subscribe(
(newStatus) => {
if (oldSyncStatus === true && newStatus === false) {
envSnapshotStop?.()
globalsSnapshotStop?.()
oldSyncStatus = newStatus
} else if (oldSyncStatus === false && newStatus === true) {
syncStop.unsubscribe()
envListenSub.unsubscribe()
globalListenSub.unsubscribe()
currentUserSub.unsubscribe()
initEnvironments()
}
}
)
}

View File

@@ -2,7 +2,6 @@ import { initializeApp } from "firebase/app"
import { platform } from "~/platform"
import { initAnalytics } from "./analytics"
import { initCollections } from "./collections"
import { initEnvironments } from "./environments"
import { initHistory } from "./history"
import { initSettings } from "./settings"
@@ -28,7 +27,7 @@ export function initializeFirebase() {
initSettings()
initCollections()
initHistory()
initEnvironments()
platform.sync.environments.initEnvironmentsSync()
initAnalytics()
initialized = true

View File

@@ -16,6 +16,10 @@ import {
TeamRequestDeletedDocument,
GetCollectionChildrenDocument,
GetCollectionRequestsDocument,
TeamRequestMovedDocument,
TeamCollectionMovedDocument,
TeamRequestOrderUpdatedDocument,
TeamCollectionOrderUpdatedDocument,
} from "~/helpers/backend/graphql"
const TEAMS_BACKEND_PAGE_SIZE = 10
@@ -201,6 +205,10 @@ export default class NewTeamCollectionAdapter {
private teamRequestAdded$: Subscription | null
private teamRequestUpdated$: Subscription | null
private teamRequestDeleted$: Subscription | null
private teamRequestMoved$: Subscription | null
private teamCollectionMoved$: Subscription | null
private teamRequestOrderUpdated$: Subscription | null
private teamCollectionOrderUpdated$: Subscription | null
private teamCollectionAddedSub: WSubscription | null
private teamCollectionUpdatedSub: WSubscription | null
@@ -208,6 +216,10 @@ export default class NewTeamCollectionAdapter {
private teamRequestAddedSub: WSubscription | null
private teamRequestUpdatedSub: WSubscription | null
private teamRequestDeletedSub: WSubscription | null
private teamRequestMovedSub: WSubscription | null
private teamCollectionMovedSub: WSubscription | null
private teamRequestOrderUpdatedSub: WSubscription | null
private teamCollectionOrderUpdatedSub: WSubscription | null
constructor(private teamID: string | null) {
this.collections$ = new BehaviorSubject<TeamCollection[]>([])
@@ -221,6 +233,10 @@ export default class NewTeamCollectionAdapter {
this.teamRequestAdded$ = null
this.teamRequestDeleted$ = null
this.teamRequestUpdated$ = null
this.teamRequestMoved$ = null
this.teamCollectionMoved$ = null
this.teamRequestOrderUpdated$ = null
this.teamCollectionOrderUpdated$ = null
this.teamCollectionAddedSub = null
this.teamCollectionUpdatedSub = null
@@ -228,6 +244,10 @@ export default class NewTeamCollectionAdapter {
this.teamRequestAddedSub = null
this.teamRequestDeletedSub = null
this.teamRequestUpdatedSub = null
this.teamRequestMovedSub = null
this.teamCollectionMovedSub = null
this.teamRequestOrderUpdatedSub = null
this.teamCollectionOrderUpdatedSub = null
if (this.teamID) this.initialize()
}
@@ -255,6 +275,10 @@ export default class NewTeamCollectionAdapter {
this.teamRequestAdded$?.unsubscribe()
this.teamRequestDeleted$?.unsubscribe()
this.teamRequestUpdated$?.unsubscribe()
this.teamRequestMoved$?.unsubscribe()
this.teamCollectionMoved$?.unsubscribe()
this.teamRequestOrderUpdated$?.unsubscribe()
this.teamCollectionOrderUpdated$?.unsubscribe()
this.teamCollectionAddedSub?.unsubscribe()
this.teamCollectionUpdatedSub?.unsubscribe()
@@ -262,6 +286,10 @@ export default class NewTeamCollectionAdapter {
this.teamRequestAddedSub?.unsubscribe()
this.teamRequestDeletedSub?.unsubscribe()
this.teamRequestUpdatedSub?.unsubscribe()
this.teamRequestMovedSub?.unsubscribe()
this.teamCollectionMovedSub?.unsubscribe()
this.teamRequestOrderUpdatedSub?.unsubscribe()
this.teamCollectionOrderUpdatedSub?.unsubscribe()
}
private async initialize() {
@@ -279,6 +307,9 @@ export default class NewTeamCollectionAdapter {
collection: TeamCollection,
parentCollectionID: string | null
) {
// Check if we have it already in the entity tree, if so, we don't need it again
if (this.entityIDs.has(`collection-${collection.id}`)) return
const tree = this.collections$.value
if (!parentCollectionID) {
@@ -328,7 +359,7 @@ export default class NewTeamCollectionAdapter {
this.loadingCollections$.getValue().filter((x) => x !== "root")
)
throw new Error(`Error fetching root collections: ${result}`)
throw new Error(`Error fetching root collections: ${result.left.error}`)
}
totalCollections.push(
@@ -456,6 +487,143 @@ export default class NewTeamCollectionAdapter {
this.collections$.next(tree)
}
/**
* Moves a request from one collection to another
*
* @param {string} request - The request to move
*/
private async moveRequest(request: TeamRequest) {
const tree = this.collections$.value
// Remove the request from the current collection
this.removeRequest(request.id)
const currentRequest = request.request
if (currentRequest === null || currentRequest === undefined) return
// Find request in tree, don't attempt if no collection or no requests is found
const collection = findCollInTree(tree, request.collectionID)
if (!collection) return // Ignore add request
// Collection is not expanded
if (!collection.requests) return
this.addRequest({
id: request.id,
collectionID: request.collectionID,
request: translateToNewRequest(request.request),
title: request.title,
})
}
/**
* Moves a collection from one collection to another or to root
*
* @param {string} collectionID - The ID of the collection to move
*/
private async moveCollection(
collectionID: string,
parentID: string | null,
title: string
) {
// Remove the collection from the current position
this.removeCollection(collectionID)
if (collectionID === null || parentID === undefined) return
// Expand the parent collection if it is not expanded
// so that the old children is also visible when expanding
if (parentID) this.expandCollection(parentID)
this.addCollection(
{
id: collectionID,
children: null,
requests: null,
title: title,
},
parentID ?? null
)
}
public updateRequestOrder(
dragedRequestID: string,
destinationRequestID: string,
destinationCollectionID: string
) {
const tree = this.collections$.value
// Find collection in tree, don't attempt if no collection is found
const collection = findCollInTree(tree, destinationCollectionID)
if (!collection) return // Ignore order update
// Collection is not expanded
if (!collection.requests) return
const requestIndex = collection.requests.findIndex(
(req) => req.id === dragedRequestID
)
const destinationIndex = collection.requests.findIndex(
(req) => req.id === destinationRequestID
)
if (requestIndex === -1) return
const request = collection.requests[requestIndex]
collection.requests.splice(requestIndex, 1)
collection.requests.splice(destinationIndex, 0, request)
this.collections$.next(tree)
}
public updateCollectionOrder = (
collectionID: string,
destinationCollectionID: string
) => {
const tree = this.collections$.value
// Find collection in tree
const coll = findParentOfColl(tree, destinationCollectionID)
// If the collection has a parent collection and check if it has children
if (coll && coll.children) {
const collectionIndex = coll.children.findIndex(
(coll) => coll.id === collectionID
)
const destinationIndex = coll.children.findIndex(
(coll) => coll.id === destinationCollectionID
)
// If the collection index is not found, don't update
if (collectionIndex === -1) return
const collection = coll.children[collectionIndex]
coll.children.splice(collectionIndex, 1)
coll.children.splice(destinationIndex, 0, collection)
} else {
// If the collection has no parent collection, it is a root collection
const collectionIndex = tree.findIndex((coll) => coll.id === collectionID)
const destinationIndex = tree.findIndex(
(coll) => coll.id === destinationCollectionID
)
// If the collection index is not found, don't update
if (collectionIndex === -1) return
const collection = tree[collectionIndex]
tree.splice(collectionIndex, 1)
tree.splice(destinationIndex, 0, collection)
}
this.collections$.next(tree)
}
private registerSubscriptions() {
if (!this.teamID) return
@@ -575,7 +743,7 @@ export default class NewTeamCollectionAdapter {
},
})
this.teamRequestUpdatedSub = teamReqDeleted
this.teamRequestDeletedSub = teamReqDeleted
this.teamRequestDeleted$ = teamReqDeleted$.subscribe((result) => {
if (E.isLeft(result))
throw new Error(
@@ -584,6 +752,110 @@ export default class NewTeamCollectionAdapter {
this.removeRequest(result.right.teamRequestDeleted)
})
const [teamRequestMoved$, teamRequestMoved] = runGQLSubscription({
query: TeamRequestMovedDocument,
variables: {
teamID: this.teamID,
},
})
this.teamRequestMovedSub = teamRequestMoved
this.teamRequestMoved$ = teamRequestMoved$.subscribe((result) => {
if (E.isLeft(result))
throw new Error(
`Team Request Move Error ${JSON.stringify(result.left)}`
)
const { requestMoved } = result.right
const request = {
id: requestMoved.id,
collectionID: requestMoved.collectionID,
title: requestMoved.title,
request: JSON.parse(requestMoved.request),
}
this.moveRequest(request)
})
const [teamCollectionMoved$, teamCollectionMoved] = runGQLSubscription({
query: TeamCollectionMovedDocument,
variables: {
teamID: this.teamID,
},
})
this.teamCollectionMovedSub = teamCollectionMoved
this.teamCollectionMoved$ = teamCollectionMoved$.subscribe((result) => {
if (E.isLeft(result))
throw new Error(
`Team Collection Move Error ${JSON.stringify(result.left)}`
)
const { teamCollectionMoved } = result.right
const { id, parent, title } = teamCollectionMoved
const parentID = parent?.id ?? null
this.moveCollection(id, parentID, title)
})
const [teamRequestOrderUpdated$, teamRequestOrderUpdated] =
runGQLSubscription({
query: TeamRequestOrderUpdatedDocument,
variables: {
teamID: this.teamID,
},
})
this.teamRequestOrderUpdatedSub = teamRequestOrderUpdated
this.teamRequestOrderUpdated$ = teamRequestOrderUpdated$.subscribe(
(result) => {
if (E.isLeft(result))
throw new Error(
`Team Request Order Update Error ${JSON.stringify(result.left)}`
)
const { requestOrderUpdated } = result.right
const { request } = requestOrderUpdated
const { nextRequest } = requestOrderUpdated
if (!nextRequest) return
this.updateRequestOrder(
request.id,
nextRequest.id,
nextRequest.collectionID
)
}
)
const [teamCollectionOrderUpdated$, teamCollectionOrderUpdated] =
runGQLSubscription({
query: TeamCollectionOrderUpdatedDocument,
variables: {
teamID: this.teamID,
},
})
this.teamCollectionOrderUpdatedSub = teamCollectionOrderUpdated
this.teamCollectionOrderUpdated$ = teamCollectionOrderUpdated$.subscribe(
(result) => {
if (E.isLeft(result))
throw new Error(
`Team Collection Order Update Error ${JSON.stringify(result.left)}`
)
const { collectionOrderUpdated } = result.right
const { collection } = collectionOrderUpdated
const { nextCollection } = collectionOrderUpdated
if (!nextCollection) return
this.updateCollectionOrder(collection.id, nextCollection.id)
}
)
}
/**

View File

@@ -11,11 +11,19 @@ import {
} from "../backend/graphql"
import { TeamEnvironment } from "./TeamEnvironment"
type EntityType = "environment"
type EntityID = `${EntityType}-${string}`
export default class TeamEnvironmentAdapter {
error$: BehaviorSubject<GQLError<string> | null>
loading$: BehaviorSubject<boolean>
teamEnvironmentList$: BehaviorSubject<TeamEnvironment[]>
/**
* Stores the entity (environments) ids of all the loaded entities.
* Used for preventing duplication of data which definitely is not possible (duplication due to network problems etc.)
*/
private entityIDs: Set<EntityID>
private isDispose: boolean
private teamEnvironmentCreated$: Subscription | null
@@ -32,6 +40,8 @@ export default class TeamEnvironmentAdapter {
this.teamEnvironmentList$ = new BehaviorSubject<TeamEnvironment[]>([])
this.isDispose = true
this.entityIDs = new Set()
this.teamEnvironmentCreated$ = null
this.teamEnvironmentDeleted$ = null
this.teamEnvironmentUpdated$ = null
@@ -56,6 +66,8 @@ export default class TeamEnvironmentAdapter {
this.teamEnvironmentList$.next([])
this.loading$.next(false)
this.entityIDs.clear()
this.unsubscribeSubscriptions()
if (this.teamID) this.initialize()
@@ -112,16 +124,25 @@ export default class TeamEnvironmentAdapter {
)
}
// Add all the environments to the entity ids list
results.forEach((env) => this.entityIDs.add(`environment-${env.id}`))
this.teamEnvironmentList$.next(results)
this.loading$.next(false)
}
private createNewTeamEnvironment(newEnvironment: TeamEnvironment) {
// Check if we have it already in the entity tree, if so, we don't need it again
if (this.entityIDs.has(`environment-${newEnvironment.id}`)) return
const teamEnvironments = this.teamEnvironmentList$.value
teamEnvironments.push(newEnvironment)
// Add to entity ids set
this.entityIDs.add(`environment-${newEnvironment.id}`)
this.teamEnvironmentList$.next(teamEnvironments)
}
@@ -129,7 +150,7 @@ export default class TeamEnvironmentAdapter {
const teamEnvironments = this.teamEnvironmentList$.value.filter(
({ id }) => id !== envId
)
this.entityIDs.delete(`environment-${envId}`)
this.teamEnvironmentList$.next(teamEnvironments)
}

View File

@@ -15,6 +15,8 @@ export default class TeamListAdapter {
private timeoutHandle: ReturnType<typeof setTimeout> | null
private isDispose: boolean
public isInitialized: boolean
constructor(deferInit = false) {
this.error$ = new BehaviorSubject<GQLError<string> | null>(null)
this.loading$ = new BehaviorSubject<boolean>(false)
@@ -22,6 +24,8 @@ export default class TeamListAdapter {
this.timeoutHandle = null
this.isDispose = false
this.isInitialized = false
if (!deferInit) this.initialize()
}
@@ -29,6 +33,8 @@ export default class TeamListAdapter {
if (this.timeoutHandle) throw new Error(`Adapter already initialized`)
if (this.isDispose) throw new Error(`Adapter has been disposed`)
this.isInitialized = true
const func = async () => {
await this.fetchList()
@@ -44,6 +50,7 @@ export default class TeamListAdapter {
this.isDispose = true
clearTimeout(this.timeoutHandle as any)
this.timeoutHandle = null
this.isInitialized = false
}
async fetchList() {

View File

@@ -186,6 +186,133 @@ const restCollectionDispatchers = defineDispatchers({
}
},
moveFolder(
{ state }: RESTCollectionStoreType,
{ path, destinationPath }: { path: string; destinationPath: string | null }
) {
const newState = state
// Move the folder to the root
if (destinationPath === null) {
const indexPaths = path.split("/").map((x) => parseInt(x))
if (indexPaths.length === 0) {
console.log("Given path too short. Skipping request.")
return {}
}
const folderIndex = indexPaths.pop() as number
const containingFolder = navigateToFolderWithIndexPath(
newState,
indexPaths
)
if (containingFolder === null) {
console.error(
`The folder to move is already in the root. Skipping request to move folder.`
)
return {}
}
const theFolder = containingFolder.folders.splice(folderIndex, 1)
newState.push(theFolder[0] as HoppCollection<HoppRESTRequest>)
return {
state: newState,
}
}
const indexPaths = path.split("/").map((x) => parseInt(x))
const destinationIndexPaths = destinationPath
.split("/")
.map((x) => parseInt(x))
if (indexPaths.length === 0 || destinationIndexPaths.length === 0) {
console.error(
`Given path is too short. Skipping request to move folder '${path}' to destination '${destinationPath}'.`
)
return {}
}
const target = navigateToFolderWithIndexPath(
newState,
destinationIndexPaths
)
if (target === null) {
console.error(
`Could not resolve destination path '${destinationPath}'. Skipping moveFolder dispatch.`
)
return {}
}
const folderIndex = indexPaths.pop() as number
const containingFolder = navigateToFolderWithIndexPath(newState, indexPaths)
// We are moving a folder from the root
if (containingFolder === null) {
const theFolder = newState.splice(folderIndex, 1)
target.folders.push(theFolder[0])
} else {
const theFolder = containingFolder.folders.splice(folderIndex, 1)
target.folders.push(theFolder[0])
}
return { state: newState }
},
updateCollectionOrder(
{ state }: RESTCollectionStoreType,
{
collectionIndex,
destinationCollectionIndex,
}: {
collectionIndex: string
destinationCollectionIndex: string
}
) {
const newState = state
const indexPaths = collectionIndex.split("/").map((x) => parseInt(x))
const destinationIndexPaths = destinationCollectionIndex
.split("/")
.map((x) => parseInt(x))
if (indexPaths.length === 0 || destinationIndexPaths.length === 0) {
console.log("Given path too short. Skipping request.")
return {}
}
const folderIndex = indexPaths.pop() as number
const destinationFolderIndex = destinationIndexPaths.pop() as number
const containingFolder = navigateToFolderWithIndexPath(
newState,
destinationIndexPaths
)
if (containingFolder === null) {
const [removed] = newState.splice(folderIndex, 1)
newState.splice(destinationFolderIndex, 0, removed)
return {
state: newState,
}
}
const [removed] = containingFolder.folders.splice(folderIndex, 1)
containingFolder.folders.splice(destinationFolderIndex, 0, removed)
return {
state: newState,
}
},
editRequest(
{ state }: RESTCollectionStoreType,
{
@@ -286,6 +413,11 @@ const restCollectionDispatchers = defineDispatchers({
const indexPaths = path.split("/").map((x) => parseInt(x))
if (indexPaths.length === 0) {
console.log("Given path too short. Skipping request.")
return {}
}
const targetLocation = navigateToFolderWithIndexPath(newState, indexPaths)
if (targetLocation === null) {
@@ -315,6 +447,47 @@ const restCollectionDispatchers = defineDispatchers({
state: newState,
}
},
updateRequestOrder(
{ state }: RESTCollectionStoreType,
{
requestIndex,
destinationRequestIndex,
destinationCollectionPath,
}: {
requestIndex: number
destinationRequestIndex: number
destinationCollectionPath: string
}
) {
const newState = state
const indexPaths = destinationCollectionPath
.split("/")
.map((x) => parseInt(x))
if (indexPaths.length === 0) {
console.log("Given path too short. Skipping request.")
return {}
}
const targetLocation = navigateToFolderWithIndexPath(newState, indexPaths)
if (targetLocation === null) {
console.log(
`Could not resolve path '${destinationCollectionPath}'. Ignoring reorderRequest dispatch.`
)
return {}
}
const [removed] = targetLocation.requests.splice(requestIndex, 1)
targetLocation.requests.splice(destinationRequestIndex, 0, removed)
return {
state: newState,
}
},
})
const gqlCollectionDispatchers = defineDispatchers({
@@ -691,6 +864,16 @@ export function removeRESTFolder(path: string) {
})
}
export function moveRESTFolder(path: string, destinationPath: string | null) {
restCollectionStore.dispatch({
dispatcher: "moveFolder",
payload: {
path,
destinationPath,
},
})
}
export function editRESTRequest(
path: string,
requestIndex: number,
@@ -757,6 +940,34 @@ export function moveRESTRequest(
})
}
export function updateRESTRequestOrder(
requestIndex: number,
destinationRequestIndex: number,
destinationCollectionPath: string
) {
restCollectionStore.dispatch({
dispatcher: "updateRequestOrder",
payload: {
requestIndex,
destinationRequestIndex,
destinationCollectionPath,
},
})
}
export function updateRESTCollectionOrder(
collectionIndex: string,
destinationCollectionIndex: string
) {
restCollectionStore.dispatch({
dispatcher: "updateCollectionOrder",
payload: {
collectionIndex,
destinationCollectionIndex,
},
})
}
export function setGraphqlCollections(
entries: HoppCollection<HoppGQLRequest>[]
) {

View File

@@ -0,0 +1,67 @@
import { distinctUntilChanged, pluck } from "rxjs"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
type Workspace =
| { type: "personal" }
| { type: "team"; teamID: string; teamName: string }
type WorkspaceState = {
workspace: Workspace
}
const initialState: WorkspaceState = {
workspace: {
type: "personal",
},
}
const dispatchers = defineDispatchers({
changeWorkspace(_, { workspace }: { workspace: Workspace }) {
return {
workspace,
}
},
updateWorkspaceTeamName(
_,
{ workspace, newTeamName }: { workspace: Workspace; newTeamName: string }
) {
if (workspace.type === "team") {
return {
workspace: {
...workspace,
teamName: newTeamName,
},
}
}
return {
workspace,
}
},
})
export const hoppWorkspaceStore = new DispatchingStore(
initialState,
dispatchers
)
export const workspaceStatus$ = hoppWorkspaceStore.subject$.pipe(
pluck("workspace"),
distinctUntilChanged()
)
export function changeWorkspace(workspace: Workspace) {
hoppWorkspaceStore.dispatch({
dispatcher: "changeWorkspace",
payload: { workspace },
})
}
export function updateWorkspaceTeamName(
workspace: Workspace,
newTeamName: string
) {
hoppWorkspaceStore.dispatch({
dispatcher: "updateWorkspaceTeamName",
payload: { workspace, newTeamName },
})
}

View File

@@ -4,7 +4,7 @@
v-if="invalidLink"
class="flex flex-col items-center justify-center flex-1"
>
<i class="pb-2 opacity-75 material-icons">error_outline</i>
<icon-lucide-alert-triangle class="mb-2 opacity-75 svg-icons" />
<h1 class="text-center heading">
{{ t("team.invalid_invite_link") }}
</h1>
@@ -42,7 +42,7 @@
v-if="!inviteDetails.loading && E.isLeft(inviteDetails.data)"
class="flex flex-col items-center p-4"
>
<i class="mb-4 material-icons">error_outline</i>
<icon-lucide-alert-triangle class="mb-4 svg-icons" />
<p>
{{ getErrorMessage(inviteDetails.data.left) }}
</p>

View File

@@ -4,7 +4,7 @@
v-if="invalidLink"
class="flex flex-col items-center justify-center flex-1"
>
<i class="pb-2 opacity-75 material-icons">error_outline</i>
<icon-lucide-alert-triangle class="mb-2 opacity-75 svg-icons" />
<h1 class="text-center heading">
{{ t("error.invalid_link") }}
</h1>
@@ -24,7 +24,7 @@
v-if="!shortcodeDetails.loading && E.isLeft(shortcodeDetails.data)"
class="flex flex-col items-center p-4"
>
<i class="pb-2 opacity-75 material-icons">error_outline</i>
<icon-lucide-alert-triangle class="mb-2 opacity-75 svg-icons" />
<h1 class="text-center heading">
{{ t("error.invalid_link") }}
</h1>

View File

@@ -0,0 +1,3 @@
export type EnvironmentsPlatformDef = {
initEnvironmentsSync: () => void
}

View File

@@ -1,9 +1,13 @@
import { AuthPlatformDef } from "./auth"
import { UIPlatformDef } from "./ui"
import { EnvironmentsPlatformDef } from "./environments"
export type PlatformDef = {
ui?: UIPlatformDef
auth: AuthPlatformDef
sync: {
environments: EnvironmentsPlatformDef
}
}
export let platform: PlatformDef