mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-01 10:36:47 +00:00
fix: base list modal oss changes
This commit is contained in:
277
packages/nc-gui/components/workspace/BaseListModal/BaseNode.vue
Normal file
277
packages/nc-gui/components/workspace/BaseListModal/BaseNode.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<script lang="ts" setup>
|
||||
import { useBaseActions } from './useBaseActions'
|
||||
|
||||
const props = defineProps<{
|
||||
base: NcProject
|
||||
isMarked?: boolean
|
||||
// Indicator icons - shown when base has attribute but displayed in another section
|
||||
showStarIndicator?: boolean
|
||||
showPrivateIndicator?: boolean
|
||||
}>()
|
||||
|
||||
// Get actions from provider
|
||||
const { onRename, onToggleStarred, onDuplicate, onOpenErd, onOpenSettings, onDelete, onUpdateColor, onSelect } =
|
||||
useBaseActions()
|
||||
|
||||
const { isUIAllowed } = useRoles()
|
||||
const { $e } = useNuxtApp()
|
||||
const { showRecordPlanLimitExceededModal } = useEeConfig()
|
||||
|
||||
// Local state
|
||||
const isMenuOpen = ref(false)
|
||||
const editMode = ref(false)
|
||||
const tempTitle = ref('')
|
||||
const inputRef = useTemplateRef('inputRef')
|
||||
|
||||
// Computed
|
||||
const iconColor = computed(() => parseProp(props.base.meta).iconColor)
|
||||
const baseRole = computed(() => props.base.project_role)
|
||||
|
||||
const isOptionVisible = computed(() => ({
|
||||
baseRename: isUIAllowed('baseRename'),
|
||||
baseDuplicate: isUIAllowed('baseDuplicate', { roles: baseRole.value }),
|
||||
baseMiscSettings: isUIAllowed('baseMiscSettings'),
|
||||
baseDelete: isUIAllowed('baseDelete', { roles: baseRole.value }),
|
||||
}))
|
||||
|
||||
// Handlers
|
||||
const handleSelect = () => {
|
||||
if (editMode.value) return
|
||||
onSelect(props.base)
|
||||
}
|
||||
|
||||
const enableEditMode = () => {
|
||||
if (!isOptionVisible.value.baseRename) return
|
||||
|
||||
editMode.value = true
|
||||
tempTitle.value = props.base.title || ''
|
||||
isMenuOpen.value = false
|
||||
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
inputRef.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const updateTitle = () => {
|
||||
if (tempTitle.value?.trim()) {
|
||||
tempTitle.value = tempTitle.value.trim()
|
||||
}
|
||||
|
||||
if (!tempTitle.value || tempTitle.value === props.base.title) {
|
||||
editMode.value = false
|
||||
tempTitle.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
onRename(props.base, tempTitle.value)
|
||||
editMode.value = false
|
||||
tempTitle.value = ''
|
||||
}
|
||||
|
||||
const handleToggleStarred = () => {
|
||||
onToggleStarred(props.base)
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
|
||||
const handleDuplicate = () => {
|
||||
if (showRecordPlanLimitExceededModal()) return
|
||||
onDuplicate(props.base)
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
|
||||
const handleOpenErd = () => {
|
||||
const source = props.base.sources?.[0]
|
||||
if (source) {
|
||||
onOpenErd(props.base, source)
|
||||
}
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
onOpenSettings(props.base.id!)
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
onDelete(props.base)
|
||||
isMenuOpen.value = false
|
||||
}
|
||||
|
||||
const handleColorChange = (color: string) => {
|
||||
onUpdateColor(props.base, color)
|
||||
}
|
||||
|
||||
const onMenuClick = (e: Event) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:tabindex="0"
|
||||
class="nc-base-node group relative flex items-center gap-3 p-3 rounded-xl cursor-pointer border-1 transition-all border-nc-border-gray-medium hover:border-nc-border-gray-dark hover:shadow-sm"
|
||||
:class="{ 'is-marked': isMarked, 'is-editing': editMode }"
|
||||
@click="handleSelect"
|
||||
@keydown.enter.stop="handleSelect"
|
||||
>
|
||||
<!-- Project Icon with Color Picker -->
|
||||
<GeneralBaseIconColorPicker
|
||||
:managed-app="{
|
||||
managed_app_master: base.managed_app_master,
|
||||
managed_app_id: base.managed_app_id,
|
||||
}"
|
||||
:key="`${base.id}_${iconColor}`"
|
||||
:type="base?.type"
|
||||
:model-value="iconColor"
|
||||
size="small"
|
||||
:readonly="!isOptionVisible.baseRename"
|
||||
@update:model-value="handleColorChange"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<div class="flex-1 min-w-0 min-h-[28px] flex items-center">
|
||||
<!-- Inline Edit Input -->
|
||||
<a-input
|
||||
v-if="editMode"
|
||||
ref="inputRef"
|
||||
v-model:value="tempTitle"
|
||||
class="!bg-transparent !text-sm !font-medium !rounded-md !px-1 !h-7 !-ml-1.2"
|
||||
@click.stop
|
||||
@keyup.enter="updateTitle"
|
||||
@keyup.esc="updateTitle"
|
||||
@blur="updateTitle"
|
||||
@keydown.stop
|
||||
/>
|
||||
<!-- Title Display -->
|
||||
<NcTooltip v-else show-on-truncate-only class="min-w-0 truncate text-sm font-medium">
|
||||
{{ base.title }}
|
||||
|
||||
<template #title>{{ base.title }}</template>
|
||||
</NcTooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Indicator icons when base has attribute but shown in another section -->
|
||||
<div v-if="showStarIndicator || showPrivateIndicator" class="flex items-center gap-1">
|
||||
<NcTooltip v-if="showStarIndicator" class="flex">
|
||||
<GeneralIcon icon="star" class="flex-none w-3.5 h-3.5 text-nc-content-gray-muted" />
|
||||
<template #title>{{ $t('general.starred') }}</template>
|
||||
</NcTooltip>
|
||||
<NcTooltip v-if="showPrivateIndicator" class="flex">
|
||||
<GeneralIcon icon="ncLock" class="flex-none w-3.5 h-3.5 text-nc-content-gray-muted" />
|
||||
<template #title>{{ $t('general.private') }}</template>
|
||||
</NcTooltip>
|
||||
</div>
|
||||
|
||||
<!-- More Options Button -->
|
||||
<div v-if="!editMode" class="nc-base-node-menu-wrapper" :class="{ 'is-open': isMenuOpen }">
|
||||
<NcDropdown
|
||||
v-model:visible="isMenuOpen"
|
||||
:trigger="['click']"
|
||||
placement="bottomRight"
|
||||
overlay-class-name="nc-base-node-menu"
|
||||
>
|
||||
<NcButton :tabindex="-1" type="text" size="xsmall" class="nc-base-node-menu-btn" @click.stop="onMenuClick">
|
||||
<GeneralIcon icon="threeDotVertical" class="text-nc-content-gray-muted" />
|
||||
</NcButton>
|
||||
|
||||
<template #overlay>
|
||||
<NcMenu class="!min-w-50" variant="small">
|
||||
<!-- Copy Base ID -->
|
||||
<NcMenuItemCopyId
|
||||
:id="base.id"
|
||||
:tooltip="$t('labels.clickToCopyBaseID')"
|
||||
:label="$t('labels.baseIdColon', { baseId: base.id })"
|
||||
/>
|
||||
<NcDivider />
|
||||
|
||||
<!-- Rename -->
|
||||
<NcMenuItem v-if="isOptionVisible.baseRename" data-testid="nc-base-node-rename" @click="enableEditMode">
|
||||
<GeneralIcon icon="rename" />
|
||||
{{ $t('general.rename') }} {{ $t('objects.project').toLowerCase() }}
|
||||
</NcMenuItem>
|
||||
|
||||
<!-- Toggle Starred -->
|
||||
<NcMenuItem data-testid="nc-base-node-starred" @click="handleToggleStarred">
|
||||
<GeneralIcon v-if="base.starred" icon="unStar" />
|
||||
<GeneralIcon v-else icon="star" />
|
||||
{{ base.starred ? $t('activity.removeFromStarred') : $t('activity.addToStarred') }}
|
||||
</NcMenuItem>
|
||||
|
||||
<!-- Duplicate -->
|
||||
<NcMenuItem v-if="isOptionVisible.baseDuplicate" data-testid="nc-base-node-duplicate" @click="handleDuplicate">
|
||||
<GeneralIcon icon="duplicate" />
|
||||
{{ $t('general.duplicate') }} {{ $t('objects.project').toLowerCase() }}
|
||||
</NcMenuItem>
|
||||
|
||||
<NcDivider />
|
||||
|
||||
<!-- ERD View -->
|
||||
<NcMenuItem v-if="base?.sources?.[0]?.enabled" data-testid="nc-base-node-erd" @click="handleOpenErd">
|
||||
<GeneralIcon icon="ncErd" />
|
||||
{{ $t('title.relations') }}
|
||||
</NcMenuItem>
|
||||
|
||||
<!-- Settings -->
|
||||
<NcMenuItem v-if="isOptionVisible.baseMiscSettings" data-testid="nc-base-node-settings" @click="handleOpenSettings">
|
||||
<GeneralIcon icon="settings" />
|
||||
{{ $t('activity.settings') }}
|
||||
</NcMenuItem>
|
||||
|
||||
<template v-if="isOptionVisible.baseDelete">
|
||||
<NcDivider />
|
||||
|
||||
<!-- Delete -->
|
||||
<NcMenuItem danger data-testid="nc-base-node-delete" @click="handleDelete">
|
||||
<GeneralIcon icon="delete" />
|
||||
{{ $t('general.delete') }} {{ $t('objects.project').toLowerCase() }}
|
||||
</NcMenuItem>
|
||||
</template>
|
||||
</NcMenu>
|
||||
</template>
|
||||
</NcDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.nc-base-node {
|
||||
@apply bg-white dark:bg-nc-bg-gray-light;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
@apply bg-nc-bg-gray-light dark:bg-nc-bg-gray-medium;
|
||||
|
||||
.nc-base-node-menu-wrapper {
|
||||
@apply w-6 !flex;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@apply outline-none shadow-focus;
|
||||
|
||||
.nc-base-node-menu-wrapper {
|
||||
@apply w-6 !flex;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-marked {
|
||||
@apply bg-nc-bg-gray-medium border-nc-border-brand;
|
||||
}
|
||||
|
||||
&.is-editing {
|
||||
@apply cursor-default;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-base-node-menu-wrapper {
|
||||
@apply w-0 hidden overflow-hidden items-center justify-center;
|
||||
@apply transition-all duration-200 ease-in-out;
|
||||
|
||||
&.is-open {
|
||||
@apply w-6 !flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" setup>
|
||||
type FilterType = 'all' | 'starred' | 'private' | 'owned' | 'managed'
|
||||
|
||||
const props = defineProps<{
|
||||
baseCount: number
|
||||
activeFilter: FilterType
|
||||
isCompactView?: boolean
|
||||
searchQuery?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeFilter': [filter: FilterType]
|
||||
'update:searchQuery': [query: string]
|
||||
}>()
|
||||
|
||||
const vSearchQuery = useVModel(props, 'searchQuery', emit)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { isMobileMode } = useGlobal()
|
||||
|
||||
const isFilterDropdownOpen = ref(false)
|
||||
const isSearchFocused = ref(false)
|
||||
|
||||
// Filter options in priority order: Starred → Private → Managed → Owned
|
||||
const filterOptions = computed<NcListItemType[]>(() => [
|
||||
{ value: 'all', label: t('activity.allBases'), icon: 'ncList' },
|
||||
{ value: 'starred', label: t('general.starred'), icon: 'star' },
|
||||
{ value: 'private', label: t('general.private'), icon: 'ncLock' },
|
||||
{ value: 'managed', label: t('labels.managed'), icon: 'ncBox' },
|
||||
{ value: 'owned', label: t('activity.ownedByMe'), icon: 'ncUser' },
|
||||
])
|
||||
|
||||
const selectedFilter = computed(() => {
|
||||
return filterOptions.value.find((option) => option.value === props.activeFilter)
|
||||
})
|
||||
|
||||
// Get icon for active filter (for compact display)
|
||||
const activeFilterIcon = computed(() => {
|
||||
return selectedFilter.value?.icon || 'ncList'
|
||||
})
|
||||
|
||||
const onFilterChange = (value: string) => {
|
||||
emit('update:activeFilter', value as FilterType)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nc-bases-header flex items-center gap-2 px-4 py-2 border-b border-nc-border-gray-medium">
|
||||
<!-- Desktop: Show "Bases in {workspace}" -->
|
||||
<template v-if="!isCompactView">
|
||||
<div class="flex-1 flex items-center gap-2 text-bodyDefaultSm font-medium">
|
||||
<slot name="baseListHeader"> </slot>
|
||||
<span class="font-normal text-nc-content-gray-muted">({{ baseCount }})</span>
|
||||
</div>
|
||||
|
||||
<!-- Filter Dropdown - Desktop -->
|
||||
<NcListDropdown v-model:is-open="isFilterDropdownOpen" :default-slot-wrapper="false" placement="bottomRight">
|
||||
<NcButton size="small" type="secondary">
|
||||
<div class="flex items-center gap-1">
|
||||
<GeneralIcon :icon="activeFilterIcon" class="w-4 h-4" />
|
||||
<span class="text-bodyDefaultSm">{{ selectedFilter?.label }}</span>
|
||||
<GeneralIcon
|
||||
icon="chevronDown"
|
||||
class="w-4 h-4 transition-transform"
|
||||
:class="{ 'transform rotate-180': isFilterDropdownOpen }"
|
||||
/>
|
||||
</div>
|
||||
</NcButton>
|
||||
<template #overlay="{ onEsc }">
|
||||
<NcList
|
||||
v-model:open="isFilterDropdownOpen"
|
||||
:value="activeFilter"
|
||||
:list="filterOptions"
|
||||
variant="medium"
|
||||
class="!w-auto min-w-[190px]"
|
||||
:min-items-for-search="10"
|
||||
@update:value="onFilterChange"
|
||||
@escape="onEsc"
|
||||
>
|
||||
<template #listItemExtraLeft="{ option }">
|
||||
<GeneralIcon :icon="option.icon" class="w-4 h-4 text-nc-content-gray-muted" />
|
||||
</template>
|
||||
</NcList>
|
||||
</template>
|
||||
</NcListDropdown>
|
||||
</template>
|
||||
|
||||
<!-- Compact: Search + Filter -->
|
||||
<template v-else>
|
||||
<!-- Search Input -->
|
||||
<a-input
|
||||
v-model:value="vSearchQuery"
|
||||
class="nc-bases-search nc-input-sm flex-1"
|
||||
:placeholder="$t('activity.searchProject')"
|
||||
allow-clear
|
||||
@focus="isSearchFocused = true"
|
||||
@blur="isSearchFocused = false"
|
||||
>
|
||||
<template #prefix>
|
||||
<GeneralIcon icon="search" class="text-nc-content-gray-muted" />
|
||||
</template>
|
||||
</a-input>
|
||||
|
||||
<!-- Filter Dropdown - Compact (shows icon only when search focused) -->
|
||||
<NcListDropdown v-model:is-open="isFilterDropdownOpen" :default-slot-wrapper="false" placement="bottomRight">
|
||||
<NcButton size="small" type="secondary" class="flex-none">
|
||||
<div class="flex items-center gap-1">
|
||||
<GeneralIcon :icon="activeFilterIcon" class="w-4 h-4" />
|
||||
<template v-if="(!isSearchFocused && !vSearchQuery) || !isMobileMode">
|
||||
<span class="max-w-20 truncate">{{ selectedFilter?.label }}</span>
|
||||
<GeneralIcon
|
||||
icon="chevronDown"
|
||||
class="w-4 h-4 transition-transform"
|
||||
:class="{ 'transform rotate-180': isFilterDropdownOpen }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</NcButton>
|
||||
<template #overlay="{ onEsc }">
|
||||
<NcList
|
||||
v-model:open="isFilterDropdownOpen"
|
||||
:value="activeFilter"
|
||||
:list="filterOptions"
|
||||
variant="medium"
|
||||
class="!w-auto min-w-[190px]"
|
||||
:min-items-for-search="10"
|
||||
@update:value="onFilterChange"
|
||||
@escape="onEsc"
|
||||
>
|
||||
<template #listItemExtraLeft="{ option }">
|
||||
<GeneralIcon :icon="option.icon" class="w-4 h-4 text-nc-content-gray-muted" />
|
||||
</template>
|
||||
</NcList>
|
||||
</template>
|
||||
</NcListDropdown>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,195 @@
|
||||
<script lang="ts" setup>
|
||||
import Sortable, { type SortableEvent } from 'sortablejs'
|
||||
import { useBaseActions } from './useBaseActions'
|
||||
|
||||
type SectionType = 'starred' | 'private' | 'owned' | 'managed' | 'default'
|
||||
|
||||
const props = defineProps<{
|
||||
type: SectionType
|
||||
bases: NcProject[]
|
||||
isFilterApplied: boolean
|
||||
// Functions to check if a base has starred/private attributes
|
||||
// Used to show indicator icons when base is displayed in a lower-priority section
|
||||
isBaseStarred?: (base: NcProject) => boolean
|
||||
isBasePrivate?: (base: NcProject) => boolean
|
||||
}>()
|
||||
|
||||
const { isFilterApplied } = toRefs(props)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isUIAllowed } = useRoles()
|
||||
const { isMobileMode } = useGlobal()
|
||||
|
||||
// Get reorder action from provider
|
||||
const { onReorder } = useBaseActions()
|
||||
|
||||
const gridRef = useTemplateRef('gridRef')
|
||||
const dragging = ref(false)
|
||||
const isMarked = ref<string | false>(false)
|
||||
|
||||
let sortable: Sortable | null = null
|
||||
|
||||
// Section configuration - using object map instead of switch
|
||||
const sectionConfigs: Record<SectionType, { icon: string; labelKey: string }> = {
|
||||
starred: { icon: 'star', labelKey: 'general.starred' },
|
||||
owned: { icon: 'ncUser', labelKey: 'activity.ownedByMe' },
|
||||
private: { icon: 'ncLock', labelKey: 'general.private' },
|
||||
managed: { icon: 'ncBox', labelKey: 'labels.managed' },
|
||||
default: { icon: 'ncList', labelKey: 'general.all' },
|
||||
}
|
||||
|
||||
const sectionConfig = computed(() => {
|
||||
const config = sectionConfigs[props.type]
|
||||
return {
|
||||
icon: config.icon,
|
||||
label: t(config.labelKey),
|
||||
}
|
||||
})
|
||||
|
||||
// Create bases by ID lookup for efficient access during drag
|
||||
const basesById = computed(() =>
|
||||
props.bases.reduce<Record<string, NcProject>>((acc, base) => {
|
||||
acc[base.id!] = base
|
||||
return acc
|
||||
}, {}),
|
||||
)
|
||||
|
||||
const canReorder = computed(() => {
|
||||
return !isMobileMode.value && isUIAllowed('baseReorder') && props.bases.length > 1
|
||||
})
|
||||
|
||||
// Determine if indicator icons should be shown based on section type
|
||||
const shouldShowStarIndicator = (base: NcProject) => {
|
||||
if (props.type === 'starred') return false
|
||||
return props.isBaseStarred?.(base) ?? false
|
||||
}
|
||||
|
||||
const shouldShowPrivateIndicator = (base: NcProject) => {
|
||||
if (props.type === 'private') return false
|
||||
return props.isBasePrivate?.(base) ?? false
|
||||
}
|
||||
|
||||
/** Briefly highlight an item after sorting */
|
||||
function markItem(id: string) {
|
||||
isMarked.value = id
|
||||
setTimeout(() => {
|
||||
isMarked.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const initSortable = (el: Element) => {
|
||||
if (isMobileMode.value || !isUIAllowed('baseReorder')) return
|
||||
if (sortable) sortable.destroy()
|
||||
|
||||
sortable = Sortable.create(el as HTMLElement, {
|
||||
ghostClass: 'ghost',
|
||||
chosenClass: 'chosen',
|
||||
dragClass: 'dragging',
|
||||
animation: 150,
|
||||
revertOnSpill: true,
|
||||
filter: isTouchEvent,
|
||||
onStart: (evt: SortableEvent) => {
|
||||
evt.stopImmediatePropagation()
|
||||
dragging.value = true
|
||||
},
|
||||
onEnd: async (evt) => {
|
||||
const { newIndex = 0, oldIndex = 0 } = evt
|
||||
|
||||
evt.stopImmediatePropagation()
|
||||
dragging.value = false
|
||||
|
||||
if (newIndex === oldIndex) return
|
||||
|
||||
const itemEl = evt.item as HTMLElement
|
||||
const item = basesById.value[itemEl.dataset.id as string]
|
||||
|
||||
if (!item) return
|
||||
|
||||
const children: HTMLCollection = evt.to.children
|
||||
|
||||
if (children.length < 2) return
|
||||
|
||||
const itemBeforeEl = children[newIndex - 1] as HTMLElement
|
||||
const itemAfterEl = children[newIndex + 1] as HTMLElement
|
||||
|
||||
const itemBefore = itemBeforeEl && basesById.value[itemBeforeEl.dataset.id as string]
|
||||
const itemAfter = itemAfterEl && basesById.value[itemAfterEl.dataset.id as string]
|
||||
|
||||
let newOrder: number
|
||||
|
||||
// Calculate new order using fractional ordering
|
||||
if (children.length - 1 === newIndex) {
|
||||
newOrder = (itemBefore?.order ?? 0) + 1
|
||||
} else if (newIndex === 0) {
|
||||
newOrder = (itemAfter?.order ?? 1) / 2
|
||||
} else {
|
||||
newOrder = ((itemBefore?.order ?? 0) + (itemAfter?.order ?? 0)) / 2
|
||||
}
|
||||
|
||||
onReorder(item, newOrder)
|
||||
markItem(item.id!)
|
||||
},
|
||||
...getDraggableAutoScrollOptions({ scrollSensitivity: 50 }),
|
||||
})
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (gridRef.value && canReorder.value) {
|
||||
initSortable(gridRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (sortable) {
|
||||
sortable.destroy()
|
||||
sortable = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="bases.length || isFilterApplied" class="nc-bases-section mb-6">
|
||||
<div class="flex items-center gap-2 mb-4 text-xs font-medium text-nc-content-gray-muted capitalize tracking-wide">
|
||||
<GeneralIcon :icon="sectionConfig.icon" class="w-3.5 h-3.5" />
|
||||
<span>{{ sectionConfig.label }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="bases.length"
|
||||
ref="gridRef"
|
||||
class="nc-bases-grid grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"
|
||||
:class="{ dragging }"
|
||||
>
|
||||
<WorkspaceBaseListModalBaseNode
|
||||
v-for="base in bases"
|
||||
:key="base.id"
|
||||
:data-id="base.id"
|
||||
:data-order="base.order"
|
||||
:base="base"
|
||||
:is-marked="isMarked === base.id"
|
||||
:show-star-indicator="shouldShowStarIndicator(base)"
|
||||
:show-private-indicator="shouldShowPrivateIndicator(base)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.nc-bases-grid {
|
||||
.ghost,
|
||||
.ghost > * {
|
||||
@apply !pointer-events-none;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
@apply !bg-nc-bg-gray-medium !opacity-50 !border-nc-border-brand;
|
||||
}
|
||||
|
||||
.chosen {
|
||||
@apply !opacity-100;
|
||||
}
|
||||
|
||||
&.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-4 p-4 border-t border-nc-border-gray-medium text-xs text-nc-content-gray-muted dark:bg-nc-bg-gray-extralight"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<kbd class="nc-keyboard-shortcut">Tab</kbd>
|
||||
<span>{{ $t('labels.navigate') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<kbd class="nc-keyboard-shortcut">Enter</kbd>
|
||||
<span>{{ $t('labels.select') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<kbd class="nc-keyboard-shortcut">Esc</kbd>
|
||||
<span>{{ $t('general.close') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nc-keyboard-shortcut {
|
||||
@apply px-2 py-1 bg-nc-bg-gray-light rounded border-1 border-nc-border-gray-medium text-tiny;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,290 @@
|
||||
<script lang="ts" setup>
|
||||
interface Props {}
|
||||
import type { VNodeRef } from '@vue/runtime-core'
|
||||
import { ProjectRoles } from 'nocodb-sdk'
|
||||
import { useBaseActionsProvider } from './useBaseActions'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {})
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const {} = toRefs(props)
|
||||
const emits = defineEmits(['update:visible'])
|
||||
|
||||
const visible = useVModel(props, 'visible', emits)
|
||||
|
||||
// Stores
|
||||
const workspaceStore = useWorkspace()
|
||||
const basesStore = useBases()
|
||||
|
||||
const { workspacesList, activeWorkspaceId } = storeToRefs(workspaceStore)
|
||||
|
||||
const { basesList } = storeToRefs(basesStore)
|
||||
|
||||
const { isMobileMode } = useGlobal()
|
||||
const { $e } = useNuxtApp()
|
||||
|
||||
// Provide base actions to child components
|
||||
const closeModal = () => {
|
||||
visible.value = false
|
||||
}
|
||||
const { dialogState } = useBaseActionsProvider(closeModal)
|
||||
|
||||
// Autofocus search input
|
||||
const focus: VNodeRef = (el) => el?.focus()
|
||||
|
||||
// Responsive state
|
||||
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1024)
|
||||
const isCompactView = computed(() => isMobileMode.value || windowWidth.value < 1024)
|
||||
|
||||
// Modal state - consolidated
|
||||
const modalState = reactive({
|
||||
selectedWorkspaceId: null as string | null,
|
||||
searchQuery: '',
|
||||
activeFilter: 'all' as 'all' | 'starred' | 'private' | 'owned' | 'managed',
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const onResize = () => {
|
||||
windowWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize)
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// Reset state when modal opens
|
||||
watch(visible, (isVisible) => {
|
||||
if (isVisible) {
|
||||
modalState.selectedWorkspaceId = activeWorkspaceId.value || workspacesList.value[0]?.id || null
|
||||
modalState.searchQuery = ''
|
||||
modalState.activeFilter = 'all'
|
||||
}
|
||||
})
|
||||
|
||||
const workspaceBases = computed(() => {
|
||||
return basesList.value
|
||||
})
|
||||
|
||||
const baseCount = computed(() => workspaceBases.value.length)
|
||||
|
||||
// Base attribute checkers
|
||||
const baseCheckers = {
|
||||
starred: (base: NcProject) => !!base.starred,
|
||||
private: (base: NcProject) => base.default_role === ProjectRoles.NO_ACCESS,
|
||||
managed: (base: NcProject) => !!base.managed_app_id,
|
||||
owned: (base: NcProject) => base.project_role === ProjectRoles.OWNER,
|
||||
}
|
||||
|
||||
// Helper to filter bases with search
|
||||
const filterWithSearch = (bases: NcProject[]) => {
|
||||
return bases.filter((base) => searchCompare(base.title, modalState.searchQuery))
|
||||
}
|
||||
|
||||
// Priority-based categorization using a single computed
|
||||
// Each base appears in only ONE category based on highest priority
|
||||
const categorizedBases = computed(() => {
|
||||
const bases = workspaceBases.value
|
||||
const { starred, private: isPrivate, managed, owned } = baseCheckers
|
||||
|
||||
// Priority order: Starred → Private → Managed → Owned → Default
|
||||
const starredBases = bases.filter(starred)
|
||||
const privateBases = bases.filter((b) => !starred(b) && isPrivate(b))
|
||||
const managedBases = bases.filter((b) => !starred(b) && !isPrivate(b) && managed(b))
|
||||
const ownedBases = bases.filter((b) => !starred(b) && !isPrivate(b) && !managed(b) && owned(b))
|
||||
const defaultBases = bases.filter((b) => !starred(b) && !isPrivate(b) && !managed(b) && !owned(b))
|
||||
|
||||
return { starred: starredBases, private: privateBases, managed: managedBases, owned: ownedBases, default: defaultBases }
|
||||
})
|
||||
|
||||
// All bases matching specific filter (not priority-based)
|
||||
const allFilteredBases = computed(() => {
|
||||
const bases = workspaceBases.value
|
||||
return {
|
||||
starred: bases.filter(baseCheckers.starred),
|
||||
private: bases.filter(baseCheckers.private),
|
||||
managed: bases.filter(baseCheckers.managed),
|
||||
owned: bases.filter(baseCheckers.owned),
|
||||
}
|
||||
})
|
||||
|
||||
// Section types for loop rendering
|
||||
type SectionType = 'starred' | 'private' | 'managed' | 'owned' | 'default'
|
||||
const sectionOrder: SectionType[] = ['starred', 'private', 'managed', 'owned', 'default']
|
||||
|
||||
// Get displayed bases based on active filter
|
||||
const displayedSections = computed(() => {
|
||||
const filter = modalState.activeFilter
|
||||
|
||||
if (filter === 'all') {
|
||||
// Show all categories with search filter applied
|
||||
return sectionOrder
|
||||
.map((type) => ({
|
||||
type,
|
||||
bases: filterWithSearch(categorizedBases.value[type]),
|
||||
}))
|
||||
.filter((section) => section.bases.length > 0)
|
||||
}
|
||||
|
||||
// Show only the selected filter category (all bases matching, not priority-filtered)
|
||||
const bases = filterWithSearch(allFilteredBases.value[filter] || [])
|
||||
return [{ type: filter, bases }]
|
||||
})
|
||||
|
||||
const emptyFilterResult = computed(() => {
|
||||
return displayedSections.value.every((section) => section.bases.length === 0) && !modalState.searchQuery
|
||||
})
|
||||
|
||||
// Check if there are no search results
|
||||
const hasNoSearchResults = computed(() => {
|
||||
if (workspaceBases.value.length === 0) return false
|
||||
return displayedSections.value.length === 0 && modalState.searchQuery.length > 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<template></template>
|
||||
<template>
|
||||
<NcModal
|
||||
v-model:visible="visible"
|
||||
:keyboard="true"
|
||||
wrap-class-name="nc-modal-wrapper nc-workspace-base-list-modal-wrapper"
|
||||
nc-modal-class-name="!p-0"
|
||||
:footer="null"
|
||||
size="xl"
|
||||
@keydown.esc="visible = false"
|
||||
>
|
||||
<div class="nc-workspace-base-list-modal flex flex-col h-full w-full">
|
||||
<!-- Header with Search (Desktop only) -->
|
||||
<div
|
||||
v-if="!isCompactView"
|
||||
class="flex items-center px-4 py-3 border-b border-nc-border-gray-medium dark:bg-nc-bg-gray-extralight"
|
||||
>
|
||||
<a-input
|
||||
:ref="focus"
|
||||
v-model:value="modalState.searchQuery"
|
||||
class="nc-workspace-base-search"
|
||||
:placeholder="$t('placeholder.searchWorkspacesAndBases')"
|
||||
allow-clear
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<GeneralIcon icon="search" class="text-nc-content-gray-muted mr-1" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
<!-- Main Content -->
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<!-- Right Panel - Bases -->
|
||||
<div class="nc-bases-panel flex-1 flex flex-col min-w-0 bg-nc-bg-gray-extralight dark:bg-transparent">
|
||||
<!-- Bases Header (with search on compact view) -->
|
||||
<WorkspaceBaseListModalBasesHeader
|
||||
v-model:search-query="modalState.searchQuery"
|
||||
:base-count="baseCount"
|
||||
:active-filter="modalState.activeFilter"
|
||||
:is-compact-view="isCompactView"
|
||||
@update:active-filter="modalState.activeFilter = $event"
|
||||
>
|
||||
<template #baseListHeader>
|
||||
<span class="text-nc-content-gray-subtle">
|
||||
{{ $t('objects.projects') }}
|
||||
</span>
|
||||
</template>
|
||||
</WorkspaceBaseListModalBasesHeader>
|
||||
|
||||
<!-- Bases Content - Loop-based rendering -->
|
||||
<div class="flex-1 overflow-y-auto nc-scrollbar-thin p-4 flex flex-col">
|
||||
<WorkspaceBaseListModalBasesSection
|
||||
v-for="section in displayedSections"
|
||||
:key="section.type"
|
||||
:type="section.type"
|
||||
:bases="section.bases"
|
||||
:is-filter-applied="modalState.activeFilter !== 'all'"
|
||||
:is-base-starred="baseCheckers.starred"
|
||||
:is-base-private="baseCheckers.private"
|
||||
/>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="emptyFilterResult" class="flex flex-col items-center justify-center h-full text-nc-content-gray-muted">
|
||||
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('activity.noBases')" />
|
||||
</div>
|
||||
|
||||
<!-- No Search Results -->
|
||||
<div
|
||||
v-else-if="hasNoSearchResults"
|
||||
class="h-full px-2 py-6 text-nc-content-gray-muted flex flex-col items-center justify-center gap-6 text-center"
|
||||
>
|
||||
<img
|
||||
src="~assets/img/placeholder/no-search-result-found.png"
|
||||
class="!w-[164px] flex-none"
|
||||
alt="No search results found"
|
||||
/>
|
||||
|
||||
{{ $t('title.noResultsMatchedYourSearch') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer with keyboard shortcuts (Desktop only) -->
|
||||
<WorkspaceBaseListModalFooter v-if="!isCompactView" />
|
||||
</div>
|
||||
</NcModal>
|
||||
|
||||
<!-- Duplicate Base Dialog -->
|
||||
<DlgBaseDuplicate v-if="dialogState.duplicate.base" v-model="dialogState.duplicate.isOpen" :base="dialogState.duplicate.base" />
|
||||
|
||||
<!-- Delete Base Dialog -->
|
||||
<DlgBaseDelete
|
||||
v-if="dialogState.delete.base"
|
||||
v-model:visible="dialogState.delete.isOpen"
|
||||
:base-id="dialogState.delete.base?.id"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.nc-workspace-base-list-modal {
|
||||
@apply rounded-xl overflow-hidden;
|
||||
}
|
||||
|
||||
.nc-workspace-base-search {
|
||||
@apply !rounded-lg dark:!bg-nc-bg-gray-dark;
|
||||
|
||||
:deep(.ant-input) {
|
||||
@apply !border-none !shadow-none !text-body dark:!bg-nc-bg-gray-dark;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
@apply !border-none !shadow-none rounded-lg px-3 py-2 dark:!bg-nc-bg-gray-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.nc-workspace-panel {
|
||||
@apply dark:bg-nc-bg-gray-extralight;
|
||||
}
|
||||
|
||||
kbd {
|
||||
@apply font-mono;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.nc-workspace-base-list-modal-wrapper {
|
||||
@apply !transition-none;
|
||||
|
||||
backdrop-filter: blur(4px);
|
||||
|
||||
.ant-modal-content {
|
||||
@apply !p-0 !rounded-xl overflow-hidden;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { SourceType } from 'nocodb-sdk'
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
export interface BaseActionsContext {
|
||||
// Actions
|
||||
onRename: (base: NcProject, title: string) => Promise<void>
|
||||
onToggleStarred: (base: NcProject) => Promise<void>
|
||||
onDuplicate: (base: NcProject) => void
|
||||
onOpenErd: (base: NcProject, source: SourceType) => void
|
||||
onOpenSettings: (baseId: string) => Promise<void>
|
||||
onDelete: (base: NcProject) => void
|
||||
onUpdateColor: (base: NcProject, color: string) => Promise<void>
|
||||
onReorder: (base: NcProject, newOrder: number) => Promise<void>
|
||||
onSelect: (base: NcProject) => Promise<void>
|
||||
// Close modal callback
|
||||
closeModal: () => void
|
||||
}
|
||||
|
||||
export const BaseActionsKey: InjectionKey<BaseActionsContext> = Symbol('BaseActions')
|
||||
|
||||
export function useBaseActionsProvider(closeModal: () => void) {
|
||||
const basesStore = useBases()
|
||||
const { workspaceBasesMap } = storeToRefs(basesStore)
|
||||
|
||||
const { navigateToProject } = useGlobal()
|
||||
const { $api, $e } = useNuxtApp()
|
||||
const route = useRoute()
|
||||
|
||||
// Dialog state - consolidated into single reactive object
|
||||
const dialogState = reactive({
|
||||
duplicate: {
|
||||
isOpen: false,
|
||||
base: null as NcProject | null,
|
||||
},
|
||||
delete: {
|
||||
isOpen: false,
|
||||
base: null as NcProject | null,
|
||||
},
|
||||
})
|
||||
|
||||
// Helper to get base from any workspace
|
||||
const getBaseFromWorkspace = (workspaceId: string, baseId: string): NcProject | undefined => {
|
||||
return workspaceBasesMap.value.get(workspaceId)?.get(baseId)
|
||||
}
|
||||
|
||||
// Helper to update base in its workspace
|
||||
const updateBaseInWorkspace = (base: NcProject, updates: Partial<NcProject>) => {
|
||||
const workspaceId = base.fk_workspace_id!
|
||||
const workspaceBases = workspaceBasesMap.value.get(workspaceId)
|
||||
if (workspaceBases && base.id) {
|
||||
const existingBase = workspaceBases.get(base.id)
|
||||
if (existingBase) {
|
||||
workspaceBases.set(base.id, { ...existingBase, ...updates })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
const onRename = async (base: NcProject, title: string) => {
|
||||
try {
|
||||
// Optimistically update UI
|
||||
updateBaseInWorkspace(base, { title })
|
||||
|
||||
// API call
|
||||
await $api.base.update(base.id!, { title })
|
||||
$e('a:base:rename')
|
||||
} catch (e: any) {
|
||||
// Revert on error
|
||||
updateBaseInWorkspace(base, { title: base.title })
|
||||
message.error(await extractSdkResponseErrorMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
const onToggleStarred = async (base: NcProject) => {
|
||||
try {
|
||||
const newStarredState = !base.starred
|
||||
|
||||
// Optimistically update UI
|
||||
updateBaseInWorkspace(base, { starred: newStarredState })
|
||||
|
||||
// API call
|
||||
await $api.base.userMetaUpdate(base.id!, { starred: newStarredState })
|
||||
$e('a:base:starred:toggle')
|
||||
} catch (e: any) {
|
||||
// Revert on error
|
||||
updateBaseInWorkspace(base, { starred: base.starred })
|
||||
message.error(await extractSdkResponseErrorMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
const onDuplicate = (base: NcProject) => {
|
||||
dialogState.duplicate.base = base
|
||||
dialogState.duplicate.isOpen = true
|
||||
$e('c:base:duplicate')
|
||||
}
|
||||
|
||||
const onOpenErd = (base: NcProject, 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.id,
|
||||
})
|
||||
|
||||
function closeDialog() {
|
||||
isOpen.value = false
|
||||
close(1000)
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenSettings = async (baseId: string) => {
|
||||
closeModal()
|
||||
await navigateTo(`/${route.params.typeOrId}/${baseId}?page=base-settings`)
|
||||
}
|
||||
|
||||
const onDelete = (base: NcProject) => {
|
||||
dialogState.delete.base = base
|
||||
dialogState.delete.isOpen = true
|
||||
}
|
||||
|
||||
const onUpdateColor = async (base: NcProject, color: string) => {
|
||||
try {
|
||||
const newMeta = {
|
||||
...parseProp(base.meta),
|
||||
iconColor: color,
|
||||
}
|
||||
|
||||
// Optimistically update UI
|
||||
updateBaseInWorkspace(base, { meta: newMeta as any })
|
||||
|
||||
// API call
|
||||
await $api.base.update(base.id!, { meta: JSON.stringify(newMeta) })
|
||||
$e('a:base:icon:color:modal', { iconColor: color })
|
||||
} catch (e: any) {
|
||||
// Revert on error
|
||||
updateBaseInWorkspace(base, { meta: base.meta })
|
||||
message.error(await extractSdkResponseErrorMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
const onReorder = async (base: NcProject, newOrder: number) => {
|
||||
try {
|
||||
const oldOrder = base.order
|
||||
|
||||
// Optimistically update UI
|
||||
updateBaseInWorkspace(base, { order: newOrder })
|
||||
|
||||
// API call
|
||||
await $api.base.update(base.id!, { order: newOrder })
|
||||
$e('a:base:reorder')
|
||||
} catch (e: any) {
|
||||
// Revert on error
|
||||
updateBaseInWorkspace(base, { order: base.order })
|
||||
message.error(await extractSdkResponseErrorMsg(e))
|
||||
}
|
||||
}
|
||||
|
||||
const onSelect = async (base: NcProject) => {
|
||||
$e('a:workspace:base:select')
|
||||
closeModal()
|
||||
|
||||
await navigateToProject({
|
||||
baseId: base.id!,
|
||||
workspaceId: base.fk_workspace_id!,
|
||||
})
|
||||
}
|
||||
|
||||
const context: BaseActionsContext = {
|
||||
onRename,
|
||||
onToggleStarred,
|
||||
onDuplicate,
|
||||
onOpenErd,
|
||||
onOpenSettings,
|
||||
onDelete,
|
||||
onUpdateColor,
|
||||
onReorder,
|
||||
onSelect,
|
||||
closeModal,
|
||||
}
|
||||
|
||||
// Provide context to child components
|
||||
provide(BaseActionsKey, context)
|
||||
|
||||
return {
|
||||
dialogState,
|
||||
...context,
|
||||
}
|
||||
}
|
||||
|
||||
export function useBaseActions() {
|
||||
const context = inject(BaseActionsKey)
|
||||
if (!context) {
|
||||
throw new Error('useBaseActions must be used within a BaseActionsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user