Files
speckle-server/packages/frontend-2/components/projects/Dashboard.vue
T
Kristaps Fabians Geikins 596312ab0e feat(frontend): personal project limit disclaimers & prompts (#4822)
* ProjectsAdd wrapper

* WorkspaceMoveProject wrapper added

* move wrapper finalized

* passing through location

* more cleanup

* model add wrapper

* permissions cleanup

* add invite wrapper

* vue-tippy bugfix

* ViewerLimitsDialog prep

* upgrade limit alert prep

* limit alerts

* movemanager fix

* new add flow

* slug update fix

* add model flow

* invites?

* some extra fixes

* move unmount fix?

* more fixes

* vue-tsc update

* style: remove h-32 for smaller screens

* vue-tsc parser fix

* prep for new viewer limits dialog

* updated viewer dialogs

* comment variant cleanup

* CR comments

---------

Co-authored-by: michalspeckle <michal@speckle.systems>
2025-05-28 12:12:18 +03:00

235 lines
6.8 KiB
Vue

<template>
<div>
<Portal to="primary-actions"></Portal>
<div v-if="!showEmptyState" class="flex flex-col gap-4">
<ProjectsMoveToWorkspaceAlert
v-if="isWorkspacesEnabled"
@move-project="(id) => onMoveProject(id, 'projects')"
/>
<div class="flex items-center gap-2 mb-2">
<Squares2X2Icon class="h-5 w-5" />
<h1 class="text-heading-lg">Projects</h1>
</div>
<div class="flex flex-col lg:flex-row gap-2 lg:items-center justify-between">
<div class="flex flex-col md:flex-row gap-2">
<FormTextInput
name="modelsearch"
:show-label="false"
placeholder="Search..."
:custom-icon="MagnifyingGlassIcon"
color="foundation"
wrapper-classes="grow md:grow-0 md:w-60"
:show-clear="!!search"
v-bind="bind"
v-on="on"
/>
<FormSelectProjectRoles
v-if="!showEmptyState"
v-model="selectedRoles"
class="md:w-56 grow md:grow-0"
allow-unset
fixed-height
clearable
/>
<div v-if="!showEmptyState && isWorkspacesEnabled" class="md:mt-1">
<FormCheckbox
id="projects-to-move"
v-model="filterProjectsToMove"
label-classes="!font-normal select-none"
name="Projects to move"
/>
</div>
</div>
<FormButton v-if="canClickCreate" @click="onClickCreate">
New project
</FormButton>
</div>
</div>
<CommonLoadingBar :loading="showLoadingBar" class="my-2" />
<ProjectsHiddenProjectWarning
v-if="projectsPanelResult?.activeUser && projects?.numberOfHidden"
:hidden-item-count="projectsPanelResult.activeUser.projects.numberOfHidden"
:user="projectsPanelResult.activeUser"
/>
<ProjectsDashboardEmptyState
v-if="showEmptyState"
:can-create-project="canClickCreate"
@create-project="onClickCreate"
/>
<template v-else-if="projects?.items?.length">
<ProjectsDashboardFilled
:projects="projects"
show-workspace-link
@move-project="(id) => onMoveProject(id, 'project_card')"
/>
<InfiniteLoading
:settings="{ identifier: infiniteLoaderId }"
@infinite="infiniteLoad"
/>
</template>
<CommonEmptySearchState v-else-if="!showLoadingBar" @clear-search="clearSearch" />
<ProjectsAdd
v-if="projectsPanelResult?.activeUser"
v-model:open="showCreateNewProjectDialog"
/>
<WorkspaceMoveProject
v-if="showMoveProjectDialog"
v-model:open="showMoveProjectDialog"
:project="emittedProject"
/>
</div>
</template>
<script setup lang="ts">
import { useQuery, useQueryLoading } from '@vue/apollo-composable'
import { projectsDashboardQuery } from '~~/lib/projects/graphql/queries'
import { graphql } from '~~/lib/common/generated/gql'
import type { Nullable, Optional, StreamRoles } from '@speckle/shared'
import { useDebouncedTextInput, type InfiniteLoaderState } from '@speckle/ui-components'
import { MagnifyingGlassIcon, Squares2X2Icon } from '@heroicons/vue/24/outline'
import { useUserProjectsUpdatedTracking } from '~~/lib/user/composables/projectUpdates'
import { useMixpanel } from '~/lib/core/composables/mp'
import { useCanCreatePersonalProject } from '~~/lib/projects/composables/permissions'
import type { ProjectsDashboardQueryQuery } from '~/lib/common/generated/gql/graphql'
import type { Get } from 'type-fest'
graphql(`
fragment ProjectsDashboard_UserProjectCollection on UserProjectCollection {
numberOfHidden
}
`)
graphql(`
fragment ProjectsDashboard_User on User {
...ProjectsAdd_User
permissions {
canCreatePersonalProject {
...FullPermissionCheckResult
}
}
}
`)
const logger = useLogger()
const infiniteLoaderId = ref('')
const cursor = ref(null as Nullable<string>)
const selectedRoles = ref(undefined as Optional<StreamRoles[]>)
const filterProjectsToMove = ref(false)
const showLoadingBar = ref(false)
const showMoveProjectDialog = ref(false)
const emittedProject =
ref<Get<ProjectsDashboardQueryQuery, 'activeUser.projects.items[0]'>>()
const areQueriesLoading = useQueryLoading()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const showCreateNewProjectDialog = ref(false)
useUserProjectsUpdatedTracking()
const {
on,
bind,
value: search
} = useDebouncedTextInput({
debouncedBy: 800
})
const {
result: projectsPanelResult,
fetchMore: fetchMoreProjects,
onResult: onProjectsResult,
variables: projectsVariables
} = useQuery(projectsDashboardQuery, () => ({
filter: {
search: (search.value || '').trim() || null,
onlyWithRoles: filterProjectsToMove.value
? ['stream:owner']
: selectedRoles.value?.length
? selectedRoles.value
: null,
personalOnly: isWorkspacesEnabled.value
},
cursor: null as Nullable<string>
}))
const { canClickCreate } = useCanCreatePersonalProject({
activeUser: computed(() => projectsPanelResult.value?.activeUser)
})
onProjectsResult((res) => {
cursor.value = res.data?.activeUser?.projects.cursor || null
infiniteLoaderId.value = JSON.stringify(projectsVariables.value?.filter || {})
})
const projects = computed(() => projectsPanelResult.value?.activeUser?.projects)
const showEmptyState = computed(() => {
const isFiltering =
projectsVariables.value?.filter?.onlyWithRoles?.length ||
projectsVariables.value?.filter?.search?.length
if (isFiltering) return false
return projects.value && !projects.value.items.length
})
const moreToLoad = computed(
() =>
(!projects.value || projects.value.items.length < projects.value.totalCount) &&
cursor.value
)
const infiniteLoad = async (state: InfiniteLoaderState) => {
if (!moreToLoad.value) return state.complete()
try {
await fetchMoreProjects({
variables: {
cursor: cursor.value
}
})
} catch (e) {
logger.error(e)
state.error()
return
}
state.loaded()
if (!moreToLoad.value) {
state.complete()
}
}
const mixpanel = useMixpanel()
const onMoveProject = (projectId: string | undefined, location: string) => {
const project = projectId
? projects.value?.items.find((p) => p.id === projectId)
: undefined
emittedProject.value = project || undefined
mixpanel.track('Move Project CTA Clicked', {
location,
// eslint-disable-next-line camelcase
workspace_id: project?.workspace?.id || undefined
})
showMoveProjectDialog.value = true
}
watch(search, (newVal) => {
if (newVal) showLoadingBar.value = true
else showLoadingBar.value = false
})
watch(areQueriesLoading, (newVal) => (showLoadingBar.value = newVal))
const clearSearch = () => {
search.value = ''
selectedRoles.value = []
}
const onClickCreate = () => {
showCreateNewProjectDialog.value = true
}
</script>