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 { FxExpression } from '../fx-expression';
import { FormItem } from '../form-item';
import { Feedback } from '../feedback';
import { JsonSchema } from '../../typings';
@ -26,11 +26,12 @@ export function FormInputs() {
type={property.type as string}
required={required.includes(key)}
>
<FxExpression
<DynamicValueInput
value={field.value}
onChange={field.onChange}
readonly={readonly}
hasError={Object.keys(fieldState?.errors || {}).length > 0}
schema={property}
/>
<Feedback errors={fieldState?.errors} />
</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 { VariableSelector } from '@flowgram.ai/form-materials';
import { VariableSelector, TypeSelector, DynamicValueInput } from '@flowgram.ai/form-materials';
import { Input, Button } from '@douyinfe/semi-ui';
import { IconCrossCircleStroked } from '@douyinfe/semi-icons';
import { TypeSelector } from '../type-selector';
import { JsonSchema } from '../../typings';
import { LeftColumn, Row } from './styles';
@ -24,6 +23,11 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
value[key] = val;
props.onChange(value, props.propertyKey);
};
const partialUpdateProperty = (val?: Partial<JsonSchema>) => {
props.onChange({ ...value, ...val }, props.propertyKey);
};
useLayoutEffect(() => {
updateKey(props.propertyKey);
}, [props.propertyKey]);
@ -31,14 +35,15 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
<Row>
<LeftColumn>
<TypeSelector
value={value.type}
value={value}
disabled={disabled}
style={{ position: 'absolute', top: 6, left: 4, zIndex: 1 }}
onChange={(val) => updateProperty('type', val)}
style={{ position: 'absolute', top: 2, left: 4, zIndex: 1, padding: '0 5px', height: 20 }}
onChange={(val) => partialUpdateProperty(val)}
/>
<Input
value={inputKey}
disabled={disabled}
size="small"
onChange={(v) => updateKey(v.trim())}
onBlur={() => {
if (inputKey !== '') {
@ -50,22 +55,22 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
style={{ paddingLeft: 26 }}
/>
</LeftColumn>
{props.useFx ? (
<VariableSelector
value={value.default}
readonly={disabled}
onChange={(val) => updateProperty('default', val)}
style={{ flexGrow: 1, height: 32 }}
/>
) : (
<Input
disabled={disabled}
{
<DynamicValueInput
value={value.default}
onChange={(val) => updateProperty('default', val)}
schema={value}
style={{ flexGrow: 1 }}
/>
)}
}
{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>
);

View File

@ -9,7 +9,7 @@ export const Row = styled.div`
`;
export const LeftColumn = styled.div`
width: 300px;
margin-right: 10px;
width: 120px;
margin-right: 5px;
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',
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: {
title: 'LLM',
inputsValues: {
modelType: 'gpt-3.5-turbo',
temperature: 0.5,
systemPrompt: 'You are an AI assistant.',
prompt: '',
modelType: {
type: 'constant',
content: 'gpt-3.5-turbo',
},
temperature: {
type: 'constant',
content: 0.5,
},
systemPrompt: {
type: 'constant',
content: 'You are an AI assistant.',
},
prompt: {
type: 'constant',
content: '',
},
},
inputs: {
type: 'object',
@ -62,6 +92,10 @@ export const initialData: FlowDocumentJSON = {
type: 'loop',
data: {
title: 'Loop',
batchFor: {
type: 'ref',
content: ['start_0', 'array_obj'],
},
},
blocks: [
{

View File

@ -3,6 +3,7 @@ import { nanoid } from 'nanoid';
import { defaultFormMeta } from '../default-form-meta';
import { FlowNodeRegistry } from '../../typings';
import iconLoop from '../../assets/icon-loop.svg';
import { LoopFormRender } from './loop-form-render';
export const LoopNodeRegistry: FlowNodeRegistry = {
type: 'loop',
@ -14,7 +15,7 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
meta: {
expandable: false, // disable expanded
},
formMeta: defaultFormMeta,
formMeta: { ...defaultFormMeta, render: LoopFormRender },
onAdd() {
return {
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,
} from '@flowgram.ai/fixed-layout-editor';
import { createASTFromJSONSchema } from './utils';
export interface SyncVariablePluginOptions {}
/**
@ -39,7 +37,7 @@ export const createSyncVariablePlugin: PluginCreator<SyncVariablePluginOptions>
// 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
const typeAST = createASTFromJSONSchema(value);
const typeAST = ASTFactory.createTypeASTFromSchema(value);
if (typeAST) {
// 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,
// NOTICE: You can add more metadata here as needed
},
key: `${node.id}.outputs`,
key: `${node.id}`,
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> {
type?: T;
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
};
}
export type BasicType = VarJSONSchema.BasicType;
export type JsonSchema = VarJSONSchema.ISchema;

View File

@ -1,3 +1,4 @@
import { IFlowValue } from '@flowgram.ai/form-materials';
import {
FlowNodeJSON as FlowNodeJSONDefault,
FlowNodeRegistry as FlowNodeRegistryDefault,
@ -7,12 +8,6 @@ import {
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
* data , JsonSchema /
@ -22,11 +17,11 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
/**
* Node title
*/
title: string;
title?: string;
/**
* Inputs data values
*/
inputsValues?: Record<string, FlowValueSchema>;
inputsValues?: Record<string, IFlowValue>;
/**
* 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
*/
outputs?: JsonSchema;
/**
* Rest properties
*/
[key: string]: any;
};
}

View File

@ -1,6 +1,6 @@
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 { Feedback } from '../feedback';
import { JsonSchema } from '../../typings';
@ -26,11 +26,12 @@ export function FormInputs() {
type={property.type as string}
required={required.includes(key)}
>
<FxExpression
<DynamicValueInput
value={field.value}
onChange={field.onChange}
readonly={readonly}
hasError={Object.keys(fieldState?.errors || {}).length > 0}
schema={property}
/>
<Feedback errors={fieldState?.errors} />
</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 { VariableSelector } from '@flowgram.ai/form-materials';
import { VariableSelector, TypeSelector, DynamicValueInput } from '@flowgram.ai/form-materials';
import { Input, Button } from '@douyinfe/semi-ui';
import { IconCrossCircleStroked } from '@douyinfe/semi-icons';
import { TypeSelector } from '../type-selector';
import { JsonSchema } from '../../typings';
import { LeftColumn, Row } from './styles';
@ -24,6 +23,11 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
value[key] = val;
props.onChange(value, props.propertyKey);
};
const partialUpdateProperty = (val?: Partial<JsonSchema>) => {
props.onChange({ ...value, ...val }, props.propertyKey);
};
useLayoutEffect(() => {
updateKey(props.propertyKey);
}, [props.propertyKey]);
@ -31,14 +35,15 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
<Row>
<LeftColumn>
<TypeSelector
value={value.type}
value={value}
disabled={disabled}
style={{ position: 'absolute', top: 6, left: 4, zIndex: 1 }}
onChange={(val) => updateProperty('type', val)}
style={{ position: 'absolute', top: 2, left: 4, zIndex: 1, padding: '0 5px', height: 20 }}
onChange={(val) => partialUpdateProperty(val)}
/>
<Input
value={inputKey}
disabled={disabled}
size="small"
onChange={(v) => updateKey(v.trim())}
onBlur={() => {
if (inputKey !== '') {
@ -50,22 +55,22 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
style={{ paddingLeft: 26 }}
/>
</LeftColumn>
{props.useFx ? (
<VariableSelector
value={value.default}
readonly={disabled}
onChange={(val) => updateProperty('default', val)}
style={{ flexGrow: 1, height: 32 }}
/>
) : (
<Input
disabled={disabled}
{
<DynamicValueInput
value={value.default}
onChange={(val) => updateProperty('default', val)}
schema={value}
style={{ flexGrow: 1 }}
/>
)}
}
{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>
);

View File

@ -9,7 +9,7 @@ export const Row = styled.div`
`;
export const LeftColumn = styled.div`
width: 300px;
margin-right: 10px;
width: 120px;
margin-right: 5px;
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',
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: {
title: 'Condition',
inputsValues: {
conditions: [
{
key: 'if_0',
value: {
type: 'expression',
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',
},
},
},
conditions: [
{
key: 'if_0',
value: {
type: 'expression',
content: '',
},
},
},
{
key: 'if_f0rOAt',
value: {
type: 'expression',
content: '',
},
},
],
},
},
{
@ -106,17 +103,9 @@ export const initialData: FlowDocumentJSON = {
},
data: {
title: 'Loop_2',
inputsValues: {
loopTimes: 2,
},
inputs: {
type: 'object',
required: ['loopTimes'],
properties: {
loopTimes: {
type: 'number',
},
},
batchFor: {
type: 'ref',
content: ['start_0', 'array_obj'],
},
outputs: {
type: 'object',

View File

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

View File

@ -4,6 +4,7 @@ import {
PositionSchema,
FlowNodeTransformData,
} from '@flowgram.ai/free-layout-editor';
import { provideBatchInputEffect } from '@flowgram.ai/form-materials';
import { defaultFormMeta } from '../default-form-meta';
import { FlowNodeRegistry } from '../../typings';
@ -63,30 +64,15 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
type: 'loop',
data: {
title: `Loop_${++index}`,
inputsValues: {
loopTimes: 2,
},
inputs: {
type: 'object',
required: ['loopTimes'],
properties: {
loopTimes: {
type: 'number',
},
},
},
outputs: {
type: 'object',
properties: {
result: { type: 'string' },
},
},
},
};
},
formMeta: {
...defaultFormMeta,
render: LoopFormRender,
effect: {
batchFor: provideBatchInputEffect,
},
},
onCreate() {
// 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 { BatchVariableSelector, IFlowRefValue } from '@flowgram.ai/form-materials';
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 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>
<FormInputs />
{batchFor}
<FormOutputs />
</FormContent>
</>
@ -21,7 +47,7 @@ export const LoopFormRender = ({ form }: FormRenderProps<FlowNodeJSON>) => {
<>
<FormHeader />
<FormContent>
<FormInputs />
{batchFor}
<SubCanvasRender />
<FormOutputs />
</FormContent>

View File

@ -7,8 +7,6 @@ import {
ASTFactory,
} from '@flowgram.ai/free-layout-editor';
import { createASTFromJSONSchema } from './utils';
export interface SyncVariablePluginOptions {}
/**
@ -39,7 +37,7 @@ export const createSyncVariablePlugin: PluginCreator<SyncVariablePluginOptions>
// 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
const typeAST = createASTFromJSONSchema(value);
const typeAST = ASTFactory.createTypeASTFromSchema(value);
if (typeAST) {
// 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,
// NOTICE: You can add more metadata here as needed
},
key: `${node.id}.outputs`,
key: `${node.id}`,
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> {
type?: T;
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
};
}
export type BasicType = VarJSONSchema.BasicType;
export type JsonSchema = VarJSONSchema.ISchema;

View File

@ -6,17 +6,10 @@ import {
type WorkflowEdgeJSON,
WorkflowNodeMeta,
} from '@flowgram.ai/free-layout-editor';
import { IFlowValue } from '@flowgram.ai/form-materials';
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
* data , JsonSchema /
@ -30,7 +23,7 @@ export interface FlowNodeJSON extends FlowNodeJSONDefault {
/**
* Inputs data values
*/
inputsValues?: Record<string, FlowValueSchema>;
inputsValues?: Record<string, IFlowValue>;
/**
* 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';
<PackageManagerTabs command={{
npm: "npm install @flowgram.ai/form-materials"
}} />
```
```tsx
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
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
? Select one material to add: (Use arrow keys)
components/json-schema-editor
components/type-selector
components/variable-selector
```
? Select one material to add: (Use arrow keys)
components/json-schema-editor
components/type-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
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.
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.
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.
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
import { TypeSelector } from '@flowgram.ai/materials'
```
import { TypeSelector } from '@flowgram.ai/materials'
```
Adding source code via CLI:
Adding source code via CLI:
```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
import { VariableSelector } from '@flowgram.ai/materials'
```
import { VariableSelector } from '@flowgram.ai/materials'
```
Adding source code via CLI:
Adding source code via CLI:
```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
import { JsonSchemaEditor } from '@flowgram.ai/materials'
```
import { JsonSchemaEditor } from '@flowgram.ai/materials'
```
Adding source code via CLI:
Adding source code via CLI:
```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
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 path from 'path';
const _types = ['components'];
const _types = ['components', 'effects', 'utils', 'typings'];
export function listAllMaterials() {
const _materials = [];
@ -32,7 +32,9 @@ export function listAllMaterials() {
export function bfsMaterials(material, _materials = []) {
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];
@ -67,11 +69,25 @@ export function bfsMaterials(material, _materials = []) {
export const copyMaterial = (material, projectInfo) => {
const sourceDir = material.path;
const targetDir = path.join(
const materialRoot = path.join(
projectInfo.projectPath,
'src',
'form-materials',
`${material.type}`,
material.name
`${material.type}`
);
const targetDir = path.join(materialRoot, material.name);
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 { TypeSelector, JsonSchema, VariableTypeIcons, ArrayIcons } from './type-selector';
export { JsonSchemaEditor } from './json-schema-editor';
export * from './variable-selector';
export * from './type-selector';
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(
() =>
isDrilldownObject
? Object.entries(drilldown.schema?.properties || {}).map(
([name, _value]) =>
({
key: genId(),
name,
isPropertyRequired: drilldown.schema?.required?.includes(name) || false,
..._value,
} as PropertyValueType)
)
? Object.entries(drilldown.schema?.properties || {})
.sort(([, a], [, b]) => (a.extra?.index ?? 0) - (b.extra?.index ?? 0))
.map(
([name, _value], index) =>
({
key: genId(),
name,
isPropertyRequired: drilldown.schema?.required?.includes(name) || false,
..._value,
extra: {
...(_value.extra || {}),
index,
},
} as PropertyValueType)
)
: [],
[isDrilldownObject]
);
@ -65,23 +71,25 @@ export function usePropertiesEdit(
nameMap.set(_property.name, _property);
}
}
return Object.entries(drilldown.schema?.properties || {}).map(([name, _value]) => {
const _property = nameMap.get(name);
if (_property) {
return Object.entries(drilldown.schema?.properties || {})
.sort(([, a], [, b]) => (a.extra?.index ?? 0) - (b.extra?.index ?? 0))
.map(([name, _value]) => {
const _property = nameMap.get(name);
if (_property) {
return {
key: _property.key,
name,
isPropertyRequired: drilldown.schema?.required?.includes(name) || false,
..._value,
};
}
return {
key: _property.key,
key: genId(),
name,
isPropertyRequired: drilldown.schema?.required?.includes(name) || false,
..._value,
};
}
return {
key: genId(),
name,
isPropertyRequired: drilldown.schema?.required?.includes(name) || false,
..._value,
};
});
});
});
}
mountRef.current = true;
@ -121,7 +129,10 @@ export function usePropertiesEdit(
};
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) => {

View File

@ -8,6 +8,8 @@ import { ArrayIcons, VariableTypeIcons, getSchemaIcon, options } from './constan
interface PropTypes {
value?: Partial<JsonSchema>;
onChange: (value?: Partial<JsonSchema>) => void;
disabled?: boolean;
style?: React.CSSProperties;
}
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) {
const { value, onChange } = props;
const { value, onChange, disabled, style } = props;
const selectValue = useMemo(() => getTypeSelectValue(value), [value]);
return (
<Cascader
disabled={disabled}
size="small"
triggerRender={() => (
<Button size="small" style={{ width: 50 }}>
<Button size="small" style={style}>
{getSchemaIcon(value)}
</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> {
type?: T;
default?: any;
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
};
}
export type BasicType = VarJSONSchema.BasicType;
export type JsonSchema = VarJSONSchema.ISchema;

View File

@ -1,5 +1,5 @@
{
"name": "variable-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 { UIRootTitle, UITag, UITreeSelect } from './styles';
export interface PropTypes {
value?: string;
config: {
interface PropTypes {
value?: string[];
config?: {
placeholder?: string;
notFoundContent?: string;
};
onChange: (value?: string) => void;
onChange: (value?: string[]) => void;
includeSchema?: VarJSONSchema.ISchema | VarJSONSchema.ISchema[];
excludeSchema?: VarJSONSchema.ISchema | VarJSONSchema.ISchema[];
readonly?: boolean;
hasError?: boolean;
style?: React.CSSProperties;
triggerRender?: (props: TriggerRenderProps) => React.ReactNode;
}
export type VariableSelectorProps = PropTypes;
export const VariableSelector = ({
value,
config,
onChange,
style,
readonly = false,
includeSchema,
excludeSchema,
hasError,
triggerRender,
}: 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 (
<>
<TreeSelect
<UITreeSelect
dropdownMatchSelectWidth={false}
disabled={readonly}
treeData={treeData}
size="small"
value={value}
style={{
...style,
outline: hasError ? '1px solid red' : undefined,
}}
value={treeValue}
clearIcon={null}
$error={hasError}
style={style}
validateStatus={hasError ? 'error' : undefined}
onChange={(option) => {
onChange(option as string);
onChange={(_, _config) => {
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...'}
/>
</>

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 { 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 { 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 }>;
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 getVariableTypeIcon = useCallback((variable: VariableField) => {
if (variable.meta.icon) {
if (variable.meta?.icon) {
if (typeof variable.meta.icon === 'string') {
return <img style={{ marginRight: 8 }} width={12} height={12} src={variable.meta.icon} />;
}
@ -44,6 +49,10 @@ export function useVariableTree(): TreeNodeData[] {
): TreeNodeData | null => {
let type = variable?.type;
if (!type) {
return null;
}
let children: TreeNodeData[] | undefined;
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 {
key: currPath,
label: variable.meta.title || variable.key,
value: currPath,
key: key,
label: variable.meta?.title || variable.key,
value: key,
keyPath,
icon: getVariableTypeIcon(variable),
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 './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,
StringType,
} from './type';
import { EnumerateExpression, ExpressionList, KeyPathExpression } from './expression';
import { EnumerateExpression, KeyPathExpression, WrapArrayExpression } from './expression';
import { Property, VariableDeclaration, VariableDeclarationList } from './declaration';
import { DataNode, MapNode } from './common';
import { ASTNode, ASTNodeRegistry } from './ast-node';
@ -43,7 +43,7 @@ export class ASTRegisters {
this.registerAST(VariableDeclarationList);
this.registerAST(KeyPathExpression);
this.registerAST(EnumerateExpression);
this.registerAST(ExpressionList);
this.registerAST(WrapArrayExpression);
this.registerAST(MapNode);
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 { ExpressionList, type ExpressionListJSON } from './expression-list';
export { KeyPathExpression, type KeyPathExpressionJSON } from './keypath-expression';
export { EnumerateExpression, type EnumerateExpressionJSON } from './enumerate-expression';
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 { MapJSON } from './type/map';
import { VarJSONSchema } from './type/json-schema';
import { ArrayJSON } from './type/array';
import { CustomTypeJSON, ObjectJSON, UnionJSON } from './type';
import { EnumerateExpressionJSON, KeyPathExpressionJSON } from './expression';
import {
EnumerateExpressionJSON,
KeyPathExpressionJSON,
WrapArrayExpressionJSON,
} from './expression';
import { PropertyJSON, VariableDeclarationJSON, VariableDeclarationListJSON } from './declaration';
import { ASTNode } from './ast-node';
@ -65,6 +72,78 @@ export namespace ASTFactory {
kind: ASTKind.KeyPathExpression,
...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

View File

@ -1,9 +1,11 @@
import { parseTypeJsonOrKind } from '../utils/helpers';
import { VarJSONSchema } from './json-schema';
import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { ASTNodeFlags } from '../flags';
import { BaseVariableField } from '../declaration';
import { ASTNode } from '../ast-node';
import { UnionJSON } from './union';
import { ASTFactory } from '../factory';
export abstract class BaseType<JSON extends ASTNodeJSON = any, InjectOpts = any> extends ASTNode<
JSON,
@ -12,7 +14,7 @@ export abstract class BaseType<JSON extends ASTNodeJSON = any, InjectOpts = any>
public flags: number = ASTNodeFlags.BasicType;
/**
* extraTypeInfoEqual
*
* @param targetTypeJSON
*/
public isTypeEqual(targetTypeJSONOrKind?: ASTNodeJSONOrKind): boolean {
@ -20,8 +22,8 @@ export abstract class BaseType<JSON extends ASTNodeJSON = any, InjectOpts = any>
// 如果是 Union 类型,有一个子类型保持相等即可
if (targetTypeJSON?.kind === ASTKind.Union) {
return ((targetTypeJSON as UnionJSON)?.types || [])?.some(_subType =>
this.isTypeEqual(_subType),
return ((targetTypeJSON as UnionJSON)?.types || [])?.some((_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}`);
}
/**
* Get AST JSON for current base type
* @returns
*/
toJSON(): ASTNodeJSON {
return {
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 { BaseType } from './base-type';
@ -7,4 +8,10 @@ export class BooleanType extends BaseType {
fromJSON(): void {
// noop
}
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: 'boolean',
};
}
}

View File

@ -1,4 +1,5 @@
import { parseTypeJsonOrKind } from '../utils/helpers';
import { VarJSONSchema } from './json-schema';
import { ASTKind, ASTNodeJSONOrKind } from '../types';
import { type UnionJSON } from './union';
import { BaseType } from './base-type';
@ -35,4 +36,10 @@ export class CustomType extends BaseType<CustomTypeJSON> {
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 { type UnionJSON } from './union';
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 { ASTNodeFlags } from '../flags';
import { BaseType } from './base-type';
@ -10,4 +11,10 @@ export class IntegerType extends BaseType {
fromJSON(): void {
// 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 { VarJSONSchema } from './json-schema';
import { ASTKind, ASTNodeJSON, ASTNodeJSONOrKind } from '../types';
import { BaseType } from './base-type';
@ -74,4 +75,11 @@ export class MapType extends BaseType<MapJSON> {
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 { BaseType } from './base-type';
@ -7,4 +8,10 @@ export class NumberType extends BaseType {
fromJSON(): void {
// noop
}
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: 'number',
};
}
}

View File

@ -1,6 +1,7 @@
import { xor } from 'lodash';
import { parseTypeJsonOrKind } from '../utils/helpers';
import { VarJSONSchema } from './json-schema';
import { ASTNodeJSON, ASTKind, ASTNodeJSONOrKind, type GlobalEventActionType } from '../types';
import { ASTNodeFlags } from '../flags';
import { Property, type PropertyJSON } from '../declaration/property';
@ -60,7 +61,7 @@ export class ObjectType extends BaseType<ObjectJSON> {
});
// 删除没有出现过的 property
removedKeys.forEach(key => {
removedKeys.forEach((key) => {
const property = this.propertyTable.get(key);
property?.dispose();
this.propertyTable.delete(key);
@ -79,7 +80,7 @@ export class ObjectType extends BaseType<ObjectJSON> {
toJSON(): ASTNodeJSON {
return {
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 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;
return (
isKeyStrongEqual &&
targetProperties.every(targetProperty => {
targetProperties.every((targetProperty) => {
const sourceProperty = this.propertyTable.get(targetProperty.key);
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 { ASTNodeFlags } from '../flags';
import { BaseType } from './base-type';
@ -10,4 +11,10 @@ export class StringType extends BaseType {
fromJSON(): void {
// noop
}
toJSONSchema(): VarJSONSchema.ISchema {
return {
type: 'string',
};
}
}

View File

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