feat(material): condition row (#268)

* feat(material): condition row

* docs(material): condition row docs
This commit is contained in:
Yiwei Mao 2025-05-23 16:52:27 +08:00 committed by GitHub
parent 9c69aa4e52
commit bac29feb3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 411 additions and 57 deletions

View File

@ -57,15 +57,25 @@ export const initialData: FlowDocumentJSON = {
{
key: 'if_0',
value: {
type: 'expression',
content: '',
left: {
type: 'ref',
content: ['start_0', 'query'],
},
operator: 'contains',
right: {
type: 'constant',
content: 'Hello Flow.',
},
},
},
{
key: 'if_f0rOAt',
value: {
type: 'expression',
content: '',
left: {
type: 'ref',
content: ['start_0', 'enable'],
},
operator: 'is_true',
},
},
],

View File

@ -1,6 +1,6 @@
import { nanoid } from 'nanoid';
import { Field, FieldArray } from '@flowgram.ai/free-layout-editor';
import { IFlowValue, VariableSelector } from '@flowgram.ai/form-materials';
import { ConditionRow, ConditionRowValueType, VariableSelector } from '@flowgram.ai/form-materials';
import { Button } from '@douyinfe/semi-ui';
import { IconPlus, IconCrossCircleStroked } from '@douyinfe/semi-icons';
@ -11,7 +11,7 @@ import { ConditionPort } from './styles';
interface ConditionValue {
key: string;
value: IFlowValue;
value?: ConditionRowValueType;
}
export function ConditionInputs() {
@ -25,17 +25,11 @@ export function ConditionInputs() {
{({ field: childField, fieldState: childState }) => (
<FormItem name="if" type="boolean" required={true} labelWidth={40}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<VariableSelector
style={{ width: '100%' }}
value={childField.value?.value?.content as string[]}
onChange={(v) =>
childField.onChange({
key: childField.value.key,
value: { type: 'ref', content: v },
})
}
hasError={Object.keys(childState?.errors || {}).length > 0}
<ConditionRow
readonly={readonly}
style={{ flexGrow: 1 }}
value={childField.value.value}
onChange={(v) => childField.onChange({ value: v, key: childField.value.key })}
/>
<Button

View File

@ -18,8 +18,8 @@ export const formMeta: FormMeta<FlowNodeJSON> = {
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),
'inputsValues.conditions.*': ({ value }) => {
if (!value?.value?.content) return 'Condition is required';
'conditions.*': ({ value }) => {
if (!value?.value) return 'Condition is required';
return undefined;
},
},

View File

@ -25,37 +25,16 @@ export const ConditionNodeRegistry: FlowNodeRegistry = {
type: 'condition',
data: {
title: 'Condition',
inputsValues: {
conditions: [
{
key: `if_${nanoid(5)}`,
value: '',
},
{
key: `if_${nanoid(5)}`,
value: '',
},
],
},
inputs: {
type: 'object',
properties: {
conditions: {
type: 'array',
items: {
type: 'object',
properties: {
key: {
type: 'string',
},
value: {
type: 'string',
},
},
},
},
conditions: [
{
key: `if_${nanoid(5)}`,
value: {},
},
},
{
key: `if_${nanoid(5)}`,
value: {},
},
],
},
};
},

View File

@ -96,6 +96,16 @@ After the CLI runs successfully, the relevant materials will be automatically ad
DynamicValueInput is used for configuring values (constant values + variable values).
</MaterialDisplay>
### ConditionRow
<MaterialDisplay
imgs={[{ src: '/materials/condition-row.png', caption: 'The first condition checks if the query variable contains Hello Flow, the second condition checks if the enable variable is true.' }]}
filePath="components/condition-row/index.tsx"
exportName="ConditionRow"
>
ConditionRow is used for configuring a **single row** of condition judgment.
</MaterialDisplay>
## Currently Supported Effect Materials
### provideBatchInput

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View File

@ -97,6 +97,19 @@ CLI 运行成功后,相关物料会自动添加到当前项目下的 `src/form
DynamicValueInput 用于值(常量值 + 变量值)的配置
</MaterialDisplay>
### ConditionRow
<MaterialDisplay
imgs={[{ src: '/materials/condition-row.png', caption: '第一个条件为 query 变量包含 Hello Flow第二个条件为 enable 变量为 true' }]}
filePath="components/condition-row/index.tsx"
exportName="ConditionRow"
>
ConditionRow 用于 **一行** 条件判断的配置
</MaterialDisplay>
## 当前支持的 Effect 物料
### provideBatchInput

View File

@ -0,0 +1,5 @@
{
"name": "condition-row",
"depMaterials": ["variable-selector", "dynamic-value-input", "flow-value", "utils/json-schema", "typings/json-schema"],
"depPackages": ["@douyinfe/semi-ui", "styled-components"]
}

View File

@ -0,0 +1,123 @@
import { IRules, Op, OpConfigs } from './types';
export const rules: IRules = {
string: {
[Op.EQ]: 'string',
[Op.NEQ]: 'string',
[Op.CONTAINS]: 'string',
[Op.NOT_CONTAINS]: 'string',
[Op.IN]: 'array',
[Op.NIN]: 'array',
[Op.IS_EMPTY]: 'string',
[Op.IS_NOT_EMPTY]: 'string',
},
number: {
[Op.EQ]: 'number',
[Op.NEQ]: 'number',
[Op.GT]: 'number',
[Op.GTE]: 'number',
[Op.LT]: 'number',
[Op.LTE]: 'number',
[Op.IN]: 'array',
[Op.NIN]: 'array',
[Op.IS_EMPTY]: null,
[Op.IS_NOT_EMPTY]: null,
},
integer: {
[Op.EQ]: 'number',
[Op.NEQ]: 'number',
[Op.GT]: 'number',
[Op.GTE]: 'number',
[Op.LT]: 'number',
[Op.LTE]: 'number',
[Op.IN]: 'array',
[Op.NIN]: 'array',
[Op.IS_EMPTY]: null,
[Op.IS_NOT_EMPTY]: null,
},
boolean: {
[Op.EQ]: 'boolean',
[Op.NEQ]: 'boolean',
[Op.IS_TRUE]: null,
[Op.IS_FALSE]: null,
[Op.IN]: 'array',
[Op.NIN]: 'array',
[Op.IS_EMPTY]: null,
[Op.IS_NOT_EMPTY]: null,
},
object: {
[Op.IS_EMPTY]: null,
[Op.IS_NOT_EMPTY]: null,
},
array: {
[Op.IS_EMPTY]: null,
[Op.IS_NOT_EMPTY]: null,
},
map: {
[Op.IS_EMPTY]: null,
[Op.IS_NOT_EMPTY]: null,
},
};
export const opConfigs: OpConfigs = {
[Op.EQ]: {
label: 'Equal',
abbreviation: '=',
},
[Op.NEQ]: {
label: 'Not Equal',
abbreviation: '≠',
},
[Op.GT]: {
label: 'Greater Than',
abbreviation: '>',
},
[Op.GTE]: {
label: 'Greater Than or Equal',
abbreviation: '>=',
},
[Op.LT]: {
label: 'Less Than',
abbreviation: '<',
},
[Op.LTE]: {
label: 'Less Than or Equal',
abbreviation: '<=',
},
[Op.IN]: {
label: 'In',
abbreviation: '∈',
},
[Op.NIN]: {
label: 'Not In',
abbreviation: '∉',
},
[Op.CONTAINS]: {
label: 'Contains',
abbreviation: '⊇',
},
[Op.NOT_CONTAINS]: {
label: 'Not Contains',
abbreviation: '⊉',
},
[Op.IS_EMPTY]: {
label: 'Is Empty',
abbreviation: '=',
rightDisplay: 'Empty',
},
[Op.IS_NOT_EMPTY]: {
label: 'Is Not Empty',
abbreviation: '≠',
rightDisplay: 'Empty',
},
[Op.IS_TRUE]: {
label: 'Is True',
abbreviation: '=',
rightDisplay: 'True',
},
[Op.IS_FALSE]: {
label: 'Is False',
abbreviation: '=',
rightDisplay: 'False',
},
};

View File

@ -0,0 +1,45 @@
import React, { useMemo } from 'react';
import { Button, Select } from '@douyinfe/semi-ui';
import { IconChevronDownStroked } from '@douyinfe/semi-icons';
import { IRule, Op } from '../types';
import { opConfigs } from '../constants';
interface HookParams {
rule?: IRule;
op?: Op;
onChange: (op: Op) => void;
}
export function useOp({ rule, op, onChange }: HookParams) {
const options = useMemo(
() =>
Object.keys(rule || {}).map((_op) => ({
...(opConfigs[_op as Op] || {}),
value: _op,
})),
[rule]
);
const opConfig = useMemo(() => opConfigs[op as Op], [op]);
const renderOpSelect = () => (
<Select
style={{ height: 22 }}
size="small"
value={op}
optionList={options}
onChange={(v) => {
onChange(v as Op);
}}
triggerRender={({ value }) => (
<Button size="small" disabled={!rule}>
{opConfig?.abbreviation || <IconChevronDownStroked size="small" />}
</Button>
)}
/>
);
return { renderOpSelect, opConfig };
}

View File

@ -0,0 +1,26 @@
import { useMemo } from 'react';
import { useScopeAvailable } from '@flowgram.ai/editor';
import { rules } from '../constants';
import { JsonSchemaUtils } from '../../../utils';
import { IFlowRefValue, JsonSchemaBasicType } from '../../../typings';
export function useRule(left?: IFlowRefValue) {
const available = useScopeAvailable();
const variable = useMemo(() => {
if (!left) return undefined;
return available.getByKeyPath(left.content);
}, [available, left]);
const rule = useMemo(() => {
if (!variable) return undefined;
const schema = JsonSchemaUtils.astToSchema(variable.type, { drilldown: false });
return rules[schema?.type as JsonSchemaBasicType];
}, [variable?.type]);
return { rule };
}

View File

@ -0,0 +1,71 @@
import React, { useMemo } from 'react';
import { Input } from '@douyinfe/semi-ui';
import { ConditionRowValueType, Op } from './types';
import { UIContainer, UILeft, UIOperator, UIRight, UIValues } from './styles';
import { useRule } from './hooks/useRule';
import { useOp } from './hooks/useOp';
import { VariableSelector } from '../variable-selector';
import { DynamicValueInput } from '../dynamic-value-input';
import { JsonSchemaBasicType } from '../../typings';
interface PropTypes {
value?: ConditionRowValueType;
onChange: (value?: ConditionRowValueType) => void;
style?: React.CSSProperties;
readonly?: boolean;
}
export function ConditionRow({ style, value, onChange, readonly }: PropTypes) {
const { left, operator, right } = value || {};
const { rule } = useRule(left);
const { renderOpSelect, opConfig } = useOp({
rule,
op: operator,
onChange: (v) => onChange({ ...value, operator: v }),
});
const targetSchema = useMemo(() => {
const targetType: JsonSchemaBasicType | null = rule?.[operator as Op] || null;
return targetType ? { type: targetType, extra: { weak: true } } : null;
}, [rule, opConfig]);
return (
<UIContainer style={style}>
<UIOperator>{renderOpSelect()}</UIOperator>
<UIValues>
<UILeft>
<VariableSelector
readonly={readonly}
style={{ width: '100%' }}
value={left?.content}
onChange={(v) =>
onChange({
...value,
left: {
type: 'ref',
content: v,
},
})
}
/>
</UILeft>
<UIRight>
{targetSchema ? (
<DynamicValueInput
readonly={readonly || !rule}
value={right}
schema={targetSchema}
onChange={(v) => onChange({ ...value, right: v })}
/>
) : (
<Input size="small" disabled value={opConfig?.rightDisplay || 'Empty'} />
)}
</UIRight>
</UIValues>
</UIContainer>
);
}
export { ConditionRowValueType };

View File

@ -0,0 +1,25 @@
import styled from 'styled-components';
export const UIContainer = styled.div`
display: flex;
align-items: center;
gap: 4px;
`;
export const UIOperator = styled.div``;
export const UILeft = styled.div`
width: 100%;
`;
export const UIRight = styled.div`
width: 100%;
`;
export const UIValues = styled.div`
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
`;

View File

@ -0,0 +1,37 @@
import { IFlowConstantRefValue, IFlowRefValue, JsonSchemaBasicType } from '../../typings';
export enum Op {
EQ = 'eq',
NEQ = 'neq',
GT = 'gt',
GTE = 'gte',
LT = 'lt',
LTE = 'lte',
IN = 'in',
NIN = 'nin',
CONTAINS = 'contains',
NOT_CONTAINS = 'not_contains',
IS_EMPTY = 'is_empty',
IS_NOT_EMPTY = 'is_not_empty',
IS_TRUE = 'is_true',
IS_FALSE = 'is_false',
}
export interface OpConfig {
label: string;
abbreviation: string;
// When right is not a value, display this text
rightDisplay?: string;
}
export type OpConfigs = Record<Op, OpConfig>;
export type IRule = Partial<Record<Op, JsonSchemaBasicType | null>>;
export type IRules = Record<JsonSchemaBasicType, IRule>;
export interface ConditionRowValueType {
left?: IFlowRefValue;
operator?: Op;
right?: IFlowConstantRefValue;
}

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo } from 'react';
import { IconButton } from '@douyinfe/semi-ui';
import { IconSetting } from '@douyinfe/semi-icons';
@ -31,6 +31,14 @@ export function DynamicValueInput({
schema,
constantProps,
}: PropsType) {
// When is number type, include integer as well
const includeSchema = useMemo(() => {
if (schema?.type === 'number') {
return [schema, { type: 'integer' }];
}
return schema;
}, [schema]);
const renderMain = () => {
if (value?.type === 'ref') {
// Display Variable Or Delete
@ -38,7 +46,7 @@ export function DynamicValueInput({
<VariableSelector
value={value?.content}
onChange={(_v) => onChange(_v ? { type: 'ref', content: _v } : undefined)}
includeSchema={schema}
includeSchema={includeSchema}
readonly={readonly}
/>
);
@ -60,7 +68,7 @@ export function DynamicValueInput({
style={{ width: '100%' }}
value={value?.type === 'ref' ? value?.content : undefined}
onChange={(_v) => onChange({ type: 'ref', content: _v })}
includeSchema={schema}
includeSchema={includeSchema}
readonly={readonly}
triggerRender={() => (
<IconButton disabled={readonly} size="small" icon={<IconSetting size="small" />} />

View File

@ -4,3 +4,4 @@ export * from './json-schema-editor';
export * from './batch-variable-selector';
export * from './constant-input';
export * from './dynamic-value-input';
export * from './condition-row';

View File

@ -74,7 +74,12 @@ export namespace JsonSchemaUtils {
* @param typeAST
* @returns
*/
export function astToSchema(typeAST: ASTNode): IJsonSchema | undefined {
export function astToSchema(
typeAST: ASTNode,
options?: { drilldown?: boolean }
): IJsonSchema | undefined {
const { drilldown = true } = options || {};
if (ASTMatch.isString(typeAST)) {
return {
type: 'string',
@ -102,23 +107,25 @@ export namespace JsonSchemaUtils {
if (ASTMatch.isObject(typeAST)) {
return {
type: 'object',
properties: Object.fromEntries(
Object.entries(typeAST.properties).map(([key, value]) => [key, astToSchema(value)!])
),
properties: drilldown
? Object.fromEntries(
Object.entries(typeAST.properties).map(([key, value]) => [key, astToSchema(value)!])
)
: {},
};
}
if (ASTMatch.isArray(typeAST)) {
return {
type: 'array',
items: astToSchema(typeAST.items),
items: drilldown ? astToSchema(typeAST.items) : undefined,
};
}
if (ASTMatch.isMap(typeAST)) {
return {
type: 'map',
items: astToSchema(typeAST.valueType),
items: drilldown ? astToSchema(typeAST.valueType) : undefined,
};
}