317 lines
12 KiB
Markdown
317 lines
12 KiB
Markdown
# Plugin development workflow
|
|
|
|
The standard recipe for creating, building, and deploying a dynamic React
|
|
component plugin. Each plugin is a standalone webpack (or vite) project that
|
|
outputs an IIFE bundle. Since 2026-10 the pipeline is multi-asset: lazy
|
|
chunks, source maps, and multiple CSS files upload alongside the entry.
|
|
|
|
## Output contract
|
|
|
|
Every plugin task ends with:
|
|
1. A built IIFE bundle (`dist/main.js` + optional chunks/`*.chunk.js` +
|
|
`*.js.map` source maps + optional `styles.css`)
|
|
2. A `plugin.json` manifest
|
|
3. Uploaded to the host via `POST /api/plugin/:id/upload-bundle`
|
|
(or `teable-plugin deploy` which handles all assets)
|
|
4. Enabled via `PUT /api/base/:baseId/plugin-config/:pluginId`
|
|
5. Installed to the target position (the install API varies by position)
|
|
|
|
## Dev loop (hot reload — preferred while iterating)
|
|
|
|
```bash
|
|
cd plugins/my-plugin # or wherever the project lives
|
|
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> pnpm dev
|
|
# or: node ../../packages/plugin-cli/dist/index.js dev -d .
|
|
```
|
|
|
|
This runs `webpack --watch` (production mode — dev-mode output carries
|
|
`/******/` banners that fail the IIFE validator), auto-uploads on every
|
|
change with a `devMode: true, buildId: <ts>` manifest, and the open host
|
|
page remounts the plugin within ~4s WITHOUT a page refresh. Production
|
|
deploys (no devMode in manifest) never poll. Verified 2026-10-13 on
|
|
plugin-showcase: edit → ~5s live update, window markers survive (SPA-level
|
|
remount, not a reload).
|
|
|
|
## 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
|
|
# or the vite template:
|
|
node /root/deployments/inkreach-teable/packages/plugin-cli/dist/index.js create my-plugin --template vite
|
|
cd my-plugin
|
|
pnpm install
|
|
```
|
|
|
|
The scaffold adapts automatically: inside the monorepo it wires
|
|
`@teable/plugin-sdk: workspace:*` + source aliases; standalone projects get a
|
|
loose-typed local host module (the npm package is not published). Vite
|
|
scaffolds also generate a `pnpm-workspace.yaml` with `allowBuilds: esbuild`
|
|
(pnpm 11 fails `pnpm install` without it).
|
|
|
|
This generates:
|
|
```
|
|
my-plugin/
|
|
├── src/
|
|
│ ├── host.ts # typed host accessor
|
|
│ └── index.tsx # entry: definePlugin via host.PluginSDK
|
|
├── plugin.json # manifest
|
|
├── webpack.config.js # or vite.config.ts
|
|
├── tsconfig.json
|
|
└── package.json # dev/build/deploy scripts
|
|
```
|
|
|
|
Reference implementations in the repo: `plugins/plugin-showcase/` (chunks +
|
|
sourcemap + Tailwind + HashRouter route app — the v2 showcase) and
|
|
`plugins/batch-edit-pro/` (classic single-file contextMenu plugin).
|
|
|
|
## 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
|
|
pnpm build
|
|
# = tsc --noEmit && webpack --mode production (scaffold script)
|
|
```
|
|
|
|
**Rebuilding without the scaffold — use this exact config** (agents
|
|
hand-writing webpack configs keep hitting `Error: Unsupported library type
|
|
iife` — there is NO `output.library` in a working plugin config; a default
|
|
single-entry production build is already IIFE, which is what the upload
|
|
validator requires):
|
|
|
|
```js
|
|
// webpack.config.js — known-good v2 (from plugins/plugin-showcase/)
|
|
const path = require('path');
|
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
|
const TerserPlugin = require('terser-webpack-plugin');
|
|
|
|
const pluginId = require('./plugin.json').id;
|
|
|
|
module.exports = {
|
|
mode: 'production',
|
|
entry: './src/index.tsx',
|
|
output: {
|
|
path: path.resolve(__dirname, 'dist'),
|
|
filename: 'main.js',
|
|
// FLAT chunk names: multipart transport strips directory parts, so
|
|
// nested chunk paths would 404 at runtime.
|
|
chunkFilename: '[name]-[contenthash].chunk.js',
|
|
// required so React.lazy/import() chunks resolve from the host runtime
|
|
publicPath: `/api/plugin/${pluginId}/runtime/`,
|
|
clean: true,
|
|
// terser's minified "(()=>{" fails the validator's "(() =>" check
|
|
environment: { arrowFunction: false },
|
|
},
|
|
devtool: 'source-map', // maps upload + serve as application/json
|
|
// NO output.library — never add library.type: 'iife' (webpack rejects it)
|
|
externals: {
|
|
'react': 'window.__TEABLE_HOST__.React',
|
|
'react-dom': 'window.__TEABLE_HOST__.ReactDOM',
|
|
},
|
|
resolve: { extensions: ['.tsx', '.ts', '.js', '.jsx'] },
|
|
module: {
|
|
rules: [
|
|
{ test: /\.tsx?$/, loader: 'ts-loader', options: { transpileOnly: true }, exclude: /node_modules/ },
|
|
{ test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'] },
|
|
],
|
|
},
|
|
optimization: {
|
|
minimizer: [
|
|
// license-comment extraction prepends a "/*!..." banner that breaks
|
|
// the IIFE-prefix check — keep comments inline
|
|
new TerserPlugin({ extractComments: false }),
|
|
],
|
|
},
|
|
plugins: [new MiniCssExtractPlugin({ filename: 'styles.css' })],
|
|
};
|
|
```
|
|
|
|
`react/jsx-runtime` is intentionally NOT external: the jsx automatic
|
|
transform bundles it and that is safe (element factories only — hooks still
|
|
come from the host singleton).
|
|
|
|
Vite template notes: `build.lib formats: ['iife']` + rollup externals with
|
|
`globals: { react: 'window.__TEABLE_HOST__.React', ... }`; output starts
|
|
`(function(...)` or `var <name>=(function...` — both pass the validator.
|
|
IIFE cannot code-split (dynamic imports inline) — use the webpack template
|
|
if you need lazy chunks.
|
|
|
|
If you build outside the monorepo, drop the `resolve.alias` block (it exists
|
|
only so TS can type-check `import type` from `@teable/*` sources) and use
|
|
`import type` + `as unknown as` casts instead of runtime imports.
|
|
|
|
Outputs: `dist/main.js` (IIFE) + optional `dist/*.chunk.js` (lazy chunks) +
|
|
`dist/*.js.map` + `dist/styles.css`.
|
|
|
|
## Step 5 — Upload the bundle
|
|
|
|
Preferred (handles entry + chunks + maps + css automatically):
|
|
|
|
```bash
|
|
TEABLE_ENDPOINT=http://localhost:3003 TEABLE_PAT=<pat> \
|
|
node ../../packages/plugin-cli/dist/index.js deploy -d .
|
|
# or from the plugin dir: pnpm deploy
|
|
```
|
|
|
|
Raw curl equivalent (single-file legacy form still works):
|
|
|
|
```bash
|
|
PLUGIN_ID=$(python3 -c "import json; print(json.load(open('plugin.json'))['id'])")
|
|
curl -s -H "Authorization: Bearer $TEABLE_PAT" \
|
|
-F "js=@dist/main.js" \
|
|
-F "css=@dist/styles.css" \
|
|
-F "manifest=@plugin.json" \
|
|
"http://localhost:3003/api/plugin/$PLUGIN_ID/upload-bundle"
|
|
```
|
|
|
|
Multipart notes (v2, 2026-10):
|
|
- `js` field accepts up to 20 files (entry first or matched by manifest
|
|
entry filename), `css` up to 5, `map` up to 20.
|
|
- The plugin row must already exist (`POST /api/plugin {"id": "<kebab-id>"}`
|
|
— custom ids require the plugin_developer whitelist; the endpoint is
|
|
`@TokenAccess()` so a PAT works).
|
|
- Limits: 5MB per JS/map file, 1MB per CSS, 15MB total. The bundleDir is
|
|
WIPED before writing — stale chunks don't linger, but hand-patched files
|
|
on disk are discarded on the next upload.
|
|
- Auth supports session cookies (`auth_session`) and PATs
|
|
(`Authorization: Bearer`). Runtime GETs (`/api/plugin/:id/runtime/*`) are
|
|
session-only (PAT gets 403 by design).
|
|
|
|
## 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
|
|
- Route (full-page app): `/base/<baseId>/p/<pluginId>` — install at position
|
|
`route`, the page renders the plugin full-screen inside BaseLayout (bring
|
|
your own router; HashRouter recommended so Next.js routing is untouched).
|
|
Verified flow: enable via plugin-config → install via
|
|
`POST /api/base/:baseId/plugin-installs {"position":"route"}` → open the URL.
|
|
|
|
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 (`!function` / `(function` /
|
|
`(() =>` / `var x=(function` prefix — run `head -c 20 dist/main.js`)
|
|
- [ ] Chunks (if any) are FLAT names (`[name]-[contenthash].chunk.js`)
|
|
- [ ] `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 2xx (assets count > 1 when chunks/maps exist)
|
|
- [ ] Enable (plugin-config) called before install
|
|
- [ ] Install returns 2xx
|
|
- [ ] Plugin renders and works
|
|
- [ ] Dev-loop deploys redone as a production upload (no `devMode` in the
|
|
final manifest) before handover
|
|
|
|
If any box is unchecked, fix it.
|