Skip to content

Commit

Permalink
feat: support personal access token and can drop down to select table…
Browse files Browse the repository at this point in the history
… and base (#11)

* feat: support personal access token and can drop down to select table and base

* docs: update widget description and instructions
  • Loading branch information
xukecheng committed Dec 14, 2023
1 parent ee88f88 commit f018ede
Show file tree
Hide file tree
Showing 40 changed files with 18,513 additions and 17,333 deletions.
9 changes: 9 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"bracketSpacing": true,
"printWidth": 150,
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"useTabs": false
}
18 changes: 5 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
# Widget `airtable-import`

Support for importing data from Airtable into APITable.
You can import data from Airtable into AITable to quickly start using AITable to grow your business.

## Get Personal Access Token

## Get Airtable API Key Configuration
To use the widget, you first need to get a Personal Access Token from your Airtable account. Once you have obtained it, simply enter it into the widget to begin fetching raw data and importing it.

1. How to get the API Key
You can use [this link](https://airtable.com/create/tokens) to create and manage your Personal Access Token. Please note that a Personal Access Token is not the same as an API Key used previously. Airtable has announced that they will stop supporting API Keys by the end of January 2024. To learn more details, you can read [this article](https://support.airtable.com/docs/airtable-api-key-deprecation-notice).

You need Airtable API Key to get the data source, Airtable API Key can be obtained by referring to [Airtable's official documentation](https://support.airtable.com/hc/en-us/articles/219046777) or click directly to [Airtable Account](https://airtable.com/account) to get it.

2. How to get the Base ID

We need the corresponding Airtable Base ID to get the data source, Airtable Base ID can be obtained by clicking into [Airtable Rest API](https://airtable.com/api), first select the Base you want to import, then copy it from the INTRODUCTION page.

3. How to get the Table ID

We need the corresponding Airtable Table ID to get the data source, Airtable Table ID can be obtained by clicking into [Airtable Rest API](https://airtable.com/api) , first select the Table you want to import, and then get it from the introduction.
After accessing the page for creating and managing personal access tokens, simply click on the "Generate new token" button to create a fresh Personal Access Token.

## Supported column types

Expand All @@ -37,4 +30,3 @@ We need the corresponding Airtable Table ID to get the data source, Airtable Tab
- [ ] Member
- [ ] MagicLink
- [ ] MagicLookUp

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33,470 changes: 17,072 additions & 16,398 deletions dist/packed/widget_bundle.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/packed/widget_bundle.js.map

Large diffs are not rendered by default.

Binary file added space_img_success.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
58 changes: 27 additions & 31 deletions src/airtable-import/add-record.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
import { Button, Typography } from '@apitable/components';
import {
FieldType, useActiveViewId, useDatasheet, useFields, upload, IAttachmentValue, t,
getLanguage, LangType,
} from '@apitable/widget-sdk';
import { FieldType, useActiveViewId, useDatasheet, useFields, upload, IAttachmentValue, t, getLanguage, LangType } from '@apitable/widget-sdk';
import { find, has, isEmpty } from 'lodash';
import React, { useContext, useEffect, useRef, useState } from 'react';
import { getFileBlob, Strings } from '../utils';
import { IFieldMap, IRecord } from '../types';
import style from './index.css';
import { Context } from '../context';
import successImg from '../../space_img_success.png';
import { MAX_FILE_SIZE } from '../constants';

interface IAddRecord {
records?: IRecord[];
fieldMap: IFieldMap;
}
export const AddRecord: React.FC<IAddRecord> = props => {
export const AddRecord: React.FC<IAddRecord> = (props) => {
const { records, fieldMap } = props;
const [importing, setImporting] = useState(false);
const { setStep } = useContext(Context);
Expand All @@ -31,28 +29,29 @@ export const AddRecord: React.FC<IAddRecord> = props => {
}
const sync = async () => {
if (records) {
setImporting(true)
setImporting(true);
let i = 0;
while(i < records.length && !stopRef.current) {
while (i < records.length && !stopRef.current) {
const record = records[i];
let newRecord: object = {};
for (const fieldName in record.fields) {
const field = find(fields, { name: fieldName });
if (!field || !fieldMap[fieldName]) {
// console.log(`${fieldName} has no column`);
continue;
} else {
let recordValue = record.fields[fieldName];
// Attachment adding line: obtain attachment blob => file with file name and file type => upload => add line
// TODO: limit file blob size
if (field.type === FieldType.Attachment) {
const files: IAttachmentValue[] = [];
for(let k = 0; k < recordValue.length; k++) {
for (let k = 0; k < recordValue.length; k++) {
const rv = recordValue[k];
const fileBlob = await getFileBlob(rv.url);
// Upload files smaller than 10MB
if (fileBlob.size < MAX_FILE_SIZE) {
const curFile = new File([fileBlob], rv.filename, {
type: rv.type
type: rv.type,
});
const uploadRlt = await upload({
file: curFile,
Expand All @@ -62,11 +61,7 @@ export const AddRecord: React.FC<IAddRecord> = props => {
}
}
recordValue = files;
} else if (
field.type !== FieldType.MultiSelect &&
Array.isArray(recordValue) &&
typeof recordValue[0] === 'string'
) {
} else if (field.type !== FieldType.MultiSelect && Array.isArray(recordValue) && typeof recordValue[0] === 'string') {
recordValue = recordValue.join(',');
} else if (field.type !== FieldType.MultiSelect && typeof recordValue === 'object') {
recordValue = JSON.stringify(recordValue);
Expand All @@ -75,16 +70,17 @@ export const AddRecord: React.FC<IAddRecord> = props => {
}
}
try {
// console.log('newRecord', newRecord);
// Integer line null ignore
if (!isEmpty(newRecord)) {
const checkRlt = await datasheet.checkPermissionsForAddRecord(newRecord);
if (checkRlt.acceptable) {
await datasheet.addRecord(newRecord);
successCountRef.current++;
successCountRef.current++;
} else {
failCountRef.current++;
console.error(checkRlt.message);
}
}
}
} catch (e) {
failCountRef.current++;
Expand All @@ -94,42 +90,42 @@ export const AddRecord: React.FC<IAddRecord> = props => {
}
setImporting(false);
}
}
};
sync();
}, []);

const isZh = getLanguage() === LangType.ZhCN;

const stopImport = () => {
stopRef.current = true;
}
};

return (
<div className={style.importAddRecord}>
{!importing && !stopRef.current && (
<img className={style.importAddRecordImg} src="https://legacy-s1.apitable.com/space/2022/12/22/ea175fa9bbc54753bec4a0a4d85b3ede" alt="succee image"/>
)}
<Typography variant="h6" className={style.importProcess}>
{!importing && !stopRef.current && <img className={style.importAddRecordImg} src={successImg} alt="succee image" />}
<Typography variant="h6" className={style.importProcess}>
{!importing && !stopRef.current && (
<span>
{t(Strings.import_completed)}{t(Strings.dot)}
{t(Strings.import_completed)}
{t(Strings.dot)}
</span>
)}
{!importing && stopRef.current && (
<span>
{t(Strings.import_stoped)}{t(Strings.dot)}
{t(Strings.import_stoped)}
{t(Strings.dot)}
</span>
)}
{isZh ? (
<span>
{records?.length} 行数据,已导入
<span className={style.importAddRecordSuccess}>{successCountRef.current}</span> 行、失败
{t(Strings.total)} {records?.length} {t(Strings.rows_imported)}
<span className={style.importAddRecordSuccess}>{successCountRef.current}</span> 行、失败
<span className={style.importAddRecordFail}>{failCountRef.current}</span>
</span>
): (
) : (
<span>
A total of {records?.length} records,
<span className={style.importAddRecordSuccess}>{successCountRef.current}</span> records has been imported,
A total of {records?.length} records,
<span className={style.importAddRecordSuccess}>{successCountRef.current}</span> records has been imported,
<span className={style.importAddRecordFail}>{failCountRef.current}</span> records failed
</span>
)}
Expand All @@ -145,5 +141,5 @@ export const AddRecord: React.FC<IAddRecord> = props => {
</Button>
)}
</div>
)
}
);
};
2 changes: 1 addition & 1 deletion src/airtable-import/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@

.importAddRecordFail {
color: var(--textDangerDefault);
}
}
20 changes: 9 additions & 11 deletions src/airtable-import/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { Typography } from '@apitable/components';
import { t, useDatasheet } from '@apitable/widget-sdk';
import React, { useState } from 'react';
import { useEffect } from 'react'
import { useEffect } from 'react';
import { IFieldMap, IRecord } from '../types';
import { addField, sleep, Strings } from '../utils';
import { AddRecord } from './add-record';
import style from './index.css';

interface IAirTableImport {
fieldMap: IFieldMap;
records?: IRecord[]
records?: IRecord[];
}

export const AirTableImport: React.FC<IAirTableImport> = props => {
export const AirTableImport: React.FC<IAirTableImport> = (props) => {
const { fieldMap, records } = props;
const [importing, setImporting] = useState(true);
const datasheet = useDatasheet();
Expand All @@ -23,17 +23,15 @@ export const AirTableImport: React.FC<IAirTableImport> = props => {
await addField(fieldMap, datasheet);
await sleep(3000);
setImporting(false);
}
};
sync();
}, [])
}, []);
if (!importing) {
return <AddRecord records={records} fieldMap={fieldMap} />
return <AddRecord records={records} fieldMap={fieldMap} />;
}
return (
<div className={style.importAddField}>
<Typography variant="h3">
{t(Strings.create_fields)}...
</Typography>
<Typography variant="h3">{t(Strings.create_fields)}...</Typography>
</div>
)
}
);
};
33 changes: 33 additions & 0 deletions src/apis/get-bases.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { AIRTABLE_API_VERSION, AIRTABLE_URL } from '../constants';
import { Base } from '../types';

