chore: 公式编辑器 input 换成 codemirror 提升交互体验 (#9821)

This commit is contained in:
liaoxuezhi 2024-03-20 20:30:50 +08:00 committed by 2betop
parent 95787b98ef
commit 90ced059b1
39 changed files with 1636 additions and 923 deletions

View File

@ -102,8 +102,8 @@ order: 21
"name": "formula",
"label": "公式",
"variableMode": "tree",
"evalMode": false,
"value": "${SUM(1 , 2)}",
"evalMode": true,
"value": "SUM(1 , 2)",
"inputMode": "button",
"variables": [
{
@ -368,77 +368,6 @@ Tab 结构:
}
```
## 高亮文本
通过配置`allowInput`为`false`可以高亮文本内容,但是只能在编辑器中编辑
```schema: scope="body"
{
"type": "form",
"debug": true,
"body": [
{
"type": "input-formula",
"name": "formula",
"allowInput": false,
"label": "公式",
"evalMode": true,
"value": "SUM(1, 2)",
"variables": [
{
"label": "表单字段",
"children": [
{
"label": "文章名",
"value": "name",
"tag": "文本"
},
{
"label": "作者",
"value": "author",
"tag": "文本"
},
{
"label": "售价",
"value": "price",
"tag": "数字"
},
{
"label": "出版时间",
"value": "time",
"tag": "时间"
},
{
"label": "版本号",
"value": "version",
"tag": "数字"
},
{
"label": "出版社",
"value": "publisher",
"tag": "文本"
}
]
},
{
"label": "流程字段",
"children": [
{
"label": "联系电话",
"value": "telphone"
},
{
"label": "地址",
"value": "addr"
}
]
}
],
}
]
}
```
## 模板模式
当配置 `evalMode` 为 false 时则为模板模式,意思是说默认不当做表达式,只有 `${`和`}`包裹的部分才是表达式。

View File

@ -59,9 +59,12 @@ export function StatusScoped<
} = {
statusStore: this.store!
};
const refConfig = ComposedComponent.prototype?.isReactComponent
? {ref: this.childRef}
: {forwardedRef: this.childRef};
const refConfig =
ComposedComponent.prototype?.isReactComponent ||
(ComposedComponent as any).$$typeof ===
Symbol.for('react.forward_ref')
? {ref: this.childRef}
: {forwardedRef: this.childRef};
return (
<ComposedComponent

View File

@ -162,9 +162,12 @@ export function localeable<
translate: translate!
};
moment.locale(momentLocaleMap?.[locale] ?? locale);
const refConfig = ComposedComponent.prototype?.isReactComponent
? {ref: this.childRef}
: {forwardedRef: this.childRef};
const refConfig =
ComposedComponent.prototype?.isReactComponent ||
(ComposedComponent as any).$$typeof ===
Symbol.for('react.forward_ref')
? {ref: this.childRef}
: {forwardedRef: this.childRef};
const body = (
<ComposedComponent

View File

@ -182,9 +182,12 @@ export function themeable<
classnames: config.classnames,
theme
};
const refConfig = ComposedComponent.prototype?.isReactComponent
? {ref: this.childRef}
: {forwardedRef: this.childRef};
const refConfig =
ComposedComponent.prototype?.isReactComponent ||
(ComposedComponent as any).$$typeof ===
Symbol.for('react.forward_ref')
? {ref: this.childRef}
: {forwardedRef: this.childRef};
const body = (
<ComposedComponent

View File

@ -212,7 +212,7 @@ export function isExpression(expression: any): boolean {
// 备注1: "\\${xxx}"不作为表达式,至少含一个${xxx}才算是表达式
// 备注2: safari 不支持 /(?<!\\)(\${).+(\})/.test(expression)
return /(^|[^\\])\$\{.+\}/.test(expression);
return /(^|[^\\])\$\{[\s\S]+\}/.test(expression);
}
// 用于判断是否需要执行表达式:

View File

@ -38,4 +38,6 @@
}
.btn-configured-tooltip {
font-size: 12px;
min-width: 320px;
min-height: 320px;
}

View File

@ -33,9 +33,9 @@
& > .CodeMirror {
height: 100%;
font-family: inherit;
span[class^='cm-'] {
color: var(--input-default-default-color);
}
// span[class^='cm-'] {
// color: var(--input-default-default-color);
// }
// 解决上下 pre标签中表达式浮层遮挡问题
.CodeMirror-measure + div {

View File

@ -48,9 +48,9 @@
color: var(--Form-input-color);
font-family: inherit;
span[class^='cm-'] {
color: var(--input-default-default-color);
}
// span[class^='cm-'] {
// color: var(--input-default-default-color);
// }
// 解决上下 pre标签中表达式浮层遮挡问题
.CodeMirror-measure + div {

View File

@ -890,7 +890,7 @@ export class BaseCRUDPlugin extends BasePlugin {
step: 10,
min: 1000
},
getSchemaTpl('tplFormulaControl', {
getSchemaTpl('expressionFormulaControl', {
name: 'stopAutoRefreshWhen',
label: tipedLabel(
'停止条件',

View File

@ -6,9 +6,8 @@ import React from 'react';
import {autobind, FormControlProps} from 'amis-core';
import cx from 'classnames';
import {FormItem, Button, Icon, PickerContainer} from 'amis';
import {FormulaEditor} from 'amis-ui';
import {FormulaCodeEditor, FormulaEditor} from 'amis-ui';
import type {VariableItem} from 'amis-ui';
import {renderFormulaValue} from './FormulaControl';
import {reaction} from 'mobx';
import {getVariables} from 'amis-editor-core';
@ -76,6 +75,12 @@ export default class ExpressionFormulaControl extends React.Component<
this.appCorpusData = editorStore?.appCorpusData;
}
);
// 要高亮,初始就要加载
const variablesArr = await getVariables(this);
this.setState({
variables: variablesArr
});
}
async componentDidUpdate(prevProps: ExpressionFormulaControlProps) {
@ -133,13 +138,6 @@ export default class ExpressionFormulaControl extends React.Component<
const {value, className, variableMode, header, size, ...rest} = this.props;
const {formulaPickerValue, variables} = this.state;
const highlightValue = FormulaEditor.highlightValue(
formulaPickerValue,
variables
) || {
html: formulaPickerValue
};
// 自身字段
const selfName = this.props?.data?.name;
return (
@ -180,11 +178,25 @@ export default class ExpressionFormulaControl extends React.Component<
mouseLeaveDelay: 20,
content: value,
tooltipClassName: 'btn-configured-tooltip',
children: () => renderFormulaValue(highlightValue)
children: () => (
<FormulaCodeEditor
readOnly
value={value}
variables={variables}
evalMode={false}
editorTheme="dark"
/>
)
}}
onClick={e => this.handleOnClick(e, onClick)}
>
{renderFormulaValue(highlightValue)}
<FormulaCodeEditor
singleLine
readOnly
value={value}
variables={variables}
evalMode={false}
/>
<Icon
icon="input-clear"
className="icon"

View File

@ -19,7 +19,7 @@ import {
TooltipWrapper
} from 'amis';
import {FormulaExec, isExpression} from 'amis';
import {FormulaEditor} from 'amis-ui';
import {FormulaCodeEditor, FormulaEditor} from 'amis-ui';
import FormulaPicker, {
CustomFormulaPickerProps
@ -29,7 +29,7 @@ import {JSONPipeOut, autobind, translateSchema} from 'amis-editor-core';
import type {
VariableItem,
FuncGroup
} from 'amis-ui/lib/components/formula/Editor';
} from 'amis-ui/lib/components/formula/CodeEditor';
import {FormControlProps} from 'amis-core';
import type {BaseEventContext} from 'amis-editor-core';
import {EditorManager} from 'amis-editor-core';
@ -42,6 +42,11 @@ export enum FormulaDateType {
IsRange // 日期时间范围类
}
/**
* @deprecated codemirror
* @param item
* @returns
*/
export function renderFormulaValue(item: any) {
const html = {__html: typeof item === 'string' ? item : item?.html};
// bca-disable-next-line
@ -189,6 +194,9 @@ export default class FormulaControl extends React.Component<
this.appCorpusData = editorStore?.appCorpusData;
}
);
const variables = await getVariables(this);
this.setState({variables});
}
componentWillUnmount() {
@ -346,8 +354,8 @@ export default class FormulaControl extends React.Component<
transExpr(str: string) {
if (
typeof str === 'string' &&
str?.slice(0, 2) === '${' &&
str?.slice(-1) === '}'
str.slice(0, 2) === '${' &&
str.slice(-1) === '}'
) {
// 非最外层内容还存在表达式情况
if (isExpression(str.slice(2, -1))) {
@ -580,12 +588,6 @@ export default class FormulaControl extends React.Component<
const FormulaPickerCmp = customFormulaPicker ?? FormulaPicker;
const highlightValue = isExpression(value)
? FormulaEditor.highlightValue(exprValue, variables) || {
html: exprValue
}
: value;
// 公式表达式弹窗内容过滤
const filterValue = isExpression(value)
? exprValue
@ -651,40 +653,38 @@ export default class FormulaControl extends React.Component<
tooltipTheme: 'dark',
mouseLeaveDelay: 20,
content: exprValue,
children: () => renderFormulaValue(highlightValue)
tooltipClassName: 'btn-configured-tooltip',
children: () => (
<FormulaCodeEditor
readOnly
value={value}
variables={variables}
evalMode={false}
editorTheme="dark"
/>
)
}}
>
<div className="ae-editor-FormulaControl-tooltipBox">
<div
className="ae-editor-FormulaControl-ResultBox-wrapper"
<div className={cx('ae-editor-FormulaControl-tooltipBox')}>
<InputBox
onClick={this.handleFormulaClick}
>
<ResultBox
className={cx(
'ae-editor-FormulaControl-ResultBox',
isError ? 'is-error' : ''
)}
allowInput={false}
value={value}
result={{
html: this.hasDateShortcutkey(value)
? value
: highlightValue?.html
}}
itemRender={renderFormulaValue}
onChange={this.handleInputChange}
onResultChange={() => {
this.handleInputChange(undefined);
}}
/>
</div>
{value && (
<Icon
icon="input-clear"
className="input-clear-icon"
onClick={() => this.handleInputChange('')}
/>
)}
hasError={isError}
inputRender={({value, onChange, onFocus, onBlur}: any) => (
<FormulaCodeEditor
singleLine
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
functions={[]}
variables={variables}
evalMode={false}
readOnly
/>
)}
value={value}
onChange={this.handleInputChange}
/>
</div>
</TooltipWrapper>
)}

View File

@ -5,12 +5,11 @@
import React from 'react';
import cx from 'classnames';
import {reaction} from 'mobx';
import {CodeMirrorEditor, FormulaEditor} from 'amis-ui';
import {CodeMirrorEditor, FormulaCodeEditor, FormulaEditor} from 'amis-ui';
import type {VariableItem, CodeMirror} from 'amis-ui';
import {Icon, Button, FormItem, TooltipWrapper} from 'amis';
import {autobind, FormControlProps} from 'amis-core';
import {FormulaPlugin, editorFactory} from './textarea-formula/plugin';
import {renderFormulaValue} from './FormulaControl';
import FormulaPicker, {
CustomFormulaPickerProps
} from './textarea-formula/FormulaPicker';
@ -383,13 +382,6 @@ export class TplFormulaControl extends React.Component<
const FormulaPickerCmp = customFormulaPicker ?? FormulaPicker;
const highlightValue = FormulaEditor.highlightValue(
formulaPickerValue,
variables
) || {
html: formulaPickerValue
};
return (
<div
className={cx('ae-TplFormulaControl', className, {
@ -441,11 +433,20 @@ export class TplFormulaControl extends React.Component<
<TooltipWrapper
trigger="hover"
placement="top"
placement="auto"
style={{fontSize: '12px'}}
tooltip={{
tooltipTheme: 'dark',
children: () => renderFormulaValue(highlightValue)
tooltipClassName: 'btn-configured-tooltip',
children: () => (
<FormulaCodeEditor
readOnly
value={formulaPickerValue}
variables={variables}
evalMode={true}
editorTheme="dark"
/>
)
}}
>
<div

View File

@ -2926,8 +2926,10 @@ export const getEventControlConfig = (
return {
showOldEntry:
!!context.schema.actionType ||
['submit', 'reset'].includes(context.schema.type),
!!(
context.schema.actionType &&
!['dialog', 'drawer'].includes(context.schema.type)
) || ['submit', 'reset'].includes(context.schema.type),
actions: manager?.pluginActions,
events: manager?.pluginEvents,
actionTree,

View File

@ -49,7 +49,7 @@ import {
} from 'amis-editor-core';
export * from './helper';
import {i18n as _i18n} from 'i18n-runtime';
import type {VariableItem} from 'amis-ui/lib/components/formula/Editor';
import type {VariableItem} from 'amis-ui/src/components/formula/CodeEditor';
import {reaction} from 'mobx';
import {updateComponentContext} from 'amis-editor-core';

View File

@ -2,7 +2,7 @@ import React, {useEffect} from 'react';
import {Modal, Button} from 'amis';
import {FormControlProps} from 'amis-core';
import cx from 'classnames';
import FormulaEditor from 'amis-ui/lib/components/formula/Editor';
import {FormulaEditor} from 'amis-ui';
export interface FormulaPickerProps extends FormControlProps {
onConfirm: (data: string | undefined) => void;

View File

@ -10,7 +10,7 @@ import type {SchemaObject} from 'amis';
import flatten from 'lodash/flatten';
import {InputComponentName} from '../component/InputComponentName';
import {FormulaDateType} from '../renderer/FormulaControl';
import {VariableItem} from 'amis-ui/lib/components/formula/Editor';
import type {VariableItem} from 'amis-ui/src/components/formula/CodeEditor';
import reduce from 'lodash/reduce';
import map from 'lodash/map';
import omit from 'lodash/omit';

View File

@ -452,6 +452,7 @@
fill: var(--Form-input-clearBtn-color);
width: var(--Form-input-clearBtn-size);
height: var(--Form-input-clearBtn-size);
top: 0;
}
&:hover svg {

View File

@ -112,6 +112,7 @@
position: relative;
&-wrapper {
flex: 1;
min-width: 0;
}
&-collapse {
@ -139,6 +140,7 @@
&-body-wrapper {
flex: 1;
min-width: 0;
}
&-toolbar {
@ -323,6 +325,8 @@
display: flex;
flex-direction: row;
align-items: center;
flex: 1;
min-width: 0;
> .#{$ns}CBGroupOrItem-dragbar {
left: px2rem(10px);
position: absolute;
@ -431,11 +435,23 @@
}
}
.#{$ns}CBItem {
display: flex;
flex: 1;
min-width: 0;
> * {
flex-shrink: 0;
}
}
.#{$ns}CBValue {
position: relative;
display: inline-block;
vertical-align: middle;
margin: px2rem(3px);
flex: 1;
min-width: 0;
}
.#{$ns}CBFormula {
@ -461,6 +477,7 @@
width: 20px;
text-align: center;
display: inline-block;
align-self: center;
user-select: none;
}

View File

@ -2,6 +2,7 @@
overflow: visible;
max-width: 100%;
box-sizing: content-box;
min-height: px2rem(450px);
@mixin scrollbar {
&::-webkit-scrollbar {
@ -31,10 +32,59 @@
flex: 1;
height: 100%;
max-width: #{px2rem(530px)};
min-width: 0;
border-top: var(--Form-input-borderWidth) solid
var(--Form-input-borderColor);
border-bottom: var(--Form-input-borderWidth) solid
var(--Form-input-borderColor);
display: flex;
flex-direction: column;
}
&-runPanel {
&.in {
height: 0;
overflow: hidden;
}
height: px2rem(200px);
transition: height 0.1s ease-out;
border-top: 1px solid var(--Form-input-borderColor);
display: flex;
flex-wrap: nowrap;
flex-direction: row;
&-context,
&-result {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
> header {
@include panel-header();
margin: 0;
}
> div {
flex: 1;
min-height: 0;
font-size: 12px;
}
}
&-context {
border-right: 1px solid var(--Form-input-borderColor);
}
&-result {
> div {
padding: 0 px2rem(10px);
}
&.is-error {
color: var(--Form-input-onError-borderColor);
}
}
}
&-header {
@ -51,8 +101,8 @@
&-editor {
@include scrollbar();
min-height: px2rem(200px);
height: calc(100% - 35px);
flex: 1;
min-height: 0;
padding: #{px2rem(5px)};
padding-right: 0;
@ -418,27 +468,6 @@
overflow: hidden;
background: var(--InputFormula-code-bgColor);
}
.cm-field,
.cm-func {
border-radius: 3px;
color: #fff;
margin: 0 1px;
padding: 0 2px;
}
.cm-field {
padding: 2px 5px;
}
.cm-field {
background: #007bff;
}
.cm-func {
color: #ae4597;
font-weight: bold;
line-height: 14px;
}
}
.#{$ns}FormulaPicker {
@ -459,8 +488,9 @@
&-input {
flex: 1;
min-width: 0;
margin-right: #{px2rem(10px)};
padding-right: 0;
padding-right: px2rem(4px);
max-width: calc(100% - #{px2rem(42px)});
}
@ -515,7 +545,8 @@
height: var(--Form-input-height);
&.#{$ns}FormulaPicker--text {
padding: var(--Form-input-paddingY) var(--Form-input-paddingX);
padding: var(--Form-input-paddingY) var(--Form-input-paddingX)
var(--Form-input-paddingY) px2rem(5px);
}
.#{$ns}FormulaPicker-input {
@ -534,8 +565,8 @@
box-sizing: none;
}
&-number,
&-select,
&-number,
&-boolean,
&-date,
&-time,
@ -548,6 +579,7 @@
.#{$ns}Number-handler-wrap {
height: unset; /* 避免调节器超出Input框 */
}
padding-left: 0;
}
&-variable {
@ -575,3 +607,67 @@
}
}
}
.#{$ns}FormulaCodeEditor {
.cm-field,
.cm-func {
border-radius: 3px;
color: #fff;
}
.cm-field {
padding: 2px 5px;
}
.cm-field {
background: #007bff;
}
.cm-func {
color: #ae4597;
font-weight: bold;
line-height: 14px;
}
.cm-error-token {
background-position: left bottom;
background-repeat: repeat-x;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAADCAYAAAC09K7GAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sJDw4cOCW1/KIAAAAZdEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIEdJTVBXgQ4XAAAAHElEQVQI12NggIL/DAz/GdA5/xkY/qPKMDAwAADLZwf5rvm+LQAAAABJRU5ErkJggg==);
}
.CodeMirror-placeholder {
color: var(--Form-input-placeholderColor) !important;
}
.lint-error {
color: var(--Form-input-onError-borderColor);
}
&--singleLine {
max-width: 100%;
> .CodeMirror {
height: 21px;
.CodeMirror-hscrollbar,
.CodeMirror-vscrollbar {
display: none !important;
}
.CodeMirror-sizer {
min-height: 21px !important;
}
.CodeMirror-scroll {
height: 21px;
margin: 0;
padding: 0;
overflow: hidden !important;
}
.CodeMirror-lines {
padding: 0;
}
}
}
}
.#{$ns}InputBox > .#{$ns}FormulaCodeEditor {
flex: 1;
min-width: 0;
}

View File

@ -1,13 +1,17 @@
import React from 'react';
// import 'codemirror/lib/codemirror.css';
import type CodeMirror from 'codemirror';
import {autobind} from 'amis-core';
import {autobind, changedEffect} from 'amis-core';
import {resizeSensor} from 'amis-core';
import 'codemirror/theme/base16-dark.css';
// import 'codemirror/theme/base16-light.css';
export interface CodeMirrorEditorProps {
className?: string;
style?: any;
value?: string;
readOnly?: boolean;
onChange?: (value: string) => void;
onFocus?: (e: any) => void;
onBlur?: (e: any) => void;
@ -37,14 +41,17 @@ export class CodeMirrorEditor extends React.Component<CodeMirrorEditorProps> {
await import('codemirror/mode/htmlmixed/htmlmixed');
await import('codemirror/addon/mode/simple');
await import('codemirror/addon/mode/multiplex');
await import('codemirror/addon/display/placeholder');
if (this.unmounted) {
return;
}
this.dom.current!.innerHTML = '';
this.editor =
this.props.editorFactory?.(this.dom.current!, cm, this.props) ??
cm(this.dom.current!, {
value: this.props.value || ''
value: this.props.value || '',
readOnly: this.props.readOnly ? 'nocursor' : false
});
this.props.editorDidMount?.(cm, this.editor);
@ -52,6 +59,8 @@ export class CodeMirrorEditor extends React.Component<CodeMirrorEditorProps> {
this.editor.on('blur', this.handleBlur);
this.editor.on('focus', this.handleFocus);
this.setValue(this.props.value);
this.toDispose.push(
resizeSensor(this.dom.current as HTMLElement, () =>
this.editor?.refresh()
@ -70,6 +79,10 @@ export class CodeMirrorEditor extends React.Component<CodeMirrorEditorProps> {
if (props.value !== prevProps.value) {
this.editor && this.setValue(props.value);
}
changedEffect(['readOnly'], prevProps, this.props, (changes: any) => {
this.editor?.setOption('readOnly', changes.readOnly ? 'nocursor' : false);
});
}
componentWillUnmount() {
@ -97,9 +110,9 @@ export class CodeMirrorEditor extends React.Component<CodeMirrorEditorProps> {
setValue(value?: string) {
const doc = this.editor!.getDoc();
if (value && value !== doc.getValue()) {
if (value !== doc.getValue()) {
const cursor = doc.getCursor();
doc.setValue(value);
doc.setValue(value || '');
doc.setCursor(cursor);
}
}

View File

@ -19,6 +19,7 @@ export interface InputBoxProps
children?: React.ReactNode | Array<React.ReactNode>;
borderMode?: 'full' | 'half' | 'none';
testid?: string;
inputRender?: (props: any, ref?: any) => JSX.Element;
}
export interface InputBoxState {
@ -49,7 +50,7 @@ export class InputBox extends React.Component<InputBoxProps, InputBoxState> {
@autobind
handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const onChange = this.props.onChange;
onChange && onChange(e.currentTarget.value);
onChange && onChange(e.currentTarget ? e.currentTarget.value : (e as any));
}
@autobind
@ -86,6 +87,7 @@ export class InputBox extends React.Component<InputBoxProps, InputBoxState> {
onClick,
mobileUI,
testid,
inputRender,
...rest
} = this.props;
const isFocused = this.state.isFocused;
@ -104,17 +106,30 @@ export class InputBox extends React.Component<InputBoxProps, InputBoxState> {
>
{result}
<Input
{...rest}
value={value ?? ''}
onChange={this.handleChange}
placeholder={placeholder}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
size={12}
disabled={disabled}
{...buildTestId(testid)}
/>
{typeof inputRender === 'function' ? (
inputRender({
...rest,
value: value ?? '',
onChange: this.handleChange as any,
placeholder,
onFocus: this.handleFocus,
onBlur: this.handleBlur,
disabled,
...buildTestId(testid)
})
) : (
<Input
{...rest}
value={value ?? ''}
onChange={this.handleChange}
placeholder={placeholder}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
size={12}
disabled={disabled}
{...buildTestId(testid)}
/>
)}
{children}

View File

@ -27,7 +27,7 @@ export interface TooltipObject {
/**
*
*/
placement?: 'top' | 'right' | 'bottom' | 'left';
placement?: 'top' | 'right' | 'bottom' | 'left' | 'auto';
/**
*
*/
@ -95,7 +95,7 @@ export interface TooltipWrapperProps {
tooltip?: string | TooltipObject;
classPrefix: string;
classnames: ClassNamesFn;
placement: 'top' | 'right' | 'bottom' | 'left';
placement: 'top' | 'right' | 'bottom' | 'left' | 'auto';
container?: HTMLElement | (() => HTMLElement | null | undefined);
trigger: Trigger | Array<Trigger>;
rootClose: boolean;

View File

@ -0,0 +1,235 @@
import {ThemeProps, themeable} from 'amis-core';
import React from 'react';
import CodeMirrorEditor, {CodeMirrorEditorProps} from '../CodeMirror';
import {FormulaPlugin, editorFactory as createEditor} from './plugin';
import type CodeMirror from 'codemirror';
export interface VariableItem {
label: string;
value?: string;
path?: string; // 路径label
children?: Array<VariableItem>;
type?: string;
tag?: string;
selectMode?: 'tree' | 'tabs';
isMember?: boolean; // 是否是数组成员
// chunks?: string[]; // 内容块,作为一个整体进行高亮标记
}
export interface FuncGroup {
groupName: string;
items: Array<FuncItem>;
}
export interface FuncItem {
name: string; // 函数名
example?: string; // 示例
description?: string; // 描述
[propName: string]: any;
}
export interface CodeEditorProps
extends ThemeProps,
Omit<CodeMirrorEditorProps, 'style' | 'editorFactory' | 'editorDidMount'> {
readOnly?: boolean;
/**
* false
*/
singleLine?: boolean;
/**
* evalMode
* ${}
* true
*/
evalMode?: boolean;
autoFocus?: boolean;
editorTheme?: 'dark' | 'light';
editorOptions?: any;
/**
*
*/
variables?: Array<VariableItem>;
/**
* amis-formula
*
*/
functions?: Array<FuncGroup>;
placeholder?: string;
editorDidMount?: (
cm: typeof CodeMirror,
editor: CodeMirror.Editor,
plugin: FormulaPlugin
) => void;
}
function CodeEditor(props: CodeEditorProps, ref: any) {
const {
classnames: cx,
className,
value,
onChange,
editorDidMount,
onFocus,
onBlur,
functions,
variables,
evalMode,
singleLine,
autoFocus,
editorTheme,
theme: defaultTheme,
editorOptions,
placeholder
} = props;
const pluginRef = React.useRef<FormulaPlugin>();
const editorFactory = React.useCallback((dom: HTMLElement, cm: any) => {
let theme =
(editorTheme ??
((defaultTheme || '').includes('dark') ? 'dark' : 'light')) === 'dark'
? 'base16-dark'
: 'default';
let options: any = {
autoFocus,
indentUnit: 2,
lineNumbers: true,
lineWrapping: true, // 自动换行
theme,
placeholder,
...editorOptions
};
if (singleLine) {
options = {
lineNumbers: false,
indentWithTabs: false,
indentUnit: 4,
lineWrapping: false,
scrollbarStyle: null,
theme,
placeholder,
...editorOptions
};
}
return createEditor(dom, cm, props, options);
}, []);
const [readOnly, setReadOnly] = React.useState(props.readOnly);
React.useEffect(() => setReadOnly(props.readOnly), [props.readOnly]);
React.useEffect(
() => pluginRef.current?.editor?.setOption('placeholder', placeholder),
[placeholder]
);
// singleLine 模式下,禁止输入换行符
const onEditorBeforeChange = React.useCallback((cm: any, event: any) => {
// Identify typing events that add a newline to the buffer.
const hasTypedNewline =
event.origin === '+input' &&
typeof event.text === 'object' &&
event.text.join('') === '';
// Prevent newline characters from being added to the buffer.
if (hasTypedNewline) {
return event.cancel();
}
// Identify paste events.
const hasPastedNewline =
event.origin === 'paste' &&
typeof event.text === 'object' &&
event.text.length > 1;
// Format pasted text to replace newlines with spaces.
if (hasPastedNewline) {
const newText = event.text.join(' ');
return event.update(null, null, [newText]);
}
return null;
}, []);
const onEditorMount = React.useCallback(
(cm: any, editor: any) => {
const plugin = (pluginRef.current = new FormulaPlugin(editor, cm));
plugin.setEvalMode(!!evalMode);
plugin.setFunctions(functions || []);
plugin.setVariables(variables || []);
editorDidMount?.(cm, editor, plugin);
plugin.autoMarkText();
// 单行模式,不允许输入换行,同时原来的换行符也要去掉
if (singleLine) {
editor.on('beforeChange', onEditorBeforeChange);
const value = editor.getValue();
if (value && /[\n\r]/.test(value)) {
// 初始数据有换行,不允许直接编辑
// 只能弹窗弹出非单行模式编辑
setReadOnly(true);
editor.setValue(value.replace(/[\n\r]+/g, ''));
}
}
},
[evalMode, functions, variables]
);
React.useEffect(() => {
return () => {
pluginRef.current?.editor.off('beforeChange', onEditorBeforeChange);
pluginRef.current?.dispose();
};
}, []);
React.useImperativeHandle(ref, () => {
return {
insertContent: (value: any, type: 'variable' | 'func') =>
pluginRef.current?.insertContent(value, type),
setValue: (value: any) => pluginRef.current?.setValue(value),
getValue: () => pluginRef.current?.getValue(),
setDisableAutoMark: (value: boolean) =>
pluginRef.current?.setDisableAutoMark(value)
};
});
React.useEffect(() => {
const plugin = pluginRef.current;
if (!plugin) {
return;
}
plugin.setEvalMode(!!evalMode);
plugin.setFunctions(functions || []);
plugin.setVariables(variables || []);
plugin.autoMarkText();
}, [evalMode, functions, variables, value]);
return (
<CodeMirrorEditor
className={cx(
'FormulaCodeEditor',
className,
singleLine ? 'FormulaCodeEditor--singleLine' : ''
)}
value={value}
onChange={onChange}
editorFactory={editorFactory}
editorDidMount={onEditorMount}
onFocus={onFocus}
onBlur={onBlur}
readOnly={readOnly}
/>
);
}
export default themeable(React.forwardRef(CodeEditor));

View File

@ -2,50 +2,43 @@
* @file
*/
import React from 'react';
import {mapTree, uncontrollable} from 'amis-core';
import {
eachTree,
resolveVariableAndFilterForAsync,
uncontrollable
} from 'amis-core';
import {
parse,
autobind,
utils,
themeable,
ThemeProps,
localeable,
LocaleProps,
eachTree
LocaleProps
} from 'amis-core';
import {functionDocs} from 'amis-formula';
import type {FunctionDocMap} from 'amis-formula/lib/types';
import {FormulaPlugin, editorFactory} from './plugin';
import {editorFactory} from './plugin';
import FuncList from './FuncList';
import VariableList from './VariableList';
import CodeMirrorEditor from '../CodeMirror';
import {toast} from '../Toast';
import Switch from '../Switch';
import CodeEditor, {FuncGroup, FuncItem, VariableItem} from './CodeEditor';
import {functionDocs} from 'amis-formula';
import Transition, {
EXITED,
ENTERING,
EXITING
} from 'react-transition-group/Transition';
import MonacoEditor from '../Editor';
import debounce from 'lodash/debounce';
export interface VariableItem {
label: string;
value?: string;
path?: string; // 路径label
children?: Array<VariableItem>;
type?: string;
tag?: string;
selectMode?: 'tree' | 'tabs';
isMember?: boolean; // 是否是数组成员
// chunks?: string[]; // 内容块,作为一个整体进行高亮标记
}
export interface FuncGroup {
groupName: string;
items: Array<FuncItem>;
}
export interface FuncItem {
name: string; // 函数名
example?: string; // 示例
description?: string; // 描述
[propName: string]: any;
}
const collapseStyles: {
[propName: string]: string;
} = {
[EXITED]: 'out',
[EXITING]: 'out',
[ENTERING]: 'in'
};
export interface FormulaEditorProps extends ThemeProps, LocaleProps {
onChange?: (value: string) => void;
@ -106,11 +99,14 @@ export interface FunctionProps {
}
export interface FormulaState {
functions: FuncGroup[];
focused: boolean;
isCodeMode: boolean;
showRunPanel: boolean;
expandTree: boolean;
normalizeVariables?: Array<VariableItem>;
functions?: Array<FuncGroup>;
runContext: string;
runResult: string;
runValid: boolean;
}
export class FormulaEditor extends React.Component<
@ -120,12 +116,15 @@ export class FormulaEditor extends React.Component<
state: FormulaState = {
focused: false,
isCodeMode: false,
showRunPanel: false,
expandTree: false,
normalizeVariables: [],
functions: []
functions: this.props.functions,
runContext: '{\n}',
runResult: '',
runValid: false
};
editorPlugin?: FormulaPlugin;
unmounted: boolean = false;
editor = React.createRef<any>();
static buildDefaultFunctions(
doc: Array<{
@ -159,6 +158,25 @@ export class FormulaEditor extends React.Component<
}));
}
static async buildFunctions(
functions?: Array<any>,
functionsFilter?: (functions: Array<FuncGroup>) => Array<FuncGroup>
): Promise<any> {
const {doc} = await import('amis-formula/lib/doc');
const customFunctions = Array.isArray(functions) ? functions : [];
const functionList = [
...FormulaEditor.buildDefaultFunctions(doc),
...FormulaEditor.buildCustomFunctions(functionDocs),
...customFunctions
];
if (functionsFilter) {
return functionsFilter(functionList);
}
return functionList;
}
static defaultProps: Pick<FormulaEditorProps, 'variables' | 'evalMode'> = {
variables: [],
evalMode: true
@ -182,6 +200,15 @@ export class FormulaEditor extends React.Component<
return new RegExp(reg);
}
/**
* 使
* codemirror
* @deprecated
* @param value
* @param variables
* @param evalMode
* @returns
*/
static highlightValue(
value: string,
variables: Array<VariableItem>,
@ -252,78 +279,38 @@ export class FormulaEditor extends React.Component<
return {html};
}
componentDidMount(): void {
const {variables} = this.props;
this.normalizeVariables(variables as VariableItem[]);
this.buildFunctions();
constructor(props: FormulaEditorProps) {
super(props);
this.runCode = debounce(this.runCode.bind(this), 250, {
leading: false,
trailing: true
});
}
componentDidUpdate(
prevProps: Readonly<FormulaEditorProps>,
prevState: Readonly<FormulaState>,
snapshot?: any
): void {
if (prevProps.variables !== this.props.variables) {
this.normalizeVariables(this.props.variables as VariableItem[]);
}
async componentDidMount() {
if (!this.state.functions) {
const functionList = await FormulaEditor.buildFunctions();
if (this.unmounted) {
return;
}
this.setState({
functions: functionList
});
}
}
componentDidUpdate(prevProps: FormulaEditorProps): void {
if (prevProps.functions !== this.props.functions) {
this.buildFunctions();
this.setState({
functions: this.props.functions
});
}
}
componentWillUnmount() {
this.editorPlugin?.dispose();
this.unmounted = true;
}
async buildFunctions() {
const {doc} = await import('amis-formula/lib/doc');
if (this.unmounted) {
return;
}
const customFunctions = Array.isArray(this.props.functions)
? this.props.functions
: [];
const functionList = [
...FormulaEditor.buildDefaultFunctions(doc),
...FormulaEditor.buildCustomFunctions(functionDocs),
...customFunctions
];
this.setState({
functions: functionList
});
}
normalizeVariables(variables?: Array<VariableItem>) {
if (!variables) {
return;
}
// 追加path用于分级高亮
const list = mapTree(
variables,
(item: any, key: number, level: number, paths: any[]) => {
const path = paths?.reduce((prev, next) => {
return !next.value
? prev
: `${prev}${prev ? '.' : ''}${next.label ?? next.value}`;
}, '');
return {
...item,
path: `${path}${path ? '.' : ''}${item.label}`,
// 自己是数组成员或者父级有数组成员
...(item.isMember || paths.some(item => item.isMember)
? {
memberDepth: paths?.filter((item: any) => item.type === 'array')
?.length
}
: {})
};
}
);
this.setState({normalizeVariables: list});
(this.runCode as any).cancel();
}
@autobind
@ -340,17 +327,17 @@ export class FormulaEditor extends React.Component<
});
}
@autobind
insertValue(value: any, type: 'variable' | 'func') {
this.editorPlugin?.insertContent(value, type);
getEditor() {
let ref = this.editor.current;
while (ref?.getWrappedInstance) {
ref = ref.getWrappedInstance();
}
return ref;
}
@autobind
handleEditorMounted(cm: any, editor: any) {
this.editorPlugin = new FormulaPlugin(editor, cm, () => ({
...this.props,
variables: this.state.normalizeVariables
}));
insertValue(value: any, type: 'variable' | 'func') {
this.getEditor()?.insertContent(value, type);
}
@autobind
@ -372,12 +359,12 @@ export class FormulaEditor extends React.Component<
@autobind
handleFunctionSelect(item: FuncItem) {
this.editorPlugin?.insertContent(`${item.name}`, 'func');
this.getEditor()?.insertContent(`${item.name}`, 'func');
}
@autobind
handleVariableSelect(item: VariableItem) {
const {evalMode, selfVariableName} = this.props;
const {selfVariableName} = this.props;
if (
item &&
@ -393,7 +380,7 @@ export class FormulaEditor extends React.Component<
return;
}
this.editorPlugin?.insertContent(
this.getEditor()?.insertContent(
item.isMember
? item.value
: {
@ -412,23 +399,72 @@ export class FormulaEditor extends React.Component<
handleOnChange(value: any) {
const onChange = this.props.onChange;
onChange?.(value);
this.runCode();
}
@autobind
editorFactory(dom: HTMLElement, cm: any) {
const {editorOptions, ...rest} = this.props;
return editorFactory(dom, cm, rest, {
lineWrapping: true // 自动换行
lineWrapping: true, // 自动换行
autoFocus: true
});
}
@autobind
handleIsCodeModeChange(showCode: boolean) {
// 重置一下value
this.editorPlugin?.setValue(this.editorPlugin?.getValue());
// this.getEditor()?.setValue(this.getEditor()?.getValue());
// 非源码模式则mark一下
!showCode && this.editorPlugin?.autoMarkText();
this.setState({isCodeMode: showCode});
// !showCode && this.getEditor()?.autoMarkText();
this.setState({isCodeMode: showCode}, () =>
this.getEditor()?.setDisableAutoMark(showCode ? true : false)
);
}
@autobind
toggleRunPanel() {
this.setState(
{
showRunPanel: !this.state.showRunPanel
},
this.runCode
);
}
@autobind
handleRunContextChange(value: string) {
this.setState({runContext: value}, this.runCode);
}
async runCode() {
const value = this.props.value || '';
if (!this.state.showRunPanel) {
return;
}
try {
// 因为 resolveVariableAndFilterForAsync 不会报语法错误
parse(value, {
evalMode: this.props.evalMode
});
const runContext = JSON.parse(this.state.runContext);
let code = this.props.evalMode ? `\${${value}}` : value;
const result = await resolveVariableAndFilterForAsync(code, runContext);
this.unmounted ||
this.setState({
runValid: true,
runResult: JSON.stringify(result)
});
} catch (e) {
this.unmounted ||
this.setState({
runValid: false,
runResult: e.message
});
}
}
@autobind
@ -440,21 +476,25 @@ export class FormulaEditor extends React.Component<
const {
header,
value,
functions,
variables,
variableMode,
translate: __,
classnames: cx,
variableClassName,
functionClassName,
classPrefix,
selfVariableName
selfVariableName,
evalMode
} = this.props;
const {
focused,
isCodeMode,
showRunPanel,
expandTree,
normalizeVariables,
functions: functionList
functions,
runContext,
runResult,
runValid
} = this.state;
return (
@ -467,15 +507,19 @@ export class FormulaEditor extends React.Component<
<FuncList
className={functionClassName}
title={__('FormulaEditor.function')}
data={functionList}
data={functions || []}
onSelect={this.handleFunctionSelect}
/>
<div className={cx(`FormulaEditor-content`)}>
<header className={cx(`FormulaEditor-header`)}>
{__(header || 'FormulaEditor.title')}
<div className={cx(`FormulaEditor-header-toolbar m-l`)}>
<span>{__('FormulaEditor.run')}</span>
<Switch value={showRunPanel} onChange={this.toggleRunPanel} />
</div>
<div className={cx(`FormulaEditor-header-toolbar`)}>
<span></span>
<span>{__('FormulaEditor.sourceMode')}</span>
<Switch
value={isCodeMode}
onChange={this.handleIsCodeModeChange}
@ -483,15 +527,61 @@ export class FormulaEditor extends React.Component<
</div>
</header>
<CodeMirrorEditor
<CodeEditor
evalMode={evalMode}
functions={functions}
variables={variables}
className={cx('FormulaEditor-editor')}
value={value}
onChange={this.handleOnChange}
editorFactory={this.editorFactory}
editorDidMount={this.handleEditorMounted}
ref={this.editor}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
autoFocus
/>
<Transition
mountOnEnter
unmountOnExit
key="run-panel"
in={showRunPanel}
timeout={300}
>
{(status: string) => {
return (
<div
className={cx(
`FormulaEditor-runPanel`,
collapseStyles[status]
)}
>
<div className={cx(`FormulaEditor-runPanel-context`)}>
<header>{__('FormulaEditor.runContext')}</header>
<div>
<MonacoEditor
value={runContext}
onChange={this.handleRunContextChange}
language="json"
options={{
tabSize: 2,
lineNumbers: false
}}
/>
</div>
</div>
<div
className={cx(
`FormulaEditor-runPanel-result`,
runValid ? '' : 'is-error'
)}
>
<header>{__('FormulaEditor.runResult')}</header>
<div>{runResult}</div>
</div>
</div>
);
}}
</Transition>
</div>
<div className={cx('FormulaEditor-panel', 'right')}>
{variableMode !== 'tabs' ? (
@ -499,7 +589,7 @@ export class FormulaEditor extends React.Component<
{__('FormulaEditor.variable')}
{variableMode === 'tree' ? (
<div className={cx(`FormulaEditor-header-toolbar`)}>
<span></span>
<span>{__('FormulaEditor.toggleAll')}</span>
<Switch
value={expandTree}
onChange={this.handleExpandTreeChange}
@ -523,7 +613,7 @@ export class FormulaEditor extends React.Component<
)}
expandTree={expandTree}
selectMode={variableMode}
data={normalizeVariables!}
data={variables!}
onSelect={this.handleVariableSelect}
selfVariableName={selfVariableName}
/>

View File

@ -1,10 +1,10 @@
import React from 'react';
import {themeable, ThemeProps} from 'amis-core';
import {mapTree, themeable, ThemeProps} from 'amis-core';
import Collapse from '../Collapse';
import CollapseGroup from '../CollapseGroup';
import SearchBox from '../SearchBox';
import type {FuncGroup, FuncItem} from './Editor';
import type {FuncGroup, FuncItem} from './CodeEditor';
import TooltipWrapper from '../TooltipWrapper';
import {Icon} from '../icons';
@ -25,26 +25,32 @@ export function FuncList(props: FuncListProps) {
descClassName,
mobileUI
} = props;
const [term, setTerm] = React.useState('');
const [filteredFuncs, setFiteredFuncs] = React.useState(props.data);
const [activeFunc, setActiveFunc] = React.useState<any>(null);
React.useEffect(() => {
setFiteredFuncs(props.data);
}, [props.data]);
const onSearch = React.useCallback(
(term: string) => {
const filtered = props.data
.map(item => {
return {
...item,
items: term
? item.items.filter(
(item: any) => ~item.name.indexOf(term.toUpperCase())
)
: item.items
};
})
.filter(item => item.items.length);
setFiteredFuncs(filtered);
},
[props.data]
);
function onSearch(term: string) {
const filtered = props.data
.map(item => {
return {
...item,
items: term
? item.items.filter(item => ~item.name.indexOf(term.toUpperCase()))
: item.items
};
})
.filter(item => item.items.length);
setFiteredFuncs(filtered);
}
React.useEffect(() => {
onSearch(term);
}, [props.data]);
return (
<div className={cx('FormulaEditor-panel', 'left', className)}>
@ -57,7 +63,13 @@ export function FuncList(props: FuncListProps) {
<div className={cx('FormulaEditor-panel-header')}>{title}</div>
<div className={cx('FormulaEditor-panel-body')}>
<div className={cx('FormulaEditor-FuncList-searchBox')}>
<SearchBox mini={false} onSearch={onSearch} mobileUI={mobileUI} />
<SearchBox
value={term}
onChange={setTerm}
mini={false}
onSearch={onSearch}
mobileUI={mobileUI}
/>
</div>
<div className={cx('FormulaEditor-FuncList-body', bodyClassName)}>
<CollapseGroup
@ -81,7 +93,7 @@ export function FuncList(props: FuncListProps) {
header={item.groupName}
key={item.groupName}
>
{item.items.map(item => (
{item.items.map((item: any) => (
<div
className={cx('FormulaEditor-FuncList-item', {
'is-active': item.name === activeFunc?.name

View File

@ -13,7 +13,7 @@ import {
isObject
} from 'amis-core';
import {FormulaEditor, VariableItem} from './Editor';
import {FormulaEditor} from './Editor';
import ResultBox from '../ResultBox';
import Select from '../Select';
import NumberInput from '../NumberInput';
@ -21,6 +21,8 @@ import DatePicker from '../DatePicker';
import Tag from '../Tag';
import type {FormulaPickerProps} from './Picker';
import CodeEditor, {FuncGroup, VariableItem} from './CodeEditor';
import InputBox from '../InputBox';
export interface FormulaInputProps
extends Pick<
@ -42,9 +44,19 @@ export interface FormulaInputProps
*/
value?: string;
/**
* evalMode
*
*
* ${}
*
*/
mixedMode?: boolean;
autoFoucs?: boolean;
variables?: VariableItem[];
functions?: Array<FuncGroup>;
popOverContainer?: any;
@ -59,7 +71,7 @@ export interface FormulaInputProps
itemRender?: (value: any) => JSX.Element | string;
}
const FormulaInput: React.FC<FormulaInputProps> = props => {
const FormulaInput = (props: FormulaInputProps, ref: any) => {
const {
translate: __,
className,
@ -71,6 +83,7 @@ const FormulaInput: React.FC<FormulaInputProps> = props => {
mixedMode,
value,
variables,
functions,
inputSettings = {type: 'text'},
popOverContainer,
onChange,
@ -81,9 +94,16 @@ const FormulaInput: React.FC<FormulaInputProps> = props => {
const sharedProps = pick(props, ['disabled', 'clearable']);
const pipInValue = useCallback(
(value?: any) => {
/** 数据来源可能是从 query中下发的CRUD查询表头导致数字或者布尔值被转为 string 格式,这里预处理一下 */
if (schemaType === 'number') {
value = isNaN(+value) ? value : +value;
} else if (schemaType === 'boolean') {
value = value === 'true' ? true : value === 'false' ? false : value;
}
return value;
},
['value']
[schemaType]
);
const pipOutValue = useCallback(
(origin: any) => {
@ -133,106 +153,9 @@ const FormulaInput: React.FC<FormulaInputProps> = props => {
);
let cmptValue = pipInValue(value ?? inputSettings.defaultValue);
const isExpr = isExpression(cmptValue);
/** 数据来源可能是从 query中下发的CRUD查询表头导致数字或者布尔值被转为 string 格式,这里预处理一下 */
if (schemaType === 'number') {
cmptValue = isNaN(+cmptValue) ? cmptValue : +cmptValue;
} else if (schemaType === 'boolean') {
cmptValue =
cmptValue === 'true' ? true : cmptValue === 'false' ? false : cmptValue;
}
const targetVariable =
variables && cmptValue != null && typeof cmptValue === 'string'
? findTree(variables, item => {
return mixedMode
? cmptValue.replace(/^\$\{/, '').replace(/\}$/, '') === item?.value
: cmptValue === item?.value;
})
: null;
let useVariable = !!(isExpression(cmptValue) || targetVariable);
/** 判断value是否为变量如果是变量使用ResultBox渲染 */
if (!useVariable) {
if (schemaType === 'number') {
useVariable = cmptValue != null && typeof cmptValue !== 'number';
} else if (['date', 'time', 'datetime'].includes(schemaType)) {
useVariable = !moment(cmptValue).isValid();
} else if (schemaType === 'select') {
const {
options,
joinValues,
extractValue,
delimiter,
multiple,
valueField = 'value'
} = inputSettings;
let selctedValue: any[] = [];
if (multiple) {
if (joinValues) {
selctedValue =
typeof cmptValue === 'string' ? cmptValue.split(delimiter) : [];
} else {
selctedValue = Array.isArray(cmptValue)
? extractValue
? cmptValue
: cmptValue.map(i => i?.[valueField])
: [];
}
} else {
if (joinValues) {
selctedValue = typeof cmptValue === 'string' ? [cmptValue] : [];
} else {
selctedValue = isObject(cmptValue) ? [cmptValue?.[valueField]] : [];
}
}
/** 选项类型清空后是空字符串, */
useVariable =
cmptValue &&
!(options ?? []).some((item: any) =>
selctedValue.includes(item?.value)
);
} else if (schemaType === 'boolean') {
useVariable = cmptValue != null && typeof cmptValue !== 'boolean';
}
}
if (useVariable) {
const varName =
typeof cmptValue === 'string' && cmptValue && mixedMode
? cmptValue.replace(/^\$\{/, '').replace(/\}$/, '')
: cmptValue;
const resultValue = targetVariable?.value ?? varName;
return (
<ResultBox
className={cx(`FormulaPicker-input-variable`)}
allowInput={allowInput}
// value={resultValue}
result={
resultValue == null
? void 0
: FormulaEditor.highlightValue(resultValue, variables!, evalMode)
}
itemRender={(item: any) => {
return (
<div
className={cx('FormulaPicker-ResultBox')}
dangerouslySetInnerHTML={{__html: item.html}}
/>
);
}}
onResultChange={noop}
onChange={pipOutValue}
onClear={() => pipOutValue(undefined)}
clearable={true}
/>
);
}
if (schemaType === 'number') {
if (!isExpr && schemaType === 'number') {
return (
<NumberInput
{...sharedProps}
@ -247,9 +170,7 @@ const FormulaInput: React.FC<FormulaInputProps> = props => {
onChange={pipOutValue}
/>
);
} else if (schemaType === 'date') {
const cmptValue = pipInValue(value ?? inputSettings.defaultValue);
} else if (!isExpr && schemaType === 'date') {
return (
<DatePicker
{...sharedProps}
@ -265,7 +186,7 @@ const FormulaInput: React.FC<FormulaInputProps> = props => {
onChange={pipOutValue}
/>
);
} else if (schemaType === 'time') {
} else if (!isExpr && schemaType === 'time') {
return (
<DatePicker
{...sharedProps}
@ -279,11 +200,11 @@ const FormulaInput: React.FC<FormulaInputProps> = props => {
dateFormat=""
timeFormat={inputSettings.format || 'HH:mm'}
popOverContainer={popOverContainer}
value={pipInValue(value ?? inputSettings.defaultValue)}
value={cmptValue}
onChange={pipOutValue}
/>
);
} else if (schemaType === 'datetime') {
} else if (!isExpr && schemaType === 'datetime') {
return (
<DatePicker
{...sharedProps}
@ -295,11 +216,11 @@ const FormulaInput: React.FC<FormulaInputProps> = props => {
inputFormat={inputSettings.inputFormat || 'YYYY-MM-DD HH:mm'}
timeFormat={inputSettings.timeFormat || 'HH:mm'}
popOverContainer={popOverContainer}
value={pipInValue(value ?? inputSettings.defaultValue)}
value={cmptValue}
onChange={pipOutValue}
/>
);
} else if (schemaType === 'select' || schemaType === 'boolean') {
} else if (!isExpr && (schemaType === 'select' || schemaType === 'boolean')) {
return (
<Select
{...sharedProps}
@ -335,21 +256,25 @@ const FormulaInput: React.FC<FormulaInputProps> = props => {
);
} else {
return (
<ResultBox
{...sharedProps}
className={cx(className)}
allowInput={allowInput}
<InputBox
className={cx('FormulaPicker-input')}
inputRender={({value, onChange, onFocus, onBlur, placeholder}: any) => (
<CodeEditor
singleLine
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
functions={functions}
variables={variables}
evalMode={evalMode}
placeholder={placeholder}
/>
)}
borderMode={borderMode}
placeholder={placeholder}
value={pipInValue(value)}
result={
allowInput || !value
? void 0
: FormulaEditor.highlightValue(value, variables!, evalMode)
}
itemRender={itemRender}
onResultChange={noop}
value={cmptValue}
onChange={pipOutValue}
placeholder={__(placeholder ?? 'placeholder.enter')}
/>
);
}
@ -357,7 +282,7 @@ const FormulaInput: React.FC<FormulaInputProps> = props => {
export default themeable(
localeable(
uncontrollable(FormulaInput, {
uncontrollable(React.forwardRef(FormulaInput), {
value: 'onChange'
})
)

View File

@ -5,12 +5,7 @@ import {
uncontrollable
} from 'amis-core';
import React from 'react';
import {
FormulaEditor,
FormulaEditorProps,
FuncGroup,
VariableItem
} from './Editor';
import {FormulaEditor, FormulaEditorProps} from './Editor';
import {
autobind,
noop,
@ -28,6 +23,8 @@ import {Icon} from '../icons';
import Modal from '../Modal';
import PopUp from '../PopUp';
import FormulaInput from './Input';
import {FuncGroup, VariableItem} from './CodeEditor';
import {functionDocs} from 'amis-formula';
export const InputSchemaType = [
'text',
@ -142,6 +139,8 @@ export interface FormulaPickerProps
*/
onPickerOpen?: (props: FormulaPickerProps) => any;
functionsFilter?: (functions: Array<FuncGroup>) => Array<FuncGroup>;
children?: (props: {
onClick: (e: React.MouseEvent) => void;
setState: (state: any) => void;
@ -180,6 +179,7 @@ export class FormulaPicker extends React.Component<
evalMode: true
};
unmounted = false;
constructor(props: FormulaPickerProps) {
super(props);
this.props.onRef && this.props.onRef(this);
@ -205,6 +205,7 @@ export class FormulaPicker extends React.Component<
);
this.setState({variables: result});
}
this.buildFunctions();
}
async componentDidUpdate(prevProps: FormulaPickerProps) {
@ -231,12 +232,47 @@ export class FormulaPicker extends React.Component<
this.setState({variables: result});
}
}
if (prevProps.functions !== this.props.functions) {
this.buildFunctions();
}
}
componentWillUnmount(): void {
this.unmounted = true;
}
async buildFunctions(
functions = this.props.functions,
setState = true
): Promise<any> {
const functionList = await FormulaEditor.buildFunctions(
functions,
this.props.functionsFilter
);
if (this.unmounted) {
return;
}
if (!setState) {
return functionList;
}
this.setState({
functions: functionList
});
}
value2EditorValue(props: FormulaPickerProps) {
const {value} = props;
const {value, mixedMode, inputSettings} = props;
if (!this.isTextInput()) {
if (
mixedMode &&
typeof value === 'string' &&
/^\s*\$\{([\s\S]+)\}\s*$/.test(value)
) {
return RegExp.$1;
} else if (typeof value !== 'string') {
let editorValue = '';
try {
@ -244,20 +280,9 @@ export class FormulaPicker extends React.Component<
} catch (error) {}
return editorValue;
} else {
return value ? (mixedMode ? `\`${value}\`` : value) : '';
}
if (props.mixedMode) {
if (
typeof props.value === 'string' &&
/^\s*\$\{(.+?)\}\s*$/.test(props.value)
) {
return RegExp.$1;
} else {
return '';
}
}
return String(props.value || '');
}
isTextInput() {
@ -281,22 +306,6 @@ export class FormulaPicker extends React.Component<
}
}
@autobind
renderFormulaValue(item: any) {
const {allowInput, classnames: cx} = this.props;
const html = {__html: item.html};
if (allowInput) {
return '';
}
return (
<div
className={cx('FormulaPicker-ResultBox')}
dangerouslySetInnerHTML={html}
></div>
);
}
@autobind
handleInputChange(value: string) {
this.setState({value}, () => this.handleConfirm());
@ -321,22 +330,17 @@ export class FormulaPicker extends React.Component<
const {translate: __, inputSettings} = this.props;
const {editorValue} = this.state;
if (this.isTextInput()) {
return this.confirm(editorValue);
} else if (inputSettings) {
if (
inputSettings?.type &&
['boolean', 'number'].includes(inputSettings?.type)
) {
let result = editorValue;
const schemaType = inputSettings?.type;
// const schemaType = inputSettings?.type;
try {
const ast = parse(editorValue, {evalMode: true, allowFilter: false});
if (
schemaType === 'select' &&
inputSettings.multiple &&
ast.type === 'array'
) {
result = ast.members.map((i: any) => i.value);
} else if (ast.type === 'literal' || ast.type === 'string') {
if (ast.type === 'literal' || ast.type === 'string') {
result = ast.value ?? '';
}
} catch (error) {
@ -347,19 +351,28 @@ export class FormulaPicker extends React.Component<
this.setState({isError: false});
return this.confirm(result);
}
return this.confirm(editorValue);
}
confirm(value: string) {
confirm(value: any) {
const {mixedMode} = this.props;
const validate = this.validate(value);
if (validate === true) {
this.setState(
{value: mixedMode && value ? `\${${value}}` : value},
() => {
this.close(undefined, () => this.handleConfirm());
}
);
let result = value;
if (mixedMode && typeof value === 'string') {
result =
!value.includes('$') &&
value[0] === '`' &&
value[value.length - 1] === '`'
? value.substring(1, value.length - 1)
: `\${${value}}`;
}
this.setState({value: result}, () => {
this.close(undefined, () => this.handleConfirm());
});
} else {
this.setState({isError: validate});
}
@ -375,6 +388,9 @@ export class FormulaPicker extends React.Component<
isOpened: true
};
if (state.functions) {
state.functions = await this.buildFunctions(state.functions, false);
}
this.setState(state);
}
@ -431,7 +447,7 @@ export class FormulaPicker extends React.Component<
try {
value &&
parse(value, {
evalMode: this.props.mixedMode ? true : this.props.evalMode,
evalMode: this.props.mixedMode ? false : this.props.evalMode,
allowFilter: false
});
@ -529,31 +545,25 @@ export class FormulaPicker extends React.Component<
)}
{mode === 'input-button' && (
<>
<ResultBox
<FormulaInput
className={cx(
'FormulaPicker-input',
isOpened ? 'is-active' : '',
!!isError ? 'is-error' : ''
)}
inputSettings={inputSettings}
allowInput={allowInput}
clearable={clearable}
evalMode={mixedMode ? false : evalMode}
variables={this.state.variables!}
functions={this.state.functions ?? functions}
value={value}
result={
allowInput
? void 0
: FormulaEditor.highlightValue(
value,
this.state.variables!,
this.props.evalMode
)
}
itemRender={this.renderFormulaValue}
onResultChange={noop}
onChange={this.handleInputChange}
disabled={disabled}
borderMode={borderMode}
placeholder={placeholder}
/>
<Button
className={cx('FormulaPicker-action')}
onClick={this.handleClick}
@ -578,11 +588,10 @@ export class FormulaPicker extends React.Component<
inputSettings={inputSettings}
allowInput={allowInput}
clearable={clearable}
evalMode={evalMode}
mixedMode={mixedMode}
evalMode={mixedMode ? false : evalMode}
variables={this.state.variables!}
functions={this.state.functions ?? functions}
value={value}
itemRender={this.renderFormulaValue}
onChange={this.handleInputChange}
disabled={disabled}
borderMode={borderMode}

View File

@ -1,12 +1,12 @@
import React, {useEffect} from 'react';
import {themeable, ThemeProps, filterTree} from 'amis-core';
import {themeable, ThemeProps, filterTree, mapTree} from 'amis-core';
import GroupedSelection from '../GroupedSelection';
import Tabs, {Tab} from '../Tabs';
import TreeSelection from '../TreeSelection';
import SearchBox from '../SearchBox';
import type {VariableItem} from './Editor';
import type {VariableItem} from './CodeEditor';
import type {ItemRenderStates} from '../Selection';
import type {Option} from '../Select';
import type {TabsMode} from '../Tabs';
@ -85,7 +85,6 @@ export interface VariableListProps extends ThemeProps, SpinnerExtraProps {
function VariableList(props: VariableListProps) {
const variableListRef = React.useRef<HTMLDivElement>(null);
const {
data: list,
className,
classnames: cx,
tabsMode = 'line',
@ -97,14 +96,37 @@ function VariableList(props: VariableListProps) {
selfVariableName,
expandTree
} = props;
const [filterVars, setFilterVars] = React.useState(list);
const [variables, setVariables] = React.useState<Array<VariableItem>>([]);
const [filterVars, setFilterVars] = React.useState<Array<VariableItem>>([]);
const classPrefix = `${themePrefix}FormulaEditor-VariableList`;
useEffect(() => {
const {data} = props;
if (data) {
setFilterVars(data);
}
React.useEffect(() => {
// 追加path用于分级高亮
const list = mapTree(
props.data,
(item: any, key: number, level: number, paths: any[]) => {
const path = paths?.reduce((prev, item) => {
return !item.value
? prev
: `${prev}${prev ? '.' : ''}${item.label ?? item.value}`;
}, '');
return {
...item,
path: `${path}${path ? '.' : ''}${item.label}`,
// 自己是数组成员或者父级有数组成员
...(item.isMember || paths.some(item => item.isMember)
? {
memberDepth: paths?.filter((item: any) => item.type === 'array')
?.length
}
: {})
};
}
);
setVariables(list);
setFilterVars(list);
}, [props.data]);
const itemRender =
@ -217,7 +239,7 @@ function VariableList(props: VariableListProps) {
function onSearch(term: string) {
const tree = filterTree(
list,
variables,
(i: any, key: number, level: number, paths: any[]) => {
return !!(
(Array.isArray(i.children) && i.children.length) ||
@ -231,7 +253,7 @@ function VariableList(props: VariableListProps) {
true
);
setFilterVars(!term ? list : tree);
setFilterVars(!term ? variables : tree);
}
function renderSearchBox() {

View File

@ -3,8 +3,10 @@
*/
import type CodeMirror from 'codemirror';
import {eachTree} from 'amis-core';
import {FormulaEditorProps, VariableItem, FormulaEditor} from './Editor';
import {findTree} from 'amis-core';
import {FuncGroup, VariableItem} from './CodeEditor';
import {parse} from 'amis-formula';
import debounce from 'lodash/debounce';
export function editorFactory(
dom: HTMLElement,
@ -16,30 +18,88 @@ export function editorFactory(
return cm(dom, {
value: props.value || '',
autofocus: true,
autofocus: false,
mode: props.evalMode ? 'text/formula' : 'text/formula-template',
readOnly: props.readOnly ? 'nocursor' : false,
...options
});
}
export class FormulaPlugin {
constructor(
readonly editor: CodeMirror.Editor,
readonly cm: typeof CodeMirror,
readonly getProps: () => FormulaEditorProps
) {
// editor.on('change', this.autoMarkText);
this.autoMarkText();
function traverseAst(ast: any, iterator: (ast: any) => void | false) {
if (!ast || !ast.type) {
return;
}
autoMarkText() {
const {functions, variables, value} = this.getProps();
const ret = iterator(ast);
if (ret === false) {
return;
}
if (value) {
// todo functions 也需要自动替换
this.autoMark(variables!);
this.focus(value);
Object.keys(ast).forEach(key => {
const value = ast[key];
if (Array.isArray(value)) {
value.forEach(child => traverseAst(child, iterator));
} else {
traverseAst(value, iterator);
}
});
}
export class FormulaPlugin {
/**
*
*/
variables: Array<VariableItem> = [];
/**
* amis-formula
*
*/
functions: Array<FuncGroup> = [];
/**
* evalMode
*/
evalMode: boolean = true;
disableAutoMark = false;
constructor(
readonly editor: CodeMirror.Editor,
readonly cm: typeof CodeMirror
) {
// this.autoMarkText();
this.autoMarkText = debounce(this.autoMarkText.bind(this), 250, {
leading: false,
trailing: true
});
editor.on('blur', () => this.autoMarkText());
}
setVariables(variables: Array<VariableItem>) {
this.variables = Array.isArray(variables) ? variables : [];
}
setFunctions(functions: Array<FuncGroup>) {
this.functions = Array.isArray(functions) ? functions : [];
}
setEvalMode(evalMode: boolean) {
this.evalMode = evalMode;
}
setDisableAutoMark(disableAutoMark: boolean) {
this.disableAutoMark = disableAutoMark;
this.autoMarkText(true);
}
autoMarkText(forceClear = false) {
if (forceClear || !this.editor.hasFocus()) {
this.editor?.getAllMarks().forEach(mark => mark.clear());
}
this.disableAutoMark || this.autoMark();
}
// 计算 `${`、`}` 括号的位置,如 ${a}+${b}, 结果是 [ { from: 0, to: 3 }, { from: 5, to: 8 } ]
@ -108,50 +168,19 @@ export class FormulaPlugin {
}
}
insertContent(
value: any,
type?: 'variable' | 'func',
className: string = 'cm-field',
toMark: boolean = true
) {
insertContent(value: any, type?: 'variable' | 'func') {
let from = this.editor.getCursor();
const {evalMode} = this.getProps();
const evalMode = this.evalMode;
if (type === 'variable') {
this.editor.replaceSelection(value.key);
const to = this.editor.getCursor();
if (toMark) {
// 路径中每个变量分别进行标记
let markFrom = from.ch;
value.path.split('.').forEach((label: string, index: number) => {
const val = value.key.split('.')[index];
this.markText(
{line: from.line, ch: markFrom},
{line: to.line, ch: markFrom + val.length},
label,
className
);
markFrom += 1 + val.length;
});
}
!evalMode && this.insertBraces(from, to);
} else if (type === 'func') {
this.editor.replaceSelection(`${value}()`);
const to = this.editor.getCursor();
toMark &&
this.markText(
from,
{
line: to.line,
ch: to.ch - 2
},
value,
'cm-func'
);
this.editor.setCursor({
line: to.line,
ch: to.ch - 1
@ -167,7 +196,6 @@ export class FormulaPlugin {
} else if (typeof value === 'string') {
this.editor.replaceSelection(value);
// 非变量、非函数,可能是组合模式,也需要标记
toMark && setTimeout(() => this.autoMarkText(), 0);
}
this.editor.focus();
@ -190,88 +218,175 @@ export class FormulaPlugin {
const text = document.createElement('span');
text.className = className;
text.innerText = label;
this.editor.markText(from, to, {
return this.editor.markText(from, to, {
atomic: true,
replacedWith: text
});
}
autoMark(variables: Array<VariableItem>) {
if (!Array.isArray(variables) || !variables.length) {
return;
}
const varMap: {
[propname: string]: string;
} = {};
eachTree(variables, item => {
if (item.value) {
varMap[item.value] = item.path ?? item.label;
}
});
const vars = Object.keys(varMap).sort((a, b) => b.length - a.length);
widgets: any[] = [];
marks: any[] = [];
autoMark() {
const editor = this.editor;
const lines = editor.lineCount();
const {evalMode = true} = this.getProps();
for (let line = 0; line < lines; line++) {
const content = editor.getLine(line);
const value = editor.getValue();
const functions = this.functions;
const variables = this.variables;
// 标记方法调用
content.replace(/([A-Z]+)\s*\(/g, (_, func, pos) => {
this.markText(
{
line: line,
ch: pos
},
{
line: line,
ch: pos + func.length
},
func,
'cm-func'
);
return _;
// 把旧的清掉
this.widgets.forEach(widget => editor.removeLineWidget(widget));
this.widgets = [];
this.marks.forEach(mark => mark.clear());
this.marks = [];
try {
const ast = parse(value, {
evalMode: this.evalMode,
variableMode: false
});
const REPLACE_KEY = 'AMIS_FORMULA_REPLACE_KEY';
// 标记变量
vars.forEach(v => {
let from = 0;
let idx = -1;
while (~(idx = content.indexOf(v, from))) {
const encode = FormulaEditor.replaceStrByIndex(
content,
idx,
v,
REPLACE_KEY
traverseAst(ast, (ast: any): any => {
if (ast.type === 'func_call') {
const funName = ast.identifier;
const exists = functions.some(item =>
item.items.some(i => i.name === funName)
);
const reg = FormulaEditor.getRegExpByMode(evalMode, REPLACE_KEY);
if (reg.test(encode)) {
let markFrom = idx;
v.split('.').forEach((val: string, index: number) => {
if (exists) {
this.markText(
{
line: ast.start.line - 1,
ch: ast.start.column - 1
},
{
line: ast.start.line - 1,
ch: ast.start.column + funName.length - 1
},
funName,
'cm-func'
);
}
} else if (ast.type === 'getter') {
// 获取对象中的变量
const list = [ast];
let current = ast;
while (current?.type === 'getter') {
current = current.host;
list.unshift(current);
}
const host = list.shift();
if (host?.type === 'variable') {
const variable = findTree(
variables,
item => item.value === host.name
);
if (variable) {
// 先标记顶层对象
this.markText(
{
line: line,
ch: markFrom
line: host.start.line - 1,
ch: host.start.column - 1
},
{
line: line,
ch: markFrom + val.length
line: host.end.line - 1,
ch: host.end.column - 1
},
varMap[v].split('.')[index],
variable.label,
'cm-field'
);
markFrom += 1 + val.length;
});
}
from = idx + v.length;
// 再标记子对象
let path = host.name + '.';
let vars = variable.children || [];
for (let i = 0, len = list.length; i < len; i++) {
const item = list[i]?.key;
// 只能识别这种固定下标的情况
if (item?.type === 'identifier') {
const variable =
findTree(vars, v => v.value === path + item.name) ??
findTree(
vars,
v => v.value === item.name // 兼容不带路径的情况
);
if (variable) {
this.markText(
{
line: item.start.line - 1,
ch: item.start.column - 1
},
{
line: item.end.line - 1,
ch: item.end.column - 1
},
variable.label,
'cm-field'
);
path += item.name + '.';
vars = variable.children || [];
} else {
break;
}
}
}
}
}
return false;
} else if (ast.type === 'variable') {
// 直接就是变量
const variable = findTree(variables, item => item.value === ast.name);
if (variable) {
this.markText(
{
line: ast.start.line - 1,
ch: ast.start.column - 1
},
{
line: ast.end.line - 1,
ch: ast.end.column - 1
},
variable.label,
'cm-field'
);
}
return false;
}
});
} catch (e) {
const reg = /^Unexpected\stoken\s(.+)\sin\s(\d+):(\d+)$/.exec(e.message);
if (reg) {
const token = reg[1];
const line = parseInt(reg[2], 10);
const column = parseInt(reg[3], 10);
const msg = document.createElement('div');
const icon = msg.appendChild(document.createElement('span'));
icon.innerText = '!!';
icon.className = 'lint-error-icon';
msg.appendChild(
document.createTextNode(`Unexpected token \`${token}\``)
);
msg.className = 'lint-error';
this.widgets.push(
editor.addLineWidget(line - 1, msg, {
coverGutter: false,
noHScroll: true
})
);
this.marks.push(
this.markText(
{
line: line - 1,
ch: column - 1
},
{
line: line - 1,
ch: column + token.length - 1
},
token,
'cm-error-token'
)
);
}
console.warn('synax error, ignore it');
}
}
@ -283,7 +398,9 @@ export class FormulaPlugin {
});
}
dispose() {}
dispose() {
(this.autoMarkText as any).cancel();
}
validate() {}
}
@ -295,6 +412,8 @@ function registerLaunguageMode(cm: typeof CodeMirror) {
}
modeRegisted = true;
// TODO 自定义语言规则
// 对应 evalMode
cm.defineMode('formula', (config: any, parserConfig: any) => {
var formula = cm.getMode(config, 'javascript');
@ -306,7 +425,6 @@ function registerLaunguageMode(cm: typeof CodeMirror) {
mode: formula
});
});
cm.defineMIME('text/formula', {name: 'formula'});
cm.defineMIME('text/formula-template', {name: 'formula', base: 'htmlmixed'});
}

View File

@ -74,7 +74,8 @@ import SchemaVariableList from './schema-editor/SchemaVariableList';
import VariableList from './formula/VariableList';
import FormulaPicker from './formula/Picker';
import {FormulaEditor} from './formula/Editor';
import type {VariableItem} from './formula/Editor';
import FormulaCodeEditor from './formula/CodeEditor';
import type {VariableItem} from './formula/CodeEditor';
import PickerContainer from './PickerContainer';
import InputJSONSchema from './json-schema';
import {Badge, withBadge} from './Badge';
@ -202,6 +203,7 @@ export {
PickerContainer,
ConfirmBox,
FormulaPicker,
FormulaCodeEditor,
VariableItem,
FormulaEditor,
InputJSONSchema,

View File

@ -8,7 +8,7 @@ import {
themeable,
ThemeProps
} from 'amis-core';
import {VariableItem} from '../formula/Editor';
import {VariableItem} from '../formula/CodeEditor';
import VariableList from '../formula/VariableList';
import TooltipWrapper from '../TooltipWrapper';

View File

@ -393,6 +393,11 @@ register('de-DE', {
'expand': 'Entfalten',
'FormulaEditor.btnLabel': 'Formel Bearbeiten',
'FormulaEditor.title': 'Formel Editor',
'FormulaEditor.run': 'Laufen',
'FormulaEditor.sourceMode': 'Source Mode',
'FormulaEditor.runContext': 'Run Context',
'FormulaEditor.runResult': 'Run Result',
'FormulaEditor.toggleAll': 'Expand All',
'FormulaEditor.variable': 'Variable',
'FormulaEditor.function': 'Funktion',
'FormulaEditor.invalidData':

View File

@ -377,6 +377,11 @@ register('en-US', {
'expand': 'Expand',
'FormulaEditor.btnLabel': 'Formula Edit',
'FormulaEditor.title': 'Formula Editor',
'FormulaEditor.run': 'Run',
'FormulaEditor.sourceMode': 'Source Mode',
'FormulaEditor.runContext': 'Run Context',
'FormulaEditor.runResult': 'Run Result',
'FormulaEditor.toggleAll': 'Expand All',
'FormulaEditor.variable': 'Variable',
'FormulaEditor.function': 'Function',
'FormulaEditor.invalidData': 'invalid data, position or reason is {{err}}',

View File

@ -370,6 +370,11 @@ register('zh-CN', {
'expand': '展开',
'FormulaEditor.btnLabel': '公式编辑',
'FormulaEditor.title': '公式编辑器',
'FormulaEditor.run': '运行',
'FormulaEditor.sourceMode': '源码模式',
'FormulaEditor.runContext': '上下文数据',
'FormulaEditor.runResult': '运行结果',
'FormulaEditor.toggleAll': '展开全部',
'FormulaEditor.variable': '变量',
'FormulaEditor.function': '函数',
'FormulaEditor.invalidData': '公式值校验错误,错误的位置/原因是 {{err}}',

View File

@ -131,3 +131,13 @@ Object.defineProperty(global, 'IntersectionObserver', {
configurable: true,
value: IntersectionObserver
});
(global as any).document.createRange = () => ({
selectNodeContents: jest.fn(),
getBoundingClientRect: jest.fn(() => ({
width: 500
})),
getClientRects: jest.fn(() => []),
setStart: jest.fn(),
setEnd: jest.fn()
});

View File

@ -431,23 +431,143 @@ exports[`Renderer:input-formula input-group 1`] = `
class="cxd-FormulaPicker is-input-group cxd-FormulaPicker--text cxd-Form-control"
>
<div
class="cxd-ResultBox cxd-FormulaPicker-input cxd-ResultBox--borderFull"
tabindex="-1"
class="cxd-InputBox cxd-FormulaPicker-input cxd-InputBox--borderFull"
>
<div
class="cxd-ResultBox-value-wrap"
class="cxd-FormulaCodeEditor cxd-FormulaCodeEditor--singleLine"
style="position: relative;"
>
<input
class="cxd-ResultBox-value-input"
placeholder="暂无数据"
theme="cxd"
type="text"
value="SUM(1 + 2)"
/>
<div
class="CodeMirror cm-s-default"
translate="no"
>
<div
style="overflow: hidden; position: relative; width: 3px; height: 0px;"
>
<textarea
autocapitalize="off"
autocorrect="off"
spellcheck="false"
style="position: absolute; bottom: -1em; padding: 0px; width: 1000px; height: 1em; min-height: 1em; outline: none;"
tabindex="0"
/>
</div>
<div
class="CodeMirror-scrollbar-filler"
cm-not-content="true"
/>
<div
class="CodeMirror-gutter-filler"
cm-not-content="true"
/>
<div
class="CodeMirror-scroll"
tabindex="-1"
>
<div
class="CodeMirror-sizer"
style="margin-left: 0px; min-width: 3px;"
>
<div
style="position: relative;"
>
<div
class="CodeMirror-lines"
role="presentation"
>
<div
role="presentation"
style="position: relative; outline: none;"
>
<div
class="CodeMirror-measure"
/>
<div
class="CodeMirror-measure"
>
<pre
class="CodeMirror-line"
role="presentation"
>
<span
role="presentation"
style="padding-right: .1px;"
>
SUM(1 + 2)
</span>
</pre>
</div>
<div
style="position: relative; z-index: 1;"
/>
<div
class="CodeMirror-cursors"
/>
<div
class="CodeMirror-code"
role="presentation"
/>
</div>
</div>
</div>
</div>
<div
style="position: absolute; height: 50px; width: 1px;"
/>
<div
class="CodeMirror-gutters"
style="display: none;"
/>
</div>
</div>
<div
class="resize-sensor"
style="position: absolute; left: 0px; top: 0px; right: 0px; bottom: 0px; overflow: scroll; z-index: -1; visibility: hidden;"
>
<div
class="resize-sensor-expand"
style="position: absolute; left: 0; top: 0; right: 0; bottom: 0; overflow: scroll; z-index: -1; visibility: hidden;"
>
<div
style="position: absolute; left: 0px; top: 0px; width: 10px; height: 10px;"
/>
</div>
<div
class="resize-sensor-shrink"
style="position: absolute; left: 0; top: 0; right: 0; bottom: 0; overflow: scroll; z-index: -1; visibility: hidden;"
>
<div
style="position: absolute; left: 0; top: 0; width: 200%; height: 200%"
/>
</div>
<div
class="resize-sensor-appear"
style="position: absolute; left: 0; top: 0; right: 0; bottom: 0; overflow: scroll; z-index: -1; visibility: hidden;animation-name: apearSensor; animation-duration: 0.2s;"
/>
</div>
</div>
<div
class="cxd-ResultBox-actions"
/>
<a
class="cxd-InputBox-clear"
>
<icon-mock
classname="icon icon-input-clear"
icon="input-clear"
/>
</a>
</div>
<a
class="cxd-FormulaPicker-toggler"

View File

@ -14,11 +14,17 @@
*/
import React from 'react';
import {fireEvent, render, screen, cleanup, waitFor} from '@testing-library/react';
import {
fireEvent,
render,
screen,
cleanup,
waitFor
} from '@testing-library/react';
import '../../../src';
import {render as amisRender, clearStoresCache} from '../../../src';
import {makeEnv, replaceReactAriaIds, wait} from '../../helper';
import { Select } from 'packages/amis-ui/lib/components/Select';
import {Select} from 'packages/amis-ui/lib/components/Select';
afterEach(() => {
cleanup();
@ -818,218 +824,248 @@ test('Renderer:condition-builder with not embed', async () => {
describe('Renderer: condition-builder with formula', () => {
const onSubmit = jest.fn();
test('condition-builder with different fields', async () => {
const {container} = render(amisRender({
"type": "form",
"data": {
"conditions": {
"id": "68bddc1495e9",
"conjunction": "and",
"children": [
{
"id": "b9cc34dae93a",
"left": {
"type": "field",
"field": "text"
},
"op": "equal"
},
{
"id": "4c718986c321",
"left": {
"type": "field",
"field": "number"
},
"op": "equal"
},
{
"id": "7ee79c416422",
"left": {
"type": "field",
"field": "boolean"
},
"op": "equal"
},
{
"id": "9cd76d8a6522",
"left": {
"type": "field",
"field": "select"
},
"op": "select_equals"
},
{
"id": "20a65e9df546",
"left": {
"type": "field",
"field": "date"
},
"op": "equal"
},
{
"id": "e729b32ea9e8",
"left": {
"type": "field",
"field": "time"
},
"op": "equal"
},
{
"id": "a5f48e000557",
"left": {
"type": "field",
"field": "datetime"
},
"op": "equal"
}
]
}
},
"body": [
const {container} = render(
amisRender(
{
"type": "condition-builder",
"label": "条件组件",
"name": "conditions",
"searchable": true,
"formula": {
"mode": "input-group",
"inputSettings": {},
"allowInput": true,
"mixedMode": true,
"variables": []
},
"fields": [
{
"label": "文本",
"type": "text",
"name": "text"
},
{
"label": "数字",
"type": "number",
"name": "number"
},
{
"label": "布尔",
"type": "boolean",
"name": "boolean"
},
{
"label": "选项",
"type": "select",
"name": "select",
"options": [
type: 'form',
data: {
conditions: {
id: '68bddc1495e9',
conjunction: 'and',
children: [
{
"label": "A",
"value": "a"
id: 'b9cc34dae93a',
left: {
type: 'field',
field: 'text'
},
op: 'equal'
},
{
"label": "B",
"value": "b"
id: '4c718986c321',
left: {
type: 'field',
field: 'number'
},
op: 'equal'
},
{
"label": "C",
"value": "c"
id: '7ee79c416422',
left: {
type: 'field',
field: 'boolean'
},
op: 'equal'
},
{
id: '9cd76d8a6522',
left: {
type: 'field',
field: 'select'
},
op: 'select_equals'
},
{
id: '20a65e9df546',
left: {
type: 'field',
field: 'date'
},
op: 'equal'
},
{
id: 'e729b32ea9e8',
left: {
type: 'field',
field: 'time'
},
op: 'equal'
},
{
id: 'a5f48e000557',
left: {
type: 'field',
field: 'datetime'
},
op: 'equal'
}
]
},
}
},
body: [
{
"label": "日期",
"children": [
type: 'condition-builder',
label: '条件组件',
name: 'conditions',
searchable: true,
formula: {
mode: 'input-group',
inputSettings: {},
allowInput: true,
mixedMode: true,
variables: []
},
fields: [
{
"label": "日期",
"type": "date",
"name": "date"
label: '文本',
type: 'text',
name: 'text'
},
{
"label": "时间",
"type": "time",
"name": "time"
label: '数字',
type: 'number',
name: 'number'
},
{
"label": "日期时间",
"type": "datetime",
"name": "datetime"
label: '布尔',
type: 'boolean',
name: 'boolean'
},
{
label: '选项',
type: 'select',
name: 'select',
options: [
{
label: 'A',
value: 'a'
},
{
label: 'B',
value: 'b'
},
{
label: 'C',
value: 'c'
}
]
},
{
label: '日期',
children: [
{
label: '日期',
type: 'date',
name: 'date'
},
{
label: '时间',
type: 'time',
name: 'time'
},
{
label: '日期时间',
type: 'datetime',
name: 'datetime'
}
]
}
]
}
]
}
]
}, {onSubmit}, makeEnv({})));
},
{onSubmit},
makeEnv({})
)
);
replaceReactAriaIds(container);
// 7种类型都存在
expect(container.querySelectorAll('.cxd-FormulaPicker-input')?.length).toEqual(7);
expect(container.querySelector('.cxd-FormulaPicker--text')).toBeInTheDocument();
expect(container.querySelector('.cxd-FormulaPicker-input-number')).toBeInTheDocument();
expect(container.querySelector('.cxd-FormulaPicker-input-boolean')).toBeInTheDocument();
expect(container.querySelector('.cxd-FormulaPicker-input-select')).toBeInTheDocument();
expect(container.querySelector('.cxd-FormulaPicker-input-date')).toBeInTheDocument();
expect(container.querySelector('.cxd-FormulaPicker-input-time')).toBeInTheDocument();
expect(container.querySelector('.cxd-FormulaPicker-input-datetime')).toBeInTheDocument();
expect(
container.querySelectorAll('.cxd-FormulaPicker-input')?.length
).toEqual(7);
expect(
container.querySelector('.cxd-FormulaPicker--text')
).toBeInTheDocument();
expect(
container.querySelector('.cxd-FormulaPicker-input-number')
).toBeInTheDocument();
expect(
container.querySelector('.cxd-FormulaPicker-input-boolean')
).toBeInTheDocument();
expect(
container.querySelector('.cxd-FormulaPicker-input-select')
).toBeInTheDocument();
expect(
container.querySelector('.cxd-FormulaPicker-input-date')
).toBeInTheDocument();
expect(
container.querySelector('.cxd-FormulaPicker-input-time')
).toBeInTheDocument();
expect(
container.querySelector('.cxd-FormulaPicker-input-datetime')
).toBeInTheDocument();
});
test('condition-builder with select field and change operator', async () => {
const {container, findByText} = render(amisRender({
"type": "form",
"data": {
"conditions": {
"id": "68bddc1495e9",
"conjunction": "and",
"children": [
{
"id": "9cd76d8a6522",
"left": {
"type": "field",
"field": "select"
},
"op": "select_equals"
}
]
}
},
"body": [
const {container, findByText} = render(
amisRender(
{
"type": "condition-builder",
"label": "条件组件",
"name": "conditions",
"searchable": true,
"formula": {
"mode": "input-group",
"inputSettings": {},
"allowInput": true,
"mixedMode": true,
"variables": []
type: 'form',
data: {
conditions: {
id: '68bddc1495e9',
conjunction: 'and',
children: [
{
id: '9cd76d8a6522',
left: {
type: 'field',
field: 'select'
},
op: 'select_equals'
}
]
}
},
"fields": [
body: [
{
"label": "选项",
"type": "select",
"name": "select",
"options": [
type: 'condition-builder',
label: '条件组件',
name: 'conditions',
searchable: true,
formula: {
mode: 'input-group',
inputSettings: {},
allowInput: true,
mixedMode: true,
variables: []
},
fields: [
{
"label": "A",
"value": "a"
},
{
"label": "B",
"value": "b"
},
{
"label": "C",
"value": "c"
label: '选项',
type: 'select',
name: 'select',
options: [
{
label: 'A',
value: 'a'
},
{
label: 'B',
value: 'b'
},
{
label: 'C',
value: 'c'
}
]
}
]
}
]
}
]
}, {}, makeEnv({})));
},
{},
makeEnv({})
)
);
replaceReactAriaIds(container);
// 选中第一个选项Form中默认值是等于操作
let fieldValueControl = container.querySelector('.cxd-FormulaPicker-input-select')!;
let fieldValueControl = container.querySelector(
'.cxd-FormulaPicker-input-select'
)!;
fireEvent.click(fieldValueControl);
await wait(100);
fireEvent.click(await findByText('A'));
@ -1041,85 +1077,101 @@ describe('Renderer: condition-builder with formula', () => {
await wait(100);
fireEvent.click(await findByText('包含'));
await wait(100);
expect(container.querySelector('.cxd-Select-placeholder')).toBeInTheDocument();
fieldValueControl = container.querySelector('.cxd-FormulaPicker-input-select')!;
expect(
container.querySelector('.cxd-Select-placeholder')
).toBeInTheDocument();
fieldValueControl = container.querySelector(
'.cxd-FormulaPicker-input-select'
)!;
fireEvent.click(fieldValueControl);
await wait(100);
expect(container.querySelectorAll('.cxd-Select-option-checkbox').length).toEqual(3);
expect(
container.querySelectorAll('.cxd-Select-option-checkbox').length
).toEqual(3);
});
test('condition-builder with field type change', async () => {
const onSubmit = jest.fn();
const {container, findByText, findByPlaceholderText} = render(amisRender({
"type": "form",
"data": {
"conditions": {
"id": "68bddc1495e9",
"conjunction": "and",
"children": [
{
"id": "b9cc34dae93a",
"left": {
"type": "field",
"field": "text"
},
"op": "equal"
}
]
}
},
"body": [
const {container, findByText, findByPlaceholderText} = render(
amisRender(
{
"type": "condition-builder",
"label": "条件组件",
"name": "conditions",
"searchable": true,
"formula": {
"mode": "input-group",
"inputSettings": {},
"allowInput": true,
"mixedMode": true,
"variables": []
type: 'form',
data: {
conditions: {
id: '68bddc1495e9',
conjunction: 'and',
children: [
{
id: 'b9cc34dae93a',
left: {
type: 'field',
field: 'text'
},
op: 'equal'
}
]
}
},
"fields": [
body: [
{
"label": "文本",
"type": "text",
"name": "text"
},
{
"label": "选项",
"type": "select",
"name": "select",
"options": [
type: 'condition-builder',
label: '条件组件',
name: 'conditions',
searchable: true,
formula: {
mode: 'input-group',
inputSettings: {},
allowInput: true,
mixedMode: true,
variables: []
},
fields: [
{
"label": "A",
"value": "a"
label: '文本',
type: 'text',
name: 'text'
},
{
"label": "B",
"value": "b"
},
{
"label": "C",
"value": "c"
label: '选项',
type: 'select',
name: 'select',
options: [
{
label: 'A',
value: 'a'
},
{
label: 'B',
value: 'b'
},
{
label: 'C',
value: 'c'
}
]
}
]
}
]
}
]
}, {onSubmit}, makeEnv({})));
},
{onSubmit},
makeEnv({})
)
);
replaceReactAriaIds(container);
// 切换字段类型,对应字段值控件更新
const fieldControl = container.querySelector('.cxd-DropDownSelection-input')!;
const fieldControl = container.querySelector(
'.cxd-DropDownSelection-input'
)!;
fireEvent.click(fieldControl);
await wait(100);
fireEvent.click(await findByText('选项'));
await wait(100);
let selectValueControl = container.querySelector('.cxd-FormulaPicker-input-select')!;
let selectValueControl = container.querySelector(
'.cxd-FormulaPicker-input-select'
)!;
expect(selectValueControl).toBeInTheDocument();
// 切换操作符,下拉选项变成多选
@ -1128,11 +1180,17 @@ describe('Renderer: condition-builder with formula', () => {
await wait(100);
fireEvent.click(await findByText('包含'));
await wait(100);
expect(container.querySelector('.cxd-Select-placeholder')).toBeInTheDocument();
selectValueControl = container.querySelector('.cxd-FormulaPicker-input-select')!;
expect(
container.querySelector('.cxd-Select-placeholder')
).toBeInTheDocument();
selectValueControl = container.querySelector(
'.cxd-FormulaPicker-input-select'
)!;
fireEvent.click(selectValueControl);
await wait(100);
expect(container.querySelectorAll('.cxd-Select-option-checkbox').length).toEqual(3);
expect(
container.querySelectorAll('.cxd-Select-option-checkbox').length
).toEqual(3);
// 选择2个选项绑定值变化
fireEvent.click(await findByText('A'));
@ -1145,4 +1203,4 @@ describe('Renderer: condition-builder with formula', () => {
expect(selectedValues.length).toEqual(2);
expect(selectedValues.join(',')).toEqual('A,C');
});
})
});

View File

@ -78,9 +78,9 @@ test('Renderer:input-formula', async () => {
)
);
// await wait(500);
await wait(500);
await findByDisplayValue('SUM(1 + 2)');
// await findByDisplayValue('SUM(1 + 2)');
// TODO: 不知道为啥切换到 @swc/jest 后不支持
// expect(container).toMatchSnapshot();

View File

@ -8,7 +8,7 @@ import {isPureVariable, resolveVariableAndFilter} from 'amis-core';
import type {
FuncGroup,
VariableItem
} from 'amis-ui/lib/components/formula/Editor';
} from 'amis-ui/src/components/formula/CodeEditor';
import type {FormulaPickerInputSettings} from 'amis-ui/lib/components/formula/Picker';
/**