Browse Source

Merge remote-tracking branch 'origin/develop' into fix/sidebar-node-context-menu-acl-fix

pull/6438/head
Muhammed Mustafa 1 year ago
parent
commit
ff5d3d51b5
  1. 12
      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. 6
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  5. 8
      packages/nc-gui/components/dashboard/TreeView/TableList.vue
  6. 281
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  7. 195
      packages/nc-gui/components/dashboard/TreeView/ViewsList.vue
  8. 127
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  9. 6
      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/dlg/share-and-collaborate/ManageUsers.vue
  13. 4
      packages/nc-gui/components/dlg/share-and-collaborate/View.vue
  14. 2
      packages/nc-gui/components/general/DeleteModal.vue
  15. 2
      packages/nc-gui/components/general/Modal.vue
  16. 7
      packages/nc-gui/components/nc/Badge.vue
  17. 9
      packages/nc-gui/components/nc/Select.vue
  18. 6
      packages/nc-gui/components/project/AccessSettings.vue
  19. 6
      packages/nc-gui/components/project/AllTables.vue
  20. 183
      packages/nc-gui/components/project/InviteProjectCollabSection.vue
  21. 2
      packages/nc-gui/components/project/View.vue
  22. 13
      packages/nc-gui/components/roles/Badge.vue
  23. 13
      packages/nc-gui/components/roles/Selector.vue
  24. 14
      packages/nc-gui/components/smartsheet/grid/GroupBy.vue
  25. 151
      packages/nc-gui/components/smartsheet/sidebar/MenuBottom.vue
  26. 281
      packages/nc-gui/components/smartsheet/sidebar/index.vue
  27. 10
      packages/nc-gui/components/smartsheet/toolbar/FieldListAutoCompleteDropdown.vue
  28. 19
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  29. 45
      packages/nc-gui/components/tabs/Smartsheet.vue
  30. 209
      packages/nc-gui/components/tabs/SmartsheetResizable.vue
  31. 32
      packages/nc-gui/components/workspace/CollaboratorsList.vue
  32. 210
      packages/nc-gui/components/workspace/InviteSection.vue
  33. 2
      packages/nc-gui/components/workspace/View.vue
  34. 17
      packages/nc-gui/composables/useViewData.ts
  35. 1
      packages/nc-gui/context/index.ts
  36. 3
      packages/nc-gui/lang/en.json
  37. 1
      packages/nc-gui/lib/acl.ts
  38. 2
      packages/nc-gui/pages/index/[typeOrId]/[projectId]/index/index/index.vue
  39. 4
      packages/nc-gui/pages/index/[typeOrId]/view/[viewId].vue
  40. 4
      packages/nc-gui/store/tables.ts
  41. 70
      packages/nc-gui/store/views.ts
  42. 5
      packages/nc-gui/utils/baseUtils.ts
  43. 1
      packages/nc-gui/utils/iconUtils.ts
  44. 2
      tests/playwright/pages/Dashboard/BulkUpdate/index.ts
  45. 61
      tests/playwright/pages/Dashboard/Sidebar/index.ts
  46. 24
      tests/playwright/pages/Dashboard/TreeView.ts
  47. 51
      tests/playwright/pages/Dashboard/ViewSidebar/index.ts
  48. 26
      tests/playwright/pages/Dashboard/common/Topbar/Share.ts
  49. 9
      tests/playwright/pages/Dashboard/common/Topbar/index.ts
  50. 4
      tests/playwright/pages/Dashboard/index.ts
  51. 3
      tests/playwright/pages/WorkspacePage/CollaborationPage.ts
  52. 2
      tests/playwright/pages/WorkspacePage/ContainerPage.ts
  53. 2
      tests/playwright/tests/db/columns/columnAttachments.spec.ts
  54. 4
      tests/playwright/tests/db/features/baseShare.spec.ts
  55. 4
      tests/playwright/tests/db/features/undo-redo.spec.ts
  56. 8
      tests/playwright/tests/db/general/toolbarOperations.spec.ts
  57. 20
      tests/playwright/tests/db/general/views.spec.ts
  58. 7
      tests/playwright/tests/db/views/viewForm.spec.ts
  59. 8
      tests/playwright/tests/db/views/viewKanban.spec.ts

12
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;
} }
} }

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>

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

@ -195,11 +195,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)

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>

