packages/core/client-v2/src/components/README.md
This folder collects the React components that @nocobase/client-v2 exposes to downstream plugins. Components are organized by directory — at the moment the main one is form/, which targets settings pages and form-shaped UIs.
Skim this before writing a new plugin so you don't reinvent the wheel. Components are mostly orthogonal — import only what you need.
Components under form/ cover the "settings page + form" shape. The typical recipe: open a form container with ctx.viewer.drawer / ctx.viewer.dialog, host an antd Form + Form.Item tree inside, and pick standard field controls from this folder.
Grouped by purpose: form containers, form fields, data table, utilities.
Drawer-style form layout. Pair with ctx.viewer.drawer({ closable: true, content }).
closable: true on the viewer.drawer call for it to appearfooter<Form> instance + fieldsimport { DrawerFormLayout } from '@nocobase/client-v2';
ctx.viewer.drawer({
width: '50%',
closable: true, // restore antd Drawer's native close X
content: () => (
<DrawerFormLayout
title={t('Add authenticator')}
onSubmit={handleSubmit}
submitting={submitting}
>
<Form form={form} layout="vertical">
</Form>
</DrawerFormLayout>
),
});
Key props:
title: title nodeonSubmit: callback; the drawer closes automatically once it resolves. Throw to keep the drawer open (e.g. on a validation error)submitting: drives the Submit button's loading statesubmitText / cancelText: button labelsfooter: full override of the footer content (replaces the default Cancel + Submit pair)To intercept close (e.g. dirty-form confirmation), use the lower-level viewer.drawer({ preventClose, beforeClose }) hooks — this layout no longer wraps a custom cancel handler.
Dialog-style form layout, the centered counterpart of DrawerFormLayout. Pair with ctx.viewer.dialog({ closable: true, content }).
The only visual difference from the drawer version is where the native close X sits — antd Drawer renders it at the top-left of the title bar, antd Modal at the top-right. Both layouts rely on the caller passing closable: true at the viewer call site; neither renders a close icon itself.
import { DialogFormLayout } from '@nocobase/client-v2';
ctx.viewer.dialog({
closable: true, // restore antd Modal's native top-right X
content: () => (
<DialogFormLayout title={t('Bind verifier')} onSubmit={handleSubmit}>
<Form form={form} layout="vertical">
</Form>
</DialogFormLayout>
),
});
When to pick which:
Props are nearly identical to DrawerFormLayout, with one extra: DialogFormLayout accepts an onCancel callback (fired by both the Cancel button and the native X) for "discard changes" confirmations.
A Select bound to an async option source. Framework-level — it knows nothing about NocoBase business resources; the caller passes a request function that fetches whatever it needs.
import { RemoteSelect } from '@nocobase/client-v2';
<Form.Item name="provider" label={t('Provider')}>
<RemoteSelect<{ name: string; title: string }>
request={async () => {
const response = await ctx.api.resource('smsOTPProviders').list();
return response?.data?.data || [];
}}
cacheKey="@nocobase/plugin-verification:smsOTPProviders:list"
mapOptions={(item) => ({ label: compileT(item.title), value: item.name })}
/>
</Form.Item>
Key props:
request: () => Promise: fetch function, required. Returns either an array of items or an envelope object (combine with selectItems to pluck the array out)selectItems: extractor that takes the request result and returns the option array. Use when the response is { items, meta }-shapedfieldNames: defaults to { label, value } mapping; override with mapOptions when the raw item doesn't matchmapOptions: (item, index) => ({ label, value }): full override of option mappingcacheKey / refreshDeps / ready: forwarded to ahooks useRequest; control caching and refresh timingonLoaded: (items, response) => void: fires after data arrives; receives both the mapped item array and the raw responseAll other antd Select props (mode / placeholder / disabled / value / onChange / etc.) are passed through.
showSearch + allowClear are on by default; search is local (filters by label). For server-side search, drive the search input through external state and pass it via refreshDeps, then read it inside request.
A variable input restricted to the $env namespace. Designed for secret / credential fields — supports environment-variable references and adds password masking for plain literal values.
import { EnvVariableInput } from '@nocobase/client-v2';
<Form.Item name={['options', 'accessKeySecret']} label={t('Access Key Secret')}>
<EnvVariableInput password />
</Form.Item>
Key props:
password: when enabled, non-variable literal values render through Input.Password so they're masked. Variable expressions like {{ $env.X }} stay visible and editableplaceholder / disabled / value / onChange: standard controlled-input propsThe persisted value is always a string: either a literal ('literal') or a server-template reference ('{{ $env.foo.bar }}'). The server expands the reference at use time.
General-purpose variable inputs. Can reference any namespace registered on flowEngine.context — $env, $user, plus ad-hoc business namespaces like $resetLink.
The two differ in shape:
VariableInput: single-line. Variables render as colored pills (compact "chips")VariableTextArea: multi-line. Variables stay as raw {{ ... }} text — better for email templates and other long-form content where the literal {{ ... }} is the intended display (the server expands them at render time)import { VariableInput, VariableTextArea } from '@nocobase/client-v2';
// Email subject — single line, pills
<Form.Item name={['options', 'emailSubject']} label={t('Subject')}>
<VariableInput
namespaces={['$env']}
extraNodes={[
{ name: '$resetLink', title: t('Reset password link'), type: 'string', paths: ['$resetLink'] },
]}
/>
</Form.Item>
// Email body — multi-line, literal
<Form.Item name={['options', 'emailContentHTML']} label={t('Content')}>
<VariableTextArea namespaces={['$env']} rows={10} />
</Form.Item>
Key props:
namespaces: restrict the picker to specific top-level namespaces. Omit to expose every registered top-level propertyextraNodes: static leaves appended after the namespace-filtered nodes. Use for variables that only make sense in the current page (e.g. $resetLink)converters: override the default path ↔ string converters. EnvVariableInput uses this hook to lock its output to $envdelimiters: token pair wrapping the stored variable reference. Defaults to ['{{', '}}'] (Handlebars HTML-escaped). Pass ['{{{', '}}}'] for fields rendered as HTML where escaping would corrupt the variable value — e.g. the in-app message bodyvalue / onChange / placeholder / disabled: standard controlled-input propsUnder the hood VariableInput wraps VariableHybridInput (inline pills), VariableTextArea wraps TextAreaWithContextSelector (textarea + variable button). Both share the same MetaTree.
Typed-constant + variable hybrid input. Ported from v1 Variable.Input's useTypedConstant pattern: an italic x button on the right triggers a Cascader switcher [Null | Constant<types> | Variable<…namespaces>]; the left side renders the matching editor (Input / InputNumber / Select(True/False) / DatePicker) or a pill carrying the variable path.
Reach for this when a field accepts both a typed literal and a variable reference. The canonical example is plugin-notification-email's SMTP port and secure fields: users can type a numeric port / boolean flag, or pass {{ $env.SMTP_PORT }} to read from environment variables.
import { TypedVariableInput } from '@nocobase/client-v2';
// Port — numeric constant + $env variable
<Form.Item name={['options', 'port']} label={t('Port')} initialValue={465}>
<TypedVariableInput
types={[['number', { min: 1, max: 65535, step: 1 }]]}
namespaces={['$env']}
/>
</Form.Item>
// Secure mode — boolean constant + $env variable
<Form.Item name={['options', 'secure']} label={t('Secure')} initialValue={true}>
<TypedVariableInput types={['boolean']} namespaces={['$env']} />
</Form.Item>
Key props:
types: allowed constant types. Shape mirrors v1 useTypedConstant — pass bare type names (['number', 'boolean']) or [type, editorProps] tuples ([['number', { min, max, step }]]) to forward props to the underlying antd editor. Defaults to ['string', 'number', 'boolean', 'date']. Even when only one type is allowed, the Constant entry still expands into a typed submenu (Number / Boolean / Date / String) — matches v1 so users can see what type the constant isnamespaces: restrict the variable picker to specific top-level namespaces (e.g. ['$env']). Omit to expose every namespace registered on flowEngine.contextextraNodes: static leaves appended after the namespace-filtered nodesnullable: whether to expose the Null switcher entry. Default true. Combined with Form.Item.rules={[{ required: true }]}, the user can explicitly clear the field but submission is still blocked by validation — mirrors v1's "Null + required" pairingdelimiters: variable-token delimiters, default ['{{', '}}'] — same as VariableInputvalue / onChange / placeholder / disabled / style / className: standard controlled-input propsValue shape:
number / boolean / Date / string)'{{ $env.SMTP_PORT }}'nullWhen not to use it:
InputNumber / Select / DatePicker / Input) and skip the Cascader column overheadEnvVariableInput ($env-only, with optional password masking) or VariableInput (general-purpose)Capabilities skipped (present in v1, not yet ported to v2):
object constant type (JSON editor) — v2 has no inline "JSON editor + Cascader switcher" yet; add when there's a concrete callerloadChildren cascading — most MetaTree namespaces are already eagerly resolved by useFilteredMetaTree, so this hasn't been neededA byte-valued size input paired with a unit selector (Byte / KB / MB / GB). The persisted value is always in bytes; the displayed number is derived from the picked unit.
import { FileSizeInput } from '@nocobase/client-v2';
<Form.Item name="maxFileSize" label={t('Max file size')}>
<FileSizeInput min={1} max={1024 * 1024 * 1024} defaultValue={20 * 1024 * 1024} />
</Form.Item>
Key props:
min / max: allowed byte range; values out of range snap back on blur. Defaults: min=1, max=InfinitydefaultValue: drives the initial unit when the field is empty (e.g. 20 MB starts in the "MB" unit)value / onChange: controlled-input contract; the value type is number (bytes)antd Input.Password plus an optional strength meter, ported from v1's
Password component. Use for any "set / change password" form when you want
to give the user the same visual signal they had in v1.
import { PasswordInput } from '@nocobase/client-v2';
<Form.Item name="newPassword" label={t('New password')} rules={[{ required: true }]}>
<PasswordInput autoComplete="new-password" checkStrength />
</Form.Item>
Key props:
checkStrength: render a strength bar beneath the input. Defaults to false. The score is bucketed [20, 40, 60, 80, 100] and shown via a clipped gradient (orange) inside a grey track, matching v1Input.Password props are passed through unchanged: value / onChange / disabled / placeholder / autoComplete / etc.The strength meter is purely a UX hint, NOT validation. Submitting a weak password is still allowed unless the server (or a separately installed password-policy plugin) rejects it. Wire up real password rules through Form.Item.rules or — when the open-source ↔ commercial extension point lands — the project's shared password-validator hook.
JSON input. The stored value is a JS object (not a string) — parsing happens live while typing and is finalized on blur.
import { JsonTextArea } from '@nocobase/client-v2';
<Form.Item name="customConfig" label={t('Custom config')}>
<JsonTextArea rows={6} json5 />
</Form.Item>
Key props:
space: serialization indent. Defaults to 2json5: parse with JSON5 (tolerates trailing commas, comments, single quotes, etc.). Defaults to falseshowError: render the parse error inline below the textarea. Defaults to trueInput.TextArea props are passed throughvalue / onChange are typed as unknown because JSON values can be any shape. Tighten the contract with validators in Form.Item.rules.
The standard settings-page table, built on antd Table with two additions:
rowSelection to be presentisDraggable to enable. Each row gets a drag handle on the left; onSortEnd fires when a row is dropped. The component does NOT mutate dataSource — the caller persists the move (resource.move(...)) and refresh()simport { Table, DEFAULT_PAGE_SIZE } from '@nocobase/client-v2';
<Table<AuthenticatorRecord>
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.records || []}
isDraggable
onSortEnd={async (from, to) => {
await resource.move({ sourceId: from.id, targetId: to.id });
refresh();
}}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
pagination={{
current: page,
pageSize,
total: data?.total || 0,
onChange: (next, nextSize) => { /* ... */ },
}}
/>
Key props:
rowKey: required. Drag-sort and row-identity both depend on itshowIndex: defaults to true; disable to keep the cell at checkbox-onlyisDraggable: drag-and-drop toggle. Defaults to false — when off the component is a thin antd Table supersetonSortEnd: (from, to) => void | Promise: fired when a row is dropped. Caller persistsshowSortHandle: defaults to true; set false when you want the handle off (or embedded into a custom column via <SortHandle />)Table props are passed throughCompanion exports:
DEFAULT_PAGE_SIZE (value 50): suggested default page sizePAGE_SIZE_OPTIONS: suggested page-size dropdown values [5, 10, 20, 50, 100, 200]SortHandle: standalone handle component, exported from @nocobase/client-v2 for embedding into custom columnsFilter button bound to a Collection. Clicking opens a Popover hosting a multi-condition filter form (field picker + operator + value control). Submit dismisses the Popover and emits the compiled NocoBase filter via onChange; Reset keeps the Popover open and emits undefined.
import { CollectionFilter, ExtendCollectionsProvider } from '@nocobase/client-v2';
import lockedUsersCollection from '../../collections/locked-users';
function Page() {
const main = engine.context.dataSourceManager?.getDataSource?.('main');
const collection = main?.getCollection?.(lockedUsersCollection.name);
const listRequest = useRequest(
async (filter) => api.resource('lockedUsers').list({ ...(filter ? { filter } : {}) }),
{ defaultParams: [undefined] },
);
return (
<ExtendCollectionsProvider collections={[lockedUsersCollection]}>
<CollectionFilter collection={collection} onChange={listRequest.run} t={t} />
</ExtendCollectionsProvider>
);
}
Key props:
collection: the Collection that drives the field picker. The button is disabled while it's undefinedonChange: (filter) => void: fired on Submit and Reset with the compiled NocoBase filter (undefined on Reset). Most pages forward straight to listRequest.runt: translator. Pass useT() from a plugin's locale.ts so server-side {{t("…")}} macros in field / operator labels get expanded — plain react-i18next's t leaves them as literal template stringsfilterableFieldNames: whitelist of root-level field names to exposenoIgnore: bypass the whitelistbuttonText: override the trigger label; defaults to t('Filter')showCount: show the (N) condition-count badge on the trigger; defaults to truepopoverProps / buttonProps: pass-through to the antd Popover / ButtonpopoverMinWidth: min-width of the popover body; defaults to 520If the target Collection is schema-only (not auto-published from the server to the v2 data source), wrap the page in <ExtendCollectionsProvider> so CollectionFilter can resolve it by name.
Factory for a namespaced "entry registry". Each call returns an independent registry instance backed by its own closure Map.
import { createFormRegistry, type FormRegistryEntry } from '@nocobase/client-v2';
interface StorageType extends FormRegistryEntry {
// FormRegistryEntry requires at least `name: string`
title: string;
Component: React.ComponentType;
}
const storageTypes = createFormRegistry<StorageType>('file-manager/storage-types');
storageTypes.register({ name: 'local', title: 'Local storage', Component: LocalStorageForm });
storageTypes.register({ name: 's3', title: 'Amazon S3', Component: S3StorageForm });
storageTypes.get('s3');
storageTypes.list();
storageTypes.has('local');
storageTypes.unregister('local');
Use this when a plugin needs an extension point for "same name + same shape + different implementation" things (the file-manager's storage types, the verification plugin's OTP providers, etc.). It's a thin wrapper around Map that adds a namespace label and an HMR-friendly overwrite warning.
Re-registering the same name overwrites the previous entry and emits a console.warn — HMR doesn't throw, and unintended duplicates surface in dev.
Components that wire collections / data sources into the React tree. Exported from the top level of @nocobase/client-v2.
Mount-scoped collection injector. On mount it registers the given collections into the target data source; on unmount it removes them. A dataSource:loaded listener re-applies the registration so mid-session reloads don't wipe injected collections.
import { ExtendCollectionsProvider } from '@nocobase/client-v2';
import lockedUsersCollection from '../../collections/locked-users';
// Module-level constant — keeps the reference stable so the provider's
// effect doesn't re-run on every parent re-render.
const collections = [lockedUsersCollection];
export function LockedUsersPage() {
return (
<ExtendCollectionsProvider collections={collections}>
<LockedUsersPageInner />
</ExtendCollectionsProvider>
);
}
Key props:
collections: CollectionOptions[]: collections to inject. The provider only adds names that aren't already present, and on unmount removes only the ones it addeddataSource: target data source key; defaults to 'main'children: subtree covered by the injectionWhen to use:
schema-only and doesn't get auto-published to the client data source (e.g. lockedUsers)Typical pairing: use together with <CollectionFilter> — the provider makes the collection resolvable; the filter button consumes it.
client-v2/RemoteSelect.selectItems is an example — it landed so envelope responses don't need their own componentTwo follow-ups after adding a new component:
export * from './XxxComponent' to form/index.tsx