feat(variable): loop batch variable materials + update ref value typings (#253)

* feat(variable): loop batch variable materials + set variable ref content in demo to be array

* docs(materials): dynamic value input and provide batch input
This commit is contained in:
Yiwei Mao 2025-05-22 19:27:44 +08:00 committed by GitHub
parent 1de20674c5
commit 67fac8cdf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
76 changed files with 1482 additions and 777 deletions

View File

@ -1,6 +1,6 @@
import { DynamicValueInput } from '@flowgram.ai/form-materials';
import { Field } from '@flowgram.ai/fixed-layout-editor'; import { Field } from '@flowgram.ai/fixed-layout-editor';
import { FxExpression } from '../fx-expression';
import { FormItem } from '../form-item'; import { FormItem } from '../form-item';
import { Feedback } from '../feedback'; import { Feedback } from '../feedback';
import { JsonSchema } from '../../typings'; import { JsonSchema } from '../../typings';
@ -26,11 +26,12 @@ export function FormInputs() {
type={property.type as string} type={property.type as string}
required={required.includes(key)} required={required.includes(key)}
> >
<FxExpression <DynamicValueInput
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
readonly={readonly} readonly={readonly}
hasError={Object.keys(fieldState?.errors || {}).length > 0} hasError={Object.keys(fieldState?.errors || {}).length > 0}
schema={property}
/> />
<Feedback errors={fieldState?.errors} /> <Feedback errors={fieldState?.errors} />
</FormItem> </FormItem>

View File

@ -1,91 +0,0 @@
import React, { type SVGProps } from 'react';
import { VariableSelector } from '@flowgram.ai/form-materials';
import { Input, Button } from '@douyinfe/semi-ui';
import { ValueDisplay } from '../value-display';
import { FlowRefValueSchema, FlowLiteralValueSchema } from '../../typings';
export function FxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" {...props}>
<path
fill="currentColor"
fillRule="evenodd"
d="M5.581 4.49A2.75 2.75 0 0 1 8.319 2h.931a.75.75 0 0 1 0 1.5h-.931a1.25 1.25 0 0 0-1.245 1.131l-.083.869H9.25a.75.75 0 0 1 0 1.5H6.849l-.43 4.51A2.75 2.75 0 0 1 3.681 14H2.75a.75.75 0 0 1 0-1.5h.931a1.25 1.25 0 0 0 1.245-1.132L5.342 7H3.75a.75.75 0 0 1 0-1.5h1.735zM9.22 9.22a.75.75 0 0 1 1.06 0l1.22 1.22l1.22-1.22a.75.75 0 1 1 1.06 1.06l-1.22 1.22l1.22 1.22a.75.75 0 1 1-1.06 1.06l-1.22-1.22l-1.22 1.22a.75.75 0 1 1-1.06-1.06l1.22-1.22l-1.22-1.22a.75.75 0 0 1 0-1.06"
clipRule="evenodd"
></path>
</svg>
);
}
function InputWrap({
value,
onChange,
readonly,
hasError,
style,
}: {
value: string;
onChange: (v: string) => void;
readonly?: boolean;
hasError?: boolean;
style?: React.CSSProperties;
}) {
if (readonly) {
return <ValueDisplay value={value} hasError={hasError} />;
}
return (
<Input
value={value as string}
onChange={onChange}
validateStatus={hasError ? 'error' : undefined}
style={style}
/>
);
}
export interface FxExpressionProps {
value?: FlowLiteralValueSchema | FlowRefValueSchema;
onChange: (value: FlowLiteralValueSchema | FlowRefValueSchema) => void;
literal?: boolean;
hasError?: boolean;
readonly?: boolean;
icon?: React.ReactNode;
}
export function FxExpression(props: FxExpressionProps) {
const { value, onChange, readonly, literal, icon } = props;
if (literal) return <InputWrap value={value as string} onChange={onChange} readonly={readonly} />;
const isExpression = typeof value === 'object' && value.type === 'expression';
const toggleExpression = () => {
if (isExpression) {
onChange((value as FlowRefValueSchema).content as string);
} else {
onChange({ content: value as string, type: 'expression' });
}
};
return (
<div style={{ display: 'flex', maxWidth: 300 }}>
{isExpression ? (
<VariableSelector
value={value.content}
hasError={props.hasError}
style={{ flexGrow: 1 }}
onChange={(v) => onChange({ type: 'expression', content: v })}
readonly={readonly}
/>
) : (
<InputWrap
value={value as string}
onChange={onChange}
hasError={props.hasError}
readonly={readonly}
style={{ flexGrow: 1, outline: props.hasError ? '1px solid red' : undefined }}
/>
)}
{!readonly &&
(icon || <Button theme="borderless" icon={<FxIcon />} onClick={toggleExpression} />)}
</div>
);
}

View File

