Add flow switch editor

This commit is contained in:
Sipke Schoorstra 2022-07-07 22:08:02 +02:00
parent fb2a4cc041
commit 92bc89b10c
12 changed files with 247 additions and 19 deletions

View file

@ -40,6 +40,7 @@ services
.AddActivity<HttpEndpoint>()
.AddActivity<Flowchart>()
.AddActivity<FlowDecision>()
.AddActivity<FlowSwitch>()
.AddActivity<Elsa.Scheduling.Activities.Delay>()
.AddActivity<Elsa.Scheduling.Activities.Timer>()
.AddActivity<ForEach>()

View file

@ -83,6 +83,9 @@ export namespace Components {
interface ElsaDropdownInput {
"inputContext": ActivityInputContext;
}
interface ElsaFlowSwitchEditor {
"inputContext": ActivityInputContext;
}
interface ElsaFlowchart {
"addActivity": (args: AddActivityArgs) => Promise<Activity>;
"export": () => Promise<Activity>;
@ -347,6 +350,12 @@ declare global {
prototype: HTMLElsaDropdownInputElement;
new (): HTMLElsaDropdownInputElement;
};
interface HTMLElsaFlowSwitchEditorElement extends Components.ElsaFlowSwitchEditor, HTMLStencilElement {
}
var HTMLElsaFlowSwitchEditorElement: {
prototype: HTMLElsaFlowSwitchEditorElement;
new (): HTMLElsaFlowSwitchEditorElement;
};
interface HTMLElsaFlowchartElement extends Components.ElsaFlowchart, HTMLStencilElement {
}
var HTMLElsaFlowchartElement: {
@ -593,6 +602,7 @@ declare global {
"elsa-default-activity-template": HTMLElsaDefaultActivityTemplateElement;
"elsa-dropdown-button": HTMLElsaDropdownButtonElement;
"elsa-dropdown-input": HTMLElsaDropdownInputElement;
"elsa-flow-switch-editor": HTMLElsaFlowSwitchEditorElement;
"elsa-flowchart": HTMLElsaFlowchartElement;
"elsa-form-panel": HTMLElsaFormPanelElement;
"elsa-input-control-switch": HTMLElsaInputControlSwitchElement;
@ -683,6 +693,9 @@ declare namespace LocalJSX {
interface ElsaDropdownInput {
"inputContext"?: ActivityInputContext;
}
interface ElsaFlowSwitchEditor {
"inputContext"?: ActivityInputContext;
}
interface ElsaFlowchart {
"interactiveMode"?: boolean;
"onActivityDeleted"?: (event: CustomEvent<ActivityDeletedArgs>) => void;
@ -883,6 +896,7 @@ declare namespace LocalJSX {
"elsa-default-activity-template": ElsaDefaultActivityTemplate;
"elsa-dropdown-button": ElsaDropdownButton;
"elsa-dropdown-input": ElsaDropdownInput;
"elsa-flow-switch-editor": ElsaFlowSwitchEditor;
"elsa-flowchart": ElsaFlowchart;
"elsa-form-panel": ElsaFormPanel;
"elsa-input-control-switch": ElsaInputControlSwitch;
@ -939,6 +953,7 @@ declare module "@stencil/core" {
"elsa-default-activity-template": LocalJSX.ElsaDefaultActivityTemplate & JSXBase.HTMLAttributes<HTMLElsaDefaultActivityTemplateElement>;
"elsa-dropdown-button": LocalJSX.ElsaDropdownButton & JSXBase.HTMLAttributes<HTMLElsaDropdownButtonElement>;
"elsa-dropdown-input": LocalJSX.ElsaDropdownInput & JSXBase.HTMLAttributes<HTMLElsaDropdownInputElement>;
"elsa-flow-switch-editor": LocalJSX.ElsaFlowSwitchEditor & JSXBase.HTMLAttributes<HTMLElsaFlowSwitchEditorElement>;
"elsa-flowchart": LocalJSX.ElsaFlowchart & JSXBase.HTMLAttributes<HTMLElsaFlowchartElement>;
"elsa-form-panel": LocalJSX.ElsaFormPanel & JSXBase.HTMLAttributes<HTMLElsaFormPanelElement>;
"elsa-input-control-switch": LocalJSX.ElsaInputControlSwitch & JSXBase.HTMLAttributes<HTMLElsaInputControlSwitchElement>;

View file

@ -16,18 +16,13 @@ export class DefaultNodeHandler implements ActivityNodeHandler {
createDesignerNode(context: CreateUINodeContext): Node.Metadata {
const {activityDescriptor, activity, x, y} = context;
const provider = this.portProviderRegistry.get(activityDescriptor.activityType);
debugger;
const providerContext: PortProviderContext = {activityDescriptor, activity};
let inPorts = provider.getInboundPorts(providerContext).filter(x => x.mode == PortMode.Port);
const inPorts = [{name: 'In', displayName: null, mode: PortMode.Port}];
let outPorts = provider.getOutboundPorts(providerContext).filter(x => x.mode == PortMode.Port);
if (inPorts.length == 0)
inPorts = [{name: 'In', displayName: 'In', mode: PortMode.Port}];
if (inPorts.length == 1)
inPorts[0].displayName = null;
// In a flowchart, always add a Done port to connect the next node.
if(outPorts.length == 0)
if (outPorts.length == 0)
outPorts = [{name: 'Done', displayName: 'Done', mode: PortMode.Port}];
if (outPorts.length == 1)

View file

@ -0,0 +1,151 @@
import {Component, h, Prop, State, Watch} from "@stencil/core";
import {camelCase} from 'lodash';
import {ActivityInputContext} from "../../services/node-input-driver";
import {mapSyntaxToLanguage} from "../../utils";
import {SyntaxNames} from "../../models";
import {MonacoValueChangedArgs} from "../../components/shared/monaco-editor/monaco-editor";
import {TrashBinButtonIcon} from "../../components/icons/buttons/trash-bin";
import {PlusButtonIcon} from "../../components/icons/buttons/plus";
import {FlowSwitchCase} from "./models";
@Component({
tag: 'elsa-flow-switch-editor',
shadow: false
})
export class FlowSwitchEditor {
@Prop() inputContext: ActivityInputContext;
@State() private cases: Array<FlowSwitchCase> = [];
private supportedSyntaxes: Array<string> = [SyntaxNames.JavaScript, SyntaxNames.Literal];
@Watch('inputContext')
onInputContextChanged(value: ActivityInputContext){
this.updateCases();
}
componentWillLoad() {
this.updateCases();
}
private updateCases(){
const inputContext = this.inputContext;
const activity = this.inputContext.node;
const inputDescriptor = inputContext.inputDescriptor;
const propertyName = inputDescriptor.name;
const camelCasePropertyName = camelCase(propertyName);
this.cases = activity[camelCasePropertyName] || [];
}
private onAddCaseClick() {
const caseName = `Case ${this.cases.length + 1}`;
const newCase: FlowSwitchCase = {label: caseName, condition: {type: SyntaxNames.JavaScript, value: ''}};
this.cases = [...this.cases, newCase];
this.updateActivity();
}
private onDeleteCaseClick(switchCase: FlowSwitchCase) {
this.cases = this.cases.filter(x => x != switchCase);
this.updateActivity();
}
private onCaseLabelChanged(e: Event, switchCase: FlowSwitchCase) {
switchCase.label = (e.currentTarget as HTMLInputElement).value.trim();
this.updateActivity();
}
private onCaseExpressionChanged(e: CustomEvent<MonacoValueChangedArgs>, switchCase: FlowSwitchCase) {
switchCase.condition = {type: switchCase.condition.type, value: e.detail.value};
this.updateActivity();
}
private onCaseSyntaxChanged(e: Event, switchCase: FlowSwitchCase) {
const select = e.currentTarget as HTMLSelectElement;
const syntax = select.value;
switchCase.condition = {...switchCase.condition, type: syntax};
this.cases = [...this.cases];
this.updateActivity();
}
private updateActivity = () => {
const inputContext = this.inputContext;
const activity = this.inputContext.node;
const inputDescriptor = inputContext.inputDescriptor;
const propertyName = inputDescriptor.name;
const camelCasePropertyName = camelCase(propertyName);
activity[camelCasePropertyName] = this.cases;
this.inputContext.notifyInputChanged();
};
render() {
const inputContext = this.inputContext;
const inputDescriptor = inputContext.inputDescriptor;
const displayName = inputDescriptor.displayName;
const cases = this.cases;
const supportedSyntaxes = this.supportedSyntaxes;
return (
<div>
<div class="p-4">
<label>{displayName}</label>
</div>
<table class="mt-1">
<thead>
<tr>
<th class="w-3/12">Name</th>
<th class="w-8/12">Expression</th>
<th class="w-1/12">&nbsp;</th>
</tr>
</thead>
<tbody>
{cases.map((switchCase, index) => {
const condition = switchCase.condition;
const expression = condition.value;
const syntax = condition.type;
const language = mapSyntaxToLanguage(condition.type);
return (
<tr key={`case-${index}`}>
<td class="py-2 pr-5">
<input type="text" value={switchCase.label} onChange={e => this.onCaseLabelChanged(e, switchCase)}/>
</td>
<td class="py-2 pl-5">
<div class="mt-1 relative rounded-md shadow-sm h-full">
<elsa-monaco-editor
key={`monaco-editor-${index}`}
value={expression}
language={language}
singleLineMode={true}
editorHeight="2.75em"
padding="pt-1.5 pl-1 pr-28"
onValueChanged={e => this.onCaseExpressionChanged(e, switchCase)}
/>
<div class="absolute inset-y-0 right-0 flex items-center">
<select onChange={e => this.onCaseSyntaxChanged(e, switchCase)} class="focus:ring-blue-500 focus:border-blue-500 h-full py-0 pl-2 pr-7 border-transparent bg-transparent text-gray-500 sm:text-sm rounded-md">
{supportedSyntaxes.map(supportedSyntax => {
const selected = supportedSyntax == syntax;
return <option selected={selected}>{supportedSyntax}</option>;
})}
</select>
</div>
</div>
</td>
<td>
<button type="button" onClick={() => this.onDeleteCaseClick(switchCase)} class="icon-button">
<TrashBinButtonIcon/>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<div class="p-4">
<button type="button" onClick={() => this.onAddCaseClick()} class="btn">
<PlusButtonIcon/>
Add Case
</button>
</div>
</div>
);
}
}

View file

@ -0,0 +1,34 @@
import 'reflect-metadata';
import {FunctionalComponent, h} from '@stencil/core';
import {Container, Service} from "typedi";
import {ActivityIconRegistry, InputControlRegistry, PortProviderRegistry} from "../../services";
import {Plugin} from "../../models";
import {FlowSwitchPortProvider} from "./flow-switch-port-provider";
import {ActivityIconSettings, getActivityIconCssClass} from "../../components/icons/activities";
@Service()
export class FlowSwitchPlugin implements Plugin {
static readonly ActivityTypeName: string = 'Elsa.FlowSwitch';
constructor() {
const activityTypeName = FlowSwitchPlugin.ActivityTypeName;
const inputControlRegistry = Container.get(InputControlRegistry);
const portProviderRegistry = Container.get(PortProviderRegistry);
const iconRegistry = Container.get(ActivityIconRegistry);
inputControlRegistry.add('flow-switch-editor', c => <elsa-flow-switch-editor inputContext={c}/>);
portProviderRegistry.add(activityTypeName, () => Container.get(FlowSwitchPortProvider));
iconRegistry.add(FlowSwitchPlugin.ActivityTypeName, settings => <FlowSwitchIcon size={settings?.size}/>);
}
}
const FlowSwitchIcon: FunctionalComponent<ActivityIconSettings> = (settings) => (
<svg class={getActivityIconCssClass(settings)} width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z"/>
<polyline points="15 4 19 4 19 8"/>
<line x1="14.75" y1="9.25" x2="19" y2="4"/>
<line x1="5" y1="19" x2="9" y2="15"/>
<polyline points="15 19 19 19 19 15"/>
<line x1="5" y1="5" x2="19" y2="19"/>
</svg>
);

View file

@ -0,0 +1,29 @@
import 'reflect-metadata';
import {Service} from "typedi";
import {Activity, Port, PortMode} from "../../models";
import {FlowSwitchActivity} from "./models";
import {PortProvider, PortProviderContext} from "../../services";
@Service()
export class FlowSwitchPortProvider implements PortProvider {
getOutboundPorts(context: PortProviderContext): Array<Port> {
const activity = context.activity as FlowSwitchActivity;
if (activity == null)
return [];
const cases = activity.cases ?? [];
return cases.map(x => ({name: x.label, displayName: x.label, mode: PortMode.Port}));
}
resolvePort(portName: string, context: PortProviderContext): Activity | Array<Activity> {
debugger;
return null;
}
assignPort(portName: string, activity: Activity, context: PortProviderContext) {
debugger;
return null;
}
}

View file

@ -0,0 +1,10 @@
import {Activity, Expression} from "../../models";
export interface FlowSwitchCase {
label: string;
condition: Expression;
}
export interface FlowSwitchActivity extends Activity {
cases: Array<FlowSwitchCase>;
}

View file

@ -8,10 +8,6 @@ import {PortProvider, PortProviderContext} from "../../services";
@Service()
export class SwitchPortProvider implements PortProvider {
getInboundPorts(context: PortProviderContext): Array<Port> {
return [];
}
getOutboundPorts(context: PortProviderContext): Array<Port> {
const activity = context.activity as SwitchActivity;

View file

@ -6,10 +6,6 @@ import {Activity, Port} from "../models";
@Service()
export class DefaultPortProvider implements PortProvider {
getInboundPorts(context: PortProviderContext): Array<Port> {
return [];
}
getOutboundPorts(context: PortProviderContext): Array<Port> {
const {activityDescriptor} = context;
return [...activityDescriptor.ports];

View file

@ -3,6 +3,7 @@ import {Container, Service} from "typedi";
import {SwitchPlugin} from "../plugins/switch/switch-plugin";
import {Plugin} from "../models";
import {SequencePlugin} from "../plugins/sequence/sequence-plugin";
import {FlowSwitchPlugin} from "../plugins/flow-switch/flow-switch-plugin";
// A registry of plugins.
@Service()
@ -12,6 +13,7 @@ export class PluginRegistry {
constructor() {
this.add(Container.get(SequencePlugin));
this.add(Container.get(SwitchPlugin));
this.add(Container.get(FlowSwitchPlugin));
}
public add(plugin: Plugin) {

View file

@ -1,8 +1,6 @@
import {Activity, ActivityDescriptor, Port} from "../models";
export interface PortProvider {
getInboundPorts(context: PortProviderContext): Array<Port>;
getOutboundPorts(context: PortProviderContext): Array<Port>;
resolvePort(portName: string, context: PortProviderContext): Activity | Array<Activity>;

View file

@ -3,6 +3,7 @@ using Elsa.Expressions;
using Elsa.Expressions.Models;
using Elsa.Expressions.Services;
using Elsa.Workflows.Core.Activities.Flowchart.Attributes;
using Elsa.Workflows.Core.Activities.Flowchart.Models;
using Elsa.Workflows.Core.Attributes;
using Elsa.Workflows.Core.Models;
@ -19,7 +20,7 @@ public class FlowSwitch : ActivityBase
var matchingCase = await FindMatchingCaseAsync(context.ExpressionExecutionContext);
var outcome = matchingCase?.Label ?? "Default";
await context.CompleteActivityAsync(outcome);
await context.CompleteActivityAsync(new Outcome(outcome));
}
private async Task<FlowSwitchCase?> FindMatchingCaseAsync(ExpressionExecutionContext context)