Files
nocodb/packages/nc-gui/components/dashboard/TreeView/Table/index.vue

447 lines
17 KiB
Vue

<script setup lang="ts">
import type { SourceType, TableType } from 'nocodb-sdk'
defineProps<{
baseId: string
}>()
const emits = defineEmits(['createTable'])
const { $e } = useNuxtApp()
const { api } = useApi()
const basesStore = useBases()
const { isUIAllowed } = useRoles()
const { refreshCommandPalette } = useCommandPalette()
const { refreshViewTabTitle } = useViewsStore()
const { activeTable } = storeToRefs(useTablesStore())
const { isSharedBase } = storeToRefs(useBase())
const { loadProject } = basesStore
const { setMenuContext } = inject(TreeViewInj)!
const isExpanded = ref(true)
const activeKey = ref<string[]>([])
const filterQuery = ref('')
const keys = ref<Record<string, number>>({})
const isBasesOptionsOpen = ref<Record<string, boolean>>({})
const sourceRenameHelpers = ref<
Record<
string,
{
editMode: boolean
tempTitle: string
}
>
>({})
const [searchActive] = useToggle()
const base = inject(ProjectInj)!
const baseRole = computed(() => base.value.project_role || base.value.workspace_role)
const hasTableCreatePermission = computed(() => {
return isUIAllowed('tableCreate', { roles: base.value.project_role, source: base.value?.sources?.[0] })
})
const enableEditModeForSource = (sourceId: string) => {
if (!isUIAllowed('baseRename')) return
const source = base.value.sources?.find((s) => s.id === sourceId)
if (!source?.id) return
sourceRenameHelpers.value[source.id] = {
editMode: true,
tempTitle: source.alias || '',
}
nextTick(() => {
const input: HTMLInputElement | null = document.querySelector(`[data-source-rename-input-id="${sourceId}"]`)
if (!input) return
input?.focus()
input?.select()
})
}
const showBaseOption = (source: SourceType) => {
return ['airtableImport', 'csvImport', 'jsonImport', 'excelImport'].some((permission) => isUIAllowed(permission, { source }))
}
const updateSourceTitle = async (sourceId: string) => {
const source = base.value.sources?.find((s) => s.id === sourceId)
if (!source?.id || !sourceRenameHelpers.value[source.id]) return
if (sourceRenameHelpers.value[source.id].tempTitle) {
sourceRenameHelpers.value[source.id].tempTitle = sourceRenameHelpers.value[source.id].tempTitle.trim()
}
if (!sourceRenameHelpers.value[source.id].tempTitle) {
delete sourceRenameHelpers.value[source.id]
return
}
try {
await api.source.update(source.base_id, source.id, {
alias: sourceRenameHelpers.value[source.id].tempTitle,
})
await loadProject(source.base_id, true)
delete sourceRenameHelpers.value[source.id]
$e('a:source:rename')
refreshViewTabTitle?.()
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
} finally {
refreshCommandPalette()
}
}
/**
* Opens a dialog to create a new table.
*
* @returns {void}
*
* @remarks
* This function is triggered when the user initiates the table creation process.
* It opens a dialog for table creation, handles the dialog closure,
* and potentially scrolls to the newly created table.
*
* @see {@link packages/nc-gui/components/smartsheet/topbar/TableListDropdown.vue} for a similar implementation
* of table creation dialog. If this function is updated, consider updating the other implementation as well.
*/
function openTableCreateDialog(sourceIndex?: number | undefined, showSourceSelector = true) {
const isOpen = ref(true)
let sourceId = base.value!.sources?.[0].id
if (typeof sourceIndex === 'number') {
sourceId = base.value!.sources?.[sourceIndex].id
}
if (!sourceId || !base.value?.id) return
const { close } = useDialog(resolveComponent('DlgTableCreate'), {
'modelValue': isOpen,
sourceId,
'baseId': base.value!.id,
'onCreate': closeDialog,
'showSourceSelector': showSourceSelector,
'onUpdate:modelValue': () => closeDialog(),
})
function closeDialog(table?: TableType) {
isOpen.value = false
if (!table) return
isExpanded.value = true
if (!activeKey.value || !activeKey.value.includes(`collapse-${sourceId}`)) {
activeKey.value.push(`collapse-${sourceId}`)
}
// TODO: Better way to know when the table node dom is available
setTimeout(() => {
const newTableDom = document.querySelector(`[data-table-id="${table.id}"]`)
if (!newTableDom) return
newTableDom?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 1000)
close(1000)
}
}
function openErdView(source: SourceType) {
$e('c:project:relation')
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgBaseErd'), {
'modelValue': isOpen,
'sourceId': source!.id,
'onUpdate:modelValue': () => closeDialog(),
'baseId': base.value.id,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
watch(
() => activeTable.value?.id,
async () => {
if (!activeTable.value) return
const sourceId = activeTable.value.source_id
if (!sourceId) return
if (!activeKey.value.includes(`collapse-${sourceId}`)) {
activeKey.value.push(`collapse-${sourceId}`)
}
},
{
immediate: true,
},
)
onKeyStroke('Escape', () => {
for (const key of Object.keys(isBasesOptionsOpen.value)) {
isBasesOptionsOpen.value[key] = false
}
})
</script>
<template>
<div class="nc-project-home-section">
<div class="nc-project-home-section-header !cursor-pointer" @click.stop="isExpanded = !isExpanded">
<div class="flex-1">{{ $t('objects.tables') }}</div>
<GeneralIcon
icon="chevronRight"
class="flex-none nc-sidebar-source-node-btns cursor-pointer transform transition-transform duration-200 text-[20px] text-nc-content-gray-muted"
:class="{ '!rotate-90': isExpanded }"
/>
</div>
<div key="g1" class="overflow-x-hidden transition-max-height" :class="{ 'max-h-0': !isExpanded }">
<template v-if="base && base?.sources">
<div class="flex-1 overflow-y-auto overflow-x-hidden flex flex-col" :class="{ 'mb-[20px]': isSharedBase }">
<div v-if="base?.sources?.[0]?.enabled" class="flex-1">
<div class="transition-height duration-200">
<DashboardTreeViewTableList
:base="base"
:base-id="baseId"
:source-index="0"
:show-create-table-btn="hasTableCreatePermission"
@create-table="emits('createTable')"
/>
</div>
</div>
<div v-if="base?.sources?.slice(1).some((el) => el.enabled)" class="transition-height duration-200">
<div class="border-none sortable-list">
<div v-for="(source, sourceIndex) of base.sources" :key="`source-${source.id}`">
<template v-if="sourceIndex === 0"></template>
<a-collapse
v-else-if="source && source.enabled"
v-model:active-key="activeKey"
v-e="['c:source:toggle-expand']"
class="!mx-0 !px-0 nc-sidebar-source-node"
:class="[{ hidden: searchActive && !!filterQuery }]"
expand-icon-position="right"
:bordered="false"
ghost
>
<template #expandIcon="{ isActive, header }">
<NcButton
v-if="
!(
header?.[0]?.props?.['data-sourceId'] &&
sourceRenameHelpers[header?.[0]?.props?.['data-sourceId']]?.editMode
)
"
v-e="['c:external:base:expand']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn nc-sidebar-expand !xs:opacity-100 !mr-0 mt-0.5"
:class="{ '!opacity-100 !inline-block': isBasesOptionsOpen[source!.id!] }"
>
<GeneralIcon
icon="chevronDown"
class="flex-none cursor-pointer transform transition-transform duration-500 rotate-270"
:class="{ '!rotate-360': isActive }"
/>
</NcButton>
</template>
<a-collapse-panel :key="`collapse-${source.id}`">
<template #header>
<div
:data-sourceId="source.id"
class="nc-sidebar-node min-w-20 w-full h-full flex flex-row group py-0.5 !mr-0"
:class="{
'pr-0.5': source.id && sourceRenameHelpers[source.id]?.editMode,
'pr-6.5': !(source.id && sourceRenameHelpers[source.id]?.editMode),
}"
>
<div
v-if="sourceIndex === 0"
class="source-context flex items-center gap-2 text-nc-content-gray nc-sidebar-node-title"
@contextmenu="setMenuContext('source', source)"
>
<GeneralBaseLogo class="flex-none min-w-4 !xs:(min-w-4.25 w-4.25 text-sm)" />
{{ $t('general.default') }}
</div>
<div
v-else
class="source-context flex flex-grow items-center gap-1 text-nc-content-gray min-w-1/20 max-w-full"
@contextmenu="setMenuContext('source', source)"
>
<NcTooltip
:tooltip-style="{ 'min-width': 'max-content' }"
:overlay-inner-style="{ 'min-width': 'max-content' }"
:mouse-leave-delay="0.3"
placement="topLeft"
trigger="hover"
class="flex items-center"
>
<template #title>
<component :is="getSourceTooltip(source)" />
</template>
<div class="flex-none w-6 flex items-center justify-center">
<GeneralBaseLogo
:color="getSourceIconColor(source)"
class="flex-none min-w-4 !xs:(min-w-4.25 w-4.25 text-sm)"
/>
</div>
</NcTooltip>
<a-input
v-if="source.id && sourceRenameHelpers[source.id]?.editMode"
ref="input"
v-model:value="sourceRenameHelpers[source.id].tempTitle"
class="capitalize !bg-transparent flex-1 mr-4 !pr-1.5 !text-nc-content-gray-subtle !rounded-md !h-6 animate-sidebar-node-input-padding"
:style="{
fontWeight: 'inherit',
}"
:data-source-rename-input-id="source.id"
@click.stop
@keydown.enter.stop.prevent
@keyup.enter="updateSourceTitle(source.id!)"
@keyup.esc="updateSourceTitle(source.id!)"
@blur="updateSourceTitle(source.id!)"
@keydown.stop
/>
<NcTooltip
v-else
class="nc-sidebar-node-title capitalize text-ellipsis overflow-hidden select-none text-nc-content-gray-subtle"
:style="{ wordBreak: 'keep-all', whiteSpace: 'nowrap', display: 'inline' }"
show-on-truncate-only
>
<template #title> {{ source.alias || '' }}</template>
<span
:data-testid="`nc-sidebar-base-${source.alias}`"
@dblclick.stop="enableEditModeForSource(source.id!)"
>
{{ source.alias || '' }}
</span>
</NcTooltip>
</div>
<div
v-if="!(source.id && sourceRenameHelpers[source.id]?.editMode)"
class="flex flex-row items-center gap-x-0.25"
>
<NcDropdown
:visible="isBasesOptionsOpen[source!.id!]"
:trigger="['click']"
@update:visible="isBasesOptionsOpen[source!.id!] = $event"
>
<NcButton
v-e="['c:source:options']"
class="nc-sidebar-node-btn"
:class="{ '!text-nc-content-gray-extreme !opacity-100 !inline-block': isBasesOptionsOpen[source!.id!] }"
type="text"
size="xxsmall"
@click.stop="isBasesOptionsOpen[source!.id!] = !isBasesOptionsOpen[source!.id!]"
>
<GeneralIcon icon="threeDotHorizontal" class="text-xl w-4.75" />
</NcButton>
<template #overlay>
<NcMenu
class="nc-scrollbar-md !min-w-50"
:style="{
maxHeight: '70vh',
overflow: 'overlay',
}"
variant="small"
@click="isBasesOptionsOpen[source!.id!] = false"
>
<NcMenuItemCopyId
:id="source.id"
:tooltip="$t('labels.clickToCopySourceID')"
:label="
$t('labels.sourceIdColon', {
sourceId: source.id,
})
"
@click.stop
/>
<NcDivider />
<NcMenuItem
v-if="isUIAllowed('baseRename')"
data-testid="nc-sidebar-source-rename"
@click="enableEditModeForSource(source.id!)"
>
<GeneralIcon icon="rename" />
{{ $t('general.rename') }}
</NcMenuItem>
<NcDivider />
<!-- ERD View -->
<NcMenuItem key="erd" @click="openErdView(source)">
<div v-e="['c:source:erd']" class="flex gap-2 items-center">
<GeneralIcon icon="ncErd" />
{{ $t('title.relations') }}
</div>
</NcMenuItem>
<DashboardTreeViewBaseOptions
v-if="showBaseOption(source)"
v-model:base="base"
:source="source"
:show-source-selector="false"
/>
</NcMenu>
</template>
</NcDropdown>
<NcButton
v-if="isUIAllowed('tableCreate', { roles: baseRole, source })"
v-e="['c:source:add-table']"
type="text"
size="xxsmall"
class="nc-sidebar-node-btn"
:class="{ '!opacity-100 !inline-block': isBasesOptionsOpen[source!.id!] }"
@click.stop="openTableCreateDialog(sourceIndex, false)"
>
<GeneralIcon icon="plus" class="text-xl leading-5" style="-webkit-text-stroke: 0.15px" />
</NcButton>
</div>
</div>
</template>
<div
ref="menuRefs"
:key="`sortable-${source.id}-${source.id && source.id in keys ? keys[source.id] : '0'}`"
:nc-source="source.id"
>
<DashboardTreeViewTableList :base="base" :base-id="baseId" :source-index="sourceIndex" />
</div>
</a-collapse-panel>
</a-collapse>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</template>