├── src ├── formula-editor │ ├── styles │ │ ├── index.tsx │ │ ├── toolbar.less │ │ ├── fieldVariable.less │ │ ├── functionStore.less │ │ └── index.less │ ├── utils │ │ ├── index.ts │ │ ├── parseMetaSchema.ts │ │ └── parseSchema.ts │ ├── components │ │ ├── index.ts │ │ ├── VariableIcon.tsx │ │ ├── FxIcon.tsx │ │ ├── FieldVariable.tsx │ │ ├── FieldTree.tsx │ │ ├── FunctionStore.tsx │ │ └── Toolbar.tsx │ ├── types.ts │ ├── index.tsx │ └── functions.ts ├── theme.less ├── index.ts ├── __test__ │ ├── schemaPatch.spec.ts │ └── parseSchema.spec.ts ├── schemaPatch.ts └── index.md ├── typings.d.ts ├── .fatherrc.ts ├── .prettierignore ├── .prettierrc ├── .editorconfig ├── README.md ├── .gitignore ├── .umirc.ts ├── tsconfig.json ├── docs └── index.md └── package.json /src/formula-editor/styles/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.less'; 2 | -------------------------------------------------------------------------------- /src/theme.less: -------------------------------------------------------------------------------- 1 | @import '~antd/lib/style/themes/index.less'; 2 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | -------------------------------------------------------------------------------- /.fatherrc.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | esm: 'rollup', 3 | cjs: 'rollup', 4 | }; 5 | -------------------------------------------------------------------------------- /src/formula-editor/styles/toolbar.less: -------------------------------------------------------------------------------- 1 | @import './index.less'; 2 | 3 | .formula-editor-toolbar { 4 | 5 | } -------------------------------------------------------------------------------- /src/formula-editor/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './parseMetaSchema'; 2 | export * from './parseSchema'; 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.svg 2 | **/*.ejs 3 | **/*.html 4 | package.json 5 | .umi 6 | .umi-production 7 | .umi-test 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FormulaEditor } from './formula-editor'; 2 | export { schemaPatch } from './schemaPatch'; 3 | -------------------------------------------------------------------------------- /src/formula-editor/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FunctionStore } from './FunctionStore'; 2 | export { default as Toolbar } from './Toolbar'; 3 | -------------------------------------------------------------------------------- /src/formula-editor/styles/fieldVariable.less: -------------------------------------------------------------------------------- 1 | @import './index.less'; 2 | 3 | .field-variable { 4 | padding: 0 8px; 5 | border-radius: 2px; 6 | &:hover { 7 | background-color: @primary-1; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 80, 5 | "overrides": [ 6 | { 7 | "files": ".prettierrc", 8 | "options": { "parser": "json" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # form-formula 2 | 3 | ## Getting Started 4 | 5 | Install dependencies, 6 | 7 | ```bash 8 | $ npm i 9 | ``` 10 | 11 | Start the dev server, 12 | 13 | ```bash 14 | $ npm start 15 | ``` 16 | 17 | Build documentation, 18 | 19 | ```bash 20 | $ npm run docs:build 21 | ``` 22 | 23 | Build library via `father-build`, 24 | 25 | ```bash 26 | $ npm run build 27 | ``` 28 | -------------------------------------------------------------------------------- /src/formula-editor/types.ts: -------------------------------------------------------------------------------- 1 | export interface Variable { 2 | label: string; 3 | value: string; 4 | type: string; 5 | fullName?: string; 6 | children?: Variable[]; 7 | } 8 | 9 | export interface FunctionItem { 10 | name: string; 11 | description: string; 12 | } 13 | 14 | export interface FunctionGroup { 15 | name: string; 16 | functions: FunctionItem[]; 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | 10 | # production 11 | /dist 12 | /docs-dist 13 | /lib 14 | 15 | # misc 16 | .DS_Store 17 | 18 | # umi 19 | .umi 20 | .umi-production 21 | .umi-test 22 | .env.local 23 | 24 | # ide 25 | /.vscode 26 | /.idea 27 | -------------------------------------------------------------------------------- /.umirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'dumi'; 2 | 3 | export default defineConfig({ 4 | title: 'form-formula', 5 | favicon: 6 | 'https://user-images.githubusercontent.com/9554297/83762004-a0761b00-a6a9-11ea-83b4-9c8ff721d4b8.png', 7 | logo: 'https://user-images.githubusercontent.com/9554297/83762004-a0761b00-a6a9-11ea-83b4-9c8ff721d4b8.png', 8 | outputPath: 'docs-dist', 9 | base: '/form-formula', 10 | publicPath: '/form-formula/', 11 | mode: 'site', 12 | // more config: https://d.umijs.org/config 13 | }); 14 | -------------------------------------------------------------------------------- /src/formula-editor/styles/functionStore.less: -------------------------------------------------------------------------------- 1 | @import './index.less'; 2 | 3 | .function-store { 4 | &-group { 5 | .function-item { 6 | cursor: pointer; 7 | padding: 4px 8px; 8 | border-radius: 2px; 9 | &:hover { 10 | background-color: @primary-1; 11 | } 12 | &__name { 13 | font-size: 14px; 14 | font-weight: 500; 15 | } 16 | &__desc { 17 | font-size: 12px; 18 | color: fade(@black, 45%); 19 | } 20 | + .function-item { 21 | margin-top: 8px; 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/formula-editor/components/VariableIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React, { SVGProps } from 'react' 3 | 4 | export default (props: SVGProps) => { 5 | return ( 6 | 7 | ) 8 | } 9 | -------------------------------------------------------------------------------- /src/__test__/schemaPatch.spec.ts: -------------------------------------------------------------------------------- 1 | import { schemaPatch } from '../index'; 2 | 3 | test('schemaPatch', () => { 4 | const newSchema = schemaPatch({ 5 | type: 'object', 6 | properties: { 7 | username: { 8 | type: 'string', 9 | title: '用户名', 10 | required: true, 11 | 'x-decorator': 'FormItem', 12 | 'x-component': 'Input', 13 | 'x-reactions': [ 14 | { 15 | type: 'formula', 16 | formula: '2 + 3', 17 | }, 18 | ], 19 | }, 20 | }, 21 | }); 22 | expect(newSchema.properties['username']['x-reactions'].length).toBe(1); 23 | }); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "importHelpers": true, 7 | "jsx": "react", 8 | "esModuleInterop": true, 9 | "sourceMap": true, 10 | "baseUrl": "./", 11 | "strict": true, 12 | "paths": { 13 | "@/*": ["src/*"], 14 | "@@/*": ["src/.umi/*"] 15 | }, 16 | "allowSyntheticDefaultImports": true 17 | }, 18 | "exclude": [ 19 | "node_modules", 20 | "lib", 21 | "es", 22 | "dist", 23 | "typings", 24 | "**/__test__", 25 | "test", 26 | "docs", 27 | "tests" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /src/formula-editor/components/FxIcon.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from 'react' 2 | 3 | 4 | export default (props: SVGProps) => { 5 | return ( 6 | 7 | ) 8 | } -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hero: 3 | title: form-formula 4 | desc: form-formula site example 5 | actions: 6 | - text: Getting Started 7 | link: /components 8 | features: 9 | - icon: https://gw.alipayobjects.com/zos/bmw-prod/881dc458-f20b-407b-947a-95104b5ec82b/k79dm8ih_w144_h144.png 10 | title: Feature 1 11 | desc: Balabala 12 | - icon: https://gw.alipayobjects.com/zos/bmw-prod/d60657df-0822-4631-9d7c-e7a869c2f21c/k79dmz3q_w126_h126.png 13 | title: Feature 2 14 | desc: Balabala 15 | - icon: https://gw.alipayobjects.com/zos/bmw-prod/d1ee0c6f-5aed-4a45-a507-339a4bfe076c/k7bjsocq_w144_h144.png 16 | title: Feature 3 17 | desc: Balabala 18 | footer: Open-source MIT Licensed | Copyright © 2020
Powered by [dumi](https://d.umijs.org) 19 | --- 20 | 21 | ## Hello form-formula! 22 | 23 | Install dependencies, 24 | 25 | ```bash 26 | $ npm i @toy-box/form-formula 27 | ``` 28 | -------------------------------------------------------------------------------- /src/formula-editor/components/FieldVariable.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import React, { FC, useCallback, useMemo } from 'react'; 3 | import '../styles/fieldVariable.less'; 4 | 5 | export interface IFieldVariableProps { 6 | label: string; 7 | value: string; 8 | type: string; 9 | pick?: (id: string) => void; 10 | } 11 | 12 | const FieldVariable: FC = ({ 13 | label, 14 | type, 15 | value, 16 | pick, 17 | }) => { 18 | const handlePick = useCallback(() => { 19 | if (type === 'array' || type === 'object') { 20 | return; 21 | } 22 | pick && pick(value); 23 | }, []); 24 | const disabled = useMemo(() => type === 'array' || type === 'object', []); 25 | return ( 26 |
30 | {label} 31 |
32 | ); 33 | }; 34 | 35 | export default FieldVariable; 36 | -------------------------------------------------------------------------------- /src/formula-editor/styles/index.less: -------------------------------------------------------------------------------- 1 | @import '../../theme.less'; 2 | 3 | @toolbarWidth: 240px; 4 | 5 | .formula-editor { 6 | display: flex; 7 | height: 100%; 8 | &-toolbar { 9 | width: @toolbarWidth; 10 | height: 560px; 11 | overflow-y: auto; 12 | border-right: 1px solid @border-color-base; 13 | } 14 | &-main { 15 | height: 100%; 16 | padding: 8px 16px; 17 | width: calc(100% - @toolbarWidth); 18 | &__code { 19 | > h1 { 20 | font-size: 28px; 21 | color: fade(@black, 75%); 22 | margin-bottom: 4px; 23 | .equle { 24 | color: fade(@black, 45%); 25 | } 26 | } 27 | } 28 | } 29 | .CodeMirror { 30 | height: 120px; 31 | font-size: 20px; 32 | .formula-tag { 33 | background: @primary-6; 34 | color: @white; 35 | border-radius: 4px; 36 | font-size: 0.8em; 37 | padding: 2px 4px; 38 | margin: 0 2px; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/formula-editor/components/FieldTree.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useMemo } from 'react'; 2 | import { Tree } from 'antd'; 3 | import { DataNode } from 'rc-tree/lib/interface'; 4 | import { Variable } from '../types'; 5 | import FieldVariable from './FieldVariable'; 6 | 7 | export interface FieldTreeProps { 8 | dataSource: Variable[]; 9 | pick?: (value: string) => void; 10 | } 11 | 12 | function convert(variable: Variable, pick?: (value: string) => void): DataNode { 13 | return { 14 | title: ( 15 | 21 | ), 22 | key: variable.value, 23 | children: (variable.children || []).map((child) => convert(child, pick)), 24 | } as DataNode; 25 | } 26 | 27 | const FieldTree: FC = ({ dataSource, pick }) => { 28 | const treeData = useMemo(() => { 29 | return dataSource.map((v) => convert(v, pick)); 30 | }, []); 31 | 32 | return ; 33 | }; 34 | 35 | export default FieldTree; 36 | -------------------------------------------------------------------------------- /src/formula-editor/components/FunctionStore.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Collapse } from 'antd'; 3 | import { FunctionGroup } from '../types'; 4 | 5 | import '../styles/functionStore.less'; 6 | 7 | const { Panel } = Collapse; 8 | 9 | 10 | export interface FunctionStoreProps { 11 | dataSource?: FunctionGroup[]; 12 | check?: (name: string) => void; 13 | } 14 | 15 | 16 | const FunctionStore: FC = ({ dataSource = [], check }) => { 17 | const prefixCls = 'function-store'; 18 | 19 | return ( 20 | 21 | { 22 | dataSource.map( 23 | (fnGroup, index) => ( 24 | 25 | { 26 | fnGroup.functions.map((fn, index) => ( 27 |
check && check(fn.name)}> 28 |
29 | {fn.name} 30 |
31 |
32 | {fn.description} 33 |
34 |
35 | )) 36 | } 37 |
38 | ) 39 | ) 40 | } 41 |
42 | ); 43 | } 44 | 45 | export default FunctionStore; 46 | 47 | -------------------------------------------------------------------------------- /src/formula-editor/components/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, ReactNode, useCallback } from 'react'; 2 | import { Tabs } from 'antd'; 3 | import FunctionStore from './FunctionStore'; 4 | import FxIcon from './FxIcon'; 5 | import VariableIcon from './VariableIcon'; 6 | import { FunctionGroup, Variable } from '../types'; 7 | import FieldTree from './FieldTree'; 8 | 9 | const { TabPane } = Tabs; 10 | 11 | export interface ToobarProps { 12 | functions?: FunctionGroup[]; 13 | variables?: Variable[]; 14 | insertFun?: (fn: string) => void; 15 | insertVariable?: (variable: string) => void; 16 | } 17 | 18 | const Toolbar: FC = ({ 19 | functions, 20 | variables = [], 21 | insertFun, 22 | insertVariable, 23 | }) => { 24 | const prefixCls = 'formula-editor-toolbar'; 25 | 26 | const TabNode = useCallback( 27 | (props: { name: string; icon?: ReactNode }) => ( 28 | 29 | {props.icon ? ( 30 | 31 | {props.icon} 32 | 33 | ) : null} 34 | {props.name} 35 | 36 | ), 37 | [], 38 | ); 39 | 40 | return ( 41 |
42 | 43 | } />} key="fx"> 44 | 45 | 46 | } />} 48 | key="field" 49 | > 50 | {} 51 | 52 | 53 |
54 | ); 55 | }; 56 | 57 | export default Toolbar; 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@toy-box/form-formula", 3 | "version": "1.0.30", 4 | "author": "DivXPro", 5 | "license": "MIT", 6 | "homepage": "https://toy-box.github.io/form-formula", 7 | "repository": "https://github.com/toy-box/form-formula", 8 | "scripts": { 9 | "start": "dumi dev", 10 | "docs:build": "dumi build", 11 | "docs:deploy": "gh-pages -d docs-dist", 12 | "build": "father-build", 13 | "deploy": "npm run docs:build && npm run docs:deploy", 14 | "release": "npm run build && npm publish --access public", 15 | "prettier": "prettier --write \"**/*.{js,jsx,tsx,ts,less,md,json}\"", 16 | "test": "umi-test", 17 | "test:coverage": "umi-test --coverage" 18 | }, 19 | "main": "dist/index.js", 20 | "module": "dist/index.esm.js", 21 | "typings": "dist/index.d.ts", 22 | "gitHooks": { 23 | "pre-commit": "lint-staged" 24 | }, 25 | "lint-staged": { 26 | "*.{js,jsx,less,md,json}": [ 27 | "prettier --write" 28 | ], 29 | "*.ts?(x)": [ 30 | "prettier --parser=typescript --write" 31 | ] 32 | }, 33 | "dependencies": { 34 | "@formily/antd": "^2.0.0-beta.50", 35 | "@formily/core": "^2.0.0-beta.50", 36 | "@formily/json-schema": "^2.0.0-beta.50", 37 | "@formily/react": "^2.0.0-beta.50", 38 | "@formulajs/formulajs": "^2.6.9", 39 | "@toy-box/formula": "^1.0.9", 40 | "@toy-box/meta-schema": "^1.0.56", 41 | "antd": "^4.15.5", 42 | "classnames": "^2.3.1", 43 | "codemirror": "^5.61.0", 44 | "react": "^16.12.0", 45 | "react-codemirror2": "^7.2.1", 46 | "react-dom": "^16.12.0" 47 | }, 48 | "peerDependencies": { 49 | "@formily/antd": "^2.0.0-beta.50", 50 | "@formily/core": "^2.0.0-beta.50", 51 | "@formily/json-schema": "^2.0.0-beta.50", 52 | "@formily/react": "^2.0.0-beta.50", 53 | "antd": "^4.15.5", 54 | "classnames": "^2.3.1", 55 | "react": "^16.12.0", 56 | "react-dom": "^16.12.0" 57 | }, 58 | "devDependencies": { 59 | "@types/codemirror": "^5.60.0", 60 | "@umijs/test": "^3.0.5", 61 | "dumi": "^1.0.14", 62 | "father-build": "^1.17.2", 63 | "gh-pages": "^3.0.0", 64 | "lint-staged": "^10.0.7", 65 | "prettier": "^2.2.1", 66 | "yorkie": "^2.0.0" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/schemaPatch.ts: -------------------------------------------------------------------------------- 1 | import { Field } from '@formily/core/esm/models/Field'; 2 | import { isArrayField } from '@formily/core'; 3 | import { isArr } from '@formily/shared'; 4 | import { formulaParse } from '@toy-box/formula'; 5 | import { ISchema } from '@formily/json-schema'; 6 | import { SchemaProperties } from '@formily/react'; 7 | 8 | export function schemaPatch(schema: ISchema) { 9 | const { ['x-reactions']: reactions, properties } = schema; 10 | let newSchema: ISchema = { ...schema }; 11 | if (properties) { 12 | newSchema = Object.assign(schema, { 13 | properties: 14 | typeof properties === 'object' 15 | ? propertiesPatch(properties) 16 | : properties, 17 | }); 18 | } 19 | if (reactions) { 20 | newSchema = Object.assign(schema, { 21 | 'x-reactions': reactionsPatch(reactions), 22 | }); 23 | } 24 | return newSchema; 25 | } 26 | 27 | function propertiesPatch( 28 | properties: SchemaProperties, 29 | ) { 30 | const propertiesPatched: SchemaProperties< 31 | any, 32 | any, 33 | any, 34 | any, 35 | any, 36 | any, 37 | any, 38 | any 39 | > = {}; 40 | Object.keys(properties).forEach((key) => { 41 | propertiesPatched[key] = schemaPatch(properties[key]); 42 | }); 43 | return propertiesPatched; 44 | } 45 | 46 | function reactionsPatch(reactions: any | any[]) { 47 | if (isArr(reactions)) { 48 | return reactions.map((reaction) => reactionPatch(reaction)); 49 | } 50 | return reactionPatch(reactions); 51 | } 52 | 53 | function reactionPatch(reaction: any) { 54 | if (reaction.type === 'formula') { 55 | return (field: Field) => { 56 | if (field.form.initialized) { 57 | const result = formulaParse(reaction.formula, (pattern: string) => { 58 | const path = pattern.substr(2, pattern.length - 3); 59 | const query = field.form.query(getParentPath(path)); 60 | const takenField = query.take(); 61 | const fieldValue = field.form.getValuesIn(path); 62 | const brotherAddress = `${getParentPath(path)}.${getIndex( 63 | field, 64 | )}.${getFieldKey(path)}`; 65 | const brotherValue = field.form.getValuesIn(brotherAddress); 66 | const arrayValue = field.form.getValuesIn(takenField?.path); 67 | if (isArrayField(field.parent) && isBrother(field, path)) { 68 | return brotherValue; 69 | } 70 | if (takenField && isArrayField(takenField)) { 71 | return arrayValue.map( 72 | (item: Record) => item[getFieldKey(path)], 73 | ); 74 | } 75 | return fieldValue; 76 | }); 77 | 78 | if (result.success) { 79 | field.form.setValuesIn(field.path, result.result); 80 | } 81 | } 82 | }; 83 | } 84 | return reaction; 85 | } 86 | 87 | function getParentPath(path: string) { 88 | const pathArr = path.split('.'); 89 | pathArr.splice(pathArr.length - 1, 1); 90 | return pathArr.join('.'); 91 | } 92 | 93 | function isBrother(field: Field, path: string) { 94 | return field.parent.path.toString() === getParentPath(path); 95 | } 96 | 97 | function getIndex(field: Field) { 98 | const pathArr = field.path.toArr(); 99 | return pathArr[pathArr.length - 2]; 100 | } 101 | 102 | function getFieldKey(field: Field | string) { 103 | if (typeof field == 'string') { 104 | const pathArr = field.split('.'); 105 | return pathArr[pathArr.length - 1]; 106 | } 107 | const pathArr = field.path.toArr(); 108 | return pathArr[pathArr.length - 1]; 109 | } 110 | -------------------------------------------------------------------------------- /src/formula-editor/utils/parseMetaSchema.ts: -------------------------------------------------------------------------------- 1 | import { isArr } from '@formily/shared'; 2 | import { IFieldMeta } from '@toy-box/meta-schema'; 3 | import { Variable } from '../types'; 4 | 5 | export function parseMetaSchema( 6 | schema: IFieldMeta, 7 | path: string = '', 8 | refPath?: string, 9 | ): Variable { 10 | const children: Variable[] = []; 11 | if (schema.type === 'object') { 12 | if (schema?.properties != null) { 13 | const properties = schema.properties; 14 | Object.keys(properties).forEach((key) => { 15 | const fieldSchema = properties[key]; 16 | const fieldPath = `${path ? `${path}.` : ''}${key}`; 17 | if (refPath !== fieldPath) { 18 | children.push(parseMetaSchema(fieldSchema, fieldPath, refPath)); 19 | } 20 | }); 21 | } 22 | } else if (schema.type === 'array') { 23 | const properties = schema.properties; 24 | if (properties) { 25 | children.push( 26 | ...Object.keys(properties) 27 | .filter((key) => { 28 | return `${path ? `${path}.` : ''}${key}` != refPath; 29 | }) 30 | .map((key) => { 31 | const fieldSchema = properties[key]; 32 | const fieldPath = `${path ? `${path}.` : ''}${key}`; 33 | return parseMetaSchema(fieldSchema, fieldPath, refPath); 34 | }), 35 | ); 36 | } 37 | } else { 38 | if (path != refPath) { 39 | children.push({ 40 | label: schema.name, 41 | value: path, 42 | type: schema.type || '', 43 | }); 44 | } 45 | } 46 | if (schema.type === 'object' || schema.type === 'array') { 47 | return { 48 | label: schema.name, 49 | value: path, 50 | type: schema.type?.toString(), 51 | children, 52 | }; 53 | } 54 | return { 55 | label: schema.name, 56 | value: path, 57 | type: schema.type || '', 58 | }; 59 | } 60 | 61 | export interface CleanMetaSchemaResult { 62 | key?: string; 63 | schema?: IFieldMeta; 64 | } 65 | 66 | function mapProperties(results: CleanMetaSchemaResult[]) { 67 | const properties: Record = {}; 68 | results.forEach((r) => { 69 | if (r.key && r.schema) { 70 | properties[r.key] = r.schema; 71 | } 72 | }); 73 | return properties; 74 | } 75 | 76 | export function cleanVoidMetaSchema( 77 | schema: IFieldMeta, 78 | key?: string, 79 | ): CleanMetaSchemaResult | CleanMetaSchemaResult[] | undefined { 80 | const properties = schema.properties; 81 | if (schema.type === 'object') { 82 | if (typeof properties === 'object') { 83 | const cleanProperties: CleanMetaSchemaResult[] = []; 84 | Object.keys(properties).forEach((key) => { 85 | const result = cleanVoidMetaSchema(properties[key], key); 86 | if (isArr(result)) { 87 | cleanProperties.push(...result); 88 | } else if (result) { 89 | cleanProperties.push(result); 90 | } 91 | }); 92 | return { 93 | schema: { ...schema, properties: mapProperties(cleanProperties) }, 94 | key, 95 | }; 96 | } 97 | return { schema, key }; 98 | } 99 | if (schema.type === 'array') { 100 | const { properties, ...other } = schema; 101 | const cleanProperties: CleanMetaSchemaResult[] = []; 102 | if (properties) { 103 | Object.keys(properties).forEach((key) => { 104 | const result = cleanVoidMetaSchema(properties[key] as IFieldMeta, key); 105 | if (isArr(result)) { 106 | cleanProperties.push(...result); 107 | } else if (result) { 108 | cleanProperties.push(result); 109 | } 110 | }); 111 | } 112 | const newProerites = properties 113 | ? mapProperties(cleanProperties) 114 | : undefined; 115 | return { 116 | schema: { 117 | ...other, 118 | properties: newProerites, 119 | }, 120 | key, 121 | }; 122 | } 123 | if (schema.type === 'void') { 124 | const properties = schema.properties; 125 | const cleanProperties: CleanMetaSchemaResult[] = []; 126 | if (properties) { 127 | Object.keys(properties).forEach((key) => { 128 | const result = cleanVoidMetaSchema(properties[key] as IFieldMeta, key); 129 | if (isArr(result)) { 130 | cleanProperties.push(...result); 131 | } else if (result) { 132 | cleanProperties.push(result); 133 | } 134 | }); 135 | } 136 | return cleanProperties; 137 | } 138 | return { schema, key }; 139 | } 140 | -------------------------------------------------------------------------------- /src/formula-editor/utils/parseSchema.ts: -------------------------------------------------------------------------------- 1 | import { ISchema, SchemaProperties } from '@formily/json-schema'; 2 | import { isArr } from '@formily/shared'; 3 | import { Variable } from '../types'; 4 | 5 | declare type SchemaProps = SchemaProperties< 6 | any, 7 | any, 8 | any, 9 | any, 10 | any, 11 | any, 12 | any, 13 | any 14 | >; 15 | 16 | export function parseSchema( 17 | schema: ISchema, 18 | path: string = '', 19 | refPath?: string, 20 | parentFullName = '', 21 | ): Variable { 22 | const children: Variable[] = []; 23 | if (schema.type === 'object') { 24 | if (schema?.properties != null) { 25 | const properties = schema.properties as SchemaProps; 26 | Object.keys(properties).forEach((key) => { 27 | const fieldSchema = properties[key]; 28 | const fieldPath = `${path ? `${path}.` : ''}${key}`; 29 | const fullName = `${parentFullName ? parentFullName : ''}${ 30 | parentFullName ? '.' : '' 31 | }${schema.title ? schema.title : ''}`; 32 | if (refPath !== fieldPath) { 33 | children.push(parseSchema(fieldSchema, fieldPath, refPath, fullName)); 34 | } 35 | }); 36 | } 37 | } else if (schema.type === 'array') { 38 | const itemProperties = schema?.properties as 39 | | SchemaProperties 40 | | undefined; 41 | if (itemProperties) { 42 | const fullName = `${parentFullName ? parentFullName : ''}${ 43 | parentFullName ? '.' : '' 44 | }${schema.title ? schema.title : ''}`; 45 | children.push( 46 | ...Object.keys(itemProperties) 47 | .filter((key) => { 48 | return `${path ? `${path}.` : ''}${key}` != refPath; 49 | }) 50 | .map((key) => { 51 | const fieldSchema = itemProperties[key]; 52 | const fieldPath = `${path ? `${path}.` : ''}${key}`; 53 | return parseSchema(fieldSchema, fieldPath, refPath, fullName); 54 | }), 55 | ); 56 | } 57 | } else { 58 | if (path != refPath) { 59 | children.push({ 60 | label: schema.title as string, 61 | value: path, 62 | type: schema.type || '', 63 | fullName: `${parentFullName ? parentFullName : ''}${ 64 | parentFullName ? '.' : '' 65 | }${schema.title}`, 66 | }); 67 | } 68 | } 69 | if (schema.type === 'object' || schema.type === 'array') { 70 | return { 71 | label: schema.title as string, 72 | value: path, 73 | type: schema.type?.toString(), 74 | children, 75 | fullName: `${parentFullName ? parentFullName : ''}${ 76 | parentFullName ? '.' : '' 77 | }${schema.title}`, 78 | }; 79 | } 80 | return { 81 | label: schema.title as string, 82 | value: path, 83 | type: schema.type || '', 84 | fullName: `${parentFullName ? parentFullName : ''}${ 85 | parentFullName ? '.' : '' 86 | }${schema.title}`, 87 | }; 88 | } 89 | 90 | function mapProperties(results: CleanSchemaResult[]) { 91 | const properties: Record = {}; 92 | results.forEach((r) => { 93 | if (r.key && r.schema) { 94 | properties[r.key] = r.schema; 95 | } 96 | }); 97 | return properties; 98 | } 99 | 100 | export interface CleanSchemaResult { 101 | key?: string; 102 | schema?: ISchema; 103 | } 104 | 105 | export function cleanVoidSchema( 106 | schema: ISchema, 107 | key?: string, 108 | ): CleanSchemaResult | CleanSchemaResult[] | undefined { 109 | if (schema.type === 'object') { 110 | const { properties } = schema; 111 | if (typeof properties === 'object') { 112 | const cleanProperties: CleanSchemaResult[] = []; 113 | Object.keys(properties).forEach((key) => { 114 | const result = cleanVoidSchema(properties[key], key); 115 | if (isArr(result)) { 116 | cleanProperties.push(...result); 117 | } else if (result) { 118 | cleanProperties.push(result); 119 | } 120 | }); 121 | return { 122 | schema: { ...schema, properties: mapProperties(cleanProperties) }, 123 | key, 124 | }; 125 | } 126 | return { schema, key }; 127 | } 128 | if (schema.type === 'array') { 129 | const itemsSchema = schema?.items as ISchema | undefined; 130 | const itemsProperties = itemsSchema?.properties as SchemaProps | undefined; 131 | const cleanProperties: CleanSchemaResult[] = []; 132 | if (itemsProperties) { 133 | Object.keys(itemsProperties).forEach((key) => { 134 | const result = cleanVoidSchema(itemsProperties[key] as ISchema, key); 135 | if (isArr(result)) { 136 | cleanProperties.push(...result); 137 | } else if (result) { 138 | cleanProperties.push(result); 139 | } 140 | }); 141 | } 142 | const properties = itemsProperties 143 | ? mapProperties(cleanProperties) 144 | : undefined; 145 | const { items, ...other } = schema; 146 | return { 147 | schema: { 148 | ...other, 149 | properties, 150 | }, 151 | key, 152 | }; 153 | } 154 | if (schema.type === 'void') { 155 | const properties = schema.properties as SchemaProps | undefined; 156 | const cleanProperties: CleanSchemaResult[] = []; 157 | if (properties) { 158 | Object.keys(properties).forEach((key) => { 159 | const result = cleanVoidSchema(properties[key] as ISchema, key); 160 | if (isArr(result)) { 161 | cleanProperties.push(...result); 162 | } else if (result) { 163 | cleanProperties.push(result); 164 | } 165 | }); 166 | } 167 | return cleanProperties; 168 | } 169 | return { schema, key }; 170 | } 171 | -------------------------------------------------------------------------------- /src/formula-editor/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState, useCallback, useMemo } from 'react'; 2 | import { UnControlled as CodeMirror } from 'react-codemirror2'; 3 | import { Editor as CodemirrorEditor } from 'codemirror'; 4 | import { isArr } from '@formily/shared'; 5 | import { ISchema } from '@formily/json-schema'; 6 | import { IFieldMeta } from '@toy-box/meta-schema'; 7 | import classNames from 'classnames'; 8 | import { Toolbar } from './components'; 9 | import { FunctionGroup, Variable } from './types'; 10 | import { default as funs } from './functions'; 11 | import { 12 | parseSchema, 13 | cleanVoidSchema, 14 | cleanVoidMetaSchema, 15 | parseMetaSchema, 16 | CleanSchemaResult, 17 | CleanMetaSchemaResult, 18 | } from './utils'; 19 | import './styles'; 20 | 21 | import 'codemirror/mode/spreadsheet/spreadsheet.js'; 22 | 23 | export interface FormulaEditorProps { 24 | title?: string; 25 | value?: string; 26 | path?: string; 27 | functions?: FunctionGroup[]; 28 | onChange?: (value: string) => void; 29 | className?: string; 30 | style?: React.CSSProperties; 31 | schema?: ISchema | IFieldMeta; 32 | metaSchema?: boolean; 33 | } 34 | 35 | const cmOptions = { 36 | mode: 'text/x-spreadsheet', 37 | line: true, 38 | lineWrapping: true, 39 | }; 40 | 41 | const FormulaEditor: FC = ({ 42 | title, 43 | value = '', 44 | path, 45 | functions = funs, 46 | onChange, 47 | style, 48 | className, 49 | schema, 50 | metaSchema, 51 | }) => { 52 | const [editor, setEditor] = useState(); 53 | const prefixCls = 'formula-editor'; 54 | const parseSchemaVariables = useCallback( 55 | (schema: ISchema, path: string, refPath?: string) => { 56 | return metaSchema 57 | ? parseMetaSchema(schema as IFieldMeta) 58 | : parseSchema(schema); 59 | }, 60 | [metaSchema], 61 | ); 62 | 63 | const innerVariables = useMemo(() => { 64 | if (schema) { 65 | const result = metaSchema 66 | ? cleanVoidMetaSchema(schema as IFieldMeta) 67 | : cleanVoidSchema(schema as ISchema); 68 | if (result) { 69 | return isArr(result) 70 | ? (result 71 | .map((r: CleanSchemaResult | CleanMetaSchemaResult) => 72 | r.schema ? parseSchemaVariables(r.schema, '', path) : null, 73 | ) 74 | .filter((v) => v != null) as Variable[]) 75 | : result.schema 76 | ? parseSchemaVariables(result.schema, '', path).children 77 | : []; 78 | } 79 | return []; 80 | } 81 | }, []); 82 | 83 | const onReady = (editor: CodemirrorEditor, value: string) => { 84 | setEditor(editor); 85 | if (value != null && value !== '') { 86 | initDocTag(editor, value); 87 | } 88 | }; 89 | 90 | const handleChange = (editor: CodemirrorEditor, data: any, value: any) => { 91 | if (value != null || value !== '') { 92 | initDocTag(editor, value); 93 | } 94 | onChange && onChange(value); 95 | }; 96 | 97 | const initDocTag = (editor: any, code: string) => { 98 | const contents = code.split('\n'); 99 | contents.forEach((content, idx) => 100 | initLineTag(editor, content, idx, innerVariables), 101 | ); 102 | }; 103 | 104 | const initLineTag = ( 105 | editor: any, 106 | content: any, 107 | line: any, 108 | innerVariables: Variable[] = [], 109 | ) => { 110 | (innerVariables || []).forEach((variable) => { 111 | const variableMark = `{!${variable.value}}`; 112 | const regex = new RegExp(variableMark, 'g'); 113 | while (regex.exec(content) !== null) { 114 | const begin = { line, ch: regex.lastIndex - variableMark.length }; 115 | const end = { line, ch: regex.lastIndex }; 116 | replaceVariable(editor, begin, end, variable); 117 | } 118 | if (variable.children && variable.children.length > 0) { 119 | initLineTag(editor, content, line, variable.children); 120 | } 121 | }); 122 | }; 123 | 124 | const insertFun = useCallback( 125 | (code: string) => { 126 | if (editor == null) return; 127 | const doc = editor.getDoc(); 128 | const pos = doc.getCursor(); 129 | doc.replaceRange(`${code}()`, pos); 130 | pos.ch += code.length + 1; 131 | doc.setCursor(pos); 132 | editor.focus(); 133 | }, 134 | [editor], 135 | ); 136 | 137 | const insertVariable = useCallback( 138 | (variable: string) => { 139 | if (editor == null) return; 140 | const doc = editor.getDoc(); 141 | const pos = doc.getCursor(); 142 | doc.replaceRange(`{!${variable}}`, pos, pos); 143 | editor.focus(); 144 | }, 145 | [editor], 146 | ); 147 | 148 | const replaceVariable = ( 149 | editor: CodemirrorEditor, 150 | begin: any, 151 | end: any, 152 | val: Variable, 153 | ) => { 154 | const doc = editor.getDoc(); 155 | const el = document.createElement('span'); 156 | el.innerText = val.fullName || val.label || val.value; 157 | el.className = 'formula-tag'; 158 | doc.markText(begin, end, { 159 | replacedWith: el, 160 | }); 161 | }; 162 | 163 | return ( 164 |
165 | 171 |
172 |
173 |