export const GetBases = async (personalAccessToken: string) => {
if (!personalAccessToken) {
throw new Error('personalAccessToken is required');
}

const url = `${AIRTABLE_URL}/${AIRTABLE_API_VERSION}/meta/bases`;

const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: `Bearer ${personalAccessToken}`,
Host: AIRTABLE_URL,
},
});

if (!response.ok) {
if (response.status === 401) {
throw new Error('Unauthorized: Error Personal Access Token.');
} else {
const json = await response.json();
throw new Error(json.error.message || 'Error fetching bases, please check your Personal Access Token.');
}
}

const json = await response.json();
const bases = json.bases.map((base: Base) => ({ id: base.id, name: base.name }));
return bases;
};
14 changes: 7 additions & 7 deletions src/apis/get-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,20 @@ export const getRecords = async (apiKey: string, baseId: string, tableId: string
// _query['fields[]'] = 'number';

const queryStr = queryString.stringify(_query);

const url = `${AIRTABLE_URL}/${AIRTABLE_API_VERSION}/${baseId}/${tableId}?${queryStr}`;

const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
Accept: 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + apiKey,
'Host': AIRTABLE_URL
}
Authorization: 'Bearer ' + apiKey,
Host: AIRTABLE_URL,
},
});

const json = await response.json();

