Browse Source

Merge pull request #6444 from nocodb/nc-feat/views-in-left-sidebar

Removed right view sidebar
pull/6447/head
Muhammed Mustafa 1 year ago committed by GitHub
parent
commit
d80a0346d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 16
      packages/nc-gui/assets/style.scss
  2. 2
      packages/nc-gui/components/dashboard/Sidebar.vue
  3. 131
      packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue
  4. 15
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  5. 8
      packages/nc-gui/components/dashboard/TreeView/TableList.vue
  6. 108
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  7. 191
      packages/nc-gui/components/dashboard/TreeView/ViewsList.vue
  8. 119
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  9. 4
      packages/nc-gui/components/dashboard/TreeView/index.vue
  10. 4
      packages/nc-gui/components/dashboard/View.vue
  11. 140
      packages/nc-gui/components/dlg/ViewCreate.vue
  12. 2
      packages/nc-gui/components/general/DeleteModal.vue
  13. 8
      packages/nc-gui/components/nc/Select.vue
  14. 6
      packages/nc-gui/components/project/AllTables.vue
  15. 14
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  16. 151
      packages/nc-gui/components/smartsheet/sidebar/MenuBottom.vue
  17. 281
      packages/nc-gui/components/smartsheet/sidebar/index.vue
  18. 10
      packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue
  19. 19
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  20. 11
      packages/nc-gui/components/tabs/Smartsheet.vue
  21. 209
      packages/nc-gui/components/tabs/SmartsheetResizable.vue
  22. 17
      packages/nc-gui/composables/useViewData.ts
  23. 1
      packages/nc-gui/context/index.ts
  24. 3
      packages/nc-gui/lang/en.json
  25. 1
      packages/nc-gui/lib/acl.ts
  26. 2
      packages/nc-gui/pages/index/[typeOrId]/[projectId]/index/index/index.vue
  27. 4
      packages/nc-gui/pages/index/[typeOrId]/view/[viewId].vue
  28. 4
      packages/nc-gui/store/tables.ts
  29. 70
      packages/nc-gui/store/views.ts
  30. 5
      packages/nc-gui/utils/baseUtils.ts
  31. 2
      tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts
  32. 61
      tests/playwright/pages/Dashboard/Sidebar/index.ts
  33. 24
      tests/playwright/pages/Dashboard/TreeView.ts
  34. 51
      tests/playwright/pages/Dashboard/ViewSidebar/index.ts
  35. 2
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  36. 26
      tests/playwright/pages/Dashboard/common/Topbar/Share.ts
  37. 9
      tests/playwright/pages/Dashboard/common/Topbar/index.ts
  38. 4
      tests/playwright/pages/Dashboard/index.ts
  39. 2
      tests/playwright/pages/SharedForm/index.ts
  40. 2
      tests/playwright/tests/db/columns/columnAttachments.spec.ts
  41. 4
      tests/playwright/tests/db/features/baseShare.spec.ts
  42. 4
      tests/playwright/tests/db/features/undo-redo.spec.ts
  43. 8
      tests/playwright/tests/db/general/toolbarOperations.spec.ts
  44. 20
      tests/playwright/tests/db/general/views.spec.ts
  45. 7
      tests/playwright/tests/db/views/viewForm.spec.ts
  46. 8
      tests/playwright/tests/db/views/viewKanban.spec.ts

16
packages/nc-gui/assets/style.scss

