Files
speckle-server/packages/frontend-2/components/viewer/saved-views/panel/View.vue
T
Kristaps Fabians Geikins 185806ec64 feat: embedding saved views (#5690)
* init + new API query

* embed dialog works

* embed actions menu

* embed label

* WIP positioning fix:

* cleanup

* embed options finalized

* prevent reset in embed mode

* more embed UX improverments

* tests fix
2025-10-08 10:49:26 +03:00

394 lines
11 KiB
Vue

<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div
v-keyboard-clickable
:class="[wrapperClasses, draggableClasses, draggableTargetClasses]"
:view-id="view.id"
draggable="true"
v-on="{ ...on, ...targetOn }"
@click="apply"
>
<div class="flex items-center shrink-0">
<div class="relative">
<img
:src="view.thumbnailUrl"
alt="View screenshot"
class="w-20 h-[60px] object-cover rounded border border-outline-3 bg-foundation-page cursor-pointer"
/>
<div
v-if="isHomeView && !isFederatedView"
class="absolute -top-1 -left-1 bg-orange-500 w-4 h-4 flex items-center justify-center rounded-[3px]"
>
<IconHome class="w-3 h-3" />
</div>
</div>
</div>
<div class="flex flex-col min-w-0 grow gap-y-0.5">
<div class="text-body-2xs font-medium text-foreground truncate grow-0">
{{ view.name }}
</div>
<div class="text-body-2xs text-foreground-3 truncate">
{{ view.author?.name }}
</div>
<div class="w-full flex items-center gap-1">
<User
v-if="isOnlyVisibleToMe"
v-tippy="getTooltipProps('Only visible to you')"
:size="12"
:stroke-width="1.5"
:absolute-stroke-width="true"
class="w-3 h-3 text-foreground-3 shrink-0"
/>
<div
v-tippy="{
content: formattedFullDate(view.updatedAt),
delay: [700, 100],
duration: [120, 150],
offset: [0, 2],
placement: 'right'
}"
class="text-body-2xs text-foreground-3 truncate pr-1.5"
>
{{ formattedRelativeDate(view.updatedAt, { capitalize: true }) }}
</div>
</div>
</div>
<div
class="flex gap-0.5 items-center opacity-0 w-0 group-hover:opacity-100 group-hover:w-auto"
@click.stop
>
<LayoutMenu
v-model:open="showMenu"
:items="menuItems"
:menu-id="menuId"
mount-menu-on-body
show-ticks="right"
:size="230"
class="shrink-0"
@chosen="({ item: actionItem }) => onActionChosen(actionItem)"
>
<FormButton
size="sm"
color="subtle"
:icon-left="Ellipsis"
hide-text
name="viewActions"
class="shrink-0"
@click="showMenu = !showMenu"
/>
</LayoutMenu>
<div
v-tippy="
getTooltipProps(
canOpenEditDialog?.authorized
? 'Edit view'
: canOpenEditDialog?.errorMessage
)
"
class="shrink-0 opacity-0 group-hover:opacity-100"
>
<FormButton
size="sm"
color="subtle"
:icon-left="SquarePen"
hide-text
name="editView"
class="shrink-0"
:disabled="!canOpenEditDialog?.authorized"
@click="onEdit"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
StringEnum,
throwUncoveredError,
type Optional,
type StringEnumValues
} from '@speckle/shared'
import type { LayoutMenuItem } from '@speckle/ui-components'
import { useMutationLoading } from '@vue/apollo-composable'
import { difference } from 'lodash-es'
import { Ellipsis, SquarePen, User } from 'lucide-vue-next'
import { graphql } from '~/lib/common/generated/gql'
import {
SavedViewVisibility,
type ViewerSavedViewsPanelView_SavedViewFragment
} from '~/lib/common/generated/gql/graphql'
import { useMixpanel } from '~/lib/core/composables/mp'
import { useViewerSavedViewsUtils } from '~/lib/viewer/composables/savedViews/general'
import {
useCollectNewSavedViewViewerData,
useUpdateSavedView
} from '~/lib/viewer/composables/savedViews/management'
import {
useDraggableView,
useDraggableViewTargetView
} from '~/lib/viewer/composables/savedViews/ui'
import { useSavedViewValidationHelpers } from '~/lib/viewer/composables/savedViews/validation'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
const MenuItems = StringEnum([
'Delete',
'LoadOriginalVersions',
'CopyLink',
'ChangeVisibility',
'ReplaceView',
'MoveToGroup',
'SetAsHomeView',
'Embed'
])
type MenuItems = StringEnumValues<typeof MenuItems>
const { getTooltipProps } = useSmartTooltipDelay()
graphql(`
fragment ViewerSavedViewsPanelView_SavedView on SavedView {
id
name
description
thumbnailUrl
visibility
isHomeView
resourceIds
author {
id
name
}
updatedAt
permissions {
canUpdate {
...FullPermissionCheckResult
}
}
...UseDeleteSavedView_SavedView
...UseUpdateSavedView_SavedView
...ViewerSavedViewsPanelViewEditDialog_SavedView
...UseSavedViewValidationHelpers_SavedView
...UseDraggableView_SavedView
}
`)
const props = defineProps<{
view: ViewerSavedViewsPanelView_SavedViewFragment
}>()
const {
resources: {
response: { savedView, isFederatedView, resourceItemsIds, project }
}
} = useInjectedViewerState()
const { collect } = useCollectNewSavedViewViewerData()
const updateView = useUpdateSavedView()
const isLoading = useMutationLoading()
const { copyLink, applyView } = useViewerSavedViewsUtils()
const eventBus = useEventBus()
const { formattedRelativeDate, formattedFullDate } = useDateFormatters()
const {
canUpdate,
isOnlyVisibleToMe,
canSetHomeView,
isHomeView,
canToggleVisibility,
canMove,
canOpenEditDialog,
canEmbed
} = useSavedViewValidationHelpers({
view: computed(() => props.view)
})
const { classes: draggableClasses, on } = useDraggableView({
view: computed(() => props.view)
})
const { classes: draggableTargetClasses, on: targetOn } = useDraggableViewTargetView({
view: computed(() => props.view)
})
const mp = useMixpanel()
const showMenu = ref(false)
const menuId = useId()
const isActive = computed(() => props.view.id === savedView.value?.id)
const isOriginalVersionAlreadyLoaded = computed(() => {
const viewResources = props.view.resourceIds
const currentlyLoadedResources = resourceItemsIds.value
return difference(viewResources, currentlyLoadedResources).length === 0
})
const canLoadOriginal = computed(
(): { authorized: boolean; message: Optional<string> } => {
if (isOriginalVersionAlreadyLoaded.value) {
return { authorized: false, message: 'Original version is already loaded' }
}
return { authorized: true, message: undefined }
}
)
const menuItems = computed((): LayoutMenuItem<MenuItems>[][] => [
[
{
id: MenuItems.MoveToGroup,
title: 'Move to group',
disabled: !canMove.value?.authorized || isLoading.value,
disabledTooltip: canMove.value?.errorMessage
},
{
id: MenuItems.ReplaceView,
title: 'Replace view',
disabled: !canUpdate.value?.authorized || isLoading.value,
disabledTooltip: canUpdate.value?.errorMessage
},
{
id: MenuItems.CopyLink,
title: 'Copy link'
},
{
id: MenuItems.LoadOriginalVersions,
title: 'Load with original model version',
disabled: !canLoadOriginal.value.authorized || isLoading.value,
disabledTooltip: canLoadOriginal.value.message
}
],
[
{
id: MenuItems.SetAsHomeView,
title: 'Set as home view',
active: !!isHomeView.value,
disabled: !canSetHomeView.value.authorized,
disabledTooltip: canSetHomeView.value.message
},
{
id: MenuItems.ChangeVisibility,
title: isOnlyVisibleToMe.value ? 'Make view shared' : 'Make view private',
disabled: !canToggleVisibility.value.authorized,
disabledTooltip: canToggleVisibility.value.message
},
{
id: MenuItems.Embed,
title: 'Embed view',
disabled: !canEmbed.value?.authorized,
disabledTooltip: canEmbed.value?.errorMessage
}
],
[
{
id: MenuItems.Delete,
title: 'Delete view...',
disabled: !canUpdate.value?.authorized || isLoading.value,
disabledTooltip: canUpdate.value?.errorMessage
}
]
])
const wrapperClasses = computed(() => {
const classParts = [
'flex items-center gap-2 p-2 w-full group rounded-md cursor-pointer relative transition-all'
]
if (isActive.value) {
classParts.push('bg-highlight-2 hover:bg-highlight-3')
} else {
classParts.push('hover:bg-highlight-1')
}
return classParts.join(' ')
})
const onActionChosen = async (item: LayoutMenuItem<MenuItems>) => {
switch (item.id) {
case MenuItems.Delete:
eventBus.emit(ViewerEventBusKeys.MarkSavedViewForEdit, {
type: 'delete',
view: props.view
})
break
case MenuItems.CopyLink:
await copyLink({
settings: {
id: props.view.id
}
})
mp.track('Saved View Link Copied', {
viewId: props.view.id,
// eslint-disable-next-line camelcase
workspace_id: project.value?.workspaceId
})
break
case MenuItems.LoadOriginalVersions:
applyView({
id: props.view.id,
loadOriginal: true
})
mp.track('Saved View Original Version Loaded', {
viewId: props.view.id,
// eslint-disable-next-line camelcase
workspace_id: project.value?.workspaceId
})
break
case MenuItems.ChangeVisibility:
await updateView({
view: props.view,
input: {
id: props.view.id,
projectId: props.view.projectId,
visibility: isOnlyVisibleToMe.value
? SavedViewVisibility.Public
: SavedViewVisibility.AuthorOnly
}
})
break
case MenuItems.ReplaceView:
// Replace view w/ active one
await updateView({
view: props.view,
input: {
id: props.view.id,
...(await collect())
}
})
break
case MenuItems.MoveToGroup:
eventBus.emit(ViewerEventBusKeys.MarkSavedViewForEdit, {
type: 'move',
view: props.view
})
break
case MenuItems.SetAsHomeView:
await updateView({
view: props.view,
input: {
id: props.view.id,
projectId: props.view.projectId,
isHomeView: !isHomeView.value
}
})
break
case MenuItems.Embed:
eventBus.emit(ViewerEventBusKeys.MarkSavedViewForEmbed, {
view: props.view
})
break
default:
throwUncoveredError(item.id)
}
}
const apply = async () => {
applyView({
id: props.view.id
})
}
const onEdit = () => {
eventBus.emit(ViewerEventBusKeys.MarkSavedViewForEdit, {
type: 'edit',
view: props.view
})
}
</script>