return json
}
return json;
};
22 changes: 22 additions & 0 deletions src/apis/get-tables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { AIRTABLE_API_VERSION, AIRTABLE_URL } from '../constants';
import { Table } from '../types';

export const GetTables = async (personalAccessToken: string, baseId: string) => {
if (!personalAccessToken) return null;

const url = `${AIRTABLE_URL}/${AIRTABLE_API_VERSION}/meta/bases/${baseId}/tables`;

const response = await fetch(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Bearer ' + personalAccessToken,
Host: AIRTABLE_URL,
},
});

const json = await response.json();
const tables = json.tables.map((table: Table) => ({ id: table.id, name: table.name }));
return tables;
};
4 changes: 3 additions & 1 deletion src/apis/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from './get-records';
export * from './get-records';
export * from './get-bases';
export * from './get-tables';
8 changes: 4 additions & 4 deletions src/choose-field/index.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
.chooseField {
max-width: 760px;
padding: 24px 16px;
margin: 0 auto;
margin: 0 auto;
height: 100%;
}

Expand All @@ -14,7 +14,7 @@
}

.chooseFieldLoading {
display:flex;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
Expand Down Expand Up @@ -74,7 +74,7 @@
.chooseFieldText {
margin: 24px 0;
color: var(--textCommonSecondary);
display: flex
display: flex;
}

.chooseFieldText a {
Expand All @@ -84,4 +84,4 @@
.chooseFieldAction {
display: flex;
justify-content: space-between;
}
}
Loading

0 comments on commit f018ede

Please sign in to comment.