diff --git a/packages/core/cli-v1/nocobase.conf.tpl b/packages/core/cli-v1/nocobase.conf.tpl index 657a60161db..254c69bfbee 100644 --- a/packages/core/cli-v1/nocobase.conf.tpl +++ b/packages/core/cli-v1/nocobase.conf.tpl @@ -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; diff --git a/packages/core/client-v2/src/components/README.md b/packages/core/client-v2/src/components/README.md index e5279dfe19a..e190e3becc3 100644 --- a/packages/core/client-v2/src/components/README.md +++ b/packages/core/client-v2/src/components/README.md @@ -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 | 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 + + + + +// Secure mode — boolean constant + $env variable + + + +``` + +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. diff --git a/packages/core/client-v2/src/components/README.zh-CN.md b/packages/core/client-v2/src/components/README.zh-CN.md index aa76c6e8d84..17302aaa2aa 100644 --- a/packages/core/client-v2/src/components/README.zh-CN.md +++ b/packages/core/client-v2/src/components/README.zh-CN.md @@ -169,6 +169,54 @@ import { VariableInput, VariableTextArea } from '@nocobase/client-v2'; 底层共用 `VariableHybridInput`(`VariableInput`)和 `TextAreaWithContextSelector`(`VariableTextArea`),用同一套 MetaTree 数据。 +#### TypedVariableInput + +类型化常量 + 变量混合输入器。移植 v1 `Variable.Input` 的 `useTypedConstant` 形态:右侧斜体 `x` 按钮触发 Cascader 切换 `[空值 | 常量 | 变量和密钥<…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 变量 + + + + +// 安全模式:布尔常量 + $env 变量 + + + +``` + +主要属性: + +- `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)。 diff --git a/packages/core/client-v2/src/components/form/TypedVariableInput.tsx b/packages/core/client-v2/src/components/form/TypedVariableInput.tsx new file mode 100644 index 00000000000..dea061ee54b --- /dev/null +++ b/packages/core/client-v2/src/components/form/TypedVariableInput.tsx @@ -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]; + +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 = { + string: 'String', + number: 'Number', + boolean: 'Boolean', + date: 'Date', +}; + +type NormalizedType = { type: TypedConstantType; props: Record }; + +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; + disabled?: boolean; + placeholder?: string; + t: (text: string) => string; + }, +): React.ReactNode { + const { typedProps, disabled, placeholder, t } = options; + switch (type) { + case 'string': + return ( + onChange?.(ev.target.value)} + disabled={disabled} + placeholder={placeholder} + {...typedProps} + /> + ); + case 'number': + return ( + onChange?.(next)} + disabled={disabled} + placeholder={placeholder} + style={{ width: '100%' }} + {...typedProps} + /> + ); + case 'boolean': + return ( + `} readOnly disabled={disabled} style={{ width: '100%' }} /> + ) : ( + renderConstantEditor(constantTypeForRendering, value, onChange, { + typedProps, + disabled, + placeholder, + t, + }) + )} + + + options={switcherOptions} + onChange={onSwitcherChange} + disabled={disabled} + changeOnSelect + > + + + + ); +} + +export default TypedVariableInput; diff --git a/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx b/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx new file mode 100644 index 00000000000..3f318c7f2fe --- /dev/null +++ b/packages/core/client-v2/src/components/form/__tests__/TypedVariableInput.test.tsx @@ -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({node}); +} + +describe('TypedVariableInput - constant rendering', () => { + it('renders an InputNumber for a numeric value when types=[number]', async () => { + const ctx = createContextWithEnv(); + renderWithCtx( + ctx, + 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, + 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, + undefined} />, + ); + // Uses placeholder slot (not value) so antd's grey placeholder colour + // applies — see comment in TypedVariableInput.tsx. + const nullInput = await screen.findByPlaceholderText(''); + 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, + 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, + , + ); + 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, + , + ); + 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, ); + 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); + }); +}); diff --git a/packages/core/client-v2/src/components/form/index.tsx b/packages/core/client-v2/src/components/form/index.tsx index 00b5f5a3dc4..1be2a9152ae 100644 --- a/packages/core/client-v2/src/components/form/index.tsx +++ b/packages/core/client-v2/src/components/form/index.tsx @@ -16,4 +16,5 @@ export * from './FileSizeInput'; export * from './JsonTextArea'; export * from './PasswordInput'; export * from './RemoteSelect'; +export * from './TypedVariableInput'; export * from './VariableInput'; diff --git a/packages/plugins/@nocobase/plugin-collection-sql/client-v2.d.ts b/packages/plugins/@nocobase/plugin-collection-sql/client-v2.d.ts new file mode 100644 index 00000000000..f12b5649835 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-sql/client-v2.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client-v2'; +export { default } from './dist/client-v2'; diff --git a/packages/plugins/@nocobase/plugin-collection-sql/client-v2.js b/packages/plugins/@nocobase/plugin-collection-sql/client-v2.js new file mode 100644 index 00000000000..3d4c34c3f79 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-sql/client-v2.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./dist/client-v2/index.js'); diff --git a/packages/plugins/@nocobase/plugin-collection-sql/package.json b/packages/plugins/@nocobase/plugin-collection-sql/package.json index e9babd525f5..c98ab54d058 100644 --- a/packages/plugins/@nocobase/plugin-collection-sql/package.json +++ b/packages/plugins/@nocobase/plugin-collection-sql/package.json @@ -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" }, diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client-v2/pages/components/SqlCollectionConfigure.tsx b/packages/plugins/@nocobase/plugin-collection-sql/src/client-v2/SqlCollectionConfigure.tsx similarity index 86% rename from packages/plugins/@nocobase/plugin-data-source-manager/src/client-v2/pages/components/SqlCollectionConfigure.tsx rename to packages/plugins/@nocobase/plugin-collection-sql/src/client-v2/SqlCollectionConfigure.tsx index 387946d464a..ee5babc1c7c 100644 --- a/packages/plugins/@nocobase/plugin-data-source-manager/src/client-v2/pages/components/SqlCollectionConfigure.tsx +++ b/packages/plugins/@nocobase/plugin-collection-sql/src/client-v2/SqlCollectionConfigure.tsx @@ -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 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); }} /> - + - + ); } @@ -791,3 +816,102 @@ export function normalizeSqlCollectionSubmitValues(values: Record { + 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 ( + +
+ + + + +
+ ); +} diff --git a/packages/plugins/@nocobase/plugin-collection-sql/src/client-v2/index.tsx b/packages/plugins/@nocobase/plugin-collection-sql/src/client-v2/index.tsx new file mode 100644 index 00000000000..2c296e99d0f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-sql/src/client-v2/index.tsx @@ -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'; diff --git a/packages/plugins/@nocobase/plugin-collection-sql/src/client-v2/plugin.tsx b/packages/plugins/@nocobase/plugin-collection-sql/src/client-v2/plugin.tsx new file mode 100644 index 00000000000..6847f198e49 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-sql/src/client-v2/plugin.tsx @@ -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 { + 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; diff --git a/packages/plugins/@nocobase/plugin-collection-tree/client-v2.d.ts b/packages/plugins/@nocobase/plugin-collection-tree/client-v2.d.ts new file mode 100644 index 00000000000..f12b5649835 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/client-v2.d.ts @@ -0,0 +1,2 @@ +export * from './dist/client-v2'; +export { default } from './dist/client-v2'; diff --git a/packages/plugins/@nocobase/plugin-collection-tree/client-v2.js b/packages/plugins/@nocobase/plugin-collection-tree/client-v2.js new file mode 100644 index 00000000000..3d4c34c3f79 --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/client-v2.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('./dist/client-v2/index.js'); diff --git a/packages/plugins/@nocobase/plugin-collection-tree/package.json b/packages/plugins/@nocobase/plugin-collection-tree/package.json index 4f2bd470e55..c7864cb54ad 100644 --- a/packages/plugins/@nocobase/plugin-collection-tree/package.json +++ b/packages/plugins/@nocobase/plugin-collection-tree/package.json @@ -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" }, diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/client-v2/index.tsx b/packages/plugins/@nocobase/plugin-collection-tree/src/client-v2/index.tsx new file mode 100644 index 00000000000..0c0477d9a5b --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/client-v2/index.tsx @@ -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'; diff --git a/packages/plugins/@nocobase/plugin-collection-tree/src/client-v2/plugin.tsx b/packages/plugins/@nocobase/plugin-collection-tree/src/client-v2/plugin.tsx new file mode 100644 index 00000000000..00bb241770a --- /dev/null +++ b/packages/plugins/@nocobase/plugin-collection-tree/src/client-v2/plugin.tsx @@ -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) { + 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 { + 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; diff --git a/packages/plugins/@nocobase/plugin-data-source-manager/src/client-v2/README.md b/packages/plugins/@nocobase/plugin-data-source-manager/src/client-v2/README.md new file mode 100644 index 00000000000..688cf77c49f --- /dev/null +++ b/packages/plugins/@nocobase/plugin-data-source-manager/src/client-v2/README.md @@ -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 ( + <> + + + + + + + + +