281
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,80 +81,128 @@ 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
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`"> class="table-context flex items-center gap-1 h-full"
<div :data-testid="`nc-tbl-side-node-${table.title}`"
class="flex items-center nc-table-icon" @contextmenu="setMenuContext('table', table)"
:class="{ @click="openTable(table)"
'pointer-events-none': !canUserEditEmote, >
}" <div class="flex flex-row h-full items-center">
@click.stop <NcButton type="text" size="xxsmall" class="nc-sidebar-node-btn" @click.stop="onExpand">
> <GeneralIcon
<LazyGeneralEmojiPicker icon="triangleFill"
:key="table.meta?.icon" 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-"
:emoji="table.meta?.icon" :class="{ '!rotate-180': isExpanded }"
size="small" />
:readonly="!canUserEditEmote" </NcButton>
@emoji-selected="setIcon($event, table)" <div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<div
class="flex items-center nc-table-icon"
:class="{
'pointer-events-none': !canUserEditEmote,
}"
@click.stop
> >
<template #default> <LazyGeneralEmojiPicker
<NcTooltip class="flex" placement="topLeft" hide-on-click :disabled="!canUserEditEmote"> :key="table.meta?.icon"
<template #title> :emoji="table.meta?.icon"
{{ 'Change icon' }} size="small"
</template> :readonly="!canUserEditEmote"
@emoji-selected="setIcon($event, table)"
<MdiTable >
v-if="table.type === 'table'" <template #default>
class="flex w-5 !text-gray-500 text-sm" <NcTooltip class="flex" placement="topLeft" hide-on-click :disabled="!canUserEditEmote">
:class="{ <template #title>
'group-hover:text-gray-500': isUIAllowed('tableSort', { roles: projectRole }), {{ 'Change icon' }}
'!text-black': openedTableId === table.id, </template>
}"
/> <MdiTable
<MdiEye v-if="table.type === 'table'"
v-else class="flex w-5 !text-gray-500 text-sm"
class="flex w-5 !text-gray-500 text-sm" :class="{
:class="{ 'group-hover:text-gray-500': isUIAllowed('tableSort', { roles: projectRole }),
'group-hover:text-gray-500': isUIAllowed('tableSort', { roles: projectRole }), '!text-black': openedTableId === table.id,
'!text-black': openedTableId === table.id, }"
}" />
/> <MdiEye
</NcTooltip> v-else
</template> class="flex w-5 !text-gray-500 text-sm"
</LazyGeneralEmojiPicker> :class="{
'group-hover:text-gray-500': isUIAllowed('tableSort', { roles: projectRole }),
'!text-black': openedTableId === table.id,
}"
/>
</NcTooltip>
</template>
</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,59 +211,62 @@ const canUserEditEmote = computed(() => {
</span> </span>
<div class="flex flex-grow h-full"></div> <div class="flex flex-grow h-full"></div>
<NcDropdown <div class="flex flex-row items-center">
v-if=" <NcDropdown
!isSharedBase && v-if="
(isUIAllowed('tableRename', { roles: projectRole }) || isUIAllowed('tableDelete', { roles: projectRole })) !isSharedBase &&
" (isUIAllowed('tableRename', { roles: projectRole }) || isUIAllowed('tableDelete', { roles: projectRole }))
:trigger="['click']" "
@click.stop :trigger="['click']"
> class="nc-sidebar-node-btn"
<MdiDotsHorizontal @click.stop
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)" >
data-testid="nc-sidebar-table-context-menu" <MdiDotsHorizontal
:class="{ 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)"
'!text-gray-600': openedTableId !== table.id, />
'!text-black': openedTableId === table.id,
}" <template #overlay>
/> <NcMenu>
<NcMenuItem
<template #overlay> v-if="isUIAllowed('tableRename', { roles: projectRole })"
<NcMenu :data-testid="`nc-sidebar-table-${table.title}-options`"> :data-testid="`sidebar-table-rename-${table.title}`"
<NcMenuItem @click="openRenameTableDialog(table, project.bases[baseIndex].id)"
v-if="isUIAllowed('tableRename', { roles: projectRole })" >
:data-testid="`sidebar-table-rename-${table.title}`" <GeneralIcon icon="edit" class="text-gray-700" />
@click="openRenameTableDialog(table, project.bases[baseIndex].id)" {{ $t('general.rename') }}
> </NcMenuItem>
<GeneralIcon icon="edit" class="text-gray-700" />
{{ $t('general.rename') }} <NcMenuItem
</NcMenuItem> v-if="
isUIAllowed('tableDuplicate') &&
<NcMenuItem project.bases?.[baseIndex] &&
v-if=" (project.bases[baseIndex].is_meta || project.bases[baseIndex].is_local)
isUIAllowed('tableDuplicate') && "
project.bases?.[baseIndex] && :data-testid="`sidebar-table-duplicate-${table.title}`"
(project.bases[baseIndex].is_meta || project.bases[baseIndex].is_local) @click="duplicateTable(table)"
" >
:data-testid="`sidebar-table-duplicate-${table.title}`" <GeneralIcon icon="duplicate" class="text-gray-700" />
@click="duplicateTable(table)" {{ $t('general.duplicate') }}
> </NcMenuItem>
<GeneralIcon icon="duplicate" class="text-gray-700" />
{{ $t('general.duplicate') }} <NcMenuItem
</NcMenuItem> v-if="isUIAllowed('tableDelete', { roles: projectRole })"
:data-testid="`sidebar-table-delete-${table.title}`"
<NcMenuItem class="!text-red-500 !hover:bg-red-50"
v-if="isUIAllowed('tableDelete', { roles: projectRole })" @click="isTableDeleteDialogVisible = true"
:data-testid="`sidebar-table-delete-${table.title}`" >
class="!text-red-500 !hover:bg-red-50" <GeneralIcon icon="delete" />
@click="isTableDeleteDialogVisible = true" {{ $t('general.delete') }}
> </NcMenuItem>
<GeneralIcon icon="delete" /> </NcMenu>
{{ $t('general.delete') }} </template>
</NcMenuItem> </NcDropdown>
</NcMenu> <DashboardTreeViewCreateViewBtn v-if="isUIAllowed('viewCreateOrEdit')">
</template> <NcButton type="text" size="xxsmall" class="nc-create-view-btn nc-sidebar-node-btn">
</NcDropdown> <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"
@ -216,20 +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.active { .nc-tree-item svg {
@apply !bg-primary-selected rounded-md; @apply text-primary text-opacity-60;
//@apply border-r-3 border-primary;
svg {
@apply !text-opacity-100;
}
} }
</style> </style>

195
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()
refreshCommandPalette()
await loadViews()
navigateToView({
view,
tableId: table.value.id!,
projectId: project.value.id!,
hardReload: view.type === ViewTypes.FORM && selected.value[0] === view.id,
})
$e('a:view:create', { view: view.type })
},
})
function closeDialog() {
isOpen.value = false
close(1000)
} }
} }
watch(
() => activeView.value?.id,
() => {
if (!activeView.value?.id) return
// TODO: Find a better way to scroll to the view node
setTimeout(() => {
scrollViewNode()
}, 800)
},
{
immediate: true,
},
)
</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>

127
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,
}" }"
@click.stop="isDropdownOpen = !isDropdownOpen"
> >
<NcButton <GeneralIcon icon="threeDotHorizontal" class="text-xl w-4.75" />
type="text" </NcButton>
size="xsmall"
class="nc-view-sidebar-node-context-btn !px-1 !hover:bg-gray-200"
@click.stop="isDropdownOpen = !isDropdownOpen"
>
<GeneralIcon icon="threeDotVertical" class="-mt-0.5" />
</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}`" <GeneralIcon icon="edit" />
> <div class="-ml-0.25">Rename</div>
<NcButton </NcMenuItem>
type="text" <NcMenuItem @click.stop="onDuplicate">
size="small" <GeneralIcon icon="duplicate" class="nc-view-copy-icon" />
class="w-full !rounded-none !hover:bg-gray-200" Duplicate
:centered="false" </NcMenuItem>
@click.stop="onDblClick"
> <NcDivider />
<div class="flex flex-row items-center gap-x-2 pl-2 text-xs">
<GeneralIcon icon="edit" />
Rename
</div>
</NcButton>
<NcButton
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
</div>
</NcButton>
<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>

