Files
inkreach-teable-cli/skills/inkreach-teable-plugin/dynamic-plugins/sdk-api.md
T

14 KiB

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.

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

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(id: string, component: React.FC): Disposable;

Route position (full-page apps, since 2026-10): the component renders at /base/<baseId>/p/<pluginId> — the whole main-area viewport inside the base layout, so SDK hooks like host.SDK.useBaseId() work. Install the plugin at position "route" (POST /api/base/:baseId/plugin-installs) and include "route" in manifest.positions. The plugin brings its own router — use HashRouter (e.g. react-router-dom bundled as a normal npm dep) so Next.js routing is untouched. Working example: plugins/plugin-showcase/src/App.tsx.

Events

ctx.on(event, handler): Disposable

Subscribe to host events. Returns a Disposable that unsubscribes when called. All subscriptions are auto-disposed on unload.

// 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.

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>.

// 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.

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.

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:

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:

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:

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

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:

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

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.

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.

const { NativeGrid } = window.__TEABLE_HOST__;

function MyGridView({ filter }) {
  return <NativeGrid externalFilter={filter} />;
}

host.events: { on, off, emit }

Event bus for cross-plugin communication.

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:

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)