Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 75 additions & 13 deletions packages/nc-gui/components/dlg/AirtableImport.vue
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@ const syncSource = ref({
syncDirection: 'Airtable to NocoDB',
syncRetryCount: 1,
apiKey: '',
personalAccessToken: '',
appId: '',
shareId: '',
syncSourceUrlOrId: '',
apiVersion: 'v1',
options: {
syncViews: true,
syncData: true,
Expand Down Expand Up @@ -104,10 +106,20 @@ const onStatus = async (status: JobStatus, data?: any) => {
}
}

const validators = computed(() => ({
'details.apiKey': [fieldRequiredValidator()],
'details.syncSourceUrlOrId': [fieldRequiredValidator()],
}))
const validators = computed(() => {
const rules: any = {
'details.syncSourceUrlOrId': [fieldRequiredValidator()],
}

// Validate credentials based on API version
if (syncSource.value.details.apiVersion === 'v2') {
rules['details.personalAccessToken'] = [fieldRequiredValidator()]
} else {
rules['details.apiKey'] = [fieldRequiredValidator()]
}

return rules
})

const dialogShow = computed({
get: () => modelValue,
Expand All @@ -118,12 +130,14 @@ 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 disableImportButton = computed(() => {
const hasCredentials =
syncSource.value.details.apiVersion === 'v2'
? !!syncSource.value.details.personalAccessToken
: !!syncSource.value.details.apiKey

return !hasCredentials || !syncSource.value.details.syncSourceUrlOrId || sourceSelectorRef.value?.selectedSource?.ncItemDisabled
})

const isLoading = ref(false)

Expand Down Expand Up @@ -230,9 +244,11 @@ async function loadSyncSrc() {
syncDirection: 'Airtable to NocoDB',
syncRetryCount: 1,
apiKey: '',
personalAccessToken: '',
appId: '',
shareId: '',
syncSourceUrlOrId: '',
apiVersion: 'v1',
options: {
syncViews: true,
syncData: true,
Expand Down Expand Up @@ -303,6 +319,18 @@ const isInProgress = computed(() => {

const detailsIsShown = ref(false)
const collapseKey = ref('')

// Auto-detect API version based on credentials
watch(
() => [syncSource.value.details.personalAccessToken, syncSource.value.details.apiKey],
([pat, apiKey]) => {
if (pat && !apiKey) {
syncSource.value.details.apiVersion = 'v2'
} else if (apiKey && !pat) {
syncSource.value.details.apiVersion = 'v1'
}
},
)
</script>

<template>
Expand Down Expand Up @@ -360,9 +388,43 @@ const collapseKey = ref('')
layout="vertical"
class="m-0 !text-nc-content-gray"
>
<a-form-item v-bind="validateInfos['details.apiKey']" class="!my-5">
<!-- API Version Selector -->
<div class="!my-5">
<label class="text-nc-content-gray text-sm mb-2 block">API Version</label>
<a-radio-group v-model:value="syncSource.details.apiVersion" class="w-full">
<a-radio value="v1" class="!mr-4">v1 (Legacy)</a-radio>
<a-radio value="v2">v2 (Recommended)</a-radio>
</a-radio-group>
</div>

<!-- Credentials Input - v2 (Personal Access Token) -->
<a-form-item
v-if="syncSource.details.apiVersion === 'v2'"
v-bind="validateInfos['details.personalAccessToken']"
class="!my-5"
>
<div class="flex items-end">
<label class="text-nc-content-gray text-sm">Personal Access Token</label>
<a href="https://airtable.com/create/tokens" class="!text-brand text-sm ml-auto" target="_blank" rel="noopener">
{{ $t('labels.whereToFind') }}
</a>
</div>

<a-input-password
v-model:value="syncSource.details.personalAccessToken"
placeholder="Enter your Airtable Personal Access Token"
class="!rounded-lg mt-2 nc-input-personal-access-token nc-input-shadow !text-nc-content-gray"
>
<template #iconRender="isVisible">
<GeneralIcon :icon="!isVisible ? 'ncEye' : 'ncEyeOff'" />
</template>
</a-input-password>
</a-form-item>

<!-- Credentials Input - v1 (API Key) -->
<a-form-item v-else 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>
<label class="text-nc-content-gray text-sm">API Key</label>
<a
href="https://nocodb.com/docs/product-docs/bases/import-base-from-airtable#get-airtable-credentials"
class="!text-brand text-sm ml-auto"
Expand All @@ -375,7 +437,7 @@ const collapseKey = ref('')

<a-input-password
v-model:value="syncSource.details.apiKey"
placeholder="Enter your Airtable Personal Access Token"
placeholder="Enter your Airtable API Key"
class="!rounded-lg mt-2 nc-input-api-key nc-input-shadow !text-nc-content-gray"
>
<template #iconRender="isVisible">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,24 @@ export class AtImportProcessor {

const file = ft.schema;
atBaseId = ft.baseId;
atBase = new Airtable({ apiKey: sDB.apiKey }).base(atBaseId);

// Determine API credentials based on version
const apiVersion = sDB.apiVersion || 'v1';
const apiKey = apiVersion === 'v2' ? sDB.personalAccessToken : sDB.apiKey;

// Validate credentials
if (apiVersion === 'v1' && !sDB.apiKey) {
throw {
message: 'API Key is required for Airtable API v1',
};
}
if (apiVersion === 'v2' && !sDB.personalAccessToken) {
throw {
message: 'Personal Access Token is required for Airtable API v2',
};
Comment on lines +328 to +336
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Тут лучше использовать switch по apiVersion

}

atBase = new Airtable({ apiKey }).base(atBaseId);
// store copy of airtable schema globally
g_aTblSchema = file.tableSchemas;

Expand Down Expand Up @@ -2760,8 +2777,10 @@ export interface AirtableSyncConfig {
baseId?: string;
sourceId?: string;
apiKey: string;
personalAccessToken?: string;
appId?: string;
shareId: string;
apiVersion?: string;
user: Partial<UserType>;
options: {
syncViews: boolean;
Expand Down
32 changes: 32 additions & 0 deletions packages/nocodb/src/services/sync.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@ export class SyncService {
) {
const base = await Base.getWithInfo(context, param.baseId);

// Validate Airtable credentials based on API version
if (param.syncPayload.type === 'Airtable' && param.syncPayload.details) {
const details = param.syncPayload.details as any;
const apiVersion = details.apiVersion || 'v1';

if (apiVersion === 'v1' && !details.apiKey) {
NcError.badRequest('API Key is required for Airtable API v1');
}

if (apiVersion === 'v2' && !details.personalAccessToken) {
NcError.badRequest(
'Personal Access Token is required for Airtable API v2',
);
Comment on lines +39 to +46
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Надо использовать switch по apiVersion

}
}

const sync = await SyncSource.insert(context, {
...param.syncPayload,
fk_user_id: param.userId,
Expand Down Expand Up @@ -81,6 +97,22 @@ export class SyncService {
NcError.badRequest('Sync source not found');
}

// Validate Airtable credentials based on API version
if (param.syncPayload.type === 'Airtable' && param.syncPayload.details) {
const details = param.syncPayload.details as any;
const apiVersion = details.apiVersion || 'v1';

if (apiVersion === 'v1' && !details.apiKey) {
NcError.badRequest('API Key is required for Airtable API v1');
}

if (apiVersion === 'v2' && !details.personalAccessToken) {
NcError.badRequest(
'Personal Access Token is required for Airtable API v2',
);
Comment on lines +105 to +112
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Нужен switch по apiVersion

}
}

const res = await SyncSource.update(
context,
param.syncId,
Expand Down
12 changes: 11 additions & 1 deletion tests/playwright/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ const airtableApiBases = [
const today = new Date();

const airtableApiKey = 'pat23tPxwKmp4P96z.6b96f6381ad1bf2abdbd09539ac64fdb898516693603137b66e1e4e5a41bca78';
const airtablePersonalAccessToken = process.env.AIRTABLE_PERSONAL_ACCESS_TOKEN || airtableApiKey;
const airtableApiBase = airtableApiBases[today.getDate() % airtableApiBases.length];
const airtableApiVersion = process.env.AIRTABLE_API_VERSION || 'v1';
const airtableBaseUrl = 'https://api.airtable.com/v0';

const defaultBaseName = 'Base';

export { airtableApiKey, airtableApiBase, defaultBaseName };
export {
airtableApiKey,
airtablePersonalAccessToken,
airtableApiBase,
airtableApiVersion,
airtableBaseUrl,
defaultBaseName,
};
25 changes: 23 additions & 2 deletions tests/playwright/pages/Dashboard/Import/Airtable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,33 @@ export class ImportAirtablePage extends BasePage {
return this.dashboard.get().locator(`.nc-modal-airtable-import`);
}

async import({ key, sourceId }: { key: string; sourceId: string }) {
async import({
key,
sourceId,
apiVersion = 'v1',
personalAccessToken,
}: {
key?: string;
sourceId: string;
apiVersion?: string;
personalAccessToken?: string;
}) {
// kludge: failing in headless mode
// additional time to allow the modal to render completely
await this.rootPage.waitForTimeout(1000);

await this.get().locator(`.nc-input-api-key >> input`).fill(key);
// Select API version if not v1
if (apiVersion === 'v2') {
await this.get().locator('input[type="radio"][value="v2"]').click();
await this.get()
.locator(`.nc-input-personal-access-token >> input`)
.fill(personalAccessToken || key || '');
} else {
await this.get()
.locator(`.nc-input-api-key >> input`)
.fill(key || '');
}

await this.get().locator(`.nc-input-shared-base`).fill(sourceId);
await this.importButton.click();

Expand Down
16 changes: 14 additions & 2 deletions tests/playwright/tests/db/features/import.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test } from '@playwright/test';
import { airtableApiBase, airtableApiKey } from '../../../constants';
import { airtableApiBase, airtableApiKey, airtableApiVersion, airtablePersonalAccessToken } from '../../../constants';
import { DashboardPage } from '../../../pages/Dashboard';
import { quickVerify } from '../../../quickTests/commonTest';
import setup, { NcContext, unsetup } from '../../../setup';
Expand All @@ -22,11 +22,23 @@ test.describe('Import', () => {
await unsetup(context);
});

test('Airtable', async () => {
test('Airtable v1', async () => {
await dashboard.treeView.quickImport({ title: 'Airtable', baseTitle: context.base.title, context });
await dashboard.importAirtable.import({
key: airtableApiKey,
sourceId: airtableApiBase,
apiVersion: 'v1',
});
await dashboard.rootPage.waitForTimeout(1000);
await quickVerify({ dashboard, airtableImport: true, context });
});

test('Airtable v2', async () => {
await dashboard.treeView.quickImport({ title: 'Airtable', baseTitle: context.base.title, context });
await dashboard.importAirtable.import({
personalAccessToken: airtablePersonalAccessToken,
sourceId: airtableApiBase,
apiVersion: 'v2',
});
await dashboard.rootPage.waitForTimeout(1000);
await quickVerify({ dashboard, airtableImport: true, context });
Expand Down