Skip to content

Commit 8feb0f8

Browse files
caoxing9claude
andcommitted
feat(n8n): scaffold n8n-nodes-teable with Record node (PAT auth)
Declarative-routing community node: Record resource with Get Many / Get / Create / Update / Delete against the Teable REST API. Base and Table are dynamic dropdowns (loadOptions). PAT credential carries the instance URL so cloud and self-hosted users each point at their own Teable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c580f99 commit 8feb0f8

10 files changed

Lines changed: 2255 additions & 0 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules/
2+
dist/
3+
*.tgz
4+
.env
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# n8n-nodes-teable
2+
3+
An [n8n](https://n8n.io) community node for [Teable](https://teable.io) — create,
4+
read, update and delete records in a Teable base.
5+
6+
Teable is self-hostable, so the **instance URL lives on the credential**: each
7+
connection points at its own Teable (cloud `https://app.teable.io` or your own
8+
host).
9+
10+
## Installation
11+
12+
Community nodes → install `n8n-nodes-teable` (n8n **Settings → Community Nodes**),
13+
or for self-hosted: `npm install n8n-nodes-teable` in your n8n custom nodes dir.
14+
15+
## Credentials
16+
17+
**Teable API** (Personal Access Token):
18+
19+
- **Instance URL** — origin of your Teable, no trailing slash / no `/api`
20+
(e.g. `https://app.teable.io`).
21+
- **Access Token** — Teable → **Settings → Personal access tokens**. Sent as a
22+
Bearer token. The "Test" button calls `GET /api/auth/user`.
23+
24+
## Operations
25+
26+
**Record**
27+
28+
| Operation | API |
29+
| --- | --- |
30+
| Get Many | `GET /api/table/{tableId}/record` |
31+
| Get | `GET /api/table/{tableId}/record/{recordId}` |
32+
| Create | `POST /api/table/{tableId}/record` |
33+
| Update | `PATCH /api/table/{tableId}/record/{recordId}` |
34+
| Delete | `DELETE /api/table/{tableId}/record/{recordId}` |
35+
36+
Base and Table are dynamic dropdowns. Create/Update take a **Fields (JSON)**
37+
object (`{"Name":"Acme"}`); values are typecast like the Teable UI.
38+
39+
## Develop
40+
41+
```bash
42+
npm install
43+
npm run build # tsc → dist/, then copies icons
44+
```
45+
46+
Link into a local n8n to test:
47+
48+
```bash
49+
npm link
50+
cd ~/.n8n/custom && npm link n8n-nodes-teable # then restart n8n
51+
```
52+
53+
## Roadmap
54+
55+
- Polling trigger (new / updated record)
56+
- Per-field inputs (resource mapper) instead of raw JSON
57+
- Attachment field support
58+
- OAuth2 credential option
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type {
2+
IAuthenticateGeneric,
3+
ICredentialTestRequest,
4+
ICredentialType,
5+
INodeProperties,
6+
} from 'n8n-workflow';
7+
8+
// Personal Access Token auth. The instance URL lives on the credential so each
9+
// user (cloud or self-hosted) points at their own Teable — unlike the Zapier
10+
// app where the instance is fixed per version.
11+
export class TeableApi implements ICredentialType {
12+
name = 'teableApi';
13+
14+
displayName = 'Teable API';
15+
16+
documentationUrl = 'https://help.teable.io/developer/api';
17+
18+
properties: INodeProperties[] = [
19+
{
20+
displayName: 'Instance URL',
21+
name: 'baseUrl',
22+
type: 'string',
23+
default: 'https://app.teable.io',
24+
placeholder: 'https://app.teable.io',
25+
required: true,
26+
description:
27+
'Origin of your Teable instance — no trailing slash and no /api suffix (e.g. https://app.teable.io or your self-hosted URL).',
28+
},
29+
{
30+
displayName: 'Access Token',
31+
name: 'accessToken',
32+
type: 'string',
33+
typeOptions: { password: true },
34+
default: '',
35+
required: true,
36+
description:
37+
'A Teable personal access token (Teable → Settings → Personal access tokens). Sent as a Bearer token.',
38+
},
39+
];
40+
41+
// Attach the token as a Bearer header on every request the node makes.
42+
authenticate: IAuthenticateGeneric = {
43+
type: 'generic',
44+
properties: {
45+
headers: {
46+
Authorization: '=Bearer {{$credentials.accessToken}}',
47+
},
48+
},
49+
};
50+
51+
// n8n's "Test" button hits this. /auth/user returns the token's user identity.
52+
test: ICredentialTestRequest = {
53+
request: {
54+
baseURL: '={{$credentials.baseUrl.replace(/\\/$/, "")}}/api',
55+
url: '/auth/user',
56+
},
57+
};
58+
}

packages/n8n-nodes-teable/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
// n8n discovers nodes/credentials via the `n8n` field in package.json (pointing
2+
// at dist/). This file only exists to satisfy package.json `main`.
3+
module.exports = {};
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import {
2+
NodeConnectionTypes,
3+
type IExecuteFunctions,
4+
type ILoadOptionsFunctions,
5+
type INodeExecutionData,
6+
type INodePropertyOptions,
7+
type INodeType,
8+
type INodeTypeDescription,
9+
} from 'n8n-workflow';
10+
11+
// Helper for loadOptions only — the record operations themselves use declarative
12+
// routing. Builds the URL from the credential's instance URL and attaches auth.
13+
async function teableApiRequest(
14+
this: ILoadOptionsFunctions,
15+
method: 'GET',
16+
endpoint: string,
17+
): Promise<unknown> {
18+
const creds = await this.getCredentials('teableApi');
19+
const baseUrl = String(creds.baseUrl).replace(/\/$/, '');
20+
return this.helpers.httpRequestWithAuthentication.call(this, 'teableApi', {
21+
method,
22+
url: `${baseUrl}/api${endpoint}`,
23+
json: true,
24+
});
25+
}
26+
27+
export class Teable implements INodeType {
28+
description: INodeTypeDescription = {
29+
displayName: 'Teable',
30+
name: 'teable',
31+
icon: 'file:teable.svg',
32+
group: ['transform'],
33+
version: 1,
34+
subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
35+
description: 'Create, read, update and delete records in a Teable base',
36+
defaults: { name: 'Teable' },
37+
inputs: [NodeConnectionTypes.Main],
38+
outputs: [NodeConnectionTypes.Main],
39+
credentials: [{ name: 'teableApi', required: true }],
40+
requestDefaults: {
41+
baseURL: '={{$credentials.baseUrl.replace(/\\/$/, "")}}/api',
42+
headers: { 'Content-Type': 'application/json' },
43+
},
44+
properties: [
45+
{
46+
displayName: 'Resource',
47+
name: 'resource',
48+
type: 'options',
49+
noDataExpression: true,
50+
options: [{ name: 'Record', value: 'record' }],
51+
default: 'record',
52+
},
53+
54+
{
55+
displayName: 'Operation',
56+
name: 'operation',
57+
type: 'options',
58+
noDataExpression: true,
59+
displayOptions: { show: { resource: ['record'] } },
60+
options: [
61+
{
62+
name: 'Create',
63+
value: 'create',
64+
action: 'Create a record',
65+
routing: {
66+
request: {
67+
method: 'POST',
68+
url: '=/table/{{$parameter.tableId}}/record',
69+
body: {
70+
fieldKeyType: 'name',
71+
typecast: true,
72+
records: '={{ [{ fields: JSON.parse($parameter.fieldsJson || "{}") }] }}',
73+
},
74+
},
75+
output: {
76+
postReceive: [{ type: 'rootProperty', properties: { property: 'records' } }],
77+
},
78+
},
79+
},
80+
{
81+
name: 'Delete',
82+
value: 'delete',
83+
action: 'Delete a record',
84+
routing: {
85+
request: {
86+
method: 'DELETE',
87+
url: '=/table/{{$parameter.tableId}}/record/{{$parameter.recordId}}',
88+
},
89+
},
90+
},
91+
{
92+
name: 'Get',
93+
value: 'get',
94+
action: 'Get a record',
95+
routing: {
96+
request: {
97+
method: 'GET',
98+
url: '=/table/{{$parameter.tableId}}/record/{{$parameter.recordId}}',
99+
qs: { fieldKeyType: 'name' },
100+
},
101+
},
102+
},
103+
{
104+
name: 'Get Many',
105+
value: 'getMany',
106+
action: 'Get many records',
107+
routing: {
108+
request: {
109+
method: 'GET',
110+
url: '=/table/{{$parameter.tableId}}/record',
111+
qs: { fieldKeyType: 'name', take: '={{$parameter.limit}}' },
112+
},
113+
output: {
114+
postReceive: [{ type: 'rootProperty', properties: { property: 'records' } }],
115+
},
116+
},
117+
},
118+
{
119+
name: 'Update',
120+
value: 'update',
121+
action: 'Update a record',
122+
routing: {
123+
request: {
124+
method: 'PATCH',
125+
url: '=/table/{{$parameter.tableId}}/record/{{$parameter.recordId}}',
126+
body: {
127+
fieldKeyType: 'name',
128+
typecast: true,
129+
record: '={{ ({ fields: JSON.parse($parameter.fieldsJson || "{}") }) }}',
130+
},
131+
},
132+
},
133+
},
134+
],
135+
default: 'getMany',
136+
},
137+
138+
// Base + Table dropdowns (dynamic). Table depends on the chosen base.
139+
{
140+
displayName: 'Base Name or ID',
141+
name: 'baseId',
142+
type: 'options',
143+
typeOptions: { loadOptionsMethod: 'getBases' },
144+
required: true,
145+
default: '',
146+
description:
147+
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
148+
displayOptions: { show: { resource: ['record'] } },
149+
},
150+
{
151+
displayName: 'Table Name or ID',
152+
name: 'tableId',
153+
type: 'options',
154+
typeOptions: { loadOptionsMethod: 'getTables', loadOptionsDependsOn: ['baseId'] },
155+
required: true,
156+
default: '',
157+
description:
158+
'Choose from the list, or specify an ID using an <a href="https://docs.n8n.io/code/expressions/">expression</a>.',
159+
displayOptions: { show: { resource: ['record'] } },
160+
},
161+
162+
// Record ID — for get / update / delete.
163+
{
164+
displayName: 'Record ID',
165+
name: 'recordId',
166+
type: 'string',
167+
required: true,
168+
default: '',
169+
placeholder: 'rec…',
170+
displayOptions: { show: { resource: ['record'], operation: ['get', 'update', 'delete'] } },
171+
},
172+
173+
// Fields JSON — for create / update.
174+
{
175+
displayName: 'Fields (JSON)',
176+
name: 'fieldsJson',
177+
type: 'json',
178+
default: '{}',
179+
description:
180+
'Object of field name → value, e.g. {"Name":"Acme","Status":"open"}. Values are typecast like the Teable UI.',
181+
displayOptions: { show: { resource: ['record'], operation: ['create', 'update'] } },
182+
},
183+
184+
// Limit — for getMany.
185+
{
186+
displayName: 'Limit',
187+
name: 'limit',
188+
type: 'number',
189+
typeOptions: { minValue: 1, maxValue: 2000 },
190+
default: 100,
191+
description: 'Max number of records to return',
192+
displayOptions: { show: { resource: ['record'], operation: ['getMany'] } },
193+
},
194+
],
195+
};
196+
197+
methods = {
198+
loadOptions: {
199+
async getBases(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
200+
const bases = (await teableApiRequest.call(this, 'GET', '/base/access/all')) as Array<{
201+
id: string;
202+
name: string;
203+
}>;
204+
return (bases || []).map((b) => ({ name: b.name, value: b.id }));
205+
},
206+
207+
async getTables(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
208+
const baseId = this.getCurrentNodeParameter('baseId') as string;
209+
if (!baseId) return [];
210+
const tables = (await teableApiRequest.call(
211+
this,
212+
'GET',
213+
`/base/${baseId}/table`,
214+
)) as Array<{ id: string; name: string }>;
215+
return (tables || []).map((t) => ({ name: t.name, value: t.id }));
216+
},
217+
},
218+
};
219+
220+
// Declarative routing handles execution; this is required by the interface but
221+
// is never called for a fully-declarative node.
222+
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
223+
return [this.getInputData()];
224+
}
225+
}
Lines changed: 10 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)