Files
nocodb/packages/nc-gui/components/dashboard/TreeView/Table/Node.vue
2026-01-17 10:29:17 +00:00

683 lines
21 KiB
Vue

<script lang="ts" setup>
import { type BaseType, PlanFeatureTypes, PlanTitles, type TableType } 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 } = useViewsStore()
const { activeView } = storeToRefs(useViewsStore())
const { isLeftSidebarOpen } = storeToRefs(useSidebarStore())
const { showRecordPlanLimitExceededModal } = useEeConfig()
// 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?.trim().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.internal.postOperation(
table.fk_workspace_id!,
table.base_id!,
{
operation: 'tableUpdate',
tableId: 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 as string, baseId: base.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) {
try {
await _openTable(table.value, true)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
return
}
isLoading.value = true
try {
await _openTable(table.value)
if (isMobileMode.value) {
isLeftSidebarOpen.value = false
}
} catch (e: any) {
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 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
}
async function onPermissions(_table: SidebarTableNode) {
isOptionsOpen.value = false
isTablePermissionsDialogVisible.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()
}
const enabledOptions = computed(() => {
return {
tableRename: isUIAllowed('tableRename', { roles: baseRole?.value, source: source.value }),
tableDescriptionEdit: isUIAllowed('tableDescriptionEdit', { roles: baseRole?.value, source: source.value }),
tableDuplicate:
isUIAllowed('tableDuplicate', {
source: source.value,
}) &&
(source.value?.is_meta || source.value?.is_local),
tablePermission:
isEeUI && table.value?.type === 'table' && isUIAllowed('tablePermission', { roles: baseRole?.value, source: source.value }),
tableDelete: isUIAllowed('tableDelete', { roles: baseRole?.value, source: source.value }),
}
})
</script>
<template>
<div
class="nc-tree-item nc-table-node-wrapper text-sm select-none w-full bg-inherit"
: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-nc-bg-gray-medium"
:class="{
'hover:bg-nc-bg-gray-medium': openedTableId !== table.id,
'pl-8 !xs:(pl-7)': sourceIndex !== 0,
'pl-2 xs:(pl-2)': sourceIndex === 0,
}"
: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-nc-content-gray-subtle2" />
<div
v-else
v-e="['c:table:emoji-picker']"
class="flex items-center nc-table-icon-wrapper 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="nc-table-icon w-4 text-sm !text-nc-gray-600/75" />
<component
:is="iconMap.table"
v-else-if="table.type === 'table'"
class="nc-table-icon w-4 text-sm !text-nc-gray-600/75"
/>
<MdiEye v-else class="nc-table-iconflex w-5 text-sm !text-nc-gray-600/75" />
</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"
: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="text-nc-content-gray-subtle"
: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>
<div class="whitespace-pre-wrap break-words">{{ table.description }}</div>
</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-nc-content-gray-subtle2 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-nc-content-gray-subtle hover:text-nc-content-gray"
: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,
})
"
:data-testid="`sidebar-table-copy-id-${table.title}`"
/>
<template
v-if="
!isSharedBase &&
(enabledOptions.tableRename ||
enabledOptions.tableDescriptionEdit ||
enabledOptions.tableDuplicate ||
enabledOptions.tablePermission)
"
>
<NcDivider v-if="enabledOptions.tableRename || enabledOptions.tableDuplicate" />
<NcMenuItem
v-if="enabledOptions.tableRename"
: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="enabledOptions.tableDuplicate"
: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="enabledOptions.tableDescriptionEdit"
: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="opacity-80" />
{{ $t('labels.editTableDescription') }}
</div>
</NcMenuItem>
<PaymentUpgradeBadgeProvider
v-if="enabledOptions.tablePermission"
: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>
</template>
<template v-if="enabledOptions.tableDelete">
<NcDivider />
<NcMenuItem
: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-nc-content-gray-subtle2 hover:text-nc-content-gray"
: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" />
</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):not(.nc-table-icon):not(.nc-view-icon):not(.nc-script-icon):not(.nc-dashboard-icon):not(
.nc-workflow-icon
) {
@apply text-primary/60;
}
}
:deep(.nc-menu-item-inner) {
@apply !w-full;
}
</style>