mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-02 06:47:08 +00:00
feat: add connection dropdown, move active connections into IntegrationsTab, adopt container grid
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)">
|
||||
· {{ getUserName(integration.created_by) }}
|
||||
</span>
|
||||
<span v-if="formattedDate">
|
||||
· {{ formattedDate }}
|
||||
</span>
|
||||
<span v-if="getUserName(integration.created_by)"> · {{ getUserName(integration.created_by) }} </span>
|
||||
<span v-if="formattedDate"> · {{ 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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user