feat: global workspace selector (#2922)

Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
Nivedin
2023-02-24 23:20:02 +05:30
committed by GitHub
parent 4ca6e9ec3a
commit 7e686a8882
35 changed files with 1130 additions and 808 deletions

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 />
<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,84 @@
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')"
:label="mdAndLarger ? t('team.invite') : ``"
: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 +218,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 +277,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

@@ -4,8 +4,8 @@
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

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

@@ -10,11 +10,13 @@
@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"
@@ -24,101 +26,63 @@
: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'
}
`"
>
<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"
@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)"
/>
</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"
: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)"
/>
</HoppSmartTab>
</HoppSmartTabs>
<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 }"
@@ -202,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"
@@ -274,7 +238,7 @@ 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()
@@ -299,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 =
@@ -310,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
@@ -379,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)
@@ -394,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) => {
@@ -403,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()
}
}
)

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

@@ -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

@@ -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,164 @@
<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"
>
<img
:src="`/images/states/${colorMode.value}/add_group.svg`"
loading="lazy"
class="inline-flex flex-col object-contain object-center w-16 h-16 mb-8"
:alt="`${t('empty.teams')}`"
/>
<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 { useColorMode } from "@composables/theming"
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 colorMode = useColorMode()
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>