6
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', block: 'nearest' })
activeTableDom?.scrollIntoView({ behavior: 'smooth' })
}
} }
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/dlg/share-and-collaborate/ManageUsers.vue

@ -72,7 +72,7 @@ const rolesTypes = [
<template> <template>
<div class="flex flex-col mx-4 h-112"> <div class="flex flex-col mx-4 h-112">
<div class="flex mt-2.5 mb-2.5 border-b-1 border-gray-50 pb-1.5" :style="{ fontWeight: 500 }">Manage Collaborators</div> <div class="flex mt-2.5 mb-2.5 border-b-1 border-gray-50 pb-1.5" :style="{ fontWeight: 500 }">Manage Members</div>
<div class="flex mt-2.5 mb-2.5 text-xs" :style="{ fontWeight: 500 }">Project Owner</div> <div class="flex mt-2.5 mb-2.5 text-xs" :style="{ fontWeight: 500 }">Project Owner</div>
<div v-if="owner" class="flex flex-row px-2 py-2 items-center gap-x-2 border-1 border-gray-100 rounded-md"> <div v-if="owner" class="flex flex-row px-2 py-2 items-center gap-x-2 border-1 border-gray-100 rounded-md">
<a-avatar></a-avatar> <a-avatar></a-avatar>

4
packages/nc-gui/components/dlg/share-and-collaborate/View.vue

@ -95,13 +95,13 @@ watch(showShareModal, (val) => {
:width="formStatus === 'manageCollaborators' ? '60rem' : '40rem'" :width="formStatus === 'manageCollaborators' ? '60rem' : '40rem'"
> >
<div v-if="formStatus === 'project-collaborateSaving'" class="flex flex-row w-full px-5 justify-between items-center py-1"> <div v-if="formStatus === 'project-collaborateSaving'" class="flex flex-row w-full px-5 justify-between items-center py-1">
<div class="flex text-base" :style="{ fontWeight: 500 }">Adding Collaborators</div> <div class="flex text-base" :style="{ fontWeight: 500 }">Adding Members</div>
<a-spin :indicator="indicator" /> <a-spin :indicator="indicator" />
</div> </div>
<template v-else-if="formStatus === 'project-collaborateSaved'"> <template v-else-if="formStatus === 'project-collaborateSaved'">
<div class="flex flex-col py-1.5"> <div class="flex flex-col py-1.5">
<div class="flex flex-row w-full px-5 justify-between items-center py-0.5"> <div class="flex flex-row w-full px-5 justify-between items-center py-0.5">
<div class="flex text-base" :style="{ fontWeight: 500 }">Collaborators added</div> <div class="flex text-base font-medium">Members added</div>
<div class="flex"> <div class="flex">
<MdiCheck /> <MdiCheck />
</div> </div>

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 }}

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

@ -66,7 +66,7 @@ const visible = useVModel(props, 'visible', emits)
:destroy-on-close="destroyOnClose" :destroy-on-close="destroyOnClose"
@keydown.esc="visible = false" @keydown.esc="visible = false"
> >
<div class="nc-modal max-h-[{{ height }}]"> <div :class="`nc-modal max-h-[${height}]`">
<slot /> <slot />
</div> </div>
</a-modal> </a-modal>

7
packages/nc-gui/components/nc/Badge.vue

@ -3,16 +3,18 @@ const props = withDefaults(
defineProps<{ defineProps<{
color: string color: string
border?: boolean border?: boolean
size?: 'sm' | 'md' | 'lg'
}>(), }>(),
{ {
border: true, border: true,
size: 'sm',
}, },
) )
</script> </script>
<template> <template>
<div <div
class="h-6 rounded-md px-1" class="rounded-md px-1 flex items-center"
:class="{ :class="{
'border-purple-500 bg-purple-100': props.color === 'purple', 'border-purple-500 bg-purple-100': props.color === 'purple',
'border-blue-500 bg-blue-100': props.color === 'blue', 'border-blue-500 bg-blue-100': props.color === 'blue',
@ -22,6 +24,9 @@ const props = withDefaults(
'border-red-500 bg-red-100': props.color === 'red', 'border-red-500 bg-red-100': props.color === 'red',
'border-gray-300': !props.color, 'border-gray-300': !props.color,
'border-1': props.border, 'border-1': props.border,
'!h-6': props.size === 'sm',
'!h-8': props.size === 'md',
'!h-10': props.size === 'lg',
}" }"
> >
<slot /> <slot />

9
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>
@ -50,6 +56,7 @@ const onChange = (value: string) => {
<style lang="scss"> <style lang="scss">
.nc-select.ant-select { .nc-select.ant-select {
height: fit-content;
.ant-select-selector { .ant-select-selector {
box-shadow: 0px 5px 3px -2px rgba(0, 0, 0, 0.02), 0px 3px 1px -2px rgba(0, 0, 0, 0.06); box-shadow: 0px 5px 3px -2px rgba(0, 0, 0, 0.02), 0px 3px 1px -2px rgba(0, 0, 0, 0.06);
@apply border-1 border-gray-200 !rounded-lg; @apply border-1 border-gray-200 !rounded-lg;

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

@ -155,14 +155,12 @@ onMounted(async () => {
<template> <template>
<div class="nc-collaborator-table-container mt-4 nc-access-settings-view"> <div class="nc-collaborator-table-container mt-4 nc-access-settings-view">
<ProjectInviteProjectCollabSection @invited="reloadCollabs" />
<div v-if="isLoading" class="nc-collaborators-list items-center justify-center"> <div v-if="isLoading" class="nc-collaborators-list items-center justify-center">
<GeneralLoader size="xlarge" /> <GeneralLoader size="xlarge" />
</div> </div>
<template v-else> <template v-else>
<div class="w-full flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2"> <div class="w-full flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2">
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md" placeholder="Search collaborators"> <a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md" placeholder="Search members">
<template #prefix> <template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" /> <PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template> </template>
@ -176,7 +174,7 @@ onMounted(async () => {
v-else-if="!collaborators?.length" v-else-if="!collaborators?.length"
class="nc-collaborators-list w-full h-full flex flex-col items-center justify-center mt-36" class="nc-collaborators-list w-full h-full flex flex-col items-center justify-center mt-36"
> >
<a-empty description="No collaborators found" /> <Empty description="No members found" />
</div> </div>
<div v-else class="nc-collaborators-list nc-scrollbar-md"> <div v-else class="nc-collaborators-list nc-scrollbar-md">
<div class="nc-collaborators-list-header"> <div class="nc-collaborators-list-header">

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)

183
packages/nc-gui/components/project/InviteProjectCollabSection.vue

@ -9,6 +9,78 @@ const inviteData = reactive({
roles: ProjectRoles.VIEWER, roles: ProjectRoles.VIEWER,
}) })
const focusRef = ref<HTMLInputElement>()
const isDivFocused = ref(false)
const divRef = ref<HTMLDivElement>()
const emailValidation = reactive({
isError: false,
message: '',
})
const validateEmail = (email: string): boolean => {
const regEx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return regEx.test(email)
}
// all user input emails are stored here
const emailBadges = ref<Array<string>>([])
const insertOrUpdateString = (str: string) => {
// Check if the string already exists in the array
const index = emailBadges.value.indexOf(str)
if (index !== -1) {
// If the string exists, remove it
emailBadges.value.splice(index, 1)
}
// Add the new string to the array
emailBadges.value.push(str)
}
watch(inviteData, (newVal) => {
const isNewEmail = newVal.email.charAt(newVal.email.length - 1) === ',' || newVal.email.charAt(newVal.email.length - 1) === ' '
if (isNewEmail && newVal.email.trim().length > 1) {
const emailToAdd = newVal.email.split(',')[0].trim() || newVal.email.split(' ')[0].trim()
if (!validateEmail(emailToAdd)) {
emailValidation.isError = true
emailValidation.message = 'INVALID EMAIL'
return
}
/**
if email is already enterd we delete the already
existing email and add new one
**/
if (emailBadges.value.includes(emailToAdd)) {
insertOrUpdateString(emailToAdd)
inviteData.email = ''
return
}
emailBadges.value.push(emailToAdd)
inviteData.email = ''
}
if (newVal.email.length < 1 && emailValidation.isError) {
emailValidation.isError = false
}
})
const handleEnter = () => {
if (inviteData.email.length < 1) {
emailValidation.isError = true
emailValidation.message = 'EMAIL SHOULD NOT BE EMPTY'
return
}
if (!validateEmail(inviteData.email.trim())) {
emailValidation.isError = true
emailValidation.message = 'INVALID EMAIL'
return
}
inviteData.email += ' '
emailValidation.isError = false
emailValidation.message = ''
}
const { dashboardUrl } = useDashboard() const { dashboardUrl } = useDashboard()
const { inviteUser } = useManageUsers() const { inviteUser } = useManageUsers()
@ -31,18 +103,28 @@ const inviteCollaborator = async () => {
isInvitingCollaborators.value = true isInvitingCollaborators.value = true
try { try {
emailBadges.value.forEach((el, index) => {
// prevent the last email from getting the ","
if (index === emailBadges.value.length - 1) {
inviteData.email += el
} else {
inviteData.email += `${el},`
}
})
usersData.value = await inviteUser(inviteData) usersData.value = await inviteUser(inviteData)
usersData.roles = inviteData.roles usersData.roles = inviteData.roles
if (usersData.value) { if (usersData.value) {
message.success('Invitation sent successfully') message.success('Invitation sent successfully')
inviteData.email = '' inviteData.email = ''
emailBadges.value = []
emit('invited') emit('invited')
} }
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} finally {
inviteData.email = ''
isInvitingCollaborators.value = false
} }
isInvitingCollaborators.value = false
} }
const inviteUrl = computed(() => const inviteUrl = computed(() =>
@ -76,11 +158,60 @@ const copyUrl = async () => {
// Copied shareable base url to clipboard! // Copied shareable base url to clipboard!
message.success(t('msg.success.shareableURLCopied')) message.success(t('msg.success.shareableURLCopied'))
inviteData.email = ''
emailBadges.value = []
} catch (e: any) { } catch (e: any) {
message.error(e.message) message.error(e.message)
} }
$e('c:shared-base:copy-url') $e('c:shared-base:copy-url')
} }
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
// remove one email per backspace
onKeyStroke('Backspace', () => {
if (isDivFocused.value && inviteData.email.length < 1) {
emailBadges.value.pop()
}
})
// when bulk email is pasted
const onPaste = (e: ClipboardEvent) => {
const pastedText = e.clipboardData?.getData('text')
const inputArray = pastedText?.split(',') || pastedText?.split(' ')
// if data is pasted to a already existing text in input
// we add existingInput + pasted data
if (inputArray?.length === 1 && inviteData.email.length > 1) {
inputArray[0] = inviteData.email += inputArray[0]
}
inputArray?.forEach((el) => {
if (el.length < 1) {
emailValidation.isError = true
emailValidation.message = 'EMAIL SHOULD NOT BE EMPTY'
return
}
if (!validateEmail(el.trim())) {
emailValidation.isError = true
emailValidation.message = 'INVALID EMAIL'
return
}
/**
if email is already enterd we delete the already
existing email and add new one
**/
if (emailBadges.value.includes(el)) {
insertOrUpdateString(el)
return
}
emailBadges.value.push(el)
inviteData.email = ''
})
inviteData.email = ''
}
</script> </script>
<template> <template>
@ -128,12 +259,44 @@ const copyUrl = async () => {
<div class="text-xl mb-4">Invite</div> <div class="text-xl mb-4">Invite</div>
<a-form> <a-form>
<div class="flex gap-2"> <div class="flex gap-2">
<a-input <div class="flex flex-col">
id="email" <div
v-model:value="inviteData.email" ref="divRef"
placeholder="Enter emails to send invitation" class="flex w-130 border-1 gap-1 items-center min-h-8 flex-wrap max-h-30 overflow-y-scroll rounded-lg nc-scrollbar-md"
class="!max-w-130 !rounded" tabindex="0"
/> :class="{
'border-primary/100': isDivFocused,
'p-1': emailBadges.length > 1,
}"
@click="focusOnDiv"
@blur="isDivFocused = false"
>
<span
v-for="(email, index) in emailBadges"
:key="email"
class="text-[14px] border-1 text-brand-500 bg-brand-50 rounded-md ml-1 p-0.5"
>
{{ email }}
<component
:is="iconMap.close"
class="ml-0.5 hover:cursor-pointer w-3.5 h-3.5"
@click="emailBadges.splice(index, 1)"
/>
</span>
<input
id="email"
ref="focusRef"
v-model="inviteData.email"
:placeholder="emailBadges.length < 1 ? 'Enter emails to send invitation' : ''"
class="min-w-50 !outline-0 !focus:outline-0 ml-2 mr-3"
data-testid="email-input"
@keyup.enter="handleEnter"
@paste.prevent="onPaste"
@blur="isDivFocused = false"
/>
</div>
<span v-if="emailValidation.isError" class="ml-2 text-red-500 text-[12px] mt-1">{{ emailValidation.message }}</span>
</div>
<RolesSelector <RolesSelector
class="px-1" class="px-1"
@ -146,13 +309,13 @@ const copyUrl = async () => {
<a-button <a-button
type="primary" type="primary"
class="!rounded-md" class="!rounded-md"
:disabled="!inviteData.email?.length || isInvitingCollaborators" :disabled="!emailBadges.length || isInvitingCollaborators || emailValidation.isError"
@click="inviteCollaborator" @click="inviteCollaborator"
> >
<div class="flex flex-row items-center gap-x-2 pr-1"> <div class="flex flex-row items-center gap-x-2 pr-1">
<GeneralLoader v-if="isInvitingCollaborators" class="flex" /> <GeneralLoader v-if="isInvitingCollaborators" class="flex" />
<MdiPlus v-else /> <MdiPlus v-else />
{{ isInvitingCollaborators ? 'Adding' : 'Add' }} User/s {{ isInvitingCollaborators ? 'Adding' : 'Add' }} User(s)
</div> </div>
</a-button> </a-button>
</div> </div>

2
packages/nc-gui/components/project/View.vue

@ -101,7 +101,7 @@ watch(
<template #tab> <template #tab>
<div class="tab-title" data-testid="proj-view-tab__access-settings"> <div class="tab-title" data-testid="proj-view-tab__access-settings">
<GeneralIcon icon="users" class="!h-3.5 !w-3.5" /> <GeneralIcon icon="users" class="!h-3.5 !w-3.5" />
<div>Collaborator</div> <div>Members</div>
</div> </div>
</template> </template>
<ProjectAccessSettings /> <ProjectAccessSettings />

13
packages/nc-gui/components/roles/Badge.vue

@ -8,19 +8,22 @@ const props = withDefaults(
clickable?: boolean clickable?: boolean
inherit?: boolean inherit?: boolean
border?: boolean border?: boolean
size?: 'sm' | 'md'
}>(), }>(),
{ {
clickable: false, clickable: false,
inherit: false, inherit: false,
border: true, border: true,
size: 'sm',
}, },
) )
const roleRef = toRef(props, 'role') const roleRef = toRef(props, 'role')
const clickableRef = toRef(props, 'clickable') const clickableRef = toRef(props, 'clickable')
// const inheritRef = toRef(props, 'inherit')
const borderRef = toRef(props, 'border') const borderRef = toRef(props, 'border')
const sizeSelect = computed(() => props.size)
const roleProperties = computed(() => { const roleProperties = computed(() => {
const role = roleRef.value const role = roleRef.value
@ -38,15 +41,14 @@ const roleProperties = computed(() => {
<template> <template>
<div <div
class="flex items-center !border-0" class="flex items-start"
:class="{ :class="{
'cursor-pointer': clickableRef, 'cursor-pointer': clickableRef,
}" }"
style="width: fit-content"
> >
<NcBadge class="!h-auto !px-[8px]" :color="roleProperties.color" :border="borderRef"> <NcBadge class="!px-2" :color="roleProperties.color" :border="borderRef" :size="sizeSelect">
<div <div
class="badge-text flex items-center gap-[4px]" class="badge-text flex items-center gap-2"
:class="{ :class="{
'text-purple-500': roleProperties.color === 'purple', 'text-purple-500': roleProperties.color === 'purple',
'text-blue-500': roleProperties.color === 'blue', 'text-blue-500': roleProperties.color === 'blue',
@ -55,6 +57,7 @@ const roleProperties = computed(() => {
'text-yellow-500': roleProperties.color === 'yellow', 'text-yellow-500': roleProperties.color === 'yellow',
'text-red-500': roleProperties.color === 'red', 'text-red-500': roleProperties.color === 'red',
'text-gray-300': !roleProperties.color, 'text-gray-300': !roleProperties.color,
sizeSelect,
}" }"
> >
<GeneralIcon :icon="roleProperties.icon" /> <GeneralIcon :icon="roleProperties.icon" />

13
packages/nc-gui/components/roles/Selector.vue

@ -10,26 +10,29 @@ const props = withDefaults(
description?: boolean description?: boolean
inherit?: string inherit?: string
onRoleChange: (role: keyof typeof RoleLabels) => void onRoleChange: (role: keyof typeof RoleLabels) => void
size: 'sm' | 'md'
}>(), }>(),
{ {
description: true, description: true,
size: 'sm',
}, },
) )
const roleRef = toRef(props, 'role') const roleRef = toRef(props, 'role')
const inheritRef = toRef(props, 'inherit') const inheritRef = toRef(props, 'inherit')
const descriptionRef = toRef(props, 'description') const descriptionRef = toRef(props, 'description')
const sizeRef = toRef(props, 'size')
</script> </script>
<template> <template>
<NcDropdown> <NcDropdown size="lg" class="nc-roles-selector">
<RolesBadge class="border-1" data-testid="roles" :role="roleRef" :inherit="inheritRef === role" clickable /> <RolesBadge data-testid="roles" :role="roleRef" :inherit="inheritRef === role" clickable :size="sizeRef" />
<template #overlay> <template #overlay>
<div class="nc-role-select-dropdown flex flex-col gap-[4px] p-1"> <div class="nc-role-select-dropdown flex flex-col gap-1 p-2">
<div class="flex flex-col gap-[4px]"> <div class="flex flex-col gap-1">
<div v-for="rl in props.roles" :key="rl" :value="rl" :selected="rl === roleRef" @click="props.onRoleChange(rl)"> <div v-for="rl in props.roles" :key="rl" :value="rl" :selected="rl === roleRef" @click="props.onRoleChange(rl)">
<div <div
class="flex flex-col py-[3px] px-[8px] gap-[4px] bg-transparent cursor-pointer" class="flex flex-col py-1.5 rounded-lg px-2 gap-1 bg-transparent cursor-pointer hover:bg-gray-100"
:class="{ :class="{
'w-[350px]': descriptionRef, 'w-[350px]': descriptionRef,
'w-[200px]': !descriptionRef, 'w-[200px]': !descriptionRef,

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>

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

@ -160,38 +160,31 @@ 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))"> <Transition name="layout" mode="out-in">
<Transition name="layout" mode="out-in"> <template v-if="meta">
<template v-if="meta"> <div class="flex flex-1 min-h-0 w-3/4">
<div class="flex flex-1 min-h-0 w-3/4"> <div v-if="activeView" class="h-full flex-1 min-w-0 min-h-0 bg-white">
<div v-if="activeView" class="h-full flex-1 min-w-0 min-h-0 bg-white"> <LazySmartsheetGrid v-if="isGrid" ref="grid" />
<LazySmartsheetGrid v-if="isGrid" ref="grid" />
<LazySmartsheetGallery v-else-if="isGallery" /> <LazySmartsheetGallery v-else-if="isGallery" />
<LazySmartsheetForm v-else-if="isForm && !$route.query.reload" /> <LazySmartsheetForm v-else-if="isForm && !$route.query.reload" />
<LazySmartsheetKanban v-else-if="isKanban" /> <LazySmartsheetKanban v-else-if="isKanban" />
<LazySmartsheetMap v-else-if="isMap" /> <LazySmartsheetMap v-else-if="isMap" />
</div>
</div> </div>
</template> </div>
</Transition> </template>
</div> </Transition>
</div> </div>
<SmartsheetDetails v-else /> </div>
</template> <SmartsheetDetails v-else />
<template #sidebar> </div>
<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>

32
packages/nc-gui/components/workspace/CollaboratorsList.vue

@ -44,18 +44,18 @@ onMounted(async () => {
<template> <template>
<div class="nc-collaborator-table-container mt-4 mx-6"> <div class="nc-collaborator-table-container mt-4 mx-6">
<WorkspaceInviteSection v-if="workspaceRoles !== WorkspaceUserRoles.VIEWER" /> <!-- <div class="w-full h-1 border-t-1 border-gray-100 opacity-50 mt-6"></div> -->
<div class="w-full h-1 border-t-1 border-gray-100 opacity-50 mt-6"></div> <div class="w-full flex justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2">
<div class="w-full flex flex-row justify-between items-baseline mt-6.5 mb-2 pr-0.25 ml-2"> <div class="text-xl">Invite Members By Email</div>
<div class="text-xl">Collaborators</div> <a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search members">
<a-input v-model:value="userSearchText" class="!max-w-90 !rounded-md mr-4" placeholder="Search collaborators">
<template #prefix> <template #prefix>
<PhMagnifyingGlassBold class="!h-3.5 text-gray-500" /> <PhMagnifyingGlassBold class="!h-3.5 text-gray-500" />
</template> </template>
</a-input> </a-input>
</div> </div>
<WorkspaceInviteSection v-if="workspaceRole !== WorkspaceUserRoles.VIEWER" />
<div v-if="!filterCollaborators?.length" class="w-full h-full flex flex-col items-center justify-center mt-36"> <div v-if="!filterCollaborators?.length" class="w-full h-full flex flex-col items-center justify-center mt-36">
<a-empty description="No collaborators found" /> <Empty description="No members found" />
</div> </div>
<table v-else class="nc-collaborators-list-table !nc-scrollbar-md"> <table v-else class="nc-collaborators-list-table !nc-scrollbar-md">
<thead> <thead>
@ -98,21 +98,19 @@ onMounted(async () => {
</td> </td>
<td class="w-1/5"> <td class="w-1/5">
<div class="-left-2.5 top-5"> <div class="-left-2.5 top-5">
<a-dropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER" :trigger="['click']"> <NcDropdown v-if="collab.roles !== WorkspaceUserRoles.OWNER" :trigger="['click']">
<MdiDotsVertical <MdiDotsVertical
class="h-5.5 w-5.5 rounded outline-0 p-0.5 nc-workspace-menu transform transition-transform !text-gray-400 cursor-pointer hover:(!text-gray-500 bg-gray-100)" class="border-1 !text-gray-600 h-5.5 w-5.5 rounded outline-0 p-0.5 nc-workspace-menu transform transition-transform !text-gray-400 cursor-pointer hover:(!text-gray-500 bg-gray-100)"
/> />
<template #overlay> <template #overlay>
<a-menu> <NcMenu>
<a-menu-item @click="removeCollaborator(collab.id)"> <NcMenuItem class="!text-red-500 !hover:bg-red-50" @click="removeCollaborator(collab.id)">
<div class="flex flex-row items-center py-2 text-s gap-1.5 text-red-500 cursor-pointer"> <MaterialSymbolsDeleteOutlineRounded />
<MaterialSymbolsDeleteOutlineRounded /> Remove user
Remove user </NcMenuItem>
</div> </NcMenu>
</a-menu-item>
</a-menu>
</template> </template>
</a-dropdown> </NcDropdown>
</div> </div>
</td> </td>
<td class="w-1/5 padding"></td> <td class="w-1/5 padding"></td>

210
packages/nc-gui/components/workspace/InviteSection.vue

@ -1,28 +1,106 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onKeyStroke } from '@vueuse/core'
import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk' import { OrderedWorkspaceRoles, WorkspaceUserRoles } from 'nocodb-sdk'
import { extractSdkResponseErrorMsg, useWorkspace } from '#imports' import { extractSdkResponseErrorMsg, useWorkspace } from '#imports'
import { validateEmail } from '~/utils/validation'
const inviteData = reactive({ const inviteData = reactive({
email: '', email: '',
roles: WorkspaceUserRoles.VIEWER, roles: WorkspaceUserRoles.VIEWER,
}) })
const focusRef = ref<HTMLInputElement>()
const isDivFocused = ref(false)
const divRef = ref<HTMLDivElement>()
const emailValidation = reactive({
isError: false,
message: '',
})
const workspaceStore = useWorkspace() const workspaceStore = useWorkspace()
const { inviteCollaborator: _inviteCollaborator } = workspaceStore const { inviteCollaborator: _inviteCollaborator } = workspaceStore
const { isInvitingCollaborators } = storeToRefs(workspaceStore) const { isInvitingCollaborators } = storeToRefs(workspaceStore)
const { workspaceRoles } = useRoles() const { workspaceRoles } = useRoles()
// all user input emails are stored here
const emailBadges = ref<Array<string>>([])
const insertOrUpdateString = (str: string) => {
// Check if the string already exists in the array
const index = emailBadges.value.indexOf(str)
if (index !== -1) {
// If the string exists, remove it
emailBadges.value.splice(index, 1)
}
// Add the new string to the array
emailBadges.value.push(str)
}
const emailInputValidation = (input: string): boolean => {
if (!input.length) {
emailValidation.isError = true
emailValidation.message = 'Email Should Not Be Empty'
return false
}
if (!validateEmail(input.trim())) {
emailValidation.isError = true
emailValidation.message = 'Invalid Email'
return false
}
return true
}
watch(inviteData, (newVal) => {
const isNewEmail = newVal.email.charAt(newVal.email.length - 1) === ',' || newVal.email.charAt(newVal.email.length - 1) === ' '
if (isNewEmail && newVal.email.trim().length) {
const emailToAdd = newVal.email.split(',')[0].trim() || newVal.email.split(' ')[0].trim()
if (!validateEmail(emailToAdd)) {
emailValidation.isError = true
emailValidation.message = 'Invalid Email'
return
}
/**
if email is already enterd we delete the already
existing email and add new one
**/
if (emailBadges.value.includes(emailToAdd)) {
insertOrUpdateString(emailToAdd)
inviteData.email = ''
return
}
emailBadges.value.push(emailToAdd)
inviteData.email = ''
}
if (!newVal.email.length && emailValidation.isError) {
emailValidation.isError = false
}
})
const handleEnter = () => {
const isEmailIsValid = emailInputValidation(inviteData.email)
if (!isEmailIsValid) return
inviteData.email += ' '
emailValidation.isError = false
emailValidation.message = ''
}
const inviteCollaborator = async () => { const inviteCollaborator = async () => {
try { try {
await _inviteCollaborator(inviteData.email, inviteData.roles) const payloadData = emailBadges.value.join(',')
await _inviteCollaborator(payloadData, inviteData.roles)
message.success('Invitation sent successfully') message.success('Invitation sent successfully')
inviteData.email = '' inviteData.email = ''
emailBadges.value = []
} catch (e: any) { } catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
} }
// allow only lower roles to be assigned // allow only lower roles to be assigned
const allowedRoles = ref<WorkspaceUserRoles[]>([]) const allowedRoles = ref<WorkspaceUserRoles[]>([])
@ -38,42 +116,110 @@ onMounted(async () => {
message.error(await extractSdkResponseErrorMsg(e)) message.error(await extractSdkResponseErrorMsg(e))
} }
}) })
const focusOnDiv = () => {
focusRef.value?.focus()
isDivFocused.value = true
}
// remove one email per backspace
onKeyStroke('Backspace', () => {
if (isDivFocused.value && inviteData.email.length < 1) {
emailBadges.value.pop()
}
})
// when bulk email is pasted
const onPaste = (e: ClipboardEvent) => {
const pastedText = e.clipboardData?.getData('text')
const inputArray = pastedText?.split(',') || pastedText?.split(' ')
// if data is pasted to a already existing text in input
// we add existingInput + pasted data
if (inputArray?.length === 1 && inviteData.email.length > 1) {
inputArray[0] = inviteData.email += inputArray[0]
}
inputArray?.forEach((el) => {
const isEmailIsValid = emailInputValidation(el)
if (!isEmailIsValid) return
/**
if email is already enterd we delete the already
existing email and add new one
**/
if (emailBadges.value.includes(el)) {
insertOrUpdateString(el)
return
}
emailBadges.value.push(el)
inviteData.email = ''
})
inviteData.email = ''
}
</script> </script>
<template> <template>
<div class="my-2 pt-3 ml-2" data-testid="invite"> <div class="my-2 pt-3 ml-2" data-testid="invite">
<div class="text-xl mb-4">Invite</div> <div class="flex gap-2">
<a-form> <div class="flex flex-col">
<div class="flex gap-2"> <div
<a-input ref="divRef"
id="email" class="flex w-130 border-1 gap-1 items-center flex-wrap min-h-8 max-h-30 rounded-lg nc-scrollbar-md"
v-model:value="inviteData.email" tabindex="0"
placeholder="Enter emails to send invitation" :class="{
class="!max-w-130 !rounded" 'border-primary/100': isDivFocused,
/> 'p-1': emailBadges.length > 1,
}"
<RolesSelector @click="focusOnDiv"
class="px-1" @blur="isDivFocused = false"
:role="inviteData.roles"
:roles="allowedRoles"
:on-role-change="(role: WorkspaceUserRoles) => (inviteData.roles = role)"
:description="true"
/>
<a-button
type="primary"
class="!rounded-md"
:disabled="!inviteData.email?.length || isInvitingCollaborators"
@click="inviteCollaborator"
> >
<div class="flex flex-row items-center gap-x-2 pr-1"> <span
<GeneralLoader v-if="isInvitingCollaborators" class="flex" /> v-for="(email, index) in emailBadges"
<MdiPlus v-else /> :key="email"
{{ isInvitingCollaborators ? 'Adding' : 'Add' }} User/s class="leading-4 border-1 text-brand-500 bg-brand-50 rounded-md ml-1 p-0.5"
</div> >
</a-button> {{ email }}
<component
:is="iconMap.close"
class="ml-0.5 hover:cursor-pointer w-3.5 h-3.5"
@click="emailBadges.splice(index, 1)"
/>
</span>
<input
id="email"
ref="focusRef"
v-model="inviteData.email"
:placeholder="emailBadges.length < 1 ? 'Enter emails to send invitation' : ''"
class="min-w-50 outline-0 ml-2 mr-3"
data-testid="email-input"
@keyup.enter="handleEnter"
@blur="isDivFocused = false"
@paste.prevent="onPaste"
/>
</div>
<span v-if="emailValidation.isError" class="ml-2 text-red-500 text-[10px] mt-1.5">{{ emailValidation.message }}</span>
</div> </div>
</a-form> <RolesSelector
size="md"
class="px-1"
:role="inviteData.roles"
:roles="allowedRoles"
:on-role-change="(role: WorkspaceUserRoles) => (inviteData.roles = role)"
:description="true"
/>
<NcButton
type="primary"
size="small"
:disabled="!emailBadges.length || isInvitingCollaborators || emailValidation.isError"
:loading="isInvitingCollaborators"
@click="inviteCollaborator"
>
<MdiPlus />
{{ isInvitingCollaborators ? 'Adding' : 'Add' }} Member(s)
</NcButton>
</div>
</div> </div>
</template> </template>

2
packages/nc-gui/components/workspace/View.vue

@ -67,7 +67,7 @@ onMounted(() => {
<template #tab> <template #tab>
<div class="flex flex-row items-center px-2 pb-1 gap-x-1.5"> <div class="flex flex-row items-center px-2 pb-1 gap-x-1.5">
<PhUsersBold /> <PhUsersBold />
Collaborators Members
</div> </div>
</template> </template>
<WorkspaceCollaboratorsList /> <WorkspaceCollaboratorsList />

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

@ -27,6 +27,7 @@ const rolePermissions = {
newUser: true, newUser: true,
tableRename: true, tableRename: true,
tableDelete: true, tableDelete: 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 }

1
packages/nc-gui/utils/iconUtils.ts

@ -233,6 +233,7 @@ import MaterialSymbolsBlock from '~icons/material-symbols/block'
export const iconMap = { export const iconMap = {
workspaceDefault: MsGroup, workspaceDefault: MsGroup,
search: NcSearch, search: NcSearch,
error: h('span', { class: 'material-symbols' }, 'error'),
info: h(MsInfo, {}, () => 'info'), info: h(MsInfo, {}, () => 'info'),
inbox: h('span', { class: 'material-symbols' }, 'inbox'), inbox: h('span', { class: 'material-symbols' }, 'inbox'),
addOutlineBox: MsAddBoxOutline, addOutlineBox: MsAddBoxOutline,

2
tests/playwright/pages/Dashboard/BulkUpdate/index.ts

@ -173,7 +173,7 @@ export class BulkUpdatePage extends BasePage {
awaitResponse?: boolean; awaitResponse?: boolean;
} = {}) { } = {}) {
await this.bulkUpdateButton.click(); await this.bulkUpdateButton.click();
const confirmModal = this.rootPage.locator('.ant-modal-content'); const confirmModal = this.rootPage.locator('.ant-modal');
const saveRowAction = () => confirmModal.locator('.ant-btn-primary').click(); const saveRowAction = () => confirmModal.locator('.ant-btn-primary').click();
if (!awaitResponse) { if (!awaitResponse) {

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';
@ -81,4 +81,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);

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));
} }

3
tests/playwright/pages/WorkspacePage/CollaborationPage.ts

@ -36,6 +36,7 @@ export class CollaborationPage extends BasePage {
// email // email
await this.input_email.fill(email); await this.input_email.fill(email);
await this.rootPage.keyboard.press('Enter');
// role // role
await this.selector_role.click(); await this.selector_role.click();
@ -46,7 +47,7 @@ export class CollaborationPage extends BasePage {
// allow button to be enabled // allow button to be enabled
await this.rootPage.waitForTimeout(500); await this.rootPage.waitForTimeout(500);
await this.rootPage.keyboard.press('Enter');
await this.button_addUser.click(); await this.button_addUser.click();
await this.verifyToast({ message: 'Invitation sent successfully' }); await this.verifyToast({ message: 'Invitation sent successfully' });
await this.rootPage.waitForTimeout(500); await this.rootPage.waitForTimeout(500);

2
tests/playwright/pages/WorkspacePage/ContainerPage.ts

@ -55,7 +55,7 @@ export class ContainerPage extends BasePage {
// tabs // tabs
this.projects = this.get().locator('.ant-tabs-tab:has-text("Projects")'); this.projects = this.get().locator('.ant-tabs-tab:has-text("Projects")');
this.collaborators = this.get().locator('.ant-tabs-tab:has-text("Collaborators")'); this.collaborators = this.get().locator('.ant-tabs-tab:has-text("Members")');
this.billing = this.get().locator('.ant-tabs-tab:has-text("Billing")'); this.billing = this.get().locator('.ant-tabs-tab:has-text("Billing")');
this.settings = this.get().locator('.ant-tabs-tab:has-text("Settings")'); this.settings = this.get().locator('.ant-tabs-tab:has-text("Settings")');

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