feat: 收录 inkreach-teable-plugin skill;install.sh 安装全部技能
skills/ 现包含 inkreach-teable + inkreach-teable-plugin, npx skills add 一并安装两个;install.sh 改为遍历安装 skills/*。
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
name: inkreach-teable-plugin
|
||||||
|
description: >-
|
||||||
|
Develop dynamic React component plugins for inkreach-teable — IIFE bundles
|
||||||
|
loaded at runtime via window.__TEABLE_HOST__, no iframe sandbox, full host
|
||||||
|
API access. Scaffold with plugin-cli, build with webpack, deploy via
|
||||||
|
upload-bundle API, register via definePlugin + PluginContext. Positions:
|
||||||
|
contextMenu, panel, view, dashboard, route. Use this whenever the user
|
||||||
|
mentions plugins, 插件, batch-edit, context-menu actions, panel widgets,
|
||||||
|
dashboard charts, custom views, plugin.json, definePlugin, bundle plugin,
|
||||||
|
plugin-cli, plugin-sdk, plugin-host, or window.__TEABLE_HOST__. Even if
|
||||||
|
the user just says "add a right-click action" or "make a side panel" or
|
||||||
|
"I need a dashboard widget" — or wants the native form embedded or
|
||||||
|
extended inside a plugin (原生表单, 表单注入, 表单虚拟字段, custom form
|
||||||
|
layout, NativeForm, NativeFormScope, form panel) — load this skill.
|
||||||
|
Reference example at plugins/batch-edit-pro/.
|
||||||
|
---
|
||||||
|
|
||||||
|
# inkreach-teable dynamic plugin development
|
||||||
|
|
||||||
|
This skill develops React component plugins that load at runtime without
|
||||||
|
rebuilding the host Next.js app. Plugins are IIFE bundles — they ship as plain
|
||||||
|
JS that executes in the host page and accesses host APIs through
|
||||||
|
`window.__TEABLE_HOST__`.
|
||||||
|
|
||||||
|
> **Contract**: A plugin = a webpack IIFE bundle (`dist/main.js` + optional
|
||||||
|
> `dist/styles.css`) + `plugin.json` manifest, uploaded to the host via
|
||||||
|
> `POST /api/plugin/:id/upload-bundle`. It runs in the same React tree as the host
|
||||||
|
> and has full access to the host's SDK, UI library, and OpenAPI helpers.
|
||||||
|
>
|
||||||
|
> **Critical constraint**: NEVER `import` from `@teable/sdk`, `@teable/ui-lib`,
|
||||||
|
> `@teable/openapi` or `react` in plugin code. These are mapped to
|
||||||
|
> `window.__TEABLE_HOST__.*` by webpack externals at build time — importing
|
||||||
|
> them produces `undefined` at runtime. Always read from the host global:
|
||||||
|
> `const { useFields } = window.__TEABLE_HOST__.SDK`. See
|
||||||
|
> `dynamic-plugins/troubleshooting.md` Pitfall 1 for details and symptoms.
|
||||||
|
|
||||||
|
## 1. When to use this skill
|
||||||
|
|
||||||
|
- User says "写个插件 / 做个插件 / make a plugin" — yes.
|
||||||
|
- User wants a context-menu action (batch edit / process selected records) — yes.
|
||||||
|
- User wants a panel gadget (real-time stats / summary) — yes.
|
||||||
|
- User wants a custom view (kanban, gallery, timeline) — yes.
|
||||||
|
- User wants a dashboard chart — yes.
|
||||||
|
- User wants to embed / extend the NATIVE FORM in a plugin (原生表单, form
|
||||||
|
injection, virtual form fields, custom multi-step form with native field
|
||||||
|
editors, `host.NativeForm`, `NativeFormScope`) — yes, see `sdk-api.md`.
|
||||||
|
- User mentions `definePlugin`, `plugin-cli`, `plugin-sdk`, `window.__TEABLE_HOST__`, bundle plugin — yes.
|
||||||
|
- User wants to manage tables/automations (not plugins) — use `inkreach-teable` skill instead.
|
||||||
|
|
||||||
|
## 2. Module map (read in order when developing)
|
||||||
|
|
||||||
|
| Stage | File | Read it when... |
|
||||||
|
|---|---|---|
|
||||||
|
| 1. Understand architecture | `dynamic-plugins/overview.md` | always first — how `window.__TEABLE_HOST__`, PluginLoader, PluginSlot work |
|
||||||
|
| 2. Dev workflow | `dynamic-plugins/dev-workflow.md` | when building a plugin — the step-by-step recipe from scaffold to deploy |
|
||||||
|
| 3. SDK API | `dynamic-plugins/sdk-api.md` | when writing plugin logic — definePlugin, PluginContext, events, storage, useSelection, native form embedding (`NativeForm` Tier-1 injection / `NativeFormScope`+`useNativeForm` Tier-2 kit), NativeGrid |
|
||||||
|
| 4. Deployment | `dynamic-plugins/deployment.md` | when uploading + enabling + authority matrix permissions |
|
||||||
|
| 5. Troubleshooting | `dynamic-plugins/troubleshooting.md` | before writing logic — common pitfalls (host globals, developer whitelist, AM, empty states) |
|
||||||
|
| Ref: batch-edit-pro | `plugins/batch-edit-pro/` | the canonical example — contextMenu plugin, full React + SDK |
|
||||||
|
|
||||||
|
## 3. Quick decision: which position?
|
||||||
|
|
||||||
|
| User wants | Position | `definePlugin` registration |
|
||||||
|
|---|---|---|
|
||||||
|
| Right-click action on records | **ContextMenu** | `ctx.addContextMenuAction(id, component)` |
|
||||||
|
| Side gadget while viewing a table | **Panel** | `ctx.addPanel(id, component)` |
|
||||||
|
| A new view type | **View** | `ctx.addView(id, component)` |
|
||||||
|
| Card on the space dashboard | **Dashboard** | `ctx.addDashboardWidget(id, component)` |
|
||||||
|
| A new page/route | **Route** | `ctx.addRoute(path, component)` |
|
||||||
|
|
||||||
|
## 4. Auth & test credentials
|
||||||
|
|
||||||
|
- Endpoint: `http://localhost:3003` (backend; `:3000` is taken by an unrelated
|
||||||
|
docker container on this host — see AGENTS.md), frontend `http://localhost:3002`.
|
||||||
|
- Test account: create a throwaway via `POST /api/auth/signup` — the old
|
||||||
|
`hr_admin@xingchen.tech` account no longer exists on this instance. Plugin
|
||||||
|
upload additionally requires a `plugin_developer` row for the uploading user
|
||||||
|
(`INSERT INTO plugin_developer (id, user_id, email, created_time, created_by)
|
||||||
|
VALUES ('plgd<name>', '<userId>', '<email>', now(), '<userId>')`).
|
||||||
|
- Test base: `bseAi6Hazmw7jPWZbfO`, test table: `tbldtYd4ZhvkcyAaada`.
|
||||||
|
- Auth: session cookie `auth_session`, or `inkreach-teable auth login`.
|
||||||
|
|
||||||
|
## 5. Start here
|
||||||
|
|
||||||
|
For any plugin request, read `dynamic-plugins/overview.md` then
|
||||||
|
`dynamic-plugins/dev-workflow.md`. Reference `plugins/batch-edit-pro/` for a
|
||||||
|
complete working example.
|
||||||
|
|
||||||
|
## 6. Plugin backend services — MUST use the same-origin proxy (2026-08-21)
|
||||||
|
|
||||||
|
The instance is publicly served at `https://table.inkreach.cc` (frp + cloud Caddy);
|
||||||
|
browsers on the public internet CANNOT reach LAN addresses like
|
||||||
|
`http://192.168.124.92:8001`. Plugin bundles that need their own backend service
|
||||||
|
MUST call it through the session-gated dynamic proxy instead:
|
||||||
|
|
||||||
|
- **URL contract**: `fetch('/api/plugin-proxy/<service>/<path...>')` — same-origin,
|
||||||
|
works from LAN and public internet alike. NEVER hardcode LAN IPs / `localhost`
|
||||||
|
in a bundle.
|
||||||
|
- **Registry**: one line in `PLUGIN_API_SERVICES` (JSON, in
|
||||||
|
`apps/nextjs-app/.env` AND `.env.development`), then restart
|
||||||
|
`teable-backend.service`. No rebuild, no code change.
|
||||||
|
Example: `PLUGIN_API_SERVICES={"track":"http://127.0.0.1:8001"}`
|
||||||
|
- **Implementation**: NestJS controller
|
||||||
|
`apps/nestjs-backend/src/features/plugin/plugin-proxy.controller.ts`
|
||||||
|
(global AuthGuard gates login; upstream whitelist = registry; session cookies
|
||||||
|
are never forwarded upstream). Do NOT move this to a Next.js pages-router API
|
||||||
|
route — the `/api/:path*` rewrite in `next.config.js` outranks catch-all file
|
||||||
|
routes, and files outside `pages/api/` get compiled as SSR pages (observed on
|
||||||
|
Next 16.1.6/turbopack).
|
||||||
|
- **CSP**: same-origin fetches need nothing; truly external origins must be
|
||||||
|
declared in the plugin manifest `apiOrigins` (appended to connect-src by
|
||||||
|
`plugin-runtime.controller.ts`).
|
||||||
|
- Re-uploading a plugin zip OVERWRITES the bundle — re-check for hardcoded LAN
|
||||||
|
URLs afterwards (`grep -o 'http://192.168[^\"']*' <bundle>/main.js`).
|
||||||
|
Live example: `apps/nestjs-backend/.assets/plugins/bundle/track-panel/`
|
||||||
|
(patched from LAN URL to `/api/plugin-proxy/track`; backups kept as
|
||||||
|
`main.js.bak-*`).
|
||||||
|
- **Root fix (2026-08-22 recurrence)**: a developer re-uploaded track-panel at
|
||||||
|
02:23 with the LAN URL back in the source-built bundle, breaking public
|
||||||
|
search with a CORS error within hours of the last patch. Patching
|
||||||
|
`main.js` on disk only fixes the CURRENT artifact — the plugin SOURCE must
|
||||||
|
use `/api/plugin-proxy/track` before building/uploading, otherwise every
|
||||||
|
re-upload reintroduces the bug. The backend serves bundle files from disk
|
||||||
|
per-request, so a disk patch takes effect without a backend restart.
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Plugin deployment
|
||||||
|
|
||||||
|
Uploading a bundle plugin, enabling it, and managing permissions.
|
||||||
|
|
||||||
|
## Upload — `POST /api/plugin/:pluginId/upload-bundle`
|
||||||
|
|
||||||
|
Upload the built bundle to the host. Uses `multipart/form-data`.
|
||||||
|
The `pluginId` in the URL must match the `id` in `plugin.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
|
||||||
|
curl -s -b "auth_session=$SESSION" \
|
||||||
|
-F "js=@dist/main.js" \
|
||||||
|
-F "css=@dist/styles.css" \
|
||||||
|
-F "manifest=@plugin.json" \
|
||||||
|
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auth note**: Supports both session cookies (`auth_session`) and Personal Access
|
||||||
|
Tokens (`Authorization: Bearer <PAT>`).
|
||||||
|
|
||||||
|
**Fields:**
|
||||||
|
| Field | Required | Max size | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `js` | Yes | 5 MB | The IIFE bundle (`dist/main.js`) |
|
||||||
|
| `css` | No | 1 MB | Optional stylesheet (`dist/styles.css`) |
|
||||||
|
| `manifest` | Yes | 10 KB | `plugin.json` manifest |
|
||||||
|
|
||||||
|
**Returns** `201` with `{ id, name, version, ... }` — the plugin ID to use for
|
||||||
|
enable and install.
|
||||||
|
|
||||||
|
**Security** (7-layer validation):
|
||||||
|
1. Authentication — valid session cookie required (401 if missing)
|
||||||
|
2. Developer whitelist — `plugin_developer` table lookup (403 if not whitelisted)
|
||||||
|
3. Rate limit — 10 uploads/min per user (429 if exceeded)
|
||||||
|
4. Size limits — JS ≤ 5 MB, CSS ≤ 1 MB, manifest ≤ 10 KB
|
||||||
|
5. Manifest validation — required fields, `id` regex `[a-z0-9-]+`
|
||||||
|
6. Content validation — JS must be IIFE format (prefix `!function`/`(()=>`),
|
||||||
|
rejects `<script>`/`eval`/`document.write` injection; CSS strips `expression()`/
|
||||||
|
`@import`/`javascript:` URIs
|
||||||
|
7. Integrity — SHA-256 hash stored in DB, verified at load time
|
||||||
|
|
||||||
|
## Enable — `PUT /api/base/:baseId/plugin-config/:pluginId`
|
||||||
|
|
||||||
|
The plugin must be enabled before it can be installed to any position.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -b "auth_session=$SESSION" -X PUT \
|
||||||
|
"http://localhost:3003/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"enabled":true,"tableIds":[]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `tableIds: []` — enabled base-wide (all tables)
|
||||||
|
- `tableIds: ["tblX", "tblY"]` — enabled only for specific tables
|
||||||
|
|
||||||
|
## Install by position
|
||||||
|
|
||||||
|
### ContextMenu
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -b "auth_session=$SESSION" -X POST \
|
||||||
|
"http://localhost:3003/api/table/$TABLE_ID/plugin-context-menu/install" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Body: `pluginId` (required), `name` (optional display name).
|
||||||
|
|
||||||
|
### Panel
|
||||||
|
|
||||||
|
Panel install requires an existing panel. First create one, then install to it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a panel (if none exists)
|
||||||
|
PANEL_ID=$(curl -s -b "auth_session=$SESSION" -X POST \
|
||||||
|
"http://localhost:3003/api/table/$TABLE_ID/plugin-panel" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"My Panel"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
|
|
||||||
|
# Install plugin to the panel
|
||||||
|
curl -s -b "auth_session=$SESSION" -X POST \
|
||||||
|
"http://localhost:3003/api/table/$TABLE_ID/plugin-panel/$PANEL_ID/install" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The `plg` prefixed ID is the panel's UUID, not a plugin ID. Create a panel first
|
||||||
|
if you don't already have one.
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -b "auth_session=$SESSION" -X POST \
|
||||||
|
"http://localhost:3003/api/base/$BASE_ID/dashboard/$DASHBOARD_ID/plugin" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### View
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -b "auth_session=$SESSION" -X POST \
|
||||||
|
"http://localhost:3003/api/table/$TABLE_ID/view/plugin" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"pluginId":"$PLUGIN_ID","name":"My Plugin"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `{ pluginId, pluginInstallId, name, viewId }`. Creates a new view
|
||||||
|
of type `plugin` in the table's view list. The plugin's `definePlugin.onLoad`
|
||||||
|
must call `ctx.addView(id, component)` to render the component as a view.
|
||||||
|
Authority Matrix entries are auto-created for the new install (default
|
||||||
|
denied for non-owner roles).
|
||||||
|
|
||||||
|
### Route
|
||||||
|
|
||||||
|
Route-type plugins registered via `ctx.addRoute(path, component)` are
|
||||||
|
automatically available at the registered path — no install API needed.
|
||||||
|
Upload + enable is sufficient.
|
||||||
|
|
||||||
|
## Authority Matrix (plugin permissions)
|
||||||
|
|
||||||
|
Plugins integrate with the 4-layer permission system. Three control layers:
|
||||||
|
|
||||||
|
| Layer | What it controls | Mechanism |
|
||||||
|
|---|---|---|
|
||||||
|
| **Plugin visible** | User sees plugin in context menu / panel / dashboard | `AuthorityMatrixRolePluginInstall` per-role per-install |
|
||||||
|
| **Plugin executable** | User can click and run the plugin | Checked at `PluginSlot` render |
|
||||||
|
| **Data operations** | Plugin's CRUD respects user's permissions | **Already protected** — plugins call `@teable/openapi` which passes through existing AM enforcement |
|
||||||
|
|
||||||
|
Default behavior:
|
||||||
|
- New installs default to disabled for non-owner roles
|
||||||
|
- Base owner always has full access
|
||||||
|
- Admin enables per role through the RoleEditor UI
|
||||||
|
|
||||||
|
## Developer whitelist
|
||||||
|
|
||||||
|
Only users in the `plugin_developer` table can upload bundles. To add a developer:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO plugin_developer (id, user_id, email, created_by)
|
||||||
|
VALUES (gen_random_uuid(), '<userId>', '<email>', '<adminUserId>');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting a session cookie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -c /tmp/cookie_hr.txt http://localhost:3003/api/auth/signin \
|
||||||
|
-X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}'
|
||||||
|
|
||||||
|
|
||||||
|
> Account note (2026-08): `hr_admin@xingchen.tech` no longer exists on the
|
||||||
|
> deployed instance. Create a throwaway user via `POST /api/auth/signup`
|
||||||
|
> and add a `plugin_developer` row for it before uploading (see SKILL.md).
|
||||||
|
SESSION=$(grep auth_session /tmp/cookie_hr.txt | awk '{print $NF}')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full deploy script (copy-paste)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
DIR="${1:-.}"
|
||||||
|
UPLOAD_URL="${UPLOAD_URL:-http://localhost:3003}"
|
||||||
|
BASE_ID="${BASE_ID:-bseAi6Hazmw7jPWZbfO}"
|
||||||
|
|
||||||
|
# Read plugin ID from manifest
|
||||||
|
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('$DIR/plugin.json'))['id'])")
|
||||||
|
|
||||||
|
# Get session
|
||||||
|
SESSION=$(grep auth_session /tmp/cookie_hr.txt 2>/dev/null | awk '{print $NF}')
|
||||||
|
if [ -z "$SESSION" ]; then
|
||||||
|
curl -s -c /tmp/cookie_hr.txt "$UPLOAD_URL/api/auth/signin" \
|
||||||
|
-X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' > /dev/null
|
||||||
|
SESSION=$(grep auth_session /tmp/cookie_hr.txt | awk '{print $NF}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload (URL includes pluginId from manifest)
|
||||||
|
curl -s -b "auth_session=$SESSION" \
|
||||||
|
-F "js=@$DIR/dist/main.js" \
|
||||||
|
-F "css=@$DIR/dist/styles.css" \
|
||||||
|
-F "manifest=@$DIR/plugin.json" \
|
||||||
|
"$UPLOAD_URL/api/plugin/$PLUGIN_ID/upload-bundle"
|
||||||
|
echo "Uploaded: $PLUGIN_ID"
|
||||||
|
|
||||||
|
# Enable
|
||||||
|
curl -s -b "auth_session=$SESSION" -X PUT \
|
||||||
|
"$UPLOAD_URL/api/base/$BASE_ID/plugin-config/$PLUGIN_ID" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"enabled":true,"tableIds":[]}' > /dev/null
|
||||||
|
echo "Enabled: $PLUGIN_ID"
|
||||||
|
```
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# Plugin development workflow
|
||||||
|
|
||||||
|
The standard recipe for creating, building, and deploying a dynamic React
|
||||||
|
component plugin. Each plugin is a standalone webpack project that outputs
|
||||||
|
an IIFE bundle.
|
||||||
|
|
||||||
|
## Output contract
|
||||||
|
|
||||||
|
Every plugin task ends with:
|
||||||
|
1. A built IIFE bundle (`dist/main.js` + optional `dist/styles.css`)
|
||||||
|
2. A `plugin.json` manifest
|
||||||
|
3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle`
|
||||||
|
4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId`
|
||||||
|
5. Installed to the target position (the install API varies by position)
|
||||||
|
|
||||||
|
## Step 1 — Understand the request
|
||||||
|
|
||||||
|
Clarify what the user wants, then pick the position using `overview.md`.
|
||||||
|
State the choice: "This is a ContextMenu plugin because..."
|
||||||
|
|
||||||
|
## Step 2 — Scaffold the plugin project
|
||||||
|
|
||||||
|
The `plugin-cli` lives in the repo at `packages/plugin-cli/` (not yet published
|
||||||
|
to npm). Run it directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp
|
||||||
|
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin
|
||||||
|
cd my-plugin
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
This generates:
|
||||||
|
```
|
||||||
|
my-plugin/
|
||||||
|
├── src/
|
||||||
|
│ └── index.tsx # entry: calls definePlugin()
|
||||||
|
├── plugin.json # manifest
|
||||||
|
├── webpack.config.js # pre-configured externals
|
||||||
|
├── tsconfig.json
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If the CLI doesn't work for some reason, manually create the structure by
|
||||||
|
copying `plugins/batch-edit-pro/` as a template.
|
||||||
|
|
||||||
|
## Step 3 — Write the plugin code
|
||||||
|
|
||||||
|
Edit `src/index.tsx`. The shape every plugin follows:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const React = window.__TEABLE_HOST__.React;
|
||||||
|
const host = window.__TEABLE_HOST__;
|
||||||
|
const { useFields, useTableId, useViewId } = host.SDK;
|
||||||
|
const { Button } = host.UILib;
|
||||||
|
const { getRecords } = host.OpenAPI;
|
||||||
|
const { definePlugin, useSelection, getSelectionRecords } = host.PluginSDK;
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const tableId = useTableId();
|
||||||
|
const fields = useFields();
|
||||||
|
// ... business logic
|
||||||
|
return React.createElement('div', null, 'Hello from plugin!');
|
||||||
|
}
|
||||||
|
|
||||||
|
definePlugin({
|
||||||
|
id: 'my-plugin',
|
||||||
|
name: 'My Plugin',
|
||||||
|
version: '1.0.0',
|
||||||
|
minAppVersion: '1.0.0',
|
||||||
|
onLoad(ctx) {
|
||||||
|
ctx.addContextMenuAction('myAction', MyComponent);
|
||||||
|
// or: ctx.addPanel('myPanel', MyComponent);
|
||||||
|
// or: ctx.addDashboardWidget('myWidget', MyComponent);
|
||||||
|
// or: ctx.addView('myView', MyComponent);
|
||||||
|
// or: ctx.addRoute('/my-page', MyComponent);
|
||||||
|
},
|
||||||
|
onUnload(ctx) {
|
||||||
|
// Optional cleanup; ctx auto-disposes registered resources
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rules
|
||||||
|
|
||||||
|
- **Never hardcode** table IDs, view IDs, or tokens — use `useTableId()`,
|
||||||
|
`useViewId()`, and the event bus for contextual data.
|
||||||
|
- **Use `React.createElement`** or JSX (webpack handles transpilation). The
|
||||||
|
host's React instance is the single source of truth.
|
||||||
|
- **Loading / error / empty states** are required. Use `useState` to manage
|
||||||
|
async data loading and surface errors in the UI.
|
||||||
|
- For contextMenu plugins that use `getSelectionRecords`, handle the case
|
||||||
|
where nothing is selected (it returns an empty array).
|
||||||
|
- Plug in to events for reactivity: `ctx.on('selection.changed', ...)`,
|
||||||
|
`ctx.on('record.updated', ...)`.
|
||||||
|
|
||||||
|
### Editable `plugin.json`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-plugin",
|
||||||
|
"name": "My Plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minAppVersion": "1.0.0",
|
||||||
|
"entry": "main.js",
|
||||||
|
"style": "styles.css",
|
||||||
|
"positions": ["contextMenu"],
|
||||||
|
"permissions": ["data.read", "data.write"],
|
||||||
|
"author": "developer@example.com",
|
||||||
|
"icon": "Pencil"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `id` must be unique, lowercase-kebab-case.
|
||||||
|
- `icon` is a lucide-react icon name (optional).
|
||||||
|
- `positions` must include the position you register in `onLoad`.
|
||||||
|
|
||||||
|
## Step 4 — Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NODE_OPTIONS='--max-old-space-size=2048' npx webpack --config webpack.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Or if the plugin has a build script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
# or: npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs: `dist/main.js` (IIFE) + `dist/styles.css` (if styles exist).
|
||||||
|
|
||||||
|
Verify the output:
|
||||||
|
```bash
|
||||||
|
ls -la dist/main.js dist/styles.css plugin.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5 — Upload the bundle
|
||||||
|
|
||||||
|
The upload URL includes the plugin's `id` from `plugin.json`. Extract it first,
|
||||||
|
then upload.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SESSION=$(grep auth_session /tmp/cookie_hr.txt 2>/dev/null | awk '{print $NF}')
|
||||||
|
if [ -z "$SESSION" ]; then
|
||||||
|
SESSION=$(curl -s -c - http://localhost:3003/api/auth/signin \
|
||||||
|
-X POST -H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"hr_admin@xingchen.tech","password":"Xingchen@2026"}' \
|
||||||
|
| grep auth_session | awk '{print $NF}')
|
||||||
|
|
||||||
|
> Account note (2026-08): `hr_admin@xingchen.tech` no longer exists on the
|
||||||
|
> deployed instance. Create a throwaway user via `POST /api/auth/signup`
|
||||||
|
> and add a `plugin_developer` row for it before uploading (see SKILL.md).
|
||||||
|
fi
|
||||||
|
|
||||||
|
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
|
||||||
|
|
||||||
|
curl -s -b "auth_session=$SESSION" \
|
||||||
|
-F "js=@dist/main.js" \
|
||||||
|
-F "css=@dist/styles.css" \
|
||||||
|
-F "manifest=@plugin.json" \
|
||||||
|
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
|
||||||
|
|
||||||
|
echo "PLUGIN_ID=$PLUGIN_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
- The plugin `id` in the URL must match the `id` in `plugin.json`. On first
|
||||||
|
upload, the plugin is created; on subsequent uploads, the existing plugin is
|
||||||
|
updated.
|
||||||
|
- Auth supports both session cookies (`auth_session`) and Personal Access Tokens
|
||||||
|
(`Authorization: Bearer <PAT>`).
|
||||||
|
- Returns 201 on success.
|
||||||
|
|
||||||
|
## Step 6 — Enable (REQUIRED before install)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -b "auth_session=$SESSION" -X PUT \
|
||||||
|
"http://localhost:3003/api/base/bseAi6Hazmw7jPWZbfO/plugin-config/$PLUGIN_ID" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"enabled":true,"tableIds":[]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
`tableIds:[]` = base-wide; `tableIds:["tblX"]` = per-table. Use base-wide unless
|
||||||
|
the user wants to restrict to specific tables.
|
||||||
|
|
||||||
|
## Step 7 — Install to the position
|
||||||
|
|
||||||
|
The install API depends on position. See `deployment.md` for the exact call.
|
||||||
|
|
||||||
|
Example — contextMenu on the test table:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -b "auth_session=$SESSION" -X POST \
|
||||||
|
"http://localhost:3003/api/table/tbldtYd4ZhvkcyAaada/plugin-context-menu/install" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"pluginId\":\"$PLUGIN_ID\",\"name\":\"My Plugin\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 8 — Verify in the browser
|
||||||
|
|
||||||
|
Open the relevant page and confirm the plugin renders and works:
|
||||||
|
- ContextMenu: right-click a record in grid view → "Plugins" group
|
||||||
|
- Panel: bottom-right Plugins button in a table view
|
||||||
|
- View: view-type dropdown
|
||||||
|
- Dashboard: space dashboard page
|
||||||
|
|
||||||
|
Use `browser-act` to verify or ask the user to check.
|
||||||
|
|
||||||
|
## Pre-deliver checklist
|
||||||
|
|
||||||
|
Before claiming done:
|
||||||
|
|
||||||
|
- [ ] `dist/main.js` built and is a valid IIFE bundle
|
||||||
|
- [ ] `plugin.json` at project root with correct `id`, `entry`, `positions`
|
||||||
|
- [ ] No hardcoded URLs/table IDs/tokens — all via host SDK
|
||||||
|
- [ ] Loading, error, and empty states handled in the UI
|
||||||
|
- [ ] `definePlugin()` called with `onLoad(ctx)` that registers at least one component
|
||||||
|
- [ ] Upload returns 201, pluginId captured
|
||||||
|
- [ ] Enable (plugin-config) called before install
|
||||||
|
- [ ] Install returns 2xx
|
||||||
|
- [ ] Plugin renders and works
|
||||||
|
|
||||||
|
If any box is unchecked, fix it.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Plugin system architecture
|
||||||
|
|
||||||
|
The host Next.js app (`:3002`) injects a global namespace `window.__TEABLE_HOST__`
|
||||||
|
during startup. Plugins are plain JS IIFE bundles that read this namespace at
|
||||||
|
runtime — no ES imports, no iframes, no penpal bridge.
|
||||||
|
|
||||||
|
## Why this system exists
|
||||||
|
|
||||||
|
The previous iframe/ZIP plugin system isolated plugins in sandboxed iframes,
|
||||||
|
limiting API access to a penpal postMessage bridge. Adding or changing a plugin
|
||||||
|
required rebuilding and re-deploying the host Next.js app. This system solves
|
||||||
|
both problems:
|
||||||
|
|
||||||
|
- **Full host API access**: Plugins run in the same React tree as the host,
|
||||||
|
sharing the same React singleton. They can use any SDK hook, UI component,
|
||||||
|
or OpenAPI helper directly.
|
||||||
|
- **No host rebuild**: Upload a new IIFE bundle via `upload-bundle` and it's
|
||||||
|
immediately available — the host loads it at runtime through PluginLoader.
|
||||||
|
- **Shared dependencies**: React, ReactDOM, and all `@teable/*` packages are
|
||||||
|
provided by the host, so plugin bundles are small (only contain the plugin's
|
||||||
|
own code).
|
||||||
|
|
||||||
|
The trade-off is that plugins are fully trusted (no sandboxing) — upload is
|
||||||
|
gated by a developer whitelist and bundles are validated for security.
|
||||||
|
|
||||||
|
## Runtime flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Host app mounts
|
||||||
|
HostInitializer (<AppProvider>):
|
||||||
|
window.__TEABLE_HOST__ = {
|
||||||
|
React, ReactDOM,
|
||||||
|
SDK: { useFields, useRecords, useTableId, useViewId, ... },
|
||||||
|
UILib: { Button, Dialog, Select, ... },
|
||||||
|
OpenAPI: { getRecords, updateRecords, ... },
|
||||||
|
events: EventBus instance,
|
||||||
|
PluginSDK: { definePlugin, registerComponent, useSelection, getSelectionRecords },
|
||||||
|
// Native embeddable components (same React tree as the host app):
|
||||||
|
openFormModal, // modal wrapper around the native form
|
||||||
|
NativeGrid, // full native grid with providers
|
||||||
|
NativeForm, // full native form (Tier-1 injection props supported)
|
||||||
|
NativeFormScope, // Tier-2: state scope for custom form layouts
|
||||||
|
NativeFormField, // Tier-2: single native form row
|
||||||
|
useNativeForm, // Tier-2: headless hook { values, setValue, verify, submit, ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
2. Plugin loads (PluginLoader component)
|
||||||
|
→ fetch /api/plugin/:id/runtime/plugin.json (manifest)
|
||||||
|
→ check minAppVersion compatibility
|
||||||
|
→ <link> inject CSS (if manifest.style exists)
|
||||||
|
→ <script src="/api/plugin/:id/runtime/main.js"> (IIFE executes)
|
||||||
|
→ script calls definePlugin({ id, name, onLoad, ... })
|
||||||
|
→ window.__TEABLE_PLUGINS__[pluginId] = config
|
||||||
|
→ PluginLoader reads registration, creates PluginContext, calls onLoad(ctx)
|
||||||
|
→ ctx.addContextMenuAction(...) etc. register React components to componentRegistry
|
||||||
|
→ PluginSlot renders registered components at the right mount point
|
||||||
|
|
||||||
|
3. Plugin unloads
|
||||||
|
→ onUnload(ctx) called
|
||||||
|
→ ctx auto-disposes all registered resources (components, event listeners)
|
||||||
|
→ script/link elements removed
|
||||||
|
→ window.__TEABLE_PLUGINS__[pluginId] deleted
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key host modules
|
||||||
|
|
||||||
|
### HostInitializer
|
||||||
|
|
||||||
|
Mounts inside `<AppProvider>` (after SDK init). Runs once — writes all host APIs
|
||||||
|
to `window.__TEABLE_HOST__`. The object is immutable after initialization.
|
||||||
|
|
||||||
|
Location: `apps/nextjs-app/src/features/app/components/plugin/host/host-initializer.tsx`
|
||||||
|
|
||||||
|
### PluginLoader
|
||||||
|
|
||||||
|
A React component that loads a single plugin. Handles the full lifecycle:
|
||||||
|
manifest fetch → compatibility check → CSS injection → script load →
|
||||||
|
definePlugin → onLoad → onUnload → cleanup.
|
||||||
|
|
||||||
|
Each installed plugin gets its own `<PluginLoader>` instance. The loader shows
|
||||||
|
a "Loading plugin..." message during fetch/load, and an error message on failure.
|
||||||
|
|
||||||
|
Location: `apps/nextjs-app/src/features/app/components/plugin/host/plugin-loader.tsx`
|
||||||
|
|
||||||
|
### PluginSlot
|
||||||
|
|
||||||
|
Queries the component registry for all plugins registered at a given position
|
||||||
|
and renders their components.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PluginSlot position="contextMenu" tableId={tableId} />
|
||||||
|
<PluginSlot position="panel" tableId={tableId} viewId={viewId} />
|
||||||
|
<PluginSlot position="dashboard" baseId={baseId} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Location: `apps/nextjs-app/src/features/app/components/plugin/host/plugin-slot.tsx`
|
||||||
|
|
||||||
|
### ComponentRegistry
|
||||||
|
|
||||||
|
An in-memory registry mapping `(position, pluginId, componentId) → React.FC`.
|
||||||
|
Plugins register components during `onLoad` and the registry auto-cleans up on
|
||||||
|
plugin unload.
|
||||||
|
|
||||||
|
Location: `apps/nextjs-app/src/features/app/components/plugin/host/component-registry.ts`
|
||||||
|
|
||||||
|
### EventBus
|
||||||
|
|
||||||
|
A simple pub/sub event bus shared between the host and all plugins. Plugins
|
||||||
|
subscribe via `ctx.on(...)` and emit via `ctx.emit(...)`.
|
||||||
|
|
||||||
|
Events include: `record.created`, `record.updated`, `record.deleted`,
|
||||||
|
`view.changed`, `selection.changed`, `plugin.activated`.
|
||||||
|
|
||||||
|
Location: `apps/nextjs-app/src/features/app/components/plugin/host/event-bus.ts`
|
||||||
|
|
||||||
|
## Plugin bundle structure
|
||||||
|
|
||||||
|
```
|
||||||
|
dist/
|
||||||
|
├── main.js # IIFE bundle (webpack output)
|
||||||
|
└── styles.css # optional stylesheet
|
||||||
|
plugin.json # manifest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shared dependency bridging
|
||||||
|
|
||||||
|
Plugin webpack config marks these as externals, mapping to host globals:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
externals: {
|
||||||
|
'react': 'window.__TEABLE_HOST__.React',
|
||||||
|
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
|
||||||
|
'@teable/sdk': 'window.__TEABLE_HOST__.SDK',
|
||||||
|
'@teable/ui-lib': 'window.__TEABLE_HOST__.UILib',
|
||||||
|
'@teable/openapi': 'window.__TEABLE_HOST__.OpenAPI',
|
||||||
|
'@teable/plugin-sdk': 'window.__TEABLE_HOST__.PluginSDK',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This means the plugin does NOT bundle React or host packages — it uses the
|
||||||
|
host's singleton instances. The bundle only contains the plugin's own code.
|
||||||
|
|
||||||
|
## Two ways to access host APIs
|
||||||
|
|
||||||
|
### 1. Through `window.__TEABLE_HOST__` (recommended for IIFE)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const host = window.__TEABLE_HOST__;
|
||||||
|
const React = host.React;
|
||||||
|
const { useFields, useTableId } = host.SDK;
|
||||||
|
const { Button } = host.UILib;
|
||||||
|
const { getRecords } = host.OpenAPI;
|
||||||
|
const { definePlugin, useSelection } = host.PluginSDK;
|
||||||
|
```
|
||||||
|
|
||||||
|
This is what `plugins/batch-edit-pro/` uses — the plugin bundle has no
|
||||||
|
`@teable/*` imports, everything resolves through the host global.
|
||||||
|
|
||||||
|
### 2. Through `@teable/plugin-sdk` (also works because webpack externals)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { definePlugin } from '@teable/plugin-sdk';
|
||||||
|
```
|
||||||
|
|
||||||
|
At build time, webpack resolves this to `window.__TEABLE_HOST__.PluginSDK.definePlugin`.
|
||||||
|
Both styles work — the externals config handles the mapping.
|
||||||
|
|
||||||
|
## Positions and registration
|
||||||
|
|
||||||
|
| Position | Mount point | Registration method |
|
||||||
|
|---|---|---|
|
||||||
|
| ContextMenu | Right-click → "Plugins" group in context menu | `ctx.addContextMenuAction(id, component)` |
|
||||||
|
| Panel | Right-side drawer of a table view | `ctx.addPanel(id, component)` |
|
||||||
|
| View | View-type dropdown (alongside grid/form/gallery) | `ctx.addView(id, component)` |
|
||||||
|
| Dashboard | Grid card on space dashboard | `ctx.addDashboardWidget(id, component)` |
|
||||||
|
| Route | Full-page route (e.g., `/plugin/:pluginId/my-page`) | `ctx.addRoute(path, component)` |
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# Plugin SDK API Reference
|
||||||
|
|
||||||
|
The SDK lives at `packages/plugin-sdk/` and is also exposed as
|
||||||
|
`window.__TEABLE_HOST__.PluginSDK`.
|
||||||
|
|
||||||
|
## Registration — `definePlugin`
|
||||||
|
|
||||||
|
The single entry point for every plugin. Must be called at the top level of the
|
||||||
|
IIFE bundle.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
definePlugin({
|
||||||
|
id: 'my-plugin', // unique, lowercase-kebab
|
||||||
|
name: 'My Plugin', // display name
|
||||||
|
version: '1.0.0', // semver
|
||||||
|
minAppVersion: '1.0.0', // minimum host version required
|
||||||
|
|
||||||
|
onLoad(ctx: IPluginContext) {
|
||||||
|
// Register components, subscribe to events
|
||||||
|
ctx.addContextMenuAction('myAction', MyComponent);
|
||||||
|
ctx.on('selection.changed', (payload) => {
|
||||||
|
console.log('Selection changed:', payload.selection);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onUnload?(ctx: IPluginContext) {
|
||||||
|
// Optional manual cleanup; ctx auto-disposes all registered resources
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`onUnload` is optional — `ctx` tracks all registered components and event
|
||||||
|
listeners and disposes them automatically on unload.
|
||||||
|
|
||||||
|
## PluginContext
|
||||||
|
|
||||||
|
Available as `ctx` in `onLoad` and `onUnload`.
|
||||||
|
|
||||||
|
### `ctx.pluginId: string`
|
||||||
|
The plugin's unique ID.
|
||||||
|
|
||||||
|
### `ctx.baseId: string`
|
||||||
|
The current base ID.
|
||||||
|
|
||||||
|
### `ctx.tableId?: string`
|
||||||
|
The current table ID (if the plugin is loaded in a table context).
|
||||||
|
|
||||||
|
### `ctx.viewId?: string`
|
||||||
|
The current view ID (if available).
|
||||||
|
|
||||||
|
### Component Registration
|
||||||
|
|
||||||
|
Each method takes a unique `id` (per-plugin scope) and a React component.
|
||||||
|
Returns a `Disposable` that can be used to manually unregister before unload
|
||||||
|
(rarely needed — ctx auto-disposes).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ctx.addContextMenuAction(id: string, component: React.FC): Disposable;
|
||||||
|
ctx.addPanel(id: string, component: React.FC): Disposable;
|
||||||
|
ctx.addDashboardWidget(id: string, component: React.FC): Disposable;
|
||||||
|
ctx.addView(id: string, component: React.FC): Disposable;
|
||||||
|
ctx.addRoute(path: string, component: React.FC): Disposable;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
#### `ctx.on(event, handler): Disposable`
|
||||||
|
|
||||||
|
Subscribe to host events. Returns a `Disposable` that unsubscribes when called.
|
||||||
|
All subscriptions are auto-disposed on unload.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Subscribe
|
||||||
|
const sub = ctx.on('selection.changed', (payload) => {
|
||||||
|
console.log('Selection:', payload.selection);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unsubscribe early if needed
|
||||||
|
sub.dispose();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `ctx.emit(event, ...args): void`
|
||||||
|
|
||||||
|
Emit custom events to the host event bus. Other plugins can listen.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
ctx.emit('my-custom-event', { data: 'hello' });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typed Events
|
||||||
|
|
||||||
|
| Event | Payload | When emitted |
|
||||||
|
|---|---|---|
|
||||||
|
| `record.created` | `{ tableId, recordId }` | A record is created |
|
||||||
|
| `record.updated` | `{ tableId, recordId, fieldIds }` | A record is updated |
|
||||||
|
| `record.deleted` | `{ tableId, recordId }` | A record is deleted |
|
||||||
|
| `view.changed` | `{ viewId, tableId }` | User switches views |
|
||||||
|
| `selection.changed` | `{ selection }` | Row selection changes |
|
||||||
|
| `plugin.activated` | `{ pluginId, position }` | A plugin component is mounted |
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
Per-install persistent storage backed by `localStorage`. Namespaced to
|
||||||
|
`plugin:<pluginId>:<key>`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Read (synchronous)
|
||||||
|
const config = ctx.storage.get<MyConfig>('config');
|
||||||
|
|
||||||
|
// Write (async, returns Promise)
|
||||||
|
await ctx.storage.set('config', { theme: 'dark', ... });
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
await ctx.storage.delete('config');
|
||||||
|
```
|
||||||
|
|
||||||
|
## PluginSDK on window.__TEABLE_HOST__
|
||||||
|
|
||||||
|
### `useSelection(): ISelection | undefined`
|
||||||
|
|
||||||
|
React hook that returns the current table row selection. Returns `undefined`
|
||||||
|
if nothing is selected or if used outside a table context.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { useSelection } = window.__TEABLE_HOST__.PluginSDK;
|
||||||
|
const selection = useSelection();
|
||||||
|
```
|
||||||
|
|
||||||
|
### `getSelectionRecords(tableId, viewId, selection, options?): Promise<IGetSelectionRecordsVo>`
|
||||||
|
|
||||||
|
Fetch the actual record data for the current selection. Used by contextMenu
|
||||||
|
plugins to get the records the user right-clicked on.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { getSelectionRecords } = window.__TEABLE_HOST__.PluginSDK;
|
||||||
|
|
||||||
|
const res = await getSelectionRecords(
|
||||||
|
tableId,
|
||||||
|
viewId,
|
||||||
|
selection,
|
||||||
|
{ skip: 0, take: 200 }
|
||||||
|
);
|
||||||
|
console.log(res.records); // IRecord[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns an empty array if nothing is selected — does NOT throw.
|
||||||
|
|
||||||
|
## Host SDK (window.__TEABLE_HOST__.SDK)
|
||||||
|
|
||||||
|
Full `@teable/sdk` namespace. Commonly used hooks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const host = window.__TEABLE_HOST__;
|
||||||
|
const {
|
||||||
|
useFields, // () => IField[]
|
||||||
|
useRecords, // (query?) => IRecord[]
|
||||||
|
useTableId, // () => string | undefined
|
||||||
|
useViewId, // () => string | undefined
|
||||||
|
useView, // () => IView | undefined
|
||||||
|
useFieldStaticGetter,// () => getFieldStatic
|
||||||
|
BaseSingleSelect, // UI component
|
||||||
|
FilterInput, // UI component
|
||||||
|
FilterCheckbox, // UI component
|
||||||
|
SelectTag, // UI component
|
||||||
|
getSelectColorPairs, // (color, theme) => { color, backgroundColor }
|
||||||
|
} = host.SDK;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Host UILib (window.__TEABLE_HOST__.UILib)
|
||||||
|
|
||||||
|
Full `@teable/ui-lib/shadcn` namespace. Commonly used components:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
Select,
|
||||||
|
Input,
|
||||||
|
// ... all shadcn components exported by ui-lib
|
||||||
|
} = window.__TEABLE_HOST__.UILib;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Host OpenAPI (window.__TEABLE_HOST__.OpenAPI)
|
||||||
|
|
||||||
|
Full `@teable/openapi` namespace. Commonly used functions:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
getRecords,
|
||||||
|
updateRecords,
|
||||||
|
createRecords,
|
||||||
|
deleteRecords,
|
||||||
|
getFields,
|
||||||
|
// ... all REST API helpers
|
||||||
|
} = window.__TEABLE_HOST__.OpenAPI;
|
||||||
|
```
|
||||||
|
|
||||||
|
These pass through the existing permission system — users can only read/write
|
||||||
|
data their role allows.
|
||||||
|
|
||||||
|
## Host Methods (window.__TEABLE_HOST__)
|
||||||
|
|
||||||
|
Top-level methods on the host object (not inside PluginSDK/SDK/UILib/OpenAPI sub-namespaces).
|
||||||
|
|
||||||
|
### `host.openFormModal(config): void`
|
||||||
|
|
||||||
|
Opens a **native form view** in a modal dialog — rendered with the EXACT
|
||||||
|
native form component tree (`FormPreviewer`/`FormCellEditor`, the same code
|
||||||
|
the built-in form view and the share page use): covers/logos, colored select
|
||||||
|
tags, date picker, attachment/link/user editors, required-field validation
|
||||||
|
with scroll-to-error, localStorage drafts, and submission through
|
||||||
|
`formSubmit` (which fires "When form submitted" automations). The plugin
|
||||||
|
specifies which form (view) to open via `formId`; the framework resolves the
|
||||||
|
table, renders the form, writes the record with the current user's
|
||||||
|
permissions, and manages the modal lifecycle (close on success, stay-open on
|
||||||
|
error).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface IOpenFormModalConfig {
|
||||||
|
formId: string; // target form view ID (= viewId)
|
||||||
|
defaults?: Record<string, unknown>; // prefill values, keyed by fieldId
|
||||||
|
// (overrides the form's own defaultValue)
|
||||||
|
onSubmit?: ( // hook called AFTER framework writes the record
|
||||||
|
fields: Record<string, unknown>,
|
||||||
|
createdRecord: unknown // the full created record
|
||||||
|
) => void | Promise<void>;
|
||||||
|
onClose?: () => void; // called when user cancels/closes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns void — closing is automatic.
|
||||||
|
host.openFormModal(config);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Permission**: submission runs as the current logged-in user (via
|
||||||
|
`formSubmit` — triggers `formSubmitted` automations exactly like the native
|
||||||
|
form). If the user lacks `record_create` permission on the target table, the
|
||||||
|
write fails → a toast shows the error and the modal stays open (user can fix
|
||||||
|
and retry, or cancel).
|
||||||
|
|
||||||
|
**Field rendering**: 100% native — the real `FormCellEditor` per field type
|
||||||
|
(same as the built-in form view), NOT a re-implementation.
|
||||||
|
|
||||||
|
**defaults override**: plugin `defaults` take priority over both the saved
|
||||||
|
draft and the form view's configured `defaultValue`. Keys must be `fieldId`
|
||||||
|
(not field name).
|
||||||
|
|
||||||
|
**Example**: a context-menu plugin that opens a form to log an issue for the
|
||||||
|
selected record:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const host = window.__TEABLE_HOST__;
|
||||||
|
const { getSelectionRecords } = host.PluginSDK;
|
||||||
|
|
||||||
|
function logIssueForSelected() {
|
||||||
|
const records = getSelectionRecords(...);
|
||||||
|
const orderNumber = records[0]?.fields['fld_orderNumber'];
|
||||||
|
|
||||||
|
host.openFormModal({
|
||||||
|
formId: 'viwIssueForm', // form view ID
|
||||||
|
defaults: {
|
||||||
|
'fld_relatedOrder': orderNumber, // prefill from selected record
|
||||||
|
},
|
||||||
|
onSubmit: (fields, record) => {
|
||||||
|
console.log('Issue logged:', record.id);
|
||||||
|
// Framework already wrote the record — no need to call createRecord.
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
console.log('User cancelled');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `host.NativeForm: React.FC<INativeFormProps>`
|
||||||
|
|
||||||
|
The embeddable sibling of `NativeGrid`: renders the full NATIVE form (same
|
||||||
|
`FormPreviewer` tree as above) inline in your own panel/view component instead
|
||||||
|
of a modal. Useful for "form embedded in a side panel" or form-style data
|
||||||
|
entry views.
|
||||||
|
|
||||||
|
**Tier-1 injection props** (all optional; base props still work unchanged):
|
||||||
|
|
||||||
|
| Prop | What it does |
|
||||||
|
|------|--------------|
|
||||||
|
| `visibleFieldIds: string[]` | Restrict + reorder rendered fields (session-only, nothing persisted; computed/lookup always excluded) |
|
||||||
|
| `hiddenFieldIds: string[]` | Hide specific fields (ignored when `visibleFieldIds` set) |
|
||||||
|
| `slots: INativeFormSlot[]` | Render custom content at `position: 'top' \| 'bottom' \| <fieldId> \| <index>`; `render(ctx)` receives `{ values, setValue, errors, fields }` |
|
||||||
|
| `interceptSubmit: (fields) => fields` | Transform the payload before submit — **the native submit (and formSubmitted automations) still runs** |
|
||||||
|
| `submitOverride: (fields) => Promise` | Replace submit entirely — **formSubmitted automation will NOT fire** |
|
||||||
|
|
||||||
|
**Virtual fields pattern**: store plugin-only values under `__virtual.<key>`
|
||||||
|
via `ctx.setValue` in a slot; they are auto-stripped before `submitOverride`
|
||||||
|
(and should be removed in `interceptSubmit` if you read them there).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { NativeForm } = window.__TEABLE_HOST__;
|
||||||
|
|
||||||
|
function MyFormPanel() {
|
||||||
|
return (
|
||||||
|
<NativeForm
|
||||||
|
formId="viwIssueForm"
|
||||||
|
hiddenFieldIds={['fld_internalNote']]}
|
||||||
|
slots={[
|
||||||
|
{
|
||||||
|
position: 'bottom',
|
||||||
|
render: ({ values, setValue }) => (
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Boolean(values['__virtual.urgent'])}
|
||||||
|
onChange={(e) => setValue('__virtual.urgent', e.target.checked)}
|
||||||
|
/>
|
||||||
|
加急处理
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
interceptSubmit={(fields) => {
|
||||||
|
const next = { ...fields };
|
||||||
|
const urgent = next['__virtual.urgent'];
|
||||||
|
delete next['__virtual.urgent'];
|
||||||
|
if (urgent) next.fld_priority = 'high'; // map virtual → physical
|
||||||
|
next.fld_internalNote = 'auto-injected'; // fill a hidden field
|
||||||
|
return next;
|
||||||
|
}}
|
||||||
|
onSubmitted={(fields, record) => console.log('created', record)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `host.NativeFormScope` + `host.NativeFormField` + `host.useNativeForm()` (Tier-2 granular kit)
|
||||||
|
|
||||||
|
Build fully custom layouts (grouping, steps, mixed tables) while every field
|
||||||
|
editor stays 100% native. `NativeFormScope` owns the state (values / localStorage
|
||||||
|
draft / required validation — same semantics as the native form); drive it with
|
||||||
|
the headless hook and render single native rows with `NativeFormField`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { NativeFormScope, NativeFormField, useNativeForm } = window.__TEABLE_HOST__;
|
||||||
|
|
||||||
|
function SteppedForm() {
|
||||||
|
return (
|
||||||
|
<NativeFormScope formId="viwIssueForm">
|
||||||
|
<StepArea />
|
||||||
|
</NativeFormScope>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StepArea() {
|
||||||
|
const form = useNativeForm(); // { ready, error, fields, values, setValue, errors, verify, reset, submit }
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
if (!form.ready) return <Spinner />;
|
||||||
|
const stepFields = form.fields.slice(step * 3, step * 3 + 3);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4>第 {step + 1} 步</h4>
|
||||||
|
{stepFields.map((f) => (
|
||||||
|
<NativeFormField key={f.id} fieldId={f.id} />
|
||||||
|
))}
|
||||||
|
<button onClick={() => (step === 0 ? setStep(1) : form.verify() && form.submit())}>
|
||||||
|
{step === 0 ? '下一步' : '提交'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`form.submit()` goes through the native formSubmit endpoint, so
|
||||||
|
formSubmitted automations fire. Values are draft-backed (localStorage),
|
||||||
|
identical to the native form.
|
||||||
|
|
||||||
|
### `host.NativeGrid: React.FC`
|
||||||
|
|
||||||
|
A wrapper around the full native grid with all providers. Pass `externalFilter`
|
||||||
|
to filter rows (merged via AND conjunction with existing view filter) and
|
||||||
|
`selectedRecordIds` to control row selection.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { NativeGrid } = window.__TEABLE_HOST__;
|
||||||
|
|
||||||
|
function MyGridView({ filter }) {
|
||||||
|
return <NativeGrid externalFilter={filter} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `host.events: { on, off, emit }`
|
||||||
|
|
||||||
|
Event bus for cross-plugin communication.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
host.events.on('my-event', (payload) => { ... });
|
||||||
|
host.events.emit('my-event', { data: 'hello' });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Disposable pattern
|
||||||
|
|
||||||
|
All `ctx.add*` and `ctx.on` methods return a `Disposable`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Disposable {
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `onLoad`, the ctx collects all disposables. In `onUnload`, they are all
|
||||||
|
disposed automatically (components unregistered, event listeners removed).
|
||||||
|
You rarely need to call `dispose()` manually.
|
||||||
|
|
||||||
|
## Batch-edit-pro: the canonical example
|
||||||
|
|
||||||
|
See `plugins/batch-edit-pro/src/index.tsx` for a complete working plugin that:
|
||||||
|
- Uses `useTableId`, `useViewId`, `useFields` from host SDK
|
||||||
|
- Uses `useSelection` and `getSelectionRecords` from host PluginSDK
|
||||||
|
- Uses `updateRecords` from host OpenAPI
|
||||||
|
- Uses `Button`, `BaseSingleSelect`, `FilterInput`, `FilterCheckbox`, `SelectTag`
|
||||||
|
from host SDK/UILib
|
||||||
|
- Handles loading, error, empty, and applying states
|
||||||
|
- Supports singleSelect, checkbox, date, text, and number field types
|
||||||
|
- Has inline SVG icons (to avoid another dependency)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Common pitfalls and how to avoid them
|
||||||
|
|
||||||
|
These are real issues discovered during plugin development. Read before writing
|
||||||
|
plugin logic — each one will save you debugging time.
|
||||||
|
|
||||||
|
## Pitfall 1: Importing `@teable/*` packages instead of using host globals
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WRONG — webpack externals map these away at build time, so at runtime
|
||||||
|
// the import produces `undefined` or throws:
|
||||||
|
import { useFields } from '@teable/sdk'; // → undefined at runtime
|
||||||
|
import { Button } from '@teable/ui-lib'; // → undefined at runtime
|
||||||
|
import { definePlugin } from '@teable/plugin-sdk'; // fine (it re-exports from host)
|
||||||
|
|
||||||
|
// CORRECT — always read from the host global:
|
||||||
|
const host = window.__TEABLE_HOST__;
|
||||||
|
const { useFields } = host.SDK;
|
||||||
|
const { Button } = host.UILib;
|
||||||
|
const { definePlugin } = host.PluginSDK;
|
||||||
|
const React = host.React;
|
||||||
|
```
|
||||||
|
|
||||||
|
The webpack `externals` config in the scaffold template maps `@teable/sdk` →
|
||||||
|
`window.__TEABLE_HOST__.SDK` at build time. This means the bundled code replaces
|
||||||
|
the import with `window.__TEABLE_HOST__.SDK`, but destructuring from the import
|
||||||
|
statement doesn't survive the webpack transformation — use direct `host.*`
|
||||||
|
access instead. The only exception is `@teable/plugin-sdk` which re-exports
|
||||||
|
from `window.__TEABLE_HOST__.PluginSDK` at the level webpack expects.
|
||||||
|
|
||||||
|
**Symptoms**: `useFields is not a function`, `Cannot read properties of undefined`,
|
||||||
|
or components silently rendering nothing.
|
||||||
|
|
||||||
|
## Pitfall 2: `window.__TEABLE_HOST__` is undefined
|
||||||
|
|
||||||
|
The host object is injected by `HostInitializer` which mounts inside
|
||||||
|
`<AppProvider>`. This happens after the initial React tree render. If you try
|
||||||
|
to access `window.__TEABLE_HOST__` in a script that executes before the host
|
||||||
|
tree mounts, it will be `undefined`.
|
||||||
|
|
||||||
|
**Fix**: The `PluginLoader` component ensures the host is ready before loading
|
||||||
|
plugin scripts, so plugins loaded through the normal flow don't hit this. But
|
||||||
|
if you're testing a plugin directly (e.g. loading main.js in a browser console),
|
||||||
|
you'll see `__TEABLE_HOST__` is null until the app renders.
|
||||||
|
|
||||||
|
## Pitfall 3: Upload returns 403 — not a developer
|
||||||
|
|
||||||
|
Only users in the `plugin_developer` DB table can upload bundles. The
|
||||||
|
`DeveloperGuard` checks this. If you get 403 on `upload-bundle`, your user is
|
||||||
|
not whitelisted.
|
||||||
|
|
||||||
|
**Fix**: Add the user to `plugin_developer`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO plugin_developer (id, user_id, email, created_by)
|
||||||
|
VALUES (gen_random_uuid(), '<userId>', '<email>', '<adminUserId>');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pitfall 4: Enable (plugin-config) must be called before install
|
||||||
|
|
||||||
|
Installing a plugin to a position (contextMenu / panel / view / dashboard)
|
||||||
|
requires the plugin to be enabled first. If you skip enable and call install
|
||||||
|
directly, you'll get 403.
|
||||||
|
|
||||||
|
**The correct sequence is always**:
|
||||||
|
1. Upload bundle → get pluginId
|
||||||
|
2. Enable via `PUT /base/:baseId/plugin-config/:pluginId`
|
||||||
|
3. Install to position
|
||||||
|
|
||||||
|
## Pitfall 5: `getSelectionRecords` returns empty array, not throw
|
||||||
|
|
||||||
|
The old iframe/ZIP `bridge.getSelectionRecords()` threw when nothing was
|
||||||
|
selected. The dynamic system's `PluginSDK.getSelectionRecords()` returns an
|
||||||
|
empty array instead. You still need to handle the empty case in your UI,
|
||||||
|
but you don't need a try/catch wrapper.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { getSelectionRecords } = host.PluginSDK;
|
||||||
|
const res = await getSelectionRecords(tableId, viewId, selection);
|
||||||
|
if (res.records.length === 0) {
|
||||||
|
// Show "no records selected" message — no try/catch needed
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pitfall 6: Plugin component renders nothing / context menu empty
|
||||||
|
|
||||||
|
If your plugin uploads, enables, and installs successfully but the component
|
||||||
|
never appears:
|
||||||
|
|
||||||
|
1. **Check `plugin.json` `positions`** — it must include the position you're
|
||||||
|
installing to (e.g. `"positions": ["contextMenu"]`)
|
||||||
|
2. **Check `onLoad` registration** — `ctx.addContextMenuAction('main', Component)`
|
||||||
|
must be called inside `onLoad`
|
||||||
|
3. **Check Authority Matrix** — the user's role must have the plugin enabled
|
||||||
|
in AM. For the test account, the owner bypasses AM, but for other users,
|
||||||
|
an admin must enable the plugin per role in the RoleEditor UI.
|
||||||
|
4. **Check browser console** — errors in `onLoad` are silently caught; add a
|
||||||
|
`console.log` at the top of `onLoad` to verify it runs.
|
||||||
|
|
||||||
|
## Pitfall 7: Component uses JSX but webpack doesn't transpile it
|
||||||
|
|
||||||
|
The scaffold template uses `ts-loader` with `jsx: "react-jsx"` in tsconfig.
|
||||||
|
If you're setting up the webpack config manually, make sure:
|
||||||
|
- `tsconfig.json` has `"jsx": "react-jsx"` under `compilerOptions`
|
||||||
|
- The plugin does NOT need `import React from 'react'` at the top when using
|
||||||
|
`react-jsx` transform — JSX is auto-transpiled. But you still need
|
||||||
|
`const React = host.React` for any explicit `React.createElement` calls.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"organization": "inkreach",
|
||||||
|
"authors": ["inkreach"],
|
||||||
|
"tags": ["plugin", "teable", "zip", "bridge", "context-menu", "dashboard", "panel", "view", "iframe"],
|
||||||
|
"abstract": "Develop ZIP plugins for inkreach-teable: 4 mount positions, penpal bridge API, AI-friendly templates and examples."
|
||||||
|
}
|
||||||
@@ -62,13 +62,17 @@ exec node "$REPO_ROOT/dist/bin.js" "\$@"
|
|||||||
WRAPPER
|
WRAPPER
|
||||||
chmod +x "$HOME/.local/bin/inkreach-teable"
|
chmod +x "$HOME/.local/bin/inkreach-teable"
|
||||||
|
|
||||||
# Install the skill at user scope so it works in ANY workspace, including
|
# Install every skill in this repo at user scope so they work in ANY workspace,
|
||||||
# checkouts of the inkreach-teable application repo (which no longer bundles it).
|
# including checkouts of the inkreach-teable application repo (which no longer
|
||||||
if [ -d "$SKILL_DIR" ]; then
|
# bundles them): inkreach-teable + inkreach-teable-plugin.
|
||||||
|
if [ -d "$REPO_ROOT/skills" ]; then
|
||||||
mkdir -p "$HOME/.zcode/skills"
|
mkdir -p "$HOME/.zcode/skills"
|
||||||
rm -rf "$HOME/.zcode/skills/inkreach-teable"
|
for skill_dir in "$REPO_ROOT/skills"/*/; do
|
||||||
cp -r "$SKILL_DIR" "$HOME/.zcode/skills/inkreach-teable"
|
skill_name="$(basename "$skill_dir")"
|
||||||
echo "✓ skill installed to ~/.zcode/skills/inkreach-teable (user scope)"
|
rm -rf "$HOME/.zcode/skills/$skill_name"
|
||||||
|
cp -r "$skill_dir" "$HOME/.zcode/skills/$skill_name"
|
||||||
|
echo "✓ skill installed: $skill_name (~/.zcode/skills, user scope)"
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
|
|||||||
Reference in New Issue
Block a user