feat: add connection dropdown, move active connections into IntegrationsTab, adopt container grid

This commit is contained in:
Ramesh Mane
2026-04-10 09:37:38 +00:00
parent f00dfd83a7
commit 36e0a5f233
7 changed files with 328 additions and 111 deletions

View File

@@ -18,14 +18,8 @@ const emits = defineEmits<{
const { t } = useI18n()
const {
editIntegration,
deleteIntegration,
getIntegration,
loadIntegrations,
deleteConfirmText,
successConfirmModal,
} = useIntegrationStore()
const { editIntegration, deleteIntegration, getIntegration, loadIntegrations, deleteConfirmText, successConfirmModal } =
useIntegrationStore()
const { allCollaborators } = storeToRefs(useWorkspace())
@@ -43,9 +37,7 @@ const collaboratorsMap = computed<Map<string, (WorkspaceUserType & { id: string
return map
})
const filteredConnections = computed(() =>
(props.connections || []).filter((i) => IntegrationsType.Sync !== i.type),
)
const filteredConnections = computed(() => (props.connections || []).filter((i) => IntegrationsType.Sync !== i.type))
const visibleConnections = computed(() => {
return filteredConnections.value.slice(0, props.maxVisible)
@@ -127,7 +119,7 @@ const handleEdit = (integration: IntegrationType) => {
</script>
<template>
<div class="nc-active-connections-section">
<div class="nc-active-connections-section" style="container-type: inline-size">
<!-- Section header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
@@ -139,14 +131,21 @@ const handleEdit = (integration: IntegrationType) => {
</NcBadge>
</div>
<NcButton v-if="filteredTotalCount > 0" type="text" size="small" class="!text-nc-content-brand" @click="emits('view-all')">
<NcButton
v-if="filteredTotalCount > 0"
type="link"
size="small"
class="!text-nc-content-brand !p-0 !h-auto !min-h-0"
inner-class="hover:underline"
@click="emits('view-all')"
>
{{ t('general.viewAllConnections') }}
<GeneralIcon icon="arrowRight" class="ml-1" />
</NcButton>
</div>
<!-- Connection cards grid -->
<div class="nc-connection-cards-grid">
<div class="nc-connection-cards-grid grid grid-cols-1 gap-3">
<WorkspaceIntegrationsConnectionCard
v-for="connection in visibleConnections"
:key="connection.id"
@@ -158,14 +157,8 @@ const handleEdit = (integration: IntegrationType) => {
/>
<!-- Overflow card -->
<div
v-if="overflowCount > 0"
class="nc-connection-overflow-card"
@click="emits('view-all')"
>
<div class="text-sm font-semibold text-nc-content-gray">
+{{ overflowCount }} {{ t('general.more') }}
</div>
<div v-if="overflowCount > 0" class="nc-connection-overflow-card" @click="emits('view-all')">
<div class="text-sm font-semibold text-nc-content-gray">+{{ overflowCount }} {{ t('general.more') }}</div>
<div class="text-xs text-nc-content-gray-subtle2">
{{ t('general.viewAllConnections') }}
</div>
@@ -207,11 +200,7 @@ const handleEdit = (integration: IntegrationType) => {
>
<div class="mb-1">Following external data sources using this connection will also be removed</div>
<ul class="!list-disc ml-6 mb-0">
<li
v-for="(source, idx) of toBeDeletedIntegration.sources"
:key="idx"
class="marker:text-nc-content-gray-muted"
>
<li v-for="(source, idx) of toBeDeletedIntegration.sources" :key="idx" class="marker:text-nc-content-gray-muted">
<div class="flex items-center gap-1">
<GeneralProjectIcon
type="database"
@@ -223,10 +212,7 @@ const handleEdit = (integration: IntegrationType) => {
{{ source.project_title }}
</NcTooltip>
>
<GeneralBaseLogo
class="!grayscale min-w-4 flex-none"
:style="{ filter: 'grayscale(100%) brightness(115%)' }"
/>
<GeneralBaseLogo class="!grayscale min-w-4 flex-none" :style="{ filter: 'grayscale(100%) brightness(115%)' }" />
<NcTooltip class="truncate !max-w-[45%] capitalize" show-on-truncate-only>
<template #title>{{ source.alias }}</template>
{{ source.alias }}
@@ -254,7 +240,11 @@ const handleEdit = (integration: IntegrationType) => {
</NcButton>
</div>
<div class="text-sm text-nc-content-inverted-secondary">{{ successConfirmModal.description }}</div>
<a target="_blank" href="https://nocodb.com/docs/product-docs/data-sources/connect-to-data-source" rel="noopener noreferrer">
<a
target="_blank"
href="https://nocodb.com/docs/product-docs/data-sources/connect-to-data-source"
rel="noopener noreferrer"
>
Learn more
</a>
</div>
@@ -274,15 +264,30 @@ const handleEdit = (integration: IntegrationType) => {
<style lang="scss" scoped>
.nc-active-connections-section {
.nc-connection-cards-grid {
@apply grid gap-4;
grid-template-columns: repeat(3, 1fr);
@supports not (container-type: inline-size) {
@media (min-width: 540px) {
@apply grid-cols-2;
}
@media (max-width: 1024px) {
grid-template-columns: repeat(2, 1fr);
@media (min-width: 1024px) {
@apply grid-cols-3;
}
@media (min-width: 1440px) {
@apply grid-cols-4;
}
}
@media (max-width: 640px) {
grid-template-columns: 1fr;
@container (min-width: 540px) {
@apply grid-cols-2;
}
@container (min-width: 820px) {
@apply grid-cols-3;
}
@container (min-width: 1140px) {
@apply grid-cols-4;
}
}

View File

@@ -0,0 +1,155 @@
<script setup lang="ts">
import { IntegrationCategoryType } from 'nocodb-sdk'
interface Props {
/** 'workspace' shows all available categories, 'base' shows only Database */
mode?: 'workspace' | 'base'
}
const props = withDefaults(defineProps<Props>(), {
mode: 'workspace',
})
const { t } = useI18n()
const { addIntegration, integrations } = useIntegrationStore()
const { isFeatureEnabled } = useBetaFeatureToggle()
const { isSyncFeatureEnabled } = storeToRefs(useSyncStore())
const { isEEFeatureBlocked } = useEeConfig()
const isOpen = ref(false)
const easterEggToggle = computed(() => isFeatureEnabled(FEATURE_FLAG.INTEGRATIONS))
// Count connections per sub_type
const connectedCountMap = computed(() => {
const map: Record<string, number> = {}
for (const integration of integrations.value) {
if (integration.sub_type) {
map[integration.sub_type] = (map[integration.sub_type] || 0) + 1
}
}
return map
})
// Category filter — mirrors the main page logic for each mode
const isCategoryAllowed = (cat: (typeof integrationCategories)[number]) => {
if (!cat.isAvailable) return false
if (props.mode === 'base') {
// Base level: same as base/Integrations.vue integrationsMap — Database + AI + Auth only
return (
cat.value === IntegrationCategoryType.DATABASE ||
cat.value === IntegrationCategoryType.AI ||
cat.value === IntegrationCategoryType.AUTH
)
}
// Workspace level: same as IntegrationsTab — respect EE blocking + feature flag
if (isEEFeatureBlocked.value && cat.value !== IntegrationCategoryType.DATABASE) return false
if (!easterEggToggle.value) {
if (cat.value !== IntegrationCategoryType.DATABASE) {
if (!(isSyncFeatureEnabled.value && cat.value === IntegrationCategoryType.AUTH)) {
return false
}
}
}
return true
}
// Integration filter — mirrors the main page logic for each mode
const isIntegrationAllowed = (i: (typeof allIntegrations)[number]) => {
if (i.hidden) return false
if (!i.isAvailable) return false
if (i.sub_type === SyncDataType.NOCODB) return false
if (props.mode === 'base') {
// Base level: same as base/Integrations.vue — exclude ossOnly
return !i.isOssOnly
}
// Workspace level: same as IntegrationsTab
if (isEeUI && i.isOssOnly) return false
return true
}
// Build the list of available integrations for NcList
const integrationListItems = computed(() => {
const items: NcListItemType[] = []
for (const cat of integrationCategories) {
if (!isCategoryAllowed(cat)) continue
const categoryIntegrations = allIntegrations.filter(
(i) => i.type === cat.value && isIntegrationAllowed(i),
)
if (!categoryIntegrations.length) continue
for (const integration of categoryIntegrations) {
items.push({
value: integration.sub_type,
label: t(integration.title),
ncGroupHeaderLabel: t(cat.title),
integration,
connectedCount: connectedCountMap.value[integration.sub_type] || 0,
})
}
}
return items
})
const handleSelect = (option: NcListItemType) => {
if (option?.integration) {
addIntegration(option.integration)
isOpen.value = false
}
}
</script>
<template>
<NcDropdown v-model:visible="isOpen" placement="bottomRight">
<NcButton size="small" data-testid="nc-add-connection-btn">
<GeneralIcon icon="plus" class="mr-1" />
{{ t('labels.addConnection') }}
</NcButton>
<template #overlay>
<NcList
v-model:open="isOpen"
:list="integrationListItems"
:search-input-placeholder="`${t('general.search')} ${t('general.integrations').toLowerCase()}...`"
option-value-key="value"
option-label-key="label"
:show-selected-option="false"
:close-on-select="true"
:item-height="36"
class="nc-add-connection-list w-72"
@change="handleSelect"
>
<template #listItem="{ option }">
<div class="flex items-center gap-2 w-full">
<div class="flex-none h-7 w-7 rounded-lg flex items-center justify-center">
<GeneralIntegrationIcon :type="option.value" size="lg" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-nc-content-gray truncate">
{{ option.label }}
</div>
<div v-if="option.connectedCount" class="text-xs text-nc-content-brand">
{{ option.connectedCount }} {{ t('general.connected').toLowerCase() }}
</div>
</div>
</div>
</template>
</NcList>
</template>
</NcDropdown>
</template>

View File

@@ -21,11 +21,7 @@ const { t } = useI18n()
const { isFeatureEnabled } = useBetaFeatureToggle()
const {
editIntegration,
duplicateIntegration,
setDefaultIntegration,
} = useIntegrationStore()
const { editIntegration, duplicateIntegration, setDefaultIntegration } = useIntegrationStore()
const emits = defineEmits<{
(e: 'delete', integration: IntegrationType): void
@@ -48,21 +44,28 @@ const openEditIntegration = (integration: IntegrationType) => {
<template>
<NcDropdown placement="bottomRight">
<NcButton size="small" type="secondary" @click.stop>
<GeneralIcon icon="threeDotVertical" />
</NcButton>
<slot>
<NcButton size="small" type="secondary" @click.stop>
<GeneralIcon icon="threeDotVertical" />
</NcButton>
</slot>
<template #overlay>
<NcMenu variant="small">
<!-- Workspace mode: full actions -->
<template v-if="mode === 'workspace'">
<NcMenuItem
v-if="props.integration.type && integrationCategoryNeedDefault(props.integration.type) && !props.integration.is_default"
v-if="
props.integration.type && integrationCategoryNeedDefault(props.integration.type) && !props.integration.is_default
"
@click="setDefaultIntegration(props.integration)"
>
<GeneralIcon class="text-current opacity-80" icon="star" />
<span>Set as default</span>
</NcMenuItem>
<NcMenuItem v-if="isEeUI" @click="emits('base-assignment', props.integration)">
<NcMenuItem
v-if="isEeUI && props.integration?.sub_type !== SyncDataType.NOCODB"
@click="emits('base-assignment', props.integration)"
>
<GeneralIcon class="text-current opacity-80" icon="ncDatabase" />
<span>{{ t('labels.manageBaseAccess') }}</span>
</NcMenuItem>
@@ -102,10 +105,7 @@ const openEditIntegration = (integration: IntegrationType) => {
<!-- Base mode: edit + unlink -->
<template v-else>
<NcMenuItem
v-if="canEdit"
@click="openEditIntegration(props.integration)"
>
<NcMenuItem v-if="canEdit" @click="openEditIntegration(props.integration)">
<GeneralIcon class="text-current opacity-80" icon="edit" />
<span>{{ t('general.edit') }}</span>
</NcMenuItem>

View File

@@ -52,11 +52,7 @@ const handleCardClick = () => {
</script>
<template>
<div
class="nc-connection-card"
data-testid="nc-connection-card"
@click="handleCardClick"
>
<div class="nc-connection-card" data-testid="nc-connection-card" @click="handleCardClick">
<div class="flex items-center gap-3 min-w-0">
<div class="nc-connection-card-icon">
<GeneralIntegrationIcon :type="integration.sub_type" size="lg" />
@@ -68,17 +64,13 @@ const handleCardClick = () => {
{{ integration.title }}
</NcTooltip>
<div class="flex items-center gap-1.5 text-xs text-nc-content-gray-subtle2 mt-0.5">
<div class="flex items-center gap-1.5 text-xs text-nc-content-gray-subtle2 mt-0.5 whitespace-nowrap truncate">
<div class="flex items-center gap-1">
<div class="w-1.5 h-1.5 rounded-full bg-green-500 flex-none" />
<span>{{ $t('general.connected') }}</span>
</div>
<span v-if="getUserName(integration.created_by)">
&middot; {{ getUserName(integration.created_by) }}
</span>
<span v-if="formattedDate">
&middot; {{ formattedDate }}
</span>
<span v-if="getUserName(integration.created_by)"> &middot; {{ getUserName(integration.created_by) }} </span>
<span v-if="formattedDate"> &middot; {{ formattedDate }} </span>
</div>
</div>
</div>
@@ -93,7 +85,11 @@ const handleCardClick = () => {
@delete="emits('delete', $event)"
@base-assignment="emits('base-assignment', $event)"
@unlink="emits('unlink', $event)"
/>
>
<NcButton size="xs" type="secondary" class="!px-1" @click.stop>
<GeneralIcon icon="threeDotVertical" />
</NcButton>
</WorkspaceIntegrationsConnectionActionMenu>
</div>
</div>
</template>

View File

@@ -12,6 +12,7 @@ const props = withDefaults(
filterIntegration?: (i: IntegrationItemType) => boolean
showFilter?: boolean
showTitle?: boolean
showActiveConnections?: boolean
}>(),
{
isModal: false,
@@ -19,9 +20,14 @@ const props = withDefaults(
filterIntegration: () => true,
showFilter: false,
showTitle: false,
showActiveConnections: false,
},
)
const emits = defineEmits<{
(e: 'view-all-connections'): void
}>()
const { isModal, filterCategory, filterIntegration } = props
const { $e } = useNuxtApp()
@@ -50,6 +56,8 @@ const {
addIntegration,
saveIntegrationRequest,
integrationsRefreshKey,
integrations,
integrationPaginationData,
integrationsCategoryFilter,
activeViewTab,
loadDynamicIntegrations,
@@ -394,6 +402,14 @@ watch(activeViewTab, (value) => {
}"
>
<div class="flex flex-col space-y-6 w-full nc-content-max-w">
<!-- Active connections section (shown as first section when not modal) -->
<WorkspaceIntegrationsActiveConnectionsSection
v-if="showActiveConnections && !isModal && integrations.length"
:connections="integrations"
:total-count="integrationPaginationData.totalRows || 0"
@view-all="emits('view-all-connections')"
/>
<template v-for="(category, key) in integrationsMapByCategory">
<div
v-if="
@@ -404,6 +420,7 @@ watch(activeViewTab, (value) => {
"
:key="key"
class="integration-type-wrapper"
style="container-type: inline-size"
>
<div class="category-type-title flex gap-2">
{{ $t(category.title) }}
@@ -414,7 +431,7 @@ watch(activeViewTab, (value) => {
>{{ $t('msg.toast.futureRelease') }}</NcBadge
>
</div>
<div v-if="category.list.length" class="integration-type-list">
<div v-if="category.list.length" class="integration-type-list grid grid-cols-1 gap-3">
<template v-for="integration of category.list" :key="integration.sub_type">
<NcTooltip
v-if="isIntegrationVisible(integration, category)"
@@ -608,10 +625,34 @@ watch(activeViewTab, (value) => {
@apply flex flex-col gap-3;
.integration-type-list {
@apply flex gap-4 flex-wrap;
@supports not (container-type: inline-size) {
@media (min-width: 540px) {
@apply grid-cols-2;
}
@media (min-width: 1024px) {
@apply grid-cols-3;
}
@media (min-width: 1440px) {
@apply grid-cols-4;
}
}
@container (min-width: 540px) {
@apply grid-cols-2;
}
@container (min-width: 820px) {
@apply grid-cols-3;
}
@container (min-width: 1140px) {
@apply grid-cols-4;
}
.source-card {
@apply flex items-center gap-4 border-1 border-nc-border-gray-medium rounded-xl p-3 w-[280px] cursor-pointer transition-all duration-300;
@apply flex items-center gap-4 border-1 border-nc-border-gray-medium rounded-xl p-3 cursor-pointer transition-all duration-300;
.integration-icon-wrapper {
@apply flex-none h-[44px] w-[44px] rounded-lg flex items-center justify-center;