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

@ -1,105 +1,142 @@
# Official Form Materials # Official Form Materials
## How to Use? ## How to Use?
### Via Package Reference ### Via Package Reference
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'
``` ```
### Adding Material Source Code via CLI ### Adding Material Source Code via CLI
If customization is required (e.g., modifying text, styles, or business logic), it is recommended to **add the material source code to the project for customization via CLI**: If customization is required (e.g., modifying text, styles, or business logic), it is recommended to **add the material source code to the project for customization via CLI**:
```bash ```bash
npx @flowgram.ai/form-materials npx @flowgram.ai/form-materials
``` ```
After running, the CLI will prompt the user to select the material to add: After running, the CLI will prompt the user to select the material to add:
```console ```console
? Select one material to add: (Use arrow keys) ? Select one material to add: (Use arrow keys)
components/json-schema-editor components/json-schema-editor
components/type-selector components/type-selector
components/variable-selector components/variable-selector
``` ```
Users can also directly add the source code of a specific material via CLI: Users can also directly add the source code of a specific material via CLI:
```bash ```bash
npx @flowgram.ai/form-materials components/json-schema-editor npx @flowgram.ai/form-materials components/json-schema-editor
``` ```
Once the CLI completes successfully, the relevant materials will be automatically added to the `src/form-materials` directory in the current project. Once the CLI completes successfully, the relevant materials will be automatically added to the `src/form-materials` directory in the current project.
:::warning Notes :::warning Notes
1. The official materials are currently implemented based on [Semi Design](https://semi.design/). If there are requirements for an underlying component library, the source code can be copied via CLI and replaced. 1. The official materials are currently implemented based on [Semi Design](https://semi.design/). If there are requirements for an underlying component library, the source code can be copied via CLI and replaced.
2. Some materials depend on third-party npm libraries, which will be automatically installed during CLI execution. 2. Some materials depend on third-party npm libraries, which will be automatically installed during CLI execution.
3. Some materials depend on other official materials. The source code of these dependent materials will also be added to the project during CLI execution. 3. Some materials depend on other official materials. The source code of these dependent materials will also be added to the project during CLI execution.
::: :::
## Currently Supported Component Materials ## Currently Supported Component Materials
### [TypeSelector](https://github.com/bytedance/flowgram.ai/tree/main/packages/materials/form-materials/src/components/type-selector/index.tsx) ### [TypeSelector](https://github.com/bytedance/flowgram.ai/tree/main/packages/materials/form-materials/src/components/type-selector/index.tsx)
TypeSelector is used for variable type selection. TypeSelector is used for variable type selection.
<img loading="lazy" src="/materials/type-selector.png" style={{width:500}}/> <img loading="lazy" src="/materials/type-selector.png" style={{width:500}}/>
Usage via package reference: Usage via package reference:
```tsx ```tsx
import { TypeSelector } from '@flowgram.ai/materials' import { TypeSelector } from '@flowgram.ai/materials'
``` ```
Adding source code via CLI: Adding source code via CLI:
```bash ```bash
npx @flowgram.ai/materials components/type-selector npx @flowgram.ai/materials components/type-selector
``` ```
### [VariableSelector](https://github.com/bytedance/flowgram.ai/tree/main/packages/materials/form-materials/src/components/variable-selector/index.tsx) ### [VariableSelector](https://github.com/bytedance/flowgram.ai/tree/main/packages/materials/form-materials/src/components/variable-selector/index.tsx)
VariableSelector is used for selecting a single variable. VariableSelector is used for selecting a single variable.
<img loading="lazy" src="/materials/variable-selector.png" style={{width:500}}/> <img loading="lazy" src="/materials/variable-selector.png" style={{width:500}}/>
Usage via package reference: Usage via package reference:
```tsx ```tsx
import { VariableSelector } from '@flowgram.ai/materials' import { VariableSelector } from '@flowgram.ai/materials'
``` ```
Adding source code via CLI: Adding source code via CLI:
```bash ```bash
npx @flowgram.ai/materials components/variable-selector npx @flowgram.ai/materials components/variable-selector
``` ```
### [JsonSchemaEditor](https://github.com/bytedance/flowgram.ai/tree/main/packages/materials/form-materials/src/components/json-schema-editor/index.tsx) ### [JsonSchemaEditor](https://github.com/bytedance/flowgram.ai/tree/main/packages/materials/form-materials/src/components/json-schema-editor/index.tsx)
JsonSchemaEditor is used for visually editing JsonSchema, commonly used for configuring node output variables visually. JsonSchemaEditor is used for visually editing JsonSchema, commonly used for configuring node output variables visually.
<img loading="lazy" src="/materials/json-schema-editor.png" style={{width:500}}/> <img loading="lazy" src="/materials/json-schema-editor.png" style={{width:500}}/>
Usage via package reference: Usage via package reference:
```tsx ```tsx
import { JsonSchemaEditor } from '@flowgram.ai/materials' import { JsonSchemaEditor } from '@flowgram.ai/materials'
``` ```
Adding source code via CLI: 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;
}