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
This commit is contained in:
Kristaps Fabians Geikins
2025-10-08 09:49:26 +02:00
committed by GitHub
parent da7bd39f6f
commit 185806ec64
25 changed files with 1146 additions and 92 deletions
@@ -0,0 +1,126 @@
<template>
<FormSelectBase
v-model="selectedValue"
:name="name || 'savedView'"
:label="label || 'View'"
:label-id="labelId"
:button-id="buttonId"
mount-menu-on-body
:show-label="showLabel"
:fully-control-value="fullyControlValue"
:disabled="disabled"
:clearable="clearable"
:get-search-results="getSearchResults"
:allow-unset="allowUnset"
search
>
<template #nothing-selected>Select view</template>
<template #something-selected="{ value }">
<div class="truncate text-foreground capitalize">
{{ isArrayValue(value) ? value.map((v) => v.name).join(', ') : value.name }}
</div>
</template>
<template #option="{ item }">
<div class="flex gap-2 items-center">
<img
:src="item.thumbnailUrl"
alt="thumbnail"
class="w-20 h-[60px] object-cover rounded border border-outline-3 bg-foundation-page"
/>
<span class="truncate capitalize">{{ item.name }}</span>
</div>
</template>
</FormSelectBase>
</template>
<script setup lang="ts">
import { useFormSelectChildInternals } from '@speckle/ui-components'
import { useApolloClient } from '@vue/apollo-composable'
import { graphql } from '~/lib/common/generated/gql'
import type {
FormSelectSavedView_SavedViewFragment,
SavedViewVisibility
} from '~/lib/common/generated/gql/graphql'
graphql(`
fragment FormSelectSavedView_SavedView on SavedView {
id
name
thumbnailUrl
}
`)
const searchItemsQuery = graphql(`
query FormSelectSavedView_SavedViews(
$projectId: String!
$input: ProjectSavedViewsInput!
) {
project(id: $projectId) {
id
savedViews(input: $input) {
items {
id
...FormSelectSavedView_SavedView
}
totalCount
cursor
}
}
}
`)
type ItemType = FormSelectSavedView_SavedViewFragment
type ValueType = ItemType | ItemType[] | undefined
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = withDefaults(
defineProps<{
projectId: string
resourceIdString?: string
onlyVisibility?: SavedViewVisibility
modelValue?: ValueType
fullyControlValue?: boolean
label?: string
disabled?: boolean
showLabel?: boolean
clearable?: boolean
allowUnset?: boolean
name?: string
}>(),
{
clearable: false,
allowUnset: false
}
)
const apollo = useApolloClient().client
const labelId = useId()
const buttonId = useId()
const { selectedValue, isArrayValue } = useFormSelectChildInternals<ItemType>({
props: toRefs(props),
emit
})
const getSearchResults = async (search: string): Promise<ItemType[]> => {
const res = await apollo
.query({
query: searchItemsQuery,
variables: {
projectId: props.projectId,
input: {
resourceIdString: props.resourceIdString,
onlyVisibility: props.onlyVisibility,
search,
limit: 10
}
}
})
.catch(convertThrowIntoFetchResult)
const items = res.data?.project.savedViews.items || []
return items
}
</script>
@@ -49,13 +49,37 @@
</p>
<h4 class="text-heading-sm text-foreground-2 mb-1 ml-0.5">Embed URL</h4>
<FormClipboardInput class="mb-4" :value="updatedUrl" />
<LayoutDialogSection border-b border-t title="Options">
<LayoutDialogSection
v-model:open="areOptionsExpanded"
border-b
border-t
title="Options"
>
<div class="flex flex-col gap-1.5 sm:gap-2 text-body-xs cursor-default">
<div v-if="areSavedViewsEnabled" class="flex flex-col gap-1">
<label for="option-saved-view" :class="optionLabelClasses">
<FormCheckbox
id="option-saved-view"
v-model="shouldEmbedSavedView"
name="Embed a saved view"
hide-label
class="cursor-pointer"
/>
<span>Embed a saved view</span>
</label>
<FormSelectSavedView
v-if="shouldEmbedSavedView"
v-model="embeddedSavedView"
:project-id="props.project.id"
:resource-id-string="routeModelId"
:only-visibility="SavedViewVisibility.Public"
:show-label="false"
mount-menu-on-body
class="max-w-sm"
/>
</div>
<div v-for="option in embedDialogOptions" :key="option.id">
<label
:for="`option-${option.id}`"
class="flex items-center gap-1 cursor-pointer max-w-max"
>
<label :for="`option-${option.id}`" :class="optionLabelClasses">
<FormCheckbox
:id="`option-${option.id}`"
:model-value="option.value.value"
@@ -70,10 +94,7 @@
</label>
</div>
<div v-if="isWorkspacesEnabled">
<label
:for="`option-hide-logo`"
class="flex items-center gap-1 cursor-pointer max-w-max"
>
<label :for="`option-hide-logo`" :class="optionLabelClasses">
<FormCheckbox
id="option-hide-logo"
v-model="hideSpeckleBranding"
@@ -163,7 +184,11 @@
</template>
<script setup lang="ts">
import type { ProjectsModelPageEmbed_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
import {
SavedViewVisibility,
type FormSelectSavedView_SavedViewFragment,
type ProjectsModelPageEmbed_ProjectFragment
} from '~~/lib/common/generated/gql/graphql'
import { useClipboard } from '~~/composables/browser'
import { SpeckleViewer, Roles } from '@speckle/shared'
import { graphql } from '~~/lib/common/generated/gql'
@@ -175,6 +200,7 @@ import {
castToSupportedVisibility
} from '~/lib/projects/helpers/visibility'
import { useMixpanel } from '~/lib/core/composables/mp'
import { useAreSavedViewsEnabled } from '~/lib/viewer/composables/savedViews/general'
graphql(`
fragment ProjectsModelPageEmbed_Project on Project {
@@ -208,6 +234,7 @@ const props = defineProps<{
}>()
const isOpen = defineModel<boolean>('open', { required: true })
const areOptionsExpanded = ref(false)
const mixpanel = useMixpanel()
const route = useRoute()
@@ -217,6 +244,7 @@ const {
} = useRuntimeConfig()
const createEmbedToken = useCreateEmbedToken()
const areSavedViewsEnabled = useAreSavedViewsEnabled()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const { isSmallerOrEqualSm } = useIsSmallerOrEqualThanBreakpoint()
@@ -228,7 +256,15 @@ const preventScrolling = ref(false)
const manuallyLoadModel = ref(false)
const hideSpeckleBranding = ref(false)
const embedToken = ref<string | null>(null)
const shouldEmbedSavedView = ref(false)
const embeddedSavedView = defineModel<FormSelectSavedView_SavedViewFragment>('view', {
required: false
})
const optionLabelClasses = computed(
() => 'flex items-center gap-1 cursor-pointer max-w-max'
)
const isAdmin = computed(() => props.project.workspace?.role === Roles.Workspace.Admin)
const routeModelId = computed(() => route.params.modelId as string)
@@ -270,7 +306,7 @@ const updatedUrl = computed(() => {
}
// Construct the embed options as a hash fragment
const embedOptions: Record<string, boolean> = { isEnabled: true }
const embedOptions: Record<string, unknown> = { isEnabled: true }
embedDialogOptions.forEach((option) => {
if (option.value.value) {
embedOptions[option.id] = true
@@ -289,6 +325,17 @@ const updatedUrl = computed(() => {
const hashFragment = encodeURIComponent(JSON.stringify(embedOptions))
url.hash = `embed=${hashFragment}`
// Embed view?
const savedViewSettings: Record<string, unknown> = {}
if (shouldEmbedSavedView.value && embeddedSavedView.value) {
savedViewSettings['id'] = embeddedSavedView.value.id
}
if (Object.keys(savedViewSettings).length > 0) {
const savedViewFragment = encodeURIComponent(JSON.stringify(savedViewSettings))
url.hash += `&savedView=${savedViewFragment}`
}
return url.toString()
})
@@ -438,4 +485,14 @@ watch(
},
{ immediate: true }
)
watch(embeddedSavedView, (newVal, oldVal) => {
if (newVal) {
shouldEmbedSavedView.value = true
if (!oldVal || newVal.id !== oldVal.id) {
areOptionsExpanded.value = true
}
}
})
</script>
@@ -94,7 +94,7 @@
</ClientOnly>
</div>
<ViewerEmbedFooter
:name="modelName || 'Loading...'"
:name="embedName"
:date="lastUpdate"
:url="route.path"
:hide-speckle-branding="hideSpeckleLogo"
@@ -121,6 +121,13 @@ import { ViewerLimitsDialogType } from '~/lib/projects/helpers/limits'
import { TailwindBreakpoints } from '~~/lib/common/helpers/tailwind'
import { useBreakpoints } from '@vueuse/core'
graphql(`
fragment ViewerPageSetup_SavedView on SavedView {
id
name
}
`)
graphql(`
fragment ModelPageProject on Project {
id
@@ -165,7 +172,7 @@ const mp = useMixpanel()
const {
resources: {
response: { project, modelsAndVersionIds }
response: { project, modelsAndVersionIds, savedView }
}
} = state
@@ -225,6 +232,18 @@ const modelName = computed(() => {
}
})
const embedName = computed(() => {
if (!modelName.value) return 'Loading...'
let ret = ''
if (savedView.value) {
ret += `${savedView.value.name} | `
}
ret += modelName.value
return ret
})
const lastUpdate = computed(() => {
if (project.value?.models?.items[0] && project.value.models.items[0].updatedAt) {
return 'Updated ' + dayjs(project.value.models.items[0].updatedAt).fromNow()
@@ -46,6 +46,12 @@
v-model:open="showGroupDeleteDialog"
:group="groupBeingDeleted"
/>
<ProjectModelPageDialogEmbed
v-if="mainProject"
v-model:open="showViewEmbedDialog"
:view="viewBeingEmbedded"
:project="mainProject"
/>
</div>
</template>
<script setup lang="ts">
@@ -53,6 +59,7 @@ import { omit } from 'lodash-es'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { graphql } from '~/lib/common/generated/gql'
import type {
FormSelectSavedView_SavedViewFragment,
UseUpdateSavedViewGroup_SavedViewGroupFragment,
ViewerSavedViewsPanelViewDeleteDialog_SavedViewFragment,
ViewerSavedViewsPanelViewEditDialog_SavedViewFragment,
@@ -99,7 +106,8 @@ const props = defineProps<{
const {
projectId,
resources: {
request: { resourceIdString }
request: { resourceIdString },
response: { project: mainProject }
},
ui: {
savedViews: { openedGroupState }
@@ -113,6 +121,7 @@ const viewBeingDeleted = ref<ViewerSavedViewsPanelViewDeleteDialog_SavedViewFrag
const groupBeingDeleted =
ref<ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroupFragment>()
const groupBeingRenamed = ref<UseUpdateSavedViewGroup_SavedViewGroupFragment>()
const viewBeingEmbedded = ref<FormSelectSavedView_SavedViewFragment>()
const {
identifier,
@@ -190,6 +199,15 @@ const showGroupDeleteDialog = computed({
}
})
const showViewEmbedDialog = computed({
get: () => !!viewBeingEmbedded.value,
set: (value) => {
if (!value) {
viewBeingEmbedded.value = undefined
}
}
})
const isGroupInRenameMode = (
group: ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragment
) => {
@@ -237,6 +255,10 @@ eventBus.on(ViewerEventBusKeys.MarkSavedViewForEdit, ({ type, view }) => {
}
})
eventBus.on(ViewerEventBusKeys.MarkSavedViewForEmbed, ({ view }) => {
viewBeingEmbedded.value = view
})
const onMoveSuccess = (groupId: string) => {
openedGroupState.value.set(groupId, true)
}
@@ -138,7 +138,8 @@ const MenuItems = StringEnum([
'ChangeVisibility',
'ReplaceView',
'MoveToGroup',
'SetAsHomeView'
'SetAsHomeView',
'Embed'
])
type MenuItems = StringEnumValues<typeof MenuItems>
@@ -193,7 +194,8 @@ const {
isHomeView,
canToggleVisibility,
canMove,
canOpenEditDialog
canOpenEditDialog,
canEmbed
} = useSavedViewValidationHelpers({
view: computed(() => props.view)
})
@@ -265,6 +267,12 @@ const menuItems = computed((): LayoutMenuItem<MenuItems>[][] => [
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
}
],
[
@@ -360,6 +368,11 @@ const onActionChosen = async (item: LayoutMenuItem<MenuItems>) => {
}
})
break
case MenuItems.Embed:
eventBus.emit(ViewerEventBusKeys.MarkSavedViewForEmbed, {
view: props.view
})
break
default:
throwUncoveredError(item.id)
}
@@ -52,6 +52,8 @@ type Documents = {
"\n mutation DashboardsShareDisableToken($input: DashboardShareInput!) {\n dashboardMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n": typeof types.DashboardsShareDisableTokenDocument,
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": typeof types.FormSelectModels_ModelFragmentDoc,
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": typeof types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormSelectSavedView_SavedView on SavedView {\n id\n name\n thumbnailUrl\n }\n": typeof types.FormSelectSavedView_SavedViewFragmentDoc,
"\n query FormSelectSavedView_SavedViews(\n $projectId: String!\n $input: ProjectSavedViewsInput!\n ) {\n project(id: $projectId) {\n id\n savedViews(input: $input) {\n items {\n id\n ...FormSelectSavedView_SavedView\n }\n totalCount\n cursor\n }\n }\n }\n": typeof types.FormSelectSavedView_SavedViewsDocument,
"\n fragment FormSelectSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n isUngroupedViewsGroup\n }\n": typeof types.FormSelectSavedViewGroup_SavedViewGroupFragmentDoc,
"\n query FormSelectSavedViewGroup_SavedViewGroups(\n $projectId: String!\n $input: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n savedViewGroups(input: $input) {\n items {\n id\n ...FormSelectSavedViewGroup_SavedViewGroup\n }\n totalCount\n cursor\n }\n }\n }\n": typeof types.FormSelectSavedViewGroup_SavedViewGroupsDocument,
"\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n": typeof types.FormUsersSelectItemFragmentDoc,
@@ -183,6 +185,7 @@ type Documents = {
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": typeof types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecurityWorkspaceCreation_Workspace on Workspace {\n id\n slug\n role\n isExclusive\n hasAccessToExclusiveMembership: hasAccessToFeature(featureName: exclusiveMembership)\n permissions {\n canMakeWorkspaceExclusive {\n authorized\n message\n }\n }\n }\n": typeof types.SettingsWorkspacesSecurityWorkspaceCreation_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n": typeof types.SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragmentDoc,
"\n fragment ViewerPageSetup_SavedView on SavedView {\n id\n name\n }\n": typeof types.ViewerPageSetup_SavedViewFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n limitedWorkspace {\n id\n slug\n name\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n ...ViewerLimitsDialog_Project\n }\n": typeof types.ModelPageProjectFragmentDoc,
"\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.ViewerCommentThreadDataFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": typeof types.ThreadCommentAttachmentFragmentDoc,
@@ -468,7 +471,7 @@ type Documents = {
"\n fragment UseDraggableViewTargetView_SavedView on SavedView {\n id\n name\n position\n group {\n id\n }\n }\n": typeof types.UseDraggableViewTargetView_SavedViewFragmentDoc,
"\n fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": typeof types.UseDraggableViewTargetGroup_SavedViewGroupFragmentDoc,
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n canSetAsHomeView {\n ...FullPermissionCheckResult\n }\n }\n }\n": typeof types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": typeof types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n ...ViewerPageSetup_SavedView\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,
"\n mutation BroadcastViewerUserActivity(\n $projectId: String!\n $resourceIdString: String!\n $message: ViewerUserActivityMessageInput!\n ) {\n broadcastViewerUserActivity(\n projectId: $projectId\n resourceIdString: $resourceIdString\n message: $message\n )\n }\n": typeof types.BroadcastViewerUserActivityDocument,
@@ -605,6 +608,8 @@ const documents: Documents = {
"\n mutation DashboardsShareDisableToken($input: DashboardShareInput!) {\n dashboardMutations {\n disableShare(input: $input) {\n id\n revoked\n content\n }\n }\n }\n": types.DashboardsShareDisableTokenDocument,
"\n fragment FormSelectModels_Model on Model {\n id\n name\n }\n": types.FormSelectModels_ModelFragmentDoc,
"\n fragment FormSelectProjects_Project on Project {\n id\n name\n }\n": types.FormSelectProjects_ProjectFragmentDoc,
"\n fragment FormSelectSavedView_SavedView on SavedView {\n id\n name\n thumbnailUrl\n }\n": types.FormSelectSavedView_SavedViewFragmentDoc,
"\n query FormSelectSavedView_SavedViews(\n $projectId: String!\n $input: ProjectSavedViewsInput!\n ) {\n project(id: $projectId) {\n id\n savedViews(input: $input) {\n items {\n id\n ...FormSelectSavedView_SavedView\n }\n totalCount\n cursor\n }\n }\n }\n": types.FormSelectSavedView_SavedViewsDocument,
"\n fragment FormSelectSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n isUngroupedViewsGroup\n }\n": types.FormSelectSavedViewGroup_SavedViewGroupFragmentDoc,
"\n query FormSelectSavedViewGroup_SavedViewGroups(\n $projectId: String!\n $input: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n savedViewGroups(input: $input) {\n items {\n id\n ...FormSelectSavedViewGroup_SavedViewGroup\n }\n totalCount\n cursor\n }\n }\n }\n": types.FormSelectSavedViewGroup_SavedViewGroupsDocument,
"\n fragment FormUsersSelectItem on LimitedUser {\n id\n name\n avatar\n }\n": types.FormUsersSelectItemFragmentDoc,
@@ -736,6 +741,7 @@ const documents: Documents = {
"\n fragment SettingsWorkspacesSecurityDomainRemoveDialog_Workspace on Workspace {\n id\n domains {\n ...SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceDomain\n }\n }\n": types.SettingsWorkspacesSecurityDomainRemoveDialog_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecurityWorkspaceCreation_Workspace on Workspace {\n id\n slug\n role\n isExclusive\n hasAccessToExclusiveMembership: hasAccessToFeature(featureName: exclusiveMembership)\n permissions {\n canMakeWorkspaceExclusive {\n authorized\n message\n }\n }\n }\n": types.SettingsWorkspacesSecurityWorkspaceCreation_WorkspaceFragmentDoc,
"\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n": types.SettingsWorkspacesSecuritySsoWrapper_WorkspaceFragmentDoc,
"\n fragment ViewerPageSetup_SavedView on SavedView {\n id\n name\n }\n": types.ViewerPageSetup_SavedViewFragmentDoc,
"\n fragment ModelPageProject on Project {\n id\n createdAt\n name\n visibility\n limitedWorkspace {\n id\n slug\n name\n }\n embedOptions {\n hideSpeckleBranding\n }\n hasAccessToFeature(featureName: hideSpeckleBranding)\n ...ViewerLimitsDialog_Project\n }\n": types.ModelPageProjectFragmentDoc,
"\n fragment ViewerCommentThreadData on Comment {\n id\n permissions {\n canArchive {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.ViewerCommentThreadDataFragmentDoc,
"\n fragment ThreadCommentAttachment on Comment {\n text {\n attachments {\n id\n fileName\n fileType\n fileSize\n }\n }\n }\n": types.ThreadCommentAttachmentFragmentDoc,
@@ -1021,7 +1027,7 @@ const documents: Documents = {
"\n fragment UseDraggableViewTargetView_SavedView on SavedView {\n id\n name\n position\n group {\n id\n }\n }\n": types.UseDraggableViewTargetView_SavedViewFragmentDoc,
"\n fragment UseDraggableViewTargetGroup_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": types.UseDraggableViewTargetGroup_SavedViewGroupFragmentDoc,
"\n fragment UseSavedViewValidationHelpers_SavedView on SavedView {\n id\n isHomeView\n visibility\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n canMove {\n ...FullPermissionCheckResult\n }\n canEditTitle {\n ...FullPermissionCheckResult\n }\n canEditDescription {\n ...FullPermissionCheckResult\n }\n canSetAsHomeView {\n ...FullPermissionCheckResult\n }\n }\n }\n": types.UseSavedViewValidationHelpers_SavedViewFragmentDoc,
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n": types.UseViewerSavedViewSetup_SavedViewFragmentDoc,
"\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n ...ViewerPageSetup_SavedView\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,
"\n mutation BroadcastViewerUserActivity(\n $projectId: String!\n $resourceIdString: String!\n $message: ViewerUserActivityMessageInput!\n ) {\n broadcastViewerUserActivity(\n projectId: $projectId\n resourceIdString: $resourceIdString\n message: $message\n )\n }\n": types.BroadcastViewerUserActivityDocument,
@@ -1286,6 +1292,14 @@ export function graphql(source: "\n fragment FormSelectModels_Model on Model {\
* 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 FormSelectProjects_Project on Project {\n id\n name\n }\n"): (typeof documents)["\n fragment FormSelectProjects_Project on Project {\n id\n name\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 FormSelectSavedView_SavedView on SavedView {\n id\n name\n thumbnailUrl\n }\n"): (typeof documents)["\n fragment FormSelectSavedView_SavedView on SavedView {\n id\n name\n thumbnailUrl\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 query FormSelectSavedView_SavedViews(\n $projectId: String!\n $input: ProjectSavedViewsInput!\n ) {\n project(id: $projectId) {\n id\n savedViews(input: $input) {\n items {\n id\n ...FormSelectSavedView_SavedView\n }\n totalCount\n cursor\n }\n }\n }\n"): (typeof documents)["\n query FormSelectSavedView_SavedViews(\n $projectId: String!\n $input: ProjectSavedViewsInput!\n ) {\n project(id: $projectId) {\n id\n savedViews(input: $input) {\n items {\n id\n ...FormSelectSavedView_SavedView\n }\n totalCount\n cursor\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1810,6 +1824,10 @@ export function graphql(source: "\n fragment SettingsWorkspacesSecurityWorkspac
* 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 SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\n }\n"): (typeof documents)["\n fragment SettingsWorkspacesSecuritySsoWrapper_Workspace on Workspace {\n id\n role\n slug\n sso {\n provider {\n id\n name\n clientId\n issuerUrl\n }\n }\n hasAccessToSSO: hasAccessToFeature(featureName: oidcSso)\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 ViewerPageSetup_SavedView on SavedView {\n id\n name\n }\n"): (typeof documents)["\n fragment ViewerPageSetup_SavedView on SavedView {\n id\n name\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2953,7 +2971,7 @@ export function graphql(source: "\n fragment UseSavedViewValidationHelpers_Save
/**
* 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 UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n"): (typeof documents)["\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n }\n"];
export function graphql(source: "\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n ...ViewerPageSetup_SavedView\n }\n"): (typeof documents)["\n fragment UseViewerSavedViewSetup_SavedView on SavedView {\n id\n viewerState\n ...ViewerPageSetup_SavedView\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
@@ -1,29 +1,60 @@
import { CleanStackTrace, collectLongTrace } from '@speckle/shared'
import { isString } from 'lodash-es'
type LoggingRefOptions<R extends Ref<unknown>> = Partial<{
writes: boolean
reads: boolean
name: string
transform: (val: R['value']) => unknown
enabled: boolean
}>
type LoggingRefNameOrOptions<R extends Ref<unknown>> = string | LoggingRefOptions<R>
const parseNameOrOptions = <R extends Ref<unknown>>(
nameOrOptions: LoggingRefNameOrOptions<R> | undefined
): Required<LoggingRefOptions<R>> => {
const options: LoggingRefOptions<R> = nameOrOptions
? isString(nameOrOptions)
? { name: nameOrOptions }
: nameOrOptions
: {}
const name = options.name || 'unknown ref'
const transform = options.transform || ((val) => val)
const enabled = options.enabled ?? true
const writes = options.writes ?? true
const reads = options.reads ?? false // usually interested in just writes
return { name, transform, enabled, writes, reads }
}
/**
* Wrap refs with writable computeds that output where reads/writes are coming from
*/
export function wrapRefWithTracking<R extends Ref<unknown>>(
export function makeRefLogged<R extends Ref<unknown>>(
ref: R,
name: string,
options?: Partial<{
writesOnly: boolean
readsOnly: boolean
}>
nameOrOptions?: LoggingRefNameOrOptions<R> | undefined
): R {
const { writesOnly, readsOnly } = options || {}
const getTrace = () => (new Error('Trace:').stack || '').substring(7)
const { writes, reads, name, transform, enabled } = parseNameOrOptions(nameOrOptions)
const getTrace = collectLongTrace
const { logger } = useSafeLogger()
return computed({
get: () => {
if (!writesOnly) {
logger().debug(`debugging: '${name}' read`, ref.value, getTrace())
if (reads && enabled) {
logger().debug(`debugging: '${name}' read`, {
val: transform(ref.value),
trace: getTrace()
})
}
return ref.value
},
set: (newVal) => {
if (!readsOnly) {
logger().debug(`debugging: '${name}' written to`, { newVal }, getTrace())
if (writes && enabled) {
logger().debug(`debugging: '${name}' written to`, {
val: transform(newVal),
trace: getTrace()
})
}
ref.value = newVal
@@ -33,15 +64,20 @@ export function wrapRefWithTracking<R extends Ref<unknown>>(
}
/**
* Use this to render a stack trace with clickable links to the source code in the browser console
* Define a trackable ref that logs reads and writes to the console w/ stack traces. Useful for figuring out
* what's causing a ref to change.
*/
export class StackTrace extends Error {
constructor() {
super('')
this.name = 'Stack trace:'
}
export const refLogged = <T>(
nameOrOptions: LoggingRefNameOrOptions<Ref<T>>,
value: T
) => {
return makeRefLogged<Ref<T>>(ref<T>(value) as Ref<T>, nameOrOptions)
}
export const refWithLogging = refLogged
export function getCurrentTrace() {
return (new Error('Trace:').stack || '').substring(7)
}
export { CleanStackTrace as StackTrace, CleanStackTrace }
@@ -11,10 +11,10 @@ import {
export const useAreSavedViewsEnabled = () => {
const {
public: { FF_SAVED_VIEWS_ENABLED }
public: { FF_SAVED_VIEWS_ENABLED, FF_WORKSPACES_MODULE_ENABLED }
} = useRuntimeConfig()
return FF_SAVED_VIEWS_ENABLED
return !!(FF_SAVED_VIEWS_ENABLED && FF_WORKSPACES_MODULE_ENABLED)
}
export const useViewerSavedViewsUtils = () => {
@@ -9,6 +9,7 @@ import {
type InitialSetupState,
type UseSetupViewerParams
} from '~/lib/viewer/composables/setup'
import { useEmbedState } from '~/lib/viewer/composables/setup/embed'
import type { SavedViewUrlSettings } from '~/lib/viewer/helpers/savedViews'
import { ViewerRenderPageType } from '~/lib/viewer/helpers/state'
@@ -32,6 +33,7 @@ export const useViewerSavedViewIntegration = () => {
const applyState = useApplySerializedState()
const { serializedStateId } = useViewerRealtimeActivityTracker()
const { on, emit } = useEventBus()
const { embedOptions } = useEmbedState()
const validState = (state: unknown) => (isSerializedViewerState(state) ? state : null)
@@ -45,6 +47,10 @@ export const useViewerSavedViewIntegration = () => {
savedViewStateId.value = serializedStateId.value
}
const resetUrlHashState = async () => {
await urlHashStateSavedViewSettings.update(null)
}
const update = async (params: { settings: SavedViewUrlSettings }) => {
const { settings } = params
@@ -52,12 +58,17 @@ export const useViewerSavedViewIntegration = () => {
// If passing in viewId and it differs, apply and wait for that to finish
if (settings.id && settings.id !== savedViewId.value) {
// wipe hash state, if any exists, otherwise the state will be stale
await resetUrlHashState()
if (embedOptions.value?.isEnabled) {
// in embed mode we want clicking views to actually show selected view in the URL
await urlHashStateSavedViewSettings.update({ id: settings.id })
} else {
// wipe hash state, if any exists, otherwise the state will be stale
await resetUrlHashState()
savedViewId.value = settings.id
}
// this acts as a reset of the state id too, cause it only applies to active view
savedViewStateId.value = undefined
savedViewId.value = settings.id
reapplyState = false
}
@@ -74,10 +85,6 @@ export const useViewerSavedViewIntegration = () => {
}
}
const resetUrlHashState = async () => {
await urlHashStateSavedViewSettings.update(null)
}
const reset = async () => {
// No such thing as a reset in presentation mode - we always have a view active
if (pageType.value === ViewerRenderPageType.Presentation) return
@@ -112,11 +119,14 @@ export const useViewerSavedViewIntegration = () => {
() => serializedStateId.value,
async (newVal, oldVal) => {
if (newVal === oldVal) return
if (embedOptions.value?.isEnabled) return // we never reset in embed mode
// If the saved view state ID is different from the current serialized state ID (user interaction) -
// user has changed the state from the view's state
if (savedViewStateId.value && newVal !== savedViewStateId.value) {
// emit event that this happened
emit(ViewerEventBusKeys.UserChangedOpenedView, { viewId: savedViewId.value })
if (savedViewId.value)
emit(ViewerEventBusKeys.UserChangedOpenedView, { viewId: savedViewId.value })
// reset the saved view - its no longer active
await reset()
@@ -56,6 +56,28 @@ export const useSavedViewValidationHelpers = (params: {
const canEditTitle = computed(() => permissions.value?.canEditTitle)
const canEditDescription = computed(() => permissions.value?.canEditDescription)
const canEmbed = computed((): FullPermissionCheckResultFragment | undefined => {
if (isLoading.value) {
return {
authorized: false,
errorMessage: undefined,
code: 'LOADING',
message: ''
}
}
if (params.view.value?.visibility !== SavedViewVisibility.Public) {
return {
authorized: false,
errorMessage: 'Only shared views can be embedded',
code: 'FORBIDDEN',
message: 'Only shared views can be embedded'
}
}
return { authorized: true, code: 'OK', message: '' }
})
const canOpenEditDialog = computed(
(): FullPermissionCheckResultFragment | undefined => {
if (isLoading.value) {
@@ -163,6 +185,7 @@ export const useSavedViewValidationHelpers = (params: {
canMove,
canEditTitle,
canEditDescription,
canOpenEditDialog
canOpenEditDialog,
canEmbed
}
}
@@ -169,6 +169,7 @@ function useViewerObjectAutoLoading() {
const getUniqueObjectIds = (resourceItems: ViewerResourceItem[]) =>
uniq(resourceItems.map((i) => i.objectId))
const activeLoads = new Set<Promise<void>>()
watch(
() => <const>[resourceItems.value, isInitialized.value, hasDoneInitialLoad.value],
async ([newResources, newIsInitialized, newHasDoneInitialLoad], oldData) => {
@@ -177,8 +178,6 @@ function useViewerObjectAutoLoading() {
const [oldResources] = oldData || [[], false]
hasLoadedQueuedUpModels.value = false
// we dont want to zoom to object, if we're loading specific coords because of a thread,
// or spotlight mode or a saved view etc.
const preventZooming =
@@ -191,33 +190,60 @@ function useViewerObjectAutoLoading() {
// Viewer initialized - load in all resources
if (!newHasDoneInitialLoad) {
const allObjectIds = getUniqueObjectIds(newResources)
if (allObjectIds.length) {
// only mark, if anything to load
hasLoadedQueuedUpModels.value = false
}
/** Load sequentially */
const res = []
for (const i of allObjectIds) {
res.push(await loadObject(i, false, { zoomToObject }))
const loadAll = async () => {
for (const i of allObjectIds) {
res.push(await loadObject(i, false, { zoomToObject }))
}
}
// Register for accurate 'is anything loading' reporting
const promise = loadAll().then(() => {
activeLoads.delete(promise)
})
activeLoads.add(promise)
await promise
if (res.length) {
hasDoneInitialLoad.value = true
hasLoadedQueuedUpModels.value = true
if (!activeLoads.size) hasLoadedQueuedUpModels.value = true
}
return
}
// Resources changed?
const newObjectIds = getUniqueObjectIds(newResources)
const oldObjectIds = getUniqueObjectIds(oldResources)
const removableObjectIds = difference(oldObjectIds, newObjectIds)
const addableObjectIds = difference(newObjectIds, oldObjectIds)
const loadAndUnloadChanged = async () => {
const newObjectIds = getUniqueObjectIds(newResources)
const oldObjectIds = getUniqueObjectIds(oldResources)
const removableObjectIds = difference(oldObjectIds, newObjectIds)
const addableObjectIds = difference(newObjectIds, oldObjectIds)
await Promise.all(removableObjectIds.map((i) => loadObject(i, true)))
await Promise.all(
addableObjectIds.map((i) => loadObject(i, false, { zoomToObject: false }))
)
if (addableObjectIds.length) {
// only mark, if anything to load
hasLoadedQueuedUpModels.value = false
}
hasLoadedQueuedUpModels.value = true
await Promise.all(removableObjectIds.map((i) => loadObject(i, true)))
await Promise.all(
addableObjectIds.map((i) => loadObject(i, false, { zoomToObject: false }))
)
}
// Register for accurate 'is anything loading' reporting
const promise = loadAndUnloadChanged().then(() => {
activeLoads.delete(promise)
})
activeLoads.add(promise)
await promise
if (!activeLoads.size) hasLoadedQueuedUpModels.value = true
},
{ deep: true, immediate: true }
)
@@ -444,21 +470,18 @@ function useViewerCameraIntegration() {
const loadCameraDataFromViewer = () => {
const extension: CameraController = instance.getExtension(CameraController)
let cameraManuallyChanged = false
const viewerPos = new Vector3().copy(extension.getPosition())
const viewerTarget = new Vector3().copy(extension.getTarget())
if (!areVectorsLooselyEqual(position.value, viewerPos)) {
if (hasInitialLoadFired.value) position.value = viewerPos.clone()
cameraManuallyChanged = true
if (hasInitialLoadFired.value) {
if (!areVectorsLooselyEqual(position.value, viewerPos)) {
position.value = viewerPos.clone()
}
if (!areVectorsLooselyEqual(target.value, viewerTarget)) {
target.value = viewerTarget.clone()
}
}
if (!areVectorsLooselyEqual(target.value, viewerTarget)) {
if (hasInitialLoadFired.value) target.value = viewerTarget.clone()
cameraManuallyChanged = true
}
return cameraManuallyChanged
}
// viewer -> state
@@ -810,6 +833,7 @@ graphql(`
fragment UseViewerSavedViewSetup_SavedView on SavedView {
id
viewerState
...ViewerPageSetup_SavedView
}
`)
@@ -1,4 +1,5 @@
import type {
FormSelectSavedView_SavedViewFragment,
ViewerSavedViewsPanelViewDeleteDialog_SavedViewFragment,
ViewerSavedViewsPanelViewEditDialog_SavedViewFragment,
ViewerSavedViewsPanelViewMoveDialog_SavedViewFragment
@@ -8,6 +9,7 @@ import type { SavedViewUrlSettings } from '~/lib/viewer/helpers/savedViews'
export enum ViewerEventBusKeys {
ApplySavedView = 'UpdateSavedView',
MarkSavedViewForEdit = 'MarkSavedViewForEdit',
MarkSavedViewForEmbed = 'MarkSavedViewForEmbed',
UserChangedOpenedView = 'UserChangedOpenedView'
}
@@ -23,10 +25,15 @@ export type ViewerSavedViewEventBusPayloads = {
[ViewerEventBusKeys.UserChangedOpenedView]: {
viewId: string
}
[ViewerEventBusKeys.MarkSavedViewForEmbed]: {
view: FormSelectSavedView_SavedViewFragment
}
}
// Add mappings between event keys and expected payloads here
export type ViewerEventBusKeyPayloadMap = {
[ViewerEventBusKeys.ApplySavedView]: ViewerSavedViewEventBusPayloads[ViewerEventBusKeys.ApplySavedView]
[ViewerEventBusKeys.MarkSavedViewForEdit]: ViewerSavedViewEventBusPayloads[ViewerEventBusKeys.MarkSavedViewForEdit]
[ViewerEventBusKeys.UserChangedOpenedView]: ViewerSavedViewEventBusPayloads[ViewerEventBusKeys.UserChangedOpenedView]
[ViewerEventBusKeys.MarkSavedViewForEmbed]: ViewerSavedViewEventBusPayloads[ViewerEventBusKeys.MarkSavedViewForEmbed]
} & { [k in ViewerEventBusKeys]: unknown } & Record<string, unknown>
+8 -2
View File
@@ -1,6 +1,10 @@
import type { RouteLocationNormalized } from 'vue-router'
import { noop } from 'lodash-es'
import { wrapRefWithTracking } from '~/lib/common/helpers/debugging'
import {
refLogged,
makeRefLogged,
refWithLogging
} from '~/lib/common/helpers/debugging'
import { ToastNotificationType } from '~~/lib/common/composables/toast'
import {
convertThrowIntoFetchResult,
@@ -30,7 +34,9 @@ export const getRouteDefinition = (route?: RouteLocationNormalized) => {
}
export {
ToastNotificationType,
wrapRefWithTracking,
makeRefLogged,
refLogged,
refWithLogging,
noop,
convertThrowIntoFetchResult,
getFirstGqlErrorMessage,
@@ -83,6 +83,33 @@ input SavedViewGroupViewsInput {
cursor: String
}
input ProjectSavedViewsInput {
"""
Viewer resource ID string that identifies which resources should be loaded. If not specified,
views from all resources in the project will be returned.
"""
resourceIdString: String
"""
Optionally filter by visibility.
"""
onlyVisibility: SavedViewVisibility
"""
Whether to only include views matching this search term
"""
search: String
"""
Optionally specify sort direction. Default: descending
"""
sortDirection: SortDirection
"""
Optionally specify sort by field. Default: position
Options: updatedAt, createdAt, name, position
"""
sortBy: String
limit: Int
cursor: String
}
type SavedViewGroup {
"""
This is always set even for fake/not persisted groups for Apollo caching
@@ -266,6 +293,7 @@ extend type ProjectMutations {
}
extend type Project {
savedViews(input: ProjectSavedViewsInput!): SavedViewCollection!
savedViewGroups(input: SavedViewGroupsInput!): SavedViewGroupCollection!
savedViewGroup(id: ID!): SavedViewGroup!
ungroupedViewGroup(input: GetUngroupedViewGroupInput!): SavedViewGroup!
@@ -2696,6 +2696,7 @@ export type Project = {
savedViewGroups: SavedViewGroupCollection;
/** Same as savedView(), but won't throw if view isn't found */
savedViewIfExists?: Maybe<SavedView>;
savedViews: SavedViewCollection;
/** Source apps used in any models of this project */
sourceApps: Array<Scalars['String']['output']>;
team: Array<ProjectCollaborator>;
@@ -2855,6 +2856,11 @@ export type ProjectSavedViewIfExistsArgs = {
};
export type ProjectSavedViewsArgs = {
input: ProjectSavedViewsInput;
};
export type ProjectUngroupedViewGroupArgs = {
input: GetUngroupedViewGroupInput;
};
@@ -3374,6 +3380,27 @@ export type ProjectSavedViewGroupsUpdatedMessage = {
type: ProjectSavedViewsUpdatedMessageType;
};
export type ProjectSavedViewsInput = {
cursor?: InputMaybe<Scalars['String']['input']>;
limit?: InputMaybe<Scalars['Int']['input']>;
/** Optionally filter by visibility. */
onlyVisibility?: InputMaybe<SavedViewVisibility>;
/**
* Viewer resource ID string that identifies which resources should be loaded. If not specified,
* views from all resources in the project will be returned.
*/
resourceIdString?: InputMaybe<Scalars['String']['input']>;
/** Whether to only include views matching this search term */
search?: InputMaybe<Scalars['String']['input']>;
/**
* Optionally specify sort by field. Default: position
* Options: updatedAt, createdAt, name, position
*/
sortBy?: InputMaybe<Scalars['String']['input']>;
/** Optionally specify sort direction. Default: descending */
sortDirection?: InputMaybe<SortDirection>;
};
export type ProjectSavedViewsUpdatedMessage = {
__typename?: 'ProjectSavedViewsUpdatedMessage';
/** Set if view was deleted/updated, allows some limited access into the view before the change (update/delete) */
@@ -6615,6 +6642,7 @@ export type ResolversTypes = {
ProjectPermissionChecks: ResolverTypeWrapper<ProjectPermissionChecksGraphQLReturn>;
ProjectRole: ResolverTypeWrapper<ProjectRoleGraphQLReturn>;
ProjectSavedViewGroupsUpdatedMessage: ResolverTypeWrapper<ProjectSavedViewGroupsUpdatedMessageGraphQLReturn>;
ProjectSavedViewsInput: ProjectSavedViewsInput;
ProjectSavedViewsUpdatedMessage: ResolverTypeWrapper<ProjectSavedViewsUpdatedMessageGraphQLReturn>;
ProjectSavedViewsUpdatedMessageType: ProjectSavedViewsUpdatedMessageType;
ProjectTestAutomationCreateInput: ProjectTestAutomationCreateInput;
@@ -7020,6 +7048,7 @@ export type ResolversParentTypes = {
ProjectPermissionChecks: ProjectPermissionChecksGraphQLReturn;
ProjectRole: ProjectRoleGraphQLReturn;
ProjectSavedViewGroupsUpdatedMessage: ProjectSavedViewGroupsUpdatedMessageGraphQLReturn;
ProjectSavedViewsInput: ProjectSavedViewsInput;
ProjectSavedViewsUpdatedMessage: ProjectSavedViewsUpdatedMessageGraphQLReturn;
ProjectTestAutomationCreateInput: ProjectTestAutomationCreateInput;
ProjectTriggeredAutomationsStatusUpdatedMessage: ProjectTriggeredAutomationsStatusUpdatedMessageGraphQLReturn;
@@ -8272,6 +8301,7 @@ export type ProjectResolvers<ContextType = GraphQLContext, ParentType extends Re
savedViewGroup?: Resolver<ResolversTypes['SavedViewGroup'], ParentType, ContextType, RequireFields<ProjectSavedViewGroupArgs, 'id'>>;
savedViewGroups?: Resolver<ResolversTypes['SavedViewGroupCollection'], ParentType, ContextType, RequireFields<ProjectSavedViewGroupsArgs, 'input'>>;
savedViewIfExists?: Resolver<Maybe<ResolversTypes['SavedView']>, ParentType, ContextType, Partial<ProjectSavedViewIfExistsArgs>>;
savedViews?: Resolver<ResolversTypes['SavedViewCollection'], ParentType, ContextType, RequireFields<ProjectSavedViewsArgs, 'input'>>;
sourceApps?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
team?: Resolver<Array<ResolversTypes['ProjectCollaborator']>, ParentType, ContextType>;
ungroupedViewGroup?: Resolver<ResolversTypes['SavedViewGroup'], ParentType, ContextType, RequireFields<ProjectUngroupedViewGroupArgs, 'input'>>;
@@ -10121,6 +10151,14 @@ export type OnProjectSavedViewGroupsUpdatedSubscriptionVariables = Exact<{
export type OnProjectSavedViewGroupsUpdatedSubscription = { __typename?: 'Subscription', projectSavedViewGroupsUpdated: { __typename?: 'ProjectSavedViewGroupsUpdatedMessage', type: ProjectSavedViewsUpdatedMessageType, id: string, project: { __typename?: 'Project', id: string }, savedViewGroup?: { __typename?: 'SavedViewGroup', id: string, projectId: string, resourceIds: Array<string>, title: string, isUngroupedViewsGroup: boolean, views: { __typename?: 'SavedViewCollection', totalCount: number, cursor?: string | null, items: Array<{ __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 } }> } } | null } };
export type GetProjectSavedViewsQueryVariables = Exact<{
projectId: Scalars['String']['input'];
input: ProjectSavedViewsInput;
}>;
export type GetProjectSavedViewsQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, savedViews: { __typename?: 'SavedViewCollection', totalCount: number, cursor?: string | null, items: Array<{ __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 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 };
@@ -11260,6 +11298,7 @@ export const UpdateSavedViewGroupDocument = {"kind":"Document","definitions":[{"
export const GetModelHomeViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModelHomeView"},"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":"modelId"}},"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":"model"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"modelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"homeView"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}},{"kind":"Field","name":{"kind":"Name","value":"resourceIdString"}}]}}]}}]}},{"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<GetModelHomeViewQuery, GetModelHomeViewQueryVariables>;
export const OnProjectSavedViewsUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"onProjectSavedViewsUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"projectSavedViewsUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"projectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"savedView"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"BasicSavedView"}}]}},{"kind":"Field","name":{"kind":"Name","value":"beforeChangeSavedView"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"groupId"}},{"kind":"Field","name":{"kind":"Name","value":"resourceIds"}}]}}]}}]}},{"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<OnProjectSavedViewsUpdatedSubscription, OnProjectSavedViewsUpdatedSubscriptionVariables>;
export const OnProjectSavedViewGroupsUpdatedDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"onProjectSavedViewGroupsUpdated"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}},"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":"projectSavedViewGroupsUpdated"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"projectId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"projectId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"project"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"savedViewGroup"},"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<OnProjectSavedViewGroupsUpdatedSubscription, OnProjectSavedViewGroupsUpdatedSubscriptionVariables>;
export const GetProjectSavedViewsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetProjectSavedViews"},"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":"ProjectSavedViewsInput"}}}}],"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":"savedViews"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"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"}}]}}]}}]}}]}},{"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<GetProjectSavedViewsQuery, GetProjectSavedViewsQueryVariables>;
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>;
@@ -43,6 +43,38 @@ export type GetStoredViewCount = (params: { projectId: string }) => Promise<numb
export type GetStoredViewGroupCount = (params: { projectId: string }) => Promise<number>
export type GetProjectSavedViewsBaseParams = {
/**
* Falsy means - anonymous user (so no onlyAuthored filtering)
*/
userId?: MaybeNullOrUndefined<string>
projectId: string
/**
* If not provided, views from all resources in the project will be returned.
*/
resourceIdString?: MaybeNullOrUndefined<string>
onlyVisibility?: MaybeNullOrUndefined<SavedViewVisibility>
search?: MaybeNullOrUndefined<string>
}
export type GetProjectSavedViewsPageParams = GetProjectSavedViewsBaseParams & {
limit?: MaybeNullOrUndefined<number>
cursor?: MaybeNullOrUndefined<string>
sortDirection?: MaybeNullOrUndefined<'asc' | 'desc'>
/**
* Null means - manual positioning
*/
sortBy?: MaybeNullOrUndefined<'createdAt' | 'name' | 'updatedAt' | 'position'>
}
export type GetProjectSavedViewsTotalCount = (
params: GetProjectSavedViewsBaseParams
) => Promise<number>
export type GetProjectSavedViewsPageItems = (
params: GetProjectSavedViewsPageParams
) => Promise<Omit<Collection<SavedView>, 'totalCount'>>
export type GetProjectSavedViewGroupsBaseParams = {
/**
* Falsy means - anonymous user (so no onlyAuthored filtering)
@@ -262,6 +294,10 @@ export type GetProjectSavedViewGroups = (
params: GetProjectSavedViewGroupsPageParams
) => Promise<Collection<SavedViewGroup>>
export type GetProjectSavedViews = (
params: GetProjectSavedViewsPageParams
) => Promise<Collection<SavedView>>
export type GetGroupSavedViews = (
params: GetGroupSavedViewsPageParams
) => Promise<Collection<SavedView>>
@@ -28,6 +28,8 @@ import {
getNewViewSpecificPositionFactory,
getProjectSavedViewGroupsPageItemsFactory,
getProjectSavedViewGroupsTotalCountFactory,
getProjectSavedViewsPageItemsFactory,
getProjectSavedViewsTotalCountFactory,
getStoredViewCountFactory,
getStoredViewGroupCountFactory,
getUngroupedSavedViewsGroupFactory,
@@ -46,6 +48,7 @@ import {
deleteSavedViewGroupFactory,
getGroupSavedViewsFactory,
getProjectSavedViewGroupsFactory,
getProjectSavedViewsFactory,
updateSavedViewFactory,
updateSavedViewGroupFactory
} from '@/modules/viewer/services/savedViewsManagement'
@@ -89,6 +92,42 @@ const buildGetViewerResourceGroups = (params: {
const resolvers: Resolvers = {
Project: {
async savedViews(parent, args, ctx) {
const { input } = args
const projectDb = await getProjectDbClient({ projectId: parent.id })
const getProjectSavedViews = getProjectSavedViewsFactory({
getProjectSavedViewsPageItems: getProjectSavedViewsPageItemsFactory({
db: projectDb
}),
getProjectSavedViewsTotalCount: getProjectSavedViewsTotalCountFactory({
db: projectDb
}),
getViewerResourceGroups: buildGetViewerResourceGroups({
projectDb,
loaders: ctx.loaders
})
})
const allowedSortBy = <const>['createdAt', 'name', 'updatedAt']
const sortBy = input.sortBy
? allowedSortBy.find((s) => s === input.sortBy)
: undefined
return await getProjectSavedViews({
projectId: parent.id,
userId: ctx.userId,
resourceIdString: input.resourceIdString,
onlyVisibility: input.onlyVisibility,
search: input.search,
limit: input.limit,
cursor: input.cursor,
sortDirection: input.sortDirection
? mapGqlToDbSortDirection(input.sortDirection)
: undefined,
sortBy
})
},
async savedViewGroups(parent, args, ctx) {
const { input } = args
@@ -9,6 +9,7 @@ import type {
GetGroupSavedViewsBaseParams,
GetGroupSavedViewsPageItems,
GetGroupSavedViewsTotalCount,
GetProjectSavedViewsBaseParams,
GetProjectSavedViewGroupsBaseParams,
GetProjectSavedViewGroupsPageItems,
GetProjectSavedViewGroupsTotalCount,
@@ -31,7 +32,9 @@ import type {
SetNewHomeView,
GetNewViewBoundaryPosition,
GetNewViewSpecificPosition,
RebalanceViewPositions
RebalanceViewPositions,
GetProjectSavedViewsTotalCount,
GetProjectSavedViewsPageItems
} from '@/modules/viewer/domain/operations/savedViews'
import {
SavedViewVisibility,
@@ -1020,3 +1023,81 @@ export const rebalancingViewPositionsFactory =
const ret = (await q) as { rowCount: number }
return ret.rowCount
}
const getProjectSavedViewsBaseQueryFactory =
(deps: { db: Knex }) => (params: GetProjectSavedViewsBaseParams) => {
const { projectId, resourceIdString, search, userId } = params
const onlyVisibility =
params.onlyVisibility === SavedViewVisibility.authorOnly && !userId
? undefined
: params.onlyVisibility
const resourceIds = formatResourceIdsForGroup(resourceIdString || '')
const q = tables
.savedViews(deps.db)
.where({ [SavedViews.col.projectId]: projectId })
// If resourceIdString provided, filter by resource overlap
if (resourceIds.length) {
q.andWhereRaw('?? && ?', [SavedViews.col.groupResourceIds, resourceIds])
}
// checking visibility/authorship
if (onlyVisibility) {
if (onlyVisibility === SavedViewVisibility.authorOnly) {
q.andWhere({ [SavedViews.col.authorId]: userId })
}
q.andWhere({ [SavedViews.col.visibility]: onlyVisibility })
} else {
q.andWhere((w1) => {
w1.andWhere(SavedViews.col.visibility, SavedViewVisibility.public)
if (userId) {
w1.orWhere(SavedViews.col.authorId, userId)
}
})
}
// search filter
if (search) {
q.andWhere(SavedViews.col.name, 'ilike', `%${search}%`)
}
return q
}
export const getProjectSavedViewsTotalCountFactory =
(deps: { db: Knex }): GetProjectSavedViewsTotalCount =>
async (params) => {
const q = getProjectSavedViewsBaseQueryFactory(deps)(params)
const countQ = deps.db.count<{ count: string }[]>().from(q.as('sq1'))
const [count] = await countQ
return parseInt(count.count + '')
}
export const getProjectSavedViewsPageItemsFactory =
(deps: { db: Knex }): GetProjectSavedViewsPageItems =>
async (params) => {
const sortByCol = params.sortBy || 'position'
const sortDir = params.sortDirection || 'desc'
const q = getProjectSavedViewsBaseQueryFactory(deps)(params)
const { applyCursorSortAndFilter, resolveNewCursor } = compositeCursorTools({
schema: SavedViews,
cols: [sortByCol, 'id']
})
const limit = clamp(params.limit ?? 10, 0, 100)
q.limit(limit)
// Apply cursor filter and sort
applyCursorSortAndFilter({ query: q, cursor: params.cursor, sort: sortDir })
const items = await q
const newCursor = resolveNewCursor(items)
return {
items,
cursor: newCursor
}
}
@@ -13,6 +13,9 @@ import type {
GetProjectSavedViewGroups,
GetProjectSavedViewGroupsPageItems,
GetProjectSavedViewGroupsTotalCount,
GetProjectSavedViews,
GetProjectSavedViewsPageItems,
GetProjectSavedViewsTotalCount,
GetSavedView,
GetSavedViewGroup,
GetStoredViewCount,
@@ -83,8 +86,12 @@ const formatIncomingScreenshotFactory =
const unwrapResourceIdStringFactory =
(deps: { getViewerResourceGroups: GetViewerResourceGroups }) =>
async (params: { resourceIdString: ViewerResourcesTarget; projectId: string }) => {
const { resourceIdString, projectId } = params
async (params: {
resourceIdString: ViewerResourcesTarget
projectId: string
allowInvalid?: boolean
}) => {
const { resourceIdString, projectId, allowInvalid } = params
// It could be a `$prefix` string, in which case we need to resolve the final resources there
const { groups: resourceGroups } = await deps.getViewerResourceGroups({
@@ -94,6 +101,10 @@ const unwrapResourceIdStringFactory =
allowEmptyModels: true
})
// If completely empty - invalid string, return as is
if (resourceGroups.length === 0 && allowInvalid)
return resourceBuilder().addResources(resourceIdString)
const finalResourceIds = resourceBuilder()
for (const resourceGroup of resourceGroups) {
// If there's any items in there, add them
@@ -1041,3 +1052,35 @@ export const updateSavedViewGroupFactory =
return updatedGroup
}
export const getProjectSavedViewsFactory =
(
deps: {
getProjectSavedViewsPageItems: GetProjectSavedViewsPageItems
getProjectSavedViewsTotalCount: GetProjectSavedViewsTotalCount
} & DependenciesOf<typeof unwrapResourceIdStringFactory>
): GetProjectSavedViews =>
async (params) => {
// Resolve final resourceIdString from input one (may contain $prefix ids that need unwrapping)
if (params.resourceIdString) {
const finalResourceIds = await unwrapResourceIdStringFactory(deps)({
resourceIdString: params.resourceIdString,
projectId: params.projectId,
allowInvalid: true // so that user gets empty results instead of results for empty resourceIdString
})
params.resourceIdString = finalResourceIds.toString()
}
const noItemsNeeded = params.limit === 0
const [totalCount, pageItems] = await Promise.all([
deps.getProjectSavedViewsTotalCount(params),
noItemsNeeded
? Promise.resolve({ items: [], cursor: null })
: deps.getProjectSavedViewsPageItems(params)
])
return {
totalCount,
...pageItems
}
}
@@ -321,3 +321,20 @@ export const onProjectSavedViewGroupsUpdated = gql`
${basicSavedViewGroupFragment}
`
export const getProjectSavedViewsQuery = gql`
query GetProjectSavedViews($projectId: String!, $input: ProjectSavedViewsInput!) {
project(id: $projectId) {
id
savedViews(input: $input) {
totalCount
cursor
items {
...BasicSavedView
}
}
}
}
${basicSavedViewFragment}
`
@@ -14,6 +14,7 @@ import type {
GetProjectSavedViewGroupsQueryVariables,
GetProjectSavedViewIfExistsQueryVariables,
GetProjectSavedViewQueryVariables,
GetProjectSavedViewsQueryVariables,
GetProjectUngroupedViewGroupQueryVariables,
UpdateSavedViewGroupMutationVariables,
UpdateSavedViewInput,
@@ -31,6 +32,7 @@ import {
GetProjectSavedViewGroupDocument,
GetProjectSavedViewGroupsDocument,
GetProjectSavedViewIfExistsDocument,
GetProjectSavedViewsDocument,
GetProjectUngroupedViewGroupDocument,
OnProjectSavedViewGroupsUpdatedDocument,
OnProjectSavedViewsUpdatedDocument,
@@ -3355,6 +3357,333 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
const sortedCreatedAt = [...createdAt].sort((a, b) => (b.isAfter(a) ? 1 : -1))
expect(createdAt).to.deep.equal(sortedCreatedAt)
})
describe('directly through Project.savedViews', () => {
const getProjectViews = (
input: GetProjectSavedViewsQueryVariables,
options?: ExecuteOperationOptions
) => apollo.execute(GetProjectSavedViewsDocument, input, options)
it('should successfully read all project views with pagination', async () => {
let cursor: string | null = null
let pagesLoaded = 0
let viewsFound = 0
// First, get the actual total count from the endpoint
const initialRes = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: 1,
resourceIdString: getAllReadModelResourceIds().toString()
}
},
{ assertNoErrors: true }
)
const TOTAL_EXPECTED_VIEWS =
initialRes.data?.project.savedViews.totalCount || 0
const PAGE_SIZE = Math.ceil(TOTAL_EXPECTED_VIEWS / 3) // Use 3 pages
const loadPage = async () => {
const res = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: PAGE_SIZE,
cursor,
resourceIdString: getAllReadModelResourceIds().toString()
}
},
{ assertNoErrors: true }
)
const data = res.data?.project.savedViews
expect(data).to.be.ok
expect(data!.totalCount).to.equal(TOTAL_EXPECTED_VIEWS)
if (data?.cursor) {
expect(data!.items.length).to.be.lessThanOrEqual(PAGE_SIZE)
} else {
expect(data!.items.length).to.eq(0)
}
for (const view of data!.items) {
expect(view.projectId).to.equal(readTestProject.id)
expect(view.resourceIds.length).to.be.greaterThan(0)
viewsFound++
}
cursor = data?.cursor || null
pagesLoaded++
}
do {
if (pagesLoaded > 5) {
// Increase max pages
throw new Error(
'Too many pages loaded, something is wrong with pagination logic'
)
}
await loadPage()
} while (cursor)
expect(pagesLoaded).to.equal(4) // 3 pages + 1 empty page
expect(viewsFound).to.equal(TOTAL_EXPECTED_VIEWS)
})
it('should respect search filter across all views', async () => {
const res = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: 100, // all in 1 page
resourceIdString: getAllReadModelResourceIds().toString(),
search: SEARCH_STRING
}
},
{ assertNoErrors: true }
)
const data = res.data?.project.savedViews
expect(data).to.be.ok
expect(data!.totalCount).to.be.greaterThan(0) // Should find some matching views
expect(data!.items.length).to.equal(data!.totalCount)
for (const view of data!.items) {
expect(view.name.includes(SEARCH_STRING)).to.be.true
}
})
it('should respect onlyAuthored flag across all views', async () => {
const res = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: 100, // all in 1 page
resourceIdString: getAllReadModelResourceIds().toString()
}
},
{
assertNoErrors: true,
authUserId: otherReader.id
}
)
const data = res.data?.project.savedViews
expect(data).to.be.ok
// Should see all public views visible to otherReader
expect(data!.totalCount).to.be.greaterThan(0)
expect(data!.items.length).to.equal(data!.totalCount)
// Should not see any private views from other authors
const privateViewsFromOthers = data!.items.filter(
(v) =>
v.author?.id !== otherReader.id &&
v.visibility === SavedViewVisibility.authorOnly
)
expect(privateViewsFromOthers.length).to.equal(0)
})
it('should allow sorting by name asc across all views', async () => {
const res = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: 100, // all in 1 page
resourceIdString: getAllReadModelResourceIds().toString(),
sortBy: 'name',
sortDirection: 'ASC'
}
},
{ assertNoErrors: true }
)
const data = res.data?.project.savedViews
expect(data).to.be.ok
expect(data!.items.length).to.be.greaterThan(0)
expect(data!.items.length).to.equal(data!.totalCount)
const names = data!.items.map((v) => v.name as string)
const sortedNames = [...names].sort()
expect(names).to.deep.equal(sortedNames)
})
it('should allow sorting by createdAt desc across all views', async () => {
const res = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: 100, // all in 1 page
resourceIdString: getAllReadModelResourceIds().toString(),
sortBy: 'createdAt',
sortDirection: 'DESC'
}
},
{ assertNoErrors: true }
)
const data = res.data?.project.savedViews
expect(data).to.be.ok
expect(data!.items.length).to.be.greaterThan(0)
expect(data!.items.length).to.equal(data!.totalCount)
const createdAt = data!.items.map((v) => dayjs(v.createdAt))
const sortedCreatedAt = [...createdAt].sort((a, b) =>
b.isAfter(a) ? 1 : -1
)
expect(createdAt).to.deep.equal(sortedCreatedAt)
})
it('should handle empty results for non-existent resource', async () => {
const res = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: 100,
resourceIdString: 'non-existent-resource-id'
}
},
{ assertNoErrors: true }
)
const data = res.data?.project.savedViews
expect(data).to.be.ok
expect(data!.totalCount).to.equal(0)
expect(data!.items.length).to.equal(0)
expect(data!.cursor).to.be.null
})
it('should handle mixed visibility filtering correctly', async () => {
const res = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: 100,
resourceIdString: getAllReadModelResourceIds().toString(),
onlyVisibility: SavedViewVisibility.public
}
},
{
assertNoErrors: true,
authUserId: otherReader.id
}
)
const data = res.data?.project.savedViews
expect(data).to.be.ok
// Should only see public views
expect(
data!.items.every((v) => v.visibility === SavedViewVisibility.public)
).to.be.true
// Should not see any private views
const privateViews = data!.items.filter(
(v) => v.visibility === SavedViewVisibility.authorOnly
)
expect(privateViews.length).to.equal(0)
})
it('should respect both search and visibility filters combined', async () => {
const res = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: 100,
resourceIdString: getAllReadModelResourceIds().toString(),
search: SEARCH_STRING,
onlyVisibility: SavedViewVisibility.public
}
},
{ assertNoErrors: true }
)
const data = res.data?.project.savedViews
expect(data).to.be.ok
// Should find views that match both search string AND are public
for (const view of data!.items) {
expect(view.name.includes(SEARCH_STRING)).to.be.true
expect(view.visibility).to.equal(SavedViewVisibility.public)
}
// Get search-only results to compare
const searchOnlyRes = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: 100,
resourceIdString: getAllReadModelResourceIds().toString(),
search: SEARCH_STRING
}
},
{ assertNoErrors: true }
)
// Should be fewer or equal results than search alone
expect(data!.totalCount).to.be.lessThanOrEqual(
searchOnlyRes.data?.project.savedViews.totalCount || 0
)
})
it('should handle cursor pagination with search filters', async () => {
const PAGE_SIZE = 2
let cursor: string | null = null
let totalFound = 0
// First page
const res1 = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: PAGE_SIZE,
resourceIdString: getAllReadModelResourceIds().toString(),
search: SEARCH_STRING
}
},
{ assertNoErrors: true }
)
const data1 = res1.data?.project.savedViews
expect(data1).to.be.ok
expect(data1!.items.length).to.be.lessThanOrEqual(PAGE_SIZE)
totalFound += data1!.items.length
cursor = data1!.cursor || null
if (cursor) {
// Second page
const res2 = await getProjectViews(
{
projectId: readTestProject.id,
input: {
limit: PAGE_SIZE,
cursor,
resourceIdString: getAllReadModelResourceIds().toString(),
search: SEARCH_STRING
}
},
{ assertNoErrors: true }
)
const data2 = res2.data?.project.savedViews
expect(data2).to.be.ok
totalFound += data2!.items.length
// Verify no duplicate views across pages
const ids1 = data1!.items.map((v) => v.id as string)
const ids2 = data2!.items.map((v) => v.id as string)
const ids1Set = new Set(ids1)
const duplicateCount = ids2.filter((id) => ids1Set.has(id)).length
expect(duplicateCount).to.equal(0)
}
// Total should match expected searchable count
expect(totalFound).to.be.lessThanOrEqual(SEARCHABLE_VIEW_COUNT)
})
})
})
})
} else {
+15 -1
View File
@@ -38,13 +38,27 @@ export function createUncoveredError(e: unknown) {
return new UncoveredError(`Uncovered error case ${errorRepr}.`)
}
/**
* A custom error class that produces a cleaner stack trace when instantiated.
*/
export class CleanStackTrace extends Error {
constructor() {
super('')
this.name = 'Stack trace:'
}
}
/**
* Note: Only V8 and Node.js support controlling the stack trace limit
*/
export const collectLongTrace = (limit?: number) => {
const originalLimit = Error.stackTraceLimit
Error.stackTraceLimit = limit || 30
const trace = (new Error().stack || '').split('\n').slice(1).join('\n').trim()
const trace = (new CleanStackTrace().stack || '')
.split('\n')
.slice(2) // remove "Error" and this function's own frame
.join('\n')
.trim()
Error.stackTraceLimit = originalLimit
return trace
}
@@ -1,6 +1,8 @@
import { FaceSmileIcon } from '@heroicons/vue/24/outline'
import type { Meta, StoryObj } from '@storybook/vue3'
import { ref } from 'vue'
import DialogSection from '~~/src/components/layout/DialogSection.vue'
import { FormButton } from '~~/src/lib'
export default {
component: DialogSection,
@@ -76,3 +78,27 @@ export const ButtonExpandsContent: StoryObj = {
}
}
}
export const WithModel: StoryObj = {
...Default,
render: (args) => ({
components: { DialogSection, FormButton },
setup() {
const open = ref(false)
return { args, open }
},
template: `
<div class="bg-foundation flex flex-col gap-2">
<DialogSection v-bind="args" v-model:open="open">
<div class="flex flex-col text-foreground space-y-4">
<div class="h4 font-semibold">Hello world!</div>
<div>Lorem ipsum blah blah blah</div>
</div>
</DialogSection>
<FormButton @click="open = !open">{{ open ? 'Close' : 'Open' }} Section</FormButton>
</div>`
}),
args: {
...Default.args
}
}
@@ -84,13 +84,14 @@
</template>
<script setup lang="ts">
import { ref, unref, computed, nextTick } from 'vue'
import { ref, computed } from 'vue'
import type { PropType, Ref } from 'vue'
import { ChevronDownIcon } from '@heroicons/vue/24/outline'
import { FormButton } from '~~/src/lib'
import { keyboardClick } from '~~/src/helpers/global/accessibility'
import type { PropAnyComponent } from '~~/src/helpers/common/components'
import type { FormButtonStyle } from '~~/src/helpers/form/button'
import { useElementSize } from '@vueuse/core'
type TitleColor = 'default' | 'danger' | 'warning' | 'success' | 'secondary' | 'info'
@@ -125,8 +126,10 @@ const props = defineProps({
})
const content: Ref<HTMLElement | null> = ref(null)
const contentHeight = ref(0)
const isExpanded = ref(false)
const { height: scrollHeight } = useElementSize(content)
const contentHeight = computed(() => (scrollHeight.value || 0) + 64)
const isExpanded = defineModel<boolean>('open', { required: false })
const backgroundClass = computed(() => {
const classes = []
@@ -155,12 +158,7 @@ const titleClasses = computed(() => {
}
})
const toggleExpansion = async () => {
const toggleExpansion = () => {
isExpanded.value = !isExpanded.value
if (isExpanded.value) {
await nextTick()
contentHeight.value = (unref(content)?.scrollHeight || 0) + 64
}
}
</script>