Browse Source

fix: Added views in left sidebar

pull/6444/head
Muhammed Mustafa 1 year ago
parent
commit
fedfdb59f8
  1. 127
      packages/nc-gui/components/dashboard/TreeView/CreateViewBtn.vue
  2. 7
      packages/nc-gui/components/dashboard/TreeView/TableList.vue
  3. 280
      packages/nc-gui/components/dashboard/TreeView/TableNode.vue
  4. 392
      packages/nc-gui/components/dashboard/TreeView/ViewsList.vue
  5. 296
      packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue
  6. 140
      packages/nc-gui/components/dlg/ViewCreate.vue
  7. 2
      packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue
  8. 15
      packages/nc-gui/components/smartsheet/toolbar/ViewInfo.vue
  9. 1
      packages/nc-gui/context/index.ts

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

@ -0,0 +1,127 @@
<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 } = viewsStore
const table = inject(SidebarTableInj)!
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()
router.push({ params: { viewTitle: view.id || '' } })
$e('a:view:create', { view: view.type })
},
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
</script>
<template>
<NcDropdown v-model:isOpen="isOpen" @click.stop="isOpen = !isOpen">
<slot />
<template #overlay>
<NcMenu class="max-w-48">
<div class="py-2 px-4 text-gray-500 text-sm">New View</div>
<NcMenuItem @click="onOpenModal({ type: ViewTypes.GRID })">
<div class="item">
<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">
<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">
<div class="item-inner">
<GeneralViewIcon :meta="{ type: ViewTypes.GALLERY }" />
<div>Gallery</div>
</div>
<GeneralIcon class="plus" icon="plus" />
</div>
</NcMenuItem>
<NcMenuItem @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>

