feat: saved view delete (#5192)

* canUpdate auth policy

* delete mutation WIP

* backend works

* frontend working

* minor adjustments

* test fix

* switch to new empty state

* beefing up coverage

* cr fix
This commit is contained in:
Kristaps Fabians Geikins
2025-08-07 12:13:39 +03:00
committed by GitHub
parent 6135ac6188
commit 21e8ec3e27
37 changed files with 1099 additions and 252 deletions
@@ -1,7 +1,10 @@
<!-- eslint-disable vuejs-accessibility/click-events-have-key-events -->
<!-- eslint-disable vuejs-accessibility/no-static-element-interactions -->
<template>
<div class="flex gap-2 p-2 w-full hover:bg-foundation-2 rounded" :view-id="view.id">
<div
class="flex gap-2 p-2 w-full group hover:bg-foundation-2 rounded"
:view-id="view.id"
>
<img
v-keyboard-clickable
:src="view.screenshot"
@@ -9,28 +12,57 @@
class="w-20 h-14 object-cover rounded border border-outline-3 bg-foundation-page cursor-pointer"
@click="apply"
/>
<div class="flex flex-col gap-1 min-w-0">
<div class="flex flex-col gap-1 min-w-0 grow">
<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 class="flex gap-1 items-center justify-between">
<div class="text-body-2xs text-foreground-3 truncate">
{{ view.author?.name }}
</div>
<LayoutMenu
v-model:open="showMenu"
:items="menuItems"
:menu-id="menuId"
mount-menu-on-body
@chosen="({ item: actionItem }) => onActionChosen(actionItem)"
>
<FormButton
size="sm"
color="subtle"
:icon-left="Ellipsis"
hide-text
name="viewActions"
class="shrink-0 opacity-0 group-hover:opacity-100"
@click="showMenu = !showMenu"
/>
</LayoutMenu>
</div>
<div
v-tippy="formattedFullDate(view.updatedAt)"
class="text-body-2xs text-foreground-3 truncate"
>
{{ formattedRelativeDate(view.updatedAt) }}
<div class="w-full flex">
<div
v-tippy="formattedFullDate(view.updatedAt)"
class="text-body-2xs text-foreground-3 truncate"
>
{{ formattedRelativeDate(view.updatedAt) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { StringEnum, throwUncoveredError, type StringEnumValues } from '@speckle/shared'
import type { LayoutMenuItem } from '@speckle/ui-components'
import { useMutationLoading } from '@vue/apollo-composable'
import { Ellipsis } from 'lucide-vue-next'
import { graphql } from '~/lib/common/generated/gql'
import type { ViewerSavedViewsPanelView_SavedViewFragment } from '~/lib/common/generated/gql/graphql'
import { useEventBus } from '~/lib/core/composables/eventBus'
import { useDeleteSavedView } from '~/lib/viewer/composables/savedViews/management'
import { ViewerEventBusKeys } from '~/lib/viewer/helpers/eventBus'
const Menuitems = StringEnum(['Delete'])
type MenuItems = StringEnumValues<typeof Menuitems>
graphql(`
fragment ViewerSavedViewsPanelView_SavedView on SavedView {
id
@@ -42,6 +74,12 @@ graphql(`
name
}
updatedAt
permissions {
canUpdate {
...FullPermissionCheckResult
}
}
...UseDeleteSavedView_SavedView
}
`)
@@ -50,6 +88,33 @@ const props = defineProps<{
}>()
const eventBus = useEventBus()
const deleteView = useDeleteSavedView()
const isLoading = useMutationLoading()
const showMenu = ref(false)
const menuId = useId()
const canUpdate = computed(() => props.view.permissions.canUpdate)
const menuItems = computed((): LayoutMenuItem<MenuItems>[][] => [
[
{
id: Menuitems.Delete,
title: 'Delete',
disabled: !canUpdate.value?.authorized || isLoading.value,
disabledTooltip: canUpdate.value.errorMessage
}
]
])
const onActionChosen = async (item: LayoutMenuItem<MenuItems>) => {
switch (item.id) {
case Menuitems.Delete:
await deleteView({ view: props.view })
break
default:
throwUncoveredError(item.id)
}
}
const apply = async () => {
// Force update, even if the view id is already set
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-8 items-center my-16">
<ViewerSavedViewsPanelViewsEmptyStateImage />
<IllustrationEmptystateViewsTab />
<div class="text-foreground-2">{{ message }}</div>
</div>
</template>
@@ -1,163 +0,0 @@
<template>
<svg
v-if="isLightTheme"
width="194"
height="141"
viewBox="0 0 194 141"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.781312"
y="0.236562"
width="136.25"
height="102.5"
rx="5.5"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 -0.105141 58.9668)"
fill="#F5F5F5"
/>
<rect
x="0.781312"
y="0.236562"
width="136.25"
height="102.5"
rx="5.5"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 -0.105141 58.9668)"
stroke="#C4C4C4"
/>
<g clip-path="url(#clip0_460_265265)">
<path
d="M70.4361 42.9116L88.1415 37.0098C90.1203 36.3504 92.7625 37.1508 94.0431 38.7972C95.6629 40.8795 99.005 41.891 101.508 41.0567L114.418 36.7534C117.415 35.7543 121.418 36.9668 123.358 39.4608L145.637 68.1056C147.577 70.5996 146.719 73.4311 143.722 74.4303L83.9661 94.349C80.9687 95.3479 76.9666 94.1362 75.0268 91.6424L52.7469 62.9967C50.8075 60.5028 51.6647 57.6712 54.6618 56.672L67.5719 52.3687C70.0746 51.5344 70.7911 49.1701 69.1717 47.0877C67.891 45.4412 68.4572 43.5712 70.4361 42.9116Z"
fill="white"
stroke="#C4C4C4"
/>
<circle
cx="19.8295"
cy="19.8295"
r="19.3295"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 67.3618 55.0841)"
fill="#FAFAFA"
stroke="#C4C4C4"
stroke-linecap="round"
/>
<circle
cx="13.6085"
cy="13.6085"
r="13.1085"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 77.0829 58.0275)"
fill="#F5F5F5"
stroke="#C4C4C4"
stroke-linecap="round"
/>
<path
d="M91.8971 58.3619C88.8257 60.0022 87.9419 63.2331 89.7792 66.2916"
stroke="#C4C4C4"
stroke-linecap="round"
/>
</g>
<rect
x="0.781312"
y="0.236562"
width="136.25"
height="102.5"
rx="5.5"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 -0.105141 43.9669)"
stroke="#C4C4C4"
stroke-dasharray="3 4"
/>
<defs>
<clipPath id="clip0_460_265265">
<rect
width="137.25"
height="103.5"
rx="6"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 0 43.67)"
fill="white"
/>
</clipPath>
</defs>
</svg>
<svg
v-else
width="194"
height="141"
viewBox="0 0 194 141"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.781312"
y="0.236562"
width="136.25"
height="102.5"
rx="5.5"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 -0.105141 58.9668)"
fill="#191A22"
/>
<rect
x="0.781312"
y="0.236562"
width="136.25"
height="102.5"
rx="5.5"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 -0.105141 58.9668)"
stroke="#434559"
/>
<g clip-path="url(#clip0_864_35861)">
<path
d="M70.4361 42.9116L88.1415 37.0098C90.1203 36.3504 92.7625 37.1508 94.0431 38.7972C95.6629 40.8795 99.005 41.891 101.508 41.0567L114.418 36.7534C117.415 35.7543 121.418 36.9668 123.358 39.4608L145.637 68.1056C147.577 70.5996 146.719 73.4311 143.722 74.4303L83.9661 94.349C80.9687 95.3479 76.9666 94.1362 75.0268 91.6424L52.7469 62.9967C50.8075 60.5028 51.6647 57.6712 54.6618 56.672L67.5719 52.3687C70.0746 51.5344 70.7911 49.1701 69.1717 47.0877C67.891 45.4412 68.4572 43.5712 70.4361 42.9116Z"
fill="#15161C"
stroke="#434559"
/>
<circle
cx="19.8295"
cy="19.8295"
r="19.3295"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 67.3618 55.0841)"
fill="#101012"
stroke="#434559"
stroke-linecap="round"
/>
<circle
cx="13.6085"
cy="13.6085"
r="13.1085"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 77.0829 58.0275)"
fill="#191A22"
stroke="#434559"
stroke-linecap="round"
/>
<path
d="M91.8971 58.3619C88.8257 60.0022 87.9419 63.2331 89.7792 66.2916"
stroke="#434559"
stroke-linecap="round"
/>
</g>
<rect
x="0.781312"
y="0.236562"
width="136.25"
height="102.5"
rx="5.5"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 -0.105141 43.9669)"
stroke="#434559"
stroke-dasharray="3 4"
/>
<defs>
<clipPath id="clip0_864_35861">
<rect
width="137.25"
height="103.5"
rx="6"
transform="matrix(0.948683 -0.316228 0.613941 0.789352 0 43.67)"
fill="white"
/>
</clipPath>
</defs>
</svg>
</template>
<script setup lang="ts">
import { useTheme } from '~/lib/core/composables/theme'
const { isLightTheme } = useTheme()
</script>
@@ -166,7 +166,7 @@ type Documents = {
"\n fragment ViewerResourcesPersonalLimitAlert_Project on Project {\n id\n ...WorkspaceMoveProject_Project\n }\n": typeof types.ViewerResourcesPersonalLimitAlert_ProjectFragmentDoc,
"\n fragment ViewerResourcesWorkspaceLimitAlert_Workspace on Workspace {\n id\n slug\n }\n": typeof types.ViewerResourcesWorkspaceLimitAlert_WorkspaceFragmentDoc,
"\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ViewerSavedViewsPanel_ProjectFragmentDoc,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n author {\n id\n name\n }\n updatedAt\n }\n": typeof types.ViewerSavedViewsPanelView_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n }\n": typeof types.ViewerSavedViewsPanelView_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViews_Project on Project {\n id\n savedViewGroups(input: $savedViewGroupsInput) {\n totalCount\n cursor\n items {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n }\n": typeof types.ViewerSavedViewsPanelViews_ProjectFragmentDoc,
"\n query ViewerSavedViewsPanelViews_Groups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelViews_Project\n }\n }\n": typeof types.ViewerSavedViewsPanelViews_GroupsDocument,
"\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {\n id\n isUngroupedViewsGroup\n ...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup\n }\n": typeof types.ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragmentDoc,
@@ -410,6 +410,8 @@ type Documents = {
"\n fragment UseCheckViewerCommentingAccess_Project on Project {\n id\n permissions {\n canCreateComment {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseCheckViewerCommentingAccess_ProjectFragmentDoc,
"\n fragment UseLoadLatestVersion_Project on Project {\n id\n workspace {\n slug\n }\n }\n": typeof types.UseLoadLatestVersion_ProjectFragmentDoc,
"\n mutation CreateSavedView($input: CreateSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n createView(input: $input) {\n id\n ...ViewerSavedViewsPanelView_SavedView\n group {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n }\n }\n }\n": typeof types.CreateSavedViewDocument,
"\n mutation DeleteSavedView($input: DeleteSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n deleteView(input: $input)\n }\n }\n }\n": typeof types.DeleteSavedViewDocument,
"\n fragment UseDeleteSavedView_SavedView on SavedView {\n id\n projectId\n group {\n id\n }\n }\n": typeof types.UseDeleteSavedView_SavedViewFragmentDoc,
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": typeof types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n": typeof types.ViewerCommentThreadFragmentDoc,
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": typeof types.ViewerCommentsReplyItemFragmentDoc,
@@ -660,7 +662,7 @@ const documents: Documents = {
"\n fragment ViewerResourcesPersonalLimitAlert_Project on Project {\n id\n ...WorkspaceMoveProject_Project\n }\n": types.ViewerResourcesPersonalLimitAlert_ProjectFragmentDoc,
"\n fragment ViewerResourcesWorkspaceLimitAlert_Workspace on Workspace {\n id\n slug\n }\n": types.ViewerResourcesWorkspaceLimitAlert_WorkspaceFragmentDoc,
"\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ViewerSavedViewsPanel_ProjectFragmentDoc,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n author {\n id\n name\n }\n updatedAt\n }\n": types.ViewerSavedViewsPanelView_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n }\n": types.ViewerSavedViewsPanelView_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViews_Project on Project {\n id\n savedViewGroups(input: $savedViewGroupsInput) {\n totalCount\n cursor\n items {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n }\n": types.ViewerSavedViewsPanelViews_ProjectFragmentDoc,
"\n query ViewerSavedViewsPanelViews_Groups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelViews_Project\n }\n }\n": types.ViewerSavedViewsPanelViews_GroupsDocument,
"\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {\n id\n isUngroupedViewsGroup\n ...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup\n }\n": types.ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragmentDoc,
@@ -904,6 +906,8 @@ const documents: Documents = {
"\n fragment UseCheckViewerCommentingAccess_Project on Project {\n id\n permissions {\n canCreateComment {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseCheckViewerCommentingAccess_ProjectFragmentDoc,
"\n fragment UseLoadLatestVersion_Project on Project {\n id\n workspace {\n slug\n }\n }\n": types.UseLoadLatestVersion_ProjectFragmentDoc,
"\n mutation CreateSavedView($input: CreateSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n createView(input: $input) {\n id\n ...ViewerSavedViewsPanelView_SavedView\n group {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n }\n }\n }\n": types.CreateSavedViewDocument,
"\n mutation DeleteSavedView($input: DeleteSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n deleteView(input: $input)\n }\n }\n }\n": types.DeleteSavedViewDocument,
"\n fragment UseDeleteSavedView_SavedView on SavedView {\n id\n projectId\n group {\n id\n }\n }\n": types.UseDeleteSavedView_SavedViewFragmentDoc,
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
"\n fragment ViewerCommentThread on Comment {\n ...ViewerCommentsListItem\n ...ViewerCommentBubblesData\n ...ViewerCommentsReplyItem\n ...ViewerCommentThreadData\n }\n": types.ViewerCommentThreadFragmentDoc,
"\n fragment ViewerCommentsReplyItem on Comment {\n id\n archived\n rawText\n text {\n doc\n }\n author {\n ...LimitedUserAvatar\n }\n createdAt\n ...ThreadCommentAttachment\n }\n": types.ViewerCommentsReplyItemFragmentDoc,
@@ -1627,7 +1631,7 @@ export function graphql(source: "\n fragment ViewerSavedViewsPanel_Project on P
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n author {\n id\n name\n }\n updatedAt\n }\n"): (typeof documents)["\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n author {\n id\n name\n }\n updatedAt\n }\n"];
export function graphql(source: "\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n }\n"): (typeof documents)["\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2600,6 +2604,14 @@ export function graphql(source: "\n fragment UseLoadLatestVersion_Project on Pr
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation CreateSavedView($input: CreateSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n createView(input: $input) {\n id\n ...ViewerSavedViewsPanelView_SavedView\n group {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n }\n }\n }\n"): (typeof documents)["\n mutation CreateSavedView($input: CreateSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n createView(input: $input) {\n id\n ...ViewerSavedViewsPanelView_SavedView\n group {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation DeleteSavedView($input: DeleteSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n deleteView(input: $input)\n }\n }\n }\n"): (typeof documents)["\n mutation DeleteSavedView($input: DeleteSavedViewInput!) {\n projectMutations {\n savedViewMutations {\n deleteView(input: $input)\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment UseDeleteSavedView_SavedView on SavedView {\n id\n projectId\n group {\n id\n }\n }\n"): (typeof documents)["\n fragment UseDeleteSavedView_SavedView on SavedView {\n id\n projectId\n group {\n id\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
@@ -811,3 +811,10 @@ export const errorsToAuthResult = (params: {
payload: firstError.extensions || null
}
}
export const parseObjectReference = <Type extends keyof AllObjectTypes>(
ref: CacheObjectReference<Type>
): { type: Type; id: string } => {
const [type, id] = ref.__ref.split(':')
return { type: type as Type, id }
}
@@ -321,6 +321,12 @@ function createCache(): InMemoryCache {
CommentThreadActivityMessage: {
merge: true
},
SavedViewPermissionChecks: {
merge: true
},
ProjectPermissionChecks: {
merge: true
},
AutomateFunction: {
fields: {
releases: {
@@ -1,6 +1,10 @@
import { useMutation } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import type { CreateSavedViewInput } from '~/lib/common/generated/gql/graphql'
import type {
CreateSavedViewInput,
UseDeleteSavedView_SavedViewFragment
} from '~/lib/common/generated/gql/graphql'
import { parseObjectReference } from '~/lib/common/helpers/graphql'
import { useStateSerialization } from '~/lib/viewer/composables/serialization'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
@@ -112,3 +116,113 @@ export const useCreateSavedView = () => {
return res
}
}
const deleteSavedViewMutation = graphql(`
mutation DeleteSavedView($input: DeleteSavedViewInput!) {
projectMutations {
savedViewMutations {
deleteView(input: $input)
}
}
}
`)
graphql(`
fragment UseDeleteSavedView_SavedView on SavedView {
id
projectId
group {
id
}
}
`)
export const useDeleteSavedView = () => {
const { mutate } = useMutation(deleteSavedViewMutation)
const { triggerNotification } = useGlobalToast()
return async (params: { view: UseDeleteSavedView_SavedViewFragment }) => {
const { id, projectId } = params.view
const groupId = params.view.group.id
if (!id || !projectId) {
return
}
const result = await mutate(
{
input: {
projectId,
id
}
},
{
update: (cache) => {
// Remove the view from the cache
cache.evict({ id: getCacheId('SavedView', id) })
// Check if default/ungrouped group
const isDefaultGroup = groupId.startsWith('default-')
// If default group and its now empty - remove it as it doesn't exist otherwise
let shouldEvict
if (isDefaultGroup) {
let viewsRemain = false
modifyObjectField(
cache,
getCacheId('SavedViewGroup', groupId),
'views',
({ value }) => {
const otherItems = value?.items?.filter(
(item) => item.__ref !== getCacheId('SavedView', id)
)
if (otherItems?.length) {
viewsRemain = true
}
}
)
if (!viewsRemain) {
shouldEvict = true
}
}
// Remove default group, if its empty
if (shouldEvict) {
cache.evict({ id: getCacheId('SavedViewGroup', groupId) })
} else {
// Remove view from view lists (in groups)
// SavedViewGroup.views
modifyObjectField(
cache,
getCacheId('SavedViewGroup', groupId),
'views',
({ helpers: { createUpdatedValue } }) => {
return createUpdatedValue(({ update }) => {
update('totalCount', (count) => count - 1)
update('items', (items) =>
items.filter((item) => parseObjectReference(item).id !== id)
)
})
},
{ autoEvictFiltered: true }
)
}
}
}
).catch(convertThrowIntoFetchResult)
const res = result?.data?.projectMutations.savedViewMutations.deleteView
if (!res) {
const err = getFirstGqlErrorMessage(result?.errors)
triggerNotification({
title: "Couldn't delete saved view",
description: err,
type: ToastNotificationType.Danger
})
}
return res
}
}
@@ -0,0 +1,11 @@
extend type ProjectPermissionChecks {
canCreateSavedView: PermissionCheckResult!
}
type SavedViewPermissionChecks {
canUpdate: PermissionCheckResult!
}
extend type SavedView {
permissions: SavedViewPermissionChecks!
}
@@ -165,15 +165,17 @@ input CreateSavedViewGroupInput {
groupName: String!
}
input DeleteSavedViewInput {
id: ID!
projectId: ID!
}
type SavedViewMutations {
createGroup(input: CreateSavedViewGroupInput!): SavedViewGroup!
createView(input: CreateSavedViewInput!): SavedView!
deleteView(input: DeleteSavedViewInput!): Boolean!
}
extend type ProjectMutations {
savedViewMutations: SavedViewMutations!
}
extend type ProjectPermissionChecks {
canCreateSavedView: PermissionCheckResult!
}
+3 -1
View File
@@ -195,7 +195,9 @@ const config: CodegenConfig = {
SavedViewGroup:
'@/modules/viewer/helpers/graphTypes#SavedViewGroupGraphQLReturn',
PermissionCheckResult:
'@/modules/core/helpers/graphTypes#PermissionCheckResultGraphQLReturn'
'@/modules/core/helpers/graphTypes#PermissionCheckResultGraphQLReturn',
SavedViewPermissionChecks:
'@/modules/viewer/helpers/graphTypes#SavedViewPermissionChecksGraphQLReturn'
}
}
}
@@ -16,7 +16,7 @@ import type { ServerAppGraphQLReturn, ServerAppListItemGraphQLReturn } from '@/m
import type { GendoAIRenderGraphQLReturn } from '@/modules/gendo/helpers/types/graphTypes';
import type { ServerRegionItemGraphQLReturn } from '@/modules/multiregion/helpers/graphTypes';
import type { AccSyncItemGraphQLReturn, AccSyncItemMutationsGraphQLReturn } from '@/modules/acc/helpers/graphTypes';
import type { SavedViewGraphQLReturn, SavedViewGroupGraphQLReturn } from '@/modules/viewer/helpers/graphTypes';
import type { SavedViewGraphQLReturn, SavedViewGroupGraphQLReturn, SavedViewPermissionChecksGraphQLReturn } from '@/modules/viewer/helpers/graphTypes';
import type { GraphQLContext } from '@/modules/shared/helpers/typeHelper';
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
export type Maybe<T> = T | null;
@@ -1117,6 +1117,11 @@ export type DeleteModelInput = {
projectId: Scalars['ID']['input'];
};
export type DeleteSavedViewInput = {
id: Scalars['ID']['input'];
projectId: Scalars['ID']['input'];
};
export type DeleteUserEmailInput = {
id: Scalars['ID']['input'];
};
@@ -3430,6 +3435,7 @@ export type SavedView = {
id: Scalars['ID']['output'];
isHomeView: Scalars['Boolean']['output'];
name: Scalars['String']['output'];
permissions: SavedViewPermissionChecks;
/** For figuring out position in the group */
position: Scalars['Float']['output'];
projectId: Scalars['ID']['output'];
@@ -3509,6 +3515,7 @@ export type SavedViewMutations = {
__typename?: 'SavedViewMutations';
createGroup: SavedViewGroup;
createView: SavedView;
deleteView: Scalars['Boolean']['output'];
};
@@ -3521,6 +3528,16 @@ export type SavedViewMutationsCreateViewArgs = {
input: CreateSavedViewInput;
};
export type SavedViewMutationsDeleteViewArgs = {
input: DeleteSavedViewInput;
};
export type SavedViewPermissionChecks = {
__typename?: 'SavedViewPermissionChecks';
canUpdate: PermissionCheckResult;
};
export const SavedViewVisibility = {
AuthorOnly: 'authorOnly',
Public: 'public'
@@ -5857,6 +5874,7 @@ export type ResolversTypes = {
DateTime: ResolverTypeWrapper<Scalars['DateTime']['output']>;
DeleteAccSyncItemInput: DeleteAccSyncItemInput;
DeleteModelInput: DeleteModelInput;
DeleteSavedViewInput: DeleteSavedViewInput;
DeleteUserEmailInput: DeleteUserEmailInput;
DeleteVersionsInput: DeleteVersionsInput;
DenyWorkspaceJoinRequestInput: DenyWorkspaceJoinRequestInput;
@@ -5977,6 +5995,7 @@ export type ResolversTypes = {
SavedViewGroupViewsInput: SavedViewGroupViewsInput;
SavedViewGroupsInput: SavedViewGroupsInput;
SavedViewMutations: ResolverTypeWrapper<MutationsObjectGraphQLReturn>;
SavedViewPermissionChecks: ResolverTypeWrapper<SavedViewPermissionChecksGraphQLReturn>;
SavedViewVisibility: SavedViewVisibility;
Scope: ResolverTypeWrapper<Scope>;
ServerApp: ResolverTypeWrapper<ServerAppGraphQLReturn>;
@@ -6227,6 +6246,7 @@ export type ResolversParentTypes = {
DateTime: Scalars['DateTime']['output'];
DeleteAccSyncItemInput: DeleteAccSyncItemInput;
DeleteModelInput: DeleteModelInput;
DeleteSavedViewInput: DeleteSavedViewInput;
DeleteUserEmailInput: DeleteUserEmailInput;
DeleteVersionsInput: DeleteVersionsInput;
DenyWorkspaceJoinRequestInput: DenyWorkspaceJoinRequestInput;
@@ -6332,6 +6352,7 @@ export type ResolversParentTypes = {
SavedViewGroupViewsInput: SavedViewGroupViewsInput;
SavedViewGroupsInput: SavedViewGroupsInput;
SavedViewMutations: MutationsObjectGraphQLReturn;
SavedViewPermissionChecks: SavedViewPermissionChecksGraphQLReturn;
Scope: Scope;
ServerApp: ServerAppGraphQLReturn;
ServerAppListItem: ServerAppListItemGraphQLReturn;
@@ -7656,6 +7677,7 @@ export type SavedViewResolvers<ContextType = GraphQLContext, ParentType extends
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
isHomeView?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
permissions?: Resolver<ResolversTypes['SavedViewPermissionChecks'], ParentType, ContextType>;
position?: Resolver<ResolversTypes['Float'], ParentType, ContextType>;
projectId?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
resourceIdString?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@@ -7695,6 +7717,12 @@ export type SavedViewGroupCollectionResolvers<ContextType = GraphQLContext, Pare
export type SavedViewMutationsResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SavedViewMutations'] = ResolversParentTypes['SavedViewMutations']> = {
createGroup?: Resolver<ResolversTypes['SavedViewGroup'], ParentType, ContextType, RequireFields<SavedViewMutationsCreateGroupArgs, 'input'>>;
createView?: Resolver<ResolversTypes['SavedView'], ParentType, ContextType, RequireFields<SavedViewMutationsCreateViewArgs, 'input'>>;
deleteView?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<SavedViewMutationsDeleteViewArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SavedViewPermissionChecksResolvers<ContextType = GraphQLContext, ParentType extends ResolversParentTypes['SavedViewPermissionChecks'] = ResolversParentTypes['SavedViewPermissionChecks']> = {
canUpdate?: Resolver<ResolversTypes['PermissionCheckResult'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -8589,6 +8617,7 @@ export type Resolvers<ContextType = GraphQLContext> = {
SavedViewGroup?: SavedViewGroupResolvers<ContextType>;
SavedViewGroupCollection?: SavedViewGroupCollectionResolvers<ContextType>;
SavedViewMutations?: SavedViewMutationsResolvers<ContextType>;
SavedViewPermissionChecks?: SavedViewPermissionChecksResolvers<ContextType>;
Scope?: ScopeResolvers<ContextType>;
ServerApp?: ServerAppResolvers<ContextType>;
ServerAppListItem?: ServerAppListItemResolvers<ContextType>;
@@ -9008,6 +9037,28 @@ export type GetProjectSavedViewQueryVariables = Exact<{
export type GetProjectSavedViewQuery = { __typename?: 'Query', project: { __typename?: 'Project', savedView: { __typename?: 'SavedView', id: string, name: string, description?: string | null, groupId?: string | null, createdAt: Date, updatedAt: Date, resourceIdString: string, resourceIds: Array<string>, isHomeView: boolean, visibility: SavedViewVisibility, viewerState: Record<string, unknown>, screenshot: string, position: number, projectId: string, author?: { __typename?: 'LimitedUser', id: string } | null, group: { __typename?: 'SavedViewGroup', id: string, title: string, isUngroupedViewsGroup: boolean } } } };
export type DeleteSavedViewMutationVariables = Exact<{
input: DeleteSavedViewInput;
}>;
export type DeleteSavedViewMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', savedViewMutations: { __typename?: 'SavedViewMutations', deleteView: boolean } } };
export type CanCreateSavedViewQueryVariables = Exact<{
projectId: Scalars['String']['input'];
}>;
export type CanCreateSavedViewQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, permissions: { __typename?: 'ProjectPermissionChecks', canCreateSavedView: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record<string, unknown> | null } } } };
export type CanUpdateSavedViewQueryVariables = Exact<{
projectId: Scalars['String']['input'];
viewId: Scalars['ID']['input'];
}>;
export type CanUpdateSavedViewQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, savedView: { __typename?: 'SavedView', id: string, permissions: { __typename?: 'SavedViewPermissionChecks', canUpdate: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record<string, unknown> | null } } } } };
export type BasicWorkspaceFragment = { __typename?: 'Workspace', id: string, name: string, slug: string, updatedAt: Date, createdAt: Date, role?: string | null, readOnly: boolean };
export type BasicPendingWorkspaceCollaboratorFragment = { __typename?: 'PendingWorkspaceCollaborator', id: string, inviteId: string, title: string, role: string, token?: string | null, workspace: { __typename?: 'LimitedWorkspace', id: string, name: string }, invitedBy: { __typename?: 'LimitedUser', id: string, name: string }, user?: { __typename?: 'LimitedUser', id: string, name: string } | null };
@@ -10135,6 +10186,9 @@ export const GetProjectSavedViewGroupsDocument = {"kind":"Document","definitions
export const GetProjectSavedViewGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectSavedViewGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"groupId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewsInput"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SavedViewGroupViewsInput"}}},"defaultValue":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedViewGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"groupId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedViewGroup"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedView"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedView"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"groupId"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIdString"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"isHomeView"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"viewerState"}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedViewGroup"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedViewGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}},{"kind":"Field","name":{"kind":"Name","value":"views"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewsInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}}]}}]}}]} as unknown as DocumentNode<GetProjectSavedViewGroupQuery, GetProjectSavedViewGroupQueryVariables>;
export const GetProjectUngroupedViewGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectUngroupedViewGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GetUngroupedViewGroupInput"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewsInput"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SavedViewGroupViewsInput"}}},"defaultValue":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"10"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"ungroupedViewGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedViewGroup"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedView"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedView"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"groupId"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIdString"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"isHomeView"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"viewerState"}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedViewGroup"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedViewGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}},{"kind":"Field","name":{"kind":"Name","value":"views"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewsInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"totalCount"}},{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}}]}}]}}]} as unknown as DocumentNode<GetProjectUngroupedViewGroupQuery, GetProjectUngroupedViewGroupQueryVariables>;
export const GetProjectSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicSavedView"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"SavedView"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"groupId"}},{"kind":"Field","name":{"kind":"Name","value":"group"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"isUngroupedViewsGroup"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIdString"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}},{"kind":"Field","name":{"kind":"Name","value":"isHomeView"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"viewerState"}},{"kind":"Field","name":{"kind":"Name","value":"screenshot"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"projectId"}}]}}]} as unknown as DocumentNode<GetProjectSavedViewQuery, GetProjectSavedViewQueryVariables>;
export const DeleteSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteSavedViewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedViewMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode<DeleteSavedViewMutation, DeleteSavedViewMutationVariables>;
export const CanCreateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CanCreateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canCreateSavedView"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}}]}}]}}]}}]}}]} as unknown as DocumentNode<CanCreateSavedViewQuery, CanCreateSavedViewQueryVariables>;
export const CanUpdateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CanUpdateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"project"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"savedView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"canUpdate"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"authorized"}},{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"payload"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<CanUpdateSavedViewQuery, CanUpdateSavedViewQueryVariables>;
export const CreateWorkspaceInviteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateWorkspaceInvite"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceInviteCreateInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode<CreateWorkspaceInviteMutation, CreateWorkspaceInviteMutationVariables>;
export const BatchCreateWorkspaceInvitesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BatchCreateWorkspaceInvites"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"WorkspaceInviteCreateInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspaceMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"invites"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"batchCreate"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workspaceId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode<BatchCreateWorkspaceInvitesMutation, BatchCreateWorkspaceInvitesMutationVariables>;
export const GetWorkspaceWithTeamDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkspaceWithTeam"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workspace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workspaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicWorkspace"}},{"kind":"Field","name":{"kind":"Name","value":"invitedTeam"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicWorkspace"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Workspace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"slug"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"readOnly"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"BasicPendingWorkspaceCollaborator"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PendingWorkspaceCollaborator"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"inviteId"}},{"kind":"Field","name":{"kind":"Name","value":"workspace"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"role"}},{"kind":"Field","name":{"kind":"Name","value":"invitedBy"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"token"}}]}}]} as unknown as DocumentNode<GetWorkspaceWithTeamQuery, GetWorkspaceWithTeamQueryVariables>;
@@ -191,7 +191,7 @@ export default {
if (savedViewId) {
const savedView = await ctx.loaders
.forRegion({ db: projectDB })
.savedViews.getSavedViews.load({ viewId: savedViewId, projectId: parent.id })
.savedViews.getSavedView.load({ viewId: savedViewId, projectId: parent.id })
if (!savedView) {
throw new NotFoundError(
`Saved view with ID ${savedViewId} not found in project ${parent.id}`
@@ -312,7 +312,7 @@ describe('Core GraphQL Subscriptions (New)', () => {
{
title: 'userProjectsUpdated()',
withoutScope: Scopes.Profile.Read,
expectedMessages: 2,
expectedMessages: 1,
sub: () => ({
query: OnUserProjectsUpdatedDocument,
variables: {}
@@ -367,7 +367,8 @@ describe('Core GraphQL Subscriptions (New)', () => {
await triggerMessage()
await onMessage.waitForMessage()
if (isMultiRegion && title === 'userProjectsUpdated()') {
if (title === 'userProjectsUpdated()') {
// TODO: Something weird is happening here - there should not be more than 1 message, but for some reason we're receiving the same one twice
// should have 2 but sometimes the expectancy hits before it gets the second event only in multiregion setups and for this specific case
expect(onMessage.getMessages()).to.have.length.gte(1)
expect(onMessage.getMessages()).to.have.length.lessThan(3)
@@ -66,6 +66,7 @@ export const mapAuthToServerError = (e: Authz.AllAuthErrors): BaseError => {
case Authz.ModelNotFoundError.code:
case Authz.VersionNotFoundError.code:
case Authz.AutomateFunctionNotFoundError.code:
case Authz.SavedViewNotFoundError.code:
return new NotFoundError(e.message)
case Authz.PersonalProjectsLimitedError.code:
return new BadRequestError(e.message)
@@ -0,0 +1,16 @@
import { defineModuleLoaders } from '@/modules/loaders'
import { getProjectDbClient } from '@/modules/multiregion/utils/dbSelector'
export default defineModuleLoaders(async () => {
return {
getSavedView: async ({ savedViewId, projectId }, { dataLoaders }) => {
const projectDb = await getProjectDbClient({ projectId })
return await dataLoaders
.forRegion({ db: projectDb })
.savedViews.getSavedView.load({
viewId: savedViewId,
projectId
})
}
}
})
@@ -8,7 +8,9 @@ import type { MaybeNullOrUndefined, NullableKeysToOptional } from '@speckle/shar
import type { SerializedViewerState } from '@speckle/shared/viewer/state'
import type { Exact, SetOptional } from 'type-fest'
/////////////////////
// REPO OPERATIONS:
/////////////////////
export type StoreSavedView = <
View extends Exact<
@@ -120,7 +122,13 @@ export type GetSavedViews = (params: {
[viewId: string]: SavedView | undefined
}>
export type DeleteSavedViewRecord = (params: {
savedViewId: string
}) => Promise<boolean>
/////////////////////
// SERVICE OPERATIONS:
/////////////////////
export type CreateSavedViewParams = {
input: {
@@ -166,3 +174,9 @@ export type GetProjectSavedViewGroups = (
export type GetGroupSavedViews = (
params: GetGroupSavedViewsPageParams
) => Promise<Collection<SavedView>>
export type DeleteSavedView = (params: {
id: string
projectId: string
userId: string
}) => Promise<void>
@@ -38,9 +38,9 @@ const dataLoadersDefinition = defineRequestDataloaders(
}
),
/**
* Get saved views by their IDs
* Get saved view by its ID
*/
getSavedViews: createLoader<
getSavedView: createLoader<
{ viewId: string; projectId: string },
Nullable<SavedView>,
string
@@ -0,0 +1,48 @@
import type { Resolvers } from '@/modules/core/graph/generated/graphql'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { toGraphqlResult } from '@speckle/shared/authz'
const resolvers: Resolvers = {
ProjectPermissionChecks: {
canCreateSavedView: async (parent, _args, ctx) => {
const projectId = parent.projectId
const canCreate = await ctx.authPolicies.project.savedViews.canCreate({
userId: ctx.userId,
projectId
})
return toGraphqlResult(canCreate)
}
},
SavedView: {
permissions: (parent) => ({
savedView: parent
})
},
SavedViewPermissionChecks: {
canUpdate: async (parent, _args, ctx) => {
const savedViewId = parent.savedView.id
const canUpdate = await ctx.authPolicies.project.savedViews.canUpdate({
userId: ctx.userId,
projectId: parent.savedView.projectId,
savedViewId
})
return toGraphqlResult(canUpdate)
}
}
}
const disabledMessage = 'Saved views are disabled on this server'
const disabledResolvers: Resolvers = {
ProjectPermissionChecks: {
canCreateSavedView: () => {
return {
authorized: false,
message: disabledMessage,
code: 'SAVED_VIEWS_DISABLED',
payload: null
}
}
}
}
export default getFeatureFlags().FF_SAVED_VIEWS_ENABLED ? resolvers : disabledResolvers
@@ -17,6 +17,7 @@ import { LogicError, NotFoundError, NotImplementedError } from '@/modules/shared
import { throwIfAuthNotOk } from '@/modules/shared/helpers/errorHelper'
import { buildDefaultGroupId } from '@/modules/viewer/helpers/savedViews'
import {
deleteSavedViewRecordFactory,
getGroupSavedViewsPageItemsFactory,
getGroupSavedViewsTotalCountFactory,
getProjectSavedViewGroupsPageItemsFactory,
@@ -31,6 +32,7 @@ import {
import {
createSavedViewFactory,
createSavedViewGroupFactory,
deleteSavedViewFactory,
getGroupSavedViewsFactory,
getProjectSavedViewGroupsFactory
} from '@/modules/viewer/services/savedViewsManagement'
@@ -108,7 +110,7 @@ const resolvers: Resolvers = {
const projectDb = await getProjectDbClient({ projectId: parent.id })
const view = await ctx.loaders
.forRegion({ db: projectDb })
.savedViews.getSavedViews.load({
.savedViews.getSavedView.load({
viewId: args.id,
projectId: parent.id
})
@@ -224,6 +226,35 @@ const resolvers: Resolvers = {
})
return await createSavedView({ input: args.input, authorId: ctx.userId! })
},
deleteView: async (_parent, args, ctx) => {
const projectId = args.input.projectId
const projectDb = await getProjectDbClient({ projectId })
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canUpdate = await ctx.authPolicies.project.savedViews.canUpdate({
userId: ctx.userId,
projectId,
savedViewId: args.input.id
})
throwIfAuthNotOk(canUpdate)
await deleteSavedViewFactory({
deleteSavedViewRecord: deleteSavedViewRecordFactory({
db: projectDb
})
})({
id: args.input.id,
projectId,
userId: ctx.userId!
})
return true
},
createGroup: async (_parent, args, ctx) => {
const projectId = args.input.projectId
throwIfResourceAccessNotAllowed({
@@ -281,16 +312,6 @@ const disabledResolvers: Resolvers = {
savedViewMutations: () => {
throw new NotImplementedError(disabledMessage)
}
},
ProjectPermissionChecks: {
canCreateSavedView: () => {
return {
authorized: false,
message: disabledMessage,
code: 'SAVED_VIEWS_DISABLED',
payload: null
}
}
}
}
@@ -5,3 +5,4 @@ import type {
export type SavedViewGraphQLReturn = SavedView
export type SavedViewGroupGraphQLReturn = SavedViewGroup
export type SavedViewPermissionChecksGraphQLReturn = { savedView: SavedView }
@@ -14,7 +14,8 @@ import type {
RecalculateGroupResourceIds,
StoreSavedView,
StoreSavedViewGroup,
GetSavedViews
GetSavedViews,
DeleteSavedViewRecord
} from '@/modules/viewer/domain/operations/savedViews'
import {
SavedViewVisibility,
@@ -483,3 +484,23 @@ export const getSavedViewsFactory =
}
return viewsMap
}
export const deleteSavedViewRecordFactory =
(deps: { db: Knex }): DeleteSavedViewRecord =>
async (params) => {
const { savedViewId } = params
const q = tables.savedViews(deps.db).where({
[SavedViews.col.id]: savedViewId
})
// Delete the saved view
const result = await q.delete()
// If no rows were deleted, return false
if (result === 0) {
return false
}
// Otherwise, return true
return true
}
@@ -1,6 +1,8 @@
import type {
CreateSavedView,
CreateSavedViewGroup,
DeleteSavedView,
DeleteSavedViewRecord,
GetGroupSavedViews,
GetGroupSavedViewsPageItems,
GetGroupSavedViewsTotalCount,
@@ -177,7 +179,7 @@ export const createSavedViewFactory =
let name = input.name?.trim()
if (!name?.length) {
const viewCount = await deps.getStoredViewCount({ projectId })
name = `Scene - ${String(viewCount + 1).padStart(3, '0')}`
name = `View - ${String(viewCount + 1).padStart(3, '0')}`
}
const concreteResourceIds = resourceIds.toResources().map((r) => r.toString())
@@ -288,3 +290,10 @@ export const getGroupSavedViewsFactory =
...pageItems
}
}
export const deleteSavedViewFactory =
(deps: { deleteSavedViewRecord: DeleteSavedViewRecord }): DeleteSavedView =>
async (params) => {
const { id } = params
await deps.deleteSavedViewRecord({ savedViewId: id })
}
@@ -133,3 +133,48 @@ export const getProjectSavedViewQuery = gql`
}
${basicSavedViewFragment}
`
export const deleteSavedViewMutation = gql`
mutation DeleteSavedView($input: DeleteSavedViewInput!) {
projectMutations {
savedViewMutations {
deleteView(input: $input)
}
}
}
`
export const canCreateSavedViewQuery = gql`
query CanCreateSavedView($projectId: String!) {
project(id: $projectId) {
id
permissions {
canCreateSavedView {
authorized
code
message
payload
}
}
}
}
`
export const canUpdateSavedViewQuery = gql`
query CanUpdateSavedView($projectId: String!, $viewId: ID!) {
project(id: $projectId) {
id
savedView(id: $viewId) {
id
permissions {
canUpdate {
authorized
code
message
payload
}
}
}
}
}
`
@@ -1,16 +1,22 @@
import type {
BasicSavedViewFragment,
BasicSavedViewGroupFragment,
CanCreateSavedViewQueryVariables,
CanUpdateSavedViewQueryVariables,
CreateSavedViewGroupMutationVariables,
CreateSavedViewMutationVariables,
DeleteSavedViewMutationVariables,
GetProjectSavedViewGroupQueryVariables,
GetProjectSavedViewGroupsQueryVariables,
GetProjectSavedViewQueryVariables,
GetProjectUngroupedViewGroupQueryVariables
} from '@/modules/core/graph/generated/graphql'
import {
CanCreateSavedViewDocument,
CanUpdateSavedViewDocument,
CreateSavedViewDocument,
CreateSavedViewGroupDocument,
DeleteSavedViewDocument,
GetProjectSavedViewDocument,
GetProjectSavedViewGroupDocument,
GetProjectSavedViewGroupsDocument,
@@ -45,6 +51,10 @@ import { createTestBranch } from '@/test/speckle-helpers/branchHelper'
import type { BasicTestStream } from '@/test/speckle-helpers/streamHelper'
import { addToStream, createTestStream } from '@/test/speckle-helpers/streamHelper'
import { Roles, WorkspacePlans } from '@speckle/shared'
import {
ProjectNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError
} from '@speckle/shared/authz'
import * as ViewerRoute from '@speckle/shared/viewer/route'
import * as ViewerState from '@speckle/shared/viewer/state'
import { expect } from 'chai'
@@ -144,11 +154,26 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
options?: ExecuteOperationOptions
) => apollo.execute(GetProjectSavedViewDocument, input, options)
const deleteView = (
input: DeleteSavedViewMutationVariables,
options?: ExecuteOperationOptions
) => apollo.execute(DeleteSavedViewDocument, input, options)
const getProjectUngroupedViewGroup = (
input: GetProjectUngroupedViewGroupQueryVariables,
options?: ExecuteOperationOptions
) => apollo.execute(GetProjectUngroupedViewGroupDocument, input, options)
const canCreateSavedView = (
input: CanCreateSavedViewQueryVariables,
options?: ExecuteOperationOptions
) => apollo.execute(CanCreateSavedViewDocument, input, options)
const canUpdateSavedView = (
input: CanUpdateSavedViewQueryVariables,
options?: ExecuteOperationOptions
) => apollo.execute(CanUpdateSavedViewDocument, input, options)
const model1ResourceIds = () => ViewerRoute.resourceBuilder().addModel(myModel1.id)
const model2ResourceIds = () => ViewerRoute.resourceBuilder().addModel(myModel2.id)
@@ -211,7 +236,7 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
if (FF_WORKSPACES_MODULE_ENABLED) {
describe('creation', () => {
describe('canCreateSavedViewPolicy - forbidden error branches', () => {
describe('auth policy checks', () => {
it('should fail with ForbiddenError if user is not logged in', async () => {
const res = await createSavedView(
buildCreateInput({ projectId: myProject.id, resourceIdString: 'abc' }),
@@ -296,6 +321,18 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
expect(res).to.haveGraphQLErrors({ code: ForbiddenError.code })
expect(res.data?.projectMutations.savedViewMutations.createView).to.not.be.ok
})
it('should support dedicated auth policy check', async () => {
const res = await canCreateSavedView({
projectId: myLackingProject.id
})
expect(res).to.not.haveGraphQLErrors()
const data = res.data?.project.permissions.canCreateSavedView
expect(data?.authorized).to.be.false
expect(data?.code).to.equal(WorkspacePlanNoFeatureAccessError.code)
})
})
it('should successfully create a saved view group', async () => {
@@ -384,7 +421,7 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
const view = res.data?.projectMutations.savedViewMutations.createView
expect(view).to.be.ok
expect(view!.id).to.be.ok
expect(view!.name).to.contain('Scene - ') // auto-generated name
expect(view!.name).to.contain('View - ') // auto-generated name
expect(view!.description).to.be.null
expect(view!.author?.id).to.equal(me.id)
expect(view!.groupId).to.be.null
@@ -649,6 +686,115 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
})
})
describe('deletions', () => {
let deletablesProject: BasicTestStream
let models: BasicTestBranch[]
before(async () => {
deletablesProject = await createTestStream(
buildBasicTestProject({
name: 'deletables-project',
workspaceId: myProjectWorkspace.id
}),
me
)
models = await Promise.all(
times(3, async (i) => {
return await createTestBranch({
branch: buildBasicTestModel({
name: `Model #${i}`
}),
stream: deletablesProject,
owner: me
})
})
)
// add guest as reviewer
await addToStream(deletablesProject, guest, Roles.Stream.Reviewer, {
owner: me
})
})
const createTestView = async () => {
const createRes = await createSavedView(
buildCreateInput({
projectId: deletablesProject.id,
resourceIdString: models[0].id,
overrides: { name: 'View to delete' }
}),
{ assertNoErrors: true }
)
const view = createRes.data?.projectMutations.savedViewMutations.createView!
expect(view).to.be.ok
return view
}
const findView = async (viewId: string) => {
const foundView = await getView({
projectId: deletablesProject.id,
viewId
})
return foundView.data?.project.savedView
}
it('allow deleting a view', async () => {
const view = await createTestView()
const foundView = await findView(view.id)
expect(foundView).to.be.ok
const deleteRes = await deleteView(
{
input: {
id: view.id,
projectId: deletablesProject.id
}
},
{ assertNoErrors: true }
)
expect(deleteRes.data?.projectMutations.savedViewMutations.deleteView).to.be
.true
const deletedView = await findView(view.id)
expect(deletedView).to.not.be.ok
})
it('should fail to delete a view if not found', async () => {
const res = await deleteView({
input: {
id: 'non-existent-view-id',
projectId: deletablesProject.id
}
})
expect(res).to.haveGraphQLErrors({ code: NotFoundError.code })
expect(res.data?.projectMutations.savedViewMutations.deleteView).to.not.be.ok
})
it('should support dedicated auth policy check', async () => {
const view = await createTestView()
const res = await canUpdateSavedView(
{
projectId: deletablesProject.id,
viewId: view.id
},
{
authUserId: guest.id
}
)
expect(res).to.not.haveGraphQLErrors()
const data = res.data?.project.savedView.permissions.canUpdate
expect(data?.authorized).to.be.false
expect(data?.code).to.equal(ProjectNotEnoughPermissionsError.code)
})
})
describe('reading groups', () => {
const NAMED_GROUP_COUNT = 15
const GROUP_COUNT = NAMED_GROUP_COUNT + 1 // + ungrouped group
@@ -117,7 +117,7 @@ export const WorkspaceLimitsReachedError = defineAuthError<
})
export const WorkspacePlanNoFeatureAccessError = defineAuthError({
code: 'WorkspaceNoFeatureAccess',
code: 'WorkspacePlanNoFeatureAccessError',
message: 'Your workspace plan does not have access to this feature.'
})
@@ -201,6 +201,11 @@ export const AccIntegrationNotEnabledError = defineAuthError({
message: 'The ACC Integration is not enabled on this server or project'
})
export const SavedViewNotFoundError = defineAuthError({
code: 'SavedViewNotFound',
message: 'Saved view not found'
})
// Resolve all exported error types
export type AllAuthErrors = ValueOf<{
[key in keyof typeof import('./authErrors.js')]: typeof import('./authErrors.js')[key] extends new (
+31 -27
View File
@@ -1,5 +1,5 @@
import { OverrideProperties } from 'type-fest'
import { MaybeAsync } from '../../core/index.js'
import { MaybeAsync, StringEnum, StringEnumValues } from '../../core/index.js'
import type { GetServerRole } from './core/operations.js'
import type {
GetProject,
@@ -25,6 +25,7 @@ import { GetComment } from './comments/operations.js'
import { GetModel } from './models/operations.js'
import { GetVersion } from './versions/operations.js'
import { GetAutomateFunction } from './automate/operations.js'
import { GetSavedView } from './savedViews/operations.js'
// utility type that ensures all properties functions that return promises
type PromiseAll<T> = {
@@ -54,35 +55,37 @@ type AuthContextLoaderMappingDefinition<
*/
/* v8 ignore start */
export const AuthCheckContextLoaderKeys = <const>{
getEnv: 'getEnv',
getAutomateFunction: 'getAutomateFunction',
getProject: 'getProject',
getProjectRoleCounts: 'getProjectRoleCounts',
getProjectRole: 'getProjectRole',
getProjectModelCount: 'getProjectModelCount',
getServerRole: 'getServerRole',
getWorkspace: 'getWorkspace',
getUsersCurrentAndEligibleToBecomeAMemberWorkspaces:
'getUsersCurrentAndEligibleToBecomeAMemberWorkspaces',
getWorkspaceRole: 'getWorkspaceRole',
getWorkspaceSeat: 'getWorkspaceSeat',
getWorkspaceModelCount: 'getWorkspaceModelCount',
getWorkspaceProjectCount: 'getWorkspaceProjectCount',
getWorkspacePlan: 'getWorkspacePlan',
getWorkspaceLimits: 'getWorkspaceLimits',
getWorkspaceSsoProvider: 'getWorkspaceSsoProvider',
getWorkspaceSsoSession: 'getWorkspaceSsoSession',
getAdminOverrideEnabled: 'getAdminOverrideEnabled',
getComment: 'getComment',
getModel: 'getModel',
getVersion: 'getVersion'
}
export const AuthCheckContextLoaderKeys = StringEnum([
'getEnv',
'getAutomateFunction',
'getProject',
'getProjectRoleCounts',
'getProjectRole',
'getProjectModelCount',
'getServerRole',
'getWorkspace',
'getUsersCurrentAndEligibleToBecomeAMemberWorkspaces',
'getWorkspaceRole',
'getWorkspaceSeat',
'getWorkspaceModelCount',
'getWorkspaceProjectCount',
'getWorkspacePlan',
'getWorkspaceLimits',
'getWorkspaceSsoProvider',
'getWorkspaceSsoSession',
'getAdminOverrideEnabled',
'getComment',
'getModel',
'getVersion',
'getSavedView'
])
export const Loaders = AuthCheckContextLoaderKeys // shorter alias
/* v8 ignore end */
export type AuthCheckContextLoaderKeys =
(typeof AuthCheckContextLoaderKeys)[keyof typeof AuthCheckContextLoaderKeys]
export type AuthCheckContextLoaderKeys = StringEnumValues<
typeof AuthCheckContextLoaderKeys
>
export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
getEnv: GetEnv
@@ -106,6 +109,7 @@ export type AllAuthCheckContextLoaders = AuthContextLoaderMappingDefinition<{
getComment: GetComment
getModel: GetModel
getVersion: GetVersion
getSavedView: GetSavedView
}>
export type AuthCheckContextLoaders<
@@ -0,0 +1,6 @@
import { SavedView } from './types.js'
export type GetSavedView = (args: {
projectId: string
savedViewId: string
}) => Promise<SavedView | null>
@@ -0,0 +1,7 @@
export type SavedView = {
id: string
name: string
authorId: string | null
groupId: string | null
projectId: string
}
@@ -0,0 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { describe, expect, it } from 'vitest'
import { authPoliciesFactory } from './index.js'
describe('authPoliciesFactory', () => {
it('builds and contains policies', () => {
const policies = authPoliciesFactory({} as any) // fake loaders
expect(policies.project.canLeave).toBeDefined()
expect(policies.automate.function.canRegenerateToken).toBeDefined()
expect(policies.workspace.canInvite).toBeDefined()
})
})
+3 -1
View File
@@ -35,6 +35,7 @@ import { canEditFunctionPolicy } from './automate/function/canEditFunction.js'
import { canUpdateEmbedTokensPolicy } from './project/canUpdateEmbedTokens.js'
import { canReadAccIntegrationSettingsPolicy } from './project/canReadAccIntegrationSettings.js'
import { canCreateSavedViewPolicy } from './project/savedViews/canCreate.js'
import { canUpdateSavedViewPolicy } from './project/savedViews/canUpdate.js'
export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
automate: {
@@ -66,7 +67,8 @@ export const authPoliciesFactory = (loaders: AllAuthCheckContextLoaders) => ({
canRequestRender: canRequestProjectVersionRenderPolicy(loaders)
},
savedViews: {
canCreate: canCreateSavedViewPolicy(loaders)
canCreate: canCreateSavedViewPolicy(loaders),
canUpdate: canUpdateSavedViewPolicy(loaders)
},
canBroadcastActivity: canBroadcastProjectActivityPolicy(loaders),
canRead: canReadProjectPolicy(loaders),
@@ -71,7 +71,7 @@ export const canCreateSavedViewPolicy: AuthPolicy<
return err(
new ProjectNotEnoughPermissionsError({
message:
"Your role on this project doesn't give you permission to create saved views."
"Your role on this project doesn't give you permission to create saved views. You need the Can edit or Project owner role."
})
)
return err(ensuredWriteAccess.error)
@@ -0,0 +1,207 @@
import { describe, expect, it } from 'vitest'
import { OverridesOf } from '../../../../tests/helpers/types.js'
import { canUpdateSavedViewPolicy } from './canUpdate.js'
import {
getEnvFake,
getProjectFake,
getSavedViewFake,
getWorkspaceFake,
getWorkspacePlanFake,
getWorkspaceSsoProviderFake,
getWorkspaceSsoSessionFake
} from '../../../../tests/fakes.js'
import { Roles } from '../../../../core/constants.js'
import {
ProjectNotEnoughPermissionsError,
ServerNoAccessError,
WorkspaceNoAccessError,
WorkspacePlanNoFeatureAccessError,
WorkspacesNotEnabledError
} from '../../../domain/authErrors.js'
describe('canUpdateSavedViewPolicy', () => {
const buildSUT = (overrides?: OverridesOf<typeof canUpdateSavedViewPolicy>) =>
canUpdateSavedViewPolicy({
getSavedView: getSavedViewFake({
projectId: 'project-id'
}),
getProject: getProjectFake({
id: 'project-id',
workspaceId: null
}),
getEnv: getEnvFake({
FF_WORKSPACES_MODULE_ENABLED: true,
FF_SAVED_VIEWS_ENABLED: true
}),
getServerRole: async () => Roles.Server.User,
getProjectRole: async () => Roles.Stream.Owner,
getWorkspaceRole: async () => null,
getWorkspace: async () => null,
getWorkspaceSsoProvider: async () => null,
getWorkspacePlan: async () => null,
getWorkspaceSsoSession: async () => null,
...overrides
})
it('fails in non-workspaced project, even if project owner', async () => {
const policy = buildSUT()
const result = await policy({
userId: 'user-id',
projectId: 'project-id',
savedViewId: 'saved-view-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspaceNoAccessError.code
})
})
describe('w/ workspaced project', async () => {
const buildWorkspacedSUT = (
overrides?: OverridesOf<typeof canUpdateSavedViewPolicy>
) =>
buildSUT({
getProject: getProjectFake({
id: 'project-id',
workspaceId: 'workspace-id'
}),
getWorkspaceRole: async () => Roles.Workspace.Member,
getWorkspace: getWorkspaceFake({
id: 'workspace-id'
}),
getWorkspacePlan: getWorkspacePlanFake({
workspaceId: 'workspace-id',
name: 'pro'
}),
getWorkspaceSsoProvider: getWorkspaceSsoProviderFake({
providerId: 'sso-provider-id'
}),
getWorkspaceSsoSession: getWorkspaceSsoSessionFake({
providerId: 'sso-provider-id'
}),
...overrides
})
it('works if user is project owner', async () => {
const sut = buildWorkspacedSUT()
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
savedViewId: 'saved-view-id'
})
expect(result).toBeOKResult()
})
it('fails if workspaces disabled', async () => {
const sut = buildWorkspacedSUT({
getEnv: getEnvFake({
FF_WORKSPACES_MODULE_ENABLED: false,
FF_SAVED_VIEWS_ENABLED: true
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
savedViewId: 'saved-view-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspacesNotEnabledError.code
})
})
it('fails if saved views disabled', async () => {
const sut = buildWorkspacedSUT({
getEnv: getEnvFake({
FF_WORKSPACES_MODULE_ENABLED: true,
FF_SAVED_VIEWS_ENABLED: false
})
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
savedViewId: 'saved-view-id'
})
expect(result).toBeAuthErrorResult({
code: WorkspacePlanNoFeatureAccessError.code
})
})
it('fails if just reviewer', async () => {
const sut = buildWorkspacedSUT({
getProjectRole: async () => Roles.Stream.Reviewer
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
savedViewId: 'saved-view-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotEnoughPermissionsError.code
})
})
it('fails if logged out', async () => {
const sut = buildWorkspacedSUT({
getWorkspaceRole: async () => null,
getServerRole: async () => null,
getProjectRole: async () => null
})
const result = await sut({
userId: 'aaa',
projectId: 'project-id',
savedViewId: 'saved-view-id'
})
expect(result).toBeAuthErrorResult({
code: ServerNoAccessError.code
})
})
it('fails if not owner and not the author', async () => {
const sut = buildWorkspacedSUT({
getSavedView: getSavedViewFake({
projectId: 'project-id',
authorId: 'another-user-id'
}),
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
savedViewId: 'saved-view-id'
})
expect(result).toBeAuthErrorResult({
code: ProjectNotEnoughPermissionsError.code
})
})
it('succeeds if not owner but author', async () => {
const sut = buildWorkspacedSUT({
getSavedView: getSavedViewFake({
projectId: 'project-id',
authorId: 'user-id'
}),
getProjectRole: async () => Roles.Stream.Contributor
})
const result = await sut({
userId: 'user-id',
projectId: 'project-id',
savedViewId: 'saved-view-id'
})
expect(result).toBeOKResult()
})
})
})
@@ -0,0 +1,112 @@
import { Roles } from '../../../../core/constants.js'
import { WorkspacePlanFeatures } from '../../../../workspaces/index.js'
import {
ProjectNoAccessError,
ProjectNotEnoughPermissionsError,
ProjectNotFoundError,
SavedViewNotFoundError,
ServerNoAccessError,
ServerNoSessionError,
ServerNotEnoughPermissionsError,
WorkspaceNoAccessError,
WorkspaceNotEnoughPermissionsError,
WorkspacePlanNoFeatureAccessError,
WorkspaceReadOnlyError,
WorkspacesNotEnabledError,
WorkspaceSsoSessionNoAccessError
} from '../../../domain/authErrors.js'
import { MaybeUserContext, ProjectContext } from '../../../domain/context.js'
import { Loaders } from '../../../domain/loaders.js'
import { AuthPolicy } from '../../../domain/policies.js'
import {
ensureCanUseProjectWorkspacePlanFeatureFragment,
ensureImplicitProjectMemberWithWriteAccessFragment
} from '../../../fragments/projects.js'
import { err, ok } from 'true-myth/result'
export const canUpdateSavedViewPolicy: AuthPolicy<
| typeof Loaders.getSavedView
| typeof Loaders.getProject
| typeof Loaders.getEnv
| typeof Loaders.getServerRole
| typeof Loaders.getWorkspaceRole
| typeof Loaders.getWorkspace
| typeof Loaders.getWorkspaceSsoProvider
| typeof Loaders.getWorkspacePlan
| typeof Loaders.getWorkspaceSsoSession
| typeof Loaders.getProjectRole,
MaybeUserContext &
ProjectContext & {
savedViewId: string
},
InstanceType<
| typeof ProjectNotFoundError
| typeof ServerNoAccessError
| typeof ServerNoSessionError
| typeof ProjectNoAccessError
| typeof WorkspaceNoAccessError
| typeof WorkspaceSsoSessionNoAccessError
| typeof ServerNotEnoughPermissionsError
| typeof ProjectNotEnoughPermissionsError
| typeof WorkspaceNotEnoughPermissionsError
| typeof WorkspacePlanNoFeatureAccessError
| typeof WorkspaceReadOnlyError
| typeof WorkspacesNotEnabledError
| typeof SavedViewNotFoundError
>
> =
(loaders) =>
async ({ userId, projectId, savedViewId }) => {
const canUseSavedViews = await ensureCanUseProjectWorkspacePlanFeatureFragment(
loaders
)({
projectId,
feature: WorkspacePlanFeatures.SavedViews
})
if (canUseSavedViews.isErr) return err(canUseSavedViews.error)
const ensuredWriteAccess = await ensureImplicitProjectMemberWithWriteAccessFragment(
loaders
)({
userId,
projectId,
role: Roles.Stream.Contributor
})
if (ensuredWriteAccess.isErr) {
if (ensuredWriteAccess.error.code === ProjectNotEnoughPermissionsError.code)
return err(
new ProjectNotEnoughPermissionsError({
message:
"Your role on this project doesn't give you permission to update saved views. You need to be the author of the view or the Project owner."
})
)
return err(ensuredWriteAccess.error)
}
// Even if user has access to project - must be author OR project admin
const savedView = await loaders.getSavedView({
projectId,
savedViewId
})
if (!savedView) return err(new SavedViewNotFoundError())
if (savedView.authorId !== userId) {
const ensuredWriteAccess =
await ensureImplicitProjectMemberWithWriteAccessFragment(loaders)({
userId,
projectId,
role: Roles.Stream.Owner
})
if (ensuredWriteAccess.isErr) {
if (ensuredWriteAccess.error.code === ProjectNotEnoughPermissionsError.code)
return err(
new ProjectNotEnoughPermissionsError({
message: "Only project owners can update saved views they don't own."
})
)
return err(ensuredWriteAccess.error)
}
}
return ok()
}
+24 -1
View File
@@ -4,11 +4,16 @@ import { Comment } from '../authz/domain/comments/types.js'
import { nanoid } from 'nanoid'
import { Model } from '../authz/domain/models/types.js'
import { Version } from '../authz/domain/versions/types.js'
import { Workspace } from '../authz/domain/workspaces/types.js'
import {
Workspace,
WorkspaceSsoProvider,
WorkspaceSsoSession
} from '../authz/domain/workspaces/types.js'
import { FeatureFlags, parseFeatureFlags } from '../environment/index.js'
import { mapValues } from 'lodash'
import { WorkspacePlan } from '../workspaces/index.js'
import { TIME_MS } from '../core/index.js'
import { SavedView } from '../authz/domain/savedViews/types.js'
export const fakeGetFactory =
<T extends Record<string, unknown>>(defaults: () => T) =>
@@ -42,6 +47,16 @@ export const getWorkspacePlanFake = fakeGetFactory<WorkspacePlan>(() => ({
updatedAt: new Date(Date.now() - TIME_MS.day)
}))
export const getWorkspaceSsoProviderFake = fakeGetFactory<WorkspaceSsoProvider>(() => ({
providerId: nanoid(10)
}))
export const getWorkspaceSsoSessionFake = fakeGetFactory<WorkspaceSsoSession>(() => ({
userId: nanoid(10),
providerId: nanoid(10),
validUntil: new Date(Date.now() + TIME_MS.day)
}))
export const getCommentFake = fakeGetFactory<Comment>(() => ({
id: nanoid(10),
authorId: nanoid(10),
@@ -61,6 +76,14 @@ export const getVersionFake = fakeGetFactory<Version>(() => ({
authorId: nanoid(10)
}))
export const getSavedViewFake = fakeGetFactory<SavedView>(() => ({
id: nanoid(10),
name: nanoid(10),
authorId: nanoid(10),
projectId: nanoid(10),
groupId: null
}))
// eslint-disable-next-line @typescript-eslint/require-await
export const getEnvFake = (overrides?: Partial<FeatureFlags>) => async () =>
parseFeatureFlags(mapValues(overrides || {}, (v) => `${v}` as 'true' | 'false'))
@@ -19,7 +19,7 @@
v-for="item in group"
v-slot="{ active, disabled }"
:key="item.id"
:disabled="item.disabled"
:disabled="item.disabled || undefined"
:color="item.color"
>
<span v-tippy="item.disabled && item.disabledTooltip">
@@ -39,7 +39,7 @@
</HeadlessMenu>
</template>
<script setup lang="ts">
<script setup lang="ts" generic="MenuIds extends string = string">
import { directive as vTippy } from 'vue-tippy'
import { Menu as HeadlessMenu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import type { Nullable } from '@speckle/shared'
@@ -54,8 +54,7 @@ import { useBodyMountedMenuPositioning } from '~~/src/composables/layout/menu'
const emit = defineEmits<{
(e: 'update:open', val: boolean): void
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(e: 'chosen', val: { event: MouseEvent; item: LayoutMenuItem<any> }): void
(e: 'chosen', val: { event: MouseEvent; item: LayoutMenuItem<MenuIds> }): void
}>()
const props = defineProps<{
@@ -63,7 +62,7 @@ const props = defineProps<{
/**
* 2D array so that items can be grouped with dividers between them
*/
items: LayoutMenuItem[][]
items: LayoutMenuItem<MenuIds>[][]
size?: 'base' | 'lg'
menuId?: string
/**
@@ -179,7 +178,7 @@ const buildButtonClassses = (params: {
return classParts.join(' ')
}
const chooseItem = (item: LayoutMenuItem, event: MouseEvent) => {
const chooseItem = (item: LayoutMenuItem<MenuIds>, event: MouseEvent) => {
emit('chosen', { item, event })
}
@@ -3,6 +3,7 @@ import type { FormButton } from '~~/src/lib'
type FormButtonProps = InstanceType<typeof FormButton>['$props']
import type { PropAnyComponent } from '~~/src/helpers/common/components'
import type { MaybeNullOrUndefined } from '@speckle/shared'
export enum GridListToggleValue {
Grid = 'grid',
@@ -28,8 +29,8 @@ export type LayoutMenuItem<I extends string = string> = {
icon?: ConcreteComponent
title: string
id: I
disabled?: boolean
disabledTooltip?: string
disabled?: MaybeNullOrUndefined<boolean>
disabledTooltip?: MaybeNullOrUndefined<string>
color?: 'danger' | 'info'
}