174 | {title} 175 | = 176 |

177 | 184 |
185 |
186 |
187 |
188 | ); 189 | }; 190 | 191 | export default FormulaEditor; 192 | -------------------------------------------------------------------------------- /src/__test__/parseSchema.spec.ts: -------------------------------------------------------------------------------- 1 | import { parseSchema, cleanVoid } from '../formula-editor/utils/parseSchema'; 2 | 3 | test('parseSchema', () => { 4 | const cleanSchema = cleanVoid({ 5 | type: 'object', 6 | properties: { 7 | username: { 8 | type: 'string', 9 | title: '用户名', 10 | required: true, 11 | 'x-decorator': 'FormItem', 12 | 'x-component': 'Input', 13 | 'x-reactions': [ 14 | { 15 | type: 'formula', 16 | formula: 'CONCATENATE({!firstName}, " ", {!lastName})', 17 | }, 18 | ], 19 | }, 20 | name: { 21 | type: 'void', 22 | title: '姓名', 23 | 'x-decorator': 'FormItem', 24 | 'x-decorator-props': { 25 | asterisk: true, 26 | feedbackLayout: 'none', 27 | }, 28 | 'x-component': 'FormGrid', 29 | properties: { 30 | firstName: { 31 | type: 'string', 32 | required: true, 33 | 'x-decorator': 'FormItem', 34 | 'x-component': 'Input', 35 | 'x-component-props': { 36 | placeholder: '姓', 37 | }, 38 | }, 39 | lastName: { 40 | type: 'string', 41 | required: true, 42 | 'x-decorator': 'FormItem', 43 | 'x-component': 'Input', 44 | 'x-component-props': { 45 | placeholder: '名', 46 | }, 47 | }, 48 | }, 49 | }, 50 | projects: { 51 | type: 'array', 52 | title: 'Projects', 53 | 'x-decorator': 'FormItem', 54 | 'x-component': 'ArrayTable', 55 | items: { 56 | type: 'object', 57 | properties: { 58 | column_1: { 59 | type: 'void', 60 | 'x-component': 'ArrayTable.Column', 61 | 'x-component-props': { 62 | width: 50, 63 | title: 'Sort', 64 | align: 'center', 65 | }, 66 | properties: { 67 | sortable: { 68 | type: 'void', 69 | 'x-component': 'ArrayTable.SortHandle', 70 | }, 71 | }, 72 | }, 73 | column_2: { 74 | type: 'void', 75 | 'x-component': 'ArrayTable.Column', 76 | 'x-component-props': { 77 | width: 50, 78 | title: 'Index', 79 | align: 'center', 80 | }, 81 | properties: { 82 | index: { 83 | type: 'void', 84 | 'x-component': 'ArrayTable.Index', 85 | }, 86 | }, 87 | }, 88 | column_3: { 89 | type: 'void', 90 | 'x-component': 'ArrayTable.Column', 91 | 'x-component-props': { 92 | title: 'Price', 93 | }, 94 | properties: { 95 | price: { 96 | type: 'number', 97 | default: 0, 98 | 'x-decorator': 'Editable', 99 | 'x-component': 'NumberPicker', 100 | 'x-component-props': { 101 | addonafter: '$', 102 | }, 103 | }, 104 | }, 105 | }, 106 | column_4: { 107 | type: 'void', 108 | 'x-component': 'ArrayTable.Column', 109 | 'x-component-props': { 110 | title: 'Count', 111 | }, 112 | properties: { 113 | count: { 114 | type: 'number', 115 | default: 0, 116 | 'x-decorator': 'Editable', 117 | 'x-component': 'NumberPicker', 118 | 'x-component-props': { 119 | addonafter: '$', 120 | }, 121 | }, 122 | }, 123 | }, 124 | column_5: { 125 | type: 'void', 126 | 'x-component': 'ArrayTable.Column', 127 | 'x-component-props': { 128 | title: 'Total', 129 | }, 130 | properties: { 131 | total: { 132 | type: 'number', 133 | 'x-read-pretty': true, 134 | 'x-decorator': 'FormItem', 135 | 'x-component': 'NumberPicker', 136 | 'x-component-props': { 137 | addonafter: '$', 138 | }, 139 | 'x-reactions': [ 140 | { 141 | type: 'formula', 142 | formula: '{!projects.price} * {!projects.count}', 143 | }, 144 | ], 145 | }, 146 | }, 147 | }, 148 | column_6: { 149 | type: 'void', 150 | 'x-component': 'ArrayTable.Column', 151 | 'x-component-props': { 152 | title: 'Operations', 153 | }, 154 | properties: { 155 | item: { 156 | type: 'void', 157 | 'x-component': 'FormItem', 158 | properties: { 159 | remove: { 160 | type: 'void', 161 | 'x-component': 'ArrayTable.Remove', 162 | }, 163 | moveDown: { 164 | type: 'void', 165 | 'x-component': 'ArrayTable.MoveDown', 166 | }, 167 | moveUp: { 168 | type: 'void', 169 | 'x-component': 'ArrayTable.MoveUp', 170 | }, 171 | }, 172 | }, 173 | }, 174 | }, 175 | }, 176 | }, 177 | properties: { 178 | add: { 179 | type: 'void', 180 | title: 'Add', 181 | 'x-component': 'ArrayTable.Addition', 182 | }, 183 | }, 184 | }, 185 | sum: { 186 | type: 'number', 187 | title: '合计', 188 | required: true, 189 | 'x-decorator': 'FormItem', 190 | 'x-component': 'NumberPicker', 191 | 'x-reactions': [ 192 | { 193 | type: 'formula', 194 | formula: 'SUM({!projects.total})', 195 | }, 196 | ], 197 | }, 198 | }, 199 | }); 200 | const variables = parseSchema(cleanSchema.schema); 201 | expect(variables.children.length).toBe(5); 202 | }); 203 | 204 | test('parseSchema', () => { 205 | const variables = parseSchema({ 206 | type: 'object', 207 | properties: { 208 | username: { 209 | type: 'string', 210 | title: '用户名', 211 | required: true, 212 | 'x-decorator': 'FormItem', 213 | 'x-component': 'Input', 214 | }, 215 | shopes: { 216 | type: 'array', 217 | title: '连锁店', 218 | items: { 219 | type: 'object', 220 | properties: { 221 | id: { 222 | type: 'string', 223 | title: 'ID', 224 | }, 225 | name: { 226 | type: 'string', 227 | title: '店名', 228 | }, 229 | }, 230 | }, 231 | }, 232 | }, 233 | }); 234 | expect(variables.children[1].children.length).toBe(0); 235 | }); 236 | -------------------------------------------------------------------------------- /src/formula-editor/functions.ts: -------------------------------------------------------------------------------- 1 | const functions = [ 2 | { 3 | name: '常用', 4 | functions: [ 5 | { 6 | name: 'SUM', 7 | description: '返回所有参与字段中数值的总和', 8 | }, 9 | { 10 | name: 'MAX', 11 | description: '返回所有参与字段中数值的最大值', 12 | }, 13 | { 14 | name: 'MIN', 15 | description: '返回所有参与字段中数值的最小值', 16 | }, 17 | { 18 | name: 'AVERAGE', 19 | description: '计算所有参与运算字段的平均值', 20 | }, 21 | { 22 | name: 'COUNT', 23 | description: '返回所有参与运算字段中值数字的数量', 24 | }, 25 | { 26 | name: 'COUNTA', 27 | description: '返回所有参与运算字段中值不为空的数量', 28 | }, 29 | { 30 | name: 'ROUND', 31 | description: '将数字四舍五入到指定的位数', 32 | }, 33 | { 34 | name: 'CONCATENATE', 35 | description: '将多个文字合并', 36 | }, 37 | { 38 | name: 'IF', 39 | description: '根据判断条件,返回正确或错误的值', 40 | }, 41 | { 42 | name: 'AND', 43 | description: 44 | '返回逻辑值:如果所有参数值均为逻辑“true”,则返回逻辑“true”,反之返回逻辑“false', 45 | }, 46 | { 47 | name: 'NOT', 48 | description: '对参数逻辑值求反', 49 | }, 50 | ], 51 | }, 52 | { 53 | name: '逻辑', 54 | functions: [ 55 | { 56 | name: 'IF', 57 | description: '根据判断条件,返回正确或错误的值', 58 | }, 59 | { 60 | name: 'AND', 61 | description: 62 | '返回逻辑值:如果所有参数值均为逻辑“true”,则返回逻辑“true”,反之返回逻辑“false', 63 | }, 64 | { 65 | name: 'OR', 66 | description: 67 | '任何一个参数逻辑值为true,即返回true;只有当所有逻辑参数值为false,才返回false', 68 | }, 69 | { 70 | name: 'NOT', 71 | description: '对参数逻辑值求反', 72 | }, 73 | { 74 | name: 'SWITCH', 75 | description: '按多组条件匹配返回值', 76 | }, 77 | ], 78 | }, 79 | { 80 | name: '文字', 81 | functions: [ 82 | { 83 | name: 'CONCATENATE', 84 | description: '将多个文字合并', 85 | }, 86 | { 87 | name: 'LEFT', 88 | description: '从左边截取指定长度的文字', 89 | }, 90 | { 91 | name: 'RIGHT', 92 | description: '从右边边截取指定长度的文字', 93 | }, 94 | { 95 | name: 'MID', 96 | description: '截取指定位置的文字', 97 | }, 98 | { 99 | name: 'REPLACE', 100 | description: '替换文本中的指定文字', 101 | }, 102 | { 103 | name: 'TRIM', 104 | description: '移除文字头尾的空格', 105 | }, 106 | { 107 | name: 'LEN', 108 | description: '后去文字的长度', 109 | }, 110 | { 111 | name: 'LOWER', 112 | description: '转成小写文字', 113 | }, 114 | { 115 | name: 'UPPER', 116 | description: '转成大写文字', 117 | }, 118 | { 119 | name: 'EXACT', 120 | description: '文字进行精确匹配', 121 | }, 122 | { 123 | name: 'FIND', 124 | description: '查找匹配文字的位置', 125 | }, 126 | { 127 | name: 'NUMBERVALUE', 128 | description: '返回数字的文本', 129 | }, 130 | { 131 | name: 'PROPER', 132 | description: '修正英文字母的大小写', 133 | }, 134 | { 135 | name: 'REPT', 136 | description: '将文本按指定次数重复显示', 137 | }, 138 | { 139 | name: 'SEARCH', 140 | description: 141 | '第二个文本字符串中查找第一个文本字符串,并返回第一个文本字符串的起始位置的编号', 142 | }, 143 | { 144 | name: 'SPLIT', 145 | description: '返回一个从零开始、一维 数组 指定数量的子字符串', 146 | }, 147 | { 148 | name: 'SUBSTITUTE', 149 | description: '在文本字符串中替换指定的文本', 150 | }, 151 | { 152 | name: 'TRIM', 153 | description: '在文本中移除前导空格和尾随空格', 154 | }, 155 | ], 156 | }, 157 | { 158 | name: '数字', 159 | functions: [ 160 | { 161 | name: 'ABS', 162 | description: '返回数字的绝对值', 163 | }, 164 | { 165 | name: 'AVERAGE', 166 | description: '计算所有参与运算字段的平均值', 167 | }, 168 | { 169 | name: 'CEILING', 170 | description: 171 | '将数字向上舍入(沿绝对值增大的方向)为最接近的指定基数的倍数', 172 | }, 173 | { 174 | name: 'CEILINGMATH', 175 | description: '将数字向上舍入为最接近的整数或最接近的指定基数的倍数', 176 | }, 177 | { 178 | name: 'COUNT', 179 | description: '返回所有参与运算字段中值数字的数量', 180 | }, 181 | { 182 | name: 'COUNTA', 183 | description: '返回所有参与运算字段中值不为空的数量', 184 | }, 185 | { 186 | name: 'EXP', 187 | description: '返回 e 的 n 次幂', 188 | }, 189 | { 190 | name: 'FLOOR', 191 | description: '返回数字向下舍入为最接近指定基数的倍数', 192 | }, 193 | { 194 | name: 'FLOORMATH', 195 | description: '将数字向下舍入为最接近的整数或最接近的指定基数的倍数', 196 | }, 197 | { 198 | name: 'MAX', 199 | description: '返回所有参与字段中的最大值', 200 | }, 201 | { 202 | name: 'MIN', 203 | description: '返回所有参与字段中的最小值', 204 | }, 205 | { 206 | name: 'INT', 207 | description: '将数字向下取整', 208 | }, 209 | { 210 | name: 'MOD', 211 | description: '返回两数相除的余数', 212 | }, 213 | { 214 | name: 'PRODUCT', 215 | description: '返回所有参与字段中数值的乘积', 216 | }, 217 | { 218 | name: 'SUM', 219 | description: '返回所有参与字段中数值的总和', 220 | }, 221 | { 222 | name: 'SUMPRODUCT', 223 | description: '返回所有参与字段中数值的总和', 224 | }, 225 | { 226 | name: 'SUMIF', 227 | description: '统计表格中符合条件的数值,并求和', 228 | }, 229 | { 230 | name: 'ROUND', 231 | description: '将数字四舍五入到指定的位数', 232 | }, 233 | { 234 | name: 'ROUNDUP', 235 | description: '将数字保留指定的位数,最后一位向上取', 236 | }, 237 | { 238 | name: 'ROUNDDOWN', 239 | description: '将数字保留指定的位数,最后一位向下取', 240 | }, 241 | { 242 | name: 'POWER', 243 | description: '计算数字num的n次方,n可以为分数或者整数', 244 | }, 245 | { 246 | name: 'LN', 247 | description: '计算指定数字的自然对数', 248 | }, 249 | { 250 | name: 'LOG', 251 | description: '根据指定底数返回数字的对数', 252 | }, 253 | { 254 | name: 'LOG10', 255 | description: '返回数字以 10 为底的对数', 256 | }, 257 | { 258 | name: 'SQRT', 259 | description: '计算指定数字的平方根', 260 | }, 261 | ], 262 | }, 263 | { 264 | name: '时间', 265 | functions: [ 266 | { 267 | name: 'DATE', 268 | description: 269 | '将数字拼接成为年份,数字字段顺序为:年/月/日/时/分/秒', 270 | }, 271 | { 272 | name: 'DAY', 273 | description: '返回日期在一个月中的第几天的数值', 274 | }, 275 | { 276 | name: 'DAYS', 277 | description: '返回日期字段1与日期字段2的差值,单位为天', 278 | }, 279 | { 280 | name: 'DAYS360', 281 | description: '按照一年 360 天的算法,返回两个日期间相差的天数', 282 | }, 283 | { 284 | name: 'EDATE', 285 | description: '返回指定日期相隔指定月份的日期', 286 | }, 287 | { 288 | name: 'EOMONTH', 289 | description: '返回指定日期相隔指定月份的最后一天', 290 | }, 291 | { 292 | name: 'YEAR', 293 | description: '返回日期中的年份', 294 | }, 295 | { 296 | name: 'MONTH', 297 | description: '返回日期中的月份', 298 | }, 299 | { 300 | name: 'HOUR', 301 | description: '返回日期中的小时,日期字段必须精确到时间才可以计算出来', 302 | }, 303 | { 304 | name: 'MINUTE', 305 | description: '返回日期中的分钟,日期字段必须精确到时间才可以计算出来', 306 | }, 307 | { 308 | name: 'SECOND', 309 | description: '返回日期中的秒,日期字段必须精确到时间才可以计算出来', 310 | }, 311 | { 312 | name: 'TODAY', 313 | description: '返回当前日期', 314 | }, 315 | { 316 | name: 'NOW', 317 | description: '返回当前时间,精确到时/分/秒', 318 | }, 319 | { 320 | name: 'WEEKDAY', 321 | description: '返回对应于某个日期的一周中的第几天', 322 | }, 323 | { 324 | name: 'WEEKNUM', 325 | description: '返回指定日期在该年的周数', 326 | }, 327 | ], 328 | }, 329 | ]; 330 | 331 | export default functions; 332 | -------------------------------------------------------------------------------- /src/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | nav: 3 | title: Components 4 | path: /components 5 | --- 6 | 7 | Install dependencies, 8 | 9 | ```bash 10 | $ npm i @toy-box/form-formula 11 | ``` 12 | 13 | ## 公式编辑器 14 | 15 | ```tsx 16 | import React, { useState } from 'react'; 17 | import 'antd/dist/antd.css'; 18 | import 'codemirror/lib/codemirror.css'; 19 | import { FormulaEditor } from '@toy-box/form-formula'; 20 | import { formulaTreeTest } from '@toy-box/formula'; 21 | 22 | const style = { 23 | border: '1px solid gray', 24 | wordBreak: 'break-word', 25 | }; 26 | 27 | const schema = { 28 | type: 'object', 29 | properties: { 30 | username: { 31 | type: 'string', 32 | title: '用户名', 33 | required: true, 34 | 'x-decorator': 'FormItem', 35 | 'x-component': 'Input', 36 | 'x-reactions': [ 37 | { 38 | type: 'formula', 39 | formula: 'CONCATENATE({!firstName}, " ", {!lastName})', 40 | }, 41 | ], 42 | }, 43 | name: { 44 | type: 'void', 45 | title: '姓名', 46 | 'x-decorator': 'FormItem', 47 | 'x-decorator-props': { 48 | asterisk: true, 49 | feedbackLayout: 'none', 50 | }, 51 | 'x-component': 'FormGrid', 52 | properties: { 53 | firstName: { 54 | type: 'string', 55 | required: true, 56 | title: '姓', 57 | 'x-decorator': 'FormItem', 58 | 'x-component': 'Input', 59 | 'x-component-props': { 60 | placeholder: '姓', 61 | }, 62 | }, 63 | lastName: { 64 | type: 'string', 65 | required: true, 66 | title: '名', 67 | 'x-decorator': 'FormItem', 68 | 'x-component': 'Input', 69 | 'x-component-props': { 70 | placeholder: '名', 71 | }, 72 | }, 73 | }, 74 | }, 75 | projects: { 76 | type: 'array', 77 | title: 'Projects', 78 | 'x-decorator': 'FormItem', 79 | 'x-component': 'ArrayTable', 80 | items: { 81 | type: 'object', 82 | properties: { 83 | column_1: { 84 | type: 'void', 85 | 'x-component': 'ArrayTable.Column', 86 | 'x-component-props': { 87 | width: 50, 88 | title: 'Sort', 89 | align: 'center', 90 | }, 91 | properties: { 92 | sortable: { 93 | type: 'void', 94 | 'x-component': 'ArrayTable.SortHandle', 95 | }, 96 | }, 97 | }, 98 | column_2: { 99 | type: 'void', 100 | 'x-component': 'ArrayTable.Column', 101 | 'x-component-props': { 102 | width: 50, 103 | title: 'Index', 104 | align: 'center', 105 | }, 106 | properties: { 107 | index: { 108 | type: 'void', 109 | 'x-component': 'ArrayTable.Index', 110 | }, 111 | }, 112 | }, 113 | column_3: { 114 | type: 'void', 115 | 'x-component': 'ArrayTable.Column', 116 | 'x-component-props': { 117 | title: 'Price', 118 | }, 119 | properties: { 120 | price: { 121 | type: 'number', 122 | title: 'Price', 123 | default: 0, 124 | 'x-decorator': 'Editable', 125 | 'x-component': 'NumberPicker', 126 | 'x-component-props': { 127 | addonafter: '$', 128 | }, 129 | }, 130 | }, 131 | }, 132 | column_4: { 133 | type: 'void', 134 | 'x-component': 'ArrayTable.Column', 135 | 'x-component-props': { 136 | title: 'Count', 137 | }, 138 | properties: { 139 | count: { 140 | type: 'number', 141 | title: 'Count', 142 | default: 0, 143 | 'x-decorator': 'Editable', 144 | 'x-component': 'NumberPicker', 145 | 'x-component-props': { 146 | addonafter: '$', 147 | }, 148 | }, 149 | }, 150 | }, 151 | column_5: { 152 | type: 'void', 153 | 'x-component': 'ArrayTable.Column', 154 | 'x-component-props': { 155 | title: 'Total', 156 | }, 157 | properties: { 158 | total: { 159 | type: 'number', 160 | title: 'Total', 161 | 'x-read-pretty': true, 162 | 'x-decorator': 'FormItem', 163 | 'x-component': 'NumberPicker', 164 | 'x-component-props': { 165 | addonafter: '$', 166 | }, 167 | 'x-reactions': [ 168 | { 169 | type: 'formula', 170 | formula: '{!projects.price} * {!projects.count}', 171 | }, 172 | ], 173 | }, 174 | }, 175 | }, 176 | column_6: { 177 | type: 'void', 178 | 'x-component': 'ArrayTable.Column', 179 | 'x-component-props': { 180 | title: 'Operations', 181 | }, 182 | properties: { 183 | item: { 184 | type: 'void', 185 | 'x-component': 'FormItem', 186 | properties: { 187 | remove: { 188 | type: 'void', 189 | 'x-component': 'ArrayTable.Remove', 190 | }, 191 | moveDown: { 192 | type: 'void', 193 | 'x-component': 'ArrayTable.MoveDown', 194 | }, 195 | moveUp: { 196 | type: 'void', 197 | 'x-component': 'ArrayTable.MoveUp', 198 | }, 199 | }, 200 | }, 201 | }, 202 | }, 203 | }, 204 | }, 205 | properties: { 206 | add: { 207 | type: 'void', 208 | title: 'Add', 209 | 'x-component': 'ArrayTable.Addition', 210 | }, 211 | }, 212 | }, 213 | sum: { 214 | type: 'number', 215 | title: '合计', 216 | required: true, 217 | 'x-decorator': 'FormItem', 218 | 'x-component': 'NumberPicker', 219 | 'x-reactions': [ 220 | { 221 | type: 'formula', 222 | formula: 'SUM({!projects.total})', 223 | }, 224 | ], 225 | }, 226 | }, 227 | }; 228 | 229 | export default () => { 230 | const [value, setValue] = useState(); 231 | return ( 232 | <> 233 | 241 | 242 | ); 243 | }; 244 | ``` 245 | 246 | ## 公式执行 247 | 248 | ```tsx 249 | import React from 'react'; 250 | import { 251 | ArrayTable, 252 | Editable, 253 | Input, 254 | FormItem, 255 | FormLayout, 256 | FormGrid, 257 | Select, 258 | NumberPicker, 259 | } from '@formily/antd'; 260 | import { createForm } from '@formily/core'; 261 | import { schemaPatch } from '@toy-box/form-formula'; 262 | import { 263 | FormProvider, 264 | createSchemaField, 265 | Schema, 266 | ISchema, 267 | } from '@formily/react'; 268 | import 'codemirror/lib/codemirror.css'; 269 | 270 | const SchemaField = createSchemaField({ 271 | components: { 272 | FormGrid, 273 | Input, 274 | Select, 275 | NumberPicker, 276 | FormItem, 277 | FormLayout, 278 | ArrayTable, 279 | Editable, 280 | }, 281 | }); 282 | 283 | const schema = { 284 | type: 'object', 285 | properties: { 286 | username: { 287 | type: 'string', 288 | title: '用户名', 289 | required: true, 290 | 'x-decorator': 'FormItem', 291 | 'x-component': 'Input', 292 | 'x-reactions': [ 293 | { 294 | type: 'formula', 295 | formula: 'CONCATENATE({!firstName}, " ", {!lastName})', 296 | }, 297 | ], 298 | }, 299 | name: { 300 | type: 'void', 301 | title: '姓名', 302 | 'x-decorator': 'FormItem', 303 | 'x-decorator-props': { 304 | asterisk: true, 305 | feedbackLayout: 'none', 306 | }, 307 | 'x-component': 'FormGrid', 308 | properties: { 309 | firstName: { 310 | type: 'string', 311 | required: true, 312 | title: '姓', 313 | 'x-decorator': 'FormItem', 314 | 'x-component': 'Input', 315 | 'x-component-props': { 316 | placeholder: '姓', 317 | }, 318 | }, 319 | lastName: { 320 | type: 'string', 321 | required: true, 322 | title: '名', 323 | 'x-decorator': 'FormItem', 324 | 'x-component': 'Input', 325 | 'x-component-props': { 326 | placeholder: '名', 327 | }, 328 | }, 329 | }, 330 | }, 331 | projects: { 332 | type: 'array', 333 | title: 'Projects', 334 | 'x-decorator': 'FormItem', 335 | 'x-component': 'ArrayTable', 336 | items: { 337 | type: 'object', 338 | properties: { 339 | column_1: { 340 | type: 'void', 341 | 'x-component': 'ArrayTable.Column', 342 | 'x-component-props': { 343 | width: 50, 344 | title: 'Sort', 345 | align: 'center', 346 | }, 347 | properties: { 348 | sortable: { 349 | type: 'void', 350 | 'x-component': 'ArrayTable.SortHandle', 351 | }, 352 | }, 353 | }, 354 | column_2: { 355 | type: 'void', 356 | 'x-component': 'ArrayTable.Column', 357 | 'x-component-props': { 358 | width: 50, 359 | title: 'Index', 360 | align: 'center', 361 | }, 362 | properties: { 363 | index: { 364 | type: 'void', 365 | 'x-component': 'ArrayTable.Index', 366 | }, 367 | }, 368 | }, 369 | column_3: { 370 | type: 'void', 371 | 'x-component': 'ArrayTable.Column', 372 | 'x-component-props': { 373 | title: 'Price', 374 | }, 375 | properties: { 376 | price: { 377 | type: 'number', 378 | title: 'Price', 379 | default: 0, 380 | 'x-decorator': 'Editable', 381 | 'x-component': 'NumberPicker', 382 | 'x-component-props': { 383 | addonafter: '$', 384 | }, 385 | }, 386 | }, 387 | }, 388 | column_4: { 389 | type: 'void', 390 | 'x-component': 'ArrayTable.Column', 391 | 'x-component-props': { 392 | title: 'Count', 393 | }, 394 | properties: { 395 | count: { 396 | type: 'number', 397 | title: 'Count', 398 | default: 0, 399 | 'x-decorator': 'Editable', 400 | 'x-component': 'NumberPicker', 401 | 'x-component-props': { 402 | addonafter: '$', 403 | }, 404 | }, 405 | }, 406 | }, 407 | column_5: { 408 | type: 'void', 409 | 'x-component': 'ArrayTable.Column', 410 | 'x-component-props': { 411 | title: 'Total', 412 | }, 413 | properties: { 414 | total: { 415 | type: 'number', 416 | title: 'Total', 417 | 'x-read-pretty': true, 418 | 'x-decorator': 'FormItem', 419 | 'x-component': 'NumberPicker', 420 | 'x-component-props': { 421 | addonafter: '$', 422 | }, 423 | 'x-reactions': [ 424 | { 425 | type: 'formula', 426 | formula: '{!projects.price} * {!projects.count}', 427 | }, 428 | ], 429 | }, 430 | }, 431 | }, 432 | column_6: { 433 | type: 'void', 434 | 'x-component': 'ArrayTable.Column', 435 | 'x-component-props': { 436 | title: 'Operations', 437 | }, 438 | properties: { 439 | item: { 440 | type: 'void', 441 | 'x-component': 'FormItem', 442 | properties: { 443 | remove: { 444 | type: 'void', 445 | 'x-component': 'ArrayTable.Remove', 446 | }, 447 | moveDown: { 448 | type: 'void', 449 | 'x-component': 'ArrayTable.MoveDown', 450 | }, 451 | moveUp: { 452 | type: 'void', 453 | 'x-component': 'ArrayTable.MoveUp', 454 | }, 455 | }, 456 | }, 457 | }, 458 | }, 459 | }, 460 | }, 461 | properties: { 462 | add: { 463 | type: 'void', 464 | title: 'Add', 465 | 'x-component': 'ArrayTable.Addition', 466 | }, 467 | }, 468 | }, 469 | sum: { 470 | type: 'number', 471 | title: '合计', 472 | required: true, 473 | 'x-decorator': 'FormItem', 474 | 'x-component': 'NumberPicker', 475 | 'x-reactions': [ 476 | { 477 | type: 'formula', 478 | formula: 'SUM({!projects.total})', 479 | }, 480 | ], 481 | }, 482 | }, 483 | }; 484 | 485 | export default () => { 486 | const form = createForm(); 487 | Schema.registerPatches(schemaPatch); 488 | return ( 489 |
490 | 491 | 492 | 493 |
494 | ); 495 | }; 496 | ``` 497 | --------------------------------------------------------------------------------