7
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 { openTable } = useTableNew({
projectId: project.value.id!,
})
const tablesById = computed(() =>
tables.value.reduce<Record<string, TableType>>((acc, table) => {
acc[table.id!] = table
@ -153,7 +149,7 @@ const availableTables = computed(() => {
v-for="table of availableTables"
:key="table.id"
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-id="table.id"
:data-testid="`tree-view-table-${table.title}`"
@ -163,7 +159,6 @@ const availableTables = computed(() => {
:data-title="table.title"
:data-base-id="base?.id"
:data-type="table.type"
@click="openTable(table)"
>
</TableNode>
</div>

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

@ -20,6 +20,10 @@ const project = toRef(props, 'project')
const table = toRef(props, 'table')
const baseIndex = toRef(props, 'baseIndex')
const { openTable } = useTableNew({
projectId: project.value.id!,
})
const route = useRoute()
const { isUIAllowed } = useRoles()
@ -34,9 +38,13 @@ useTableNew({
})
const projectRole = inject(ProjectRoleInj)
provide(SidebarTableInj, table)
const { setMenuContext, openRenameTableDialog, duplicateTable } = inject(TreeViewInj)!
const { loadViews: _loadViews } = useViewsStore()
const { activeView } = storeToRefs(useViewsStore())
// todo: temp
const { projectTables } = storeToRefs(useTablesStore())
const tables = computed(() => projectTables.value.get(project.value.id!) ?? [])
@ -73,80 +81,127 @@ const { isSharedBase } = useProject()
const canUserEditEmote = computed(() => {
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>
<template>
<div
class="nc-tree-item text-sm cursor-pointer group select-none"
class="nc-tree-item text-sm select-none w-full"
:data-order="table.order"
:data-id="table.id"
:data-testid="`tree-view-table-${table.title}`"
:data-table-id="table.id"
:class="[
// todo: table filter
// { hidden: !filteredTables?.includes(table), active: openedTableId === table.id },
`nc-project-tree-tbl nc-project-tree-tbl-${table.title}`,
{ active: openedTableId === table.id },
]"
:class="[`nc-project-tree-tbl nc-project-tree-tbl-${table.title}`]"
>
<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="{
'hover:bg-gray-200': openedTableId !== table.id,
'pl-17.75': baseIndex !== 0,
'pl-12.25': baseIndex === 0,
'pl-12': baseIndex !== 0,
'pl-6.5': baseIndex === 0,
'!bg-primary-selected': isTableOpened,
}"
modifier-key="Alt"
>
<template #title>{{ table.table_name }}</template>
<div class="table-context flex items-center gap-1 h-full" @contextmenu="setMenuContext('table', 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
>
<LazyGeneralEmojiPicker
:key="table.meta?.icon"
:emoji="table.meta?.icon"
size="small"
:readonly="!canUserEditEmote"
@emoji-selected="setIcon($event, table)"
<div
class="table-context flex items-center gap-1 h-full"
@contextmenu="setMenuContext('table', table)"
@click="openTable(table)"
>
<div class="flex flex-row h-full items-center">
<NcButton type="text" size="xxsmall" class="nc-sidebar-node-btn" @click.stop="onExpand">
<GeneralIcon
icon="triangleFill"
class="nc-sidebar-base-node-btns group-hover:visible invisible cursor-pointer transform transition-transform duration-500 h-1.5 w-1.5 !text-gray-600 rotate-90 hover:bg-"
:class="{ '!rotate-180': isExpanded }"
/>
</NcButton>
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<div
class="flex items-center nc-table-icon"
:class="{
'pointer-events-none': !canUserEditEmote,
}"
@click.stop
>
<template #default>
<NcTooltip class="flex" placement="topLeft" hide-on-click :disabled="!canUserEditEmote">
<template #title>
{{ 'Change icon' }}
</template>
<MdiTable
v-if="table.type === 'table'"
class="flex w-5 !text-gray-500 text-sm"
:class="{
'group-hover:text-gray-500': isUIAllowed('tableSort', { roles: projectRole }),
'!text-black': openedTableId === table.id,
}"
/>
<MdiEye
v-else
class="flex w-5 !text-gray-500 text-sm"
:class="{
'group-hover:text-gray-500': isUIAllowed('tableSort', { roles: projectRole }),
'!text-black': openedTableId === table.id,
}"
/>
</NcTooltip>
</template>
</LazyGeneralEmojiPicker>
<LazyGeneralEmojiPicker
:key="table.meta?.icon"
:emoji="table.meta?.icon"
size="small"
:readonly="!canUserEditEmote"
@emoji-selected="setIcon($event, table)"
>
<template #default>
<NcTooltip class="flex" placement="topLeft" hide-on-click :disabled="!canUserEditEmote">
<template #title>
{{ 'Change icon' }}
</template>
<MdiTable
v-if="table.type === 'table'"
class="flex w-5 !text-gray-500 text-sm"
:class="{
'group-hover:text-gray-500': isUIAllowed('tableSort', { roles: projectRole }),
'!text-black': openedTableId === table.id,
}"
/>
<MdiEye
v-else
class="flex w-5 !text-gray-500 text-sm"
:class="{
'group-hover:text-gray-500': isUIAllowed('tableSort', { roles: projectRole }),
'!text-black': openedTableId === table.id,
}"
/>
</NcTooltip>
</template>
</LazyGeneralEmojiPicker>
</div>
</div>
</div>
<span
class="nc-tbl-title capitalize text-ellipsis overflow-hidden select-none"
:class="{
'text-black !font-semibold': openedTableId === table.id,
'text-black !font-medium': isTableOpened,
}"
:data-testid="`nc-tbl-title-${table.title}`"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
@ -155,58 +210,65 @@ const canUserEditEmote = computed(() => {
</span>
<div class="flex flex-grow h-full"></div>
<NcDropdown
v-if="
!isSharedBase &&
(isUIAllowed('tableRename', { roles: projectRole }) || isUIAllowed('tableDelete', { roles: projectRole }))
"
:trigger="['click']"
@click.stop
>
<MdiDotsHorizontal
class="min-w-5.75 min-h-5.75 mt-0.2 mr-0.25 px-0.5 transition-opacity opacity-0 group-hover:opacity-100 nc-tbl-context-menu outline-0 rounded-md hover:(bg-gray-500 bg-opacity-15 !text-black)"
:class="{
'!text-gray-600': openedTableId !== table.id,
'!text-black': openedTableId === table.id,
}"
/>
<template #overlay>
<NcMenu>
<NcMenuItem
v-if="isUIAllowed('tableRename', { roles: projectRole })"
:data-testid="`sidebar-table-rename-${table.title}`"
@click="openRenameTableDialog(table, project.bases[baseIndex].id)"
>
<GeneralIcon icon="edit" class="text-gray-700" />
{{ $t('general.rename') }}
</NcMenuItem>
<NcMenuItem
v-if="
isUIAllowed('tableDuplicate') &&
project.bases?.[baseIndex] &&
(project.bases[baseIndex].is_meta || project.bases[baseIndex].is_local)
"
:data-testid="`sidebar-table-duplicate-${table.title}`"
@click="duplicateTable(table)"
>
<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}`"
class="!text-red-500 !hover:bg-red-50"
@click="isTableDeleteDialogVisible = true"
>
<GeneralIcon icon="delete" />
{{ $t('general.delete') }}
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<div class="flex flex-row items-center">
<NcDropdown
v-if="
!isSharedBase &&
(isUIAllowed('tableRename', { roles: projectRole }) || isUIAllowed('tableDelete', { roles: projectRole }))
"
:trigger="['click']"
@click.stop
>
<MdiDotsHorizontal
class="min-w-5.75 min-h-5.75 mt-0.2 mr-0.25 px-0.5 transition-opacity opacity-0 group-hover:opacity-100 nc-tbl-context-menu outline-0 rounded-md hover:(bg-gray-500 bg-opacity-15 !text-black)"
:class="{
'!text-gray-600': openedTableId !== table.id,
'!text-black': openedTableId === table.id,
}"
/>
<template #overlay>
<NcMenu>
<NcMenuItem
v-if="isUIAllowed('tableRename', { roles: projectRole })"
:data-testid="`sidebar-table-rename-${table.title}`"
@click="openRenameTableDialog(table, project.bases[baseIndex].id)"
>
<GeneralIcon icon="edit" class="text-gray-700" />
{{ $t('general.rename') }}
</NcMenuItem>
<NcMenuItem
v-if="
isUIAllowed('tableDuplicate') &&
project.bases?.[baseIndex] &&
(project.bases[baseIndex].is_meta || project.bases[baseIndex].is_local)
"
:data-testid="`sidebar-table-duplicate-${table.title}`"
@click="duplicateTable(table)"
>
<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}`"
class="!text-red-500 !hover:bg-red-50"
@click="isTableDeleteDialogVisible = true"
>
<GeneralIcon icon="delete" />
{{ $t('general.delete') }}
</NcMenuItem>
</NcMenu>
</template>
</NcDropdown>
<DashboardTreeViewCreateViewBtn>
<NcButton type="text" size="xxsmall" class="nc-sidebar-node-btn">
<GeneralIcon icon="plus" class="text-xl leading-5" style="-webkit-text-stroke: 0.15px" />
</NcButton>
</DashboardTreeViewCreateViewBtn>
</div>
</div>
<DlgTableDelete
v-if="table.id && project?.id"
@ -215,24 +277,16 @@ const canUserEditEmote = computed(() => {
:project-id="project.id"
/>
</GeneralTooltip>
<DashboardTreeViewViewsList v-if="isExpanded" :table-id="table.id" :project-id="project.id" />
</div>
</template>
<style scoped lang="scss">
.nc-tree-item {
@apply relative cursor-pointer after:(pointer-events-none content-[''] rounded absolute top-0 left-0 w-full h-full right-0 !bg-current transition transition-opactity duration-100 opacity-0);
@apply relative after:(pointer-events-none content-[''] rounded absolute top-0 left-0 w-full h-full right-0 !bg-current transition duration-100 opacity-0);
}
.nc-tree-item svg {
@apply text-primary text-opacity-60;
}
.nc-tree-item.active {
@apply !bg-primary-selected rounded-md;
//@apply border-r-3 border-primary;
svg {
@apply !text-opacity-100;
}
}
</style>

392
packages/nc-gui/components/dashboard/TreeView/ViewsList.vue

@ -0,0 +1,392 @@
<script lang="ts" setup>
import type { ViewType } from 'nocodb-sdk'
import { ViewTypes } from 'nocodb-sdk'
import type { SortableEvent } from 'sortablejs'
import Sortable from 'sortablejs'
import type { Menu as AntMenu } from 'ant-design-vue'
import {
extractSdkResponseErrorMsg,
message,
onMounted,
parseProp,
ref,
resolveComponent,
useApi,
useCommandPalette,
useDialog,
useNuxtApp,
useRouter,
useUndoRedo,
viewTypeAlias,
watch,
} from '#imports'
interface Emits {
(event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string; groupingFieldColumnId?: string }): void
(event: 'deleted'): void
}
const emits = defineEmits<Emits>()
const project = inject(ProjectInj)!
const table = inject(SidebarTableInj)!
const { $e } = useNuxtApp()
const isDefaultBase = computed(() => {
const base = project.value?.bases?.find((b) => b.id === table.value.base_id)
return base?.is_meta
})
const { viewsByTable, activeView } = storeToRefs(useViewsStore())
const views = computed(() => viewsByTable.value.get(table.value.id!)?.filter((v) => !v.is_default) ?? [])
const { api } = useApi()
const router = useRouter()
const { refreshCommandPalette } = useCommandPalette()
const { addUndo, defineModelScope } = useUndoRedo()
/** Selected view(s) for menu */
const selected = ref<string[]>([])
/** dragging renamable view items */
const dragging = ref(false)
const menuRef = ref<typeof AntMenu>()
const isMarked = ref<string | false>(false)
/** Watch currently active view, so we can mark it in the menu */
watch(activeView, (nextActiveView) => {
if (nextActiveView && nextActiveView.id) {
selected.value = [nextActiveView.id]
}
})
/** shortly mark an item after sorting */
function markItem(id: string) {
isMarked.value = id
setTimeout(() => {
isMarked.value = false
}, 300)
}
/** validate view title */
function validate(view: ViewType) {
if (!view.title || view.title.trim().length < 0) {
return 'View name is required'
}
if (views.value.some((v) => v.title === view.title && v.id !== view.id)) {
return 'View name should be unique'
}
return true
}
let sortable: Sortable
function onSortStart(evt: SortableEvent) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging.value = true
}
async function onSortEnd(evt: SortableEvent, undo = false) {
if (!undo) {
evt.stopImmediatePropagation()
evt.preventDefault()
dragging.value = false
}
if (views.value.length < 2) return
const { newIndex = 0, oldIndex = 0 } = evt
if (newIndex === oldIndex) return
if (!undo) {
addUndo({
redo: {
fn: async () => {
const ord = sortable.toArray()
const temp = ord.splice(oldIndex, 1)
ord.splice(newIndex, 0, temp[0])
sortable.sort(ord)
await onSortEnd(evt, true)
},
args: [],
},
undo: {
fn: async () => {
const ord = sortable.toArray()
const temp = ord.splice(newIndex, 1)
ord.splice(oldIndex, 0, temp[0])
sortable.sort(ord)
await onSortEnd({ ...evt, oldIndex: newIndex, newIndex: oldIndex }, true)
},
args: [],
},
scope: defineModelScope({ view: activeView.value }),
})
}
const children = evt.to.children as unknown as HTMLLIElement[]
const previousEl = children[newIndex - 1]
const nextEl = children[newIndex + 1]
const currentItem = views.value.find((v) => v.id === evt.item.id)
if (!currentItem || !currentItem.id) return
const previousItem = (previousEl ? views.value.find((v) => v.id === previousEl.id) : {}) as ViewType
const nextItem = (nextEl ? views.value.find((v) => v.id === nextEl.id) : {}) as ViewType
let nextOrder: number
// set new order value based on the new order of the items
if (views.value.length - 1 === newIndex) {
nextOrder = parseFloat(String(previousItem.order)) + 1
} else if (newIndex === 0) {
nextOrder = parseFloat(String(nextItem.order)) / 2
} else {
nextOrder = (parseFloat(String(previousItem.order)) + parseFloat(String(nextItem.order))) / 2
}
const _nextOrder = !isNaN(Number(nextOrder)) ? nextOrder : oldIndex
currentItem.order = _nextOrder
await api.dbView.update(currentItem.id, { order: _nextOrder })
markItem(currentItem.id)
$e('a:view:reorder')
}
const initSortable = (el: HTMLElement) => {
if (sortable) sortable.destroy()
sortable = new Sortable(el, {
// handle: '.nc-drag-icon',
ghostClass: 'ghost',
onStart: onSortStart,
onEnd: onSortEnd,
})
}
onMounted(() => menuRef.value && initSortable(menuRef.value.$el))
/** Navigate to view by changing url param */
function changeView(view: ViewType) {
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'
) {
router.push({
name: routeName,
params: { viewTitle: view.id || '', viewId: table.value.id, projectId: project.value.id },
query: router.currentRoute.value.query,
})
} else {
router.push({ name: routeName, params: { viewTitle: view.id || '', viewId: table.value.id, projectId: project.value.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({ name: routeName, query: { reload: 'true' }, params: { viewId: table.value.id, projectId: project.value.id } })
.then(() => {
router.replace({ name: routeName, query: {}, params: { viewId: table.value.id, projectId: project.value.id } })
})
}
}
/** Rename a view */
async function onRename(view: ViewType, originalTitle?: string, undo = false) {
try {
await api.dbView.update(view.id!, {
title: view.title,
order: view.order,
})
await router.replace({
params: {
viewTitle: view.id,
},
})
refreshCommandPalette()
if (!undo) {
addUndo({
redo: {
fn: (v: ViewType, title: string) => {
const tempTitle = v.title
v.title = title
onRename(v, tempTitle, true)
},
args: [view, view.title],
},
undo: {
fn: (v: ViewType, title: string) => {
const tempTitle = v.title
v.title = title
onRename(v, tempTitle, true)
},
args: [view, originalTitle],
},
scope: defineModelScope({ view: activeView.value }),
})
}
// View renamed successfully
// message.success(t('msg.success.viewRenamed'))
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
/** Open delete modal */
function openDeleteDialog(view: ViewType) {
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgViewDelete'), {
'modelValue': isOpen,
'view': view,
'onUpdate:modelValue': closeDialog,
'onDeleted': () => {
closeDialog()
emits('deleted')
refreshCommandPalette()
if (activeView.value === view) {
// return to the default view
router.replace({
params: {
viewTitle: views.value[0].id,
},
})
}
},
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
const setIcon = async (icon: string, view: ViewType) => {
try {
// modify the icon property in meta
view.meta = {
...parseProp(view.meta),
icon,
}
api.dbView.update(view.id as string, {
meta: view.meta,
})
$e('a:view:icon:sidebar', { icon })
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
</script>
<template>
<DashboardTreeViewCreateViewBtn :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
ref="menuRef"
:class="{ dragging }"
class="nc-views-menu flex flex-col w-full !border-r-0 !bg-inherit"
:selected-keys="selected"
>
<DashboardTreeViewViewsNode
v-for="view of views"
:id="view.id"
:key="view.id"
:view="view"
:on-validate="validate"
class="nc-view-item !rounded-md !px-0.75 !py-0.5 w-full transition-all ease-in duration-100"
:class="{
'bg-gray-200': isMarked === view.id,
'active': activeView?.id === view.id,
[`nc-${view.type ? viewTypeAlias[view.type] : undefined || view.type}-view-item`]: true,
}"
:data-view-id="view.id"
@change-view="changeView"
@open-modal="$emit('openModal', $event)"
@delete="openDeleteDialog"
@rename="onRename"
@select-icon="setIcon($event, view)"
/>
</a-menu>
</template>
<style lang="scss">
.nc-views-menu {
.ghost,
.ghost > * {
@apply !pointer-events-none;
}
&.dragging {
.nc-icon {
@apply !hidden;
}
.nc-view-icon {
@apply !block;
}
}
.ant-menu-item:not(.sortable-chosen) {
@apply color-transition;
}
.ant-menu-title-content {
@apply !w-full;
}
.sortable-chosen {
@apply !bg-gray-200;
}
.active {
@apply !bg-primary-selected font-medium;
}
}
</style>

296
packages/nc-gui/components/dashboard/TreeView/ViewsNode.vue

@ -0,0 +1,296 @@
<script lang="ts" setup>
import type { VNodeRef } from '@vue/runtime-core'
import type { KanbanType, ViewType, ViewTypes } from 'nocodb-sdk'
import type { WritableComputedRef } from '@vue/reactivity'
import { IsLockedInj, inject, message, onKeyStroke, useDebounceFn, useNuxtApp, useRoles, useVModel } from '#imports'
interface Props {
view: ViewType
onValidate: (view: ViewType) => boolean | string
}
interface Emits {
(event: 'update:view', data: Record<string, any>): void
(event: 'selectIcon', icon: string): void
(event: 'changeView', view: Record<string, any>): void
(event: 'rename', view: ViewType, title: string | undefined): void
(event: 'delete', view: ViewType): void
(event: 'openModal', data: { type: ViewTypes; title?: string; copyViewId?: string; groupingFieldColumnId?: string }): void
}
const props = defineProps<Props>()
const emits = defineEmits<Emits>()
const vModel = useVModel(props, 'view', emits) as WritableComputedRef<ViewType & { alias?: string; is_default: boolean }>
const { $e } = useNuxtApp()
const { isUIAllowed } = useRoles()
const { activeViewTitleOrId } = storeToRefs(useViewsStore())
const project = inject(ProjectInj, ref())
const activeView = inject(ActiveViewInj, ref())
const isLocked = inject(IsLockedInj, ref(false))
const { rightSidebarState } = storeToRefs(useSidebarStore())
const isDefaultBase = computed(() => {
const base = project.value?.bases?.find((b) => b.id === vModel.value.base_id)
return base?.is_meta
})
const isDropdownOpen = ref(false)
const isEditing = ref(false)
/** Is editing the view name enabled */
/** Helper to check if editing was disabled before the view navigation timeout triggers */
const isStopped = ref(false)
/** Original view title when editing the view name */
const _title = ref<string | undefined>()
/** Debounce click handler, so we can potentially enable editing view name {@see onDblClick} */
const onClick = useDebounceFn(() => {
if (isEditing.value || isStopped.value) return
emits('changeView', vModel.value)
}, 250)
/** Enable editing view name on dbl click */
function onDblClick() {
if (!isUIAllowed('viewCreateOrEdit')) return
if (!isEditing.value) {
isEditing.value = true
_title.value = vModel.value.title
$e('c:view:rename', { view: vModel.value?.type })
}
}
/** Handle keydown on input field */
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onKeyEsc(event)
} else if (event.key === 'Enter') {
onKeyEnter(event)
}
}
/** Rename view when enter is pressed */
function onKeyEnter(event: KeyboardEvent) {
event.stopImmediatePropagation()
event.preventDefault()
onRename()
}
/** Disable renaming view when escape is pressed */
function onKeyEsc(event: KeyboardEvent) {
event.stopImmediatePropagation()
event.preventDefault()
onCancel()
}
onKeyStroke('Enter', (event) => {
if (isEditing.value) {
onKeyEnter(event)
}
})
const focusInput: VNodeRef = (el) => (el as HTMLInputElement)?.focus()
/** Duplicate a view */
// todo: This is not really a duplication, maybe we need to implement a true duplication?
function onDuplicate() {
isDropdownOpen.value = false
emits('openModal', {
type: vModel.value.type!,
title: vModel.value.title,
copyViewId: vModel.value.id,
groupingFieldColumnId: (vModel.value.view as KanbanType).fk_grp_col_id!,
})
$e('c:view:copy', { view: vModel.value.type })
}
/** Delete a view */
async function onDelete() {
isDropdownOpen.value = false
emits('delete', vModel.value)
}
/** Rename a view */
async function onRename() {
isDropdownOpen.value = false
if (!isEditing.value) return
const isValid = props.onValidate({ ...vModel.value, title: _title.value! })
if (isValid !== true) {
message.error(isValid)
onCancel()
return
}
if (vModel.value.title === '' || vModel.value.title === _title.value) {
onCancel()
return
}
const originalTitle = vModel.value.title
vModel.value.title = _title.value || ''
emits('rename', vModel.value, originalTitle)
onStopEdit()
}
/** Cancel renaming view */
function onCancel() {
if (!isEditing.value) return
// vModel.value.title = _title || ''
onStopEdit()
}
/** Stop editing view name, timeout makes sure that view navigation (click trigger) does not pick up before stop is done */
function onStopEdit() {
isStopped.value = true
isEditing.value = false
_title.value = ''
setTimeout(() => {
isStopped.value = false
}, 250)
}
watch(rightSidebarState, () => {
if (rightSidebarState.value === 'peekCloseEnd') {
isDropdownOpen.value = false
}
})
function onRef(el: HTMLElement) {
if (activeViewTitleOrId.value === vModel.value.id) {
nextTick(() => {
setTimeout(() => {
el?.scrollIntoView({ block: 'center', inline: 'center' })
}, 1000)
})
}
}
</script>
<template>
<a-menu-item
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}`"
@dblclick.stop="onDblClick"
@click="onClick"
>
<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}`">
<LazyGeneralEmojiPicker
class="nc-table-icon"
:emoji="props.view?.meta?.icon"
size="small"
:clearable="true"
@emoji-selected="emits('selectIcon', $event)"
>
<template #default>
<GeneralViewIcon :meta="props.view" class="nc-view-icon"></GeneralViewIcon>
</template>
</LazyGeneralEmojiPicker>
</div>
<a-input
v-if="isEditing"
:ref="focusInput"
v-model:value="_title"
class="!bg-transparent !border-0 !ring-0 !outline-transparent !border-transparent"
:class="{
'font-medium': activeView?.id === vModel.id,
}"
@blur="onRename"
@keydown.stop="onKeyDown($event)"
/>
<div
v-else
class="capitalize text-ellipsis overflow-hidden select-none w-full"
data-testid="sidebar-view-title"
:class="{
'font-medium': activeView?.id === vModel.id,
}"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ vModel.alias || vModel.title }}
</div>
<div class="flex-1" />
<template v-if="!isEditing && !isLocked && isUIAllowed('viewCreateOrEdit')">
<NcDropdown v-model:visible="isDropdownOpen" overlay-class-name="!rounded-lg">
<NcButton
type="text"
size="xxsmall"
class="nc-sidebar-node-btn invisible !group-hover:visible"
:class="{
'!visible': isDropdownOpen,
}"
@click.stop="isDropdownOpen = !isDropdownOpen"
>
<GeneralIcon icon="threeDotHorizontal" class="text-xl w-4.75" />
</NcButton>
<template #overlay>
<NcMenu class="min-w-27" :data-testid="`view-sidebar-view-actions-${vModel.alias || vModel.title}`">
<NcMenuItem @click.stop="onDblClick">
<GeneralIcon icon="edit" />
Rename
</NcMenuItem>
<NcMenuItem @click.stop="onDuplicate">
<GeneralIcon icon="copy" />
Duplicate
</NcMenuItem>
<NcDivider />
<template v-if="!vModel.is_default">
<NcMenuItem class="!text-red-500" @click.stop="onDelete">
<GeneralIcon icon="delete" class="text-sm" />
<div class="-ml-0.25">Delete</div>
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
</template>
</div>
</a-menu-item>
</template>

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

@ -14,7 +14,7 @@ interface Props {
groupingFieldColumnId?: string
geoDataFieldColumnId?: string
views: ViewType[]
meta: TableType
tableId: string
}
interface Emits {
@ -31,10 +31,20 @@ interface Form {
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 { getMeta } = useMetas()
const { views, selectedViewId, groupingFieldColumnId, geoDataFieldColumnId, tableId } = toRefs(props)
const meta = ref<TableType | undefined>()
const inputEl = ref<ComponentPublicInstance>()
const formValidator = ref<typeof AntForm>()
@ -64,7 +74,7 @@ const viewNameRules = [
{
validator: (_: unknown, v: string) =>
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',
},
@ -97,52 +107,13 @@ watch(
function init() {
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) {
form.title = `${form.title} ${repeatCount}`
}
if (selectedViewId) {
form.copy_from_id = selectedViewId
}
// 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
}
if (selectedViewId.value) {
form.copy_from_id = selectedViewId?.value
}
nextTick(() => {
@ -165,9 +136,7 @@ async function onSubmit() {
}
if (isValid && form.type) {
const _meta = unref(meta)
if (!_meta || !_meta.id) return
if (!tableId.value) return
try {
let data: GridType | KanbanType | GalleryType | FormType | MapType | null = null
@ -176,19 +145,19 @@ async function onSubmit() {
switch (form.type) {
case ViewTypes.GRID:
data = await api.dbView.gridCreate(_meta.id, form)
data = await api.dbView.gridCreate(tableId.value, form)
break
case ViewTypes.GALLERY:
data = await api.dbView.galleryCreate(_meta.id, form)
data = await api.dbView.galleryCreate(tableId.value, form)
break
case ViewTypes.FORM:
data = await api.dbView.formCreate(_meta.id, form)
data = await api.dbView.formCreate(tableId.value, form)
break
case ViewTypes.KANBAN:
data = await api.dbView.kanbanCreate(_meta.id, form)
data = await api.dbView.kanbanCreate(tableId.value, form)
break
case ViewTypes.MAP:
data = await api.dbView.mapCreate(_meta.id, form)
data = await api.dbView.mapCreate(tableId.value, form)
}
if (data) {
@ -208,6 +177,60 @@ async function onSubmit() {
}, 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>
<template>
@ -237,11 +260,11 @@ async function onSubmit() {
name="fk_grp_col_id"
:rules="groupingFieldColumnRules"
>
<a-select
<NcSelect
v-model:value="form.fk_grp_col_id"
class="w-full nc-kanban-grouping-field-select"
:options="viewSelectFieldOptions"
:disabled="groupingFieldColumnId"
:disabled="groupingFieldColumnId || isMetaLoading"
:loading="true"
placeholder="Select a Grouping Field"
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"
:rules="geoDataFieldColumnRules"
>
<a-select
<NcSelect
v-model:value="form.fk_geo_data_col_id"
class="w-full"
:options="viewSelectFieldOptions"
:disabled="geoDataFieldColumnId"
:disabled="groupingFieldColumnId || isMetaLoading"
:loading="isMetaLoading"
placeholder="Select a GeoData Field"
not-found-content="No GeoData Field can be found. Please create one first."
/>

2
packages/nc-gui/components/smartsheet/sidebar/MenuTop.vue

@ -355,7 +355,7 @@ watch(
</a-menu>
</template>
<style lang="scss">
<style lang="scss" scoped>
.nc-views-menu {
@apply min-h-20 flex-grow;

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

@ -18,11 +18,21 @@ const { activeTable } = storeToRefs(useTablesStore())
>
<LazyGeneralEmojiPicker :emoji="activeTable?.meta?.icon" readonly size="xsmall">
<template #default>
<MdiTable class="min-w-5 !text-gray-500" :class="{}" />
<MdiTable
class="min-w-5 !text-gray-500"
:class="{
'!text-gray-500': !selectedView?.is_default,
'!text-gray-800 mr-0.25': selectedView?.is_default,
}"
/>
</template>
</LazyGeneralEmojiPicker>
<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"
:class="{
'text-gray-500': !selectedView?.is_default,
'text-gray-700': selectedView?.is_default,
}"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
>
{{ activeTable?.title }}
@ -38,6 +48,7 @@ const { activeTable } = storeToRefs(useTablesStore())
<span class="truncate pl-1.25 text-gray-700 max-w-28/100">
{{ selectedView?.title }}
</span>
<LazySmartsheetToolbarReload v-if="openedViewsTab === 'view'" />
</div>
</template>

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 ProjectIdInj: InjectionKey<Ref<string>> = Symbol('project-id-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<{
setMenuContext: (type: 'project' | 'base' | 'table' | 'main' | 'layout', value?: any) => void
duplicateTable: (table: TableType) => void

Loading…
Cancel
Save