mirror of
https://github.com/nocobase/nocobase.git
synced 2026-09-24 16:02:20 +08:00
Merge remote-tracking branch 'origin/next' into next
# Conflicts: # packages/plugins/@nocobase/plugin-workflow-javascript/src/server/ScriptInstruction.ts
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
+129
-5
@@ -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'] });
|
||||
|
||||
+4
-4
@@ -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(() => {
|
||||
|
||||
+8
-4
@@ -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
-143
@@ -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")}}',
|
||||
|
||||
+6
-11
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user