feat: 收录 inkreach-teable-plugin skill;install.sh 安装全部技能

skills/ 现包含 inkreach-teable + inkreach-teable-plugin,
npx skills add 一并安装两个;install.sh 改为遍历安装 skills/*。
This commit is contained in:
yeuimu
2026-09-23 06:23:46 +00:00
parent d81d14bd52
commit e09ed1bc0f
8 changed files with 1261 additions and 6 deletions
@@ -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.