fix: base list modal oss changes

This commit is contained in:
Ramesh Mane
2026-02-20 10:35:31 +00:00
parent 8af1d1f8f6
commit 40005a3af0
7 changed files with 1134 additions and 53 deletions

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}