Files
inkreach-teable-cli/skills/inkreach-teable-plugin/dynamic-plugins/dev-workflow.md
T

12 KiB

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)

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:

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:

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

{
  "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

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):

// 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):

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):

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)

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:

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.