feat: rename a group (#5228)

* add disclosure edit mode support

* group update backend works

* WIP group title edit

* rename seems to work

* fix menu overflow

* remove comment

* optimistic responser fix

* rename validation

* disclosure sync fix
This commit is contained in:
Kristaps Fabians Geikins
2025-08-13 17:38:20 +03:00
committed by GitHub
parent a3d18b7655
commit be0155a95d
24 changed files with 991 additions and 189 deletions
@@ -61,7 +61,7 @@
<ViewerSavedViewsPanelConnectorViews
v-if="selectedViewsType === ViewsType.Connector"
/>
<ViewerSavedViewsPanelViews
<ViewerSavedViewsPanelGroups
v-else
v-model:selected-group-id="selectedGroupId"
:views-type="selectedViewsType"
@@ -9,10 +9,13 @@
v-for="group in groups"
:key="group.id"
:group="group"
:is-selected="group.id === selectedGroupId"
:is-selected="isGroupSelected(group)"
:rename-mode="isGroupInRenameMode(group)"
:only-authored="viewsType === ViewsType.My"
@update:is-selected="(value) => (selectedGroupId = value ? group.id : null)"
@update:rename-mode="(value) => (groupBeingRenamed = value ? group : undefined)"
@delete-group="($event) => (groupBeingDeleted = $event)"
@rename-group="($event) => (groupBeingRenamed = $event)"
/>
<InfiniteLoading
v-if="groups.length"
@@ -45,16 +48,18 @@ import { omit } from 'lodash-es'
import { usePaginatedQuery } from '~/lib/common/composables/graphql'
import { graphql } from '~/lib/common/generated/gql'
import type {
UseUpdateSavedViewGroup_SavedViewGroupFragment,
ViewerSavedViewsPanelViewDeleteDialog_SavedViewFragment,
ViewerSavedViewsPanelViewEditDialog_SavedViewFragment,
ViewerSavedViewsPanelViewMoveDialog_SavedViewFragment,
ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragment,
ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroupFragment
} from '~/lib/common/generated/gql/graphql'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
import { ViewsType } from '~/lib/viewer/helpers/savedViews'
graphql(`
fragment ViewerSavedViewsPanelViews_Project on Project {
fragment ViewerSavedViewsPanelGroups_Project on Project {
id
savedViewGroups(input: $savedViewGroupsInput) {
totalCount
@@ -68,13 +73,13 @@ graphql(`
`)
const paginableGroupsQuery = graphql(`
query ViewerSavedViewsPanelViews_Groups(
query ViewerSavedViewsPanelGroups_SavedViewGroups(
$projectId: String!
$savedViewGroupsInput: SavedViewGroupsInput!
) {
project(id: $projectId) {
id
...ViewerSavedViewsPanelViews_Project
...ViewerSavedViewsPanelGroups_Project
}
}
`)
@@ -101,6 +106,7 @@ const viewBeingMoved = ref<ViewerSavedViewsPanelViewMoveDialog_SavedViewFragment
const viewBeingDeleted = ref<ViewerSavedViewsPanelViewDeleteDialog_SavedViewFragment>()
const groupBeingDeleted =
ref<ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroupFragment>()
const groupBeingRenamed = ref<UseUpdateSavedViewGroup_SavedViewGroupFragment>()
const {
identifier,
@@ -178,6 +184,18 @@ const showGroupDeleteDialog = computed({
}
})
const isGroupInRenameMode = (
group: ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragment
) => {
return group.id === groupBeingRenamed.value?.id
}
const isGroupSelected = (
group: ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragment
) => {
return group.id === selectedGroupId.value
}
watch(
groups,
(newGroups) => {
@@ -2,8 +2,10 @@
<LayoutDisclosure
v-if="!isUngroupedGroup"
v-model:open="open"
v-model:edit-title="renameMode"
:title="group.title"
lazy-load
@update:title="onRename"
>
<ViewerSavedViewsPanelViewsGroupInner
:group="group"
@@ -60,18 +62,23 @@ import { useMutationLoading } from '@vue/apollo-composable'
import { Ellipsis, Plus } from 'lucide-vue-next'
import { graphql } from '~/lib/common/generated/gql'
import type {
UseUpdateSavedViewGroup_SavedViewGroupFragment,
ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragment,
ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroupFragment
} from '~/lib/common/generated/gql/graphql'
import { useCreateSavedView } from '~/lib/viewer/composables/savedViews/management'
import {
useCreateSavedView,
useUpdateSavedViewGroup
} from '~/lib/viewer/composables/savedViews/management'
const MenuItems = StringEnum(['Delete'])
const MenuItems = StringEnum(['Delete', 'Rename'])
type MenuItems = StringEnumValues<typeof MenuItems>
graphql(`
fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {
id
isUngroupedViewsGroup
title
permissions {
canUpdate {
...FullPermissionCheckResult
@@ -79,6 +86,7 @@ graphql(`
}
...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup
...ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup
...UseUpdateSavedViewGroup_SavedViewGroup
}
`)
@@ -100,6 +108,7 @@ const emit = defineEmits<{
'delete-group': [
group: ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroupFragment
]
'rename-group': [group: UseUpdateSavedViewGroup_SavedViewGroupFragment]
}>()
const props = defineProps<{
@@ -108,9 +117,12 @@ const props = defineProps<{
onlyAuthored?: boolean
}>()
const { triggerNotification } = useGlobalToast()
const isLoading = useMutationLoading()
const createView = useCreateSavedView()
const updateGroup = useUpdateSavedViewGroup()
const isSelected = defineModel<boolean>('isSelected')
const renameMode = defineModel<boolean>('renameMode')
const open = ref(false)
const showMenu = ref(false)
@@ -120,6 +132,14 @@ const isUngroupedGroup = computed(() => props.group.isUngroupedViewsGroup)
const canUpdate = computed(() => props.group.permissions.canUpdate)
const menuItems = computed((): LayoutMenuItem<MenuItems>[][] => [
[
{
id: MenuItems.Rename,
title: 'Rename',
disabled: !canUpdate.value?.authorized || isLoading.value,
disabledTooltip: canUpdate.value.errorMessage
}
],
[
{
id: MenuItems.Delete,
@@ -135,6 +155,9 @@ const onActionChosen = async (item: LayoutMenuItem<MenuItems>) => {
case MenuItems.Delete:
emit('delete-group', props.group)
break
case MenuItems.Rename:
emit('rename-group', props.group)
break
default:
throwUncoveredError(item.id)
}
@@ -147,6 +170,32 @@ const onAddGroupView = async () => {
isSelected.value = true
}
const onRename = async (newName: string) => {
if (!newName.trim() || newName.length > 255) {
triggerNotification({
type: ToastNotificationType.Danger,
title: 'Name must be between 1 and 255 characters long'
})
renameMode.value = false
return
}
if (props.group.title === newName) {
renameMode.value = false
return
}
const res = await updateGroup({
group: props.group,
update: {
name: newName
}
})
if (res?.id) {
renameMode.value = false
}
}
watch(
() => isSelected.value,
(isSelected) => {
@@ -167,13 +167,13 @@ 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 ViewerSavedViewsPanelGroups_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.ViewerSavedViewsPanelGroups_ProjectFragmentDoc,
"\n query ViewerSavedViewsPanelGroups_SavedViewGroups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelGroups_Project\n }\n }\n": typeof types.ViewerSavedViewsPanelGroups_SavedViewGroupsDocument,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n visibility\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_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 ViewerSavedViewsPanelViewDeleteDialog_SavedView on SavedView {\n id\n name\n ...UseDeleteSavedView_SavedView\n }\n": typeof types.ViewerSavedViewsPanelViewDeleteDialog_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewEditDialog_SavedView on SavedView {\n id\n name\n description\n visibility\n group {\n ...FormSelectSavedViewGroup_SavedViewGroup\n }\n ...UseUpdateSavedView_SavedView\n }\n": typeof types.ViewerSavedViewsPanelViewEditDialog_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewMoveDialog_SavedView on SavedView {\n id\n group {\n ...FormSelectSavedViewGroup_SavedViewGroup\n }\n ...UseUpdateSavedView_SavedView\n }\n": typeof types.ViewerSavedViewsPanelViewMoveDialog_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {\n id\n isUngroupedViewsGroup\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup\n ...ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup\n }\n": typeof types.ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {\n id\n isUngroupedViewsGroup\n title\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup\n ...ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n": typeof types.ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup_Paginated on SavedViewGroup {\n id\n views(input: $savedViewsInput) {\n cursor\n totalCount\n items {\n id\n ...ViewerSavedViewsPanelView_SavedView\n }\n }\n }\n": typeof types.ViewerSavedViewsPanelViewsGroup_SavedViewGroup_PaginatedFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup on SavedViewGroup {\n id\n title\n ...UseDeleteSavedViewGroup_SavedViewGroup\n }\n": typeof types.ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroupFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": typeof types.ViewerSavedViewsPanelViewsGroupInner_SavedViewGroupFragmentDoc,
@@ -416,6 +416,8 @@ type Documents = {
"\n mutation CreateSavedViewGroup($input: CreateSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n createGroup(input: $input) {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n }\n }\n": typeof types.CreateSavedViewGroupDocument,
"\n mutation DeleteSavedViewGroup($input: DeleteSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n deleteGroup(input: $input)\n }\n }\n }\n": typeof types.DeleteSavedViewGroupDocument,
"\n fragment UseDeleteSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n groupId\n projectId\n isUngroupedViewsGroup\n }\n": typeof types.UseDeleteSavedViewGroup_SavedViewGroupFragmentDoc,
"\n mutation UpdateSavedViewGroup($input: UpdateSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n updateGroup(input: $input) {\n id\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n }\n }\n }\n": typeof types.UpdateSavedViewGroupDocument,
"\n fragment UseUpdateSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n projectId\n groupId\n title\n isUngroupedViewsGroup\n }\n": typeof types.UseUpdateSavedViewGroup_SavedViewGroupFragmentDoc,
"\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,
@@ -666,13 +668,13 @@ 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 ViewerSavedViewsPanelGroups_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.ViewerSavedViewsPanelGroups_ProjectFragmentDoc,
"\n query ViewerSavedViewsPanelGroups_SavedViewGroups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelGroups_Project\n }\n }\n": types.ViewerSavedViewsPanelGroups_SavedViewGroupsDocument,
"\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n visibility\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_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 ViewerSavedViewsPanelViewDeleteDialog_SavedView on SavedView {\n id\n name\n ...UseDeleteSavedView_SavedView\n }\n": types.ViewerSavedViewsPanelViewDeleteDialog_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewEditDialog_SavedView on SavedView {\n id\n name\n description\n visibility\n group {\n ...FormSelectSavedViewGroup_SavedViewGroup\n }\n ...UseUpdateSavedView_SavedView\n }\n": types.ViewerSavedViewsPanelViewEditDialog_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewMoveDialog_SavedView on SavedView {\n id\n group {\n ...FormSelectSavedViewGroup_SavedViewGroup\n }\n ...UseUpdateSavedView_SavedView\n }\n": types.ViewerSavedViewsPanelViewMoveDialog_SavedViewFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {\n id\n isUngroupedViewsGroup\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup\n ...ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup\n }\n": types.ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {\n id\n isUngroupedViewsGroup\n title\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup\n ...ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n": types.ViewerSavedViewsPanelViewsGroup_SavedViewGroupFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup_Paginated on SavedViewGroup {\n id\n views(input: $savedViewsInput) {\n cursor\n totalCount\n items {\n id\n ...ViewerSavedViewsPanelView_SavedView\n }\n }\n }\n": types.ViewerSavedViewsPanelViewsGroup_SavedViewGroup_PaginatedFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup on SavedViewGroup {\n id\n title\n ...UseDeleteSavedViewGroup_SavedViewGroup\n }\n": types.ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroupFragmentDoc,
"\n fragment ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup on SavedViewGroup {\n id\n title\n }\n": types.ViewerSavedViewsPanelViewsGroupInner_SavedViewGroupFragmentDoc,
@@ -915,6 +917,8 @@ const documents: Documents = {
"\n mutation CreateSavedViewGroup($input: CreateSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n createGroup(input: $input) {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\n }\n }\n }\n }\n": types.CreateSavedViewGroupDocument,
"\n mutation DeleteSavedViewGroup($input: DeleteSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n deleteGroup(input: $input)\n }\n }\n }\n": types.DeleteSavedViewGroupDocument,
"\n fragment UseDeleteSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n groupId\n projectId\n isUngroupedViewsGroup\n }\n": types.UseDeleteSavedViewGroup_SavedViewGroupFragmentDoc,
"\n mutation UpdateSavedViewGroup($input: UpdateSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n updateGroup(input: $input) {\n id\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n }\n }\n }\n": types.UpdateSavedViewGroupDocument,
"\n fragment UseUpdateSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n projectId\n groupId\n title\n isUngroupedViewsGroup\n }\n": types.UseUpdateSavedViewGroup_SavedViewGroupFragmentDoc,
"\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,
@@ -1638,18 +1642,18 @@ export function graphql(source: "\n fragment ViewerResourcesWorkspaceLimitAlert
* 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 ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\n }\n }\n }\n"): (typeof documents)["\n fragment ViewerSavedViewsPanel_Project on Project {\n id\n permissions {\n canCreateSavedView {\n ...FullPermissionCheckResult\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 ViewerSavedViewsPanelGroups_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 documents)["\n fragment ViewerSavedViewsPanelGroups_Project on Project {\n id\n savedViewGroups(input: $savedViewGroupsInput) {\n totalCount\n cursor\n items {\n id\n ...ViewerSavedViewsPanelViewsGroup_SavedViewGroup\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 query ViewerSavedViewsPanelGroups_SavedViewGroups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelGroups_Project\n }\n }\n"): (typeof documents)["\n query ViewerSavedViewsPanelGroups_SavedViewGroups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelGroups_Project\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 ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n visibility\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\n }\n"): (typeof documents)["\n fragment ViewerSavedViewsPanelView_SavedView on SavedView {\n id\n name\n description\n screenshot\n visibility\n author {\n id\n name\n }\n updatedAt\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...UseDeleteSavedView_SavedView\n ...UseUpdateSavedView_SavedView\n ...ViewerSavedViewsPanelViewEditDialog_SavedView\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 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 documents)["\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"];
/**
* 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 ViewerSavedViewsPanelViews_Groups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelViews_Project\n }\n }\n"): (typeof documents)["\n query ViewerSavedViewsPanelViews_Groups(\n $projectId: String!\n $savedViewGroupsInput: SavedViewGroupsInput!\n ) {\n project(id: $projectId) {\n id\n ...ViewerSavedViewsPanelViews_Project\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -1665,7 +1669,7 @@ export function graphql(source: "\n fragment ViewerSavedViewsPanelViewMoveDialo
/**
* 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 ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {\n id\n isUngroupedViewsGroup\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup\n ...ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup\n }\n"): (typeof documents)["\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {\n id\n isUngroupedViewsGroup\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup\n ...ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup\n }\n"];
export function graphql(source: "\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {\n id\n isUngroupedViewsGroup\n title\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup\n ...ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n"): (typeof documents)["\n fragment ViewerSavedViewsPanelViewsGroup_SavedViewGroup on SavedViewGroup {\n id\n isUngroupedViewsGroup\n title\n permissions {\n canUpdate {\n ...FullPermissionCheckResult\n }\n }\n ...ViewerSavedViewsPanelViewsGroupInner_SavedViewGroup\n ...ViewerSavedViewsPanelViewsGroupDeleteDialog_SavedViewGroup\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -2634,6 +2638,14 @@ export function graphql(source: "\n mutation DeleteSavedViewGroup($input: Delet
* 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 UseDeleteSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n groupId\n projectId\n isUngroupedViewsGroup\n }\n"): (typeof documents)["\n fragment UseDeleteSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n groupId\n projectId\n isUngroupedViewsGroup\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 UpdateSavedViewGroup($input: UpdateSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n updateGroup(input: $input) {\n id\n ...UseUpdateSavedViewGroup_SavedViewGroup\n }\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateSavedViewGroup($input: UpdateSavedViewGroupInput!) {\n projectMutations {\n savedViewMutations {\n updateGroup(input: $input) {\n id\n ...UseUpdateSavedViewGroup_SavedViewGroup\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 fragment UseUpdateSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n projectId\n groupId\n title\n isUngroupedViewsGroup\n }\n"): (typeof documents)["\n fragment UseUpdateSavedViewGroup_SavedViewGroup on SavedViewGroup {\n id\n projectId\n groupId\n title\n isUngroupedViewsGroup\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
@@ -8,6 +8,12 @@ import { ApolloClients, provideApolloClient } from '@vue/apollo-composable'
import {markRaw, toRaw} from 'vue'
export default defineNuxtPlugin(async (nuxt) => {
// in dev mode, load better messages
if (import.meta.dev) {
const devSettings = await import('@apollo/client/dev')
devSettings.loadDevMessages()
}
// Load all configs
const keyedConfigs = {};
<% for (const key of Object.keys(options.configResolvers)) { %>
@@ -3,10 +3,13 @@ import { graphql } from '~/lib/common/generated/gql'
import type {
CreateSavedViewGroupInput,
CreateSavedViewInput,
UpdateSavedViewGroupInput,
UpdateSavedViewGroupMutationVariables,
UpdateSavedViewInput,
UseDeleteSavedView_SavedViewFragment,
UseDeleteSavedViewGroup_SavedViewGroupFragment,
UseUpdateSavedView_SavedViewFragment
UseUpdateSavedView_SavedViewFragment,
UseUpdateSavedViewGroup_SavedViewGroupFragment
} from '~/lib/common/generated/gql/graphql'
import { useStateSerialization } from '~/lib/viewer/composables/serialization'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
@@ -470,3 +473,86 @@ export const useDeleteSavedViewGroup = () => {
return res
}
}
const updateSavedViewGroupMutation = graphql(`
mutation UpdateSavedViewGroup($input: UpdateSavedViewGroupInput!) {
projectMutations {
savedViewMutations {
updateGroup(input: $input) {
id
...UseUpdateSavedViewGroup_SavedViewGroup
}
}
}
}
`)
graphql(`
fragment UseUpdateSavedViewGroup_SavedViewGroup on SavedViewGroup {
id
projectId
groupId
title
isUngroupedViewsGroup
}
`)
export const useUpdateSavedViewGroup = () => {
const { mutate } = useMutation(updateSavedViewGroupMutation)
const { triggerNotification } = useGlobalToast()
const { isLoggedIn } = useActiveUser()
return async (params: {
group: UseUpdateSavedViewGroup_SavedViewGroupFragment
update: Omit<UpdateSavedViewGroupInput, 'projectId' | 'groupId'>
}) => {
const { group, update } = params
if (!isLoggedIn.value) return
if (group.isUngroupedViewsGroup) return
const result = await mutate(
{
input: {
projectId: group.projectId,
groupId: group.id,
...update
}
},
{
optimisticResponse(vars) {
// apollo typing issue:
const typedVars = vars as UpdateSavedViewGroupMutationVariables
// We want the name update to be immediate to avoid flashing content
return {
projectMutations: {
savedViewMutations: {
updateGroup: {
...group,
title: typedVars.input.name || group.title
}
}
}
}
}
}
).catch(convertThrowIntoFetchResult)
const res = result?.data?.projectMutations.savedViewMutations.updateGroup
if (res?.id) {
triggerNotification({
title: 'Group updated',
type: ToastNotificationType.Success
})
} else {
const err = getFirstGqlErrorMessage(result?.errors)
triggerNotification({
title: "Couldn't update group",
description: err,
type: ToastNotificationType.Danger
})
}
return res
}
}
@@ -207,9 +207,16 @@ input UpdateSavedViewInput {
visibility: SavedViewVisibility
}
input UpdateSavedViewGroupInput {
projectId: ID!
groupId: ID!
name: String
}
type SavedViewMutations {
createGroup(input: CreateSavedViewGroupInput!): SavedViewGroup!
deleteGroup(input: DeleteSavedViewGroupInput!): Boolean!
updateGroup(input: UpdateSavedViewGroupInput!): SavedViewGroup!
createView(input: CreateSavedViewInput!): SavedView!
deleteView(input: DeleteSavedViewInput!): Boolean!
updateView(input: UpdateSavedViewInput!): SavedView!
@@ -3537,6 +3537,7 @@ export type SavedViewMutations = {
createView: SavedView;
deleteGroup: Scalars['Boolean']['output'];
deleteView: Scalars['Boolean']['output'];
updateGroup: SavedViewGroup;
updateView: SavedView;
};
@@ -3561,6 +3562,11 @@ export type SavedViewMutationsDeleteViewArgs = {
};
export type SavedViewMutationsUpdateGroupArgs = {
input: UpdateSavedViewGroupInput;
};
export type SavedViewMutationsUpdateViewArgs = {
input: UpdateSavedViewInput;
};
@@ -4396,6 +4402,12 @@ export type UpdateModelInput = {
projectId: Scalars['ID']['input'];
};
export type UpdateSavedViewGroupInput = {
groupId: Scalars['ID']['input'];
name?: InputMaybe<Scalars['String']['input']>;
projectId: Scalars['ID']['input'];
};
export type UpdateSavedViewInput = {
description?: InputMaybe<Scalars['String']['input']>;
/** New group id, if grouping necessary */
@@ -6110,6 +6122,7 @@ export type ResolversTypes = {
UpdateAccSyncItemInput: UpdateAccSyncItemInput;
UpdateAutomateFunctionInput: UpdateAutomateFunctionInput;
UpdateModelInput: UpdateModelInput;
UpdateSavedViewGroupInput: UpdateSavedViewGroupInput;
UpdateSavedViewInput: UpdateSavedViewInput;
UpdateServerRegionInput: UpdateServerRegionInput;
UpdateVersionInput: UpdateVersionInput;
@@ -6465,6 +6478,7 @@ export type ResolversParentTypes = {
UpdateAccSyncItemInput: UpdateAccSyncItemInput;
UpdateAutomateFunctionInput: UpdateAutomateFunctionInput;
UpdateModelInput: UpdateModelInput;
UpdateSavedViewGroupInput: UpdateSavedViewGroupInput;
UpdateSavedViewInput: UpdateSavedViewInput;
UpdateServerRegionInput: UpdateServerRegionInput;
UpdateVersionInput: UpdateVersionInput;
@@ -7798,6 +7812,7 @@ export type SavedViewMutationsResolvers<ContextType = GraphQLContext, ParentType
createView?: Resolver<ResolversTypes['SavedView'], ParentType, ContextType, RequireFields<SavedViewMutationsCreateViewArgs, 'input'>>;
deleteGroup?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<SavedViewMutationsDeleteGroupArgs, 'input'>>;
deleteView?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<SavedViewMutationsDeleteViewArgs, 'input'>>;
updateGroup?: Resolver<ResolversTypes['SavedViewGroup'], ParentType, ContextType, RequireFields<SavedViewMutationsUpdateGroupArgs, 'input'>>;
updateView?: Resolver<ResolversTypes['SavedView'], ParentType, ContextType, RequireFields<SavedViewMutationsUpdateViewArgs, 'input'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
@@ -9163,6 +9178,14 @@ export type CanUpdateSavedViewGroupQueryVariables = Exact<{
export type CanUpdateSavedViewGroupQuery = { __typename?: 'Query', project: { __typename?: 'Project', id: string, savedViewGroup: { __typename?: 'SavedViewGroup', id: string, permissions: { __typename?: 'SavedViewGroupPermissionChecks', canUpdate: { __typename?: 'PermissionCheckResult', authorized: boolean, code: string, message: string, payload?: Record<string, unknown> | null } } } } };
export type UpdateSavedViewGroupMutationVariables = Exact<{
input: UpdateSavedViewGroupInput;
viewsInput?: SavedViewGroupViewsInput;
}>;
export type UpdateSavedViewGroupMutation = { __typename?: 'Mutation', projectMutations: { __typename?: 'ProjectMutations', savedViewMutations: { __typename?: 'SavedViewMutations', updateGroup: { __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 } }> } } } } };
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 };
@@ -10296,6 +10319,7 @@ export const CanUpdateSavedViewDocument = {"kind":"Document","definitions":[{"ki
export const UpdateSavedViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSavedView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateSavedViewInput"}}}}],"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":"updateView"},"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":"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<UpdateSavedViewMutation, UpdateSavedViewMutationVariables>;
export const DeleteSavedViewGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteSavedViewGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteSavedViewGroupInput"}}}}],"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":"deleteGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]}}]} as unknown as DocumentNode<DeleteSavedViewGroupMutation, DeleteSavedViewGroupMutationVariables>;
export const CanUpdateSavedViewGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"CanUpdateSavedViewGroup"},"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"}}}}],"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":"savedViewGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"groupId"}}}],"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<CanUpdateSavedViewGroupQuery, CanUpdateSavedViewGroupQueryVariables>;
export const UpdateSavedViewGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSavedViewGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateSavedViewGroupInput"}}}},{"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":"projectMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"savedViewMutations"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateGroup"},"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<UpdateSavedViewGroupMutation, UpdateSavedViewGroupMutationVariables>;
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>;
@@ -143,6 +143,14 @@ export type DeleteSavedViewGroupRecord = (params: {
projectId: string
}) => Promise<boolean>
export type UpdateSavedViewGroupRecord = <
Update extends Exact<Partial<SavedViewGroup>, Update>
>(params: {
groupId: string
projectId: string
update: Update
}) => Promise<SavedViewGroup | undefined>
/////////////////////
// SERVICE OPERATIONS:
/////////////////////
@@ -223,3 +231,12 @@ export type DeleteSavedViewGroup = (params: {
}
userId: string
}) => Promise<boolean>
export type UpdateSavedViewGroup = (params: {
input: {
groupId: string
projectId: string
name?: MaybeNullOrUndefined<string>
}
userId: string
}) => Promise<SavedViewGroup>
@@ -29,6 +29,7 @@ import {
recalculateGroupResourceIdsFactory,
storeSavedViewFactory,
storeSavedViewGroupFactory,
updateSavedViewGroupRecordFactory,
updateSavedViewRecordFactory
} from '@/modules/viewer/repositories/savedViews'
import {
@@ -38,7 +39,8 @@ import {
deleteSavedViewGroupFactory,
getGroupSavedViewsFactory,
getProjectSavedViewGroupsFactory,
updateSavedViewFactory
updateSavedViewFactory,
updateSavedViewGroupFactory
} from '@/modules/viewer/services/savedViewsManagement'
import { getViewerResourceGroupsFactory } from '@/modules/viewer/services/viewerResources'
import { Authz } from '@speckle/shared'
@@ -403,6 +405,34 @@ const resolvers: Resolvers = {
})
return true
},
updateGroup: async (_parent, args, ctx) => {
const projectId = args.input.projectId
throwIfResourceAccessNotAllowed({
resourceId: projectId,
resourceType: TokenResourceIdentifierType.Project,
resourceAccessRules: ctx.resourceAccessRules
})
const canUpdate = await ctx.authPolicies.project.savedViews.canUpdateGroup({
userId: ctx.userId,
projectId,
groupId: args.input.groupId
})
throwIfAuthNotOk(canUpdate)
const projectDb = await getProjectDbClient({ projectId })
const updateSavedViewGroup = updateSavedViewGroupFactory({
updateSavedViewGroupRecord: updateSavedViewGroupRecordFactory({
db: projectDb
}),
getSavedViewGroup: getSavedViewGroupFactory({ loaders: ctx.loaders })
})
return await updateSavedViewGroup({
input: args.input,
userId: ctx.userId!
})
}
},
ProjectPermissionChecks: {
@@ -19,7 +19,8 @@ import type {
UpdateSavedViewRecord,
GetSavedView,
GetStoredViewGroupCount,
DeleteSavedViewGroupRecord
DeleteSavedViewGroupRecord,
UpdateSavedViewGroupRecord
} from '@/modules/viewer/domain/operations/savedViews'
import {
SavedViewVisibility,
@@ -585,3 +586,20 @@ export const deleteSavedViewGroupRecordFactory =
// Otherwise, return true
return true
}
export const updateSavedViewGroupRecordFactory =
(deps: { db: Knex }): UpdateSavedViewGroupRecord =>
async (params) => {
const { groupId, projectId, update } = params
// Update the saved view group
const [updatedGroup] = await tables
.savedViewGroups(deps.db)
.where({
[SavedViewGroups.col.id]: groupId,
[SavedViewGroups.col.projectId]: projectId
})
.update(update, '*')
return updatedGroup
}
@@ -19,6 +19,8 @@ import type {
StoreSavedView,
StoreSavedViewGroup,
UpdateSavedView,
UpdateSavedViewGroup,
UpdateSavedViewGroupRecord,
UpdateSavedViewRecord
} from '@/modules/viewer/domain/operations/savedViews'
import { SavedViewVisibility } from '@/modules/viewer/domain/types/savedViews'
@@ -422,14 +424,6 @@ export const updateSavedViewFactory =
}
: {})
}
if (Object.keys(changes).length === 0) {
throw new SavedViewUpdateValidationError('No changes submitted with the input.', {
info: {
input,
userId
}
})
}
// Validate updated resourceIds
let resourceIds: ResourceBuilder | undefined = undefined
@@ -499,38 +493,50 @@ export const updateSavedViewFactory =
}
// Validate name
if (changes.name && changes.name.length > 255) {
throw new SavedViewUpdateValidationError(
'View name must be between 1 and 255 characters long',
{
info: {
input,
userId
if (changes.name?.trim()) {
if (changes.name.length > 255) {
throw new SavedViewUpdateValidationError(
'View name must be between 1 and 255 characters long',
{
info: {
input,
userId
}
}
}
)
)
}
} else {
delete changes['name']
}
const finalChanges = omit(changes, ['resourceIdString', 'viewerState'])
const update = {
...finalChanges,
...(resourceIds
? {
resourceIds: resourceIds ? resourceIds.map((r) => r.toString()) : undefined,
groupResourceIds: formatResourceIdsForGroup(resourceIds)
}
: {}),
...(viewerState
? {
viewerState
}
: {})
}
if (Object.keys(update).length === 0) {
throw new SavedViewUpdateValidationError('No changes submitted with the input.', {
info: {
input,
userId
}
})
}
const updatedView = await deps.updateSavedViewRecord({
id,
projectId,
update: {
...finalChanges,
...(resourceIds
? {
resourceIds: resourceIds
? resourceIds.map((r) => r.toString())
: undefined,
groupResourceIds: formatResourceIdsForGroup(resourceIds)
}
: { resourceIdString: undefined }),
...(viewerState
? {
viewerState
}
: { viewerState: undefined })
}
update
})
if (updatedView?.groupId !== view.groupId) {
@@ -573,3 +579,71 @@ export const deleteSavedViewGroupFactory =
projectId
})
}
export const updateSavedViewGroupFactory =
(deps: {
updateSavedViewGroupRecord: UpdateSavedViewGroupRecord
getSavedViewGroup: GetSavedViewGroup
}): UpdateSavedViewGroup =>
async ({ input, userId }) => {
const { groupId, projectId } = input
if (isUngroupedGroup(groupId)) {
throw new SavedViewGroupUpdateValidationError(
'Cannot update ungrouped/default saved view group.'
)
}
const group = await deps.getSavedViewGroup({
id: groupId,
projectId
})
if (!group) {
throw new SavedViewGroupUpdateValidationError('Group not found.', {
info: {
input,
userId
}
})
}
const changes = removeNullOrUndefinedKeys(omit(input, ['groupId', 'projectId']))
// Validate name
if (changes.name?.trim()) {
if (changes.name.length > 255) {
throw new SavedViewGroupUpdateValidationError(
'View name must be between 1 and 255 characters long',
{
info: {
input,
userId
}
}
)
}
} else {
delete changes['name']
}
if (Object.keys(changes).length === 0) {
throw new SavedViewGroupUpdateValidationError(
'No changes submitted with the input.',
{
info: {
input,
userId
}
}
)
}
// Update the saved view group
const updatedGroup = await deps.updateSavedViewGroupRecord({
groupId,
projectId,
update: changes
})
return updatedGroup! // should exist, we checked before
}
@@ -221,3 +221,20 @@ export const canUpdateSavedViewGroupQuery = gql`
}
}
`
export const updateSavedViewGroupMutation = gql`
mutation UpdateSavedViewGroup(
$input: UpdateSavedViewGroupInput!
$viewsInput: SavedViewGroupViewsInput! = { limit: 10 }
) {
projectMutations {
savedViewMutations {
updateGroup(input: $input) {
...BasicSavedViewGroup
}
}
}
}
${basicSavedViewGroupFragment}
`
@@ -12,6 +12,7 @@ import type {
GetProjectSavedViewGroupsQueryVariables,
GetProjectSavedViewQueryVariables,
GetProjectUngroupedViewGroupQueryVariables,
UpdateSavedViewGroupMutationVariables,
UpdateSavedViewInput,
UpdateSavedViewMutationVariables
} from '@/modules/core/graph/generated/graphql'
@@ -27,7 +28,8 @@ import {
GetProjectSavedViewGroupDocument,
GetProjectSavedViewGroupsDocument,
GetProjectUngroupedViewGroupDocument,
UpdateSavedViewDocument
UpdateSavedViewDocument,
UpdateSavedViewGroupDocument
} from '@/modules/core/graph/generated/graphql'
import {
buildBasicTestModel,
@@ -39,6 +41,7 @@ import { SavedViewVisibility } from '@/modules/viewer/domain/types/savedViews'
import {
SavedViewCreationValidationError,
SavedViewGroupCreationValidationError,
SavedViewGroupUpdateValidationError,
SavedViewInvalidResourceTargetError,
SavedViewUpdateValidationError
} from '@/modules/viewer/errors/savedViews'
@@ -205,6 +208,35 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
options?: ExecuteOperationOptions
) => apollo.execute(CanUpdateSavedViewGroupDocument, input, options)
const updateSavedViewGroup = (
input: UpdateSavedViewGroupMutationVariables,
options?: ExecuteOperationOptions
) => apollo.execute(UpdateSavedViewGroupDocument, input, options)
const getDefaultGroup = async (params: {
projectId: string
resourceIdString: string
}) => {
const { projectId, resourceIdString } = params
// Get default group id
const groupsRes = await getProjectViewGroups(
{
projectId,
input: {
limit: 1,
resourceIdString
}
},
{ assertNoErrors: true }
)
const defaultGroup = groupsRes.data?.project.savedViewGroups.items[0]
expect(defaultGroup).to.be.ok
expect(defaultGroup?.isUngroupedViewsGroup).to.be.true
return defaultGroup!
}
const model1ResourceIds = () => ViewerRoute.resourceBuilder().addModel(myModel1.id)
const model2ResourceIds = () => ViewerRoute.resourceBuilder().addModel(myModel2.id)
@@ -919,23 +951,10 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
})
it('allow setting default group as group, which actually sets it to null', async () => {
// Get default group id
const groupsRes = await getProjectViewGroups(
{
projectId: updatablesProject.id,
input: {
limit: 1,
resourceIdString: models[0].id
}
},
{ assertNoErrors: true }
)
const defaultGroup = groupsRes.data?.project.savedViewGroups.items[0]
expect(defaultGroup).to.be.ok
expect(defaultGroup?.isUngroupedViewsGroup).to.be.true
// Update view to have that be the group
const defaultGroup = await getDefaultGroup({
projectId: updatablesProject.id,
resourceIdString: models[0].id
})
const update = await updateView(
{
input: {
@@ -953,6 +972,25 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
expect(updatedView?.group.id).to.equal(defaultGroup!.id)
})
it('empty string name update gets ignored', async () => {
const updatedname = ''
const res = await updateView({
input: {
id: testView.id,
projectId: updatablesProject.id,
name: updatedname
}
})
// should show empty changes update as we have nothing else to update
expect(res).to.haveGraphQLErrors({
code: SavedViewUpdateValidationError.code,
message: 'No changes submitted with the input'
})
expect(res.data?.projectMutations.savedViewMutations.updateView.id).to.not.be.ok
})
it('fails if user has no access to update the view', async () => {
const newName = 'Updated View Name'
@@ -1086,6 +1124,144 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
})
expect(res.data?.projectMutations.savedViewMutations.updateView).to.not.be.ok
})
describe('to groups', () => {
let updatableGroup: BasicSavedViewGroupFragment
beforeEach(async () => {
const createRes = await createSavedViewGroup(
{
input: {
projectId: updatablesProject.id,
resourceIdString: models[0].id,
groupName: 'Group to update'
}
},
{ assertNoErrors: true }
)
const group = createRes.data?.projectMutations.savedViewMutations.createGroup!
expect(group).to.be.ok
updatableGroup = group
})
afterEach(async () => {
await deleteSavedViewGroup({
input: {
groupId: updatableGroup.id,
projectId: updatablesProject.id
}
})
})
it('successfully update the name', async () => {
const updatedname = 'babababababababa123'
const res = await updateSavedViewGroup({
input: {
groupId: updatableGroup.id,
projectId: updatableGroup.projectId,
name: updatedname
}
})
expect(res).to.not.haveGraphQLErrors()
const group = res.data?.projectMutations.savedViewMutations.updateGroup
expect(group?.id).to.be.ok
expect(group?.title).to.equal(updatedname)
})
it('fail invalid name length', async () => {
const updatedname = 'a'.repeat(300)
const res = await updateSavedViewGroup({
input: {
groupId: updatableGroup.id,
projectId: updatableGroup.projectId,
name: updatedname
}
})
expect(res).to.haveGraphQLErrors({
code: SavedViewGroupUpdateValidationError.code
})
expect(res.data?.projectMutations.savedViewMutations.updateGroup.id).to.not.be
.ok
})
it('empty string name update gets ignored', async () => {
const updatedname = ''
const res = await updateSavedViewGroup({
input: {
groupId: updatableGroup.id,
projectId: updatableGroup.projectId,
name: updatedname
}
})
// should show empty changes update as we have nothing else to update
expect(res).to.haveGraphQLErrors({
code: SavedViewGroupUpdateValidationError.code,
message: 'No changes submitted with the input'
})
expect(res.data?.projectMutations.savedViewMutations.updateGroup.id).to.not.be
.ok
})
it('prevent updates to default/ungrouped groups', async () => {
const defaultGroup = await getDefaultGroup({
projectId: updatableGroup.projectId,
resourceIdString: models[0].id
})
const res = await updateSavedViewGroup({
input: {
groupId: defaultGroup.id,
projectId: defaultGroup.projectId,
name: 'New Group Name'
}
})
expect(res).to.haveGraphQLErrors({
code: BadRequestError.code,
message: 'ungrouped group cannot be modified'
})
expect(res.data?.projectMutations.savedViewMutations.updateGroup.id).to.not.be
.ok
})
it('prevent updates to nonexistant groups', async () => {
const res = await updateSavedViewGroup({
input: {
groupId: 'nonexistent-group-id',
projectId: updatableGroup.projectId,
name: 'New Group Name'
}
})
expect(res).to.haveGraphQLErrors({
code: NotFoundError.code
})
expect(res.data?.projectMutations.savedViewMutations.updateGroup.id).to.not.be
.ok
})
it('disallow empty changes being submitted', async () => {
const res = await updateSavedViewGroup({
input: {
groupId: updatableGroup.id,
projectId: updatableGroup.projectId
}
})
expect(res).to.haveGraphQLErrors({
code: SavedViewGroupUpdateValidationError.code
})
expect(res.data?.projectMutations.savedViewMutations.updateGroup.id).to.not.be
.ok
})
})
})
describe('deletions', () => {
@@ -1290,22 +1466,10 @@ const fakeViewerState = (overrides?: PartialDeep<ViewerState.SerializedViewerSta
})
it('should fail to delete default group', async () => {
// Get default group id
const groupsRes = await getProjectViewGroups(
{
projectId: deletablesProject.id,
input: {
limit: 1,
resourceIdString: models[0].id
}
},
{ assertNoErrors: true }
)
const defaultGroup = groupsRes.data?.project.savedViewGroups.items[0]
expect(defaultGroup).to.be.ok
expect(defaultGroup?.isUngroupedViewsGroup).to.be.true
const defaultGroup = await getDefaultGroup({
projectId: deletablesProject.id,
resourceIdString: models[0].id
})
const res = await deleteSavedViewGroup({
input: {
groupId: defaultGroup!.id,
@@ -20,7 +20,7 @@ export default {
control: { type: 'select' }
},
color: {
options: ['page', 'foundation', 'transparent'],
options: ['page', 'foundation', 'transparent', 'fully-transparent'],
control: { type: 'select' }
},
rules: {
@@ -207,3 +207,19 @@ export const WithFoundationColor = mergeStories(Default, {
name: generateRandomName('withFoundationColor')
}
})
export const WithFullyTransparentColor = mergeStories(Default, {
render: (args) => ({
components: { FormTextInput },
setup() {
return { args }
},
template: `<div class="bg-foundation-page p-5">
<form-text-input v-bind="args" @update:modelValue="args['update:modelValue']"/>
</div>`
}),
args: {
color: 'fully-transparent',
name: generateRandomName('withFullyTransparentColor')
}
})
@@ -59,6 +59,7 @@
:readonly="readOnly"
role="textbox"
v-bind="$attrs"
:style="inputStyle"
@change="$emit('change', { event: $event, value })"
@input="$emit('input', { event: $event, value })"
@focus="$emit('focus')"
@@ -119,7 +120,7 @@
import type { RuleExpression } from 'vee-validate'
import { XMarkIcon } from '@heroicons/vue/20/solid'
import { computed, ref, toRefs, useSlots } from 'vue'
import type { PropType } from 'vue'
import type { CSSProperties, PropType } from 'vue'
import type { Nullable, Optional } from '@speckle/shared'
import { useTextInputCore } from '~~/src/composables/form/textInput'
import type { PropAnyComponent } from '~~/src/helpers/common/components'
@@ -343,6 +344,16 @@ const {
inputEl: inputElement
})
const inputStyle = computed((): CSSProperties => {
if (props.color !== 'fully-transparent') return {}
// In fully transparent mode, we want the input to fully blend in w/ parent styling
const style: CSSProperties = {
fontSize: 'inherit'
}
return style
})
const leadingIconClasses = computed(() => {
const classParts: string[] = ['h-4 w-4']
@@ -372,16 +383,21 @@ const iconClasses = computed((): string => {
})
const sizeClasses = computed((): string => {
// fully transparent should get sizing/coloring info from parent elements,
// its supposed to fit into the existing style
const ifNotFullyTransparent = (val: string) =>
props.color === 'fully-transparent' ? '' : val
switch (props.size) {
case 'sm':
return 'h-6 text-body sm:text-body-sm'
return `h-6 ${ifNotFullyTransparent('text-body sm:text-body-sm')}`
case 'lg':
return 'h-10 text-body sm:text-[13px]'
return `h-10 ${ifNotFullyTransparent('text-body sm:text-[13px]')}`
case 'xl':
return 'h-14 text-body sm:text-sm'
return `h-14 ${ifNotFullyTransparent('text-body sm:text-sm')}`
case 'base':
default:
return 'h-8 text-body sm:text-body-sm'
return `h-8 ${ifNotFullyTransparent('text-body sm:text-body-sm')}`
}
})
@@ -761,13 +761,13 @@ const finalItems = computed(() => {
const listboxOptionsClasses = computed(() => {
const classParts = [
'rounded-md bg-foundation py-1 label label--light border border-outline-3 shadow-md mt-1 '
'rounded-md bg-foundation py-1 label label--light border border-outline-3 shadow-md'
]
if (props.mountMenuOnBody) {
classParts.push('fixed z-50')
} else {
classParts.push('absolute top-[100%] w-full z-40')
classParts.push('absolute top-[100%] w-full z-40 mt-1')
}
return classParts.join(' ')
@@ -122,3 +122,31 @@ export const WithTitleActions: StoryObj = {
...Default.args
}
}
export const WithEditableTitle: StoryObj = {
render: (args) => ({
components: { LayoutDisclosure, FormButton },
setup() {
const title = ref("Baby's first title")
const editTitle = ref(false)
return { args, title, editTitle }
},
template: `
<div class="flex flex-col gap-2">
<LayoutDisclosure v-bind="args" v-model:edit-title="editTitle" v-model:title="title">
<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>
</LayoutDisclosure>
<div>
Saved/current title: {{ title }}
</div>
<FormButton @click="editTitle = true">Enable edit mode</FormButton>
</div>`
}),
args: {
...Default.args
}
}
@@ -4,7 +4,18 @@
<DisclosureButton :class="buttonClasses" @click="toggle">
<div class="inline-flex items-center space-x-2">
<Component :is="icon" v-if="icon" class="h-5 w-5" />
<span>{{ title }}</span>
<span v-if="!editTitle">{{ title }}</span>
<FormTextInput
v-else
v-bind="bind"
name="disclosureTitle"
color="fully-transparent"
:input-classes="buttonTextClasses"
:auto-focus="true"
v-on="on"
@click.stop
@blur="onTitleInputBlur"
/>
<slot name="title-actions" />
</div>
<ChevronUpIcon :class="!open ? 'rotate-180 transform' : ''" class="h-5 w-5" />
@@ -24,14 +35,14 @@ import {
DisclosurePanel
} from '@headlessui/vue'
import { ChevronUpIcon } from '@heroicons/vue/24/solid'
import { computed } from 'vue'
import { computed, watch } from 'vue'
import type { PropAnyComponent } from '~~/src/helpers/common/components'
import { FormTextInput, useDebouncedTextInput } from '~~/src/lib'
type DisclosureColor = 'default' | 'danger' | 'success' | 'warning'
const props = withDefaults(
defineProps<{
title: string
/**
* HeadlessUI icon component to use
*/
@@ -41,42 +52,71 @@ const props = withDefaults(
* Whether to lazy load the panel contents only upon opening
*/
lazyLoad?: boolean
/**
* If edit mode enabled - it will exit mode when user unfocuses
*/
exitEditModeOnBlur?: boolean
}>(),
{
color: 'default'
color: 'default',
exitEditModeOnBlur: true
}
)
const editTitle = defineModel<boolean>('editTitle')
const title = defineModel<string>('title')
const open = defineModel<boolean>('open', {
default: false
})
const { on, bind, syncFromValue } = useDebouncedTextInput({
disableDebouncedInput: true,
model: title
})
const buttonTextClasses = computed(() => {
const classParts = ['font-medium']
switch (props.color) {
case 'warning':
classParts.push('text-warning')
break
case 'success':
classParts.push('text-success')
break
case 'danger':
classParts.push('text-danger')
break
case 'default':
default:
classParts.push('text-primary')
break
}
return classParts.join(' ')
})
const buttonClasses = computed(() => {
const classParts = [
'pr-3 h-10 w-full flex items-center justify-between border-l-2 px-2 rounded transition',
'ring-1 font-medium',
'group/disclosure'
'ring-1',
'group/disclosure',
buttonTextClasses.value
]
switch (props.color) {
case 'warning':
classParts.push(
'border-warning text-warning ring-warning-lighter hover:ring-warning'
)
classParts.push('border-warning ring-warning-lighter hover:ring-warning')
break
case 'success':
classParts.push(
'border-success text-success ring-success-lighter hover:ring-success'
)
classParts.push('border-success ring-success-lighter hover:ring-success')
break
case 'danger':
classParts.push('border-danger text-danger ring-danger-lighter hover:ring-danger')
classParts.push('border-danger ring-danger-lighter hover:ring-danger')
break
case 'default':
default:
classParts.push(
'border-primary text-primary ring-primary-muted hover:ring-primary'
)
classParts.push('border-primary ring-primary-muted hover:ring-primary')
break
}
@@ -108,4 +148,17 @@ const panelClasses = computed(() => {
const toggle = () => {
open.value = !open.value
}
const onTitleInputBlur = () => {
if (!props.exitEditModeOnBlur) return
editTitle.value = false
}
watch(editTitle, (newVal, oldVal) => {
// Reset input value on turning on edit mode
if (newVal && !oldVal) {
syncFromValue()
}
})
</script>
@@ -6,6 +6,8 @@ import { EllipsisVerticalIcon, StarIcon } from '@heroicons/vue/24/solid'
import { action } from '@storybook/addon-actions'
import { computed, ref } from 'vue'
import { HorizontalDirection } from '~~/src/lib'
import { StringEnum, type StringEnumValues } from '@speckle/shared'
import { includes } from 'lodash-es'
type StoryType = StoryObj<
Record<string, unknown> & {
@@ -142,30 +144,78 @@ export const WithResponsiveMenuDirection: StoryType = {
render: (args, ctx) => ({
components: { LayoutMenu, FormButton, EllipsisVerticalIcon },
setup() {
const location = ref<string | undefined>('left')
const Location = StringEnum([
'TopLeft',
'TopCenter',
'TopRight',
'BottomLeft',
'BottomCenter',
'BottomRight'
])
type Location = StringEnumValues<typeof Location>
const location = ref<Location>(Location.TopLeft)
const showMenu = ref(false)
const changeLocation = () => {
if (location.value === 'left') {
location.value = 'right'
} else if (location.value === 'right') {
location.value = undefined
} else {
location.value = 'left'
switch (location.value) {
case Location.TopLeft:
location.value = Location.TopCenter
break
case Location.TopCenter:
location.value = Location.TopRight
break
case Location.TopRight:
location.value = Location.BottomLeft
break
case Location.BottomLeft:
location.value = Location.BottomCenter
break
case Location.BottomCenter:
location.value = Location.BottomRight
break
case Location.BottomRight:
location.value = Location.TopLeft
break
}
}
const wrapperClasses = computed(() => {
const classParts: string[] = []
if (location.value === 'left') {
// x axis
const isLeft = includes([Location.TopLeft, Location.BottomLeft], location.value)
const isRight = includes(
[Location.TopRight, Location.BottomRight],
location.value
)
const isCenter = includes(
[Location.TopCenter, Location.BottomCenter],
location.value
)
if (isLeft) {
classParts.push('items-start')
} else if (location.value === 'right') {
} else if (isRight) {
classParts.push('items-end')
} else {
} else if (isCenter) {
classParts.push('items-center')
}
// y axis
const isTop = includes(
[Location.TopLeft, Location.TopCenter, Location.TopRight],
location.value
)
const isBottom = includes(
[Location.BottomLeft, Location.BottomCenter, Location.BottomRight],
location.value
)
if (isTop) {
classParts.push('justify-start')
} else if (isBottom) {
classParts.push('justify-end')
}
return classParts.join(' ')
})
@@ -179,23 +229,27 @@ export const WithResponsiveMenuDirection: StoryType = {
wrapperClasses
}
},
// -2rem for padding added by storybook
template: `
<div :class="['flex gap-2 flex-col', wrapperClasses]">
<LayoutMenu
v-bind="args"
@click.stop.prevent
v-model:open="showMenu"
:items="longItems"
@chosen="chosen"
@update:open="args['update:open']"
>
<FormButton @click="showMenu = !showMenu">
<EllipsisVerticalIcon class="w-4 h-4" />
Open menu
</FormButton>
</LayoutMenu>
<FormButton @click="changeLocation">Change location</FormButton>
</div>`,
<div class="flex gap-2 flex-col min-h-[calc(100vh-2rem)]">
<FormButton @click="changeLocation">Change location</FormButton>
<div :class="['flex gap-2 flex-col grow', wrapperClasses]">
<LayoutMenu
v-bind="args"
@click.stop.prevent
v-model:open="showMenu"
:items="longItems"
@chosen="chosen"
@update:open="args['update:open']"
>
<FormButton @click="showMenu = !showMenu">
<EllipsisVerticalIcon class="w-4 h-4" />
Open menu
</FormButton>
</LayoutMenu>
</div>
</div>
`,
methods: {
onOpenUpdate(val: boolean) {
args['update:open'](val)
@@ -57,7 +57,7 @@ import {
useResponsiveHorizontalDirectionCalculation
} from '~~/src/composables/common/window'
import type { LayoutMenuItem } from '~~/src/helpers/layout/components'
import { useElementBounding, useEventListener } from '@vueuse/core'
import { useElementBounding, useElementSize, useEventListener } from '@vueuse/core'
import { useBodyMountedMenuPositioning } from '~~/src/composables/layout/menu'
import { isNumber } from '#lodash'
import IconCheck from '~~/src/components/global/icon/Check.vue'
@@ -101,6 +101,8 @@ const menuButtonBounding = useElementBounding(menuButtonWrapper, {
immediate: true
})
const menuItemsSize = useElementSize(computed(() => menuItems.value?.el || null))
const { direction: calculatedDirection } = useResponsiveHorizontalDirectionCalculation({
el: computed(() => menuItems.value?.el || null),
defaultDirection: props.menuPosition,
@@ -124,7 +126,8 @@ const { menuStyle } = useBodyMountedMenuPositioning({
default:
return 176
}
})
}),
menuHeight: computed(() => menuItemsSize.height.value)
})
const menuItemsStyles = computed(() => {
@@ -141,7 +144,7 @@ const menuItemsStyles = computed(() => {
const menuItemsClasses = computed(() => {
const classParts = [
'mt-1 w-44 origin-top-right divide-y divide-outline-3 rounded-md bg-foundation shadow-lg border border-outline-2 z-50'
'w-44 origin-top-right divide-y divide-outline-3 rounded-md bg-foundation shadow-lg border border-outline-2 z-50'
]
if (props.customMenuItemsClasses) {
@@ -151,7 +154,7 @@ const menuItemsClasses = computed(() => {
if (props.mountMenuOnBody) {
classParts.push('fixed')
} else {
classParts.push('absolute')
classParts.push('absolute mt-1')
if (menuDirection.value === HorizontalDirection.Left) {
classParts.push('right-0')
@@ -5,10 +5,18 @@ import { computed, onMounted, ref, unref, watch } from 'vue'
import type { Ref, ToRefs } from 'vue'
import type { MaybeNullOrUndefined, Nullable } from '@speckle/shared'
import { nanoid } from 'nanoid'
import { debounce, isArray, isBoolean, isString, isUndefined, noop } from '#lodash'
import {
debounce,
includes,
isArray,
isBoolean,
isString,
isUndefined,
noop
} from '#lodash'
import type { LabelPosition } from './input'
export type InputColor = 'page' | 'foundation' | 'transparent'
export type InputColor = 'page' | 'foundation' | 'transparent' | 'fully-transparent'
/**
* Common setup for text input & textarea fields
@@ -77,23 +85,31 @@ export function useTextInputCore<V extends string | string[] = string>(params: {
})
const coreClasses = computed(() => {
const classParts = [
'block w-full text-foreground transition-all',
coreInputClasses.value
]
const color = unref(props.color)
const classParts = ['block w-full text-foreground', coreInputClasses.value]
if (color !== 'fully-transparent') {
classParts.push('py-2 px-3')
} else {
classParts.push('p-0')
}
if (hasError.value) {
classParts.push('!border-danger')
} else {
classParts.push('border-0 focus:ring-2 focus:ring-outline-2')
classParts.push('border-0')
if (color !== 'fully-transparent') {
classParts.push('transition-all focus:ring-2 focus:ring-outline-2')
} else {
classParts.push('focus:ring-0')
}
}
const color = unref(props.color)
if (color === 'foundation') {
classParts.push(
'bg-foundation !border border-outline-2 hover:border-outline-5 focus-visible:border-outline-4 !ring-0 focus-visible:!outline-0'
)
} else if (color === 'transparent') {
} else if (includes(['transparent', 'fully-transparent'], color)) {
classParts.push('bg-transparent')
} else {
classParts.push('bg-foundation-page')
@@ -127,6 +143,7 @@ export function useTextInputCore<V extends string | string[] = string>(params: {
const helpTipId = computed(() =>
hasHelpTip.value ? `${unref(props.name)}-${internalHelpTipId.value}` : undefined
)
const helpTipClasses = computed((): string => {
const classParts = ['text-body-2xs break-words']
classParts.push(hasError.value ? 'text-danger' : 'text-foreground-2')
@@ -135,6 +152,7 @@ export function useTextInputCore<V extends string | string[] = string>(params: {
}
return classParts.join(' ')
})
const shouldShowClear = computed(() => {
if (!unref(props.showClear)) return false
return (value.value?.length || 0) > 0
@@ -192,6 +210,13 @@ export function useDebouncedTextInput(params?: {
*/
debouncedBy?: number
/**
* If enabled, value will only change on submit/enter, and just typing in values will never
* register.
* Default: false
*/
disableDebouncedInput?: boolean
/**
* Optionally pass in the model ref that should be used as the source of truth
*/
@@ -217,15 +242,29 @@ export function useDebouncedTextInput(params?: {
* Set to true if you want to see debug output for how events fire and are handled
*/
debug?: boolean | ((...logArgs: unknown[]) => void)
/**
* Callback function that gets called when a new value is actually written to the model
*/
onWrite?: (val: string) => void
}) {
const { debouncedBy = 1000, isBasicHtmlInput = false, submitOnEnter } = params || {}
const {
debouncedBy = 1000,
isBasicHtmlInput = false,
submitOnEnter,
disableDebouncedInput,
onWrite
} = params || {}
const log = params?.debug
? isBoolean(params.debug)
? console.debug
: params.debug
: noop
// The actual source of truth holding the final value
const value = params?.model || ref('')
// The internal model of the input
const model = ref(value.value)
const getValue = (val: string | InputEvent | Event | FormInputChangeEvent) => {
@@ -236,29 +275,39 @@ export function useDebouncedTextInput(params?: {
return target?.value || ''
}
const debouncedValueUpdate = debounce((val: string) => {
/**
* Persist changes to the core underlying source of truth that's available outwards
*/
const persistValue = (val: string) => {
value.value = val
log('Value updated: ' + val)
}, debouncedBy)
onWrite?.(val)
}
const debouncedValueUpdate = disableDebouncedInput
? undefined
: debounce((val: string) => {
persistValue(val)
}, debouncedBy)
const inputEventName = isBasicHtmlInput ? 'input' : 'update:modelValue'
const on = {
[inputEventName]: (val: string | InputEvent) => {
const newVal = getValue(val)
model.value = newVal
debouncedValueUpdate(newVal)
debouncedValueUpdate?.(newVal)
log(`Input event [${inputEventName}] triggered: ${newVal}`)
},
clear: () => {
debouncedValueUpdate.cancel()
debouncedValueUpdate?.cancel()
model.value = ''
value.value = ''
persistValue('')
log('Clear event')
},
change: (val: FormInputChangeEvent | Event) => {
const newVal = getValue(val)
debouncedValueUpdate.cancel()
value.value = newVal
debouncedValueUpdate?.cancel()
persistValue(newVal)
model.value = newVal
log('Change event: ' + newVal)
},
@@ -297,9 +346,18 @@ export function useDebouncedTextInput(params?: {
model.value = value.value
})
const syncFromValue = () => {
debouncedValueUpdate?.cancel()
model.value = value.value
}
return {
on,
bind,
value
value,
/**
* Force sync internal state from the source of truth
*/
syncFromValue
}
}
@@ -7,6 +7,8 @@ import { HorizontalDirection } from '~~/src/composables/common/window'
* Simplifies correctly and responsively positioning (dropdown/right-click/etc) menus so that they open
* to the correct direction, can change directions if there's not enough space or even go full screen
* if there's no space in either direction.
*
* Also supports updating vertical position, incase the menu would clip w/ the bottom of the screen
*/
export const useBodyMountedMenuPositioning = (params: {
/**
@@ -22,6 +24,10 @@ export const useBodyMountedMenuPositioning = (params: {
* that just uses the button width.
*/
menuWidth: ComputedRef<number | undefined>
/**
* Optionally also control target menu height.
*/
menuHeight?: ComputedRef<number | undefined>
}) => {
const menuStyle = computed(() => {
const style: CSSProperties = {}
@@ -31,6 +37,11 @@ export const useBodyMountedMenuPositioning = (params: {
* 1.a. If menuWidth is bigger than screen width, use screen width
* 1.b. If menuWidth is smaller than screen width, use menuWidth
* 2. If 1.b. but menu is leaving screen bounds, make it open to other direction
*
* Also:
* 1.a. If menuHeight is bigger than screen height, use screen height
* 1.b. If menuHeight is smaller than screen height, use screenHeight
* 2. If 1.b. but menu is leaving screen bounds, make it open to other direction (upwards)
*/
const openToLeft = unref(params.menuOpenDirection) === HorizontalDirection.Left
@@ -39,40 +50,57 @@ export const useBodyMountedMenuPositioning = (params: {
const left = params.buttonBoundingBox.left.value
const width = params.buttonBoundingBox.width.value
const height = params.buttonBoundingBox.height.value
const margin = 4 // how much space to leave in full-screen mode or between button and menu
let finalWidth = width
let finalLeft = left
let finalTop = top + height + margin
const menuWidth = unref(params.menuWidth)
const menuHeight = unref(params?.menuHeight)
const viewportWidth = window.innerWidth
const xMargin = 10 // how much space to leave in full-screen mode
const viewportWithoutMargins = viewportWidth - xMargin * 2
const viewportHeight = window.innerHeight
const viewportWidthWithoutMargins = viewportWidth - margin * 2
const viewportHeightWithoutMargins = viewportHeight - margin * 2
if (!isUndefined(menuWidth)) {
if (menuWidth > viewportWithoutMargins) {
if (menuWidth > viewportWidthWithoutMargins) {
// Menu too big: use full screen width
finalWidth = viewportWithoutMargins
finalLeft = xMargin
finalWidth = viewportWidthWithoutMargins
finalLeft = margin
} else {
// Open to right or left depending on available space
finalWidth = menuWidth
if (openToLeft) {
finalLeft = left + width - menuWidth
if (finalLeft < xMargin) {
finalLeft = xMargin
if (finalLeft < margin) {
finalLeft = margin
}
} else {
if (left + menuWidth > viewportWithoutMargins) {
finalLeft = Math.max(left + width - menuWidth, xMargin)
if (left + menuWidth > viewportWidthWithoutMargins) {
finalLeft = Math.max(left + width - menuWidth, margin)
}
}
}
}
if (!isUndefined(menuHeight)) {
if (menuHeight > viewportHeightWithoutMargins) {
finalTop = margin
} else {
// By default opens downward, see if we need to move upward instead
if (top + height + menuHeight > viewportHeightWithoutMargins) {
finalTop = top - menuHeight - margin
}
}
}
style.left = `${finalLeft}px`
style.width = `${finalWidth}px`
style.top = `${top + height}px`
style.top = `${finalTop}px`
return style
})