Files
speckle-server/packages/frontend-2/components/settings/shared/projects/index.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

269 lines
7.6 KiB
Vue

<template>
<div>
<div class="flex flex-col-reverse md:justify-between md:flex-row md:gap-x-4">
<div class="relative w-full md:max-w-md mt-6 md:mt-0">
<FormTextInput
name="search"
:custom-icon="MagnifyingGlassIcon"
color="foundation"
search
placeholder="Search projects"
v-bind="bind"
v-on="on"
/>
</div>
<div v-tippy="createDisabledTooltip">
<FormButton :disabled="isCreateDisabled" @click="openNewProject = true">
Create
</FormButton>
</div>
</div>
<LayoutTable
class="mt-6"
:columns="[
{ id: 'name', header: 'Name', classes: 'col-span-3 truncate' },
{ id: 'created', header: 'Created', classes: 'col-span-2' },
{ id: 'modified', header: 'Modified', classes: 'col-span-2' },
{ id: 'models', header: 'Models', classes: 'col-span-1' },
{ id: 'versions', header: 'Versions', classes: 'col-span-1' },
{ id: 'contributors', header: 'Project members', classes: 'col-span-2 pr-8' },
{ id: 'actions', header: '', classes: 'absolute right-2 top-0.5' }
]"
:items="projects"
>
<template #name="{ item }">
<NuxtLink :to="projectRoute(item.id)">
{{ isProject(item) ? item.name : '' }}
</NuxtLink>
</template>
<template #created="{ item }">
<div class="text-xs">
{{ formattedFullDate(item.createdAt) }}
</div>
</template>
<template #modified="{ item }">
<div class="text-xs">
{{ formattedFullDate(item.updatedAt) }}
</div>
</template>
<template #models="{ item }">
<div class="text-xs">
{{ isProject(item) ? item.models.totalCount : '' }}
</div>
</template>
<template #versions="{ item }">
<div class="text-xs">
{{ isProject(item) ? item.versions.totalCount : '' }}
</div>
</template>
<template #contributors="{ item }">
<div v-if="isProject(item)">
<UserAvatarGroup :users="item.team.map((t) => t.user)" :max-count="3" />
</div>
</template>
<template #actions="{ item }">
<LayoutMenu
v-model:open="showActionsMenu[item.id]"
:items="actionItems[item.id]"
mount-menu-on-body
:menu-position="HorizontalDirection.Left"
:menu-id="menuId"
@chosen="({ item: actionItem }) => onActionChosen(actionItem, item)"
>
<FormButton
:color="showActionsMenu[item.id] ? 'outline' : 'subtle'"
hide-text
:icon-right="showActionsMenu[item.id] ? XMarkIcon : EllipsisHorizontalIcon"
@click.stop="toggleMenu(item.id)"
/>
</LayoutMenu>
</template>
</LayoutTable>
<ProjectsDeleteDialog
v-if="projectToModify"
v-model:open="showProjectDeleteDialog"
:project="projectToModify"
/>
<ProjectsAdd v-model:open="openNewProject" :workspace="workspace" />
</div>
</template>
<script setup lang="ts">
import { HorizontalDirection } from '~~/lib/common/composables/window'
import type {
SettingsSharedProjects_ProjectFragment,
ProjectsDeleteDialog_ProjectFragment,
SettingsSharedProjects_WorkspaceFragment
} from '~~/lib/common/generated/gql/graphql'
import {
MagnifyingGlassIcon,
EllipsisHorizontalIcon,
XMarkIcon
} from '@heroicons/vue/24/outline'
import { isProject } from '~~/lib/server-management/helpers/utils'
import { useDebouncedTextInput, type LayoutMenuItem } from '@speckle/ui-components'
import { graphql } from '~/lib/common/generated/gql'
import { useRouter } from 'vue-router'
import { projectRoute } from '~/lib/common/helpers/route'
import { useCanCreatePersonalProject } from '~/lib/projects/composables/permissions'
import { useCanCreateWorkspaceProject } from '~/lib/workspaces/composables/projects/permissions'
import type { MaybeNullOrUndefined } from '@speckle/shared'
graphql(`
fragment SettingsSharedProjects_Project on Project {
...ProjectsDeleteDialog_Project
id
name
visibility
createdAt
updatedAt
models(limit: 0) {
totalCount
}
versions(limit: 0) {
totalCount
}
team {
id
user {
name
id
avatar
}
}
permissions {
canDelete {
...FullPermissionCheckResult
}
canReadSettings {
...FullPermissionCheckResult
}
canRead {
...FullPermissionCheckResult
}
}
}
`)
graphql(`
fragment SettingsSharedProjects_Workspace on Workspace {
id
...ProjectsAdd_Workspace
}
`)
const props = defineProps<{
workspaceId: MaybeNullOrUndefined<string>
projects: MaybeNullOrUndefined<SettingsSharedProjects_ProjectFragment[]>
workspace: MaybeNullOrUndefined<SettingsSharedProjects_WorkspaceFragment>
}>()
const { activeUser } = useActiveUser()
const canCreatePersonal = useCanCreatePersonalProject({
activeUser: computed(() => activeUser.value)
})
const canCreateWorkspace = useCanCreateWorkspaceProject({
workspace: computed(() => props.workspace)
})
const isCreateDisabled = computed(() => {
if (props.workspaceId) {
return !canCreateWorkspace.canClickCreate.value
}
return !canCreatePersonal.canClickCreate.value
})
const createDisabledTooltip = computed(() => {
if (props.workspaceId) {
return canCreateWorkspace.cantClickCreateReason.value
}
return canCreatePersonal.cantClickCreateReason.value
})
const search = defineModel<string>('search')
const { on, bind } = useDebouncedTextInput({ model: search })
const router = useRouter()
const menuId = useId()
const projectToModify = ref<ProjectsDeleteDialog_ProjectFragment | null>(null)
const showProjectDeleteDialog = ref(false)
const openNewProject = ref(false)
const openProjectDeleteDialog = (item: ProjectsDeleteDialog_ProjectFragment) => {
projectToModify.value = item
showProjectDeleteDialog.value = true
}
const handleProjectClick = (id: string) => {
router.push(projectRoute(id))
}
enum ActionTypes {
ViewProject = 'view-project',
EditMembers = 'edit-members',
DeleteProject = 'delete-project'
}
const showActionsMenu = ref<Record<string, boolean>>({})
const actionItems = computed((): { [projectId: string]: LayoutMenuItem[][] } =>
(props.projects || []).reduce((ret, project) => {
const canRead = project.permissions.canRead
const canDelete = project.permissions.canDelete
const canReadSettings = project.permissions.canReadSettings
ret[project.id] = [
[
{
title: 'View project',
id: ActionTypes.ViewProject,
disabled: !canRead?.authorized,
disabledTooltip: canRead?.message
},
{
title: 'Edit members',
id: ActionTypes.EditMembers,
disabled: !canReadSettings?.authorized,
disabledTooltip: canReadSettings?.message
},
{
title: 'Delete project...',
id: ActionTypes.DeleteProject,
disabled: !canDelete?.authorized,
disabledTooltip: canDelete?.message
}
]
]
return ret
}, {} as { [projectId: string]: LayoutMenuItem[][] })
)
const onActionChosen = (
actionItem: LayoutMenuItem,
project: ProjectsDeleteDialog_ProjectFragment
) => {
if (actionItem.id === ActionTypes.EditMembers) {
router.push(projectRoute(project.id, 'collaborators'))
} else if (actionItem.id === ActionTypes.ViewProject) {
handleProjectClick(project.id)
} else if (actionItem.id === ActionTypes.DeleteProject) {
openProjectDeleteDialog(project)
}
}
const toggleMenu = (itemId: string) => {
showActionsMenu.value[itemId] = !showActionsMenu.value[itemId]
}
</script>