diff --git a/packages/core/client/src/locale/zh_CN.ts b/packages/core/client/src/locale/zh_CN.ts index c92ac2bfd..1723168ed 100644 --- a/packages/core/client/src/locale/zh_CN.ts +++ b/packages/core/client/src/locale/zh_CN.ts @@ -158,6 +158,7 @@ export default { "Long text": "多行文本", "Phone": "手机号码", "Email": "电子邮箱", + 'Null': '空值', "Boolean": "逻辑值", "Number": "数字", "Integer": "整数", @@ -640,4 +641,10 @@ export default { 'Display page title': '显示页面标题', 'Edit page title': '编辑页面标题', 'Enable page tabs': '启用页面选项卡', + + 'Constant': '常量', + 'Use variable': '使用变量', + 'True': '真', + 'False': '假', + 'Prettify': '格式化' } diff --git a/packages/core/client/src/schema-component/antd/index.ts b/packages/core/client/src/schema-component/antd/index.ts index 9ed8b810b..d8ba6518a 100644 --- a/packages/core/client/src/schema-component/antd/index.ts +++ b/packages/core/client/src/schema-component/antd/index.ts @@ -36,4 +36,5 @@ export * from './tabs'; export * from './time-picker'; export * from './tree-select'; export * from './upload'; +export * from './variable'; import './index.less'; diff --git a/packages/plugins/workflow/src/client/components/VariableInput.tsx b/packages/core/client/src/schema-component/antd/variable/Input.tsx similarity index 85% rename from packages/plugins/workflow/src/client/components/VariableInput.tsx rename to packages/core/client/src/schema-component/antd/variable/Input.tsx index 345a599e8..31ce29f27 100644 --- a/packages/plugins/workflow/src/client/components/VariableInput.tsx +++ b/packages/core/client/src/schema-component/antd/variable/Input.tsx @@ -1,20 +1,18 @@ import React from "react"; import { useForm } from '@formily/react'; -import { Cascader, Input, Button, Tag, InputNumber, Select, DatePicker } from "antd"; +import { Cascader, Input as AntInput, Button, Tag, InputNumber, Select, DatePicker } from "antd"; import { CloseCircleFilled } from "@ant-design/icons"; import { cx, css } from "@emotion/css"; import { useTranslation } from "react-i18next"; import moment from "moment"; -import { useCompile } from "@nocobase/client"; - -import { lang, NAMESPACE } from "../locale"; +import { useCompile } from '../../hooks/useCompile'; -const JT_VALUE_RE = /^\s*\{\{([\s\S]*)\}\}\s*$/; +const JT_VALUE_RE = /^\s*{{\s*([^{}]+)\s*}}\s*$/; -export function parseValue(value: any): string | string[] { +function parseValue(value: any): string | string[] { if (value == null) { return 'null'; } @@ -36,11 +34,11 @@ export function parseValue(value: any): string | string[] { const ConstantTypes = { string: { - label: `{{t("String", { ns: "${NAMESPACE}" })}}`, + label: `{{t("String")}}`, value: 'string', component({ onChange, value }) { return ( - onChange(ev.target.value)} /> @@ -62,7 +60,7 @@ const ConstantTypes = { default: 0 }, boolean: { - label: `{{t("Boolean", { ns: "${NAMESPACE}" })}}`, + label: `{{t("Boolean")}}`, value: 'boolean', component({ onChange, value }) { const { t } = useTranslation(); @@ -72,8 +70,8 @@ const ConstantTypes = { onChange={onChange} placeholder={t('Select')} options={[ - { value: true, label: lang('True') }, - { value: false, label: lang('False') }, + { value: true, label: t('True') }, + { value: false, label: t('False') }, ]} /> ); @@ -81,7 +79,7 @@ const ConstantTypes = { default: false }, date: { - label: '日期', + label: '{{t("Date")}}', value: 'date', component({ onChange, value }) { return ( @@ -99,11 +97,12 @@ const ConstantTypes = { })(), }, null: { - label: `{{t("Null", { ns: "${NAMESPACE}" })}}`, + label: `{{t("Null")}}`, value: 'null', component() { + const { t } = useTranslation(); return ( - + ); }, default: null, @@ -116,19 +115,21 @@ type VariableOptions = { children?: VariableOptions[]; } -export function VariableInput(props) { +export function Input(props) { const { value = '', scope, onChange, children, button } = props; const parsed = parseValue(value); const isConstant = typeof parsed === 'string'; - const type = isConstant ? parsed : 'string'; + const type = isConstant ? parsed : ''; const variable = isConstant ? null : parsed; const ConstantComponent = ConstantTypes[type]?.component; const constantOptions = Object.values(ConstantTypes); const compile = useCompile(); + const { t } = useTranslation(); const options: VariableOptions[] = compile([ - { value: '', label: lang('Constant'), children: children ? null : constantOptions }, + { value: '', label: t('Constant'), children: children ? null : constantOptions }, ...(typeof scope === 'function' ? scope() : (scope ?? [])) ]); + const form = useForm(); function onSwitch(next) { @@ -155,7 +156,7 @@ export function VariableInput(props) { const disabled = props.disabled || form.disabled; return ( - {variable @@ -202,6 +207,7 @@ export function VariableInput(props) { }} className={cx('ant-input', { 'ant-input-disabled': disabled })} contentEditable={!disabled} + suppressContentEditableWarning > {variableText} @@ -247,6 +253,6 @@ export function VariableInput(props) { ) : null } - + ); } diff --git a/packages/plugins/workflow/src/client/components/VariableJSONInput.tsx b/packages/core/client/src/schema-component/antd/variable/JSONInput.tsx similarity index 91% rename from packages/plugins/workflow/src/client/components/VariableJSONInput.tsx rename to packages/core/client/src/schema-component/antd/variable/JSONInput.tsx index eb6d4c6cb..9903ceadb 100644 --- a/packages/plugins/workflow/src/client/components/VariableJSONInput.tsx +++ b/packages/core/client/src/schema-component/antd/variable/JSONInput.tsx @@ -2,9 +2,8 @@ import React, { useRef } from 'react'; import { Button, Cascader } from 'antd'; import { css } from "@emotion/css"; -import { Input } from "@nocobase/client"; - -import { lang } from '../locale'; +import { Input } from "../input"; +import { useTranslation } from 'react-i18next'; @@ -17,9 +16,10 @@ function setNativeInputValue(input, value) { })); } -export function VariableJSONInput(props) { +export function JSONInput(props) { const inputRef = useRef(null); const { value, space = 2, scope } = props; + const { t } = useTranslation() const options = typeof scope === 'function' ? scope() : (scope ?? []); function onFormat() { @@ -69,7 +69,7 @@ export function VariableJSONInput(props) { } `} > - + (); for (const option of options) { - map.set(option.key, [option.label]); + map.set(option.value, [option.label]); if (option.children) { - for (const [key, labels] of createOptionsKeyLabelMap(option.children)) { - map.set(`${option.key}.${key}`, [option.label, ...labels]); + for (const [value, labels] of createOptionsValueLabelMap(option.children)) { + map.set(`${option.value}.${value}`, [option.label, ...labels]); } } } @@ -103,12 +103,13 @@ function createVariableTagHTML(variable, keyLabelMap) { return `${labels?.join(' / ')}`; } -export function VariableTextArea(props) { +export function TextArea(props) { const { value = '', scope, onChange, multiline = true, button } = props; + const { t } = useTranslation() const inputRef = useRef(null); const options = (typeof scope === 'function' ? scope() : scope) ?? []; const form = useForm(); - const keyLabelMap = useMemo(() => createOptionsKeyLabelMap(options), [scope]); + const keyLabelMap = useMemo(() => createOptionsValueLabelMap(options), [scope]); const [changed, setChanged] = useState(false); const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap)); // [startElementIndex, startOffset, endElementIndex, endOffset] @@ -213,7 +214,7 @@ export function VariableTextArea(props) { contentEditable={!disabled} dangerouslySetInnerHTML={{ __html: html }} /> - + (); +export const evaluators = new Registry(); evaluators.register('math.js', mathjs); evaluators.register('formula.js', formulajs); +export const renderReference = (key: string) => { + const engine = evaluators.get(key); + if (!engine) { + return null; + } + + return engine.link + ? ( + <> + + {i18n.t('Syntax references')} + + {engine.label} + + ) + : null +}; + export default evaluators; diff --git a/packages/core/evaluators/src/index.ts b/packages/core/evaluators/src/index.ts index 7ddad5814..a5fb6b4d3 100644 --- a/packages/core/evaluators/src/index.ts +++ b/packages/core/evaluators/src/index.ts @@ -1 +1,2 @@ export { default } from './server'; +export * from './server'; diff --git a/packages/core/evaluators/src/server/index.ts b/packages/core/evaluators/src/server/index.ts index 2223447d9..563114b41 100644 --- a/packages/core/evaluators/src/server/index.ts +++ b/packages/core/evaluators/src/server/index.ts @@ -1,24 +1,16 @@ -import { get } from "lodash"; - -import { Registry, RegistryOptions } from "@nocobase/utils"; +import { Registry } from "@nocobase/utils"; +import { evaluate, Evaluator } from '../utils'; import mathjs from "../utils/mathjs"; import formulajs from "../utils/formulajs"; -export interface Evaluator { - (expression: string, scope?: { [key: string]: any }): any; -} +export { Evaluator } from '../utils'; -export interface EvaluatorsOptions extends RegistryOptions { - empty?: boolean; - evaluators?: { [key: string]: Evaluator }; -} +export const evaluators = new Registry(); -const evaluators = new Registry(); - -evaluators.register('math.js', mathjs); -evaluators.register('formula.js', formulajs); +evaluators.register('math.js', evaluate.bind(mathjs)); +evaluators.register('formula.js', evaluate.bind(formulajs)); export default evaluators; diff --git a/packages/core/evaluators/src/utils/formulajs.ts b/packages/core/evaluators/src/utils/formulajs.ts index aa2b81f82..48af8d798 100644 --- a/packages/core/evaluators/src/utils/formulajs.ts +++ b/packages/core/evaluators/src/utils/formulajs.ts @@ -5,8 +5,7 @@ import * as functions from '@formulajs/formulajs'; const fnNames = Object.keys(functions).filter(key => key !== 'default'); const fns = fnNames.map(key => functions[key]); -export default function(exp: string, scope = {}) { - const expression = exp.replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => v); +export default function(expression: string, scope = {}) { const fn = new Function(...fnNames, ...Object.keys(scope), `return ${expression}`); const result = fn(...fns, ...Object.values(scope)); if (typeof result === 'number') { diff --git a/packages/core/evaluators/src/utils/index.ts b/packages/core/evaluators/src/utils/index.ts new file mode 100644 index 000000000..f8bacd72e --- /dev/null +++ b/packages/core/evaluators/src/utils/index.ts @@ -0,0 +1,16 @@ +import { get } from "lodash"; + + + +export type Scope = { [key: string]: any }; + +export type Evaluator = (expression: string, scope?: Scope) => any; + +export function evaluate(this: Evaluator, expression: string, scope: Scope = {}) { + const exp = expression.trim().replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => { + const item = get(scope, v); + const key = v.replace(/\.(\d+)/g, '["$1"]'); + return ` ${typeof item === 'function' ? item() : key} `; + }); + return this(exp, scope); +} diff --git a/packages/core/evaluators/src/utils/mathjs.ts b/packages/core/evaluators/src/utils/mathjs.ts index 7dccbd9f8..d55a378e6 100644 --- a/packages/core/evaluators/src/utils/mathjs.ts +++ b/packages/core/evaluators/src/utils/mathjs.ts @@ -1,8 +1,7 @@ import * as math from 'mathjs'; export default function (expression: string, scope = {}) { - const exp = expression.trim().replace(/{{\s*([^{}]+)\s*}}/g, (_, v) => v); - const result = math.evaluate(exp, scope); + const result = math.evaluate(expression, scope); if (typeof result === 'number') { if (Number.isNaN(result) || !Number.isFinite(result)) { return null; diff --git a/packages/plugins/formula-field/src/client/field.tsx b/packages/plugins/formula-field/src/client/field.tsx index 8c38ace30..c717f33f7 100644 --- a/packages/plugins/formula-field/src/client/field.tsx +++ b/packages/plugins/formula-field/src/client/field.tsx @@ -61,7 +61,7 @@ export default { group: 'advanced', order: 1, title: `{{t("Formula", { ns: "${NAMESPACE}" })}}`, - description: '{{t("Compute a value based on the other fields using mathjs")}}', + description: `{{t("Compute a value based on the other fields", { ns: "${NAMESPACE}" })}}`, sortable: true, default: { type: 'formula', diff --git a/packages/plugins/formula-field/src/client/formula/Expression.tsx b/packages/plugins/formula-field/src/client/formula/Expression.tsx index 0e48e8b3a..ce16d3c1e 100644 --- a/packages/plugins/formula-field/src/client/formula/Expression.tsx +++ b/packages/plugins/formula-field/src/client/formula/Expression.tsx @@ -1,242 +1,25 @@ -import React, { useEffect, useRef, useState, useMemo } from 'react'; -import { css, cx } from '@emotion/css'; -import { useForm } from '@formily/react'; -import { Button, Cascader, Input, Tooltip } from 'antd'; -import { useTranslation } from 'react-i18next'; -import { useCompile } from '@nocobase/client'; +import React from 'react'; +import { useCompile, Variable } from '@nocobase/client'; -const VARIABLE_RE = /\{\{\s*([^{}]+)\s*\}\}/g; - -function pasteHtml(container, html, { selectPastedContent = false, range: indexes }) { - // IE9 and non-IE - const sel = window.getSelection?.(); - if (!sel?.getRangeAt || !sel.rangeCount) { - return; - } - const range = sel.getRangeAt(0); - if (!range) { - return; - } - const children = Array.from(container.childNodes) as HTMLElement[]; - if (indexes[0] === -1) { - if (indexes[1]) { - range.setStartAfter(children[indexes[1] - 1]); - } - } else { - range.setStart(children[indexes[0]], indexes[1]); - } - - if (indexes[2] === -1) { - if (indexes[3]) { - range.setEndAfter(children[indexes[3] - 1]); - } - } else { - range.setEnd(children[indexes[2]], indexes[3]); - } - range.deleteContents(); - - // Range.createContextualFragment() would be useful here but is - // only relatively recently standardized and is not supported in - // some browsers (IE9, for one) - const el = document.createElement('div'); - el.innerHTML = html; - - const frag = document.createDocumentFragment(); - let lastNode; - while (el.firstChild) { - lastNode = frag.appendChild(el.firstChild); - } - const { firstChild } = frag; - range.insertNode(frag); - - // Preserve the selection - if (lastNode) { - const next = range.cloneRange(); - next.setStartAfter(lastNode); - if (selectPastedContent) { - if (firstChild) { - next.setStartBefore(firstChild); - } - } else { - next.collapse(true); - } - sel.removeAllRanges(); - sel.addRange(next); - } -} - -function getValue(el) { - const values: any[] = []; - for (const node of el.childNodes) { - if (node.nodeName === 'SPAN') { - values.push(`{{${node['dataset']['key']}}}`); - } else { - values.push(node.textContent?.trim?.()); - } - } - return values.join(' ').replace(/\s+/g, ' ').trim(); -} - -function renderHTML(exp: string, keyLabelMap) { - return exp.replace(VARIABLE_RE, (_, i) => { - const key = i.trim(); - return createVariableTagHTML(key, keyLabelMap) ?? ''; - }); -} - -function createOptionsKeyLabelMap(options: any[]) { - const map = new Map(); - for (const option of options) { - map.set(option.value, [option.label]); - if (option.children) { - for (const [value, labels] of createOptionsKeyLabelMap(option.children)) { - map.set(`${option.value}.${value}`, [option.label, ...labels]); - } - } - } - return map; -} - -function createVariableTagHTML(variable, keyLabelMap) { - const labels = keyLabelMap.get(variable); - return `${labels?.join(' / ')}`; -} - export const Expression = (props) => { const { value = '', supports, useCurrentFields, onChange } = props; - const { t } = useTranslation(); const compile = useCompile(); + const fields = useCurrentFields().filter(field => supports.includes(field.interface)); - const inputRef = useRef(null); const options = fields.map(field => ({ label: compile(field.uiSchema.title), value: field.name })); - const form = useForm(); - const keyLabelMap = useMemo(() => createOptionsKeyLabelMap(options), []); - const [changed, setChanged] = useState(false); - const [html, setHtml] = useState(() => renderHTML(value ?? '', keyLabelMap)); - // [startElementIndex, startOffset, endElementIndex, endOffset] - const [range, setRange] = useState<[number, number, number, number]>([-1, 0, -1, 0]); - - useEffect(() => { - if (changed) { - return; - } - setHtml(renderHTML(value ?? '', keyLabelMap)); - }, [value]); - - useEffect(() => { - const { current } = inputRef; - if (!current || changed) { - return; - } - const nextRange = new Range(); - const { lastChild } = current; - if (lastChild) { - nextRange.setStartAfter(lastChild); - nextRange.setEndAfter(lastChild); - const nodes = Array.from(current.childNodes); - const startElementIndex = nextRange.startContainer === current ? -1 : nodes.indexOf(lastChild); - const endElementIndex = nextRange.startContainer === current ? -1 : nodes.indexOf(lastChild); - setRange([startElementIndex, nextRange.startOffset, endElementIndex, nextRange.endOffset]); - } - }, [html]); - - function onInsert(keyPath) { - const variable: string[] = keyPath.filter(key => Boolean(key.trim())); - const { current } = inputRef; - if (!current || !variable) { - return; - } - - current.focus(); - - pasteHtml(current, createVariableTagHTML(variable.join('.'), keyLabelMap), { - range, - }); - - setChanged(true); - onChange(getValue(current)); - } - - function onInput({ currentTarget }) { - setChanged(true); - onChange(getValue(currentTarget)); - } - - function onBlur({ currentTarget }) { - const sel = window.getSelection?.(); - if (!sel?.getRangeAt || !sel.rangeCount) { - return; - } - const r = sel.getRangeAt(0); - const nodes = Array.from(currentTarget.childNodes); - const startElementIndex = nodes.indexOf(r.startContainer); - const endElementIndex = nodes.indexOf(r.endContainer); - setRange([startElementIndex, r.startOffset, endElementIndex, r.endOffset]); - } - - const disabled = props.disabled || form.disabled; return ( - -
{ - if (e.key === 'Enter') { - e.preventDefault(); - } - }} - onBlur={onBlur} - onInput={onInput} - className={cx('ant-input', { 'ant-input-disabled': disabled }, css` - overflow: auto; - white-space: nowrap; - .ant-tag{ - display: inline; - line-height: 19px; - margin: 0 .5em; - padding: 2px 7px; - border-radius: 10px; - } - `)} - ref={inputRef as any} - contentEditable={!disabled} - dangerouslySetInnerHTML={{ __html: html }} - /> - - - - - - + ); }; diff --git a/packages/plugins/formula-field/src/client/index.tsx b/packages/plugins/formula-field/src/client/index.tsx index 2164cc9be..71a8cda04 100644 --- a/packages/plugins/formula-field/src/client/index.tsx +++ b/packages/plugins/formula-field/src/client/index.tsx @@ -5,7 +5,7 @@ import { css } from '@emotion/css'; import { CollectionManagerContext, registerField, SchemaComponentOptions } from '@nocobase/client'; import evaluators, { Evaluator } from '@nocobase/evaluators/client'; -import Formula from './formula'; +import { Formula } from './formula'; import field from './field'; import { NAMESPACE } from './locale'; import { Registry } from '@nocobase/utils/client'; @@ -36,6 +36,8 @@ function renderExpressionDescription(key: string) { : null } +export { Formula } from './formula'; + export default React.memo((props) => { const ctx = useContext(CollectionManagerContext); return ( diff --git a/packages/plugins/formula-field/src/client/locale/zh-CN.ts b/packages/plugins/formula-field/src/client/locale/zh-CN.ts index ac8fc1b31..25d9e60f9 100644 --- a/packages/plugins/formula-field/src/client/locale/zh-CN.ts +++ b/packages/plugins/formula-field/src/client/locale/zh-CN.ts @@ -3,5 +3,6 @@ export default { 'Formula engine': '公式引擎', 'Expression': '表达式', 'Expression syntax error': '表达式语法错误', - 'Syntax references': '语法参考' + 'Syntax references': '语法参考', + 'Compute a value based on the other fields': '基于其他字段进行计算' } diff --git a/packages/plugins/formula-field/src/server/__tests__/formula-field.test.ts b/packages/plugins/formula-field/src/server/__tests__/formula-field.test.ts index 6005713a0..f816494d5 100644 --- a/packages/plugins/formula-field/src/server/__tests__/formula-field.test.ts +++ b/packages/plugins/formula-field/src/server/__tests__/formula-field.test.ts @@ -89,6 +89,25 @@ describe('formula field', () => { expect(test.get('sum')).toEqual(3.22); }); + + it('scope with number key', async () => { + const expression = '{{a.1}}+1'; + const Test = db.collection({ + name: 'tests', + fields: [ + { type: 'json', name: 'a' }, + { name: 'sum', type: 'formula', expression, engine: 'math.js' }, + ], + }); + + await db.sync(); + + const test = await Test.model.create({ + a: { '1': 1 }, + }); + + expect(test.get('sum')).toEqual(2); + }); }); describe('formula.js', () => { diff --git a/packages/plugins/workflow/package.json b/packages/plugins/workflow/package.json index 858d7e4de..3b1542f9a 100644 --- a/packages/plugins/workflow/package.json +++ b/packages/plugins/workflow/package.json @@ -9,6 +9,7 @@ "@nocobase/actions": "0.9.0-alpha.2", "@nocobase/client": "0.9.0-alpha.2", "@nocobase/database": "0.9.0-alpha.2", + "@nocobase/evaluators": "0.9.0-alpha.2", "@nocobase/resourcer": "0.9.0-alpha.2", "@nocobase/server": "0.9.0-alpha.2", "@nocobase/utils": "0.9.0-alpha.2", @@ -16,9 +17,7 @@ "axios": "^0.27.2", "classnames": "^2.3.1", "cron-parser": "4.4.0", - "@formulajs/formulajs": "4.2.0", "json-templates": "^4.2.0", - "mathjs": "^10.6.0", "moment": "^2.29.2", "react-js-cron": "^1.4.0" }, diff --git a/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx b/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx index 604677c58..5d4dc0436 100644 --- a/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx +++ b/packages/plugins/workflow/src/client/components/CollectionFieldset.tsx @@ -5,8 +5,7 @@ import { PlusOutlined, CloseCircleOutlined } from '@ant-design/icons'; import { useTranslation } from "react-i18next"; import { css } from "@emotion/css"; -import { CollectionField, CollectionProvider, SchemaComponent, useCollectionManager, useCompile } from "@nocobase/client"; -import { VariableInput } from "./VariableInput"; +import { CollectionField, CollectionProvider, SchemaComponent, Variable, useCollectionManager, useCompile } from "@nocobase/client"; import { lang } from "../locale"; import { useWorkflowVariableOptions } from "../variable"; @@ -40,7 +39,8 @@ export default observer(({ value, disabled, onChange }: any) => { .filter(field => ( !field.hidden && (field.uiSchema ? !field.uiSchema['x-read-pretty'] : false) - // && (!['linkTo', 'hasMany', 'hasOne', 'belongsToMany'].includes(field.type)) + // TODO: should use some field option but not type to control this + && (!['formula'].includes(field.type)) )); const unassignedFields = fields.filter(field => !(field.name in value)); @@ -77,7 +77,7 @@ export default observer(({ value, disabled, onChange }: any) => { display: flex; } `}> - { @@ -94,7 +94,7 @@ export default observer(({ value, disabled, onChange }: any) => { } }} /> - + {!mergedDisabled ? (