Files
nocodb/packages/nc-gui/components/dlg/AirtableImport.vue
2026-01-27 12:26:18 +05:30

571 lines
16 KiB
Vue

<script setup lang="ts">
import { JobStatus } from '#imports'
const {
modelValue,
baseId,
sourceId,
transition,
showSourceSelector = true,
} = defineProps<{
modelValue: boolean
baseId: string
sourceId: string
transition?: string
showBackBtn?: boolean
showSourceSelector?: boolean
}>()
const emit = defineEmits(['update:modelValue', 'back'])
const { $api } = useNuxtApp()
const { $poller } = useNuxtApp()
const workspace = useWorkspace()
const { activeWorkspace } = storeToRefs(workspace)
const baseStore = useBase()
const { refreshCommandPalette } = useCommandPalette()
const { loadTables } = baseStore
const { getJobsForBase, loadJobsForBase } = useJobs()
const showGoToDashboardButton = ref(false)
const step = ref(1)
const progressRef = ref()
const lastProgress = ref()
const enableAbort = ref(false)
const goBack = ref(false)
const listeningForUpdates = ref(false)
const hasWarning = ref(false)
const syncSource = ref({
id: '',
type: 'Airtable',
details: {
syncInterval: '15mins',
syncDirection: 'Airtable to NocoDB',
syncRetryCount: 1,
apiKey: '',
appId: '',
shareId: '',
syncSourceUrlOrId: '',
options: {
syncViews: true,
syncData: true,
syncRollup: false,
syncLookup: true,
syncFormula: false,
syncAttachment: true,
syncUsers: false,
},
},
})
const sourceSelectorRef = ref()
const customSourceId = computed(() => {
return sourceSelectorRef.value?.customSourceId || sourceId
})
const onLog = (data: { message: string }) => {
progressRef.value?.pushProgress(data.message, 'progress')
lastProgress.value = { msg: data.message, status: 'progress' }
if (data.message.startsWith('WARNING: ')) {
hasWarning.value = true
}
}
const onStatus = async (status: JobStatus, data?: any) => {
lastProgress.value = { msg: data?.message, status }
if (status === JobStatus.COMPLETED) {
showGoToDashboardButton.value = true
await loadTables()
progressRef.value?.pushProgress('Done!', status)
refreshCommandPalette()
if (activeWorkspace.value?.id) {
workspace.loadWorkspace(activeWorkspace.value.id)
}
// TODO: add tab of the first table
} else if (status === JobStatus.FAILED) {
await loadTables()
goBack.value = true
progressRef.value?.pushProgress(data.error.message, status)
refreshCommandPalette()
}
}
const validators = computed(() => ({
'details.apiKey': [fieldRequiredValidator()],
'details.syncSourceUrlOrId': [fieldRequiredValidator()],
}))
const dialogShow = computed({
get: () => modelValue,
set: (v) => emit('update:modelValue', v),
})
const useForm = Form.useForm
const { validateInfos } = useForm(syncSource, validators)
const disableImportButton = computed(
() =>
!syncSource.value.details.apiKey ||
!syncSource.value.details.syncSourceUrlOrId ||
sourceSelectorRef.value?.selectedSource?.ncItemDisabled,
)
const isLoading = ref(false)
async function saveAndSync() {
isLoading.value = true
await createOrUpdate()
await sync()
}
async function createOrUpdate() {
try {
const { id, ...payload } = syncSource.value
if (id !== '') {
await $api.internal.postOperation(
activeWorkspace.value!.id,
baseId,
{
operation: 'syncSourceUpdate',
syncId: id,
},
payload,
)
} else {
syncSource.value = await $api.internal.postOperation(
activeWorkspace.value!.id,
baseId,
{
operation: 'syncSourceCreate',
sourceId: customSourceId.value,
},
payload,
)
}
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
async function listenForUpdates(id?: string) {
if (listeningForUpdates.value) return
listeningForUpdates.value = true
await loadJobsForBase(baseId)
const jobs = await getJobsForBase(baseId)
const job = id
? { id }
: jobs
// sort by created_at desc (latest first)
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.find((j) => j.base_id === baseId && j.status !== JobStatus.COMPLETED && j.status !== JobStatus.FAILED)
if (!job) {
listeningForUpdates.value = false
return
}
$poller.subscribe(
{ id: job.id },
(data: {
id: string
status?: string
data?: {
error?: {
message: string
}
message?: string
result?: any
}
}) => {
if (data.status !== 'close') {
step.value = 2
if (data.status) {
onStatus(data.status as JobStatus, data.data)
} else {
onLog(data.data as any)
}
} else {
listeningForUpdates.value = false
isLoading.value = false
}
},
)
}
async function loadSyncSrc() {
const data: any = await $api.internal.getOperation(activeWorkspace.value!.id, baseId, {
operation: 'syncSourceList',
sourceId: customSourceId.value,
})
const { list: srcs } = data
if (srcs && srcs[0]) {
srcs[0].details = srcs[0].details || {}
syncSource.value = migrateSync(srcs[0])
syncSource.value.details.syncSourceUrlOrId =
srcs[0].details.appId && srcs[0].details.appId.length > 0 ? srcs[0].details.syncSourceUrlOrId : srcs[0].details.shareId
listenForUpdates()
} else {
syncSource.value = {
id: '',
type: 'Airtable',
details: {
syncInterval: '15mins',
syncDirection: 'Airtable to NocoDB',
syncRetryCount: 1,
apiKey: '',
appId: '',
shareId: '',
syncSourceUrlOrId: '',
options: {
syncViews: true,
syncData: true,
syncRollup: false,
syncLookup: true,
syncFormula: false,
syncAttachment: true,
syncUsers: false,
},
},
}
}
}
async function sync() {
try {
const jobData: any = await $api.internal.postOperation(
activeWorkspace.value!.id,
baseId,
{
operation: 'atImportTrigger',
syncId: syncSource.value.id,
},
{},
)
listeningForUpdates.value = false
listenForUpdates(jobData.id)
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
function migrateSync(src: any) {
if (!src.details?.options) {
src.details.options = {
syncViews: false,
syncData: true,
syncRollup: false,
syncLookup: true,
syncFormula: false,
syncAttachment: true,
}
src.details.options.syncViews = src.syncViews
delete src.syncViews
}
return src
}
watch(
() => syncSource.value.details.syncSourceUrlOrId,
(v) => {
if (syncSource.value.details) {
const m = v && v.match(/(exp|shr).{14}/g)
syncSource.value.details.shareId = m ? m[0] : ''
const m2 = v && v.match(/(app).{14}/g)
syncSource.value.details.appId = m2 ? m2[0] : ''
}
},
)
onMounted(async () => {
if (syncSource.value.id) {
listenForUpdates()
}
await loadSyncSrc()
})
const isInProgress = computed(() => {
return !lastProgress.value || ![JobStatus.COMPLETED, JobStatus.FAILED].includes(lastProgress.value?.status)
})
const detailsIsShown = ref(false)
const collapseKey = ref('')
</script>
<template>
<a-modal
v-model:visible="dialogShow"
class="!top-[25vh]"
:class="{ active: dialogShow }"
:closable="false"
:transition-name="transition"
:keyboard="step !== 2"
:mask-closable="step !== 2"
width="448px"
wrap-class-name="nc-modal-airtable-import"
hide
@keydown.esc="dialogShow = false"
>
<div class="text-base font-weight-bold flex items-center gap-4 mb-6" @dblclick="enableAbort = true">
<GeneralIcon icon="airtable" class="w-6 h-6" />
<span v-if="step === 1" class="flex-1">
{{ $t('title.quickImportAirtable') }}
</span>
<span v-else-if="isInProgress" class="flex-1"> {{ `${$t('labels.importingFromAirtable')}...` }} </span>
<span v-else class="flex-1"> {{ $t('labels.airtableBaseImported') }} </span>
<template v-if="step === 1">
<a
href="https://nocodb.com/docs/product-docs/bases/import-base-from-airtable#get-airtable-credentials"
class="!text-nc-content-gray-subtle2 text-sm font-weight-500 ml-auto"
target="_blank"
rel="noopener"
>
Docs
</a>
</template>
<div v-else-if="step === 2" class="flex items-center gap-2">
<nc-button type="text" size="xs" class="ml-auto" @click="detailsIsShown = !detailsIsShown">
{{ detailsIsShown ? 'Hide' : 'Show' }} Details
<GeneralIcon icon="chevronDown" class="ml-2 transition-all transform" :class="{ 'rotate-180': detailsIsShown }" />
</nc-button>
<NcButton v-if="!isInProgress" icon-only type="text" size="xs" @click.stop="dialogShow = false">
<template #icon>
<GeneralIcon icon="close" class="text-nc-content-gray-subtle2" />
</template>
</NcButton>
</div>
</div>
<div v-if="step === 1">
<a-form
ref="form"
:model="syncSource"
name="quick-import-airtable-form"
layout="vertical"
class="m-0 !text-nc-content-gray"
>
<a-form-item v-bind="validateInfos['details.apiKey']" class="!my-5">
<div class="flex items-end">
<label class="text-nc-content-gray text-sm"> {{ $t('labels.personalAccessToken') }} </label>
<a
href="https://nocodb.com/docs/product-docs/bases/import-base-from-airtable#get-airtable-credentials"
class="!text-brand text-sm ml-auto"
target="_blank"
rel="noopener"
>
{{ $t('labels.whereToFind') }}
</a>
</div>
<a-input-password
v-model:value="syncSource.details.apiKey"
placeholder="Enter your Airtable Personal Access Token"
class="!rounded-lg mt-2 nc-input-api-key nc-input-shadow !text-nc-content-gray"
>
<template #iconRender="isVisible">
<GeneralIcon :icon="!isVisible ? 'ncEye' : 'ncEyeOff'" />
</template>
</a-input-password>
</a-form-item>
<a-form-item v-bind="validateInfos['details.syncSourceUrlOrId']" class="!my-5">
<label class="text-nc-content-gray text-sm"> {{ `${$t('labels.sharedBase')} ID/URL` }} </label>
<a-input
v-model:value="syncSource.details.syncSourceUrlOrId"
placeholder="Paste the Base URL or Base ID from Airtable"
class="!rounded-lg !mt-2 nc-input-shared-base nc-input-shadow !text-nc-content-gray"
/>
</a-form-item>
<div class="my-5">
<NcListSourceSelector
ref="sourceSelectorRef"
:base-id="baseId"
:source-id="sourceId"
:show-source-selector="showSourceSelector"
force-layout="vertical"
/>
</div>
<nc-button type="text" size="small" @click="collapseKey = !collapseKey ? 'advanced-settings' : ''">
{{ $t('title.advancedSettings') }}
<GeneralIcon
icon="chevronDown"
class="ml-2 !transition-all !transform"
:class="{ '!rotate-180': collapseKey === 'advanced-settings' }"
/>
</nc-button>
<a-collapse v-model:active-key="collapseKey" ghost class="nc-import-collapse">
<a-collapse-panel key="advanced-settings">
<div class="mb-2">
<NcCheckbox v-model:checked="syncSource.details.options.syncData">{{ $t('labels.importData') }}</NcCheckbox>
</div>
<div class="my-2">
<NcCheckbox v-model:checked="syncSource.details.options.syncViews">
{{ $t('labels.importSecondaryViews') }}
</NcCheckbox>
</div>
<div class="my-2">
<NcCheckbox v-model:checked="syncSource.details.options.syncRollup">
{{ $t('labels.importRollupColumns') }}
</NcCheckbox>
</div>
<div class="my-2">
<NcCheckbox v-model:checked="syncSource.details.options.syncLookup">
{{ $t('labels.importLookupColumns') }}
</NcCheckbox>
</div>
<div class="my-2">
<NcCheckbox v-model:checked="syncSource.details.options.syncAttachment">
{{ $t('labels.importAttachmentColumns') }}
</NcCheckbox>
</div>
<div class="my-2">
<NcCheckbox v-model:checked="syncSource.details.options.syncFormula" disabled>
{{ $t('labels.importFormulaColumns') }}
</NcCheckbox>
</div>
</a-collapse-panel>
</a-collapse>
</a-form>
</div>
<div v-if="step === 2">
<GeneralProgressPanel v-show="detailsIsShown" ref="progressRef" class="w-full h-[200px]" />
<div v-show="!detailsIsShown" class="flex items-center gap-2">
<template v-if="isInProgress">
<GeneralLoader size="large" class="!mr-1" />
<span class="truncate">
{{ lastProgress?.msg ?? '---' }}
</span>
</template>
<template v-else-if="lastProgress?.status === JobStatus.FAILED">
<NcAlert
align="center"
type="error"
show-icon
message-class="!text-sm"
description-class="!text-small !leading-[18px]"
:copy-text="lastProgress?.msg"
:message="$t('msg.error.importError')"
:description="$t('msg.error.anErrorOccuredWhileAirtableBaseImport')"
/>
</template>
<div v-else class="flex flex-col gap-3">
<div class="flex items-center gap-3">
<GeneralIcon icon="checkFill" class="text-white w-4 h-4" />
<span> {{ $t('msg.airtableImportSuccess') }} </span>
</div>
<div v-if="hasWarning" class="text-yellow-500">{{ $t('msg.airtableImportWarning') }}</div>
</div>
</div>
<div v-if="!isInProgress" class="text-right mt-5">
<nc-button v-if="lastProgress?.status === JobStatus.FAILED" size="small" @click="step = 1"> Retry import </nc-button>
<nc-button v-else size="small" @click="dialogShow = false"> Go to base </nc-button>
</div>
</div>
<template #footer>
<div v-if="step === 1" class="flex justify-between mt-5">
<nc-button
key="back"
type="text"
size="small"
@click="
() => {
dialogShow = false
emit('back')
}
"
>
<GeneralIcon v-if="showBackBtn" icon="chevronLeft" class="mr-1" />
{{ showBackBtn ? $t('general.back') : $t('general.cancel') }}
</nc-button>
<nc-button
key="submit"
v-e="['c:sync-airtable:save-and-sync']"
type="primary"
class="nc-btn-airtable-import"
size="small"
:loading="isLoading"
:disabled="disableImportButton"
@click="saveAndSync"
>
{{ $t('activity.import') }} Base
</nc-button>
</div>
</template>
</a-modal>
</template>
<style lang="scss" scoped>
.nc-import-collapse :deep(.ant-collapse-header) {
display: none !important;
}
.nc-import-collapse :deep(.ant-collapse-content-box) {
@apply !pb-0 !pt-2 !pr-0.2;
}
.nc-input-api-key {
:deep(.ant-input-password-icon) {
@apply !text-current !hover:text-current;
}
}
.nc-input-api-key,
.nc-input-shared-base {
@apply !text-nc-content-gray;
input {
@apply !text-nc-content-gray;
}
}
</style>
<style>
.nc-modal-airtable-import .ant-modal-footer {
@apply !border-none p-0;
}
.nc-modal-airtable-import .ant-collapse-content-box {
padding-left: 6px;
}
</style>