@ -1,10 +1,9 @@
import React, { useState, useLayoutEffect } from 'react'; import React, { useState, useLayoutEffect } from 'react';
import { VariableSelector } from '@flowgram.ai/form-materials'; import { VariableSelector, TypeSelector, DynamicValueInput } from '@flowgram.ai/form-materials';
import { Input, Button } from '@douyinfe/semi-ui'; import { Input, Button } from '@douyinfe/semi-ui';
import { IconCrossCircleStroked } from '@douyinfe/semi-icons'; import { IconCrossCircleStroked } from '@douyinfe/semi-icons';
import { TypeSelector } from '../type-selector';
import { JsonSchema } from '../../typings'; import { JsonSchema } from '../../typings';
import { LeftColumn, Row } from './styles'; import { LeftColumn, Row } from './styles';
@ -24,6 +23,11 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
value[key] = val; value[key] = val;
props.onChange(value, props.propertyKey); props.onChange(value, props.propertyKey);
}; };
const partialUpdateProperty = (val?: Partial<JsonSchema>) => {
props.onChange({ ...value, ...val }, props.propertyKey);
};
useLayoutEffect(() => { useLayoutEffect(() => {
updateKey(props.propertyKey); updateKey(props.propertyKey);
}, [props.propertyKey]); }, [props.propertyKey]);
@ -31,14 +35,15 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
<Row> <Row>
<LeftColumn> <LeftColumn>
<TypeSelector <TypeSelector
value={value.type} value={value}
disabled={disabled} disabled={disabled}
style={{ position: 'absolute', top: 6, left: 4, zIndex: 1 }} style={{ position: 'absolute', top: 2, left: 4, zIndex: 1, padding: '0 5px', height: 20 }}
onChange={(val) => updateProperty('type', val)} onChange={(val) => partialUpdateProperty(val)}
/> />
<Input <Input
value={inputKey} value={inputKey}
disabled={disabled} disabled={disabled}
size="small"
onChange={(v) => updateKey(v.trim())} onChange={(v) => updateKey(v.trim())}
onBlur={() => { onBlur={() => {
if (inputKey !== '') { if (inputKey !== '') {
@ -50,22 +55,22 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
style={{ paddingLeft: 26 }} style={{ paddingLeft: 26 }}
/> />
</LeftColumn> </LeftColumn>
{props.useFx ? ( {
<VariableSelector <DynamicValueInput
value={value.default}
readonly={disabled}
onChange={(val) => updateProperty('default', val)}
style={{ flexGrow: 1, height: 32 }}
/>
) : (
<Input
disabled={disabled}
value={value.default} value={value.default}
onChange={(val) => updateProperty('default', val)} onChange={(val) => updateProperty('default', val)}
schema={value}
style={{ flexGrow: 1 }}
/> />
)} }
{props.onDelete && !disabled && ( {props.onDelete && !disabled && (
<Button theme="borderless" icon={<IconCrossCircleStroked />} onClick={props.onDelete} /> <Button
style={{ marginLeft: 5, position: 'relative', top: 2 }}
size="small"
theme="borderless"
icon={<IconCrossCircleStroked />}
onClick={props.onDelete}
/>
)} )}
</Row> </Row>
); );

View File

@ -9,7 +9,7 @@ export const Row = styled.div`
`; `;
export const LeftColumn = styled.div` export const LeftColumn = styled.div`
width: 300px; width: 120px;
margin-right: 10px; margin-right: 5px;
position: relative; position: relative;
`; `;

View File

@ -1,50 +0,0 @@
import React from 'react';
import { VariableTypeIcons } from '@flowgram.ai/form-materials';
import { Tag, Dropdown } from '@douyinfe/semi-ui';
export interface TypeSelectorProps {
value?: string;
disabled?: boolean;
onChange?: (value?: string) => void;
style?: React.CSSProperties;
}
const dropdownMenus = ['object', 'boolean', 'array', 'string', 'integer', 'number'];
export const TypeSelector: React.FC<TypeSelectorProps> = (props) => {
const { value, disabled } = props;
const icon = VariableTypeIcons[value as any];
return (
<Dropdown
trigger="hover"
position="bottomRight"
disabled={disabled}
render={
<Dropdown.Menu>
{dropdownMenus.map((key) => (
<Dropdown.Item
key={key}
onClick={() => {
props.onChange?.(key);
}}
>
{VariableTypeIcons[key]}
<span style={{ paddingLeft: '4px' }}>{key}</span>
</Dropdown.Item>
))}
</Dropdown.Menu>
}
>
<Tag
color="white"
style={props.style}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
{icon}
</Tag>
</Dropdown>
);
};

View File

@ -15,6 +15,24 @@ export const initialData: FlowDocumentJSON = {
type: 'string', type: 'string',
default: 'Hello Flow.', default: 'Hello Flow.',
}, },
enable: {
type: 'boolean',
default: true,
},
array_obj: {
type: 'array',
items: {
type: 'object',
properties: {
int: {
type: 'number',
},
str: {
type: 'string',
},
},
},
},
}, },
}, },
}, },
@ -26,10 +44,22 @@ export const initialData: FlowDocumentJSON = {
data: { data: {
title: 'LLM', title: 'LLM',
inputsValues: { inputsValues: {
modelType: 'gpt-3.5-turbo', modelType: {
temperature: 0.5, type: 'constant',
systemPrompt: 'You are an AI assistant.', content: 'gpt-3.5-turbo',
prompt: '', },
temperature: {
type: 'constant',
content: 0.5,
},
systemPrompt: {
type: 'constant',
content: 'You are an AI assistant.',
},
prompt: {
type: 'constant',
content: '',
},
}, },
inputs: { inputs: {
type: 'object', type: 'object',
@ -62,6 +92,10 @@ export const initialData: FlowDocumentJSON = {
type: 'loop', type: 'loop',
data: { data: {
title: 'Loop', title: 'Loop',
batchFor: {
type: 'ref',
content: ['start_0', 'array_obj'],
},
}, },
blocks: [ blocks: [
{ {

View File

@ -3,6 +3,7 @@ import { nanoid } from 'nanoid';
import { defaultFormMeta } from '../default-form-meta'; import { defaultFormMeta } from '../default-form-meta';
import { FlowNodeRegistry } from '../../typings'; import { FlowNodeRegistry } from '../../typings';
import iconLoop from '../../assets/icon-loop.svg'; import iconLoop from '../../assets/icon-loop.svg';
import { LoopFormRender } from './loop-form-render';
export const LoopNodeRegistry: FlowNodeRegistry = { export const LoopNodeRegistry: FlowNodeRegistry = {
type: 'loop', type: 'loop',
@ -14,7 +15,7 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
meta: { meta: {
expandable: false, // disable expanded expandable: false, // disable expanded
}, },
formMeta: defaultFormMeta, formMeta: { ...defaultFormMeta, render: LoopFormRender },
onAdd() { onAdd() {
return { return {
id: `loop_${nanoid(5)}`, id: `loop_${nanoid(5)}`,

View File

@ -0,0 +1,54 @@
import { BatchVariableSelector, IFlowRefValue } from '@flowgram.ai/form-materials';
import { FormRenderProps, FlowNodeJSON, Field } from '@flowgram.ai/fixed-layout-editor';
import { useIsSidebar } from '../../hooks';
import { FormHeader, FormContent, FormOutputs, FormItem, Feedback } from '../../form-components';
interface LoopNodeJSON extends FlowNodeJSON {
data: {
batchFor: IFlowRefValue;
};
}
export const LoopFormRender = ({ form }: FormRenderProps<LoopNodeJSON>) => {
const isSidebar = useIsSidebar();
const readonly = !isSidebar;
const batchFor = (
<Field<IFlowRefValue> name={`batchFor`}>
{({ field, fieldState }) => (
<FormItem name={'batchFor'} type={'array'} required>
<BatchVariableSelector
style={{ width: '100%' }}
value={field.value?.content}
onChange={(val) => field.onChange({ type: 'ref', content: val })}
readonly={readonly}
hasError={Object.keys(fieldState?.errors || {}).length > 0}
/>
<Feedback errors={fieldState?.errors} />
</FormItem>
)}
</Field>
);
if (isSidebar) {
return (
<>
<FormHeader />
<FormContent>
{batchFor}
<FormOutputs />
</FormContent>
</>
);
}
return (
<>
<FormHeader />
<FormContent>
{batchFor}
<FormOutputs />
</FormContent>
</>
);
};

View File

@ -7,8 +7,6 @@ import {
ASTFactory, ASTFactory,
} from '@flowgram.ai/fixed-layout-editor'; } from '@flowgram.ai/fixed-layout-editor';
import { createASTFromJSONSchema } from './utils';
export interface SyncVariablePluginOptions {} export interface SyncVariablePluginOptions {}
/** /**
@ -39,7 +37,7 @@ export const createSyncVariablePlugin: PluginCreator<SyncVariablePluginOptions>
// Create an Type AST from the output data's JSON schema // Create an Type AST from the output data's JSON schema
// NOTICE: You can create a new function to generate an AST based on YOUR CUSTOM DSL // NOTICE: You can create a new function to generate an AST based on YOUR CUSTOM DSL
const typeAST = createASTFromJSONSchema(value); const typeAST = ASTFactory.createTypeASTFromSchema(value);
if (typeAST) { if (typeAST) {
// Use the node's title or its ID as the title for the variable // Use the node's title or its ID as the title for the variable
@ -53,7 +51,7 @@ export const createSyncVariablePlugin: PluginCreator<SyncVariablePluginOptions>
icon: node.getNodeRegistry()?.info?.icon, icon: node.getNodeRegistry()?.info?.icon,
// NOTICE: You can add more metadata here as needed // NOTICE: You can add more metadata here as needed
}, },
key: `${node.id}.outputs`, key: `${node.id}`,
type: typeAST, type: typeAST,
}) })
); );

View File

@ -1,65 +0,0 @@
import { get } from 'lodash-es';
import { ASTFactory, type ASTNodeJSON } from '@flowgram.ai/fixed-layout-editor';
import { type JsonSchema } from '../../typings';
/**
* Sorts the properties of a JSON schema based on the 'extra.index' field.
* If the 'extra.index' field is not present, the property will be treated as having an index of 0.
*
* @param properties - The properties of the JSON schema to sort.
* @returns A sorted array of property entries.
*/
function sortProperties(properties: Record<string, JsonSchema>) {
return Object.entries(properties).sort(
(a, b) => (get(a?.[1], 'extra.index') || 0) - (get(b?.[1], 'extra.index') || 0)
);
}
/**
* Converts a JSON schema to an Abstract Syntax Tree (AST) representation.
* This function recursively processes the JSON schema and creates corresponding AST nodes.
*
* For more information on JSON Schema, refer to the official documentation:
* https://json-schema.org/
*
* Note: Depending on your business needs, you can use your own Domain-Specific Language (DSL)
* Create a new function to convert your custom DSL to AST directly.
*
* @param jsonSchema - The JSON schema to convert.
* @returns An AST node representing the JSON schema, or undefined if the schema type is not recognized.
*/
export function createASTFromJSONSchema(jsonSchema: JsonSchema): ASTNodeJSON | undefined {
const { type } = jsonSchema || {};
if (!type) {
return undefined;
}
switch (type) {
case 'object':
return ASTFactory.createObject({
properties: sortProperties(jsonSchema.properties || {}).map(([key, _property]) => ({
key,
type: createASTFromJSONSchema(_property),
meta: { description: _property.description },
})),
});
case 'array':
return ASTFactory.createArray({
items: createASTFromJSONSchema(jsonSchema.items!),
});
case 'string':
return ASTFactory.createString();
case 'number':
return ASTFactory.createNumber();
case 'boolean':
return ASTFactory.createBoolean();
case 'integer':
return ASTFactory.createInteger();
default:
// If the type is not recognized, return CustomType
return ASTFactory.createCustomType({ typeName: type });
}
}

View File

@ -1,18 +1,4 @@
export type BasicType = 'boolean' | 'string' | 'integer' | 'number' | 'object' | 'array'; import type { VarJSONSchema } from '@flowgram.ai/fixed-layout-editor';
export interface JsonSchema<T extends BasicType = BasicType> { export type BasicType = VarJSONSchema.BasicType;
type?: T; export type JsonSchema = VarJSONSchema.ISchema;
default?: any;
title?: string;
description?: string;
enum?: (string | number)[];
properties?: Record<string, JsonSchema>;
additionalProperties?: JsonSchema;
items?: JsonSchema;
required?: string[];
$ref?: string;
extra?: {
literal?: boolean; // is literal type
formComponent?: string; // Set the render component
};
}

View File

@ -1,3 +1,4 @@
import { IFlowValue } from '@flowgram.ai/form-materials';
import { import {
FlowNodeJSON as FlowNodeJSONDefault, FlowNodeJSON as FlowNodeJSONDefault,
FlowNodeRegistry as FlowNodeRegistryDefault, FlowNodeRegistry as FlowNodeRegistryDefault,
@ -7,12 +8,6 @@ import {
import { type JsonSchema } from './json-schema'; import { type JsonSchema } from './json-schema';
export type FlowLiteralValueSchema = string | number | boolean;
export type FlowRefValueSchema =
| { type: 'ref'; content?: string }
| { type: 'expression'; content?: string }
| { type: 'template'; content?: string };
export type FlowValueSchema = FlowLiteralValueSchema | FlowRefValueSchema;
/** /**
* You can customize the data of the node, and here you can use JsonSchema to define the input and output of the node * You can customize the data of the node, and here you can use JsonSchema to define the input and output of the node
* data , JsonSchema / * data , JsonSchema /
@ -22,11 +17,11 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
/** /**
* Node title * Node title
*/ */
title: string; title?: string;
/** /**
* Inputs data values * Inputs data values
*/ */
inputsValues?: Record<string, FlowValueSchema>; inputsValues?: Record<string, IFlowValue>;
/** /**
* Define the inputs data of the node by JsonSchema * Define the inputs data of the node by JsonSchema
*/ */
@ -35,6 +30,10 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
* Define the outputs data of the node by JsonSchema * Define the outputs data of the node by JsonSchema
*/ */
outputs?: JsonSchema; outputs?: JsonSchema;
/**
* Rest properties
*/
[key: string]: any;
}; };
} }

View File

@ -1,6 +1,6 @@
import { Field } from '@flowgram.ai/free-layout-editor'; import { Field } from '@flowgram.ai/free-layout-editor';
import { DynamicValueInput } from '@flowgram.ai/form-materials';
import { FxExpression } from '../fx-expression';
import { FormItem } from '../form-item'; import { FormItem } from '../form-item';
import { Feedback } from '../feedback'; import { Feedback } from '../feedback';
import { JsonSchema } from '../../typings'; import { JsonSchema } from '../../typings';
@ -26,11 +26,12 @@ export function FormInputs() {
type={property.type as string} type={property.type as string}
required={required.includes(key)} required={required.includes(key)}
> >
<FxExpression <DynamicValueInput
value={field.value} value={field.value}
onChange={field.onChange} onChange={field.onChange}
readonly={readonly} readonly={readonly}
hasError={Object.keys(fieldState?.errors || {}).length > 0} hasError={Object.keys(fieldState?.errors || {}).length > 0}
schema={property}
/> />
<Feedback errors={fieldState?.errors} /> <Feedback errors={fieldState?.errors} />
</FormItem> </FormItem>

View File

@ -1,91 +0,0 @@
import React, { type SVGProps } from 'react';
import { VariableSelector } from '@flowgram.ai/form-materials';
import { Input, Button } from '@douyinfe/semi-ui';
import { ValueDisplay } from '../value-display';
import { FlowRefValueSchema, FlowLiteralValueSchema } from '../../typings';
export function FxIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 16 16" {...props}>
<path
fill="currentColor"
fillRule="evenodd"
d="M5.581 4.49A2.75 2.75 0 0 1 8.319 2h.931a.75.75 0 0 1 0 1.5h-.931a1.25 1.25 0 0 0-1.245 1.131l-.083.869H9.25a.75.75 0 0 1 0 1.5H6.849l-.43 4.51A2.75 2.75 0 0 1 3.681 14H2.75a.75.75 0 0 1 0-1.5h.931a1.25 1.25 0 0 0 1.245-1.132L5.342 7H3.75a.75.75 0 0 1 0-1.5h1.735zM9.22 9.22a.75.75 0 0 1 1.06 0l1.22 1.22l1.22-1.22a.75.75 0 1 1 1.06 1.06l-1.22 1.22l1.22 1.22a.75.75 0 1 1-1.06 1.06l-1.22-1.22l-1.22 1.22a.75.75 0 1 1-1.06-1.06l1.22-1.22l-1.22-1.22a.75.75 0 0 1 0-1.06"
clipRule="evenodd"
></path>
</svg>
);
}
function InputWrap({
value,
onChange,
readonly,
hasError,
style,
}: {
value: string;
onChange: (v: string) => void;
readonly?: boolean;
hasError?: boolean;
style?: React.CSSProperties;
}) {
if (readonly) {
return <ValueDisplay value={value} hasError={hasError} />;
}
return (
<Input
value={value as string}
onChange={onChange}
validateStatus={hasError ? 'error' : undefined}
style={style}
/>
);
}
export interface FxExpressionProps {
value?: FlowLiteralValueSchema | FlowRefValueSchema;
onChange: (value: FlowLiteralValueSchema | FlowRefValueSchema) => void;
literal?: boolean;
hasError?: boolean;
readonly?: boolean;
icon?: React.ReactNode;
}
export function FxExpression(props: FxExpressionProps) {
const { value, onChange, readonly, literal, icon } = props;
if (literal) return <InputWrap value={value as string} onChange={onChange} readonly={readonly} />;
const isExpression = typeof value === 'object' && value.type === 'expression';
const toggleExpression = () => {
if (isExpression) {
onChange((value as FlowRefValueSchema).content as string);
} else {
onChange({ content: value as string, type: 'expression' });
}
};
return (
<div style={{ display: 'flex', maxWidth: 300 }}>
{isExpression ? (
<VariableSelector
value={value.content}
hasError={props.hasError}
style={{ flexGrow: 1 }}
onChange={(v) => onChange({ type: 'expression', content: v })}
readonly={readonly}
/>
) : (
<InputWrap
value={value as string}
onChange={onChange}
hasError={props.hasError}
readonly={readonly}
style={{ flexGrow: 1, outline: props.hasError ? '1px solid red' : undefined }}
/>
)}
{!readonly &&
(icon || <Button theme="borderless" icon={<FxIcon />} onClick={toggleExpression} />)}
</div>
);
}

View File

@ -1,10 +1,9 @@
import React, { useState, useLayoutEffect } from 'react'; import React, { useState, useLayoutEffect } from 'react';
import { VariableSelector } from '@flowgram.ai/form-materials'; import { VariableSelector, TypeSelector, DynamicValueInput } from '@flowgram.ai/form-materials';
import { Input, Button } from '@douyinfe/semi-ui'; import { Input, Button } from '@douyinfe/semi-ui';
import { IconCrossCircleStroked } from '@douyinfe/semi-icons'; import { IconCrossCircleStroked } from '@douyinfe/semi-icons';
import { TypeSelector } from '../type-selector';
import { JsonSchema } from '../../typings'; import { JsonSchema } from '../../typings';
import { LeftColumn, Row } from './styles'; import { LeftColumn, Row } from './styles';
@ -24,6 +23,11 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
value[key] = val; value[key] = val;
props.onChange(value, props.propertyKey); props.onChange(value, props.propertyKey);
}; };
const partialUpdateProperty = (val?: Partial<JsonSchema>) => {
props.onChange({ ...value, ...val }, props.propertyKey);
};
useLayoutEffect(() => { useLayoutEffect(() => {
updateKey(props.propertyKey); updateKey(props.propertyKey);
}, [props.propertyKey]); }, [props.propertyKey]);
@ -31,14 +35,15 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
<Row> <Row>
<LeftColumn> <LeftColumn>
<TypeSelector <TypeSelector
value={value.type} value={value}
disabled={disabled} disabled={disabled}
style={{ position: 'absolute', top: 6, left: 4, zIndex: 1 }} style={{ position: 'absolute', top: 2, left: 4, zIndex: 1, padding: '0 5px', height: 20 }}
onChange={(val) => updateProperty('type', val)} onChange={(val) => partialUpdateProperty(val)}
/> />
<Input <Input
value={inputKey} value={inputKey}
disabled={disabled} disabled={disabled}
size="small"
onChange={(v) => updateKey(v.trim())} onChange={(v) => updateKey(v.trim())}
onBlur={() => { onBlur={() => {
if (inputKey !== '') { if (inputKey !== '') {
@ -50,22 +55,22 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
style={{ paddingLeft: 26 }} style={{ paddingLeft: 26 }}
/> />
</LeftColumn> </LeftColumn>
{props.useFx ? ( {
<VariableSelector <DynamicValueInput
value={value.default}
readonly={disabled}
onChange={(val) => updateProperty('default', val)}
style={{ flexGrow: 1, height: 32 }}
/>
) : (
<Input
disabled={disabled}
value={value.default} value={value.default}
onChange={(val) => updateProperty('default', val)} onChange={(val) => updateProperty('default', val)}
schema={value}
style={{ flexGrow: 1 }}
/> />
)} }
{props.onDelete && !disabled && ( {props.onDelete && !disabled && (
<Button theme="borderless" icon={<IconCrossCircleStroked />} onClick={props.onDelete} /> <Button
style={{ marginLeft: 5, position: 'relative', top: 2 }}
size="small"
theme="borderless"
icon={<IconCrossCircleStroked />}
onClick={props.onDelete}
/>
)} )}
</Row> </Row>
); );

View File

@ -9,7 +9,7 @@ export const Row = styled.div`
`; `;
export const LeftColumn = styled.div` export const LeftColumn = styled.div`
width: 300px; width: 120px;
margin-right: 10px; margin-right: 5px;
position: relative; position: relative;
`; `;

View File

@ -1,50 +0,0 @@
import React from 'react';
import { VariableTypeIcons } from '@flowgram.ai/form-materials';
import { Tag, Dropdown } from '@douyinfe/semi-ui';
export interface TypeSelectorProps {
value?: string;
disabled?: boolean;
onChange?: (value?: string) => void;
style?: React.CSSProperties;
}
const dropdownMenus = ['object', 'boolean', 'array', 'string', 'integer', 'number'];
export const TypeSelector: React.FC<TypeSelectorProps> = (props) => {
const { value, disabled } = props;
const icon = VariableTypeIcons[value as any];
return (
<Dropdown
trigger="hover"
position="bottomRight"
disabled={disabled}
render={
<Dropdown.Menu>
{dropdownMenus.map((key) => (
<Dropdown.Item
key={key}
onClick={() => {
props.onChange?.(key);
}}
>
{VariableTypeIcons[key]}
<span style={{ paddingLeft: '4px' }}>{key}</span>
</Dropdown.Item>
))}
</Dropdown.Menu>
}
>
<Tag
color="white"
style={props.style}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
{icon}
</Tag>
</Dropdown>
);
};

View File

@ -20,6 +20,24 @@ export const initialData: FlowDocumentJSON = {
type: 'string', type: 'string',
default: 'Hello Flow.', default: 'Hello Flow.',
}, },
enable: {
type: 'boolean',
default: true,
},
array_obj: {
type: 'array',
items: {
type: 'object',
properties: {
int: {
type: 'number',
},
str: {
type: 'string',
},
},
},
},
}, },
}, },
}, },
@ -35,43 +53,22 @@ export const initialData: FlowDocumentJSON = {
}, },
data: { data: {
title: 'Condition', title: 'Condition',
inputsValues: { conditions: [
conditions: [ {
{ key: 'if_0',
key: 'if_0', value: {
value: { type: 'expression',
type: 'expression', content: '',
content: '',
},
},
{
key: 'if_f0rOAt',
value: {
type: 'expression',
content: '',
},
},
],
},
inputs: {
type: 'object',
properties: {
conditions: {
type: 'array',
items: {
type: 'object',
properties: {
key: {
type: 'string',
},
value: {
type: 'string',
},
},
},
}, },
}, },
}, {
key: 'if_f0rOAt',
value: {
type: 'expression',
content: '',
},
},
],
}, },
}, },
{ {
@ -106,17 +103,9 @@ export const initialData: FlowDocumentJSON = {
}, },
data: { data: {
title: 'Loop_2', title: 'Loop_2',
inputsValues: { batchFor: {
loopTimes: 2, type: 'ref',
}, content: ['start_0', 'array_obj'],
inputs: {
type: 'object',
required: ['loopTimes'],
properties: {
loopTimes: {
type: 'number',
},
},
}, },
outputs: { outputs: {
type: 'object', type: 'object',

View File

@ -1,43 +1,50 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { Field, FieldArray } from '@flowgram.ai/free-layout-editor'; import { Field, FieldArray } from '@flowgram.ai/free-layout-editor';
import { IFlowValue, VariableSelector } from '@flowgram.ai/form-materials';
import { Button } from '@douyinfe/semi-ui'; import { Button } from '@douyinfe/semi-ui';
import { IconPlus, IconCrossCircleStroked } from '@douyinfe/semi-icons'; import { IconPlus, IconCrossCircleStroked } from '@douyinfe/semi-icons';
import { FlowLiteralValueSchema, FlowRefValueSchema } from '../../../typings';
import { useIsSidebar } from '../../../hooks'; import { useIsSidebar } from '../../../hooks';
import { FxExpression } from '../../../form-components/fx-expression';
import { FormItem } from '../../../form-components'; import { FormItem } from '../../../form-components';
import { Feedback } from '../../../form-components'; import { Feedback } from '../../../form-components';
import { ConditionPort } from './styles'; import { ConditionPort } from './styles';
interface ConditionValue { interface ConditionValue {
key: string; key: string;
value: FlowLiteralValueSchema | FlowRefValueSchema; value: IFlowValue;
} }
export function ConditionInputs() { export function ConditionInputs() {
const readonly = !useIsSidebar(); const readonly = !useIsSidebar();
return ( return (
<FieldArray name="inputsValues.conditions"> <FieldArray name="conditions">
{({ field }) => ( {({ field }) => (
<> <>
{field.map((child, index) => ( {field.map((child, index) => (
<Field<ConditionValue> key={child.name} name={child.name}> <Field<ConditionValue> key={child.name} name={child.name}>
{({ field: childField, fieldState: childState }) => ( {({ field: childField, fieldState: childState }) => (
<FormItem name="if" type="boolean" required={true} labelWidth={40}> <FormItem name="if" type="boolean" required={true} labelWidth={40}>
<FxExpression <div style={{ display: 'flex', alignItems: 'center' }}>
value={childField.value.value} <VariableSelector
onChange={(v) => childField.onChange({ key: childField.value.key, value: v })} style={{ width: '100%' }}
icon={ value={childField.value?.value?.content as string[]}
<Button onChange={(v) =>
theme="borderless" childField.onChange({
icon={<IconCrossCircleStroked />} key: childField.value.key,
onClick={() => field.delete(index)} value: { type: 'ref', content: v },
/> })
} }
hasError={Object.keys(childState?.errors || {}).length > 0} hasError={Object.keys(childState?.errors || {}).length > 0}
readonly={readonly} readonly={readonly}
/> />
<Button
theme="borderless"
icon={<IconCrossCircleStroked />}
onClick={() => field.delete(index)}
/>
</div>
<Feedback errors={childState?.errors} invalid={childState?.invalid} /> <Feedback errors={childState?.errors} invalid={childState?.invalid} />
<ConditionPort data-port-id={childField.value.key} data-port-type="output" /> <ConditionPort data-port-id={childField.value.key} data-port-type="output" />
</FormItem> </FormItem>

View File

@ -4,6 +4,7 @@ import {
PositionSchema, PositionSchema,
FlowNodeTransformData, FlowNodeTransformData,
} from '@flowgram.ai/free-layout-editor'; } from '@flowgram.ai/free-layout-editor';
import { provideBatchInputEffect } from '@flowgram.ai/form-materials';
import { defaultFormMeta } from '../default-form-meta'; import { defaultFormMeta } from '../default-form-meta';
import { FlowNodeRegistry } from '../../typings'; import { FlowNodeRegistry } from '../../typings';
@ -63,30 +64,15 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
type: 'loop', type: 'loop',
data: { data: {
title: `Loop_${++index}`, title: `Loop_${++index}`,
inputsValues: {
loopTimes: 2,
},
inputs: {
type: 'object',
required: ['loopTimes'],
properties: {
loopTimes: {
type: 'number',
},
},
},
outputs: {
type: 'object',
properties: {
result: { type: 'string' },
},
},
}, },
}; };
}, },
formMeta: { formMeta: {
...defaultFormMeta, ...defaultFormMeta,
render: LoopFormRender, render: LoopFormRender,
effect: {
batchFor: provideBatchInputEffect,
},
}, },
onCreate() { onCreate() {
// NOTICE: 这个函数是为了避免触发固定布局 flowDocument.addBlocksAsChildren // NOTICE: 这个函数是为了避免触发固定布局 flowDocument.addBlocksAsChildren

View File

@ -1,17 +1,43 @@
import { FormRenderProps, FlowNodeJSON } from '@flowgram.ai/free-layout-editor'; import { FormRenderProps, FlowNodeJSON, Field } from '@flowgram.ai/free-layout-editor';
import { SubCanvasRender } from '@flowgram.ai/free-container-plugin'; import { SubCanvasRender } from '@flowgram.ai/free-container-plugin';
import { BatchVariableSelector, IFlowRefValue } from '@flowgram.ai/form-materials';
import { useIsSidebar } from '../../hooks'; import { useIsSidebar } from '../../hooks';
import { FormHeader, FormContent, FormInputs, FormOutputs } from '../../form-components'; import { FormHeader, FormContent, FormOutputs, FormItem, Feedback } from '../../form-components';
export const LoopFormRender = ({ form }: FormRenderProps<FlowNodeJSON>) => { interface LoopNodeJSON extends FlowNodeJSON {
data: {
batchFor: IFlowRefValue;
};
}
export const LoopFormRender = ({ form }: FormRenderProps<LoopNodeJSON>) => {
const isSidebar = useIsSidebar(); const isSidebar = useIsSidebar();
const readonly = !isSidebar;
const batchFor = (
<Field<IFlowRefValue> name={`batchFor`}>
{({ field, fieldState }) => (
<FormItem name={'batchFor'} type={'array'} required>
<BatchVariableSelector
style={{ width: '100%' }}
value={field.value?.content}
onChange={(val) => field.onChange({ type: 'ref', content: val })}
readonly={readonly}
hasError={Object.keys(fieldState?.errors || {}).length > 0}
/>
<Feedback errors={fieldState?.errors} />
</FormItem>
)}
</Field>
);
if (isSidebar) { if (isSidebar) {
return ( return (
<> <>
<FormHeader /> <FormHeader />
<FormContent> <FormContent>
<FormInputs /> {batchFor}
<FormOutputs /> <FormOutputs />
</FormContent> </FormContent>
</> </>
@ -21,7 +47,7 @@ export const LoopFormRender = ({ form }: FormRenderProps<FlowNodeJSON>) => {
<> <>
<FormHeader /> <FormHeader />
<FormContent> <FormContent>
<FormInputs /> {batchFor}
<SubCanvasRender /> <SubCanvasRender />
<FormOutputs /> <FormOutputs />
</FormContent> </FormContent>

View File

@ -7,8 +7,6 @@ import {
ASTFactory, ASTFactory,
} from '@flowgram.ai/free-layout-editor'; } from '@flowgram.ai/free-layout-editor';
import { createASTFromJSONSchema } from './utils';
export interface SyncVariablePluginOptions {} export interface SyncVariablePluginOptions {}
/** /**
@ -39,7 +37,7 @@ export const createSyncVariablePlugin: PluginCreator<SyncVariablePluginOptions>
// Create an Type AST from the output data's JSON schema // Create an Type AST from the output data's JSON schema
// NOTICE: You can create a new function to generate an AST based on YOUR CUSTOM DSL // NOTICE: You can create a new function to generate an AST based on YOUR CUSTOM DSL
const typeAST = createASTFromJSONSchema(value); const typeAST = ASTFactory.createTypeASTFromSchema(value);
if (typeAST) { if (typeAST) {
// Use the node's title or its ID as the title for the variable // Use the node's title or its ID as the title for the variable
@ -53,7 +51,7 @@ export const createSyncVariablePlugin: PluginCreator<SyncVariablePluginOptions>
icon: node.getNodeRegistry()?.info?.icon, icon: node.getNodeRegistry()?.info?.icon,
// NOTICE: You can add more metadata here as needed // NOTICE: You can add more metadata here as needed
}, },
key: `${node.id}.outputs`, key: `${node.id}`,
type: typeAST, type: typeAST,
}) })
); );

View File

@ -1,65 +0,0 @@
import { get } from 'lodash-es';
import { ASTFactory, type ASTNodeJSON } from '@flowgram.ai/free-layout-editor';
import { type JsonSchema } from '../../typings';
/**
* Sorts the properties of a JSON schema based on the 'extra.index' field.
* If the 'extra.index' field is not present, the property will be treated as having an index of 0.
*
* @param properties - The properties of the JSON schema to sort.
* @returns A sorted array of property entries.
*/
function sortProperties(properties: Record<string, JsonSchema>) {
return Object.entries(properties).sort(
(a, b) => (get(a?.[1], 'extra.index') || 0) - (get(b?.[1], 'extra.index') || 0)
);
}
/**
* Converts a JSON schema to an Abstract Syntax Tree (AST) representation.
* This function recursively processes the JSON schema and creates corresponding AST nodes.
*
* For more information on JSON Schema, refer to the official documentation:
* https://json-schema.org/
*
* Note: Depending on your business needs, you can use your own Domain-Specific Language (DSL)
* Create a new function to convert your custom DSL to AST directly.
*
* @param jsonSchema - The JSON schema to convert.
* @returns An AST node representing the JSON schema, or undefined if the schema type is not recognized.
*/
export function createASTFromJSONSchema(jsonSchema: JsonSchema): ASTNodeJSON | undefined {
const { type } = jsonSchema || {};
if (!type) {
return undefined;
}
switch (type) {
case 'object':
return ASTFactory.createObject({
properties: sortProperties(jsonSchema.properties || {}).map(([key, _property]) => ({
key,
type: createASTFromJSONSchema(_property),
meta: { description: _property.description },
})),
});
case 'array':
return ASTFactory.createArray({
items: createASTFromJSONSchema(jsonSchema.items!),
});
case 'string':
return ASTFactory.createString();
case 'number':
return ASTFactory.createNumber();
case 'boolean':
return ASTFactory.createBoolean();
case 'integer':
return ASTFactory.createInteger();
default:
// If the type is not recognized, return CustomType
return ASTFactory.createCustomType({ typeName: type });
}
}

View File

@ -1,18 +1,4 @@
export type BasicType = 'boolean' | 'string' | 'integer' | 'number' | 'object' | 'array'; import type { VarJSONSchema } from '@flowgram.ai/free-layout-editor';
export interface JsonSchema<T extends BasicType = BasicType> { export type BasicType = VarJSONSchema.BasicType;
type?: T; export type JsonSchema = VarJSONSchema.ISchema;
default?: any;
title?: string;
description?: string;
enum?: (string | number)[];
properties?: Record<string, JsonSchema>;
additionalProperties?: JsonSchema;
items?: JsonSchema;
required?: string[];
$ref?: string;
extra?: {
literal?: boolean; // is literal type
formComponent?: string; // Set the render component
};
}

View File

@ -6,17 +6,10 @@ import {
type WorkflowEdgeJSON, type WorkflowEdgeJSON,
WorkflowNodeMeta, WorkflowNodeMeta,
} from '@flowgram.ai/free-layout-editor'; } from '@flowgram.ai/free-layout-editor';
import { IFlowValue } from '@flowgram.ai/form-materials';
import { type JsonSchema } from './json-schema'; import { type JsonSchema } from './json-schema';
export type FlowLiteralValueSchema = string | number | boolean;
export type FlowObjectValueSchema = Record<string, FlowLiteralValueSchema | FlowRefValueSchema>;
export type FlowArrayValueSchema = FlowObjectValueSchema[];
export type FlowRefValueSchema =
| { type: 'ref'; content?: string }
| { type: 'expression'; content?: string }
| { type: 'template'; content?: string };
export type FlowValueSchema = FlowLiteralValueSchema | FlowRefValueSchema | FlowArrayValueSchema;
/** /**
* You can customize the data of the node, and here you can use JsonSchema to define the input and output of the node * You can customize the data of the node, and here you can use JsonSchema to define the input and output of the node
* data , JsonSchema / * data , JsonSchema /
@ -30,7 +23,7 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
/** /**
* Inputs data values * Inputs data values
*/ */
inputsValues?: Record<string, FlowValueSchema>; inputsValues?: Record<string, IFlowValue>;
/** /**
* Define the inputs data of the node by JsonSchema * Define the inputs data of the node by JsonSchema
*/ */

View File

@ -6,13 +6,11 @@
Official form materials can be used directly via package reference: Official form materials can be used directly via package reference:
```tsx
import { PackageManagerTabs } from '@theme'; import { PackageManagerTabs } from '@theme';
<PackageManagerTabs command={{ <PackageManagerTabs command={{
npm: "npm install @flowgram.ai/form-materials" npm: "npm install @flowgram.ai/form-materials"
}} /> }} />
```
```tsx ```tsx
import { JsonSchemaEditor } from '@flowgram.ai/form-materials' import { JsonSchemaEditor } from '@flowgram.ai/form-materials'
@ -103,3 +101,42 @@ Adding source code via CLI:
```bash ```bash
npx @flowgram.ai/materials components/json-schema-editor npx @flowgram.ai/materials components/json-schema-editor
``` ```
### [DynamicValueInput](https://github.com/bytedance/flowgram.ai/tree/main/packages/materials/form-materials/src/components/dynamic-value-input/index.tsx)
DynamicValueInput is used for configuring values (constant values + variable values).
<img loading="lazy" src="/materials/dynamic-value-input.png" style={{width:500}}/>
Usage via package reference:
```tsx
import { DynamicValueInput } from '@flowgram.ai/materials'
```
Adding source code via CLI:
```bash
npx @flowgram.ai/materials components/dynamic-value-input
```
## Currently Supported Effect Materials
### [provideBatchInputEffect](https://github.com/bytedance/flowgram.ai/tree/main/packages/materials/form-materials/src/effects/provide-batch-input/index.ts)
provideBatchInputEffect is used for configuring batch input derivation in loops. It automatically derives two variables based on the input:
- item: Derived from the input variable array type, representing each item in the loop
- index: Numeric type, representing the iteration count
<img loading="lazy" src="/materials/provide-batch-input.png" style={{width:500}}/>
Usage via package reference:
```tsx
import { provideBatchInputEffect } from '@flowgram.ai/materials'
```
Adding source code via CLI:
```bash
npx @flowgram.ai/materials effects/provide-batch-input
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

View File

@ -107,3 +107,44 @@ import { JsonSchemaEditor } from '@flowgram.ai/materials'
```bash ```bash
npx @flowgram.ai/materials components/json-schema-editor npx @flowgram.ai/materials components/json-schema-editor
``` ```
### [DynamicValueInput](https://github.com/bytedance/flowgram.ai/tree/main/packages/materials/form-materials/src/components/dynamic-value-input/index.tsx)
DynamicValueInput 用于值(常量值 + 变量值)的配置
<img loading="lazy" src="/materials/dynamic-value-input.png" style={{width:500}}/>
通过包引用使用:
```tsx
import { DynamicValueInput } from '@flowgram.ai/materials'
```
通过 CLI 复制源代码使用:
```bash
npx @flowgram.ai/materials components/dynamic-value-input
```
## 当前支持的 Effect 物料
### [provideBatchInputEffect](https://github.com/bytedance/flowgram.ai/tree/main/packages/materials/form-materials/src/effects/provide-batch-input/index.ts)
provideBatchInputEffect 用于配置循环批处理输入推导,会根据输入自动推导出两个变量:
- item根据输入变量数组类型自动推导代表循环的每一项
- index数字类型代表循环第几次
<img loading="lazy" src="/materials/provide-batch-input.png" style={{width:500}}/>
通过包引用使用:
```tsx
import { provideBatchInputEffect } from '@flowgram.ai/materials'
```
通过 CLI 复制源代码使用:
```bash
npx @flowgram.ai/materials effects/provide-batch-input
```

View File

@ -1,7 +1,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
const _types = ['components']; const _types = ['components', 'effects', 'utils', 'typings'];
export function listAllMaterials() { export function listAllMaterials() {
const _materials = []; const _materials = [];
@ -32,7 +32,9 @@ export function listAllMaterials() {
export function bfsMaterials(material, _materials = []) { export function bfsMaterials(material, _materials = []) {
function findConfigByName(name) { function findConfigByName(name) {
return _materials.find((_config) => _config.name === name); return _materials.find(
(_config) => _config.name === name || `${_config.type}/${_config.name}` === name
);
} }
const queue = [material]; const queue = [material];
@ -67,11 +69,25 @@ export function bfsMaterials(material, _materials = []) {
export const copyMaterial = (material, projectInfo) => { export const copyMaterial = (material, projectInfo) => {
const sourceDir = material.path; const sourceDir = material.path;
const targetDir = path.join( const materialRoot = path.join(
projectInfo.projectPath, projectInfo.projectPath,
'src',
'form-materials', 'form-materials',
`${material.type}`, `${material.type}`
material.name
); );
const targetDir = path.join(materialRoot, material.name);
fs.cpSync(sourceDir, targetDir, { recursive: true }); fs.cpSync(sourceDir, targetDir, { recursive: true });
let materialRootIndexTs = '';
if (fs.existsSync(path.join(materialRoot, 'index.ts'))) {
materialRootIndexTs = fs.readFileSync(path.join(materialRoot, 'index.ts'), 'utf8');
}
if (!materialRootIndexTs.includes(material.name)) {
fs.writeFileSync(
path.join(materialRoot, 'index.ts'),
`${materialRootIndexTs}${materialRootIndexTs.endsWith('\n') ? '' : '\n'}export * from './${
material.name
}';\n`
);
}
}; };

View File

@ -0,0 +1,5 @@
{
"name": "batch-variable-selector",
"depMaterials": ["variable-selector"],
"depPackages": []
}

View File

@ -0,0 +1,18 @@
import React from 'react';
import { PrivateScopeProvider, VarJSONSchema } from '@flowgram.ai/editor';
import { VariableSelector, VariableSelectorProps } from '../variable-selector';
const batchVariableSchema: VarJSONSchema.ISchema = {
type: 'array',
extra: { weak: true },
};
export function BatchVariableSelector(props: VariableSelectorProps) {
return (
<PrivateScopeProvider>
<VariableSelector {...props} includeSchema={batchVariableSchema} />
</PrivateScopeProvider>
);
}

View File

@ -0,0 +1,6 @@
{
"name": "constant-input",
"depMaterials": [],
"depPackages": ["@douyinfe/semi-ui"]
}

View File

@ -0,0 +1,81 @@
/* eslint-disable react/prop-types */
import React, { useMemo } from 'react';
import { Input, InputNumber, Select } from '@douyinfe/semi-ui';
import { PropsType, Strategy } from './types';
const defaultStrategies: Strategy[] = [
{
hit: (schema) => schema?.type === 'string',
Renderer: (props) => (
<Input placeholder="Please Input String" size="small" disabled={props.readonly} {...props} />
),
},
{
hit: (schema) => schema?.type === 'number',
Renderer: (props) => (
<InputNumber
placeholder="Please Input Number"
size="small"
disabled={props.readonly}
hideButtons
{...props}
/>
),
},
{
hit: (schema) => schema?.type === 'integer',
Renderer: (props) => (
<InputNumber
placeholder="Please Input Integer"
size="small"
disabled={props.readonly}
hideButtons
precision={0}
{...props}
/>
),
},
{
hit: (schema) => schema?.type === 'boolean',
Renderer: (props) => {
const { value, onChange, ...rest } = props;
return (
<Select
placeholder="Please Select Boolean"
size="small"
disabled={props.readonly}
optionList={[
{ label: 'True', value: 1 },
{ label: 'False', value: 0 },
]}
value={value ? 1 : 0}
onChange={(value) => onChange?.(!!value)}
{...rest}
/>
);
},
},
];
export function ConstantInput(props: PropsType) {
const { value, onChange, schema, strategies: extraStrategies, readonly, ...rest } = props;
const strategies = useMemo(
() => [...defaultStrategies, ...(extraStrategies || [])],
[extraStrategies]
);
const Renderer = useMemo(() => {
const strategy = strategies.find((_strategy) => _strategy.hit(schema));
return strategy?.Renderer;
}, [strategies, schema]);
if (!Renderer) {
return <Input size="small" disabled placeholder="Unsupported type" />;
}
return <Renderer value={value} onChange={onChange} readonly={readonly} {...rest} />;
}

View File

@ -0,0 +1,18 @@
import { VarJSONSchema } from '@flowgram.ai/editor';
export interface Strategy<Value = any> {
hit: (schema: VarJSONSchema.ISchema) => boolean;
Renderer: React.FC<RendererProps<Value>>;
}
export interface RendererProps<Value = any> {
value?: Value;
onChange?: (value: Value) => void;
readonly?: boolean;
}
export interface PropsType extends RendererProps {
schema: VarJSONSchema.ISchema;
strategies?: Strategy[];
[key: string]: any;
}

View File

@ -0,0 +1,5 @@
{
"name": "dynamic-value-input",
"depMaterials": ["flow-value", "constant-input", "variable-selector"],
"depPackages": ["@douyinfe/semi-ui", "@douyinfe/semi-icons", "styled-components"]
}

View File

@ -0,0 +1,77 @@
import React from 'react';
import { VarJSONSchema } from '@flowgram.ai/editor';
import { IconButton } from '@douyinfe/semi-ui';
import { IconSetting } from '@douyinfe/semi-icons';
import { Strategy } from '../constant-input/types';
import { ConstantInput } from '../constant-input';
import { IFlowConstantRefValue } from '../../typings/flow-value';
import { UIContainer, UIMain, UITrigger } from './styles';
import { VariableSelector } from '../variable-selector';
interface PropsType {
value?: IFlowConstantRefValue;
onChange: (value?: IFlowConstantRefValue) => void;
readonly?: boolean;
hasError?: boolean;
style?: React.CSSProperties;
schema?: VarJSONSchema.ISchema;
constantProps?: {
strategies?: Strategy[];
[key: string]: any;
};
}
export function DynamicValueInput({
value,
onChange,
readonly,
style,
schema,
constantProps,
}: PropsType) {
const renderMain = () => {
if (value?.type === 'ref') {
// Display Variable Or Delete
return (
<VariableSelector
value={value?.content}
onChange={(_v) => onChange(_v ? { type: 'ref', content: _v } : undefined)}
includeSchema={schema}
readonly={readonly}
/>
);
}
return (
<ConstantInput
value={value?.content}
onChange={(_v) => onChange({ type: 'constant', content: _v })}
schema={schema || { type: 'string' }}
readonly={readonly}
{...constantProps}
/>
);
};
const renderTrigger = () => (
<VariableSelector
style={{ width: '100%' }}
value={value?.type === 'ref' ? value?.content : undefined}
onChange={(_v) => onChange({ type: 'ref', content: _v })}
includeSchema={schema}
readonly={readonly}
triggerRender={() => (
<IconButton disabled={readonly} size="small" icon={<IconSetting size="small" />} />
)}
/>
);
return (
<UIContainer style={style}>
<UIMain>{renderMain()}</UIMain>
<UITrigger>{renderTrigger()}</UITrigger>
</UIContainer>
);
}

View File

@ -0,0 +1,19 @@
import styled from 'styled-components';
export const UIContainer = styled.div`
display: flex;
align-items: center;
gap: 5px;
`;
export const UIMain = styled.div`
flex-grow: 1;
& .semi-tree-select,
& .semi-input-number,
& .semi-select {
width: 100%;
}
`;
export const UITrigger = styled.div``;

View File

@ -1,3 +1,6 @@
export { VariableSelector } from './variable-selector'; export * from './variable-selector';
export { TypeSelector, JsonSchema, VariableTypeIcons, ArrayIcons } from './type-selector'; export * from './type-selector';
export { JsonSchemaEditor } from './json-schema-editor'; export * from './json-schema-editor';
export * from './batch-variable-selector';
export * from './constant-input';
export * from './dynamic-value-input';

View File

@ -36,15 +36,21 @@ export function usePropertiesEdit(
const initPropertyList = useMemo( const initPropertyList = useMemo(
() => () =>
isDrilldownObject isDrilldownObject
? Object.entries(drilldown.schema?.properties || {}).map( ? Object.entries(drilldown.schema?.properties || {})
([name, _value]) => .sort(([, a], [, b]) => (a.extra?.index ?? 0) - (b.extra?.index ?? 0))
({ .map(
key: genId(), ([name, _value], index) =>
name, ({
isPropertyRequired: drilldown.schema?.required?.includes(name) || false, key: genId(),
..._value, name,
} as PropertyValueType) isPropertyRequired: drilldown.schema?.required?.includes(name) || false,
) ..._value,
extra: {
...(_value.extra || {}),
index,
},
} as PropertyValueType)
)
: [], : [],
[isDrilldownObject] [isDrilldownObject]
); );
@ -65,23 +71,25 @@ export function usePropertiesEdit(
nameMap.set(_property.name, _property); nameMap.set(_property.name, _property);
} }
} }
return Object.entries(drilldown.schema?.properties || {}).map(([name, _value]) => { return Object.entries(drilldown.schema?.properties || {})
const _property = nameMap.get(name); .sort(([, a], [, b]) => (a.extra?.index ?? 0) - (b.extra?.index ?? 0))
if (_property) { .map(([name, _value]) => {
const _property = nameMap.get(name);
if (_property) {
return {
key: _property.key,
name,
isPropertyRequired: drilldown.schema?.required?.includes(name) || false,
..._value,
};
}
return { return {
key: _property.key, key: genId(),
name, name,
isPropertyRequired: drilldown.schema?.required?.includes(name) || false, isPropertyRequired: drilldown.schema?.required?.includes(name) || false,
..._value, ..._value,
}; };
} });
return {
key: genId(),
name,
isPropertyRequired: drilldown.schema?.required?.includes(name) || false,
..._value,
};
});
}); });
} }
mountRef.current = true; mountRef.current = true;
@ -121,7 +129,10 @@ export function usePropertiesEdit(
}; };
const onAddProperty = () => { const onAddProperty = () => {
updatePropertyList((_list) => [..._list, { key: genId(), name: '', type: 'string' }]); updatePropertyList((_list) => [
..._list,
{ key: genId(), name: '', type: 'string', extra: { index: _list.length + 1 } },
]);
}; };
const onRemoveProperty = (key: number) => { const onRemoveProperty = (key: number) => {

View File

@ -8,6 +8,8 @@ import { ArrayIcons, VariableTypeIcons, getSchemaIcon, options } from './constan
interface PropTypes { interface PropTypes {
value?: Partial<JsonSchema>; value?: Partial<JsonSchema>;
onChange: (value?: Partial<JsonSchema>) => void; onChange: (value?: Partial<JsonSchema>) => void;
disabled?: boolean;
style?: React.CSSProperties;
} }
export const getTypeSelectValue = (value?: Partial<JsonSchema>): string[] | undefined => { export const getTypeSelectValue = (value?: Partial<JsonSchema>): string[] | undefined => {
@ -29,15 +31,16 @@ export const parseTypeSelectValue = (value?: string[]): Partial<JsonSchema> | un
}; };
export function TypeSelector(props: PropTypes) { export function TypeSelector(props: PropTypes) {
const { value, onChange } = props; const { value, onChange, disabled, style } = props;
const selectValue = useMemo(() => getTypeSelectValue(value), [value]); const selectValue = useMemo(() => getTypeSelectValue(value), [value]);
return ( return (
<Cascader <Cascader
disabled={disabled}
size="small" size="small"
triggerRender={() => ( triggerRender={() => (
<Button size="small" style={{ width: 50 }}> <Button size="small" style={style}>
{getSchemaIcon(value)} {getSchemaIcon(value)}
</Button> </Button>
)} )}

View File

@ -1,19 +1,5 @@
export type BasicType = 'boolean' | 'string' | 'integer' | 'number' | 'object' | 'array'; import { VarJSONSchema } from '@flowgram.ai/editor';
export interface JsonSchema<T = string> { export type BasicType = VarJSONSchema.BasicType;
type?: T;
default?: any; export type JsonSchema = VarJSONSchema.ISchema;
title?: string;
description?: string;
enum?: (string | number)[];
properties?: Record<string, JsonSchema>;
additionalProperties?: JsonSchema;
items?: JsonSchema;
required?: string[];
$ref?: string;
extra?: {
order?: number;
literal?: boolean; // is literal type
formComponent?: string; // Set the render component
};
}

View File

@ -1,5 +1,5 @@
{ {
"name": "variable-selector", "name": "variable-selector",
"depMaterials": ["type-selector"], "depMaterials": ["type-selector"],
"depPackages": ["@douyinfe/semi-ui"] "depPackages": ["@douyinfe/semi-ui", "styled-components"]
} }

View File

@ -1,47 +1,107 @@
import React from 'react'; import React, { useMemo } from 'react';
import { TreeSelect } from '@douyinfe/semi-ui'; import { VarJSONSchema } from '@flowgram.ai/editor';
import { TriggerRenderProps } from '@douyinfe/semi-ui/lib/es/treeSelect';
import { TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree';
import { IconChevronDownStroked, IconIssueStroked } from '@douyinfe/semi-icons';
import { useVariableTree } from './use-variable-tree'; import { useVariableTree } from './use-variable-tree';
import { UIRootTitle, UITag, UITreeSelect } from './styles';
export interface PropTypes { interface PropTypes {
value?: string; value?: string[];
config: { config?: {
placeholder?: string; placeholder?: string;
notFoundContent?: string;
}; };
onChange: (value?: string) => void; onChange: (value?: string[]) => void;
includeSchema?: VarJSONSchema.ISchema | VarJSONSchema.ISchema[];
excludeSchema?: VarJSONSchema.ISchema | VarJSONSchema.ISchema[];
readonly?: boolean; readonly?: boolean;
hasError?: boolean; hasError?: boolean;
style?: React.CSSProperties; style?: React.CSSProperties;
triggerRender?: (props: TriggerRenderProps) => React.ReactNode;
} }
export type VariableSelectorProps = PropTypes;
export const VariableSelector = ({ export const VariableSelector = ({
value, value,
config, config,
onChange, onChange,
style, style,
readonly = false, readonly = false,
includeSchema,
excludeSchema,
hasError, hasError,
triggerRender,
}: PropTypes) => { }: PropTypes) => {
const treeData = useVariableTree(); const treeData = useVariableTree({ includeSchema, excludeSchema });
const treeValue = useMemo(() => {
if (typeof value === 'string') {
console.warn(
'The Value of VariableSelector is a string, it should be an ARRAY. \n',
'Please check the value of VariableSelector \n'
);
return value;
}
return value?.join('.');
}, [value]);
const renderIcon = (icon: string | JSX.Element) => {
if (typeof icon === 'string') {
return <img style={{ marginRight: 8 }} width={12} height={12} src={icon} />;
}
return icon;
};
return ( return (
<> <>
<TreeSelect <UITreeSelect
dropdownMatchSelectWidth={false} dropdownMatchSelectWidth={false}
disabled={readonly} disabled={readonly}
treeData={treeData} treeData={treeData}
size="small" size="small"
value={value} value={treeValue}
style={{ clearIcon={null}
...style, $error={hasError}
outline: hasError ? '1px solid red' : undefined, style={style}
}}
validateStatus={hasError ? 'error' : undefined} validateStatus={hasError ? 'error' : undefined}
onChange={(option) => { onChange={(_, _config) => {
onChange(option as string); onChange((_config as TreeNodeData).keyPath as string[]);
}} }}
showClear renderSelectedItem={(_option: TreeNodeData) => {
if (!_option?.keyPath) {
return (
<UITag
prefixIcon={<IconIssueStroked />}
color="amber"
closable={!readonly}
onClose={() => onChange(undefined)}
>
{config?.notFoundContent ?? 'Undefined'}
</UITag>
);
}
return (
<UITag
prefixIcon={renderIcon(_option.rootMeta?.icon || _option?.icon)}
closable={!readonly}
onClose={() => onChange(undefined)}
>
<UIRootTitle>
{_option.rootMeta?.title ? `${_option.rootMeta?.title} -` : null}
</UIRootTitle>
{_option.label}
</UITag>
);
}}
showClear={false}
arrowIcon={value ? null : <IconChevronDownStroked size="small" />}
triggerRender={triggerRender}
placeholder={config?.placeholder ?? 'Select Variable...'} placeholder={config?.placeholder ?? 'Select Variable...'}
/> />
</> </>

View File

@ -0,0 +1,43 @@
import styled from 'styled-components';
import { Tag, TreeSelect } from '@douyinfe/semi-ui';
export const UIRootTitle = styled.span`
margin-right: 4px;
color: var(--semi-color-text-2);
`;
export const UITag = styled(Tag)`
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
& .semi-tag-content-center {
justify-content: flex-start;
}
&.semi-tag {
margin: 0;
}
`;
export const UITreeSelect = styled(TreeSelect)<{ $error?: boolean }>`
outline: ${({ $error }) => ($error ? '1px solid red' : 'none')};
height: 22px;
min-height: 22px;
line-height: 22px;
& .semi-tree-select-selection {
padding: 0 2px;
height: 22px;
}
& .semi-tree-select-selection-content {
width: 100%;
}
& .semi-tree-select-selection-placeholder {
padding-left: 10px;
}
`;

View File

@ -1,6 +1,6 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { useScopeAvailable, ASTMatch, BaseVariableField } from '@flowgram.ai/editor'; import { useScopeAvailable, ASTMatch, BaseVariableField, VarJSONSchema } from '@flowgram.ai/editor';
import { TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree'; import { TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree';
import { Icon } from '@douyinfe/semi-ui'; import { Icon } from '@douyinfe/semi-ui';
@ -8,11 +8,16 @@ import { ArrayIcons, VariableTypeIcons } from '../type-selector/constants';
type VariableField = BaseVariableField<{ icon?: string | JSX.Element; title?: string }>; type VariableField = BaseVariableField<{ icon?: string | JSX.Element; title?: string }>;
export function useVariableTree(): TreeNodeData[] { export function useVariableTree(params: {
includeSchema?: VarJSONSchema.ISchema | VarJSONSchema.ISchema[];
excludeSchema?: VarJSONSchema.ISchema | VarJSONSchema.ISchema[];
}): TreeNodeData[] {
const { includeSchema, excludeSchema } = params;
const available = useScopeAvailable(); const available = useScopeAvailable();
const getVariableTypeIcon = useCallback((variable: VariableField) => { const getVariableTypeIcon = useCallback((variable: VariableField) => {
if (variable.meta.icon) { if (variable.meta?.icon) {
if (typeof variable.meta.icon === 'string') { if (typeof variable.meta.icon === 'string') {
return <img style={{ marginRight: 8 }} width={12} height={12} src={variable.meta.icon} />; return <img style={{ marginRight: 8 }} width={12} height={12} src={variable.meta.icon} />;
} }
@ -44,6 +49,10 @@ export function useVariableTree(): TreeNodeData[] {
): TreeNodeData | null => { ): TreeNodeData | null => {
let type = variable?.type; let type = variable?.type;
if (!type) {
return null;
}
let children: TreeNodeData[] | undefined; let children: TreeNodeData[] | undefined;
if (ASTMatch.isObject(type)) { if (ASTMatch.isObject(type)) {
@ -56,14 +65,27 @@ export function useVariableTree(): TreeNodeData[] {
} }
} }
const currPath = [...parentFields.map((_field) => _field.key), variable.key].join('.'); const keyPath = [...parentFields.map((_field) => _field.key), variable.key];
const key = keyPath.join('.');
const isSchemaInclude = includeSchema ? type.isEqualWithJSONSchema(includeSchema) : true;
const isSchemaExclude = excludeSchema ? type.isEqualWithJSONSchema(excludeSchema) : false;
const isSchemaMatch = isSchemaInclude && !isSchemaExclude;
// If not match, and no children, return null
if (!isSchemaMatch && !children?.length) {
return null;
}
return { return {
key: currPath, key: key,
label: variable.meta.title || variable.key, label: variable.meta?.title || variable.key,
value: currPath, value: key,
keyPath,
icon: getVariableTypeIcon(variable), icon: getVariableTypeIcon(variable),
children, children,
disabled: !isSchemaMatch,
rootMeta: parentFields[0]?.meta,
}; };
}; };

View File

@ -0,0 +1,2 @@
export * from './provide-batch-input';
export * from './provide-batch-outputs';

View File

@ -0,0 +1,5 @@
{
"name": "provide-batch-input",
"depMaterials": ["flow-value"],
"depPackages": []
}

View File

@ -0,0 +1,38 @@
import {
ASTFactory,
EffectOptions,
FlowNodeRegistry,
createEffectFromVariableProvider,
getNodeForm,
} from '@flowgram.ai/editor';
import { IFlowRefValue } from '../../typings';
export const provideBatchInputEffect: EffectOptions[] = createEffectFromVariableProvider({
private: true,
parse: (value: IFlowRefValue, ctx) => [
ASTFactory.createVariableDeclaration({
key: `${ctx.node.id}_locals`,
meta: {
title: getNodeForm(ctx.node)?.getValueIn('title'),
icon: ctx.node.getNodeRegistry<FlowNodeRegistry>().info?.icon,
},
type: ASTFactory.createObject({
properties: [
ASTFactory.createProperty({
key: 'item',
initializer: ASTFactory.createEnumerateExpression({
enumerateFor: ASTFactory.createKeyPathExpression({
keyPath: value.content || [],
}),
}),
}),
ASTFactory.createProperty({
key: 'index',
type: ASTFactory.createNumber(),
}),
],
}),
}),
],
});

View File

@ -0,0 +1,5 @@
{
"name": "provide-batch-outputs",
"depMaterials": ["flow-value"],
"depPackages": []
}

View File

@ -0,0 +1,34 @@
import {
ASTFactory,
EffectOptions,
FlowNodeRegistry,
createEffectFromVariableProvider,
getNodeForm,
} from '@flowgram.ai/editor';
import { IFlowRefValue } from '../../typings';
export const provideBatchOutputsEffect: EffectOptions[] = createEffectFromVariableProvider({
private: true,
parse: (value: Record<string, IFlowRefValue>, ctx) => [
ASTFactory.createVariableDeclaration({
key: `${ctx.node.id}`,
meta: {
title: getNodeForm(ctx.node)?.getValueIn('title'),
icon: ctx.node.getNodeRegistry<FlowNodeRegistry>().info?.icon,
},
type: ASTFactory.createObject({
properties: Object.entries(value).map(([_key, value]) =>
ASTFactory.createProperty({
key: _key,
initializer: ASTFactory.createWrapArrayExpression({
wrapFor: ASTFactory.createKeyPathExpression({
keyPath: value.content || [],
}),
}),
})
),
}),
}),
],
});

View File

@ -1 +1,4 @@
export * from './components'; export * from './components';
export * from './effects';
export * from './utils';
export * from './typings';

View File

@ -0,0 +1,5 @@
{
"name": "flow-value",
"depMaterials": [],
"depPackages": []
}

View File

@ -0,0 +1,27 @@
export interface IFlowConstantValue {
type: 'constant';
content?: string | number | boolean;
}
export interface IFlowRefValue {
type: 'ref';
content?: string[];
}
export interface IFlowExpressionValue {
type: 'expression';
content?: string;
}
export interface IFlowTemplateValue {
type: 'template';
content?: string;
}
export type IFlowValue =
| IFlowConstantValue
| IFlowRefValue
| IFlowExpressionValue
| IFlowTemplateValue;
export type IFlowConstantRefValue = IFlowConstantValue | IFlowRefValue;

View File

@ -0,0 +1 @@
export * from './flow-value';

View File

@ -0,0 +1,5 @@
{
"name": "format-legacy-ref",
"depMaterials": [],
"depPackages": []
}

View File

@ -0,0 +1,153 @@
import { isObject } from 'lodash';
interface LegacyFlowRefValueSchema {
type: 'ref';
content: string;
}
interface NewFlowRefValueSchema {
type: 'ref';
content: string[];
}
/**
* In flowgram 0.2.0, for introducing Loop variable functionality,
* the FlowRefValueSchema type definition is updated:
*
* interface LegacyFlowRefValueSchema {
* type: 'ref';
* content: string;
* }
*
* interface NewFlowRefValueSchema {
* type: 'ref';
* content: string[];
* }
*
*
* For making sure backend json will not be changed, we provide format legacy ref utils for updating the formData
*
* How to use:
*
* 1. Call formatLegacyRefOnSubmit on the formData before submitting
* 2. Call formatLegacyRefOnInit on the formData after submitting
*
* Example:
* import { formatLegacyRefOnSubmit, formatLegacyRefOnInit } from '@flowgram.ai/form-materials';
* formMeta: {
* formatOnSubmit: (data) => formatLegacyRefOnSubmit(data),
* formatOnInit: (data) => formatLegacyRefOnInit(data),
* }
*/
export function formatLegacyRefOnSubmit(value: any): any {
if (isObject(value)) {
if (isLegacyFlowRefValueSchema(value)) {
return formatLegacyRefToNewRef(value);
}
return Object.fromEntries(
Object.entries(value).map(([key, value]: [string, any]) => [
key,
formatLegacyRefOnSubmit(value),
])
);
}
if (Array.isArray(value)) {
return value.map(formatLegacyRefOnSubmit);
}
return value;
}
/**
* In flowgram 0.2.0, for introducing Loop variable functionality,
* the FlowRefValueSchema type definition is updated:
*
* interface LegacyFlowRefValueSchema {
* type: 'ref';
* content: string;
* }
*
* interface NewFlowRefValueSchema {
* type: 'ref';
* content: string[];
* }
*
*
* For making sure backend json will not be changed, we provide format legacy ref utils for updating the formData
*
* How to use:
*
* 1. Call formatLegacyRefOnSubmit on the formData before submitting
* 2. Call formatLegacyRefOnInit on the formData after submitting
*
* Example:
* import { formatLegacyRefOnSubmit, formatLegacyRefOnInit } from '@flowgram.ai/form-materials';
*
* formMeta: {
* formatOnSubmit: (data) => formatLegacyRefOnSubmit(data),
* formatOnInit: (data) => formatLegacyRefOnInit(data),
* }
*/
export function formatLegacyRefOnInit(value: any): any {
if (isObject(value)) {
if (isNewFlowRefValueSchema(value)) {
return formatNewRefToLegacyRef(value);
}
return Object.fromEntries(
Object.entries(value).map(([key, value]: [string, any]) => [
key,
formatLegacyRefOnInit(value),
])
);
}
if (Array.isArray(value)) {
return value.map(formatLegacyRefOnInit);
}
return value;
}
export function isLegacyFlowRefValueSchema(value: any): value is LegacyFlowRefValueSchema {
return (
isObject(value) &&
Object.keys(value).length === 2 &&
(value as any).type === 'ref' &&
typeof (value as any).content === 'string'
);
}
export function isNewFlowRefValueSchema(value: any): value is NewFlowRefValueSchema {
return (
isObject(value) &&
Object.keys(value).length === 2 &&
(value as any).type === 'ref' &&
Array.isArray((value as any).content)
);
}
export function formatLegacyRefToNewRef(value: LegacyFlowRefValueSchema) {
const keyPath = value.content.split('.');
if (keyPath[1] === 'outputs') {
return {
type: 'ref',
content: [`${keyPath[0]}.${keyPath[1]}`, ...(keyPath.length > 2 ? keyPath.slice(2) : [])],
};
}
return {
type: 'ref',
content: keyPath,
};
}
export function formatNewRefToLegacyRef(value: NewFlowRefValueSchema) {
return {
type: 'ref',
content: value.content.join('.'),
};
}

View File

@ -0,0 +1,38 @@
# Notice
In `@flowgram.ai/form-materials@0.2.0`, for introducing loop-related materials,
The FlowRefValueSchema type definition is updated:
```typescript
interface LegacyFlowRefValueSchema {
type: 'ref';
content: string;
}
interface NewFlowRefValueSchema {
type: 'ref';
content: string[];
}
```
For making sure backend json will not be changed in your application, we provide `format-legacy-ref` utils for upgrading
How to use:
1. Call formatLegacyRefOnSubmit on the formData before submitting
2. Call formatLegacyRefOnInit on the formData after submitting
Example:
```typescript
import { formatLegacyRefOnSubmit, formatLegacyRefOnInit } from '@flowgram.ai/form-materials';
formMeta: {
formatOnSubmit: (data) => formatLegacyRefOnSubmit(data),
formatOnInit: (data) => formatLegacyRefOnInit(data),
}
```

View File

@ -0,0 +1 @@
export * from './format-legacy-refs';

View File

@ -13,7 +13,7 @@ import {
ObjectType, ObjectType,
StringType, StringType,
} from './type'; } from './type';
import { EnumerateExpression, ExpressionList, KeyPathExpression } from './expression'; import { EnumerateExpression, KeyPathExpression, WrapArrayExpression } from './expression';
import { Property, VariableDeclaration, VariableDeclarationList } from './declaration'; import { Property, VariableDeclaration, VariableDeclarationList } from './declaration';
import { DataNode, MapNode } from './common'; import { DataNode, MapNode } from './common';
import { ASTNode, ASTNodeRegistry } from './ast-node'; import { ASTNode, ASTNodeRegistry } from './ast-node';
@ -43,7 +43,7 @@ export class ASTRegisters {
this.registerAST(VariableDeclarationList); this.registerAST(VariableDeclarationList);
this.registerAST(KeyPathExpression); this.registerAST(KeyPathExpression);
this.registerAST(EnumerateExpression); this.registerAST(EnumerateExpression);
this.registerAST(ExpressionList); this.registerAST(WrapArrayExpression);
this.registerAST(MapNode); this.registerAST(MapNode);
this.registerAST(DataNode); this.registerAST(DataNode);
} }

View File

@ -1,34 +0,0 @@
import { ASTKind, ASTNodeJSON } from '../types';
import { ASTNode } from '../ast-node';
export interface ExpressionListJSON {
expressions: ASTNodeJSON[];
}
export class ExpressionList extends ASTNode<ExpressionListJSON> {
static kind: string = ASTKind.ExpressionList;
expressions: ASTNode[];
fromJSON({ expressions }: ExpressionListJSON): void {
this.expressions = expressions.map((_expression, idx) => {
const prevExpression = this.expressions[idx];
if (prevExpression.kind !== _expression.kind) {
prevExpression.dispose();
this.fireChange();
return this.createChildNode(_expression);
}
prevExpression.fromJSON(_expression);
return prevExpression;
});
}
toJSON(): ASTNodeJSON {
return {
kind: ASTKind.ExpressionList,
properties: this.expressions.map(_expression => _expression.toJSON()),
};
}
}

View File

@ -1,5 +1,5 @@
export { BaseExpression } from './base-expression'; export { BaseExpression } from './base-expression';
export { ExpressionList, type ExpressionListJSON } from './expression-list';
export { KeyPathExpression, type KeyPathExpressionJSON } from './keypath-expression'; export { KeyPathExpression, type KeyPathExpressionJSON } from './keypath-expression';
export { EnumerateExpression, type EnumerateExpressionJSON } from './enumerate-expression'; export { EnumerateExpression, type EnumerateExpressionJSON } from './enumerate-expression';
export { KeyPathExpressionV2 } from './keypath-expression-v2'; export { KeyPathExpressionV2 } from './keypath-expression-v2';
export { WrapArrayExpression, type WrapArrayExpressionJSON } from './wrap-array-expression';

View File

@ -0,0 +1,60 @@
import { postConstructAST } from '../utils/inversify';
import { ASTKind, ASTNodeJSON } from '../types';
import { BaseType } from '../type';
import { BaseExpression } from './base-expression';
export interface WrapArrayExpressionJSON {
wrapFor: ASTNodeJSON; // 需要被遍历的表达式类型
}
/**
*
*/
export class WrapArrayExpression extends BaseExpression<WrapArrayExpressionJSON> {
static kind: string = ASTKind.WrapArrayExpression;
protected _wrapFor: BaseExpression | undefined;
protected _returnType: BaseType | undefined;
get wrapFor() {
return this._wrapFor;
}
get returnType(): BaseType | undefined {
return this._returnType;
}
refreshReturnType() {
// 被遍历表达式的返回值
const childReturnTypeJSON = this.wrapFor?.returnType?.toJSON();
this.updateChildNodeByKey('_returnType', {
kind: ASTKind.Array,
items: childReturnTypeJSON,
});
}
getRefFields(): [] {
return [];
}
fromJSON({ wrapFor: expression }: WrapArrayExpressionJSON): void {
this.updateChildNodeByKey('_wrapFor', expression);
}
toJSON(): ASTNodeJSON {
return {
kind: ASTKind.WrapArrayExpression,
wrapFor: this.wrapFor?.toJSON(),
};
}
@postConstructAST()
protected init() {
this.toDispose.push(
this.subscribe(this.refreshReturnType, {
selector: (curr) => curr.wrapFor?.returnType,
})
);
}
}

View File

@ -1,8 +1,15 @@
import { get } from 'lodash';
import { ASTKind, ASTNodeJSON } from './types'; import { ASTKind, ASTNodeJSON } from './types';
import { MapJSON } from './type/map'; import { MapJSON } from './type/map';
import { VarJSONSchema } from './type/json-schema';
import { ArrayJSON } from './type/array'; import { ArrayJSON } from './type/array';
import { CustomTypeJSON, ObjectJSON, UnionJSON } from './type'; import { CustomTypeJSON, ObjectJSON, UnionJSON } from './type';
import { EnumerateExpressionJSON, KeyPathExpressionJSON } from './expression'; import {
EnumerateExpressionJSON,
KeyPathExpressionJSON,
WrapArrayExpressionJSON,
} from './expression';
import { PropertyJSON, VariableDeclarationJSON, VariableDeclarationListJSON } from './declaration'; import { PropertyJSON, VariableDeclarationJSON, VariableDeclarationListJSON } from './declaration';
import { ASTNode } from './ast-node'; import { ASTNode } from './ast-node';
@ -65,6 +72,78 @@ export namespace ASTFactory {
kind: ASTKind.KeyPathExpression, kind: ASTKind.KeyPathExpression,
...json, ...json,
}); });
export const createWrapArrayExpression = (json: WrapArrayExpressionJSON) => ({
kind: ASTKind.WrapArrayExpression,
...json,
});
/**
* Converts a JSON schema to an Abstract Syntax Tree (AST) representation.
* This function recursively processes the JSON schema and creates corresponding AST nodes.
*
* For more information on JSON Schema, refer to the official documentation:
* https://json-schema.org/
*
*
* @param jsonSchema - The JSON schema to convert.
* @returns An AST node representing the JSON schema, or undefined if the schema type is not recognized.
*/
export function createTypeASTFromSchema(
jsonSchema: VarJSONSchema.ISchema
): ASTNodeJSON | undefined {
const { type, extra } = jsonSchema || {};
const { weak = false } = extra || {};
if (!type) {
return undefined;
}
switch (type) {
case 'object':
if (weak) {
return { kind: ASTKind.Object, weak: true };
}
return ASTFactory.createObject({
properties: Object.entries(jsonSchema.properties || {})
/**
* Sorts the properties of a JSON schema based on the 'extra.index' field.
* If the 'extra.index' field is not present, the property will be treated as having an index of 0.
*/
.sort((a, b) => (get(a?.[1], 'extra.index') || 0) - (get(b?.[1], 'extra.index') || 0))
.map(([key, _property]) => ({
key,
type: createTypeASTFromSchema(_property),
meta: { description: _property.description },
})),
});
case 'array':
if (weak) {
return { kind: ASTKind.Array, weak: true };
}
return ASTFactory.createArray({
items: createTypeASTFromSchema(jsonSchema.items!),
});
case 'map':
if (weak) {
return { kind: ASTKind.Map, weak: true };
}
return ASTFactory.createMap({
valueType: createTypeASTFromSchema(jsonSchema.additionalProperties!),
});
case 'string':
return ASTFactory.createString();
case 'number':
return ASTFactory.createNumber();
case 'boolean':
return ASTFactory.createBoolean();
case 'integer':
return ASTFactory.createInteger();
default:
// If the type is not recognized, return CustomType
return ASTFactory.createCustomType({ typeName: type });
}
}
/** /**
* AST Class * AST Class

View File

@ -1,9 +1,11 @@
import { parseTypeJsonOrKind } from '../utils/helpers'; import { parseTypeJsonOrKind } from '../utils/helpers';
import { VarJSONSchema } from './json-schema';
import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types'; import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { ASTNodeFlags } from '../flags'; import { ASTNodeFlags } from '../flags';
import { BaseVariableField } from '../declaration'; import { BaseVariableField } from '../declaration';
import { ASTNode } from '../ast-node'; import { ASTNode } from '../ast-node';
import { UnionJSON } from './union'; import { UnionJSON } from './union';
import { ASTFactory } from '../factory';
export abstract class BaseType<JSON extends ASTNodeJSON = any, InjectOpts = any> extends ASTNode< export abstract class BaseType<JSON extends ASTNodeJSON = any, InjectOpts = any> extends ASTNode<
JSON, JSON,
@ -12,7 +14,7 @@ export abstract class BaseType<JSON extends ASTNodeJSON = any, InjectOpts = any>
public flags: number = ASTNodeFlags.BasicType; public flags: number = ASTNodeFlags.BasicType;
/** /**
* extraTypeInfoEqual *
* @param targetTypeJSON * @param targetTypeJSON
*/ */
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean { public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
@ -20,8 +22,8 @@ export abstract class BaseType<JSON extends ASTNodeJSON = any, InjectOpts = any>
// 如果是 Union 类型,有一个子类型保持相等即可 // 如果是 Union 类型,有一个子类型保持相等即可
if (targetTypeJSON?.kind === ASTKind.Union) { if (targetTypeJSON?.kind === ASTKind.Union) {
return ((targetTypeJSON as UnionJSON)?.types || [])?.some(_subType => return ((targetTypeJSON as UnionJSON)?.types || [])?.some((_subType) =>
this.isTypeEqual(_subType), this.isTypeEqual(_subType)
); );
} }
@ -36,9 +38,40 @@ export abstract class BaseType<JSON extends ASTNodeJSON = any, InjectOpts = any>
throw new Error(`Get By Key Path is not implemented for Type: ${this.kind}`); throw new Error(`Get By Key Path is not implemented for Type: ${this.kind}`);
} }
/**
* Get AST JSON for current base type
* @returns
*/
toJSON(): ASTNodeJSON { toJSON(): ASTNodeJSON {
return { return {
kind: this.kind, kind: this.kind,
}; };
} }
/**
* Get Standard JSON Schema for current base type
* @returns
*/
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: this.kind.toLowerCase() as VarJSONSchema.BasicType,
};
}
/**
* Check if the type is equal with json schema
*/
isEqualWithJSONSchema(schema: VarJSONSchema.ISchema | VarJSONSchema.ISchema[]): boolean {
if (Array.isArray(schema)) {
return this.isTypeEqual(
ASTFactory.createUnion({
types: schema
.map((_schema) => ASTFactory.createTypeASTFromSchema(_schema)!)
.filter(Boolean),
})
);
}
return this.isTypeEqual(ASTFactory.createTypeASTFromSchema(schema));
}
} }

View File

@ -1,3 +1,4 @@
import { VarJSONSchema } from './json-schema';
import { ASTKind } from '../types'; import { ASTKind } from '../types';
import { BaseType } from './base-type'; import { BaseType } from './base-type';
@ -7,4 +8,10 @@ export class BooleanType extends BaseType {
fromJSON(): void { fromJSON(): void {
// noop // noop
} }
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: 'boolean',
};
}
} }

View File

@ -1,4 +1,5 @@
import { parseTypeJsonOrKind } from '../utils/helpers'; import { parseTypeJsonOrKind } from '../utils/helpers';
import { VarJSONSchema } from './json-schema';
import { ASTKind, ASTNodeJSONOrKind } from '../types'; import { ASTKind, ASTNodeJSONOrKind } from '../types';
import { type UnionJSON } from './union'; import { type UnionJSON } from './union';
import { BaseType } from './base-type'; import { BaseType } from './base-type';
@ -35,4 +36,10 @@ export class CustomType extends BaseType<CustomTypeJSON> {
return targetTypeJSON?.kind === this.kind && targetTypeJSON?.typeName === this.typeName; return targetTypeJSON?.kind === this.kind && targetTypeJSON?.typeName === this.typeName;
} }
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: this._typeName,
};
}
} }

View File

@ -12,3 +12,4 @@ export {
export { BaseType } from './base-type'; export { BaseType } from './base-type';
export { type UnionJSON } from './union'; export { type UnionJSON } from './union';
export { CustomType, type CustomTypeJSON } from './custom-type'; export { CustomType, type CustomTypeJSON } from './custom-type';
export { VarJSONSchema } from './json-schema';

View File

@ -1,3 +1,4 @@
import { VarJSONSchema } from './json-schema';
import { ASTKind } from '../types'; import { ASTKind } from '../types';
import { ASTNodeFlags } from '../flags'; import { ASTNodeFlags } from '../flags';
import { BaseType } from './base-type'; import { BaseType } from './base-type';
@ -10,4 +11,10 @@ export class IntegerType extends BaseType {
fromJSON(): void { fromJSON(): void {
// noop // noop
} }
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: 'integer',
};
}
} }

View File

@ -0,0 +1,26 @@
export namespace VarJSONSchema {
export type BasicType = 'boolean' | 'string' | 'integer' | 'number' | 'object' | 'array' | 'map';
export interface ISchema<T = string> {
type?: T;
default?: any;
title?: string;
description?: string;
enum?: (string | number)[];
properties?: Record<string, ISchema<T>>;
additionalProperties?: ISchema<T>;
items?: ISchema<T>;
required?: string[];
$ref?: string;
extra?: {
index?: number;
// Used in BaseType.isEqualWithJSONSchema, the type comparison will be weak
weak?: boolean;
// Set the render component
formComponent?: string;
[key: string]: any;
};
}
export type IBasicSchema = ISchema<BasicType>;
}

View File

@ -1,4 +1,5 @@
import { parseTypeJsonOrKind } from '../utils/helpers'; import { parseTypeJsonOrKind } from '../utils/helpers';
import { VarJSONSchema } from './json-schema';
import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types'; import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { BaseType } from './base-type'; import { BaseType } from './base-type';
@ -74,4 +75,11 @@ export class MapType extends BaseType<MapJSON> {
valueType: this.valueType?.toJSON(), valueType: this.valueType?.toJSON(),
}; };
} }
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: 'map',
additionalProperties: this.valueType?.toJSONSchema(),
};
}
} }

View File

@ -1,3 +1,4 @@
import { VarJSONSchema } from './json-schema';
import { ASTKind } from '../types'; import { ASTKind } from '../types';
import { BaseType } from './base-type'; import { BaseType } from './base-type';
@ -7,4 +8,10 @@ export class NumberType extends BaseType {
fromJSON(): void { fromJSON(): void {
// noop // noop
} }
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: 'number',
};
}
} }

