Skip to content

Commit

Permalink
feat(fe2): workspace project list (#2616)
Browse files Browse the repository at this point in the history
* Create and Use ProjectList

* ProjectList and WorkspaceList

* Workspace Header

* Header Actions Menu

* Add projects to Workspace

* Add middleware

* Remove unused title

* Rename WorkspaceList

* useDebouncedSearch

* Merge ProjectList into Dashboard

* Make workspaceId reactive

* Remove unneeded useSubscription

* Merge Dashboard into index

* Add fragments

* Cache updates

* gql

* GQL

* Linting updates

* Updates from CR

* Changes from CR

* Changes from PR. Middleware added

* Updates from CR

* GQL

* Updates from CR

* Updates from CR

* Updates from CR

* Add id to WorkspaceHeader_Workspace

* GQL

* Fragment naming

* Use identifier

* Comment buttons not yet ready

* Fix problem with pagination
  • Loading branch information
andrewwallacespeckle authored Aug 14, 2024
1 parent afe1d19 commit e3f9037
Show file tree
Hide file tree
Showing 15 changed files with 532 additions and 79 deletions.
7 changes: 6 additions & 1 deletion packages/frontend-2/components/projects/AddDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ type FormValues = {
description?: string
}
const props = defineProps<{
workspaceId?: string
}>()
const emit = defineEmits<{
(e: 'created'): void
}>()
Expand All @@ -60,7 +64,8 @@ const mp = useMixpanel()
const onSubmit = handleSubmit(async (values) => {
await createProject({
...values,
visibility: visibility.value
visibility: visibility.value,
workspaceId: props.workspaceId
})
emit('created')
mp.track('Stream Action', { type: 'action', name: 'create' })
Expand Down
129 changes: 59 additions & 70 deletions packages/frontend-2/components/projects/Dashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,41 +18,36 @@
</div>

<PromoBannersWrapper v-if="promoBanners.length" :banners="promoBanners" />
<div
v-if="!showEmptyState"
class="flex flex-col space-y-2 md:flex-row md:items-center mb-8 pt-4"
>
<h1 class="text-heading-xl">Projects</h1>

<div
class="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:items-center sm:space-x-2 grow md:justify-end"
>
<FormTextInput
v-model="search"
name="modelsearch"
:show-label="false"
placeholder="Search projects..."
color="foundation"
wrapper-classes="grow md:grow-0 md:w-60"
:show-clear="!!search"
@change="updateSearchImmediately"
@update:model-value="updateDebouncedSearch"
></FormTextInput>
<div class="flex items-center space-x-2">

<div v-if="!showEmptyState" class="flex flex-col gap-4">
<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 sm:flex-row gap-2 sm:items-center justify-between">
<div class="flex flex-col sm: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"
></FormTextInput>
<FormSelectProjectRoles
v-if="!showEmptyState"
v-model="selectedRoles"
class="w-56 grow md:grow-0"
class="md:w-56 grow md:grow-0"
fixed-height
/>
<FormButton
v-if="!isGuest"
:icon-left="PlusIcon"
@click="openNewProject = true"
>
New project
</FormButton>
</div>
<FormButton v-if="!isGuest" @click="openNewProject = true">
New project
</FormButton>
</div>
</div>
<CommonLoadingBar :loading="showLoadingBar" class="my-2" />
Expand All @@ -71,16 +66,15 @@
<ProjectsAddDialog v-model:open="openNewProject" />
</div>
</template>

<script setup lang="ts">
import { PlusIcon } from '@heroicons/vue/24/outline'
import {
useApolloClient,
useQuery,
useQueryLoading,
useSubscription
} from '@vue/apollo-composable'
import { projectsDashboardQuery } from '~~/lib/projects/graphql/queries'
import { debounce } from 'lodash-es'
import { graphql } from '~~/lib/common/generated/gql'
import {
getCacheId,
Expand All @@ -92,32 +86,23 @@ import { UserProjectsUpdatedMessageType } from '~~/lib/common/generated/gql/grap
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
import { projectRoute } from '~~/lib/common/helpers/route'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
import type { InfiniteLoaderState } from '~~/lib/global/helpers/components'
import type { Nullable, Optional, StreamRoles } from '@speckle/shared'
import { useSynchronizedCookie } from '~~/lib/common/composables/reactiveCookie'
import type { PromoBanner } from '~/lib/promo-banners/types'
const onUserProjectsUpdateSubscription = graphql(`
subscription OnUserProjectsUpdate {
userProjectsUpdated {
type
id
project {
...ProjectDashboardItem
}
}
}
`)
import { useDebouncedTextInput, type InfiniteLoaderState } from '@speckle/ui-components'
import { MagnifyingGlassIcon, Squares2X2Icon } from '@heroicons/vue/24/outline'
const logger = useLogger()
const infiniteLoaderId = ref('')
const cursor = ref(null as Nullable<string>)
const selectedRoles = ref(undefined as Optional<StreamRoles[]>)
const search = ref('')
const debouncedSearch = ref('')
const openNewProject = ref(false)
const showLoadingBar = ref(false)
const { activeUser, isGuest } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const areQueriesLoading = useQueryLoading()
const apollo = useApolloClient().client
const promoBanners = ref<PromoBanner[]>([
{
Expand All @@ -130,18 +115,22 @@ const promoBanners = ref<PromoBanner[]>([
}
])
const { activeUser, isGuest } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const areQueriesLoading = useQueryLoading()
const apollo = useApolloClient().client
const {
on,
bind,
value: search
} = useDebouncedTextInput({
debouncedBy: 800
})
const {
result: projectsPanelResult,
fetchMore: fetchMoreProjects,
onResult: onProjectsResult,
variables: projectsVariables
} = useQuery(projectsDashboardQuery, () => ({
filter: {
search: (debouncedSearch.value || '').trim() || null,
search: (search.value || '').trim() || null,
onlyWithRoles: selectedRoles.value?.length ? selectedRoles.value : null
}
}))
Expand All @@ -152,7 +141,17 @@ onProjectsResult((res) => {
})
const { onResult: onUserProjectsUpdate } = useSubscription(
onUserProjectsUpdateSubscription
graphql(`
subscription OnUserProjectsUpdate {
userProjectsUpdated {
type
id
project {
...ProjectDashboardItem
}
}
}
`)
)
const projects = computed(() => projectsPanelResult.value?.activeUser?.projects)
Expand All @@ -171,19 +170,6 @@ const moreToLoad = computed(
cursor.value
)
const updateDebouncedSearch = debounce(() => {
debouncedSearch.value = search.value.trim()
}, 1000)
const updateSearchImmediately = () => {
updateDebouncedSearch.cancel()
debouncedSearch.value = search.value.trim()
}
const onDismissNewSpeckleBanner = () => {
hasDismissedNewSpeckleBanner.value = true
}
onUserProjectsUpdate((res) => {
const activeUserId = activeUser.value?.id
const event = res.data?.userProjectsUpdated
Expand Down Expand Up @@ -278,6 +264,11 @@ watch(search, (newVal) => {
watch(areQueriesLoading, (newVal) => (showLoadingBar.value = newVal))
const clearSearch = () => {
search.value = ''
selectedRoles.value = []
}
const hasCompletedChecklistV1 = useSynchronizedCookie<boolean>(
`hasCompletedChecklistV1`,
{
Expand Down Expand Up @@ -315,7 +306,7 @@ const showChecklist = computed(() => {
if (hasDismissedChecklistTime.value === undefined) return true
if (
hasDismissedChecklistTime.value !== undefined &&
hasDismissedChecklistTimeAgo.value > 86400000 // 10_0000 // 86400000
hasDismissedChecklistTimeAgo.value > 86400000
)
return true
return false
Expand All @@ -328,9 +319,7 @@ const showNewSpeckleBanner = computed(() => {
return true
})
const clearSearch = () => {
search.value = ''
selectedRoles.value = []
updateSearchImmediately()
const onDismissNewSpeckleBanner = () => {
hasDismissedNewSpeckleBanner.value = true
}
</script>
134 changes: 134 additions & 0 deletions packages/frontend-2/components/workspace/ProjectList.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<template>
<div>
<WorkspaceHeader
v-if="workspace"
:icon="Squares2X2Icon"
:workspace-info="workspace"
/>
<div class="flex flex-col gap-4 mt-4">
<div class="flex flex-col sm:flex-row gap-2 sm:items-center justify-between">
<div class="flex flex-col sm: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
v-bind="bind"
v-on="on"
></FormTextInput>
</div>
<FormButton v-if="!isGuest" @click="openNewProject = true">
New project
</FormButton>
</div>
</div>

<CommonLoadingBar :loading="showLoadingBar" class="my-2" />

<ProjectsDashboardEmptyState
v-if="showEmptyState"
@create-project="openNewProject = true"
/>

<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="workspaceId" />
</div>
</template>

<script setup lang="ts">
import { MagnifyingGlassIcon, Squares2X2Icon } from '@heroicons/vue/24/outline'
import { useQuery, useQueryLoading } from '@vue/apollo-composable'
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
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 {
WorkspaceProjectList_ProjectCollectionFragment,
WorkspaceProjectsQueryQueryVariables
} from '~~/lib/common/generated/gql/graphql'
graphql(`
fragment WorkspaceProjectList_ProjectCollection on ProjectCollection {
totalCount
items {
...ProjectDashboardItem
}
cursor
}
`)
const selectedRoles = ref(undefined as Optional<StreamRoles[]>)
const openNewProject = ref(false)
const { isGuest } = useActiveUser()
const areQueriesLoading = useQueryLoading()
const props = defineProps<{
workspaceId: string
}>()
const {
on,
bind,
value: search
} = useDebouncedTextInput({
debouncedBy: 800
})
const { result: initialQueryResult } = useQuery(workspacePageQuery, {
workspaceId: props.workspaceId,
filter: {
search: (search.value || '').trim() || null
}
})
const { query, identifier, onInfiniteLoad } = usePaginatedQuery<
{ workspace: { projects: WorkspaceProjectList_ProjectCollectionFragment } },
WorkspaceProjectsQueryQueryVariables
>({
query: workspaceProjectsQuery,
baseVariables: computed(() => ({
workspaceId: props.workspaceId,
filter: {
search: (search.value || '').trim() || null
}
})),
resolveKey: (vars: WorkspaceProjectsQueryQueryVariables) => ({
workspaceId: vars.workspaceId,
search: vars.filter?.search || ''
}),
resolveInitialResult: () => initialQueryResult.value?.workspace.projects,
resolveCurrentResult: (result) => result?.workspace?.projects,
resolveNextPageVariables: (baseVariables, newCursor) => ({
...baseVariables,
cursor: newCursor
}),
resolveCursorFromVariables: (vars) => vars.cursor
})
const workspace = computed(() => initialQueryResult.value?.workspace)
const projects = computed(() => query.result.value?.workspace?.projects)
const showEmptyState = computed(() => !projects.value?.items?.length)
const showLoadingBar = computed(() => {
return areQueriesLoading.value && (!!search.value || !projects.value?.items?.length)
})
const clearSearch = () => {
search.value = ''
selectedRoles.value = []
}
</script>
Loading

0 comments on commit e3f9037

Please sign in to comment.