Files
nocodb/packages/nc-gui/components/dashboard/TreeView/Table/Node.vue
2025-09-11 15:12:36 +00:00

772 lines
23 KiB
Vue

<script lang="ts" setup>
import { type BaseType, PlanFeatureTypes, PlanTitles, type TableType, ViewTypes } from 'nocodb-sdk'
import type { SidebarTableNode } from '~/lib/types'
const props = withDefaults(
defineProps<{
base: BaseType
table: SidebarTableNode
sourceIndex: number
}>(),
{ sourceIndex: 0 },
)
const { base, table, sourceIndex } = toRefs(props)
const { openTable: _openTable } = useTableNew({
baseId: base.value.id!,
})
const route = useRoute()
const { isUIAllowed } = useRoles()
const { isMobileMode } = useGlobal()
const { $e, $api } = useNuxtApp()
const { isMysql, isPg } = useBase()
useTableNew({
baseId: base.value.id!,
})
const { meta: metaKey, control } = useMagicKeys()
const baseRole = inject(ProjectRoleInj)
provide(SidebarTableInj, table)
const {
setMenuContext,
handleTableRename,
openTableDescriptionDialog: _openTableDescriptionDialog,
duplicateTable: _duplicateTable,
tableRenameId,
} = inject(TreeViewInj)!
const { loadViews: _loadViews, navigateToView, duplicateView } = useViewsStore()
const { activeView, activeViewTitleOrId, viewsByTable } = storeToRefs(useViewsStore())
const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
const { refreshCommandPalette } = useCommandPalette()
const { showRecordPlanLimitExceededModal } = useEeConfig()
const { isTableAndFieldPermissionsEnabled } = usePermissions()
// todo: temp
const { baseTables } = storeToRefs(useTablesStore())
const tables = computed(() => baseTables.value.get(base.value.id!) ?? [])
const openedTableId = computed(() => route.params.viewId)
const source = computed(() => {
return base.value?.sources?.[sourceIndex.value]
})
const isTableDeleteDialogVisible = ref(false)
const isTablePermissionsDialogVisible = ref(false)
const isOptionsOpen = ref(false)
const input = ref<HTMLInputElement>()
/** Is editing the table name enabled */
const isEditing = ref(false)
/** Helper to check if editing was disabled before the view navigation timeout triggers */
const isStopped = ref(false)
const useForm = Form.useForm
const formState = reactive({
title: '',
})
const validators = computed(() => {
return {
title: [
validateTableName,
{
validator: (rule: any, value: any) => {
return new Promise<void>((resolve, reject) => {
let tableNameLengthLimit = 255
if (isMysql(source.value?.id)) {
tableNameLengthLimit = 64
} else if (isPg(source.value?.id)) {
tableNameLengthLimit = 63
}
const basePrefix = base?.value?.prefix || ''
if ((basePrefix + value).length > tableNameLengthLimit) {
return reject(new Error(`Table name exceeds ${tableNameLengthLimit} characters`))
}
resolve()
})
},
},
{
validator: (rule: any, value: any) => {
return new Promise<void>((resolve, reject) => {
if (
!(tables?.value || []).every(
(t) => t.id === table.value.id || t.title.toLowerCase() !== (value?.trim() || '').toLowerCase(),
)
) {
return reject(new Error('Duplicate table alias'))
}
resolve()
})
},
},
],
}
})
const { validate } = useForm(formState, validators)
const setIcon = async (icon: string, table: TableType) => {
try {
table.meta = {
...((table.meta as object) || {}),
icon,
}
const index = tables.value.findIndex((t) => t.id === table.id)
if (index !== -1) {
tables.value[index] = { ...table }
}
await $api.dbTable.update(table.id as string, {
meta: table.meta,
})
$e('a:table:icon:navdraw', { icon })
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
// Todo: temp
const { isSharedBase } = useBase()
// const isMultiBase = computed(() => base.sources && base.sources.length > 1)
const canUserEditEmote = computed(() => {
return isUIAllowed('tableIconEdit', { roles: baseRole?.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
}
}
const onOpenTable = async () => {
if (isEditing.value || isStopped.value) return
if (isMac() ? metaKey.value : control.value) {
await _openTable(table.value, true)
return
}
isLoading.value = true
try {
await _openTable(table.value)
if (isMobileMode.value) {
isLeftSidebarOpen.value = false
}
} 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 || !activeViewTitleOrId.value)
})
let tableTimeout: NodeJS.Timeout
watch(openedTableId, () => {
if (tableTimeout) {
clearTimeout(tableTimeout)
}
if (table.value.id !== openedTableId.value && isExpanded.value) {
const views = viewsByTable.value.get(table.value.id!)?.filter((v) => !v.is_default) ?? []
if (views.length) return
tableTimeout = setTimeout(() => {
if (isExpanded.value) {
isExpanded.value = false
}
clearTimeout(tableTimeout)
}, 10000)
}
})
const duplicateTable = (table: SidebarTableNode) => {
isOptionsOpen.value = false
if (showRecordPlanLimitExceededModal()) return
_duplicateTable(table)
}
const focusInput = () => {
setTimeout(() => {
input.value?.focus()
input.value?.select()
})
}
const onRenameMenuClick = (table: SidebarTableNode) => {
if (isMobileMode.value || !isUIAllowed('tableRename', { roles: baseRole?.value, source: source.value })) return
isOptionsOpen.value = false
if (!isEditing.value) {
isEditing.value = true
formState.title = table.title
nextTick(() => {
focusInput()
})
}
}
watch(
tableRenameId,
(n, o) => {
if (n === o) return
if (n && `${table.value.id}:${source.value?.id}` === tableRenameId.value) {
onRenameMenuClick(table.value)
} else {
isEditing.value = false
onCancel()
}
},
{ immediate: true },
)
const openTableDescriptionDialog = (table: SidebarTableNode) => {
isOptionsOpen.value = false
_openTableDescriptionDialog(table)
}
const deleteTable = () => {
isOptionsOpen.value = false
isTableDeleteDialogVisible.value = true
}
const isOnDuplicateLoading = ref<boolean>(false)
async function onDuplicate() {
isOnDuplicateLoading.value = true
// Load views if not loaded
if (!viewsByTable.value.get(table.value.id as string)) {
await _openTable(table.value, undefined, false)
}
const views = viewsByTable.value.get(table.value.id as string)
const defaultView = views?.find((v) => v.is_default) || views?.[0]
if (defaultView) {
const view = await duplicateView(defaultView)
refreshCommandPalette()
await _loadViews({
force: true,
tableId: table.value!.id!,
})
if (view) {
navigateToView({
view,
tableId: table.value!.id!,
baseId: base.value.id!,
hardReload: view.type === ViewTypes.FORM,
})
$e('a:view:create', { view: view.type, sidebar: true })
}
}
isOnDuplicateLoading.value = false
isOptionsOpen.value = false
}
async function onPermissions(_table: SidebarTableNode) {
isOptionsOpen.value = false
isTablePermissionsDialogVisible.value = true
}
// TODO: Should find a way to render the components without using the `nextTick` function
const refreshViews = async () => {
isExpanded.value = false
await nextTick()
isExpanded.value = true
}
/** Cancel renaming view */
function onCancel() {
if (!isEditing.value) return
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
formState.title = ''
tableRenameId.value = ''
setTimeout(() => {
isStopped.value = false
}, 250)
}
/** 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 validateTitle = async () => {
try {
await validate()
return true
} catch (e: any) {
console.log('e', e)
const errMsg = e.errorFields?.[0]?.errors?.[0]
if (errMsg) {
message.error(errMsg)
}
}
}
/** Rename a table */
async function onRename() {
if (!isEditing.value) return
if (!formState.title?.trim() || table.value.title === formState.title) {
onCancel()
return
}
const isValid = await validateTitle()
if (!isValid) {
onCancel()
return
}
const originalTitle = table.value.title
table.value.title = formState.title.trim() || ''
const updateTitle = (title: string) => {
table.value.title = title
}
handleTableRename(table.value, formState.title, originalTitle, updateTitle)
onStopEdit()
onCancel()
}
</script>
<template>
<div
class="nc-tree-item nc-table-node-wrapper text-sm select-none w-full"
:data-order="table.order"
:data-id="table.id"
:data-table-id="table.id"
:class="[`nc-base-tree-tbl nc-base-tree-tbl-${table.title?.replaceAll(' ', '')}`]"
:data-active="openedTableId === table.id"
>
<div class="flex items-center py-0.5">
<div
v-e="['a:table:open']"
class="flex-none flex-1 table-context flex items-center gap-1 h-full nc-tree-item-inner nc-sidebar-node pr-0.75 mb-0.25 rounded-md h-7 w-full group cursor-pointer hover:bg-gray-200"
:class="{
'hover:bg-gray-200': openedTableId !== table.id,
'pl-8 !xs:(pl-7)': sourceIndex !== 0,
'pl-2 xs:(pl-2)': sourceIndex === 0,
'!bg-primary-selected': isTableOpened,
}"
:data-testid="`nc-tbl-side-node-${table.title}`"
@contextmenu="setMenuContext('table', table)"
@click="onOpenTable"
>
<div class="flex flex-row h-full items-center">
<div class="flex w-auto" :data-testid="`tree-view-table-draggable-handle-${table.title}`">
<GeneralLoader v-if="table.isViewsLoading" class="flex items-center w-6 h-full !text-gray-600" />
<div
v-else
v-e="['c:table:emoji-picker']"
class="flex items-center nc-table-icon min-w-6"
:class="{
'pointer-events-none': !canUserEditEmote,
}"
@click.stop
>
<LazyGeneralEmojiPicker
:key="table.meta?.icon"
:emoji="table.meta?.icon"
size="small"
:readonly="!canUserEditEmote || isMobileMode"
@emoji-selected="setIcon($event, table)"
>
<template #default="{ isOpen }">
<NcTooltip class="flex" placement="topLeft" hide-on-click :disabled="!canUserEditEmote || isOpen">
<template #title>
{{ $t('general.changeIcon') }}
</template>
<component
:is="iconMap.ncZap"
v-if="table?.synced"
class="w-4 text-sm"
:class="isTableOpened ? '!text-brand-600/85' : '!text-gray-600/75'"
/>
<component
:is="iconMap.table"
v-else-if="table.type === 'table'"
class="w-4 text-sm"
:class="isTableOpened ? '!text-brand-600/85' : '!text-gray-600/75'"
/>
<MdiEye v-else class="flex w-5 text-sm" :class="isTableOpened ? '!text-brand-600' : '!text-gray-600'" />
</NcTooltip>
</template>
</LazyGeneralEmojiPicker>
</div>
</div>
</div>
<a-form v-if="isEditing" :model="formState" name="rename-table-form" class="w-full" @finish.prevent>
<a-input
ref="input"
v-model:value="formState.title"
class="!bg-transparent !pr-1.5 !flex-1 mr-4 !rounded-md !h-6 animate-sidebar-node-input-padding"
:class="{
'!font-semibold !text-brand-600': isTableOpened,
}"
:style="{
fontWeight: 'inherit',
}"
@blur="onRename"
@keydown.stop="onKeyDown($event)"
/>
</a-form>
<NcTooltip
v-else
class="nc-tbl-title nc-sidebar-node-title text-ellipsis overflow-hidden select-none !flex-1"
show-on-truncate-only
>
<template #title>{{ table.title }}</template>
<span
:class="isTableOpened ? 'text-brand-600 font-semibold' : 'text-gray-700'"
:data-testid="`nc-tbl-title-${table.title}`"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
@dblclick.stop="onRenameMenuClick(table)"
>
{{ table.title }}
</span>
</NcTooltip>
<div v-if="!isEditing" class="flex items-center">
<NcTooltip v-if="table.description?.length" placement="bottom">
<template #title>
{{ table.description }}
</template>
<NcButton type="text" class="!hover:bg-transparent" size="xsmall">
<GeneralIcon icon="info" class="!w-3.5 !h-3.5 nc-info-icon group-hover:opacity-100 text-gray-600 opacity-0" />
</NcButton>
</NcTooltip>
<NcDropdown v-model:visible="isOptionsOpen" :trigger="['click']" @click.stop>
<NcButton
v-e="['c:table:option']"
class="nc-sidebar-node-btn nc-tbl-context-menu text-gray-700 hover:text-gray-800"
:class="{
'!opacity-100 !inline-block': isOptionsOpen,
}"
data-testid="nc-sidebar-table-context-menu"
type="text"
size="xxsmall"
@click.stop
>
<MdiDotsHorizontal class="!text-current" />
</NcButton>
<template #overlay>
<NcMenu class="!min-w-62.5" :data-testid="`sidebar-table-context-menu-list-${table.title}`" variant="small">
<NcMenuItemCopyId
v-if="table"
:id="table.id"
:tooltip="$t('labels.clickToCopyTableID')"
:label="
$t('labels.tableIdColon', {
tableId: table.id,
})
"
/>
<NcMenuItem
v-if="
isUIAllowed('tableDescriptionEdit', { roles: baseRole, source }) &&
!isUIAllowed('tableRename', { roles: baseRole, source })
"
:data-testid="`sidebar-table-description-${table.title}`"
class="nc-table-description"
@click="openTableDescriptionDialog(table)"
>
<div v-e="['c:table:update-description']" class="flex gap-2 items-center">
<!-- <GeneralIcon icon="ncAlignLeft" class="text-gray-700" /> -->
<GeneralIcon icon="ncAlignLeft" class="opacity-80" />
{{ $t('labels.editTableDescription') }}
</div>
</NcMenuItem>
<template
v-if="
!isSharedBase &&
(isUIAllowed('tableRename', { roles: baseRole, source }) ||
isUIAllowed('tableDelete', { roles: baseRole, source }))
"
>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('tableRename', { roles: baseRole, source })"
:data-testid="`sidebar-table-rename-${table.title}`"
class="nc-table-rename"
@click="onRenameMenuClick(table)"
>
<div v-e="['c:table:rename']" class="flex gap-2 items-center">
<GeneralIcon icon="rename" class="opacity-80" />
{{ $t('general.rename') }} {{ $t('objects.table').toLowerCase() }}
</div>
</NcMenuItem>
<NcMenuItem
v-if="
isUIAllowed('tableDuplicate', {
source,
}) &&
(source?.is_meta || source?.is_local)
"
:data-testid="`sidebar-table-duplicate-${table.title}`"
@click="duplicateTable(table)"
>
<div v-e="['c:table:duplicate']" class="flex-1 flex gap-2 items-center">
<GeneralIcon icon="duplicate" class="opacity-80" />
{{ $t('general.duplicate') }} {{ $t('objects.table').toLowerCase() }}
</div>
</NcMenuItem>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('tableDescriptionEdit', { roles: baseRole, source })"
:data-testid="`sidebar-table-description-${table.title}`"
class="nc-table-description"
@click="openTableDescriptionDialog(table)"
>
<div v-e="['c:table:update-description']" class="flex gap-2 items-center">
<!-- <GeneralIcon icon="ncAlignLeft" class="text-gray-700" /> -->
<GeneralIcon icon="ncAlignLeft" class="opacity-80" />
{{ $t('labels.editTableDescription') }}
</div>
</NcMenuItem>
<PaymentUpgradeBadgeProvider
v-if="
isTableAndFieldPermissionsEnabled &&
isEeUI &&
isUIAllowed('tableDuplicate', {
source,
}) &&
(source?.is_meta || source?.is_local)
"
:feature="PlanFeatureTypes.FEATURE_TABLE_AND_FIELD_PERMISSIONS"
>
<template #default="{ click }">
<NcMenuItem
:data-testid="`sidebar-table-permissions-${table.title}`"
class="nc-table-permissions"
@click="
click(PlanFeatureTypes.FEATURE_TABLE_AND_FIELD_PERMISSIONS, () => {
onPermissions(table)
})
"
>
<div v-e="['c:table:permissions']" class="flex gap-2 items-center w-full">
<GeneralIcon icon="ncLock" class="opacity-80" />
<div class="flex-1">
{{ $t('title.editTablePermissions') }}
</div>
<LazyPaymentUpgradeBadge
:feature="PlanFeatureTypes.FEATURE_TABLE_AND_FIELD_PERMISSIONS"
:title="$t('upgrade.upgradeToUseTableAndFieldPermissions')"
:content="
$t('upgrade.upgradeToUseTableAndFieldPermissionsSubtitle', {
plan: PlanTitles.PLUS,
})
"
:on-click-callback="
() => {
isOptionsOpen = false
}
"
/>
</div>
</NcMenuItem>
</template>
</PaymentUpgradeBadgeProvider>
<NcDivider />
<NcMenuItem @click="onDuplicate">
<GeneralLoader v-if="isOnDuplicateLoading" size="regular" />
<GeneralIcon v-else class="nc-view-copy-icon opacity-80" icon="duplicate" />
{{
$t('general.duplicateEntity', {
entity: $t('title.defaultView').toLowerCase(),
})
}}
</NcMenuItem>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('tableDelete', { roles: baseRole, source })"
:data-testid="`sidebar-table-delete-${table.title}`"
class="nc-table-delete"
danger
:disabled="!!table.synced"
@click="deleteTable"
>
<div v-e="['c:table:delete']" class="flex gap-2 items-center">
<GeneralIcon icon="delete" />
{{ $t('general.delete') }} {{ $t('objects.table').toLowerCase() }}
</div>
</NcMenuItem>
</template>
</NcMenu>
</template>
</NcDropdown>
<NcButton
v-e="['c:table:toggle-expand']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand text-gray-700 hover:text-gray-800"
:class="{
'!opacity-100 !visible': isOptionsOpen,
}"
@click.stop="onExpand"
>
<GeneralIcon
icon="chevronRight"
class="nc-sidebar-source-node-btns cursor-pointer transform transition-transform duration-200 !text-current text-[20px]"
:class="{ '!rotate-90': isExpanded }"
/>
</NcButton>
</div>
</div>
</div>
<DlgTableDelete
v-if="table.id && base?.id"
v-model:visible="isTableDeleteDialogVisible"
:table-id="table.id"
:base-id="base.id"
/>
<DlgTablePermissions
v-if="table.id && isEeUI"
v-model:visible="isTablePermissionsDialogVisible"
:table-id="table.id"
:title="table.title"
/>
<DashboardTreeViewViewsList v-if="isExpanded" :table-id="table.id" :base-id="base.id" @deleted="refreshViews" />
</div>
</template>
<style scoped lang="scss">
.nc-tree-item {
@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 {
&:not(.nc-info-icon) {
@apply text-primary text-opacity-60;
}
}
:deep(.nc-menu-item-inner) {
@apply !w-full;
}
</style>