View File

@ -1,6 +1,7 @@
import { xor } from 'lodash'; import { xor } from 'lodash';
import { parseTypeJsonOrKind } from '../utils/helpers'; import { parseTypeJsonOrKind } from '../utils/helpers';
import { VarJSONSchema } from './json-schema';
import { ASTNodeJSON, ASTKind, ASTNodeJSONOrKind, type GlobalEventActionType } from '../types'; import { ASTNodeJSON, ASTKind, ASTNodeJSONOrKind, type GlobalEventActionType } from '../types';
import { ASTNodeFlags } from '../flags'; import { ASTNodeFlags } from '../flags';
import { Property, type PropertyJSON } from '../declaration/property'; import { Property, type PropertyJSON } from '../declaration/property';
@ -60,7 +61,7 @@ export class ObjectType extends BaseType<ObjectJSON> {
}); });
// 删除没有出现过的 property // 删除没有出现过的 property
removedKeys.forEach(key => { removedKeys.forEach((key) => {
const property = this.propertyTable.get(key); const property = this.propertyTable.get(key);
property?.dispose(); property?.dispose();
this.propertyTable.delete(key); this.propertyTable.delete(key);
@ -79,7 +80,7 @@ export class ObjectType extends BaseType<ObjectJSON> {
toJSON(): ASTNodeJSON { toJSON(): ASTNodeJSON {
return { return {
kind: ASTKind.Object, kind: ASTKind.Object,
properties: this.properties.map(_property => _property.toJSON()), properties: this.properties.map((_property) => _property.toJSON()),
}; };
} }
@ -131,13 +132,13 @@ export class ObjectType extends BaseType<ObjectJSON> {
const targetProperties = (targetTypeJSON as ObjectJSON).properties || []; const targetProperties = (targetTypeJSON as ObjectJSON).properties || [];
const sourcePropertyKeys = Array.from(this.propertyTable.keys()); const sourcePropertyKeys = Array.from(this.propertyTable.keys());
const targetPropertyKeys = targetProperties.map(_target => _target.key); const targetPropertyKeys = targetProperties.map((_target) => _target.key);
const isKeyStrongEqual = !xor(sourcePropertyKeys, targetPropertyKeys).length; const isKeyStrongEqual = !xor(sourcePropertyKeys, targetPropertyKeys).length;
return ( return (
isKeyStrongEqual && isKeyStrongEqual &&
targetProperties.every(targetProperty => { targetProperties.every((targetProperty) => {
const sourceProperty = this.propertyTable.get(targetProperty.key); const sourceProperty = this.propertyTable.get(targetProperty.key);
return ( return (
@ -148,4 +149,14 @@ export class ObjectType extends BaseType<ObjectJSON> {
}) })
); );
} }
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: 'object',
properties: this.properties.reduce((acc, _property) => {
acc[_property.key] = _property.type?.toJSONSchema();
return acc;
}, {} as Record<string, VarJSONSchema.ISchema>),
};
}
} }

View File

@ -1,3 +1,4 @@
import { VarJSONSchema } from './json-schema';
import { ASTKind } from '../types'; import { ASTKind } from '../types';
import { ASTNodeFlags } from '../flags'; import { ASTNodeFlags } from '../flags';
import { BaseType } from './base-type'; import { BaseType } from './base-type';
@ -10,4 +11,10 @@ export class StringType extends BaseType {
fromJSON(): void { fromJSON(): void {
// noop // noop
} }
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: 'string',
};
}
} }

