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

429 lines
14 KiB
Markdown

# 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(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.
```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)