429 lines
14 KiB
Markdown
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)
|