View File

@ -43,7 +43,7 @@ export enum ASTKind {
*/ */
KeyPathExpression = 'KeyPathExpression', // 通过路径系统访问变量上的字段 KeyPathExpression = 'KeyPathExpression', // 通过路径系统访问变量上的字段
EnumerateExpression = 'EnumerateExpression', // 对指定的数据进行遍历 EnumerateExpression = 'EnumerateExpression', // 对指定的数据进行遍历
ExpressionList = 'ExpressionList', // 表达式列表 WrapArrayExpression = 'WrapArrayExpression', // Wrap with Array Type
/** /**
* AST * AST

View File

@ -0,0 +1,61 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DisposableCollection } from '@flowgram.ai/utils';
import { useService } from '@flowgram.ai/core';
import { useCurrentScope } from '../context';
import { VariableEngine } from '../../variable-engine';
import { Scope } from '../../scope';
import {
DisposeASTAction,
GlobalEventActionType,
NewASTAction,
UpdateASTAction,
} from '../../ast/types';
export function useScopeOutputVariables(scopeFromParam?: Scope | Scope[]) {
const currentScope = useCurrentScope();
const variableEngine = useService(VariableEngine);
const [refreshKey, setRefreshKey] = useState(0);
const scopes = useMemo(
() => {
if (!scopeFromParam) {
return [currentScope];
}
if (Array.isArray(scopeFromParam)) {
return scopeFromParam;
}
return [scopeFromParam];
},
Array.isArray(scopeFromParam) ? scopeFromParam : [scopeFromParam]
);
useEffect(() => {
setRefreshKey((_key) => _key + 1);
const refreshWhenInScopes = useCallback((action: GlobalEventActionType) => {
if (scopes.includes(action.ast?.scope!)) {
setRefreshKey((_key) => _key + 1);
}
}, []);
const disposable = new DisposableCollection();
disposable.pushAll([
variableEngine.onGlobalEvent<DisposeASTAction>('DisposeAST', refreshWhenInScopes),
variableEngine.onGlobalEvent<NewASTAction>('NewAST', refreshWhenInScopes),
variableEngine.onGlobalEvent<UpdateASTAction>('UpdateAST', refreshWhenInScopes),
]);
return () => disposable.dispose();
}, [scopes]);
const variables = useMemo(
() => scopes.map((scope) => scope.output.variables).flat(),
[scopes, refreshKey]
);
return variables;
}