Merge remote-tracking branch 'origin/next' into next

# Conflicts:
#	packages/plugins/@nocobase/plugin-workflow-javascript/src/server/ScriptInstruction.ts
This commit is contained in:
xilesun
2026-06-01 12:06:44 +08:00
24 changed files with 1704 additions and 314 deletions
+3
View File
@@ -30,6 +30,9 @@ server {
client_max_body_size 0;
access_log /var/log/nginx/nocobase.log apm;
include /etc/nginx/mime.types;
types { application/javascript mjs; }
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
@@ -169,6 +169,54 @@ Key props:
Under the hood `VariableInput` wraps `VariableHybridInput` (inline pills), `VariableTextArea` wraps `TextAreaWithContextSelector` (textarea + variable button). Both share the same MetaTree.
#### TypedVariableInput
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.
```tsx
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 is
- `namespaces`: restrict the variable picker to specific top-level namespaces (e.g. `['$env']`). Omit to expose every namespace registered on `flowEngine.context`
- `extraNodes`: static leaves appended after the namespace-filtered nodes
- `nullable`: 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" pairing
- `delimiters`: variable-token delimiters, default `['{{', '}}']` — same as `VariableInput`
- `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`: standard controlled-input props
Value shape:
- Constant: stored as the native type (`number` / `boolean` / `Date` / `string`)
- Variable: a string like `'{{ $env.SMTP_PORT }}'`
- Null: `null`
When **not** to use it:
- **Pure literal fields** (users will never pass a variable) → use the antd primitive directly (`InputNumber` / `Select` / `DatePicker` / `Input`) and skip the Cascader column overhead
- **Pure variable fields** (users will never pass a literal) → use `EnvVariableInput` (`$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 caller
- Async `loadChildren` cascading — most MetaTree namespaces are already eagerly resolved by `useFilteredMetaTree`, so this hasn't been needed
#### FileSizeInput
A 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.
@@ -169,6 +169,54 @@ import { VariableInput, VariableTextArea } from '@nocobase/client-v2';
底层共用 `VariableHybridInput`(`VariableInput`)和 `TextAreaWithContextSelector`(`VariableTextArea`),用同一套 MetaTree 数据。
#### TypedVariableInput
类型化常量 + 变量混合输入器。移植 v1 `Variable.Input` 的 `useTypedConstant` 形态:右侧斜体 `x` 按钮触发 Cascader 切换 `[空值 | 常量<types> | 变量和密钥<…namespaces>]`,左侧根据当前模式渲染对应编辑器(`Input` / `InputNumber` / `Select(True/False)` / `DatePicker`)或一颗带变量路径的 pill。
用于字段**同时接受**字面量**和**变量引用的场景。最典型的就是 `plugin-notification-email` 的 SMTP `port` 和 `secure`:可以填具体数字 / 布尔值,也可以填 `{{ $env.SMTP_PORT }}` 走环境变量。
```tsx
import { TypedVariableInput } from '@nocobase/client-v2';
// 端口:数字常量 + $env 变量
<Form.Item name={['options', 'port']} label={t('端口')} initialValue={465}>
<TypedVariableInput
types={[['number', { min: 1, max: 65535, step: 1 }]]}
namespaces={['$env']}
/>
</Form.Item>
// 安全模式:布尔常量 + $env 变量
<Form.Item name={['options', 'secure']} label={t('安全模式')} initialValue={true}>
<TypedVariableInput types={['boolean']} namespaces={['$env']} />
</Form.Item>
```
主要属性:
- `types`:允许的常量类型。形态对齐 v1 `useTypedConstant`,可以传裸类型名 `['number', 'boolean']`,也可以传 `[type, editorProps]` 元组 `[['number', { min, max, step }]]` 把 props 透传给底层 antd 编辑器。默认 `['string', 'number', 'boolean', 'date']`。**即使只允许一种类型,「常量」入口也会展开二级菜单**(数字 / 逻辑值 / 日期 / 字符串)——跟 v1 一致,让用户能直观看到当前常量是什么类型
- `namespaces`:限定变量 picker 可选的顶层命名空间(如 `['$env']`)。不传就用 `flowEngine.context` 里所有已注册命名空间
- `extraNodes`:在命名空间过滤后追加几条静态变量节点
- `nullable`:是否暴露「空值」入口,默认 `true`。配合 `Form.Item.rules={[{ required: true }]}` 可以让用户能手动清空、但提交时会被校验拦截——跟 v1 的「空值 + required」组合一致
- `delimiters`:变量 token 开闭分隔符,默认 `['{{', '}}']`,跟 `VariableInput` 一致
- `value` / `onChange` / `placeholder` / `disabled` / `style` / `className`:标准受控字段属性
值的形态:
- 常量:原生类型直接存(`number` / `boolean` / `Date` / `string`)
- 变量:字符串 `'{{ $env.SMTP_PORT }}'`
- 空值:`null`
什么时候**不该用**:
- **纯字面量字段**(用户不会想填变量)→ 直接用 antd `InputNumber` / `Select` / `DatePicker` / `Input`,省掉 Cascader 那一格的视觉开销
- **纯变量字段**(用户不会想填字面量)→ 用 `EnvVariableInput`(`$env` 专用,带 password mask)或 `VariableInput`(更通用)
跳过的能力(v1 有但 v2 还没补):
- `object` 类型(JSON 编辑器)——v2 还没对应的「内联 JSON 编辑器 + Cascader 切换」组件,等真有需求再补
- 异步 `loadChildren` 分支——大多数命名空间的 MetaTree 已经由 `useFilteredMetaTree` 提前展平,没遇到刚需
#### FileSizeInput
文件大小输入器。值统一存字节数,UI 上配一个单位选择器(Byte / KB / MB / GB)。
@@ -0,0 +1,441 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { CloseCircleFilled } from '@ant-design/icons';
import {
buildContextSelectorItems,
useFlowContext,
type ContextSelectorItem,
type MetaTreeNode,
} from '@nocobase/flow-engine';
import { Button, Cascader, DatePicker, Input, InputNumber, Select, Space, Tag, theme, type CascaderProps } from 'antd';
import dayjs from 'dayjs';
import React, { useCallback, useMemo } from 'react';
import {
makeFormatVariablePath,
makeParseVariablePath,
useFilteredMetaTree,
type VariableDelimiters,
} from './VariableInput';
/**
* Constant types this input can edit. Subset of v1 `Variable.Input`
* `useTypedConstant` — drops `'object'` (no v2 JSON editor yet) and `'null'`
* (handled by the dedicated `nullable` prop).
*/
export type TypedConstantType = 'string' | 'number' | 'boolean' | 'date';
/**
* One allowed constant type. Either a bare type name (`'number'`) or a
* `[type, editorProps]` pair (`['number', { min: 1, max: 65535 }]`) where
* `editorProps` is forwarded to the antd editor for that type — same
* shape as v1 `useTypedConstant`.
*/
export type TypedConstantSpec = TypedConstantType | [TypedConstantType, Record<string, unknown>];
export interface TypedVariableInputProps {
/**
* Stored value. A `{{ ... }}` string is treated as a variable reference;
* anything else is a constant of the inferred type.
*/
value?: unknown;
onChange?: (next: unknown) => void;
/**
* Allowed constant types. The `Constant` switcher entry always exposes a
* typed submenu (matching v1 `Variable.Input`) so users can see what type
* the constant is, even when only one type is permitted. Default: all four
* supported types.
*/
types?: TypedConstantSpec[];
/**
* Restrict the variable picker to specific top-level meta-tree namespaces
* (e.g. `['$env']`). When omitted, every registered top-level property is
* exposed — matching `VariableInput`'s default behaviour.
*/
namespaces?: string[];
/** Additional leaves appended to the picker after the namespace-filtered nodes. */
extraNodes?: MetaTreeNode[];
/**
* When true (default), the switcher exposes a `Null` option that resets the
* value to `null`. When false, the value is constrained to one of the
* allowed types or a variable reference.
*/
nullable?: boolean;
/** Variable-token delimiters. Default `['{{', '}}']` — see `VariableInput`. */
delimiters?: VariableDelimiters;
disabled?: boolean;
placeholder?: string;
style?: React.CSSProperties;
className?: string;
}
const DEFAULT_TYPES: TypedConstantSpec[] = ['string', 'number', 'boolean', 'date'];
const TYPE_LABEL_KEYS: Record<TypedConstantType, string> = {
string: 'String',
number: 'Number',
boolean: 'Boolean',
date: 'Date',
};
type NormalizedType = { type: TypedConstantType; props: Record<string, unknown> };
function normalizeTypes(types: TypedConstantSpec[]): NormalizedType[] {
return types.map((spec) =>
Array.isArray(spec) ? { type: spec[0], props: spec[1] ?? {} } : { type: spec, props: {} },
);
}
function defaultValueFor(type: TypedConstantType): unknown {
switch (type) {
case 'string':
return '';
case 'number':
return 0;
case 'boolean':
return false;
case 'date': {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
}
default:
return null;
}
}
type DetectedMode = { mode: 'null' | 'variable' | TypedConstantType; variablePath?: string[] };
function detectMode(value: unknown, parseVariablePath: (v?: string) => string[] | undefined): DetectedMode {
if (value == null) return { mode: 'null' };
if (typeof value === 'string') {
const path = parseVariablePath(value);
if (path && path.length > 0) return { mode: 'variable', variablePath: path };
return { mode: 'string' };
}
if (typeof value === 'number') return { mode: 'number' };
if (typeof value === 'boolean') return { mode: 'boolean' };
if (value instanceof Date) return { mode: 'date' };
return { mode: 'string' };
}
const NULL_KEY = '__null__';
const CONST_KEY = '__const__';
interface SwitcherOption {
value: string;
label: React.ReactNode;
isLeaf?: boolean;
children?: SwitcherOption[];
meta?: MetaTreeNode;
paths?: string[];
disabled?: boolean;
}
function fromContextItem(item: ContextSelectorItem): SwitcherOption {
return {
value: item.value,
label: item.label,
isLeaf: item.isLeaf,
meta: item.meta,
paths: item.paths,
disabled: item.disabled,
children: item.children?.map(fromContextItem),
};
}
function resolveVariableLabels(path: string[], metaTree: MetaTreeNode[]): string[] {
const labels: string[] = [];
let nodes: MetaTreeNode[] | undefined = metaTree;
for (const segment of path) {
if (!nodes) break;
const matched: MetaTreeNode | undefined = nodes.find((node) => node.name === segment);
if (!matched) {
labels.push(segment);
nodes = undefined;
continue;
}
labels.push(typeof matched.title === 'string' && matched.title ? matched.title : matched.name);
nodes = Array.isArray(matched.children) ? matched.children : undefined;
}
return labels;
}
function renderConstantEditor(
type: TypedConstantType,
value: unknown,
onChange: ((next: unknown) => void) | undefined,
options: {
typedProps: Record<string, unknown>;
disabled?: boolean;
placeholder?: string;
t: (text: string) => string;
},
): React.ReactNode {
const { typedProps, disabled, placeholder, t } = options;
switch (type) {
case 'string':
return (
<Input
value={typeof value === 'string' ? value : ''}
onChange={(ev) => onChange?.(ev.target.value)}
disabled={disabled}
placeholder={placeholder}
{...typedProps}
/>
);
case 'number':
return (
<InputNumber
value={typeof value === 'number' ? value : null}
onChange={(next) => onChange?.(next)}
disabled={disabled}
placeholder={placeholder}
style={{ width: '100%' }}
{...typedProps}
/>
);
case 'boolean':
return (
<Select
value={typeof value === 'boolean' ? value : undefined}
onChange={(next) => onChange?.(next)}
disabled={disabled}
placeholder={placeholder ?? t('Select')}
options={[
{ value: true, label: t('True') },
{ value: false, label: t('False') },
]}
style={{ width: '100%' }}
{...typedProps}
/>
);
case 'date': {
const parsed = value instanceof Date ? dayjs(value) : null;
return (
<DatePicker
value={parsed}
onChange={(next) => onChange?.(next ? next.toDate() : null)}
disabled={disabled}
showTime
allowClear={false}
style={{ width: '100%' }}
{...typedProps}
/>
);
}
default:
return null;
}
}
/**
* Constant-or-variable input. Port of v1 `Variable.Input` typed-constant
* Cascader pattern (the `[Null | Constant<type> | Variable<…namespaces>]`
* switcher). Use this when a field can accept either a typed literal
* (number, boolean, date, string) or a variable reference like
* `{{ $env.SMTP_PORT }}` — see `plugin-notification-email`'s port / secure
* fields for the canonical example.
*
* Pure literal fields should keep using the antd primitive
* (`InputNumber`, `Select`, `DatePicker`). Pure variable fields should keep
* using `EnvVariableInput` / `VariableInput`. This component carries the
* Cascader switcher overhead, so reach for it only when the field
* genuinely accepts both shapes.
*/
export function TypedVariableInput(props: TypedVariableInputProps) {
const {
value,
onChange,
types = DEFAULT_TYPES,
namespaces,
extraNodes,
nullable = true,
delimiters,
disabled,
placeholder,
style,
className,
} = props;
const ctx = useFlowContext();
const t = useCallback((text: string): string => (typeof ctx?.t === 'function' ? ctx.t(text) : text), [ctx]);
const { token } = theme.useToken();
const metaTree = useFilteredMetaTree({ namespaces, extraNodes });
const parseVariablePath = useMemo(() => makeParseVariablePath(delimiters), [delimiters]);
const formatVariablePath = useMemo(() => makeFormatVariablePath(delimiters), [delimiters]);
const normalizedTypes = useMemo(() => normalizeTypes(types), [types]);
const detected = useMemo(() => detectMode(value, parseVariablePath), [value, parseVariablePath]);
const variableItems = useMemo(() => buildContextSelectorItems(metaTree).map(fromContextItem), [metaTree]);
const switcherOptions = useMemo<SwitcherOption[]>(() => {
const items: SwitcherOption[] = [];
if (nullable) {
items.push({ value: NULL_KEY, label: t('Null'), isLeaf: true });
}
// Always render Constant with a typed submenu — even when only one type is
// allowed. Matches v1 `Variable.Input`, where clicking 常量 reveals 数字 /
// 逻辑值 / etc. so the user can see what type the constant actually is.
if (normalizedTypes.length > 0) {
items.push({
value: CONST_KEY,
label: t('Constant'),
children: normalizedTypes.map(({ type }) => ({
value: type,
label: t(TYPE_LABEL_KEYS[type]),
isLeaf: true,
})),
});
}
items.push(...variableItems);
return items;
}, [nullable, normalizedTypes, variableItems, t]);
const onSwitcherChange = useCallback<NonNullable<CascaderProps<SwitcherOption>['onChange']>>(
(path, selectedOptions) => {
const head = path?.[0];
if (head === NULL_KEY) {
onChange?.(null);
return;
}
if (head === CONST_KEY) {
// Cascader always emits the second segment now that we render typed
// children even for single-type configs. Fall back to the first
// allowed type so picking only the parent still does something
// sensible (`changeOnSelect` lets this fire).
const targetType = (path[1] as TypedConstantType | undefined) ?? normalizedTypes[0]?.type;
if (!targetType) return;
if (detected.mode === targetType) return;
onChange?.(defaultValueFor(targetType));
return;
}
const leaf = selectedOptions?.[selectedOptions.length - 1] as SwitcherOption | undefined;
const meta = leaf?.meta;
if (!meta) return;
const formatted = formatVariablePath(meta);
if (formatted != null) onChange?.(formatted);
},
[onChange, normalizedTypes, detected.mode, formatVariablePath],
);
const onClearVariable = useCallback(() => {
if (nullable) {
onChange?.(null);
return;
}
const first = normalizedTypes[0];
if (first) onChange?.(defaultValueFor(first.type));
}, [nullable, normalizedTypes, onChange]);
const constantTypeForRendering: TypedConstantType = useMemo(() => {
const m = detected.mode;
if (m === 'string' || m === 'number' || m === 'boolean' || m === 'date') return m;
return normalizedTypes[0]?.type ?? 'string';
}, [detected.mode, normalizedTypes]);
const typedProps = useMemo(
() => normalizedTypes.find((nt) => nt.type === constantTypeForRendering)?.props ?? {},
[normalizedTypes, constantTypeForRendering],
);
const isVariable = detected.mode === 'variable';
const isNull = detected.mode === 'null';
const variableLabels = useMemo(
() => (isVariable && detected.variablePath ? resolveVariableLabels(detected.variablePath, metaTree) : []),
[isVariable, detected.variablePath, metaTree],
);
return (
<Space.Compact style={{ display: 'flex', width: '100%', ...style }} className={className}>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
{isVariable ? (
<div
role="button"
aria-label="variable-tag"
style={{
display: 'flex',
alignItems: 'center',
gap: token.marginXXS,
padding: `0 ${token.paddingSM}px`,
minHeight: token.controlHeight,
border: `1px solid ${token.colorBorder}`,
borderRadius: token.borderRadius,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
background: disabled ? token.colorBgContainerDisabled : token.colorBgContainer,
overflow: 'hidden',
}}
>
<Tag
color="blue"
style={{
marginInlineEnd: 0,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{variableLabels.map((label, index) => (
<React.Fragment key={`${label}-${index}`}>
{index ? ' / ' : ''}
{label}
</React.Fragment>
))}
</Tag>
{!disabled ? (
<Button
type="text"
size="small"
aria-label="icon-close"
onClick={onClearVariable}
icon={<CloseCircleFilled style={{ color: token.colorTextTertiary }} />}
/>
) : null}
</div>
) : isNull ? (
// v1 used the `placeholder` slot (not `value`) so the antd default
// placeholder colour applies — keeps the field looking visibly
// empty/inactive rather than holding a real text value.
<Input placeholder={`<${t('Null')}>`} readOnly disabled={disabled} style={{ width: '100%' }} />
) : (
renderConstantEditor(constantTypeForRendering, value, onChange, {
typedProps,
disabled,
placeholder,
t,
})
)}
</div>
<Cascader<SwitcherOption>
options={switcherOptions}
onChange={onSwitcherChange}
disabled={disabled}
changeOnSelect
>
<Button
aria-label="variable-switcher"
disabled={disabled}
type={isVariable ? 'primary' : 'default'}
style={{
flexShrink: 0,
fontStyle: 'italic',
fontFamily: '"New York", "Times New Roman", Times, serif',
}}
>
x
</Button>
</Cascader>
</Space.Compact>
);
}
export default TypedVariableInput;
@@ -0,0 +1,152 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { FlowContext, FlowContextProvider } from '@nocobase/flow-engine';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { TypedVariableInput } from '../TypedVariableInput';
function createContextWithEnv() {
const ctx = new FlowContext();
(ctx as any).t = (key: string) => key;
ctx.defineProperty('$env', {
value: { SMTP_PORT: 465, SECURE_FLAG: true },
meta: {
title: 'Env',
type: 'object',
properties: {
SMTP_PORT: { title: 'SMTP Port', type: 'number' },
SECURE_FLAG: { title: 'Secure flag', type: 'boolean' },
},
},
});
return ctx;
}
function renderWithCtx(ctx: FlowContext, node: React.ReactNode) {
return render(<FlowContextProvider context={ctx}>{node}</FlowContextProvider>);
}
describe('TypedVariableInput - constant rendering', () => {
it('renders an InputNumber for a numeric value when types=[number]', async () => {
const ctx = createContextWithEnv();
renderWithCtx(
ctx,
<TypedVariableInput
value={465}
types={[['number', { min: 1, max: 65535 }]]}
namespaces={['$env']}
onChange={() => undefined}
/>,
);
const numberInput = await screen.findByDisplayValue('465');
expect(numberInput).toBeInTheDocument();
expect(numberInput.getAttribute('role')).toBe('spinbutton');
});
it('renders a boolean Select when value is true and types=[boolean]', async () => {
const ctx = createContextWithEnv();
renderWithCtx(
ctx,
<TypedVariableInput value={true} types={['boolean']} namespaces={['$env']} onChange={() => undefined} />,
);
await waitFor(() => {
// antd Select renders the chosen item label via a selection-item span
const item = screen.getByText('True');
expect(item).toBeInTheDocument();
});
});
it('renders the Null placeholder when value=null and nullable=true', async () => {
const ctx = createContextWithEnv();
renderWithCtx(
ctx,
<TypedVariableInput value={null} types={['number']} namespaces={['$env']} nullable onChange={() => undefined} />,
);
// Uses placeholder slot (not value) so antd's grey placeholder colour
// applies — see comment in TypedVariableInput.tsx.
const nullInput = await screen.findByPlaceholderText('<Null>');
expect(nullInput).toBeInTheDocument();
expect(nullInput.getAttribute('readonly')).not.toBeNull();
});
});
describe('TypedVariableInput - variable rendering', () => {
it('renders a labelled pill when value is a {{ $env.X }} expression', async () => {
const ctx = createContextWithEnv();
renderWithCtx(
ctx,
<TypedVariableInput
value="{{$env.SMTP_PORT}}"
types={['number']}
namespaces={['$env']}
onChange={() => undefined}
/>,
);
await waitFor(() => {
const tag = screen.getByRole('button', { name: 'variable-tag' });
expect(tag.textContent).toContain('Env');
expect(tag.textContent).toContain('SMTP Port');
});
});
it('clears back to null when the close button is clicked (nullable=true)', async () => {
const ctx = createContextWithEnv();
const handleChange = vi.fn();
renderWithCtx(
ctx,
<TypedVariableInput
value="{{$env.SMTP_PORT}}"
types={['number']}
namespaces={['$env']}
nullable
onChange={handleChange}
/>,
);
const clear = await screen.findByRole('button', { name: 'icon-close' });
fireEvent.click(clear);
expect(handleChange).toHaveBeenCalledWith(null);
});
it('clears back to default-of-first-type when nullable=false', async () => {
const ctx = createContextWithEnv();
const handleChange = vi.fn();
renderWithCtx(
ctx,
<TypedVariableInput
value="{{$env.SMTP_PORT}}"
types={['number']}
namespaces={['$env']}
nullable={false}
onChange={handleChange}
/>,
);
const clear = await screen.findByRole('button', { name: 'icon-close' });
fireEvent.click(clear);
expect(handleChange).toHaveBeenCalledWith(0);
});
});
describe('TypedVariableInput - editor onChange propagation', () => {
it('forwards numeric edits via onChange', async () => {
const ctx = createContextWithEnv();
const handleChange = vi.fn();
renderWithCtx(ctx, <TypedVariableInput value={465} types={['number']} onChange={handleChange} />);
const numberInput = await screen.findByDisplayValue('465');
fireEvent.change(numberInput, { target: { value: '587' } });
// antd InputNumber may emit on blur; force blur to flush
fireEvent.blur(numberInput);
expect(handleChange).toHaveBeenCalled();
const lastCall = handleChange.mock.calls[handleChange.mock.calls.length - 1][0];
expect(lastCall).toBe(587);
});
});
@@ -16,4 +16,5 @@ export * from './FileSizeInput';
export * from './JsonTextArea';
export * from './PasswordInput';
export * from './RemoteSelect';
export * from './TypedVariableInput';
export * from './VariableInput';
@@ -0,0 +1,2 @@
export * from './dist/client-v2';
export { default } from './dist/client-v2';
@@ -0,0 +1,3 @@
'use strict';
module.exports = require('./dist/client-v2/index.js');
@@ -14,6 +14,9 @@
"license": "Apache-2.0",
"peerDependencies": {
"@nocobase/client": "2.x",
"@nocobase/client-v2": "2.x",
"@nocobase/flow-engine": "2.x",
"@nocobase/plugin-data-source-manager": "2.x",
"@nocobase/server": "2.x",
"@nocobase/test": "2.x"
},
@@ -8,15 +8,19 @@
*/
import { EditOutlined, RightSquareOutlined } from '@ant-design/icons';
import { DrawerFormLayout } from '@nocobase/client-v2';
import { randomId, useFlowContext } from '@nocobase/flow-engine';
import type {
CollectionTemplateConfigureItemProps,
CollectionTemplateSyncFieldsProps,
} from '@nocobase/plugin-data-source-manager/client-v2';
import { useRequest } from 'ahooks';
import { Alert, Button, Cascader, Form, Input, Select, Spin, Table, Tag } from 'antd';
import { Alert, App, Button, Cascader, Form, Input, Select, Space, Spin, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { get } from 'lodash';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { CollectionTemplateConfigureItemProps } from '../../plugin';
import { compileLegacyTemplate } from '../../utils/compileLegacyTemplate';
import { compileLegacyTemplate } from '@nocobase/plugin-data-source-manager/client-v2';
export type SqlPreviewField = {
collection?: string;
@@ -247,6 +251,27 @@ function getSqlFieldInterfaceOptions(groups: ReturnType<typeof getFieldInterface
.filter((group) => group.options.length);
}
function getSqlSyncErrorMessage(error: unknown, fallback: string) {
const typedError = error as
| {
message?: string;
response?: {
data?: {
errors?: Array<{ message?: string }>;
};
};
}
| undefined;
return (
typedError?.response?.data?.errors
?.map((item) => item.message)
.filter(Boolean)
.join('\n') ||
typedError?.message ||
fallback
);
}
export function buildSqlFieldsFromPreview(options: {
currentFields?: SqlFieldRecord[];
manager?: FieldInterfaceManager;
@@ -339,7 +364,7 @@ function SqlStatementControl(props: {
onChange?.(event.target.value);
}}
/>
<Button.Group>
<Space size={8} style={{ marginTop: 4 }}>
<Button
ghost
icon={<EditOutlined />}
@@ -353,7 +378,7 @@ function SqlStatementControl(props: {
<Button ghost icon={<RightSquareOutlined />} loading={loading} size="small" type="primary" onClick={onExecute}>
{t('Execute')}
</Button>
</Button.Group>
</Space>
</div>
);
}
@@ -791,3 +816,102 @@ export function normalizeSqlCollectionSubmitValues(values: Record<string, unknow
export function getSqlPreviewInternalName() {
return internalPreviewName;
}
export function SqlSyncFieldsDrawer(props: CollectionTemplateSyncFieldsProps) {
const ctx = useFlowContext();
const t = ctx.t;
const { message } = App.useApp();
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const previewName = getSqlPreviewInternalName();
useEffect(() => {
form.setFieldsValue(props.collection);
}, [form, props.collection]);
useEffect(() => {
let ignore = false;
const loadPreview = async () => {
const sql = props.collection.sql;
if (!sql) {
return;
}
try {
const response = await ctx.api.resource('sqlCollection').execute({
values: {
sql,
},
});
if (ignore) {
return;
}
const preview = normalizeSqlPreviewResult(response);
const currentSources = props.collection.sources || [];
const currentFields = props.collection.fields || [];
form.setFieldsValue({
sources: Array.from(
new Set([
...(Array.isArray(currentSources) ? currentSources : []),
...(Array.isArray(preview.sources) ? preview.sources : []),
]),
),
fields: buildSqlFieldsFromPreview({
currentFields: Array.isArray(currentFields) ? currentFields : [],
manager: ctx.dataSourceManager.collectionFieldInterfaceManager,
preview,
}),
[previewName]: preview,
});
} catch (error) {
if (!ignore) {
form.setFieldValue(previewName, {
error: getSqlSyncErrorMessage(error, t('SQL error')),
});
}
}
};
loadPreview();
return () => {
ignore = true;
};
}, [ctx.api, ctx.dataSourceManager.collectionFieldInterfaceManager, form, previewName, props.collection, t]);
const handleSubmit = useCallback(async () => {
const values = await form.validateFields();
setSubmitting(true);
try {
await ctx.api.resource('sqlCollection').setFields({
filterByTk: props.collection.name,
values: {
fields: values.fields,
sources: values.sources,
},
});
await ctx.dataSourceManager.getDataSource(props.dataSourceKey)?.reload();
props.onSubmitted();
message.success(t('Sync successfully'));
} finally {
setSubmitting(false);
}
}, [ctx.api, ctx.dataSourceManager, form, message, props, t]);
return (
<DrawerFormLayout
title={t('Sync from database')}
submitting={submitting}
submitText={t('Submit')}
cancelText={t('Cancel')}
onSubmit={handleSubmit}
>
<Form form={form} layout="vertical" initialValues={props.collection}>
<SqlSourceCollectionsConfigureItem form={form} mode="edit" template={{ title: '', name: 'sql' }} item={{}} />
<SqlFieldsConfigureItem form={form} mode="edit" template={{ title: '', name: 'sql' }} item={{}} />
<SqlPreviewConfigureItem form={form} mode="edit" template={{ title: '', name: 'sql' }} item={{}} />
</Form>
</DrawerFormLayout>
);
}
@@ -0,0 +1,12 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { default } from './plugin';
export * from './plugin';
export * from './SqlCollectionConfigure';
@@ -0,0 +1,72 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Application, Plugin } from '@nocobase/client-v2';
import type { PluginDataSourceManagerClientV2 } from '@nocobase/plugin-data-source-manager/client-v2';
import {
normalizeSqlCollectionSubmitValues,
SqlFieldsConfigureItem,
SqlPreviewConfigureItem,
SqlSourceCollectionsConfigureItem,
SqlStatementConfigureItem,
SqlSyncFieldsDrawer,
} from './SqlCollectionConfigure';
export class PluginCollectionSqlClientV2 extends Plugin<any, Application> {
async load() {
const dataSourceManager = (this.app.pm.get('@nocobase/plugin-data-source-manager') ||
this.app.pm.get('data-source-manager')) as PluginDataSourceManagerClientV2 | undefined;
dataSourceManager?.registerCollectionTemplate?.({
name: 'sql',
title: '{{t("SQL collection")}}',
order: 40,
color: 'yellow',
divider: true,
collection: {
options: {
template: 'sql',
},
fields: [],
},
capabilities: {
recordUniqueKey: true,
},
configure: {
items: [
{
name: 'sql',
Component: SqlStatementConfigureItem,
required: true,
},
{
name: 'sources',
Component: SqlSourceCollectionsConfigureItem,
},
{
name: 'fields',
Component: SqlFieldsConfigureItem,
required: true,
},
{
name: 'preview',
Component: SqlPreviewConfigureItem,
},
],
syncFields: {
visible: true,
Component: SqlSyncFieldsDrawer,
},
transformSubmitValues: normalizeSqlCollectionSubmitValues,
},
});
}
}
export default PluginCollectionSqlClientV2;
@@ -0,0 +1,2 @@
export * from './dist/client-v2';
export { default } from './dist/client-v2';
@@ -0,0 +1,3 @@
'use strict';
module.exports = require('./dist/client-v2/index.js');
@@ -13,6 +13,8 @@
"main": "dist/server/index.js",
"peerDependencies": {
"@nocobase/client": "2.x",
"@nocobase/client-v2": "2.x",
"@nocobase/plugin-data-source-manager": "2.x",
"@nocobase/server": "2.x",
"@nocobase/test": "2.x"
},
@@ -0,0 +1,11 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
export { default } from './plugin';
export * from './plugin';
@@ -0,0 +1,112 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { Application, Plugin } from '@nocobase/client-v2';
import type { CollectionTemplateField } from '@nocobase/client-v2';
import type { PluginDataSourceManagerClientV2 } from '@nocobase/plugin-data-source-manager/client-v2';
function getTreeCollectionFields(): CollectionTemplateField[] {
return [
{
interface: 'integer',
name: 'parentId',
type: 'bigInt',
title: '{{t("Parent ID")}}',
isForeignKey: true,
uiSchema: {
'x-read-pretty': true,
},
},
{
interface: 'm2o',
type: 'belongsTo',
name: 'parent',
title: '{{t("Parent")}}',
foreignKey: 'parentId',
treeParent: true,
onDelete: 'CASCADE',
componentProps: {
multiple: false,
fieldNames: {
label: 'id',
value: 'id',
},
},
},
{
interface: 'o2m',
type: 'hasMany',
name: 'children',
title: '{{t("Children")}}',
foreignKey: 'parentId',
treeChildren: true,
onDelete: 'CASCADE',
componentProps: {
multiple: true,
fieldNames: {
label: 'id',
value: 'id',
},
},
},
];
}
function normalizeTreeCollectionSubmitValues(values: Record<string, any>) {
if (!Array.isArray(values.fields)) {
return;
}
values.fields = values.fields.map((field) => {
if (!field.target && ['belongsToMany', 'belongsTo', 'hasMany', 'hasOne'].includes(field.type)) {
return { ...field, target: values.name };
}
return field;
});
const primaryKey = values.fields.find((field) => field.primaryKey);
const parentId = values.fields.find((field) => field.name === 'parentId' && field.isForeignKey);
if (primaryKey && parentId) {
const parentIdTitle = parentId.uiSchema?.title;
parentId.interface = primaryKey.interface;
parentId.type = primaryKey.type;
parentId.uiSchema = structuredClone(primaryKey.uiSchema || {});
parentId.uiSchema.title = parentIdTitle;
parentId.autoFill = false;
}
}
export class PluginCollectionTreeClientV2 extends Plugin<any, Application> {
async load() {
const dataSourceManager = (this.app.pm.get('@nocobase/plugin-data-source-manager') ||
this.app.pm.get('data-source-manager')) as PluginDataSourceManagerClientV2 | undefined;
dataSourceManager?.registerCollectionTemplate?.({
name: 'tree',
title: '{{t("Tree collection")}}',
order: 30,
color: 'blue',
collection: {
options: {
template: 'tree',
tree: 'adjacencyList',
},
fields: getTreeCollectionFields,
},
presetFields: {
disabledIncludes: ['id'],
},
configure: {
transformSubmitValues: normalizeTreeCollectionSubmitValues,
},
});
}
}
export default PluginCollectionTreeClientV2;
@@ -0,0 +1,583 @@
# v2 Data Source Manager 扩展说明
本文档说明 `plugin-data-source-manager/src/client-v2` 当前提供的扩展点,主要面向需要扩展外部数据源、字段 Interface、数据表模板的插件开发者。
v2 Data Source Manager 的实现原则是:
- 管理页使用 client-v2 / Ant Design 组件实现,不再依赖 v1 SchemaComponent / Formily 页面搭建方式。
- 页面跳转和抽屉统一使用 v2 的 `ctx.viewer` / `ctx.router`。
- 字段 Interface 和数据表模板由各自插件注册,Data Source Manager 只负责读取注册信息并渲染通用管理界面。
- v1 兼容入口仍存在,但新增 v2 扩展应优先使用本文档中的 v2 注册结构。
## 关键入口
- 插件入口:`plugin.tsx`
- 外部数据源表单:`components/DataSourceForm.tsx`
- 数据源列表页:`pages/DataSourcesPage.tsx`
- 数据表列表和创建/编辑:`pages/components/CollectionsPage.tsx`
- 字段列表和创建/编辑:`pages/components/FieldsPage.tsx`、`pages/components/FieldForm.tsx`
- 字段 Interface 配置类型:`field-interfaces/index.ts`
- 数据表模板字段归一化:`packages/core/client-v2/src/collection-manager/template-fields.ts`
- 字段配置项通用工具:`packages/core/client-v2/src/collection-manager/field-configure.ts`
## 1. 扩展外部数据源
外部数据源类型由数据源插件在 client-v2 中注册:
```tsx
import React from 'react';
import { Form, Input, Select } from 'antd';
import { Plugin } from '@nocobase/client-v2';
import PluginDataSourceManagerClientV2 from '@nocobase/plugin-data-source-manager/client-v2';
function MyDataSourceSettingsForm(props) {
return (
<>
<Form.Item name={['options', 'host']} label="Host" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name={['options', 'database']} label="Database" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="collections" label="Collections">
<Select
mode="multiple"
options={[]}
onFocus={async () => {
const tables = await props.loadCollections(props.initialValues?.key);
// 根据插件需要把 tables 转成 Select options 或自定义表格数据。
}}
/>
</Form.Item>
</>
);
}
export class PluginMyDataSourceClientV2 extends Plugin {
async load() {
const dataSourceManagerPlugin = this.app.pm.get(PluginDataSourceManagerClientV2);
dataSourceManagerPlugin.registerType('myDataSource', {
label: '{{t("My data source", { ns: "my-data-source" })}}',
defaultValues: {
type: 'myDataSource',
enabled: true,
options: {},
},
SettingsForm: MyDataSourceSettingsForm,
disableTestConnection: false,
disableAddFields: false,
});
}
}
```
`registerType(name, options)` 的常用字段:
- `name`:数据源类型名,通常由第一个参数传入。
- `label`:在 “Add new” 下拉和 Type 列显示的文案,支持 `{{t(...)}}`。
- `defaultValues`:创建数据源时写入表单的默认值。
- `SettingsForm`:当前数据源类型自己的配置表单,使用原生 Ant Design `Form.Item`。字段值会和基础字段一起提交给 `dataSources:create/update`。
- `disableTestConnection`:隐藏 “Test Connection” 按钮。
- `disableAddFields`:给后续管理逻辑识别是否允许添加字段。
`SettingsForm` 接收的 props:
- `mode`:`create` 或 `edit`。
- `type`:当前注册的 `DataSourceTypeOptions`。
- `initialValues`:编辑时的初始数据,创建时包含 `defaultValues`、`type`、自动生成的 `key`。
- `loadCollections(key)`:调用 `dataSources:readTables` 读取外部库表,通常用于选择需要纳入管理的数据表。
客户端注册只负责管理页 UI 和运行时同步。完整外部数据源还需要服务端提供对应的 data source 类型、连接测试、读取表、加载 collections 等能力。
运行时同步逻辑在 `runtime.ts`:
- `dataSources:listEnabled` 返回的数据会同步到 `ctx.dataSourceManager`。
- `main` 数据源保留已有实现。
- 外部数据源默认使用 flow-engine 的 `DataSource` 类,并把接口返回的 `collections` 写入运行时。
## 2. 注册字段 Interface
字段 Interface 由字段插件注册到 client-v2 的 `dataSourceManager.collectionFieldInterfaceManager`。常规做法是定义一个继承 `CollectionFieldInterface` 的类,并在插件 `load()` 中调用 `this.app.addFieldInterfaces()`。
```tsx
import { CollectionFieldInterface, Plugin } from '@nocobase/client-v2';
export class RatingFieldInterface extends CollectionFieldInterface {
name = 'rating';
group = 'basic';
order = 50;
title = '{{t("Rating", { ns: "plugin-field-rating" })}}';
sortable = true;
availableTypes = ['integer'];
hasDefaultValue = true;
titleUsable = true;
filterable = {
operators: 'number',
};
default = {
interface: 'rating',
type: 'integer',
uiSchema: {
type: 'number',
'x-component': 'InputNumber',
'x-component-props': {
min: 1,
max: 5,
},
},
};
configure = {
items: [
{
name: 'uiSchema.x-component-props.max',
title: '{{t("Max rating", { ns: "plugin-field-rating" })}}',
component: 'InputNumber',
defaultValue: 5,
required: true,
},
{
name: 'defaultValue',
title: '{{t("Default value")}}',
component: 'InputNumber',
hidden: ({ values }) => values.primaryKey || values.unique,
},
],
normalizeValues(values) {
return {
...values,
type: 'integer',
};
},
};
}
export class PluginFieldRatingClientV2 extends Plugin {
async load() {
this.app.addFieldInterfaces([RatingFieldInterface]);
}
}
```
字段 Interface 常用字段:
- `name`:字段 Interface 唯一标识,保存到字段的 `interface`。
- `group` / `order`:添加字段时的分组和排序。
- `title` / `description`:添加字段界面显示文案,支持 `{{t(...)}}`。
- `default`:创建字段时的基础提交值,通常包含 `interface`、`type`、`uiSchema`。
- `availableTypes`:SQL / View 等场景下按存储类型筛选可选 Interface。
- `supportDataSourceType` / `notSupportDataSourceType`:按数据源类型控制是否显示。
- `hasDefaultValue`:表示该 Interface 支持默认值配置。
- `filterable`:筛选操作符配置,例如 `{ operators: 'number' }`、`{ operators: 'string' }`。
- `titleUsable`:是否可作为 title field。
- `isAssociation`:关系字段标识,创建时会自动补 `source`。
### configure.items
v2 新增字段配置应优先使用 `configure.items`,不要新增 v1 Formily schema 配置。`FieldForm` 会读取 `getFieldInterfaceConfigure()` 的结果并渲染这些配置项。
`configure.items` 中每一项常用字段:
- `name`:表单字段路径,支持点路径,如 `uiSchema.x-component-props.max`。
- `title` / `description`:表单项文案。
- `component`:内置控件名,如 `Input`、`Input.TextArea`、`InputNumber`、`Checkbox`、`Select`、`Radio.Group`、`DatePicker`、`ColorPicker`、`ArrayTable`、`FieldValidation`、`CollectionSelect` 等。
- `Component`:自定义 React 组件。复杂交互建议放在字段插件内部实现,然后通过这里注册。
- `componentProps`:传给控件的属性。
- `options`:`Select`、`Radio.Group` 等控件选项。
- `defaultValue`:创建字段时的默认配置值。
- `required`:是否必填。
- `hidden` / `disabled`:布尔值、表达式字符串,或函数。函数会收到当前表单值和运行时上下文。
- `effect`:配置项联动逻辑。
- `schema`:保留给兼容和特殊数据源场景,例如 `schema.enum = '{{collections}}'` 会触发加载 collections。
- `layout`:用于配置项横向布局。
复杂组件示例可以参考:
- Sequence:`plugin-field-sequence/src/client-v2/interface.tsx`
- Formula:`plugin-field-formula/src/client-v2/interfaces/formula.tsx`
- Markdown Vditor:`plugin-field-markdown-vditor/src/client-v2/interface.tsx`
### 追加或覆盖已有 Interface 配置
如果插件不拥有字段 Interface 类本身,但需要给某个 Interface 追加 v2 配置,可以通过 Data Source Manager 插件注册:
```tsx
const dataSourceManagerPlugin = this.app.pm.get(PluginDataSourceManagerClientV2);
dataSourceManagerPlugin.registerFieldInterfaceConfigure({
name: 'input',
items: [
{
name: 'uiSchema.x-component-props.placeholder',
title: '{{t("Placeholder")}}',
component: 'Input',
},
],
});
```
如果目标 Interface 尚未注册,配置会先暂存,等 Interface 注册后自动合并。合并规则是同名 `items.name` 后注册覆盖先注册。
### 字段值提交流程
字段创建/编辑时,`FieldForm` 的处理顺序大致是:
1. 读取 Interface 的 `default` 和 `configure.items` 生成初始值。
2. 应用 `items.defaultValue`。
3. 执行 core 通用联动,例如 primary/unique/defaultValue/date format。
4. 提交前执行 `configure.validate()`。
5. 执行 `configure.normalizeValues()` 或 `configure.normalize()`。
6. 执行字段 Interface 的 `initialize()` 和 `configure.initialize()`。
7. 调用字段接口:
- 主数据源:`collections/{collection}/fields:create/update`
- 外部数据源:`dataSourcesCollections/{dataSourceKey}.{collection}/fields:create/update`
## 3. 注册数据表模板
数据表模板由 Data Source Manager 插件维护。插件可以调用 `registerCollectionTemplate()` 注册自己的模板。
```tsx
const dataSourceManagerPlugin = this.app.pm.get(PluginDataSourceManagerClientV2);
dataSourceManagerPlugin.registerCollectionTemplate({
name: 'calendar',
title: '{{t("Calendar collection", { ns: "calendar" })}}',
description: '{{t("Store calendar events", { ns: "calendar" })}}',
order: 20,
color: 'orange',
collection: {
options: {
template: 'calendar',
createdBy: true,
updatedBy: true,
createdAt: true,
updatedAt: true,
sortable: true,
},
fields: [
{
name: 'cron',
interface: 'select',
type: 'string',
title: '{{t("Repeats", { ns: "calendar" })}}',
component: 'Select',
componentProps: {
allowClear: true,
},
options: [
{ label: '{{t("Daily", { ns: "calendar" })}}', value: '0 0 0 * * ?' },
{ label: '{{t("Weekly", { ns: "calendar" })}}', value: 'every_week' },
{ label: '{{t("Monthly", { ns: "calendar" })}}', value: 'every_month' },
{ label: '{{t("Yearly", { ns: "calendar" })}}', value: 'every_year' },
],
},
{
name: 'exclude',
interface: 'json',
type: 'json',
},
],
},
fieldInterfaces: {
create: {
exclude: ['tableoid'],
},
},
presetFields: {
disabledIncludes: ['id'],
},
});
```
模板常用字段:
- `name`:模板唯一标识,会写入 collection 的 `template`。
- `title` / `description`:创建数据表时显示的说明。
- `order` / `divider`:模板列表排序和分隔线。
- `color`:保留字段,当前 v2 创建界面不强调展示颜色。
- `collection.options`:创建数据表时合并到提交值里的 collection 选项。
- `collection.fields`:模板默认创建的字段。
- `capabilities.recordUniqueKey`:显示 “Record unique key” 配置。
- `capabilities.simplePaginate`:模板支持 simple paginate。
- `fieldInterfaces`:控制该模板下字段 Interface 可见范围。
- `presetFields`:控制通用预设字段是否可选。
- `configure.items`:模板自己的创建/编辑配置项。
- `configure.Form`:模板自己的完整表单片段。
- `configure.transformSubmitValues`:提交前转换数据。
### collection.fields 的写法
模板字段可以写成更语义化的结构,Data Source Manager 会在提交前转换成接口需要的字段格式。
```ts
{
name: 'status',
interface: 'select',
type: 'string',
title: '{{t("Status")}}',
component: 'Select',
componentProps: {
allowClear: true,
},
options: [
{ label: '{{t("Open")}}', value: 'open' },
{ label: '{{t("Closed")}}', value: 'closed' },
],
}
```
转换规则:
- `title` -> `uiSchema.title`
- `options` -> `uiSchema.enum`
- `component` -> `uiSchema['x-component']`
- `componentProps` -> `uiSchema['x-component-props']`
- 如果字段有 `interface`,会先合并该 Interface 的 `default`,再合并模板字段自身配置。
因此模板作者只需要写模板特有字段,通用预设字段如 `id`、`createdAt`、`createdBy`、`updatedAt`、`updatedBy` 由预设字段机制统一处理,不需要在每个模板里重复写。
### configure.items 示例
SQL / View 这类模板可以在所属插件里使用 `configure.items` 提供复杂配置:
```tsx
dataSourceManagerPlugin.registerCollectionTemplate({
name: 'sql',
title: '{{t("SQL collection")}}',
order: 40,
collection: {
options: {
template: 'sql',
},
fields: [],
},
capabilities: {
recordUniqueKey: true,
},
configure: {
items: [
{
name: 'sql',
Component: SqlStatementConfigureItem,
required: true,
},
{
name: 'sources',
Component: SqlSourceCollectionsConfigureItem,
},
{
name: 'fields',
Component: SqlFieldsConfigureItem,
required: true,
},
{
name: 'preview',
Component: SqlPreviewConfigureItem,
},
],
syncFields: {
visible: true,
Component: SqlSyncFieldsDrawer,
},
transformSubmitValues: normalizeSqlCollectionSubmitValues,
},
});
```
`configure.items` 会同时用于创建和编辑。可以通过 `hidden: ({ mode }) => mode === 'edit'` 控制不同状态下的显示。
`configure.syncFields` 用于扩展 Configure fields 抽屉里的 `Sync from database` 行为,适合 SQL collection 这类需要自定义同步字段交互的模板。
### fieldInterfaces 限制
模板可以限制字段列表和添加字段时可用的 Interface:
```ts
fieldInterfaces: {
include: ['input', 'integer', 'datetime'],
exclude: ['tableoid'],
create: {
include: ['m2o'],
},
}
```
- `include` / `exclude` 控制字段列表和字段配置中可见 Interface。
- `create.include` / `create.exclude` 控制添加字段时可选 Interface。
- `systemInfo` 分组字段有特殊处理:例如 `createdAt` 是否显示会参考 collection 上的同名布尔配置;`tableoid` 默认只在 PostgreSQL 下显示,除非模板显式 include。
### 预设字段扩展
通用预设字段通过 `registerCollectionPresetField()` 注册:
```tsx
dataSourceManagerPlugin.registerCollectionPresetField({
name: 'space',
order: 600,
defaultSelected: true,
description: '{{t("Store record space")}}',
value: {
name: 'space',
interface: 'space',
type: 'belongsTo',
target: 'spaces',
foreignKey: 'spaceName',
onDelete: 'SET NULL',
uiSchema: {
type: 'object',
title: '{{t("Space", { ns: "@nocobase/plugin-multi-space" })}}',
'x-component': 'AssociationField',
'x-component-props': {
fieldNames: {
value: 'name',
label: 'title',
},
},
},
},
});
```
模板可以通过 `presetFields.disabled` 禁用全部预设字段选择,或通过 `presetFields.disabledIncludes` 禁用部分预设字段。
## 其他扩展点和注意事项
### 管理页 Action 扩展
`extensionManager.registerManagerAction()` 可向数据源管理页补充顶部操作按钮:
```tsx
dataSourceManagerPlugin.extensionManager.registerManagerAction({
order: 100,
component: MyManagerAction,
});
```
### 筛选操作符
字段 Interface 的 `filterable` 用于告诉 v2 筛选组件、筛选区块、变量筛选等场景:这个字段是否可筛选,以及应该展示哪些操作符。
推荐写法是引用 core client-v2 中注册的 operator group,而不是在每个字段 Interface 里重复写完整操作符数组:
```ts
filterable = {
operators: 'number',
};
```
core client-v2 当前常用 operator group:
| group | 适用字段 | 典型操作符 |
| --- | --- | --- |
| `string` | 单行文本、邮箱、手机号、URL、UUID、NanoID、密码等 | contains / does not contain / is / is not / is empty |
| `number` | integer、number、percent、sort、snowflakeId 等 | = / != / > / >= / < / <= / is empty |
| `datetime` | datetime、dateOnly、createdAt、updatedAt、unixTimestamp 等 | is / is before / is after / is between / is empty |
| `enumType` | select、radioGroup 等单选枚举 | is / is not / is any of / is none of |
| `array` | multipleSelect、checkboxGroup 等多值字段 | is / is not / is any of / is none of |
| `boolean` | checkbox 等布尔字段 | Yes / No / is empty |
| `id` | id 字段 | is / is not / exists / not exists |
| `time` | time 字段 | is / is not / is empty |
| `bigField` | markdown、richText、code、attachment-url 等大文本或长内容字段 | contains |
| `collection` | collection selector 字段 | is / is not / is any of / is none of |
| `tableoid` | tableoid 字段 | is any of / is none of |
动态类型字段可以使用 `createTypedFilterable()`。例如 Formula 字段的返回类型可能是 boolean、string、date 或 number,不同类型应该显示不同操作符:
```ts
import { createTypedFilterable } from '@nocobase/client-v2';
filterable = createTypedFilterable(
[
{ types: ['boolean'], operators: 'boolean' },
{ types: ['string'], operators: 'string' },
{ types: ['date'], operators: 'datetime' },
{ types: ['integer', 'double', 'bigInt', 'number'], operators: 'number' },
],
(meta) => meta?.dataType,
);
```
如果插件需要特殊操作符,应先注册操作符或操作符组,再在字段 Interface 里引用:
```ts
export class PluginFieldFormulaClient extends Plugin {
async load() {
this.app.registerFieldFilterOperatorGroup('formulaDate', [
{
label: '{{t("is current fiscal year", { ns: "field-formula" })}}',
value: '$isCurrentFiscalYear',
noValue: true,
},
{
label: '{{t("is previous fiscal year", { ns: "field-formula" })}}',
value: '$isPreviousFiscalYear',
noValue: true,
},
]);
this.app.addFieldInterfaces([FormulaFieldInterface]);
}
}
export class FormulaFieldInterface extends CollectionFieldInterface {
name = 'formula';
filterable = {
operators: 'formulaDate',
};
}
```
也可以先注册单个 operator,再用 operator 名组装 group:
```ts
this.app.registerFieldFilterOperator({
label: '{{t("matches regex")}}',
value: '$regex',
schema: {
'x-component': 'Input',
},
});
this.app.registerFieldFilterOperatorGroup('advancedString', ['$regex']);
```
如果这个操作符还会用于联动规则,需要同时注册前端 JSON Logic 操作。操作符注册只决定下拉列表里能不能选到,联动规则的条件判断是在前端通过 `app.jsonLogic.apply()` 执行的:
```ts
this.app.registerFieldFilterOperator({
label: '{{t("is any of")}}',
value: '$isAnyOf',
schema: {
'x-component': 'Select',
'x-component-props': {
mode: 'multiple',
},
},
});
this.app.registerFieldFilterOperatorGroup('customSelect', ['$isAnyOf']);
this.app.jsonLogic.addOperation('$isAnyOf', (left, right) => {
const values = Array.isArray(right) ? right : [right];
return values.includes(left);
});
```
如果同一个操作符还要用于数据表查询、区块筛选或服务端资源查询,还需要在服务端注册对应的 database operator。
注意事项:
- `filterable` 为空或不设置时,该字段通常不会出现在依赖 filterable 元数据的筛选列表中。
- 如果字段明确不允许筛选,可以设置 `filterable = false`。
- `operator.value` 用于生成筛选条件;如果用于服务端查询,必须有后端查询层能识别的 database operator;如果用于联动规则,必须有前端 `jsonLogic` operation。
- `operator.schema` 用于控制该操作符对应的值输入组件;`noValue: true` 表示该操作符不需要输入值,例如 empty、exists。
- 注册自定义 group 时应尽量使用插件命名空间语义,避免和 core group 重名。
### 与 v1 的边界
- v2 新代码不要 import `@nocobase/client`,应使用 `@nocobase/client-v2`、`@nocobase/flow-engine` 和 Ant Design。
- 新字段配置优先写 `configure.items` / `Component`,不要继续扩展 v1 schema family 的实现。
- `properties`、`getConfigureFormProperties`、`availableFieldInterfaces`、`ConfigureForm`、`beforeSubmit` 等旧入口仍有兼容处理,但新扩展应使用 `configure.items`、`fieldInterfaces`、`configure.Form`、`configure.transformSubmitValues`。
- 复杂配置组件应放在对应字段插件或模板插件内注册,Data Source Manager 只消费注册结果。
@@ -12,6 +12,7 @@ import { tExpr as _tExpr, useFlowEngine } from '@nocobase/flow-engine';
import pkg from '../../package.json';
export const NAMESPACE = pkg.name;
export const DATA_SOURCE_MANAGER_SETTINGS_KEY = 'data-source-manager';
export function tExpr(key: string) {
return _tExpr(key, { ns: [NAMESPACE, 'client'] });
@@ -12,7 +12,7 @@ import { useRequest } from 'ahooks';
import { Breadcrumb, Select, Space, Spin, Tag } from 'antd';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { NAMESPACE, useT } from '../locale';
import { DATA_SOURCE_MANAGER_SETTINGS_KEY, useT } from '../locale';
import { compileLegacyTemplate } from '../utils/compileLegacyTemplate';
import CollectionsPage from './components/CollectionsPage';
@@ -48,8 +48,8 @@ function statusTag(t: (key: string) => string, value?: string) {
export default function DataSourceCollectionsPage() {
const t = useT();
const ctx = useFlowContext();
const params = useParams<{ dataSourceKey?: string }>();
const routeDataSourceKey = decodeRouteParam(params.dataSourceKey);
const params = useParams<{ name?: string; dataSourceKey?: string }>();
const routeDataSourceKey = decodeRouteParam(params.name || params.dataSourceKey);
const [dataSourceKey, setDataSourceKey] = useState<string>('main');
const currentDataSourceKey = routeDataSourceKey || dataSourceKey;
const request = useRequest(async () => {
@@ -67,7 +67,7 @@ export default function DataSourceCollectionsPage() {
[t],
);
const dataSourcesPath = useMemo(
() => ctx.app.pluginSettingsManager.getRoutePath(`${NAMESPACE}.index`),
() => ctx.app.pluginSettingsManager.getRoutePath(`${DATA_SOURCE_MANAGER_SETTINGS_KEY}.list`),
[ctx.app.pluginSettingsManager],
);
const handleBackToDataSources = useCallback(() => {
@@ -15,7 +15,7 @@ import { App, Button, Card, Dropdown, Empty, Space, Switch, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import React, { useCallback, useMemo, useState } from 'react';
import { DataSourceForm } from '../components/DataSourceForm';
import { NAMESPACE, useT } from '../locale';
import { DATA_SOURCE_MANAGER_SETTINGS_KEY, useT } from '../locale';
import { PluginDataSourceManagerClientV2 } from '../plugin';
import { removeDataSourcesFromRuntime, syncDataSourcesToRuntime } from '../runtime';
import { compileLegacyTemplate } from '../utils/compileLegacyTemplate';
@@ -111,8 +111,8 @@ export default function DataSourcesPage() {
const openCollections = useCallback(
(record: DataSourceRecord) => {
const path = ctx.app.pluginSettingsManager
.getRoutePath(`${NAMESPACE}.collections/:dataSourceKey`)
.replace(':dataSourceKey', encodeURIComponent(record.key));
.getRoutePath(`${DATA_SOURCE_MANAGER_SETTINGS_KEY}.:name/collections`)
.replace(':name', encodeURIComponent(record.key));
ctx.router.navigate(path);
},
@@ -158,7 +158,11 @@ export default function DataSourcesPage() {
value === 'main' ? t('Main') : compileLegacyTemplate(plugin.getType(value)?.label || value || '-', t),
},
{ title: t('Status'), dataIndex: 'status', render: (value: string) => statusTag(t, value) },
{ title: t('Enabled'), dataIndex: 'enabled', render: (value: boolean) => <Switch checked={value} disabled /> },
{
title: t('Enabled'),
dataIndex: 'enabled',
render: (value: boolean) => <Switch checked={value} disabled size="small" />,
},
{
title: t('Actions'),
width: 220,
@@ -39,14 +39,6 @@ import {
filterFieldInterfacesByCollectionTemplate,
} from './collectionTemplateFieldInterfaces';
import { FieldForm } from './FieldForm';
import {
buildSqlFieldsFromPreview,
getSqlPreviewInternalName,
normalizeSqlPreviewResult,
SqlFieldsConfigureItem,
SqlPreviewConfigureItem,
SqlSourceCollectionsConfigureItem,
} from './SqlCollectionConfigure';
interface FieldsPageProps {
dataSourceKey: string;
@@ -228,23 +220,39 @@ function getCollectionUpdateActionUrl(dataSourceKey: string) {
return dataSourceKey === 'main' ? 'collections:update' : `dataSources/${dataSourceKey}/collections:update`;
}
function isSqlCollection(collection: Record<string, any>) {
return collection.template === 'sql';
}
function isViewCollection(collection: Record<string, any>) {
return collection.template === 'view' || collection.view;
}
function isSyncFieldsVisible(dataSourceKey: string, collection: Record<string, any>) {
if (isSqlCollection(collection)) {
return true;
}
function getCollectionTemplate(ctx: any, collection: Record<string, any>) {
const plugin = ctx.app.pm.get(PluginDataSourceManagerClientV2);
return plugin?.getCollectionTemplate?.(collection.template || 'general');
}
function isTemplateSyncFieldsVisible(options: { collection: Record<string, any>; ctx: any; dataSourceKey: string }) {
const template = getCollectionTemplate(options.ctx, options.collection);
const syncFields = template?.configure?.syncFields;
if (!syncFields) {
return false;
}
if (typeof syncFields.visible === 'function') {
return syncFields.visible({
collection: options.collection,
dataSourceKey: options.dataSourceKey,
});
}
return syncFields.visible !== false;
}
function isSyncFieldsVisible(dataSourceKey: string, collection: Record<string, any>, ctx: any) {
if (isViewCollection(collection)) {
return dataSourceKey === 'main';
}
if (isTemplateSyncFieldsVisible({ collection, ctx, dataSourceKey })) {
return true;
}
if (collection.from === 'db2cm') {
return false;
}
@@ -267,27 +275,6 @@ function isAddFieldVisible(options: {
);
}
function getSqlSyncErrorMessage(error: unknown, fallback: string) {
const typedError = error as
| {
message?: string;
response?: {
data?: {
errors?: Array<{ message?: string }>;
};
};
}
| undefined;
return (
typedError?.response?.data?.errors
?.map((item) => item.message)
.filter(Boolean)
.join('\n') ||
typedError?.message ||
fallback
);
}
function getViewName(collection: Record<string, any>) {
return collection.viewName || collection.name;
}
@@ -738,105 +725,6 @@ function ViewSyncFieldsDrawer(props: {
);
}
function SqlSyncFieldsDrawer(props: { collection: Record<string, any>; onSubmitted: () => void }) {
const t = useT();
const ctx = useFlowContext();
const { message } = App.useApp();
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const previewName = getSqlPreviewInternalName();
useEffect(() => {
form.setFieldsValue(props.collection);
}, [form, props.collection]);
useEffect(() => {
let ignore = false;
const loadPreview = async () => {
const sql = props.collection.sql;
if (!sql) {
return;
}
try {
const response = await ctx.api.resource('sqlCollection').execute({
values: {
sql,
},
});
if (ignore) {
return;
}
const preview = normalizeSqlPreviewResult(response);
const currentSources = props.collection.sources || [];
const currentFields = props.collection.fields || [];
form.setFieldsValue({
sources: Array.from(
new Set([
...(Array.isArray(currentSources) ? currentSources : []),
...(Array.isArray(preview.sources) ? preview.sources : []),
]),
),
fields: buildSqlFieldsFromPreview({
currentFields: Array.isArray(currentFields) ? currentFields : [],
manager: ctx.dataSourceManager.collectionFieldInterfaceManager,
preview,
}),
[previewName]: preview,
});
} catch (error) {
if (!ignore) {
form.setFieldValue(previewName, {
error: getSqlSyncErrorMessage(error, t('SQL error')),
});
}
}
};
loadPreview();
return () => {
ignore = true;
};
}, [ctx.api, ctx.dataSourceManager.collectionFieldInterfaceManager, form, previewName, props.collection, t]);
const handleSubmit = useCallback(async () => {
const values = await form.validateFields();
setSubmitting(true);
try {
await ctx.api.resource('sqlCollection').setFields({
filterByTk: props.collection.name,
values: {
fields: values.fields,
sources: values.sources,
},
});
await ctx.dataSourceManager.getDataSource('main')?.reload();
props.onSubmitted();
message.success(t('Sync successfully'));
} finally {
setSubmitting(false);
}
}, [ctx.api, ctx.dataSourceManager, form, message, props, t]);
return (
<DrawerFormLayout
title={t('Sync from database')}
submitting={submitting}
submitText={t('Submit')}
cancelText={t('Cancel')}
onSubmit={handleSubmit}
>
<Form form={form} layout="vertical" initialValues={props.collection}>
<SqlSourceCollectionsConfigureItem form={form} mode="edit" template={{ title: '', name: 'sql' }} item={{}} />
<SqlFieldsConfigureItem form={form} mode="edit" template={{ title: '', name: 'sql' }} item={{}} />
<SqlPreviewConfigureItem form={form} mode="edit" template={{ title: '', name: 'sql' }} item={{}} />
</Form>
</DrawerFormLayout>
);
}
export default function FieldsPage(props: FieldsPageProps) {
const t = useT();
const ctx = useFlowContext();
@@ -931,20 +819,27 @@ export default function FieldsPage(props: FieldsPageProps) {
[fieldInterfaceGroups, openFieldForm, t],
);
const openSqlSyncFieldsDrawer = useCallback(() => {
const collectionTemplate = useMemo(() => getCollectionTemplate(ctx, props.collection), [ctx, props.collection]);
const TemplateSyncFieldsDrawer = collectionTemplate?.configure?.syncFields?.Component;
const openTemplateSyncFieldsDrawer = useCallback(() => {
if (!TemplateSyncFieldsDrawer) {
return;
}
ctx.viewer.drawer({
width: 900,
closable: true,
content: () => (
<SqlSyncFieldsDrawer
<TemplateSyncFieldsDrawer
collection={props.collection}
dataSourceKey={props.dataSourceKey}
onSubmitted={() => {
request.refresh();
}}
/>
),
});
}, [ctx.viewer, props.collection, request]);
}, [TemplateSyncFieldsDrawer, ctx.viewer, props.collection, props.dataSourceKey, request]);
const openViewSyncFieldsDrawer = useCallback(() => {
ctx.viewer.drawer({
@@ -1038,8 +933,8 @@ export default function FieldsPage(props: FieldsPageProps) {
);
const handleSyncFields = useCallback(async () => {
if (isSqlCollection(props.collection)) {
openSqlSyncFieldsDrawer();
if (TemplateSyncFieldsDrawer) {
openTemplateSyncFieldsDrawer();
return;
}
@@ -1065,14 +960,15 @@ export default function FieldsPage(props: FieldsPageProps) {
ctx.api,
ctx.dataSourceManager,
message,
openSqlSyncFieldsDrawer,
openTemplateSyncFieldsDrawer,
openViewSyncFieldsDrawer,
props.collection,
request,
TemplateSyncFieldsDrawer,
t,
]);
const syncFieldsVisible = isSyncFieldsVisible(props.dataSourceKey, props.collection);
const syncFieldsVisible = isSyncFieldsVisible(props.dataSourceKey, props.collection, ctx);
const addFieldVisible = isAddFieldVisible({
collection: props.collection,
ctx,
@@ -10,14 +10,7 @@
import { Application, Plugin, type CollectionTemplateField } from '@nocobase/client-v2';
import React, { ComponentType } from 'react';
import { FieldInterfaceConfigureOptions } from './field-interfaces';
import { NAMESPACE } from './locale';
import {
normalizeSqlCollectionSubmitValues,
SqlFieldsConfigureItem,
SqlPreviewConfigureItem,
SqlSourceCollectionsConfigureItem,
SqlStatementConfigureItem,
} from './pages/components/SqlCollectionConfigure';
import { DATA_SOURCE_MANAGER_SETTINGS_KEY } from './locale';
import {
normalizeViewCollectionSubmitValues,
ViewDatabaseConfigureItem,
@@ -69,6 +62,17 @@ export interface CollectionTemplateConfigureItemProps extends CollectionTemplate
item: CollectionTemplateConfigureItem;
}
export interface CollectionTemplateSyncFieldsProps {
collection: Record<string, any>;
dataSourceKey: string;
onSubmitted: () => void;
}
export interface CollectionTemplateSyncFieldsOptions {
Component?: ComponentType<CollectionTemplateSyncFieldsProps>;
visible?: boolean | ((props: { collection: Record<string, any>; dataSourceKey: string }) => boolean);
}
export interface CollectionTemplateConfigureItem {
name?: string;
label?: React.ReactNode;
@@ -98,6 +102,7 @@ export interface CollectionTemplateOptions {
configure?: {
items?: CollectionTemplateConfigureItem[];
Form?: ComponentType<CollectionTemplateFormProps>;
syncFields?: CollectionTemplateSyncFieldsOptions;
transformSubmitValues?: (values: Record<string, any>) => Record<string, any> | void;
};
fieldInterfaces?: {
@@ -230,7 +235,7 @@ export class PluginDataSourceManagerClientV2 extends Plugin<any, Application> {
});
this.pluginSettingsManager.addMenuItem({
key: NAMESPACE,
key: DATA_SOURCE_MANAGER_SETTINGS_KEY,
title: this.t('Data sources'),
icon: 'ClusterOutlined',
isPinned: true,
@@ -240,8 +245,8 @@ export class PluginDataSourceManagerClientV2 extends Plugin<any, Application> {
});
this.pluginSettingsManager.addPageTabItem({
menuKey: NAMESPACE,
key: 'index',
menuKey: DATA_SOURCE_MANAGER_SETTINGS_KEY,
key: 'list',
title: this.t('Data sources'),
componentLoader: () => import('./pages/DataSourcesPage'),
sort: 1,
@@ -249,21 +254,12 @@ export class PluginDataSourceManagerClientV2 extends Plugin<any, Application> {
});
this.pluginSettingsManager.addPageTabItem({
menuKey: NAMESPACE,
key: 'collections',
title: this.t('Collections'),
componentLoader: () => import('./pages/DataSourceCollectionsPage'),
sort: 2,
aclSnippet: 'pm.data-source-manager',
});
this.pluginSettingsManager.addPageTabItem({
menuKey: NAMESPACE,
key: 'collections/:dataSourceKey',
menuKey: DATA_SOURCE_MANAGER_SETTINGS_KEY,
key: ':name/collections',
title: this.t('Collections'),
componentLoader: () => import('./pages/DataSourceCollectionsPage'),
hidden: true,
sort: 3,
sort: 2,
aclSnippet: 'pm.data-source-manager',
});
}
@@ -431,130 +427,6 @@ export class PluginDataSourceManagerClientV2 extends Plugin<any, Application> {
},
});
this.registerCollectionTemplate({
name: 'tree',
title: '{{t("Tree collection")}}',
order: 30,
color: 'blue',
collection: {
options: {
template: 'tree',
tree: 'adjacencyList',
},
fields: [
{
interface: 'integer',
name: 'parentId',
type: 'bigInt',
title: '{{t("Parent ID")}}',
isForeignKey: true,
uiSchema: {
'x-read-pretty': true,
},
},
{
interface: 'm2o',
type: 'belongsTo',
name: 'parent',
title: '{{t("Parent")}}',
foreignKey: 'parentId',
treeParent: true,
onDelete: 'CASCADE',
componentProps: {
multiple: false,
fieldNames: {
label: 'id',
value: 'id',
},
},
},
{
interface: 'o2m',
type: 'hasMany',
name: 'children',
title: '{{t("Children")}}',
foreignKey: 'parentId',
treeChildren: true,
onDelete: 'CASCADE',
componentProps: {
multiple: true,
fieldNames: {
label: 'id',
value: 'id',
},
},
},
],
},
presetFields: {
disabledIncludes: ['id'],
},
configure: {
transformSubmitValues(values) {
if (!Array.isArray(values.fields)) {
return;
}
values.fields = values.fields.map((field) => {
if (!field.target && ['belongsToMany', 'belongsTo', 'hasMany', 'hasOne'].includes(field.type)) {
return { ...field, target: values.name };
}
return field;
});
const primaryKey = values.fields.find((field) => field.primaryKey);
const parentId = values.fields.find((field) => field.name === 'parentId' && field.isForeignKey);
if (primaryKey && parentId) {
const parentIdTitle = parentId.uiSchema?.title;
parentId.interface = primaryKey.interface;
parentId.type = primaryKey.type;
parentId.uiSchema = structuredClone(primaryKey.uiSchema || {});
parentId.uiSchema.title = parentIdTitle;
parentId.autoFill = false;
}
},
},
});
this.registerCollectionTemplate({
name: 'sql',
title: '{{t("SQL collection")}}',
order: 40,
color: 'yellow',
divider: true,
collection: {
options: {
template: 'sql',
},
fields: [],
},
capabilities: {
recordUniqueKey: true,
},
configure: {
items: [
{
name: 'sql',
Component: SqlStatementConfigureItem,
required: true,
},
{
name: 'sources',
Component: SqlSourceCollectionsConfigureItem,
},
{
name: 'fields',
Component: SqlFieldsConfigureItem,
required: true,
},
{
name: 'preview',
Component: SqlPreviewConfigureItem,
},
],
transformSubmitValues: normalizeSqlCollectionSubmitValues,
},
});
this.registerCollectionTemplate({
name: 'view',
title: '{{t("Connect to database view")}}',
@@ -7,8 +7,8 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { EnvVariableInput } from '@nocobase/client-v2';
import { Col, Form, InputNumber, Row, Select } from 'antd';
import { EnvVariableInput, TypedVariableInput } from '@nocobase/client-v2';
import { Col, Form, Row, Select } from 'antd';
import React from 'react';
import { useNotificationEmailTranslation } from '../locale';
@@ -27,7 +27,7 @@ export function ChannelConfigForm() {
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Col span={11}>
<Form.Item
label={t('Host')}
name={['options', 'host']}
@@ -37,14 +37,14 @@ export function ChannelConfigForm() {
<EnvVariableInput placeholder="smtp.example.com" />
</Form.Item>
</Col>
<Col span={4}>
<Col span={5}>
<Form.Item
label={t('Port')}
name={['options', 'port']}
initialValue={465}
rules={[{ required: true, message: t('The field value is required') }]}
>
<InputNumber min={1} max={65535} step={1} style={{ width: '100%' }} />
<TypedVariableInput types={[['number', { min: 1, max: 65535, step: 1 }]]} namespaces={['$env']} />
</Form.Item>
</Col>
<Col span={8}>
@@ -54,12 +54,7 @@ export function ChannelConfigForm() {
initialValue={true}
extra={t('In most cases, if using port 465, set it to true; otherwise, set it to false.')}
>
<Select
options={[
{ value: true, label: t('Yes') },
{ value: false, label: t('No') },
]}
/>
<TypedVariableInput types={['boolean']} namespaces={['$env']} />
</Form.Item>
</Col>
</Row>