Files
speckle-server/packages/frontend-2/components/workspace/ProjectList.vue
T
andrewwallacespeckle 484713ea71 refactor(fe2): Make it easier to find workspace settings (#3260)
* remove manage and make avatars clickable

* Add Settings button to workspaces header

* Cleaner workspace header incl. no copy link action

* Decrease width of version bar and avatar groups

* Merge

* Updates to tooltips

* Add ... Hide action from guests

* Hide avatargroup from guests

---------

Co-authored-by: Benjamin Ottensten <benjamin.ottensten@gmail.com>
2024-10-15 16:03:34 +01:00

283 lines
8.7 KiB
Vue

<template>
<div>
<div v-if="workspaceInvite" class="flex justify-center">
<WorkspaceInviteBlock :invite="workspaceInvite" />
</div>
<template v-else>
<Portal v-if="workspace?.name" to="navigation">
<HeaderNavLink
:to="workspaceRoute(workspaceSlug)"
:name="workspace?.name"
:separator="false"
/>
</Portal>
<WorkspaceHeader
v-if="workspace"
:icon="Squares2X2Icon"
:workspace-info="workspace"
@show-invite-dialog="showInviteDialog = true"
@show-settings-dialog="onShowSettingsDialog"
@show-move-projects-dialog="showMoveProjectsDialog = true"
/>
<div class="flex flex-col gap-4 mt-4">
<div class="flex flex-row gap-2 sm:items-center justify-between">
<FormTextInput
name="modelsearch"
:show-label="false"
placeholder="Search..."
:custom-icon="MagnifyingGlassIcon"
color="foundation"
wrapper-classes="grow md:grow-0 md:w-60"
show-clear
v-bind="bind"
v-on="on"
/>
<div class="flex gap-2">
<!--- Conditionally apply tooltip only for non-admins and avoid v-tippy reactivity bug -->
<div v-if="!isWorkspaceAdmin" v-tippy="'You must be a workspace admin'">
<FormButton
:disabled="!isWorkspaceAdmin"
class="hidden md:block"
color="outline"
@click="showMoveProjectsDialog = true"
>
Move projects
</FormButton>
</div>
<FormButton
v-else
class="hidden md:block"
color="subtle"
@click="showMoveProjectsDialog = true"
>
Move projects
</FormButton>
<FormButton v-if="!isWorkspaceGuest" @click="openNewProject = true">
New project
</FormButton>
</div>
</div>
</div>
<CommonLoadingBar :loading="showLoadingBar" class="my-2" />
<section
v-if="showEmptyState"
class="flex flex-col items-center justify-center py-8 md:py-16"
>
<h3 class="text-heading-lg text-foreground">
Welcome to your new workspace. Let's set it up for a success...
</h3>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 pt-5 mt-4 max-w-5xl">
<CommonCard
v-for="emptyStateItem in emptyStateItems"
:key="emptyStateItem.title"
:title="emptyStateItem.title"
:description="emptyStateItem.description"
:buttons="emptyStateItem.buttons"
/>
</div>
</section>
<template v-else-if="projects?.items?.length">
<ProjectsDashboardFilled :projects="projects" />
<InfiniteLoading :settings="{ identifier }" @infinite="onInfiniteLoad" />
</template>
<CommonEmptySearchState v-else-if="!showLoadingBar" @clear-search="clearSearch" />
<ProjectsAddDialog v-model:open="openNewProject" :workspace-id="workspace?.id" />
<template v-if="workspace">
<WorkspaceInviteDialog
v-model:open="showInviteDialog"
:workspace-id="workspace.id"
:workspace="workspace"
/>
<SettingsDialog
v-model:open="showSettingsDialog"
:target-menu-item="settingsDialogTarget"
:target-workspace-id="workspace.id"
/>
<WorkspaceMoveProjectsDialog
v-model:open="showMoveProjectsDialog"
:workspace="workspace"
/>
</template>
</template>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon, Squares2X2Icon } from '@heroicons/vue/24/outline'
import { useQuery, useQueryLoading } from '@vue/apollo-composable'
import type { Optional, StreamRoles } from '@speckle/shared'
import {
workspacePageQuery,
workspaceProjectsQuery
} from '~~/lib/workspaces/graphql/queries'
import { useDebouncedTextInput } from '@speckle/ui-components'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { graphql } from '~~/lib/common/generated/gql'
import type { WorkspaceProjectsQueryQueryVariables } from '~~/lib/common/generated/gql/graphql'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { Roles } from '@speckle/shared'
import { useWorkspacesMixpanel } from '~/lib/workspaces/composables/mixpanel'
import {
SettingMenuKeys,
type AvailableSettingsMenuKeys
} from '~/lib/settings/helpers/types'
graphql(`
fragment WorkspaceProjectList_ProjectCollection on ProjectCollection {
totalCount
items {
...ProjectDashboardItem
}
cursor
}
`)
const { workspaceMixpanelUpdateGroup } = useWorkspacesMixpanel()
const areQueriesLoading = useQueryLoading()
const route = useRoute()
const {
on,
bind,
value: search
} = useDebouncedTextInput({
debouncedBy: 800
})
const props = defineProps<{
workspaceSlug: string
}>()
const showMoveProjectsDialog = ref(false)
const selectedRoles = ref(undefined as Optional<StreamRoles[]>)
const openNewProject = ref(false)
const showInviteDialog = ref(false)
const showSettingsDialog = ref(false)
const settingsDialogTarget = ref<AvailableSettingsMenuKeys>(
SettingMenuKeys.Workspace.General
)
const token = computed(() => route.query.token as Optional<string>)
const pageFetchPolicy = usePageQueryStandardFetchPolicy()
const { result: initialQueryResult, onResult } = useQuery(
workspacePageQuery,
() => ({
workspaceSlug: props.workspaceSlug,
filter: {
search: (search.value || '').trim() || null
},
token: token.value || null
}),
() => ({
fetchPolicy: pageFetchPolicy.value
})
)
const { query, identifier, onInfiniteLoad } = usePaginatedQuery({
query: workspaceProjectsQuery,
baseVariables: computed(() => ({
workspaceSlug: props.workspaceSlug,
filter: {
search: (search.value || '').trim() || null
}
})),
resolveKey: (vars: WorkspaceProjectsQueryQueryVariables) => ({
workspaceSlug: vars.workspaceSlug,
search: vars.filter?.search || ''
}),
resolveInitialResult: () =>
initialQueryResult.value?.workspaceBySlug.projectListProject,
resolveCurrentResult: (result) => result?.workspaceBySlug?.projects,
resolveNextPageVariables: (baseVariables, newCursor) => ({
...baseVariables,
cursor: newCursor
}),
resolveCursorFromVariables: (vars) => vars.cursor
})
const projects = computed(() => query.result.value?.workspaceBySlug?.projects)
const workspaceInvite = computed(() => initialQueryResult.value?.workspaceInvite)
const workspace = computed(() => initialQueryResult.value?.workspaceBySlug)
const isWorkspaceGuest = computed(() => workspace.value?.role === Roles.Workspace.Guest)
const isWorkspaceAdmin = computed(() => workspace.value?.role === Roles.Workspace.Admin)
const showEmptyState = computed(() => {
if (search.value) return false
return projects.value && !projects.value?.items?.length
})
const showLoadingBar = computed(() => {
const isLoading =
areQueriesLoading.value || (!!search.value && !projects.value?.items?.length)
return isLoading
})
const emptyStateItems = computed(() => [
{
title: 'Set up verified domains',
description:
'Manage your team and allow them to join your workspace automatically based on email domain policies.',
buttons: [
{
text: 'Manage domains',
onClick: () => onShowSettingsDialog(SettingMenuKeys.Workspace.Security),
disabled: workspace.value?.role !== Roles.Workspace.Admin
}
]
},
{
title: 'Make it a space for your entire team',
description:
'Nothing great is made alone. Safely collaborate with your entire team and manage guests.',
buttons: [
{
text: 'Invite members & guests',
onClick: () => (showInviteDialog.value = true),
disabled: isWorkspaceGuest.value
}
]
},
{
title: 'Add your first project',
description:
'Projects are the place where your models and their versions live. Add one and start creating.',
buttons: [
{
text: 'Move project',
onClick: () => (showMoveProjectsDialog.value = true),
disabled: !isWorkspaceAdmin.value
},
{
text: 'New project',
onClick: () => (openNewProject.value = true),
disabled: isWorkspaceGuest.value
}
]
}
])
const clearSearch = () => {
search.value = ''
selectedRoles.value = []
}
const onShowSettingsDialog = (target: AvailableSettingsMenuKeys) => {
showSettingsDialog.value = true
settingsDialogTarget.value = target
}
onResult((queryResult) => {
if (queryResult.data?.workspaceBySlug) {
workspaceMixpanelUpdateGroup(queryResult.data.workspaceBySlug)
}
})
</script>