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
|
||||
chmod +x "$HOME/.local/bin/inkreach-teable"
|
||||
|
||||
# Install the skill at user scope so it works in ANY workspace, including
|
||||
# checkouts of the inkreach-teable application repo (which no longer bundles it).
|
||||
if [ -d "$SKILL_DIR" ]; then
|
||||
# Install every skill in this repo at user scope so they work in ANY workspace,
|
||||
# including checkouts of the inkreach-teable application repo (which no longer
|
||||
# bundles them): inkreach-teable + inkreach-teable-plugin.
|
||||
if [ -d "$REPO_ROOT/skills" ]; then
|
||||
mkdir -p "$HOME/.zcode/skills"
|
||||
rm -rf "$HOME/.zcode/skills/inkreach-teable"
|
||||
cp -r "$SKILL_DIR" "$HOME/.zcode/skills/inkreach-teable"
|
||||
echo "✓ skill installed to ~/.zcode/skills/inkreach-teable (user scope)"
|
||||
for skill_dir in "$REPO_ROOT/skills"/*/; do
|
||||
skill_name="$(basename "$skill_dir")"
|
||||
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
|
||||
|
||||
# Verify
|
||||
|
||||
Reference in New Issue
Block a user