@ -79,6 +79,11 @@ main {
width: 4px; width: 4px;
height: 4px; height: 4px;
} }
&::-webkit-scrollbar-track {
-webkit-border-radius: 10px;
border-radius: 10px;
}
&::-webkit-scrollbar-track-piece { &::-webkit-scrollbar-track-piece {
width: 0px; width: 0px;
} }
@ -86,11 +91,14 @@ main {
@apply bg-transparent; @apply bg-transparent;
} }
&::-webkit-scrollbar-thumb { &::-webkit-scrollbar-thumb {
-webkit-border-radius: 10px;
border-radius: 10px;
width: 4px; width: 4px;
@apply bg-gray-200; @apply bg-gray-300;
} }
&::-webkit-scrollbar-thumb:hover { &::-webkit-scrollbar-thumb:hover {
@apply bg-gray-300; @apply bg-gray-400;
} }
} }
@ -583,3 +591,7 @@ input[type='number'] {
.ant-pagination .ant-pagination-item-link-icon { .ant-pagination .ant-pagination-item-link-icon {
@apply !block !py-1.5; @apply !block !py-1.5;
} }
.nc-button.ant-btn.nc-sidebar-node-btn {
@apply opacity-0 group-hover:(opacity-100) text-gray-600 hover:(bg-gray-400 bg-opacity-20 text-gray-900) duration-100;
}

2
packages/nc-gui/components/dashboard/Sidebar.vue

@ -43,7 +43,7 @@ onUnmounted(() => {
</div> </div>
<div <div
ref="treeViewDom" ref="treeViewDom"
class="flex flex-col nc-scrollbar-sm-dark flex-grow" class="flex flex-col nc-scrollbar-dark-md flex-grow"
:class="{ :class="{
'border-t-1': !isSharedBase, 'border-t-1': !isSharedBase,
'border-transparent': !isTreeViewOnScrollTop, 'border-transparent': !isTreeViewOnScrollTop,

131
packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue

@ -0,0 +1,131 @@
<script setup lang="ts">
import type { ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
const { $e } = useNuxtApp()
const router = useRouter()
const { refreshCommandPalette } = useCommandPalette()
const viewsStore = useViewsStore()
const { views } = storeToRefs(viewsStore)
const { loadViews, navigateToView } = viewsStore
const table = inject(SidebarTableInj)!
const project = inject(ProjectInj)!
const isOpen = ref(false)
function onOpenModal({
title = '',
type,
copyViewId,
groupingFieldColumnId,
}: {
title?: string
type: ViewTypes
copyViewId?: string
groupingFieldColumnId?: string
}) {
isOpen.value = false
const isDlgOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgViewCreate'), {
'modelValue': isDlgOpen,
title,
type,
'tableId': table.value.id,
'selectedViewId': copyViewId,
groupingFieldColumnId,
'views': views,
'onUpdate:modelValue': closeDialog,
'onCreated': async (view: ViewType) => {
closeDialog()
refreshCommandPalette()
await loadViews()
navigateToView({
view,
tableId: table.value.id!,
projectId: project.value.id!,
})
$e('a:view:create', { view: view.type })
},
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
</script>
<template>
<NcDropdown v-model:isOpen="isOpen" destroy-popup-on-hide @click.stop="isOpen = !isOpen">
<slot />
<template #overlay>
<NcMenu class="max-w-48">
<NcMenuItem @click="onOpenModal({ type: ViewTypes.GRID })">
<div class="item" data-testid="sidebar-view-create-grid">
<div class="item-inner">
<GeneralViewIcon :meta="{ type: ViewTypes.GRID }" />
<div>Grid</div>
</div>
<GeneralIcon class="plus" icon="plus" />
</div>
</NcMenuItem>
<NcMenuItem @click="onOpenModal({ type: ViewTypes.FORM })">
<div class="item" data-testid="sidebar-view-create-form">
<div class="item-inner">
<GeneralViewIcon :meta="{ type: ViewTypes.FORM }" />
<div>Form</div>
</div>
<GeneralIcon class="plus" icon="plus" />
</div>
</NcMenuItem>
<NcMenuItem @click="onOpenModal({ type: ViewTypes.GALLERY })">
<div class="item" data-testid="sidebar-view-create-gallery">
<div class="item-inner">
<GeneralViewIcon :meta="{ type: ViewTypes.GALLERY }" />
<div>Gallery</div>
</div>
<GeneralIcon class="plus" icon="plus" />
</div>
</NcMenuItem>
<NcMenuItem data-testid="sidebar-view-create-kanban" @click="onOpenModal({ type: ViewTypes.KANBAN })">
<div class="item">
<div class="item-inner">
<GeneralViewIcon :meta="{ type: ViewTypes.KANBAN }" />
<div>Kanban</div>
</div>
<GeneralIcon class="plus" icon="plus" />
</div>
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
</template>
<style lang="scss" scoped>
.item {
@apply flex flex-row items-center w-36 justify-between;
}
.item-inner {
@apply flex flex-row items-center gap-x-1.75;
}
.plus {
@apply text-brand-400;
}
</style>

15
packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue

@ -191,11 +191,7 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
const newTableDom = document.querySelector(`[data-table-id="${table.id}"]`) const newTableDom = document.querySelector(`[data-table-id="${table.id}"]`)
if (!newTableDom) return if (!newTableDom) return
// Verify that table node is not in the viewport newTableDom?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
if (isElementInvisible(newTableDom)) {
// Scroll to the table node
newTableDom?.scrollIntoView({ behavior: 'smooth' })
}
}, 1000) }, 1000)
close(1000) close(1000)
@ -616,10 +612,7 @@ const DlgProjectDuplicateOnOk = async (jobData: { id: string; project_id: string
</div> </div>
</a-tooltip> </a-tooltip>
</div> </div>
<div <div class="flex flex-row items-center gap-x-0.25 w-12.25">
v-if="isUIAllowed('tableCreate', { roles: projectRole })"
class="flex flex-row items-center gap-x-0.25 w-12.25"
>
<NcDropdown <NcDropdown
:visible="isBasesOptionsOpen[base!.id!]" :visible="isBasesOptionsOpen[base!.id!]"
:trigger="['click']" :trigger="['click']"
@ -749,10 +742,6 @@ const DlgProjectDuplicateOnOk = async (jobData: { id: string; project_id: string
@apply !mx-0 !pl-8.75 !pr-0.5 !py-0.75 hover:bg-gray-200 !rounded-md; @apply !mx-0 !pl-8.75 !pr-0.5 !py-0.75 hover:bg-gray-200 !rounded-md;
} }
:deep(.nc-button.ant-btn.nc-sidebar-node-btn) {
@apply opacity-0 group-hover:(opacity-100) text-gray-600 hover:(bg-gray-400 bg-opacity-20 text-gray-900) duration-100;
}
:deep(.ant-collapse-content-box) { :deep(.ant-collapse-content-box) {
@apply !px-0 !pb-0 !pt-0.25; @apply !px-0 !pb-0 !pt-0.25;
} }

8
packages/nc-gui/components/dashboard/TreeView/TableList.vue

@ -26,10 +26,6 @@ const tables = computed(() => projectTables.value.get(project.value.id!) ?? [])
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const { openTable } = useTableNew({
projectId: project.value.id!,
})
const tablesById = computed(() => const tablesById = computed(() =>
tables.value.reduce<Record<string, TableType>>((acc, table) => { tables.value.reduce<Record<string, TableType>>((acc, table) => {
acc[table.id!] = table acc[table.id!] = table
@ -153,17 +149,15 @@ const availableTables = computed(() => {
v-for="table of availableTables" v-for="table of availableTables"
:key="table.id" :key="table.id"
v-e="['a:table:open']" v-e="['a:table:open']"
class="nc-tree-item text-sm cursor-pointer group" class="nc-tree-item text-sm"
:data-order="table.order" :data-order="table.order"
:data-id="table.id" :data-id="table.id"
:data-testid="`tree-view-table-${table.title}`"
:table="table" :table="table"
:project="project" :project="project"
:base-index="baseIndex" :base-index="baseIndex"
:data-title="table.title" :data-title="table.title"
:data-base-id="base?.id" :data-base-id="base?.id"
:data-type="table.type" :data-type="table.type"
@click="openTable(table)"
> >
</TableNode> </TableNode>
</div> </div>

108
packages/nc-gui/components/dashboard/TreeView/TableNode.vue

@ -20,6 +20,10 @@ const project = toRef(props, 'project')
const table = toRef(props, 'table') const table = toRef(props, 'table')
const baseIndex = toRef(props, 'baseIndex') const baseIndex = toRef(props, 'baseIndex')
const { openTable } = useTableNew({
projectId: project.value.id!,
})
const route = useRoute() const route = useRoute()
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
@ -34,9 +38,13 @@ useTableNew({
}) })
const projectRole = inject(ProjectRoleInj) const projectRole = inject(ProjectRoleInj)
provide(SidebarTableInj, table)
const { setMenuContext, openRenameTableDialog, duplicateTable } = inject(TreeViewInj)! const { setMenuContext, openRenameTableDialog, duplicateTable } = inject(TreeViewInj)!
const { loadViews: _loadViews } = useViewsStore()
const { activeView } = storeToRefs(useViewsStore())
// todo: temp // todo: temp
const { projectTables } = storeToRefs(useTablesStore()) const { projectTables } = storeToRefs(useTablesStore())
const tables = computed(() => projectTables.value.get(project.value.id!) ?? []) const tables = computed(() => projectTables.value.get(project.value.id!) ?? [])
@ -73,33 +81,80 @@ const { isSharedBase } = useProject()
const canUserEditEmote = computed(() => { const canUserEditEmote = computed(() => {
return isUIAllowed('tableIconEdit', { roles: projectRole?.value }) return isUIAllowed('tableIconEdit', { roles: projectRole?.value })
}) })
const isExpanded = ref(false)
const isLoading = ref(false)
const onExpand = async () => {
if (isExpanded.value) {
isExpanded.value = false
return
}
isLoading.value = true
try {
await _loadViews({ tableId: table.value.id, ignoreLoading: true })
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
isLoading.value = false
isExpanded.value = true
}
}
watch(
() => activeView.value?.id,
() => {
if (!activeView.value) return
if (activeView.value?.fk_model_id === table.value?.id) {
isExpanded.value = true
}
},
{
immediate: true,
},
)
const isTableOpened = computed(() => {
return openedTableId.value === table.value?.id && activeView.value?.is_default
})
</script> </script>
<template> <template>
<div <div
class="nc-tree-item text-sm cursor-pointer group select-none" class="nc-tree-item nc-table-node-wrapper text-sm select-none w-full"
:data-order="table.order" :data-order="table.order"
:data-id="table.id" :data-id="table.id"
:data-testid="`tree-view-table-${table.title}`"
:data-table-id="table.id" :data-table-id="table.id"
:class="[ :class="[`nc-project-tree-tbl nc-project-tree-tbl-${table.title}`]"
// todo: table filter :data-active="openedTableId === table.id"
// { hidden: !filteredTables?.includes(table), active: openedTableId === table.id },
`nc-project-tree-tbl nc-project-tree-tbl-${table.title}`,
{ active: openedTableId === table.id },
]"
> >
<GeneralTooltip <GeneralTooltip
class="pl-11 pr-0.75 mb-0.25 rounded-md h-7.1" class="nc-tree-item-inner pl-11 pr-0.75 mb-0.25 rounded-md h-7.1 w-full group cursor-pointer hover:bg-gray-200"
:class="{ :class="{
'hover:bg-gray-200': openedTableId !== table.id, 'hover:bg-gray-200': openedTableId !== table.id,
'pl-17.75': baseIndex !== 0, 'pl-12': baseIndex !== 0,
'pl-12.25': baseIndex === 0, 'pl-6.5': baseIndex === 0,
'!bg-primary-selected': isTableOpened,
}" }"
modifier-key="Alt" modifier-key="Alt"
> >
<template #title>{{ table.table_name }}</template> <template #title>{{ table.table_name }}</template>
<div class="table-context flex items-center gap-1 h-full" @contextmenu="setMenuContext('table', table)"> <div
class="table-context flex items-center gap-1 h-full"
:data-testid="`nc-tbl-side-node-${table.title}`"
@contextmenu="setMenuContext('table', table)"
@click="openTable(table)"
>
<div class="flex flex-row h-full items-center">
<NcButton type="text" size="xxsmall" class="nc-sidebar-node-btn" @click.stop="onExpand">
<GeneralIcon
icon="triangleFill"
class="nc-sidebar-base-node-btns group-hover:visible invisible cursor-pointer transform transition-transform duration-500 h-1.5 w-1.5 !text-gray-600 rotate-90 hover:bg-"
:class="{ '!rotate-180': isExpanded }"
/>
</NcButton>
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`"> <div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<div <div
class="flex items-center nc-table-icon" class="flex items-center nc-table-icon"
@ -142,11 +197,12 @@ const canUserEditEmote = computed(() => {
</LazyGeneralEmojiPicker> </LazyGeneralEmojiPicker>
</div> </div>
</div> </div>
</div>
<span <span
class="nc-tbl-title capitalize text-ellipsis overflow-hidden select-none" class="nc-tbl-title capitalize text-ellipsis overflow-hidden select-none"
:class="{ :class="{
'text-black !font-semibold': openedTableId === table.id, 'text-black !font-medium': isTableOpened,
}" }"
:data-testid="`nc-tbl-title-${table.title}`" :data-testid="`nc-tbl-title-${table.title}`"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }" :style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
@ -155,20 +211,18 @@ const canUserEditEmote = computed(() => {
</span> </span>
<div class="flex flex-grow h-full"></div> <div class="flex flex-grow h-full"></div>
<div class="flex flex-row items-center">
<NcDropdown <NcDropdown
v-if=" v-if="
!isSharedBase && !isSharedBase &&
(isUIAllowed('tableRename', { roles: projectRole }) || isUIAllowed('tableDelete', { roles: projectRole })) (isUIAllowed('tableRename', { roles: projectRole }) || isUIAllowed('tableDelete', { roles: projectRole }))
" "
:trigger="['click']" :trigger="['click']"
class="nc-sidebar-node-btn"
@click.stop @click.stop
> >
<MdiDotsHorizontal <MdiDotsHorizontal
class="min-w-5.75 min-h-5.75 mt-0.2 mr-0.25 px-0.5 transition-opacity opacity-0 group-hover:opacity-100 nc-tbl-context-menu outline-0 rounded-md hover:(bg-gray-500 bg-opacity-15 !text-black)" class="min-w-5.75 min-h-5.75 mt-0.2 mr-0.25 px-0.5 !text-gray-600 transition-opacity opacity-0 group-hover:opacity-100 nc-tbl-context-menu outline-0 rounded-md hover:(bg-gray-500 bg-opacity-15 !text-black)"
:class="{
'!text-gray-600': openedTableId !== table.id,
'!text-black': openedTableId === table.id,
}"
/> />
<template #overlay> <template #overlay>
@ -207,6 +261,12 @@ const canUserEditEmote = computed(() => {
</NcMenu> </NcMenu>
</template> </template>
</NcDropdown> </NcDropdown>
<DashboardTreeViewCreateViewBtn v-if="isUIAllowed('viewCreateOrEdit')">
<NcButton type="text" size="xxsmall" class="nc-create-view-btn nc-sidebar-node-btn">
<GeneralIcon icon="plus" class="text-xl leading-5" style="-webkit-text-stroke: 0.15px" />
</NcButton>
</DashboardTreeViewCreateViewBtn>
</div>
</div> </div>
<DlgTableDelete <DlgTableDelete
v-if="table.id && project?.id" v-if="table.id && project?.id"
@ -215,24 +275,16 @@ const canUserEditEmote = computed(() => {
:project-id="project.id" :project-id="project.id"
/> />
</GeneralTooltip> </GeneralTooltip>
<DashboardTreeViewViewsList v-if="isExpanded" :table-id="table.id" :project-id="project.id" />
</div> </div>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
.nc-tree-item { .nc-tree-item {
@apply relative cursor-pointer after:(pointer-events-none content-[''] rounded absolute top-0 left-0 w-full h-full right-0 !bg-current transition transition-opactity duration-100 opacity-0); @apply relative after:(pointer-events-none content-[''] rounded absolute top-0 left-0 w-full h-full right-0 !bg-current transition duration-100 opacity-0);
} }
.nc-tree-item svg { .nc-tree-item svg {
@apply text-primary text-opacity-60; @apply text-primary text-opacity-60;
} }
.nc-tree-item.active {
@apply !bg-primary-selected rounded-md;
//@apply border-r-3 border-primary;
svg {
@apply !text-opacity-100;
}
}
</style> </style>

191
packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue → packages/nc-gui/components/dashboard/TreeView/ViewsList.vue

@ -5,9 +5,8 @@ import type { SortableEvent } from 'sortablejs'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import type { Menu as AntMenu } from 'ant-design-vue' import type { Menu as AntMenu } from 'ant-design-vue'
import { import {
ActiveViewInj, isDefaultBase as _isDefaultBase,
extractSdkResponseErrorMsg, extractSdkResponseErrorMsg,
inject,
message, message,
onMounted, onMounted,
parseProp, parseProp,
@ -23,23 +22,32 @@ import {
watch, watch,
} from '#imports' } from '#imports'
interface Props {
views: ViewType[]
}
interface Emits { interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string; groupingFieldColumnId?: string }): void (event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string; groupingFieldColumnId?: string }): void
(event: 'deleted'): void (event: 'deleted'): void
} }
const { views = [] } = defineProps<Props>()
const emits = defineEmits<Emits>() const emits = defineEmits<Emits>()
const project = inject(ProjectInj)!
const table = inject(SidebarTableInj)!
const { isUIAllowed } = useRoles()
const { $e } = useNuxtApp() const { $e } = useNuxtApp()
const activeView = inject(ActiveViewInj, ref()) const isDefaultBase = computed(() => {
const base = project.value?.bases?.find((b) => b.id === table.value.base_id)
if (!base) return false
return _isDefaultBase(base)
})
const { viewsByTable, activeView } = storeToRefs(useViewsStore())
const { navigateToTable } = useTablesStore()
const views = computed(() => viewsByTable.value.get(table.value.id!)?.filter((v) => !v.is_default) ?? [])
const { api } = useApi() const { api } = useApi()
@ -49,6 +57,8 @@ const { refreshCommandPalette } = useCommandPalette()
const { addUndo, defineModelScope } = useUndoRedo() const { addUndo, defineModelScope } = useUndoRedo()
const { navigateToView, loadViews } = useViewsStore()
/** Selected view(s) for menu */ /** Selected view(s) for menu */
const selected = ref<string[]>([]) const selected = ref<string[]>([])
@ -80,7 +90,7 @@ function validate(view: ViewType) {
return 'View name is required' return 'View name is required'
} }
if (views.some((v) => v.title === view.title && v.id !== view.id)) { if (views.value.some((v) => v.title === view.title && v.id !== view.id)) {
return 'View name should be unique' return 'View name should be unique'
} }
@ -102,7 +112,7 @@ async function onSortEnd(evt: SortableEvent, undo = false) {
dragging.value = false dragging.value = false
} }
if (views.length < 2) return if (views.value.length < 2) return
const { newIndex = 0, oldIndex = 0 } = evt const { newIndex = 0, oldIndex = 0 } = evt
@ -139,17 +149,17 @@ async function onSortEnd(evt: SortableEvent, undo = false) {
const previousEl = children[newIndex - 1] const previousEl = children[newIndex - 1]
const nextEl = children[newIndex + 1] const nextEl = children[newIndex + 1]
const currentItem = views.find((v) => v.id === evt.item.id) const currentItem = views.value.find((v) => v.id === evt.item.id)
if (!currentItem || !currentItem.id) return if (!currentItem || !currentItem.id) return
const previousItem = (previousEl ? views.find((v) => v.id === previousEl.id) : {}) as ViewType const previousItem = (previousEl ? views.value.find((v) => v.id === previousEl.id) : {}) as ViewType
const nextItem = (nextEl ? views.find((v) => v.id === nextEl.id) : {}) as ViewType const nextItem = (nextEl ? views.value.find((v) => v.id === nextEl.id) : {}) as ViewType
let nextOrder: number let nextOrder: number
// set new order value based on the new order of the items // set new order value based on the new order of the items
if (views.length - 1 === newIndex) { if (views.value.length - 1 === newIndex) {
nextOrder = parseFloat(String(previousItem.order)) + 1 nextOrder = parseFloat(String(previousItem.order)) + 1
} else if (newIndex === 0) { } else if (newIndex === 0) {
nextOrder = parseFloat(String(nextItem.order)) / 2 nextOrder = parseFloat(String(nextItem.order)) / 2
@ -183,24 +193,12 @@ onMounted(() => menuRef.value && initSortable(menuRef.value.$el))
/** Navigate to view by changing url param */ /** Navigate to view by changing url param */
function changeView(view: ViewType) { function changeView(view: ViewType) {
if ( navigateToView({
router.currentRoute.value.query && view,
router.currentRoute.value.query.page && tableId: table.value.id!,
router.currentRoute.value.query.page === 'fields' projectId: project.value.id!,
) { hardReload: view.type === ViewTypes.FORM && selected.value[0] === view.id,
router.push({ params: { viewTitle: view.id || '' }, query: router.currentRoute.value.query })
} else {
router.push({ params: { viewTitle: view.id || '' } })
}
if (view.type === ViewTypes.FORM && selected.value[0] === view.id) {
// reload the page if the same form view is clicked
// router.go(0)
// fix me: router.go(0) reloads entire page. need to reload only the form view
router.replace({ query: { reload: 'true' } }).then(() => {
router.replace({ query: {} })
}) })
}
} }
/** Rename a view */ /** Rename a view */
@ -211,10 +209,11 @@ async function onRename(view: ViewType, originalTitle?: string, undo = false) {
order: view.order, order: view.order,
}) })
await router.replace({ navigateToView({
params: { view,
viewTitle: view.id, tableId: table.value.id!,
}, projectId: project.value.id!,
hardReload: view.type === ViewTypes.FORM && selected.value[0] === view.id,
}) })
refreshCommandPalette() refreshCommandPalette()
@ -256,20 +255,22 @@ function openDeleteDialog(view: ViewType) {
'modelValue': isOpen, 'modelValue': isOpen,
'view': view, 'view': view,
'onUpdate:modelValue': closeDialog, 'onUpdate:modelValue': closeDialog,
'onDeleted': () => { 'onDeleted': async () => {
closeDialog() closeDialog()
emits('deleted') emits('deleted')
refreshCommandPalette() refreshCommandPalette()
if (activeView.value === view) { if (activeView.value?.id === view.id) {
// return to the default view navigateToTable({
router.replace({ tableId: table.value.id!,
params: { projectId: project.value.id!,
viewTitle: views[0].id,
},
}) })
} }
await loadViews({
tableId: table.value.id!,
})
}, },
}) })
@ -298,47 +299,90 @@ const setIcon = async (icon: string, view: ViewType) => {
} }
} }
const scrollViewNode = () => { function onOpenModal({
const activeViewDom = document.querySelector(`.nc-views-menu [data-view-id="${activeView.value?.id}"]`) as HTMLElement title = '',
if (!activeViewDom) return type,
copyViewId,
groupingFieldColumnId,
}: {
title?: string
type: ViewTypes
copyViewId?: string
groupingFieldColumnId?: string
}) {
const isOpen = ref(true)
if (isElementInvisible(activeViewDom)) { const { close } = useDialog(resolveComponent('DlgViewCreate'), {
// Scroll to the view node 'modelValue': isOpen,
activeViewDom?.scrollIntoView({ behavior: 'auto', inline: 'start' }) title,
} type,
} 'tableId': table.value.id,
'selectedViewId': copyViewId,
groupingFieldColumnId,
'views': views,
'onUpdate:modelValue': closeDialog,
'onCreated': async (view: ViewType) => {
closeDialog()
watch( refreshCommandPalette()
() => activeView.value?.id,
() => {
if (!activeView.value?.id) return
// TODO: Find a better way to scroll to the view node await loadViews()
setTimeout(() => {
scrollViewNode() navigateToView({
}, 800) view,
}, tableId: table.value.id!,
{ projectId: project.value.id!,
immediate: true, hardReload: view.type === ViewTypes.FORM && selected.value[0] === view.id,
})
$e('a:view:create', { view: view.type })
}, },
) })
function closeDialog() {
isOpen.value = false
close(1000)
}
}
</script> </script>
<template> <template>
<DashboardTreeViewCreateViewBtn
v-if="isUIAllowed('viewCreateOrEdit')"
:overlay-class-name="isDefaultBase ? '!left-18 !min-w-42' : '!left-25 !min-w-42'"
>
<NcButton
type="text"
size="xsmall"
class="!w-full !py-0 !h-7 !text-gray-500 !hover:(bg-transparent font-normal text-brand-500) !font-normal !text-sm"
:centered="false"
>
<GeneralIcon
icon="plus"
class="mr-2"
:class="{
'ml-18.75': isDefaultBase,
'ml-24.25': !isDefaultBase,
}"
/>
<span class="text-sm">New View</span>
</NcButton>
</DashboardTreeViewCreateViewBtn>
<a-menu <a-menu
ref="menuRef" ref="menuRef"
:class="{ dragging }" :class="{ dragging }"
class="nc-views-menu flex flex-col !ml-3 w-full !border-r-0 !bg-inherit" class="nc-views-menu flex flex-col w-full !border-r-0 !bg-inherit"
:selected-keys="selected" :selected-keys="selected"
> >
<!-- Lazy load breaks menu item active styles, i.e. styles never change even when active item changes --> <DashboardTreeViewViewsNode
<SmartsheetSidebarRenameableMenuItem
v-for="view of views" v-for="view of views"
:id="view.id" :id="view.id"
:key="view.id" :key="view.id"
:view="view" :view="view"
:on-validate="validate" :on-validate="validate"
class="nc-view-item !rounded-md !px-1.25 !py-0.5 w-full transition-all ease-in duration-300" class="nc-view-item !rounded-md !px-0.75 !py-0.5 w-full transition-all ease-in duration-100"
:class="{ :class="{
'bg-gray-200': isMarked === view.id, 'bg-gray-200': isMarked === view.id,
'active': activeView?.id === view.id, 'active': activeView?.id === view.id,
@ -346,19 +390,16 @@ watch(
}" }"
:data-view-id="view.id" :data-view-id="view.id"
@change-view="changeView" @change-view="changeView"
@open-modal="$emit('openModal', $event)" @open-modal="onOpenModal"
@delete="openDeleteDialog" @delete="openDeleteDialog"
@rename="onRename" @rename="onRename"
@select-icon="setIcon($event, view)" @select-icon="setIcon($event, view)"
/> />
<div class="min-h-1 max-h-1 w-full bg-transparent"></div>
</a-menu> </a-menu>
</template> </template>
<style lang="scss"> <style lang="scss">
.nc-views-menu { .nc-views-menu {
@apply min-h-20 flex-grow;
.ghost, .ghost,
.ghost > * { .ghost > * {
@apply !pointer-events-none; @apply !pointer-events-none;
@ -378,12 +419,16 @@ watch(
@apply color-transition; @apply color-transition;
} }
.ant-menu-title-content {
@apply !w-full;
}
.sortable-chosen { .sortable-chosen {
@apply !bg-gray-100 bg-opacity-60; @apply !bg-gray-200;
} }
.active { .active {
@apply bg-gray-200 bg-opacity-60 font-medium; @apply !bg-primary-selected font-medium;
} }
} }
</style> </style>

119
packages/nc-gui/components/smartsheet/sidebar/RenameableMenuItem.vue → packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue

@ -2,7 +2,17 @@
import type { VNodeRef } from '@vue/runtime-core' import type { VNodeRef } from '@vue/runtime-core'
import type { KanbanType, ViewType, ViewTypes } from 'nocodb-sdk' import type { KanbanType, ViewType, ViewTypes } from 'nocodb-sdk'
import type { WritableComputedRef } from '@vue/reactivity' import type { WritableComputedRef } from '@vue/reactivity'
import { IsLockedInj, inject, message, onKeyStroke, useDebounceFn, useNuxtApp, useRoles, useVModel } from '#imports' import {
IsLockedInj,
isDefaultBase as _isDefaultBase,
inject,
message,
onKeyStroke,
useDebounceFn,
useNuxtApp,
useRoles,
useVModel,
} from '#imports'
interface Props { interface Props {
view: ViewType view: ViewType
@ -33,12 +43,23 @@ const { $e } = useNuxtApp()
const { isUIAllowed } = useRoles() const { isUIAllowed } = useRoles()
const { activeViewTitleOrId } = storeToRefs(useViewsStore())
const project = inject(ProjectInj, ref())
const activeView = inject(ActiveViewInj, ref()) const activeView = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj, ref(false)) const isLocked = inject(IsLockedInj, ref(false))
const { rightSidebarState } = storeToRefs(useSidebarStore()) const { rightSidebarState } = storeToRefs(useSidebarStore())
const isDefaultBase = computed(() => {
const base = project.value?.bases?.find((b) => b.id === vModel.value.base_id)
if (!base) return false
return _isDefaultBase(base)
})
const isDropdownOpen = ref(false) const isDropdownOpen = ref(false)
const isEditing = ref(false) const isEditing = ref(false)
@ -175,16 +196,35 @@ watch(rightSidebarState, () => {
isDropdownOpen.value = false isDropdownOpen.value = false
} }
}) })
function onRef(el: HTMLElement) {
if (activeViewTitleOrId.value === vModel.value.id) {
nextTick(() => {
setTimeout(() => {
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}, 1000)
})
}
}
</script> </script>
<template> <template>
<a-menu-item <a-menu-item
class="!min-h-8 !max-h-8 !mb-0.25 select-none group text-gray-700 !flex !items-center !mt-0 hover:(!bg-gray-100 !text-gray-900)" class="!min-h-7 !max-h-7 !mb-0.25 select-none group text-gray-700 !flex !items-center !mt-0 hover:(!bg-gray-200 !text-gray-900) cursor-pointer"
:class="{
'!pl-18': isDefaultBase,
'!pl-23.5': !isDefaultBase,
}"
:data-testid="`view-sidebar-view-${vModel.alias || vModel.title}`" :data-testid="`view-sidebar-view-${vModel.alias || vModel.title}`"
@dblclick.stop="onDblClick" @dblclick.stop="onDblClick"
@click="onClick" @click="onClick"
> >
<div v-e="['a:view:open', { view: vModel.type }]" class="text-xs flex items-center w-full gap-1" data-testid="view-item"> <div
:ref="onRef"
v-e="['a:view:open', { view: vModel.type }]"
class="text-sm flex items-center w-full gap-1"
data-testid="view-item"
>
<div class="flex min-w-6" :data-testid="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`"> <div class="flex min-w-6" :data-testid="`view-sidebar-drag-handle-${vModel.alias || vModel.title}`">
<LazyGeneralEmojiPicker <LazyGeneralEmojiPicker
class="nc-table-icon" class="nc-table-icon"
@ -203,7 +243,7 @@ watch(rightSidebarState, () => {
v-if="isEditing" v-if="isEditing"
:ref="focusInput" :ref="focusInput"
v-model:value="_title" v-model:value="_title"
class="!bg-transparent !text-xs !border-0 !ring-0 !outline-transparent !border-transparent" class="!bg-transparent !border-0 !ring-0 !outline-transparent !border-transparent"
:class="{ :class="{
'font-medium': activeView?.id === vModel.id, 'font-medium': activeView?.id === vModel.id,
}" }"
@ -215,6 +255,9 @@ watch(rightSidebarState, () => {
v-else v-else
class="capitalize text-ellipsis overflow-hidden select-none w-full" class="capitalize text-ellipsis overflow-hidden select-none w-full"
data-testid="sidebar-view-title" data-testid="sidebar-view-title"
:class="{
'font-medium': activeView?.id === vModel.id,
}"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }" :style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
> >
{{ vModel.alias || vModel.title }} {{ vModel.alias || vModel.title }}
@ -224,66 +267,38 @@ watch(rightSidebarState, () => {
<template v-if="!isEditing && !isLocked && isUIAllowed('viewCreateOrEdit')"> <template v-if="!isEditing && !isLocked && isUIAllowed('viewCreateOrEdit')">
<NcDropdown v-model:visible="isDropdownOpen" overlay-class-name="!rounded-lg"> <NcDropdown v-model:visible="isDropdownOpen" overlay-class-name="!rounded-lg">
<div <NcButton
class="invisible !group-hover:visible" type="text"
size="xxsmall"
class="nc-sidebar-node-btn invisible !group-hover:visible nc-sidebar-view-node-context-btn"
:class="{ :class="{
'!visible': isDropdownOpen, '!visible': isDropdownOpen,
}" }"
>
<NcButton
type="text"
size="xsmall"
class="nc-view-sidebar-node-context-btn !px-1 !hover:bg-gray-200"
@click.stop="isDropdownOpen = !isDropdownOpen" @click.stop="isDropdownOpen = !isDropdownOpen"
> >
<GeneralIcon icon="threeDotVertical" class="-mt-0.5" /> <GeneralIcon icon="threeDotHorizontal" class="text-xl w-4.75" />
</NcButton> </NcButton>
</div>
<template #overlay> <template #overlay>
<div <NcMenu class="min-w-27" :data-testid="`view-sidebar-view-actions-${vModel.alias || vModel.title}`">
class="flex flex-col items-center min-w-27" <NcMenuItem @click.stop="onDblClick">
:data-testid="`view-sidebar-view-actions-${vModel.alias || vModel.title}`"
>
<NcButton
type="text"
size="small"
class="w-full !rounded-none !hover:bg-gray-200"
:centered="false"
@click.stop="onDblClick"
>
<div class="flex flex-row items-center gap-x-2 pl-2 text-xs">
<GeneralIcon icon="edit" /> <GeneralIcon icon="edit" />
Rename <div class="-ml-0.25">Rename</div>
</div> </NcMenuItem>
</NcButton> <NcMenuItem @click.stop="onDuplicate">
<NcButton <GeneralIcon icon="duplicate" class="nc-view-copy-icon" />
type="text"
size="small"
class="nc-view-copy-icon w-full !rounded-none !hover:bg-gray-200"
:centered="false"
@click.stop="onDuplicate"
>
<div class="flex flex-row items-center gap-x-2 pl-1.5 text-xs">
<GeneralIcon icon="copy" class="text-base" />
Duplicate Duplicate
</div> </NcMenuItem>
</NcButton>
<NcDivider />
<template v-if="!vModel.is_default"> <template v-if="!vModel.is_default">
<NcButton <NcMenuItem class="!text-red-500" l @click.stop="onDelete">
type="text" <GeneralIcon icon="delete" class="text-sm nc-view-delete-icon" />
size="small" <div class="-ml-0.25">Delete</div>
class="nc-view-delete-icon w-full !hover:bg-gray-200 !rounded-none" </NcMenuItem>
:centered="false"
@click.stop="onDelete"
>
<div class="flex flex-row items-center gap-x-2.25 pl-1.75 text-red-400 text-xs">
<GeneralIcon icon="delete" />
Delete
</div>
</NcButton>
</template> </template>
</div> </NcMenu>
</template> </template>
</NcDropdown> </NcDropdown>
</template> </template>

4
packages/nc-gui/components/dashboard/TreeView/index.vue

@ -212,10 +212,8 @@ const scrollTableNode = () => {
const activeTableDom = document.querySelector(`.nc-treeview [data-table-id="${_activeTable.value?.id}"]`) const activeTableDom = document.querySelector(`.nc-treeview [data-table-id="${_activeTable.value?.id}"]`)
if (!activeTableDom) return if (!activeTableDom) return
if (isElementInvisible(activeTableDom)) {
// Scroll to the table node // Scroll to the table node
activeTableDom?.scrollIntoView({ behavior: 'smooth' }) activeTableDom?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
} }
watch( watch(

4
packages/nc-gui/components/dashboard/View.vue

@ -154,13 +154,13 @@ watch(route, () => {
.splitpanes__splitter:hover:before { .splitpanes__splitter:hover:before {
@apply bg-scrollbar; @apply bg-scrollbar;
width: 3px !important; width: 3px !important;
left: -3px; left: 0px;
} }
.splitpanes--dragging .splitpanes__splitter:before { .splitpanes--dragging .splitpanes__splitter:before {
@apply bg-scrollbar; @apply bg-scrollbar;
width: 3px !important; width: 3px !important;
left: -3px; left: 0px;
} }
.splitpanes--dragging .splitpanes__splitter { .splitpanes--dragging .splitpanes__splitter {

140
packages/nc-gui/components/dlg/ViewCreate.vue

@ -14,7 +14,7 @@ interface Props {
groupingFieldColumnId?: string groupingFieldColumnId?: string
geoDataFieldColumnId?: string geoDataFieldColumnId?: string
views: ViewType[] views: ViewType[]
meta: TableType tableId: string
} }
interface Emits { interface Emits {
@ -31,10 +31,20 @@ interface Form {
fk_geo_data_col_id: string | null fk_geo_data_col_id: string | null
} }
const { views = [], meta, selectedViewId, groupingFieldColumnId, geoDataFieldColumnId, ...props } = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
selectedViewId: undefined,
groupingFieldColumnId: undefined,
geoDataFieldColumnId: undefined,
})
const emits = defineEmits<Emits>() const emits = defineEmits<Emits>()
const { getMeta } = useMetas()
const { views, selectedViewId, groupingFieldColumnId, geoDataFieldColumnId, tableId } = toRefs(props)
const meta = ref<TableType | undefined>()
const inputEl = ref<ComponentPublicInstance>() const inputEl = ref<ComponentPublicInstance>()
const formValidator = ref<typeof AntForm>() const formValidator = ref<typeof AntForm>()
@ -64,7 +74,7 @@ const viewNameRules = [
{ {
validator: (_: unknown, v: string) => validator: (_: unknown, v: string) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
views.every((v1) => v1.title !== v) ? resolve(true) : reject(new Error(`View name should be unique`)) views.value.every((v1) => v1.title !== v) ? resolve(true) : reject(new Error(`View name should be unique`))
}), }),
message: 'View name should be unique', message: 'View name should be unique',
}, },
@ -97,52 +107,13 @@ watch(
function init() { function init() {
form.title = `Untitled ${capitalize(typeAlias.value)}` form.title = `Untitled ${capitalize(typeAlias.value)}`
const repeatCount = views.filter((v) => v.title.startsWith(form.title)).length const repeatCount = views.value.filter((v) => v.title.startsWith(form.title)).length
if (repeatCount) { if (repeatCount) {
form.title = `${form.title} ${repeatCount}` form.title = `${form.title} ${repeatCount}`
} }
if (selectedViewId) { if (selectedViewId.value) {
form.copy_from_id = selectedViewId form.copy_from_id = selectedViewId?.value
}
// preset the grouping field column
if (props.type === ViewTypes.KANBAN) {
viewSelectFieldOptions.value = meta
.columns!.filter((el) => el.uidt === UITypes.SingleSelect)
.map((field) => {
return {
value: field.id,
label: field.title,
}
})
if (groupingFieldColumnId) {
// take from the one from copy view
form.fk_grp_col_id = groupingFieldColumnId
} else {
// take the first option
form.fk_grp_col_id = viewSelectFieldOptions.value?.[0]?.value as string
}
}
if (props.type === ViewTypes.MAP) {
viewSelectFieldOptions.value = meta
.columns!.filter((el) => el.uidt === UITypes.GeoData)
.map((field) => {
return {
value: field.id,
label: field.title,
}
})
if (geoDataFieldColumnId) {
// take from the one from copy view
form.fk_geo_data_col_id = geoDataFieldColumnId
} else {
// take the first option
form.fk_geo_data_col_id = viewSelectFieldOptions.value?.[0]?.value as string
}
} }
nextTick(() => { nextTick(() => {
@ -165,9 +136,7 @@ async function onSubmit() {
} }
if (isValid && form.type) { if (isValid && form.type) {
const _meta = unref(meta) if (!tableId.value) return
if (!_meta || !_meta.id) return
try { try {
let data: GridType | KanbanType | GalleryType | FormType | MapType | null = null let data: GridType | KanbanType | GalleryType | FormType | MapType | null = null
@ -176,19 +145,19 @@ async function onSubmit() {
switch (form.type) { switch (form.type) {
case ViewTypes.GRID: case ViewTypes.GRID:
data = await api.dbView.gridCreate(_meta.id, form) data = await api.dbView.gridCreate(tableId.value, form)
break break
case ViewTypes.GALLERY: case ViewTypes.GALLERY:
data = await api.dbView.galleryCreate(_meta.id, form) data = await api.dbView.galleryCreate(tableId.value, form)
break break
case ViewTypes.FORM: case ViewTypes.FORM:
data = await api.dbView.formCreate(_meta.id, form) data = await api.dbView.formCreate(tableId.value, form)
break break
case ViewTypes.KANBAN: case ViewTypes.KANBAN:
data = await api.dbView.kanbanCreate(_meta.id, form) data = await api.dbView.kanbanCreate(tableId.value, form)
break break
case ViewTypes.MAP: case ViewTypes.MAP:
data = await api.dbView.mapCreate(_meta.id, form) data = await api.dbView.mapCreate(tableId.value, form)
} }
if (data) { if (data) {
@ -208,6 +177,60 @@ async function onSubmit() {
}, 500) }, 500)
} }
} }
const isMetaLoading = ref(false)
onMounted(async () => {
if (props.type === ViewTypes.KANBAN || props.type === ViewTypes.MAP) {
isMetaLoading.value = true
try {
meta.value = (await getMeta(tableId.value))!
if (props.type === ViewTypes.MAP) {
viewSelectFieldOptions.value = meta
.value!.columns!.filter((el) => el.uidt === UITypes.GeoData)
.map((field) => {
return {
value: field.id,
label: field.title,
}
})
if (geoDataFieldColumnId.value) {
// take from the one from copy view
form.fk_geo_data_col_id = geoDataFieldColumnId.value
} else {
// take the first option
form.fk_geo_data_col_id = viewSelectFieldOptions.value?.[0]?.value as string
}
}
// preset the grouping field column
if (props.type === ViewTypes.KANBAN) {
viewSelectFieldOptions.value = meta.value
.columns!.filter((el) => el.uidt === UITypes.SingleSelect)
.map((field) => {
return {
value: field.id,
label: field.title,
}
})
if (groupingFieldColumnId.value) {
// take from the one from copy view
form.fk_grp_col_id = groupingFieldColumnId.value
} else {
// take the first option
form.fk_grp_col_id = viewSelectFieldOptions.value?.[0]?.value as string
}
}
} catch (e) {
console.error(e)
} finally {
isMetaLoading.value = false
}
}
})
</script> </script>
<template> <template>
@ -237,11 +260,11 @@ async function onSubmit() {
name="fk_grp_col_id" name="fk_grp_col_id"
:rules="groupingFieldColumnRules" :rules="groupingFieldColumnRules"
> >
<a-select <NcSelect
v-model:value="form.fk_grp_col_id" v-model:value="form.fk_grp_col_id"
class="w-full nc-kanban-grouping-field-select" class="w-full nc-kanban-grouping-field-select"
:options="viewSelectFieldOptions" :disabled="groupingFieldColumnId || isMetaLoading"
:disabled="groupingFieldColumnId" :loading="true"
placeholder="Select a Grouping Field" placeholder="Select a Grouping Field"
not-found-content="No Single Select Field can be found. Please create one first." not-found-content="No Single Select Field can be found. Please create one first."
/> />
@ -252,11 +275,12 @@ async function onSubmit() {
name="fk_geo_data_col_id" name="fk_geo_data_col_id"
:rules="geoDataFieldColumnRules" :rules="geoDataFieldColumnRules"
> >
<a-select <NcSelect
v-model:value="form.fk_geo_data_col_id" v-model:value="form.fk_geo_data_col_id"
class="w-full" class="w-full"
:options="viewSelectFieldOptions" :options="viewSelectFieldOptions"
:disabled="geoDataFieldColumnId" :disabled="groupingFieldColumnId || isMetaLoading"
:loading="isMetaLoading"
placeholder="Select a GeoData Field" placeholder="Select a GeoData Field"
not-found-content="No GeoData Field can be found. Please create one first." not-found-content="No GeoData Field can be found. Please create one first."
/> />

2
packages/nc-gui/components/general/DeleteModal.vue

@ -40,7 +40,7 @@ onKeyStroke('Enter', () => {
</script> </script>
<template> <template>
<GeneralModal v-model:visible="visible" size="small"> <GeneralModal v-model:visible="visible" size="small" centered>
<div class="flex flex-col p-6"> <div class="flex flex-col p-6">
<div class="flex flex-row pb-2 mb-4 font-medium text-lg border-b-1 border-gray-50 text-gray-800"> <div class="flex flex-row pb-2 mb-4 font-medium text-lg border-b-1 border-gray-50 text-gray-800">
{{ $t('general.delete') }} {{ props.entityName }} {{ $t('general.delete') }} {{ props.entityName }}

8
packages/nc-gui/components/nc/Select.vue

@ -8,6 +8,7 @@ const props = defineProps<{
filterOption?: (input: string, option: any) => boolean filterOption?: (input: string, option: any) => boolean
dropdownMatchSelectWidth?: boolean dropdownMatchSelectWidth?: boolean
allowClear?: boolean allowClear?: boolean
loading?: boolean
}>() }>()
const emits = defineEmits(['update:value', 'change']) const emits = defineEmits(['update:value', 'change'])
@ -22,6 +23,8 @@ const filterOption = computed(() => props.filterOption)
const dropdownMatchSelectWidth = computed(() => props.dropdownMatchSelectWidth) const dropdownMatchSelectWidth = computed(() => props.dropdownMatchSelectWidth)
const loading = computed(() => props.loading)
const vModel = useVModel(props, 'value', emits) const vModel = useVModel(props, 'value', emits)
const onChange = (value: string) => { const onChange = (value: string) => {
@ -39,10 +42,13 @@ const onChange = (value: string) => {
:filter-option="filterOption" :filter-option="filterOption"
:dropdown-match-select-width="dropdownMatchSelectWidth" :dropdown-match-select-width="dropdownMatchSelectWidth"
:allow-clear="allowClear" :allow-clear="allowClear"
:loading="loading"
:disabled="loading"
@change="onChange" @change="onChange"
> >
<template #suffixIcon> <template #suffixIcon>
<GeneralIcon icon="arrowDown" class="text-gray-800 nc-select-expand-btn" /> <GeneralLoader v-if="loading" />
<GeneralIcon v-else icon="arrowDown" class="text-gray-800 nc-select-expand-btn" />
</template> </template>
<slot /> <slot />
</a-select> </a-select>

6
packages/nc-gui/components/project/AllTables.vue

@ -57,11 +57,7 @@ function openTableCreateDialog(baseIndex?: number | undefined) {
const newTableDom = document.querySelector(`[data-table-id="${table.id}"]`) const newTableDom = document.querySelector(`[data-table-id="${table.id}"]`)
if (!newTableDom) return if (!newTableDom) return
// Verify that table node is not in the viewport newTableDom?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
if (isElementInvisible(newTableDom)) {
// Scroll to the table node
newTableDom?.scrollIntoView({ behavior: 'smooth' })
}
}, 1000) }, 1000)
close(1000) close(1000)

14
packages/nc-gui/components/smartsheet/grid/GroupBy.vue

@ -30,6 +30,8 @@ const emits = defineEmits(['update:paginationData'])
const vGroup = useVModel(props, 'group', emits) const vGroup = useVModel(props, 'group', emits)
const { isViewDataLoading, isPaginationLoading } = storeToRefs(useViewsStore())
const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook()) const reloadViewDataHook = inject(ReloadViewDataHookInj, createEventHook())
const _depth = props.depth ?? 0 const _depth = props.depth ?? 0
@ -85,13 +87,19 @@ reloadViewDataHook?.on(reloadViewDataHandler)
watch( watch(
[() => vGroup.value.key], [() => vGroup.value.key],
(n, o) => { async (n, o) => {
if (n !== o) { if (n !== o) {
isViewDataLoading.value = true
isPaginationLoading.value = true
if (vGroup.value.nested) { if (vGroup.value.nested) {
props.loadGroups({}, vGroup.value) await props.loadGroups({}, vGroup.value)
} else { } else {
props.loadGroupData(vGroup.value, true) await props.loadGroupData(vGroup.value, true)
} }
isViewDataLoading.value = false
isPaginationLoading.value = false
} }
}, },
{ immediate: true }, { immediate: true },

151
packages/nc-gui/components/smartsheet/sidebar/MenuBottom.vue

@ -1,151 +0,0 @@
<script lang="ts" setup>
import { ViewTypes } from 'nocodb-sdk'
import { iconMap, useNuxtApp, useSmartsheetStoreOrThrow, viewIcons } from '#imports'
const emits = defineEmits<Emits>()
interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string }): void
}
const { $e } = useNuxtApp()
const { isSqlView } = useSmartsheetStoreOrThrow()
const { betaFeatureToggleState } = useBetaFeatureToggle()
function onOpenModal(type: ViewTypes, title = '') {
$e('c:view:create', { view: type })
emits('openModal', { type, title })
}
</script>
<template>
<a-menu :selected-keys="[]" class="flex flex-col !text-gray-600 !bg-inherit">
<div class="px-6 text-xs flex items-center gap-4 my-2 !text-gray-700">
{{ $t('activity.createView') }}
</div>
<a-menu-item
key="grid"
class="nc-create-view group !flex !items-center !my-0 !h-2.5rem nc-create-grid-view"
@click="onOpenModal(ViewTypes.GRID)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.grid') }}
</template>
<div class="!py-0 text-xs flex items-center h-full w-full gap-2">
<GeneralViewIcon :meta="{ type: ViewTypes.GRID }" class="min-w-5 flex" />
<div>{{ $t('objects.viewType.grid') }}</div>
<div class="flex-1" />
<component :is="iconMap.plus" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
key="gallery"
class="nc-create-view group !flex !items-center !my-0 !h-2.5rem nc-create-gallery-view"
@click="onOpenModal(ViewTypes.GALLERY)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.gallery') }}
</template>
<div class="!py-0 text-xs flex items-center h-full w-full gap-2">
<GeneralViewIcon :meta="{ type: ViewTypes.GALLERY }" class="min-w-5 flex" />
<div>{{ $t('objects.viewType.gallery') }}</div>
<div class="flex-1" />
<component :is="iconMap.plus" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
v-if="!isSqlView"
key="form"
class="nc-create-view group !flex !items-center !my-0 !h-2.5rem nc-create-form-view"
@click="onOpenModal(ViewTypes.FORM)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.form') }}
</template>
<div class="!py-0 text-xs flex items-center h-full w-full gap-2">
<GeneralViewIcon :meta="{ type: ViewTypes.FORM }" class="min-w-5 flex" />
<div>{{ $t('objects.viewType.form') }}</div>
<div class="flex-1" />
<component :is="iconMap.plus" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
key="kanban"
class="nc-create-view group !flex !items-center !my-0 !h-2.5rem nc-create-kanban-view"
@click="onOpenModal(ViewTypes.KANBAN)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.kanban') }}
</template>
<div class="!py-0 text-xs flex items-center h-full w-full gap-2">
<GeneralViewIcon :meta="{ type: ViewTypes.KANBAN }" class="min-w-5 flex" />
<div>{{ $t('objects.viewType.kanban') }}</div>
<div class="flex-1" />
<component :is="iconMap.plus" />
</div>
</a-tooltip>
</a-menu-item>
<a-menu-item
v-if="betaFeatureToggleState.show"
key="map"
class="nc-create-view group !flex !items-center !my-0 !h-2.5rem nc-create-map-view"
@click="onOpenModal(ViewTypes.MAP)"
>
<a-tooltip :mouse-enter-delay="1" placement="left">
<template #title>
{{ $t('msg.info.addView.map') }}
</template>
<div class="!py-0 text-xs flex items-center h-full w-full gap-2">
<component :is="viewIcons[ViewTypes.MAP].icon" :style="{ color: viewIcons[ViewTypes.MAP]?.color }" />
<div>{{ $t('objects.viewType.map') }}</div>
<div class="flex-1" />
<component :is="iconMap.plus" />
</div>
</a-tooltip>
</a-menu-item>
<div class="w-full h-3" />
</a-menu>
</template>
<style lang="scss" scoped>
:deep(.nc-create-view) {
@apply !py-0 !h-8 mx-3.75 rounded-md hover:(text-gray-800 bg-gray-100);
}
:deep(.nc-create-view.ant-menu-item) {
@apply px-2.25;
}
</style>

281
packages/nc-gui/components/smartsheet/sidebar/index.vue

@ -1,281 +0,0 @@
<script setup lang="ts">
import type { ViewType, ViewTypes } from 'nocodb-sdk'
import {
ActiveViewInj,
MetaInj,
inject,
ref,
resolveComponent,
storeToRefs,
useCommandPalette,
useDialog,
useNuxtApp,
useRoles,
useRoute,
useRouter,
useViewsStore,
watch,
} from '#imports'
const { refreshCommandPalette } = useCommandPalette()
const meta = inject(MetaInj, ref())
const activeView = inject(ActiveViewInj, ref())
const { activeTab } = storeToRefs(useTabs())
const viewsStore = useViewsStore()
const { loadViews } = viewsStore
const { isViewsLoading, views } = storeToRefs(viewsStore)
const { lastOpenedViewMap } = storeToRefs(useProject())
const { activeTable } = storeToRefs(useTablesStore())
const setLastOpenedViewId = (viewId?: string) => {
if (viewId && activeTab.value?.id) {
lastOpenedViewMap.value[activeTab.value?.id] = viewId
}
}
const { isUIAllowed } = useRoles()
const router = useRouter()
const route = useRoute()
const { $e } = useNuxtApp()
const { isRightSidebarOpen } = storeToRefs(useSidebarStore())
const tabBtnsContainerRef = ref<HTMLElement | null>(null)
/** Watch route param and change active view based on `viewTitle` */
watch(
[views, () => route.params.viewTitle],
([nextViews, viewTitle]) => {
const lastOpenedViewId = activeTab.value?.id && lastOpenedViewMap.value[activeTab.value?.id]
const lastOpenedView = nextViews.find((v) => v.id === lastOpenedViewId)
if (viewTitle) {
let view = nextViews.find((v) => v.title === viewTitle)
if (view) {
activeView.value = view
setLastOpenedViewId(activeView.value?.id)
} else {
/** search with view id and if found replace with title */
view = nextViews.find((v) => v.id === viewTitle)
if (view) {
router.replace({
params: {
viewTitle: view.id,
},
})
}
}
} else if (lastOpenedView) {
/** if active view is not found, set it to last opened view */
router.replace({
params: {
viewTitle: lastOpenedView.id,
},
})
} else {
if (nextViews?.length && activeView.value !== nextViews[0]) {
activeView.value = nextViews[0]
}
}
/** if active view is not found, set it to first view */
if (nextViews?.length && (!activeView.value || !nextViews.includes(activeView.value))) {
activeView.value = nextViews[0]
}
},
{ immediate: true },
)
/** Open delete modal */
function onOpenModal({
title = '',
type,
copyViewId,
groupingFieldColumnId,
}: {
title?: string
type: ViewTypes
copyViewId?: string
groupingFieldColumnId?: string
}) {
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgViewCreate'), {
'modelValue': isOpen,
title,
type,
meta,
'selectedViewId': copyViewId,
groupingFieldColumnId,
'views': views,
'onUpdate:modelValue': closeDialog,
'onCreated': async (view: ViewType) => {
closeDialog()
refreshCommandPalette()
await loadViews()
router.push({ params: { viewTitle: view.id || '' } })
$e('a:view:create', { view: view.type })
},
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
</script>
<template>
<div class="relative nc-view-sidebar flex flex-col border-l-1 border-gray-200 relative h-full w-full bg-white">
<template v-if="isViewsLoading">
<a-skeleton-input :active="true" class="!h-8 !rounded overflow-hidden ml-3 mr-3 mt-3.75 mb-3.75" />
</template>
<div
v-else
ref="tabBtnsContainerRef"
class="flex flex-row group py-1 mx-3.25 mt-1.25 mb-2.75 rounded-md gap-x-2 nc-view-sidebar-tab items-center justify-between"
>
<div class="flex text-gray-600 ml-1.75">Views</div>
<NcTooltip
placement="bottomLeft"
hide-on-click
class="flex opacity-0 group-hover:(opacity-100) transition-all duration-50"
:class="{
'!w-8 !opacity-100': !isRightSidebarOpen,
}"
>
<template #title>
{{
isRightSidebarOpen
? `${$t('general.hide')} ${$t('objects.sidebar').toLowerCase()}`
: `${$t('general.show')} ${$t('objects.sidebar').toLowerCase()}`
}}
</template>
<NcButton
type="text"
size="small"
class="nc-sidebar-left-toggle-icon !text-gray-600 !hover:text-gray-800"
@click="isRightSidebarOpen = !isRightSidebarOpen"
>
<div class="flex items-center text-inherit">
<GeneralIcon
icon="doubleRightArrow"
class="duration-150 transition-all"
:class="{
'transform rotate-180': !isRightSidebarOpen,
}"
/>
</div>
</NcButton>
</NcTooltip>
</div>
<div class="flex-1 flex flex-col min-h-0">
<div class="flex flex-col h-full justify-between w-full">
<div class="flex flex-grow nc-scrollbar-md pr-1.75 mr-0.5">
<div v-if="isViewsLoading" class="flex flex-col w-full">
<div class="flex flex-row items-center w-full mt-1.5 ml-5 gap-x-3">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
<a-skeleton-input :active="true" class="!w-1/2 !h-4 !rounded overflow-hidden" />
</div>
<div class="flex flex-row items-center w-full mt-4 ml-5 gap-x-3">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
<a-skeleton-input :active="true" class="!w-1/2 !h-4 !rounded overflow-hidden" />
</div>
<div class="flex flex-row items-center w-full mt-4 ml-5 gap-x-3">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
<a-skeleton-input :active="true" class="!w-1/2 !h-4 !rounded overflow-hidden" />
</div>
</div>
<LazySmartsheetSidebarMenuTop v-else :views="views" @open-modal="onOpenModal" @deleted="loadViews" />
</div>
<div v-if="isUIAllowed('viewCreateOrEdit')" class="flex flex-col">
<div class="!mb-3 w-full border-b-1 border-gray-200" />
<div v-if="!activeTable" class="flex flex-col pt-2 pb-5 px-6">
<a-skeleton-input :active="true" class="!w-3/5 !h-4 !rounded overflow-hidden" />
<div class="flex flex-row justify-between items-center w-full mt-4.75">
<div class="flex flex-row items-center flex-grow gap-x-3">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
<a-skeleton-input :active="true" class="!w-3/5 !h-4 !rounded overflow-hidden" />
</div>
<div class="flex">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full mt-3.75">
<div class="flex flex-row items-center flex-grow gap-x-3">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
<a-skeleton-input :active="true" class="!w-3/5 !h-4 !rounded overflow-hidden" />
</div>
<div class="flex">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full mt-3.75">
<div class="flex flex-row items-center flex-grow gap-x-3">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
<a-skeleton-input :active="true" class="!w-3/5 !h-4 !rounded overflow-hidden" />
</div>
<div class="flex">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full mt-3.75">
<div class="flex flex-row items-center flex-grow gap-x-3">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
<a-skeleton-input :active="true" class="!w-3/5 !h-4 !rounded overflow-hidden" />
</div>
<div class="flex">
<a-skeleton-input :active="true" class="!w-4 !h-4 !rounded overflow-hidden" />
</div>
</div>
</div>
<LazySmartsheetSidebarMenuBottom v-else @open-modal="onOpenModal" />
</div>
</div>
</div>
</div>
</template>
<style scoped>
:deep(.ant-menu-title-content) {
@apply w-full;
}
:deep(.ant-layout-sider-children) {
@apply flex flex-col;
}
.tab {
@apply flex flex-row items-center h-7.5 justify-center w-1/2 py-1 bg-gray-100 rounded-md gap-x-1.5 text-gray-500 hover:text-black cursor-pointer transition-all duration-300 select-none;
}
.tab-icon {
@apply transition-all duration-300;
}
.tab .tab-title {
@apply min-w-0;
word-break: 'keep-all';
white-space: 'nowrap';
display: 'inline';
}
.active {
@apply bg-white shadow text-gray-700;
}
</style>

10
packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue

@ -100,14 +100,4 @@ if (!localValue.value && allowEmpty !== true) {
.ant-select-selection-search-input { .ant-select-selection-search-input {
box-shadow: none !important; box-shadow: none !important;
} }
::-webkit-scrollbar {
-webkit-appearance: none;
width: 7px;
}
::-webkit-scrollbar-thumb {
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.5);
box-shadow: 0 0 1px rgba(255, 255, 255, 0.5);
}
</style> </style>

19
packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue

@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ViewTypes } from 'nocodb-sdk' import { ViewTypes } from 'nocodb-sdk'
import { ActiveViewInj, inject } from '#imports'
const selectedView = inject(ActiveViewInj) const { openedViewsTab, activeView } = storeToRefs(useViewsStore())
const { openedViewsTab } = storeToRefs(useViewsStore())
const { activeTable } = storeToRefs(useTablesStore()) const { activeTable } = storeToRefs(useTablesStore())
</script> </script>
@ -12,32 +10,33 @@ const { activeTable } = storeToRefs(useTablesStore())
<div <div
class="flex flex-row font-medium items-center border-gray-50 mt-0.5" class="flex flex-row font-medium items-center border-gray-50 mt-0.5"
:class="{ :class="{
'min-w-2/5 max-w-2/5': selectedView?.type !== ViewTypes.KANBAN, 'min-w-2/5 max-w-2/5': activeView?.type !== ViewTypes.KANBAN,
'min-w-1/4 max-w-1/4': selectedView?.type === ViewTypes.KANBAN, 'min-w-1/4 max-w-1/4': activeView?.type === ViewTypes.KANBAN,
}" }"
> >
<LazyGeneralEmojiPicker :emoji="activeTable?.meta?.icon" readonly size="xsmall"> <LazyGeneralEmojiPicker :emoji="activeTable?.meta?.icon" readonly size="xsmall">
<template #default> <template #default>
<MdiTable class="min-w-5 !text-gray-500" :class="{}" /> <MdiTable class="min-w-5 !text-gray-500" />
</template> </template>
</LazyGeneralEmojiPicker> </LazyGeneralEmojiPicker>
<span <span
class="text-ellipsis overflow-hidden pl-1 text-gray-500 max-w-1/2" class="text-ellipsis overflow-hidden pl-1 max-w-1/2 text-gray-500"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }" :style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
> >
{{ activeTable?.title }} {{ activeTable?.title }}
</span> </span>
<div class="px-2 text-gray-300">/</div> <div class="px-2 text-gray-300">/</div>
<LazyGeneralEmojiPicker :emoji="selectedView?.meta?.icon" readonly size="xsmall"> <LazyGeneralEmojiPicker :emoji="activeView?.meta?.icon" readonly size="xsmall">
<template #default> <template #default>
<GeneralViewIcon :meta="{ type: selectedView?.type }" class="min-w-4.5 text-lg flex" /> <GeneralViewIcon :meta="{ type: activeView?.type }" class="min-w-4.5 text-lg flex" />
</template> </template>
</LazyGeneralEmojiPicker> </LazyGeneralEmojiPicker>
<span class="truncate pl-1.25 text-gray-700 max-w-28/100"> <span class="truncate pl-1.25 text-gray-700 max-w-28/100">
{{ selectedView?.title }} {{ activeView?.is_default ? $t('title.defaultView') : activeView?.title }}
</span> </span>
<LazySmartsheetToolbarReload v-if="openedViewsTab === 'view'" /> <LazySmartsheetToolbarReload v-if="openedViewsTab === 'view'" />
</div> </div>
</template> </template>

11
packages/nc-gui/components/tabs/Smartsheet.vue

@ -160,8 +160,7 @@ const onDrop = async (event: DragEvent) => {
<template> <template>
<div class="nc-container flex flex-col h-full" @drop="onDrop" @dragover.prevent> <div class="nc-container flex flex-col h-full" @drop="onDrop" @dragover.prevent>
<LazySmartsheetTopbar /> <LazySmartsheetTopbar />
<TabsSmartsheetResizable style="height: calc(100% - var(--topbar-height))"> <div style="height: calc(100% - var(--topbar-height))">
<template #content>
<div v-if="openedViewsTab === 'view'" class="flex flex-col h-full flex-1 min-w-0"> <div v-if="openedViewsTab === 'view'" class="flex flex-col h-full flex-1 min-w-0">
<LazySmartsheetToolbar v-if="!isForm" /> <LazySmartsheetToolbar v-if="!isForm" />
<div class="flex flex-row w-full" style="height: calc(100% - var(--topbar-height))"> <div class="flex flex-row w-full" style="height: calc(100% - var(--topbar-height))">
@ -185,13 +184,7 @@ const onDrop = async (event: DragEvent) => {
</div> </div>
</div> </div>
<SmartsheetDetails v-else /> <SmartsheetDetails v-else />
</template> </div>
<template #sidebar>
<template v-if="!isPublic">
<LazySmartsheetSidebar />
</template>
</template>
</TabsSmartsheetResizable>
<LazySmartsheetExpandedFormDetached /> <LazySmartsheetExpandedFormDetached />
</div> </div>

209
packages/nc-gui/components/tabs/SmartsheetResizable.vue

@ -1,209 +0,0 @@
<script lang="ts" setup>
import { Pane, Splitpanes } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'
const {
isRightSidebarOpen,
isLeftSidebarOpen,
leftSidebarWidthPercent,
rightSidebarSize: sideBarSize,
} = storeToRefs(useSidebarStore())
const wrapperRef = ref<HTMLDivElement>()
const splitpaneWrapperRef = ref()
const { rightSidebarState: sidebarState } = storeToRefs(useSidebarStore())
const contentSize = computed(() => 100 - sideBarSize.value.current)
const animationDuration = 250
const contentDomWidth = ref(window.innerWidth)
const sidebarWidth = computed(() => (sideBarSize.value.old * contentDomWidth.value) / 100)
const currentSidebarSize = computed({
get: () => sideBarSize.value.current,
set: (val) => {
sideBarSize.value.current = val
sideBarSize.value.old = val
},
})
watch(isRightSidebarOpen, () => {
sideBarSize.value.current = sideBarSize.value.old
if (isRightSidebarOpen.value) {
setTimeout(() => (sidebarState.value = 'openStart'), 0)
setTimeout(() => (sidebarState.value = 'openEnd'), animationDuration)
} else {
sideBarSize.value.old = sideBarSize.value.current
sidebarState.value = 'hiddenStart'
setTimeout(() => {
sideBarSize.value.current = 0
sidebarState.value = 'hiddenEnd'
}, animationDuration)
}
})
function handleMouseMove(e: MouseEvent) {
if (!wrapperRef.value) return
if (sidebarState.value === 'openEnd') return
const viewportWidth = window.innerWidth
if (e.clientX > viewportWidth - 14 && ['hiddenEnd', 'peekCloseEnd'].includes(sidebarState.value)) {
sidebarState.value = 'peekOpenStart'
setTimeout(() => {
sidebarState.value = 'peekOpenEnd'
}, animationDuration)
} else if (e.clientX < viewportWidth - (sidebarWidth.value + 10) && sidebarState.value === 'peekOpenEnd') {
sidebarState.value = 'peekCloseOpen'
setTimeout(() => {
sidebarState.value = 'peekCloseEnd'
}, animationDuration)
}
}
function onWindowResize() {
contentDomWidth.value = ((100 - leftSidebarWidthPercent.value) / 100) * window.innerWidth
}
onMounted(() => {
document.addEventListener('mousemove', handleMouseMove)
window.addEventListener('resize', onWindowResize)
})
onBeforeUnmount(() => {
document.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('resize', onWindowResize)
})
watch(
[isLeftSidebarOpen, leftSidebarWidthPercent],
() => {
if (isLeftSidebarOpen.value) {
contentDomWidth.value = ((100 - leftSidebarWidthPercent.value) / 100) * window.innerWidth
} else {
contentDomWidth.value = window.innerWidth
}
},
{
immediate: true,
},
)
</script>
<template>
<Splitpanes
ref="splitpaneWrapperRef"
class="nc-view-sidebar-content-resizable-wrapper w-full h-full"
:class="{
'hide-resize-bar': !isRightSidebarOpen || sidebarState === 'openStart',
}"
@resize="currentSidebarSize = $event[1].size"
>
<Pane :size="contentSize">
<slot name="content" />
</Pane>
<Pane min-size="15%" :size="currentSidebarSize" max-size="40%" class="nc-view-sidebar-splitpane relative !overflow-visible">
<div
ref="wrapperRef"
class="nc-view-sidebar-wrapper relative flex flex-col h-full justify-center !min-w-32 absolute overflow-visible"
:class="{
'minimized-height': !isRightSidebarOpen,
'peek-sidebar': ['peekOpenEnd', 'peekCloseOpen'].includes(sidebarState),
'hide-sidebar': ['hiddenStart', 'hiddenEnd', 'peekCloseEnd'].includes(sidebarState),
}"
:style="{
width: sidebarState === 'hiddenEnd' ? '0px' : `${sidebarWidth}px`,
}"
>
<slot name="sidebar" />
</div>
</Pane>
</Splitpanes>
</template>
<style lang="scss">
.nc-view-sidebar-wrapper.minimized-height > * {
@apply pb-1 !(rounded-l-lg border-1 border-gray-200 shadow-lg);
height: 89.5%;
}
.nc-view-sidebar-wrapper > * {
transition: all 0.15s ease-in-out;
@apply z-10 absolute;
}
.nc-view-sidebar-wrapper.peek-sidebar {
> * {
@apply !opacity-100;
transform: translateX(-100%);
}
}
.nc-view-sidebar-wrapper.hide-sidebar {
@apply !min-w-0;
> * {
@apply opacity-0;
transform: translateX(100%);
}
}
/** Split pane CSS */
.nc-view-sidebar-content-resizable-wrapper > {
.splitpanes__splitter {
width: 0 !important;
position: relative;
overflow: visible;
}
.splitpanes__splitter:before {
@apply bg-transparent;
width: 1px;
content: '';
position: absolute;
left: -2px;
top: 0;
height: 100%;
z-index: 40;
}
.splitpanes__splitter:hover:before {
@apply bg-scrollbar;
z-index: 40;
width: 4px !important;
left: -2px;
}
.splitpanes--dragging .splitpanes__splitter:before {
@apply bg-scrollbar;
z-index: 40;
width: 10px !important;
left: -2px;
}
}
.splitpanes--dragging > .splitpanes__splitter::before {
@apply w-1 mr-0 bg-scrollbar;
z-index: 40;
width: 4px !important;
left: -2px;
}
.splitpanes--dragging {
cursor: col-resize;
}
.nc-view-sidebar-content-resizable-wrapper.hide-resize-bar > {
.splitpanes__splitter {
display: none !important;
background-color: transparent !important;
}
}
</style>

17
packages/nc-gui/composables/useViewData.ts

@ -32,11 +32,16 @@ const formatData = (list: Record<string, any>[]) =>
})) }))
export function useViewData( export function useViewData(
meta: Ref<TableType | undefined> | ComputedRef<TableType | undefined>, _meta: Ref<TableType | undefined> | ComputedRef<TableType | undefined>,
viewMeta: Ref<ViewType | undefined> | ComputedRef<(ViewType & { id: string }) | undefined>, viewMeta: Ref<ViewType | undefined> | ComputedRef<(ViewType & { id: string }) | undefined>,
where?: ComputedRef<string | undefined>, where?: ComputedRef<string | undefined>,
) { ) {
if (!meta) { const { activeTableId, activeTable } = storeToRefs(useTablesStore())
const meta = computed(() => _meta.value || activeTable.value)
const metaId = computed(() => _meta.value?.id || activeTableId.value)
if (!meta.value) {
throw new Error('Table meta is not available') throw new Error('Table meta is not available')
} }
@ -101,7 +106,7 @@ export function useViewData(
const { count } = await $api.dbViewRow.count( const { count } = await $api.dbViewRow.count(
NOCO, NOCO,
project?.value?.id as string, project?.value?.id as string,
meta?.value?.id as string, metaId.value as string,
viewMeta?.value?.id as string, viewMeta?.value?.id as string,
) )
paginationData.value.totalRows = count paginationData.value.totalRows = count
@ -150,7 +155,7 @@ export function useViewData(
aggCommentCount.value = await $api.utils.commentCount({ aggCommentCount.value = await $api.utils.commentCount({
ids, ids,
fk_model_id: meta.value?.id as string, fk_model_id: metaId.value as string,
}) })
for (const row of formattedData.value) { for (const row of formattedData.value) {
@ -160,9 +165,9 @@ export function useViewData(
} }
async function loadData(params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) { async function loadData(params: Parameters<Api<any>['dbViewRow']['list']>[4] = {}) {
if ((!project?.value?.id || !meta.value?.id || !viewMeta.value?.id) && !isPublic.value) return if ((!project?.value?.id || !metaId.value || !viewMeta.value?.id) && !isPublic.value) return
const response = !isPublic.value const response = !isPublic.value
? await api.dbViewRow.list('noco', project.value.id!, meta.value!.id!, viewMeta.value!.id!, { ? await api.dbViewRow.list('noco', project.value.id!, metaId.value!, viewMeta.value!.id!, {
...queryParams.value, ...queryParams.value,
...params, ...params,
...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }), ...(isUIAllowed('sortSync') ? {} : { sortArrJson: JSON.stringify(sorts.value) }),

1
packages/nc-gui/context/index.ts

@ -43,6 +43,7 @@ export const ProjectStarredModeInj: InjectionKey<Ref<boolean>> = Symbol('project
export const ProjectInj: InjectionKey<Ref<NcProject>> = Symbol('project-injection') export const ProjectInj: InjectionKey<Ref<NcProject>> = Symbol('project-injection')
export const ProjectIdInj: InjectionKey<Ref<string>> = Symbol('project-id-injection') export const ProjectIdInj: InjectionKey<Ref<string>> = Symbol('project-id-injection')
export const EditColumnInj: InjectionKey<Ref<boolean>> = Symbol('edit-column-injection') export const EditColumnInj: InjectionKey<Ref<boolean>> = Symbol('edit-column-injection')
export const SidebarTableInj: InjectionKey<Ref<TableType>> = Symbol('sidebar-table-injection')
export const TreeViewInj: InjectionKey<{ export const TreeViewInj: InjectionKey<{
setMenuContext: (type: 'project' | 'base' | 'table' | 'main' | 'layout', value?: any) => void setMenuContext: (type: 'project' | 'base' | 'table' | 'main' | 'layout', value?: any) => void
duplicateTable: (table: TableType) => void duplicateTable: (table: TableType) => void

3
packages/nc-gui/lang/en.json

@ -281,7 +281,8 @@
"resetPasswordMenu": "Reset Password", "resetPasswordMenu": "Reset Password",
"tokens": "Tokens", "tokens": "Tokens",
"userManagement": "User Management", "userManagement": "User Management",
"licence": "Licence" "licence": "Licence",
"defaultView": "Default View"
}, },
"labels": { "labels": {
"searchProjects": "Search Projects", "searchProjects": "Search Projects",

1
packages/nc-gui/lib/acl.ts

@ -25,6 +25,7 @@ const rolePermissions = {
projectDelete: true, projectDelete: true,
projectDuplicate: true, projectDuplicate: true,
newUser: true, newUser: true,
viewCreateOrEdit: true,
}, },
}, },
[OrgUserRoles.VIEWER]: { [OrgUserRoles.VIEWER]: {

2
packages/nc-gui/pages/index/[typeOrId]/[projectId]/index/index/index.vue

@ -112,7 +112,7 @@ function openQuickImportDialog(type: QuickImportTypes, file: File) {
} }
watch( watch(
() => project.value.id, () => project.value?.id,
() => { () => {
if (project.value?.id && project.value.type === 'database') { if (project.value?.id && project.value.type === 'database') {
const { addTab } = useTabs() const { addTab } = useTabs()

4
packages/nc-gui/pages/index/[typeOrId]/view/[viewId].vue

@ -10,7 +10,7 @@ definePageMeta({
const route = useRoute() const route = useRoute()
const { loadSharedView } = useSharedView() const { loadSharedView, meta } = useSharedView()
const { isViewDataLoading } = storeToRefs(useViewsStore()) const { isViewDataLoading } = storeToRefs(useViewsStore())
const showPassword = ref(false) const showPassword = ref(false)
@ -37,5 +37,5 @@ onMounted(async () => {
<LazySharedViewAskPassword v-model="showPassword" /> <LazySharedViewAskPassword v-model="showPassword" />
</div> </div>
<LazySharedViewGrid v-else /> <LazySharedViewGrid v-else-if="meta" />
</template> </template>

4
packages/nc-gui/store/tables.ts

@ -63,14 +63,10 @@ export const useTablesStore = defineStore('tablesStore', () => {
) )
const loadProjectTables = async (projectId: string, force = false) => { const loadProjectTables = async (projectId: string, force = false) => {
const projects = projectsStore.projects
if (!force && projectTables.value.get(projectId)) { if (!force && projectTables.value.get(projectId)) {
return return
} }
const workspaceProject = projects.get(projectId)
if (!workspaceProject) throw new Error('Project not found')
const existingTables = projectTables.value.get(projectId) const existingTables = projectTables.value.get(projectId)
if (existingTables && !force) { if (existingTables && !force) {
return return

70
packages/nc-gui/store/views.ts

@ -1,4 +1,4 @@
import type { ViewType } from 'nocodb-sdk' import { type ViewType } from 'nocodb-sdk'
import { acceptHMRUpdate, defineStore } from 'pinia' import { acceptHMRUpdate, defineStore } from 'pinia'
import type { ViewPageType } from '~/lib' import type { ViewPageType } from '~/lib'
@ -8,7 +8,18 @@ export const useViewsStore = defineStore('viewsStore', () => {
const router = useRouter() const router = useRouter()
const route = router.currentRoute const route = router.currentRoute
const views = ref<ViewType[]>([]) const tablesStore = useTablesStore()
const viewsByTable = ref<Map<string, ViewType[]>>(new Map())
const views = computed({
get: () => (tablesStore.activeTableId ? viewsByTable.value.get(tablesStore.activeTableId) : []) ?? [],
set: (value) => {
if (!tablesStore.activeTableId) return
if (!value) return viewsByTable.value.delete(tablesStore.activeTableId)
viewsByTable.value.set(tablesStore.activeTableId, value)
},
})
const isViewsLoading = ref(true) const isViewsLoading = ref(true)
const isViewDataLoading = ref(true) const isViewDataLoading = ref(true)
const isPublic = computed(() => route.value.meta?.public) const isPublic = computed(() => route.value.meta?.public)
@ -70,16 +81,20 @@ export const useViewsStore = defineStore('viewsStore', () => {
// Used for Grid View Pagination // Used for Grid View Pagination
const isPaginationLoading = ref(false) const isPaginationLoading = ref(false)
const tablesStore = useTablesStore() const loadViews = async ({ tableId, ignoreLoading }: { tableId?: string; ignoreLoading?: boolean } = {}) => {
tableId = tableId ?? tablesStore.activeTableId
if (tableId) {
if (!ignoreLoading) isViewsLoading.value = true
const loadViews = async () => { const response = (await $api.dbView.list(tableId)).list as ViewType[]
if (tablesStore.activeTableId) {
isViewsLoading.value = true
const response = (await $api.dbView.list(tablesStore.activeTableId)).list as ViewType[]
if (response) { if (response) {
views.value = response.sort((a, b) => a.order! - b.order!) viewsByTable.value.set(
tableId,
response.sort((a, b) => a.order! - b.order!),
)
} }
isViewsLoading.value = false
if (!ignoreLoading) isViewsLoading.value = false
} }
} }
@ -121,6 +136,40 @@ export const useViewsStore = defineStore('viewsStore', () => {
const isLockedView = computed(() => activeView.value?.lock_type === 'locked') const isLockedView = computed(() => activeView.value?.lock_type === 'locked')
const navigateToView = async ({
view,
projectId,
tableId,
hardReload,
}: {
view: ViewType
projectId: string
tableId: string
hardReload?: boolean
}) => {
const routeName = 'index-typeOrId-projectId-index-index-viewId-viewTitle'
if (
router.currentRoute.value.query &&
router.currentRoute.value.query.page &&
router.currentRoute.value.query.page === 'fields'
) {
await router.push({
name: routeName,
params: { viewTitle: view.id || '', viewId: tableId, projectId },
query: router.currentRoute.value.query,
})
} else {
await router.push({ name: routeName, params: { viewTitle: view.id || '', viewId: tableId, projectId } })
}
if (hardReload) {
await router.replace({ name: routeName, query: { reload: 'true' }, params: { viewId: tableId, projectId } }).then(() => {
router.replace({ name: routeName, query: {}, params: { viewId: tableId, projectId } })
})
}
}
return { return {
isLockedView, isLockedView,
isViewsLoading, isViewsLoading,
@ -132,6 +181,9 @@ export const useViewsStore = defineStore('viewsStore', () => {
openedViewsTab, openedViewsTab,
onViewsTabChange, onViewsTabChange,
sharedView, sharedView,
viewsByTable,
activeViewTitleOrId,
navigateToView,
} }
}) })

5
packages/nc-gui/utils/baseUtils.ts

@ -0,0 +1,5 @@
import type { BaseType } from 'nocodb-sdk'
const isDefaultBase = (base: BaseType) => base.is_meta
export { isDefaultBase }

2
tests/playwright/pages/Dashboard/Grid/Column/LTAR/LinkRecord.ts

@ -24,7 +24,7 @@ export class LinkRecord extends BasePage {
{ {
const childList = linkRecord.getByTestId(`nc-excluded-list-item`); const childList = linkRecord.getByTestId(`nc-excluded-list-item`);
expect.poll(() => linkRecord.getByTestId(`nc-excluded-list-item`).count()).toBe(cardTitle.length); await expect.poll(() => linkRecord.getByTestId(`nc-excluded-list-item`).count()).toBe(cardTitle.length);
for (let i = 0; i < cardTitle.length; i++) { for (let i = 0; i < cardTitle.length; i++) {
await childList.nth(i).locator('.nc-display-value').scrollIntoViewIfNeeded(); await childList.nth(i).locator('.nc-display-value').scrollIntoViewIfNeeded();
await childList.nth(i).locator('.nc-display-value').waitFor({ state: 'visible' }); await childList.nth(i).locator('.nc-display-value').waitFor({ state: 'visible' });

61
tests/playwright/pages/Dashboard/Sidebar/index.ts

@ -1,5 +1,5 @@
import { expect, Locator } from '@playwright/test'; import { expect, Locator } from '@playwright/test';
import { ProjectTypes } from 'nocodb-sdk'; import { ProjectTypes, ViewTypes } from 'nocodb-sdk';
import { DashboardPage } from '..'; import { DashboardPage } from '..';
import BasePage from '../../Base'; import BasePage from '../../Base';
import { DocsSidebarPage } from './DocsSidebar'; import { DocsSidebarPage } from './DocsSidebar';
@ -59,4 +59,63 @@ export class SidebarPage extends BasePage {
}); });
await this.dashboard.docs.pagesList.waitForOpen({ title }); await this.dashboard.docs.pagesList.waitForOpen({ title });
} }
async createView({ title, type }: { title: string; type: ViewTypes }) {
const createViewButtonOfActiveProject = this.dashboard
.get()
.locator('.nc-table-node-wrapper[data-active="true"] .nc-create-view-btn');
await createViewButtonOfActiveProject.scrollIntoViewIfNeeded();
await createViewButtonOfActiveProject.click();
// TODO: Find a better way to do it
let createViewTypeButton: Locator;
if (type === ViewTypes.GRID) {
createViewTypeButton = this.rootPage.getByTestId('sidebar-view-create-grid');
} else if (type === ViewTypes.FORM) {
createViewTypeButton = this.rootPage.getByTestId('sidebar-view-create-form');
} else if (type === ViewTypes.KANBAN) {
createViewTypeButton = this.rootPage.getByTestId('sidebar-view-create-kanban');
} else if (type === ViewTypes.GALLERY) {
createViewTypeButton = this.rootPage.getByTestId('sidebar-view-create-gallery');
}
await this.rootPage.waitForTimeout(750);
const allButtons = await createViewTypeButton.all();
for (const btn of allButtons) {
if (await btn.isVisible()) {
createViewTypeButton = btn;
break;
}
}
await createViewTypeButton.click({
force: true,
});
await this.rootPage.locator('input[id="form_item_title"]:visible').waitFor({ state: 'visible' });
await this.rootPage.locator('input[id="form_item_title"]:visible').fill(title);
const submitAction = () =>
this.rootPage.locator('.ant-modal-content').locator('button.ant-btn.ant-btn-primary').click();
await this.waitForResponse({
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: '/api/v1/db/meta/tables/',
uiAction: submitAction,
responseJsonMatcher: json => json.title === title,
});
// Todo: Wait for view to be rendered
await this.rootPage.waitForTimeout(1000);
}
async verifyCreateViewButtonVisibility({ isVisible }: { isVisible: boolean }) {
const createViewButtonOfActiveProject = this.dashboard
.get()
.locator('.nc-table-node-wrapper[data-active="true"] .nc-create-view-btn');
if (isVisible) {
await expect(createViewButtonOfActiveProject).toBeVisible();
} else {
await expect(createViewButtonOfActiveProject).toHaveCount(0);
}
}
} }

24
tests/playwright/pages/Dashboard/TreeView.ts

@ -72,7 +72,7 @@ export class TreeViewPage extends BasePage {
async getTable({ index, tableTitle }: { index: number; tableTitle?: string }) { async getTable({ index, tableTitle }: { index: number; tableTitle?: string }) {
if (tableTitle) { if (tableTitle) {
return this.get().getByTestId(`tree-view-table-${tableTitle}`); return this.get().getByTestId(`nc-tbl-side-node-${tableTitle}`);
} }
return this.get().locator('.nc-tree-item').nth(index); return this.get().locator('.nc-tree-item').nth(index);
@ -95,18 +95,26 @@ export class TreeViewPage extends BasePage {
await this.rootPage.locator('.h-full > div > .nc-sidebar-left-toggle-icon').click(); await this.rootPage.locator('.h-full > div > .nc-sidebar-left-toggle-icon').click();
} }
await this.get().getByTestId(`tree-view-table-${title}`).waitFor({ state: 'visible' }); await this.get().getByTestId(`nc-tbl-title-${title}`).waitFor({ state: 'visible' });
if (networkResponse === true) { if (networkResponse === true) {
await this.waitForResponse({ await this.waitForResponse({
uiAction: () => this.get().getByTestId(`tree-view-table-${title}`).click(), uiAction: () =>
this.get()
.getByTestId(`nc-tbl-title-${title}`)
.click({
position: {
x: 10,
y: 10,
},
}),
httpMethodsToMatch: ['GET'], httpMethodsToMatch: ['GET'],
requestUrlPathToMatch: `/api/v1/db/data/noco/`, requestUrlPathToMatch: `/api/v1/db/data/noco/`,
responseJsonMatcher: json => json.pageInfo, responseJsonMatcher: json => json.pageInfo,
}); });
await this.dashboard.waitForTabRender({ title, mode }); await this.dashboard.waitForTabRender({ title, mode });
} else { } else {
await this.get().getByTestId(`tree-view-table-${title}`).click(); await this.get().getByTestId(`nc-tbl-title-${title}`).click();
await this.rootPage.waitForTimeout(1000); await this.rootPage.waitForTimeout(1000);
} }
} }
@ -148,13 +156,13 @@ export class TreeViewPage extends BasePage {
async verifyTable({ title, index, exists = true }: { title: string; index?: number; exists?: boolean }) { async verifyTable({ title, index, exists = true }: { title: string; index?: number; exists?: boolean }) {
if (exists) { if (exists) {
await expect(this.get().getByTestId(`tree-view-table-${title}`)).toHaveCount(1); await expect(this.get().getByTestId(`nc-tbl-title-${title}`)).toHaveCount(1);
if (index) { if (index) {
await expect(this.get().locator('.nc-tbl-title').nth(index)).toHaveText(title); await expect(this.get().locator('.nc-tbl-title').nth(index)).toHaveText(title);
} }
} else { } else {
await expect(this.get().getByTestId(`tree-view-table-${title}`)).toHaveCount(0); await expect(this.get().getByTestId(`nc-tbl-title-${title}`)).toHaveCount(0);
} }
} }
@ -206,7 +214,7 @@ export class TreeViewPage extends BasePage {
await this.dashboard await this.dashboard
.get() .get()
.locator(`[data-testid="tree-view-table-draggable-handle-${sourceTable}"]`) .locator(`[data-testid="tree-view-table-draggable-handle-${sourceTable}"]`)
.dragTo(this.get().locator(`[data-testid="tree-view-table-${destinationTable}"]`)); .dragTo(this.get().locator(`[data-testid="nc-tbl-title-${destinationTable}"]`));
} }
async projectSettings({ title }: { title?: string }) { async projectSettings({ title }: { title?: string }) {
@ -261,7 +269,7 @@ export class TreeViewPage extends BasePage {
httpMethodsToMatch: ['POST'], httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: `/api/v1/db/meta/duplicate/`, requestUrlPathToMatch: `/api/v1/db/meta/duplicate/`,
}); });
await this.get().locator(`[data-testid="tree-view-table-${title} copy"]`).waitFor(); await this.get().locator(`[data-testid="nc-tbl-title-${title} copy"]`).waitFor();
} }
async verifyTabIcon({ title, icon, iconDisplay }: { title: string; icon: string; iconDisplay?: string }) { async verifyTabIcon({ title, icon, iconDisplay }: { title: string; icon: string; iconDisplay?: string }) {

51
tests/playwright/pages/Dashboard/ViewSidebar/index.ts

@ -1,6 +1,7 @@
import { expect, Locator } from '@playwright/test'; import { expect, Locator } from '@playwright/test';
import { DashboardPage } from '..'; import { DashboardPage } from '..';
import BasePage from '../../Base'; import BasePage from '../../Base';
import { ViewTypes } from 'nocodb-sdk';
export class ViewSidebarPage extends BasePage { export class ViewSidebarPage extends BasePage {
readonly project: any; readonly project: any;
@ -31,7 +32,7 @@ export class ViewSidebarPage extends BasePage {
} }
get() { get() {
return this.dashboard.get().locator('.nc-view-sidebar'); return this.dashboard.get().locator('.nc-table-node-wrapper[data-active="true"]');
} }
async isVisible() { async isVisible() {
@ -53,34 +54,22 @@ export class ViewSidebarPage extends BasePage {
await this.rootPage.goto(this.rootPage.url()); await this.rootPage.goto(this.rootPage.url());
} }
private async createView({ title, locator }: { title: string; locator: Locator }) { private async createView({ title, type }: { title: string; type: ViewTypes }) {
await this.rootPage.waitForTimeout(1000);
await locator.click();
await this.rootPage.locator('input[id="form_item_title"]:visible').waitFor({ state: 'visible' });
await this.rootPage.locator('input[id="form_item_title"]:visible').fill(title);
const submitAction = () =>
this.rootPage.locator('.ant-modal-content').locator('button.ant-btn.ant-btn-primary').click({ force: true });
await this.waitForResponse({
httpMethodsToMatch: ['POST'],
requestUrlPathToMatch: '/api/v1/db/meta/tables/',
uiAction: submitAction,
responseJsonMatcher: json => json.title === title,
});
await this.verifyToast({ message: 'View created successfully' });
// Todo: Wait for view to be rendered
await this.rootPage.waitForTimeout(1000); await this.rootPage.waitForTimeout(1000);
await this.dashboard.sidebar.createView({ title, type });
} }
async createGalleryView({ title }: { title: string }) { async createGalleryView({ title }: { title: string }) {
await this.createView({ title, locator: this.createGalleryButton }); await this.createView({ title, type: ViewTypes.GALLERY });
} }
async createGridView({ title }: { title: string }) { async createGridView({ title }: { title: string }) {
await this.createView({ title, locator: this.createGridButton }); await this.createView({ title, type: ViewTypes.GRID });
} }
async createFormView({ title }: { title: string }) { async createFormView({ title }: { title: string }) {
await this.createView({ title, locator: this.createFormButton }); await this.createView({ title, type: ViewTypes.FORM });
} }
async openView({ title }: { title: string }) { async openView({ title }: { title: string }) {
@ -90,13 +79,13 @@ export class ViewSidebarPage extends BasePage {
} }
async createKanbanView({ title }: { title: string }) { async createKanbanView({ title }: { title: string }) {
await this.createView({ title, locator: this.createKanbanButton }); await this.createView({ title, type: ViewTypes.KANBAN });
await this.rootPage.waitForTimeout(1500); await this.rootPage.waitForTimeout(1500);
} }
async createMapView({ title }: { title: string }) { async createMapView({ title }: { title: string }) {
await this.createView({ title, locator: this.createMapButton }); await this.createView({ title, type: ViewTypes.MAP });
} }
// Todo: Make selection better // Todo: Make selection better
@ -135,13 +124,15 @@ export class ViewSidebarPage extends BasePage {
await this.get().locator(`[data-testid="view-sidebar-view-${title}"]`).hover(); await this.get().locator(`[data-testid="view-sidebar-view-${title}"]`).hover();
await this.get() await this.get()
.locator(`[data-testid="view-sidebar-view-${title}"]`) .locator(`[data-testid="view-sidebar-view-${title}"]`)
.locator('.nc-view-sidebar-node-context-btn') .locator('.nc-sidebar-view-node-context-btn')
.click(); .click();
await this.rootPage await this.rootPage
.locator(`[data-testid="view-sidebar-view-actions-${title}"]`) .locator(`[data-testid="view-sidebar-view-actions-${title}"]`)
.locator('.nc-view-delete-icon') .locator('.nc-view-delete-icon')
.click(); .click({
force: true,
});
await this.rootPage.locator('button:has-text("Delete View"):visible').click(); await this.rootPage.locator('button:has-text("Delete View"):visible').click();
} }
@ -157,13 +148,15 @@ export class ViewSidebarPage extends BasePage {
await this.get().locator(`[data-testid="view-sidebar-view-${title}"]`).hover(); await this.get().locator(`[data-testid="view-sidebar-view-${title}"]`).hover();
await this.get() await this.get()
.locator(`[data-testid="view-sidebar-view-${title}"]`) .locator(`[data-testid="view-sidebar-view-${title}"]`)
.locator('.nc-view-sidebar-node-context-btn') .locator('.nc-sidebar-view-node-context-btn')
.click(); .click();
await this.rootPage await this.rootPage
.locator(`[data-testid="view-sidebar-view-actions-${title}"]`) .locator(`[data-testid="view-sidebar-view-actions-${title}"]`)
.locator('.nc-view-copy-icon') .locator('.nc-view-copy-icon')
.click(); .click({
force: true,
});
const submitAction = () => const submitAction = () =>
this.rootPage.locator('.ant-modal-content').locator('button:has-text("Create View"):visible').click(); this.rootPage.locator('.ant-modal-content').locator('button:has-text("Create View"):visible').click();
await this.waitForResponse({ await this.waitForResponse({
@ -198,11 +191,9 @@ export class ViewSidebarPage extends BasePage {
} }
async validateRoleAccess(param: { role: string }) { async validateRoleAccess(param: { role: string }) {
const count = param.role.toLowerCase() === 'creator' ? 1 : 0; await this.dashboard.sidebar.verifyCreateViewButtonVisibility({
await expect(this.createGridButton).toHaveCount(count); isVisible: param.role.toLowerCase() === 'creator',
await expect(this.createGalleryButton).toHaveCount(count); });
await expect(this.createFormButton).toHaveCount(count);
await expect(this.createKanbanButton).toHaveCount(count);
// await this.openDeveloperTab({}); // await this.openDeveloperTab({});
// await expect(this.erdButton).toHaveCount(1); // await expect(this.erdButton).toHaveCount(1);

2
tests/playwright/pages/Dashboard/common/Cell/index.ts

@ -341,7 +341,7 @@ export class CellPageObject extends BasePage {
await this.rootPage.waitForSelector('.nc-modal-child-list:visible'); await this.rootPage.waitForSelector('.nc-modal-child-list:visible');
// verify child list count & contents // verify child list count & contents
expect.poll(() => this.rootPage.locator('.ant-card:visible').count()).toBe(count); await expect.poll(() => this.rootPage.locator('.ant-card:visible').count()).toBe(count);
// close child list // close child list
await this.rootPage.locator('.nc-modal-child-list').locator('.nc-close-btn').last().click(); await this.rootPage.locator('.nc-modal-child-list').locator('.nc-close-btn').last().click();

26
tests/playwright/pages/Dashboard/common/Topbar/Share.ts

@ -51,7 +51,16 @@ export class TopbarSharePage extends BasePage {
} }
async clickShareBasePublicAccess() { async clickShareBasePublicAccess() {
await this.get().locator(`[data-testid="nc-share-base-sub-modal"]`).locator('.ant-switch').nth(0).click(); await this.get()
.locator(`[data-testid="nc-share-base-sub-modal"]`)
.locator('.ant-switch')
.nth(0)
.click({
position: {
x: 4,
y: 4,
},
});
} }
async isSharedBasePublicAccessEnabled() { async isSharedBasePublicAccessEnabled() {
@ -63,14 +72,25 @@ export class TopbarSharePage extends BasePage {
} }
async clickShareBaseEditorAccess() { async clickShareBaseEditorAccess() {
await this.get().locator(`[data-testid="nc-share-base-sub-modal"]`).locator('.ant-switch').nth(1).click(); await this.rootPage.waitForTimeout(1000);
const shareBaseSwitch = this.get().locator(`[data-testid="nc-share-base-sub-modal"]`).locator('.ant-switch');
const count = await shareBaseSwitch.count();
await this.get()
.locator(`[data-testid="nc-share-base-sub-modal"]`)
.locator('.ant-switch')
.nth(count - 1)
.click({
position: { x: 4, y: 4 },
});
} }
async isSharedBaseEditorAccessEnabled() { async isSharedBaseEditorAccessEnabled() {
return await this.get() return await this.get()
.locator(`[data-testid="nc-share-base-sub-modal"]`) .locator(`[data-testid="nc-share-base-sub-modal"]`)
.locator('.ant-switch') .locator('.ant-switch')
.nth(1) .nth(0)
.isChecked(); .isChecked();
} }

9
tests/playwright/pages/Dashboard/common/Topbar/index.ts

@ -54,12 +54,13 @@ export class TopbarPage extends BasePage {
return await this.getClipboardText(); return await this.getClipboardText();
} }
async getSharedBaseUrl({ role }: { role: string }) { async getSharedBaseUrl({ role, enableSharedBase }: { role: string; enableSharedBase: boolean }) {
await this.clickShare(); await this.clickShare();
if (!(await this.share.isSharedBasePublicAccessEnabled())) await this.share.clickShareBasePublicAccess(); if (enableSharedBase) await this.share.clickShareBasePublicAccess();
if (role === 'editor' && !(await this.share.isSharedBaseEditorAccessEnabled())) {
if (role === 'editor' && enableSharedBase) {
await this.share.clickShareBaseEditorAccess(); await this.share.clickShareBaseEditorAccess();
} else if (role === 'viewer' && (await this.share.isSharedBaseEditorAccessEnabled())) { } else if (role === 'viewer' && !enableSharedBase) {
await this.share.clickShareBaseEditorAccess(); await this.share.clickShareBaseEditorAccess();
} }
await this.share.clickCopyLink(); await this.share.clickCopyLink();

4
tests/playwright/pages/Dashboard/index.ts

@ -191,6 +191,10 @@ export class DashboardPage extends BasePage {
await this.sidebar.userMenu.click(); await this.sidebar.userMenu.click();
await this.rootPage.getByTestId('nc-sidebar-user-logout').waitFor({ state: 'visible' }); await this.rootPage.getByTestId('nc-sidebar-user-logout').waitFor({ state: 'visible' });
await this.sidebar.userMenu.clickLogout(); await this.sidebar.userMenu.clickLogout();
// TODO: Remove this
await this.rootPage.reload();
await this.rootPage.locator('[data-testid="nc-form-signin"]:visible').waitFor(); await this.rootPage.locator('[data-testid="nc-form-signin"]:visible').waitFor();
await new Promise(resolve => setTimeout(resolve, 150)); await new Promise(resolve => setTimeout(resolve, 150));
} }

2
tests/playwright/pages/SharedForm/index.ts

@ -58,7 +58,7 @@ export class SharedFormPage extends BasePage {
{ {
const childList = linkRecord.locator(`.ant-card`); const childList = linkRecord.locator(`.ant-card`);
expect.poll(() => linkRecord.locator(`.ant-card`).count()).toBe(cardTitle.length); await expect.poll(() => linkRecord.locator(`.ant-card`).count()).toBe(cardTitle.length);
for (let i = 0; i < cardTitle.length; i++) { for (let i = 0; i < cardTitle.length; i++) {
expect(await childList.nth(i).textContent()).toContain(cardTitle[i]); expect(await childList.nth(i).textContent()).toContain(cardTitle[i]);
} }

2
tests/playwright/tests/db/columns/columnAttachments.spec.ts

@ -51,7 +51,7 @@ test.describe('Attachment column', () => {
}); });
await dashboard.rootPage.waitForTimeout(500); await dashboard.rootPage.waitForTimeout(500);
const sharedFormUrl = await dashboard.form.topbar.getSharedViewUrl(); const sharedFormUrl = await dashboard.form.topbar.getSharedViewUrl();
await dashboard.viewSidebar.openView({ title: 'Country' }); await dashboard.treeView.openTable({ title: 'Country' });
// Verify attachment in shared form // Verify attachment in shared form
const newPage = await context.newPage(); const newPage = await context.newPage();

4
tests/playwright/tests/db/features/baseShare.spec.ts

@ -66,7 +66,7 @@ test.describe('Shared base', () => {
let url = ''; let url = '';
// share button visible only if a table is opened // share button visible only if a table is opened
await dashboard.treeView.openTable({ title: 'Country' }); await dashboard.treeView.openTable({ title: 'Country' });
url = await dashboard.grid.topbar.getSharedBaseUrl({ role: 'editor' }); url = await dashboard.grid.topbar.getSharedBaseUrl({ role: 'editor', enableSharedBase: true });
await dashboard.rootPage.waitForTimeout(2000); await dashboard.rootPage.waitForTimeout(2000);
// access shared base link // access shared base link
@ -85,7 +85,7 @@ test.describe('Shared base', () => {
// await dashboard.treeView.openProject({ title: context.project.title }); // await dashboard.treeView.openProject({ title: context.project.title });
await dashboard.treeView.openTable({ title: 'Country' }); await dashboard.treeView.openTable({ title: 'Country' });
url = await dashboard.grid.topbar.getSharedBaseUrl({ role: 'viewer' }); url = await dashboard.grid.topbar.getSharedBaseUrl({ role: 'viewer', enableSharedBase: false });
await dashboard.rootPage.waitForTimeout(2000); await dashboard.rootPage.waitForTimeout(2000);
// access shared base link // access shared base link

4
tests/playwright/tests/db/features/undo-redo.spec.ts

@ -457,10 +457,10 @@ test.describe('Undo Redo - Table & view rename operations', () => {
break; break;
} }
await dashboard.viewSidebar.renameView({ title: viewTypes[i], newTitle: 'newNameForTest' }); await dashboard.viewSidebar.renameView({ title: viewTypes[i], newTitle: 'newNameForTest' });
await dashboard.viewSidebar.verifyView({ title: 'newNameForTest', index: 1 }); await dashboard.viewSidebar.verifyView({ title: 'newNameForTest', index: 0 });
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
await undo({ page, dashboard }); await undo({ page, dashboard });
await dashboard.viewSidebar.verifyView({ title: viewTypes[i], index: 1 }); await dashboard.viewSidebar.verifyView({ title: viewTypes[i], index: 0 });
await dashboard.viewSidebar.deleteView({ title: viewTypes[i] }); await dashboard.viewSidebar.deleteView({ title: viewTypes[i] });
} }
}); });

8
tests/playwright/tests/db/general/toolbarOperations.spec.ts

@ -512,8 +512,9 @@ test.describe('Toolbar operations (GRID)', () => {
await toolbar.clickGroupBy(); await toolbar.clickGroupBy();
await dashboard.viewSidebar.createGridView({ title: 'Test' }); await dashboard.viewSidebar.createGridView({ title: 'Test' });
await dashboard.viewSidebar.openView({ title: 'Test' }); await dashboard.rootPage.waitForTimeout(500);
await dashboard.viewSidebar.openView({ title: 'Film' });
await dashboard.treeView.openTable({ title: 'Film' });
await dashboard.grid.groupPage.verifyGroupHeader({ await dashboard.grid.groupPage.verifyGroupHeader({
indexMap: [0], indexMap: [0],
@ -529,13 +530,14 @@ test.describe('Toolbar operations (GRID)', () => {
test('Duplicate View and Verify GroupBy', async () => { test('Duplicate View and Verify GroupBy', async () => {
await dashboard.treeView.openTable({ title: 'Film' }); await dashboard.treeView.openTable({ title: 'Film' });
await dashboard.viewSidebar.createGridView({ title: 'Film Grid' });
// Open GroupBy Menu // Open GroupBy Menu
await toolbar.clickGroupBy(); await toolbar.clickGroupBy();
await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false }); await toolbar.groupBy.add({ title: 'Length', ascending: false, locallySaved: false });
await toolbar.clickGroupBy(); await toolbar.clickGroupBy();
await dashboard.viewSidebar.copyView({ title: 'Film' }); await dashboard.viewSidebar.copyView({ title: 'Film Grid' });
await dashboard.grid.groupPage.verifyGroupHeader({ await dashboard.grid.groupPage.verifyGroupHeader({
indexMap: [0], indexMap: [0],

20
tests/playwright/tests/db/general/views.spec.ts

@ -21,29 +21,29 @@ test.describe('Views CRUD Operations', () => {
test('Create views, reorder and delete', async () => { test('Create views, reorder and delete', async () => {
await dashboard.treeView.openTable({ title: 'City' }); await dashboard.treeView.openTable({ title: 'City' });
await dashboard.viewSidebar.createGridView({ title: 'CityGrid' }); await dashboard.viewSidebar.createGridView({ title: 'CityGrid' });
await dashboard.viewSidebar.verifyView({ title: 'CityGrid', index: 1 }); await dashboard.viewSidebar.verifyView({ title: 'CityGrid', index: 0 });
await dashboard.viewSidebar.renameView({ await dashboard.viewSidebar.renameView({
title: 'CityGrid', title: 'CityGrid',
newTitle: 'CityGrid2', newTitle: 'CityGrid2',
}); });
await dashboard.viewSidebar.verifyView({ await dashboard.viewSidebar.verifyView({
title: 'CityGrid2', title: 'CityGrid2',
index: 1, index: 0,
}); });
await dashboard.viewSidebar.createFormView({ title: 'CityForm' }); await dashboard.viewSidebar.createFormView({ title: 'CityForm' });
await dashboard.viewSidebar.verifyView({ title: 'CityForm', index: 2 }); await dashboard.viewSidebar.verifyView({ title: 'CityForm', index: 1 });
await dashboard.viewSidebar.renameView({ await dashboard.viewSidebar.renameView({
title: 'CityForm', title: 'CityForm',
newTitle: 'CityForm2', newTitle: 'CityForm2',
}); });
await dashboard.viewSidebar.verifyView({ await dashboard.viewSidebar.verifyView({
title: 'CityForm2', title: 'CityForm2',
index: 2, index: 1,
}); });
await dashboard.viewSidebar.createGalleryView({ title: 'CityGallery' }); await dashboard.viewSidebar.createGalleryView({ title: 'CityGallery' });
await dashboard.viewSidebar.verifyView({ title: 'CityGallery', index: 3 }); await dashboard.viewSidebar.verifyView({ title: 'CityGallery', index: 2 });
await dashboard.viewSidebar.renameView({ await dashboard.viewSidebar.renameView({
title: 'CityGallery', title: 'CityGallery',
newTitle: 'CityGallery2', newTitle: 'CityGallery2',
@ -51,7 +51,7 @@ test.describe('Views CRUD Operations', () => {
await dashboard.viewSidebar.verifyView({ await dashboard.viewSidebar.verifyView({
title: 'CityGallery2', title: 'CityGallery2',
index: 3, index: 2,
}); });
await dashboard.viewSidebar.changeViewIcon({ await dashboard.viewSidebar.changeViewIcon({
@ -77,14 +77,14 @@ test.describe('Views CRUD Operations', () => {
await dashboard.viewSidebar.deleteView({ title: 'CityForm2' }); await dashboard.viewSidebar.deleteView({ title: 'CityForm2' });
await dashboard.viewSidebar.verifyViewNotPresent({ await dashboard.viewSidebar.verifyViewNotPresent({
title: 'CityForm2', title: 'CityForm2',
index: 2, index: 1,
}); });
// fix index after enabling reorder test // fix index after enabling reorder test
await dashboard.viewSidebar.deleteView({ title: 'CityGallery2' }); await dashboard.viewSidebar.deleteView({ title: 'CityGallery2' });
await dashboard.viewSidebar.verifyViewNotPresent({ await dashboard.viewSidebar.verifyViewNotPresent({
title: 'CityGallery2', title: 'CityGallery2',
index: 1, index: 0,
}); });
}); });
@ -109,7 +109,7 @@ test.describe('Views CRUD Operations', () => {
await dashboard.rootPage.waitForTimeout(1000); await dashboard.rootPage.waitForTimeout(1000);
await toolbar.searchData.verify('City-CityGrid'); await toolbar.searchData.verify('City-CityGrid');
await dashboard.viewSidebar.openView({ title: 'City' }); await dashboard.treeView.openTable({ title: 'City' });
await dashboard.rootPage.waitForTimeout(1000); await dashboard.rootPage.waitForTimeout(1000);
await toolbar.searchData.verify('City-City'); await toolbar.searchData.verify('City-City');
@ -121,7 +121,7 @@ test.describe('Views CRUD Operations', () => {
await toolbar.searchData.get().fill('Actor-ActorGrid'); await toolbar.searchData.get().fill('Actor-ActorGrid');
await toolbar.searchData.verify('Actor-ActorGrid'); await toolbar.searchData.verify('Actor-ActorGrid');
await dashboard.viewSidebar.openView({ title: 'Actor' }); await dashboard.treeView.openTable({ title: 'Actor' });
await dashboard.rootPage.waitForTimeout(1000); await dashboard.rootPage.waitForTimeout(1000);
await toolbar.searchData.verify(''); await toolbar.searchData.verify('');

7
tests/playwright/tests/db/views/viewForm.spec.ts

@ -31,7 +31,7 @@ test.describe('Form view', () => {
await dashboard.treeView.openTable({ title: 'Country' }); await dashboard.treeView.openTable({ title: 'Country' });
await dashboard.viewSidebar.createFormView({ title: 'CountryForm' }); await dashboard.viewSidebar.createFormView({ title: 'CountryForm' });
await dashboard.viewSidebar.verifyView({ title: 'CountryForm', index: 1 }); await dashboard.viewSidebar.verifyView({ title: 'CountryForm', index: 0 });
// verify form-view fields order // verify form-view fields order
await form.verifyFormViewFieldsOrder({ await form.verifyFormViewFieldsOrder({
@ -92,7 +92,7 @@ test.describe('Form view', () => {
await dashboard.treeView.openTable({ title: 'Country' }); await dashboard.treeView.openTable({ title: 'Country' });
await dashboard.viewSidebar.createFormView({ title: 'CountryForm' }); await dashboard.viewSidebar.createFormView({ title: 'CountryForm' });
await dashboard.viewSidebar.verifyView({ title: 'CountryForm', index: 1 }); await dashboard.viewSidebar.verifyView({ title: 'CountryForm', index: 0 });
await form.configureHeader({ await form.configureHeader({
title: 'Country', title: 'Country',
@ -385,7 +385,6 @@ test.describe('Form view with LTAR', () => {
await dashboard.rootPage.waitForTimeout(500); await dashboard.rootPage.waitForTimeout(500);
await dashboard.treeView.openTable({ title: 'Country' }); await dashboard.treeView.openTable({ title: 'Country' });
await dashboard.viewSidebar.openView({ title: 'Country' });
await dashboard.grid.cell.verify({ await dashboard.grid.cell.verify({
index: 3, index: 3,
@ -492,7 +491,7 @@ test.describe('Form view', () => {
// kludge- reload // kludge- reload
await dashboard.rootPage.reload(); await dashboard.rootPage.reload();
await dashboard.viewSidebar.openView({ title: 'selectBased' }); await dashboard.treeView.openTable({ title: 'selectBased' });
await dashboard.rootPage.waitForTimeout(2000); await dashboard.rootPage.waitForTimeout(2000);

8
tests/playwright/tests/db/views/viewKanban.spec.ts

@ -61,7 +61,7 @@ test.describe('View', () => {
}); });
await dashboard.viewSidebar.verifyView({ await dashboard.viewSidebar.verifyView({
title: 'Film Kanban', title: 'Film Kanban',
index: 1, index: 0,
}); });
// configure stack-by field // configure stack-by field
@ -202,7 +202,7 @@ test.describe('View', () => {
}); });
await dashboard.viewSidebar.verifyView({ await dashboard.viewSidebar.verifyView({
title: 'Film Kanban', title: 'Film Kanban',
index: 1, index: 0,
}); });
await toolbar.sort.add({ await toolbar.sort.add({
@ -227,7 +227,7 @@ test.describe('View', () => {
await dashboard.viewSidebar.copyView({ title: 'Film Kanban' }); await dashboard.viewSidebar.copyView({ title: 'Film Kanban' });
await dashboard.viewSidebar.verifyView({ await dashboard.viewSidebar.verifyView({
title: 'Untitled Kanban', title: 'Untitled Kanban',
index: 2, index: 1,
}); });
const kanban = dashboard.kanban; const kanban = dashboard.kanban;
await kanban.verifyStackCount({ count: 6 }); await kanban.verifyStackCount({ count: 6 });
@ -328,7 +328,7 @@ test.describe('View', () => {
}); });
await dashboard.viewSidebar.verifyView({ await dashboard.viewSidebar.verifyView({
title: 'Film Kanban', title: 'Film Kanban',
index: 1, index: 0,
}); });
// Share view // Share view

Loading…
Cancel
Save