diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala
index 4e9d6c6e2cd..6bdb066cfaa 100644
--- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala
+++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/LogicalOp.scala
@@ -439,6 +439,9 @@ abstract class LogicalOp extends PortDescriptor with Serializable {
@JsonProperty(PropertyNameConstants.OPERATOR_VERSION)
var operatorVersion: String = getOperatorVersion
+ @JsonProperty(PropertyNameConstants.MACRO_ID_PARENT)
+ var macroIdParent: String = _
+
def operatorIdentifier: OperatorIdentity = OperatorIdentity(operatorId)
def getPhysicalOp(
diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/OperatorMetadataGenerator.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/OperatorMetadataGenerator.scala
index fdfcbcf27dc..2ccfc1a05b6 100644
--- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/OperatorMetadataGenerator.scala
+++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/OperatorMetadataGenerator.scala
@@ -118,26 +118,24 @@ object OperatorMetadataGenerator {
def generateOperatorJsonSchema(opDescClass: Class[_ <: LogicalOp]): JsonNode = {
val jsonSchema = jsonSchemaGenerator.generateJsonSchema(opDescClass).asInstanceOf[ObjectNode]
- // remove operatorID from json schema
- jsonSchema.get("properties").asInstanceOf[ObjectNode].remove("operatorID")
- // remove operatorId from json schema
- jsonSchema.get("properties").asInstanceOf[ObjectNode].remove("operatorId")
- // remove operatorType from json schema
- jsonSchema.get("properties").asInstanceOf[ObjectNode].remove("operatorType")
- // remove operatorVersion from json schema
- jsonSchema.get("properties").asInstanceOf[ObjectNode].remove("operatorVersion")
- // remove inputPorts/outputPorts from json schema
- jsonSchema.get("properties").asInstanceOf[ObjectNode].remove("inputPorts")
- jsonSchema.get("properties").asInstanceOf[ObjectNode].remove("outputPorts")
- // remove operatorType from required list
- val operatorTypeIndex =
- jsonSchema
- .get("required")
- .asInstanceOf[ArrayNode]
- .elements()
- .asScala
- .indexWhere(p => p.asText().equals("operatorType"))
- jsonSchema.get("required").asInstanceOf[ArrayNode].remove(operatorTypeIndex)
+ val hiddenProperties = Seq(
+ "operatorID",
+ "operatorId",
+ "operatorType",
+ "operatorVersion",
+ "macroIdParent",
+ "inputPorts",
+ "outputPorts"
+ )
+ val properties = jsonSchema.get("properties").asInstanceOf[ObjectNode]
+ val required = jsonSchema.get("required").asInstanceOf[ArrayNode]
+ hiddenProperties.foreach { propertyName =>
+ properties.remove(propertyName)
+ val requiredIndex = required.elements().asScala.indexWhere(_.asText() == propertyName)
+ if (requiredIndex >= 0) {
+ required.remove(requiredIndex)
+ }
+ }
// remove "title" for the operator - frontend uses userFriendlyName to show operator title
jsonSchema.remove("title")
jsonSchema
diff --git a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/PropertyNameConstants.scala b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/PropertyNameConstants.scala
index 52bb0414692..20207f654a3 100644
--- a/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/PropertyNameConstants.scala
+++ b/common/workflow-operator/src/main/scala/org/apache/texera/amber/operator/metadata/PropertyNameConstants.scala
@@ -33,6 +33,7 @@ object PropertyNameConstants { // logical plan property names
final val OPERATOR_LIST = "operators"
final val OPERATOR_LINK_LIST = "links"
final val OPERATOR_VERSION = "operatorVersion"
+ final val MACRO_ID_PARENT = "macroIdParent"
// common operator property names
final val ATTRIBUTE_NAMES = "attributes"
final val ATTRIBUTE_NAME = "attribute"
diff --git a/frontend/src/app/common/type/workflow.ts b/frontend/src/app/common/type/workflow.ts
index 8e1c1c7e85b..1587d6cd5c2 100644
--- a/frontend/src/app/common/type/workflow.ts
+++ b/frontend/src/app/common/type/workflow.ts
@@ -18,7 +18,13 @@
*/
import { WorkflowMetadata } from "../../dashboard/type/workflow-metadata.interface";
-import { CommentBox, OperatorLink, OperatorPredicate, Point } from "../../workspace/types/workflow-common.interface";
+import {
+ CommentBox,
+ OperatorLink,
+ OperatorPredicate,
+ Point,
+ WorkflowMacro,
+} from "../../workspace/types/workflow-common.interface";
export enum ExecutionMode {
PIPELINED = "PIPELINED",
@@ -49,6 +55,7 @@ export interface WorkflowContent
links: OperatorLink[];
commentBoxes: CommentBox[];
settings: WorkflowSettings;
+ macros?: WorkflowMacro[];
}> {}
export type Workflow = { content: WorkflowContent } & WorkflowMetadata;
diff --git a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.ts b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.ts
index ed172ae5d13..0abc94e093f 100644
--- a/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.ts
+++ b/frontend/src/app/workspace/component/left-panel/operator-menu/operator-menu.component.ts
@@ -36,6 +36,8 @@ import { FormsModule } from "@angular/forms";
import { NgFor, NgTemplateOutlet } from "@angular/common";
import { OperatorLabelComponent } from "./operator-label/operator-label.component";
import { NzCollapseComponent, NzCollapsePanelComponent } from "ng-zorro-antd/collapse";
+import { WORKFLOW_MACRO_OPERATOR_TYPE } from "../../../service/operator-metadata/operator-metadata.service";
+import { JointGraphWrapper } from "../../../service/workflow-graph/model/joint-graph-wrapper";
@UntilDestroy()
@Component({
@@ -138,6 +140,12 @@ export class OperatorMenuComponent {
// add the operator to the graph on select (position relative to the current viewpoint)
const origin = this.workflowActionService.getJointGraphWrapper().getMainJointPaper()?.translate();
const point = { x: 400 - (origin?.tx ?? 0), y: 200 - (origin?.ty ?? 0) };
+ if (selectSchema.operatorType === WORKFLOW_MACRO_OPERATOR_TYPE) {
+ const macroID = this.workflowActionService.createMacroAt(point);
+ this.workflowActionService.getJointGraphWrapper().highlightOperators(JointGraphWrapper.getMacroNodeID(macroID));
+ this.clearSearch();
+ return;
+ }
this.workflowActionService.addOperator(
this.workflowUtilService.getNewOperatorPredicate(selectSchema.operatorType),
point
@@ -145,6 +153,10 @@ export class OperatorMenuComponent {
// asynchronously immediately clear the search input and suggestions
// because ng-zorro shows the selected value if it's synchronously
+ this.clearSearch();
+ }
+
+ private clearSearch(): void {
setTimeout(() => {
this.searchInputValue = "";
this.autocompleteOptions = [];
diff --git a/frontend/src/app/workspace/component/property-editor/macro-property-edit-frame/macro-property-edit-frame.component.html b/frontend/src/app/workspace/component/property-editor/macro-property-edit-frame/macro-property-edit-frame.component.html
new file mode 100644
index 00000000000..68b2a3908bb
--- /dev/null
+++ b/frontend/src/app/workspace/component/property-editor/macro-property-edit-frame/macro-property-edit-frame.component.html
@@ -0,0 +1,54 @@
+
+
+
Workflow Macro
+
+
+
+
+
+
+
+
+ #{{ macro?.workflowId }} {{ macro?.workflowName }}
+
+
+
+
+
diff --git a/frontend/src/app/workspace/component/property-editor/macro-property-edit-frame/macro-property-edit-frame.component.scss b/frontend/src/app/workspace/component/property-editor/macro-property-edit-frame/macro-property-edit-frame.component.scss
new file mode 100644
index 00000000000..5684286b239
--- /dev/null
+++ b/frontend/src/app/workspace/component/property-editor/macro-property-edit-frame/macro-property-edit-frame.component.scss
@@ -0,0 +1,42 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+.macro-field-label {
+ display: block;
+ margin-bottom: 6px;
+ font-weight: 600;
+}
+
+nz-select {
+ width: 100%;
+}
+
+.macro-selected-workflow {
+ margin-top: 10px;
+ color: #666;
+ font-size: 12px;
+}
+
+.macro-actions {
+ margin-top: 14px;
+}
+
+.macro-actions button {
+ width: 100%;
+}
diff --git a/frontend/src/app/workspace/component/property-editor/macro-property-edit-frame/macro-property-edit-frame.component.ts b/frontend/src/app/workspace/component/property-editor/macro-property-edit-frame/macro-property-edit-frame.component.ts
new file mode 100644
index 00000000000..35684a530b0
--- /dev/null
+++ b/frontend/src/app/workspace/component/property-editor/macro-property-edit-frame/macro-property-edit-frame.component.ts
@@ -0,0 +1,107 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component, Input, OnChanges, OnDestroy, OnInit } from "@angular/core";
+import { FormsModule } from "@angular/forms";
+import { NgFor, NgIf } from "@angular/common";
+import { NzOptionComponent, NzSelectComponent } from "ng-zorro-antd/select";
+import { NzButtonComponent } from "ng-zorro-antd/button";
+import { NzIconDirective } from "ng-zorro-antd/icon";
+import { Subject } from "rxjs";
+import { finalize, take, takeUntil } from "rxjs/operators";
+import { WorkflowPersistService } from "../../../../common/service/workflow-persist/workflow-persist.service";
+import { DashboardWorkflow } from "../../../../dashboard/type/dashboard-workflow.interface";
+import { NotificationService } from "../../../../common/service/notification/notification.service";
+import { WorkflowActionService } from "../../../service/workflow-graph/model/workflow-action.service";
+import { JointGraphWrapper } from "../../../service/workflow-graph/model/joint-graph-wrapper";
+import { WorkflowMacro } from "../../../types/workflow-common.interface";
+
+@Component({
+ selector: "texera-macro-property-edit-frame",
+ templateUrl: "./macro-property-edit-frame.component.html",
+ styleUrls: ["./macro-property-edit-frame.component.scss"],
+ imports: [FormsModule, NgFor, NgIf, NzSelectComponent, NzOptionComponent, NzButtonComponent, NzIconDirective],
+})
+export class MacroPropertyEditFrameComponent implements OnInit, OnChanges, OnDestroy {
+ @Input() macroNodeId?: string;
+
+ public workflows: DashboardWorkflow[] = [];
+ public selectedWorkflowId?: number;
+ public loading = false;
+ public canModify = true;
+ private macroID?: string;
+ private destroy$ = new Subject();
+
+ constructor(
+ private workflowPersistService: WorkflowPersistService,
+ private workflowActionService: WorkflowActionService,
+ private notificationService: NotificationService
+ ) {}
+
+ ngOnInit(): void {
+ this.canModify = this.workflowActionService.checkWorkflowModificationEnabled();
+ this.workflowActionService
+ .getWorkflowModificationEnabledStream()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(canModify => (this.canModify = canModify));
+
+ this.workflowPersistService
+ .retrieveWorkflowsBySessionUser()
+ .pipe(take(1))
+ .subscribe(workflows => (this.workflows = workflows));
+ }
+
+ ngOnChanges(): void {
+ if (!this.macroNodeId) return;
+ this.macroID = JointGraphWrapper.getMacroIDFromNodeID(this.macroNodeId);
+ this.selectedWorkflowId = this.macro?.workflowId;
+ }
+
+ public get macro(): WorkflowMacro | undefined {
+ return this.macroID ? this.workflowActionService.getWorkflowMacro(this.macroID) : undefined;
+ }
+
+ public onWorkflowChange(workflowId: number | undefined): void {
+ if (!this.canModify || !this.macroID || workflowId === undefined) return;
+ this.loading = true;
+ this.workflowPersistService
+ .retrieveWorkflow(workflowId)
+ .pipe(
+ take(1),
+ finalize(() => (this.loading = false))
+ )
+ .subscribe({
+ next: workflow => {
+ this.workflowActionService.replaceMacroWorkflow(this.macroID!, workflow.content, workflow.wid, workflow.name);
+ this.notificationService.info(`Imported workflow "${workflow.name}" into macro.`);
+ },
+ error: () => this.notificationService.error("Failed to import workflow into macro."),
+ });
+ }
+
+ public toggleCollapsed(): void {
+ if (!this.canModify || !this.macroID || !this.macro) return;
+ this.workflowActionService.setMacroCollapsed(this.macroID, !(this.macro.collapsed ?? false));
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+}
diff --git a/frontend/src/app/workspace/component/property-editor/property-editor.component.ts b/frontend/src/app/workspace/component/property-editor/property-editor.component.ts
index c868151f270..1335390d6e2 100644
--- a/frontend/src/app/workspace/component/property-editor/property-editor.component.ts
+++ b/frontend/src/app/workspace/component/property-editor/property-editor.component.ts
@@ -45,6 +45,8 @@ import { CdkDrag, CdkDragHandle } from "@angular/cdk/drag-drop";
import { NzSpaceCompactItemDirective } from "ng-zorro-antd/space";
import { NzButtonComponent } from "ng-zorro-antd/button";
import { FormlyRepeatDndComponent } from "../../../common/formly/repeat-dnd/repeat-dnd.component";
+import { JointGraphWrapper } from "../../service/workflow-graph/model/joint-graph-wrapper";
+import { MacroPropertyEditFrameComponent } from "./macro-property-edit-frame/macro-property-edit-frame.component";
/**
* PropertyEditorComponent is the panel that allows user to edit operator properties.
@@ -74,6 +76,7 @@ import { FormlyRepeatDndComponent } from "../../../common/formly/repeat-dnd/repe
NgComponentOutlet,
NzResizeHandlesComponent,
FormlyRepeatDndComponent,
+ MacroPropertyEditFrameComponent,
],
})
export class PropertyEditorComponent implements OnInit, OnDestroy {
@@ -165,7 +168,15 @@ export class PropertyEditorComponent implements OnInit, OnDestroy {
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedCommentBoxIDs();
const highlightedPorts = this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedPortIDs();
- if (highlightedOperators.length === 1 && highlightLinks.length === 0 && highlightedPorts.length === 0) {
+ if (
+ highlightedOperators.length === 1 &&
+ highlightLinks.length === 0 &&
+ highlightedPorts.length === 0 &&
+ JointGraphWrapper.isMacroNodeID(highlightedOperators[0])
+ ) {
+ this.currentComponent = MacroPropertyEditFrameComponent;
+ this.componentInputs = { macroNodeId: highlightedOperators[0] };
+ } else if (highlightedOperators.length === 1 && highlightLinks.length === 0 && highlightedPorts.length === 0) {
this.currentComponent = OperatorPropertyEditFrameComponent;
this.componentInputs = { currentOperatorId: highlightedOperators[0] };
} else if (highlightedPorts.length === 1 && highlightLinks.length === 0) {
diff --git a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html
index 4465d65cb27..6d50e8232d8 100644
--- a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html
+++ b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.html
@@ -30,6 +30,20 @@
nzTheme="outline">copy
+ 0 &&
+ highlightedCommentBoxIds.length === 0 &&
+ !hasHighlightedLinks() &&
+ isWorkflowModifiable &&
+ operatorMenuService.canRemoveHighlightedOperatorsFromMacro()"
+ (click)="operatorMenuService.removeHighlightedOperatorsFromMacro()">
+
+ remove from macro
+
0 ||
@@ -45,9 +59,10 @@
0 ||
- highlightedCommentBoxIds.length > 0) &&
+ highlightedCommentBoxIds.length > 0 ||
+ hasHighlightedMacroNode()) &&
isWorkflowModifiable"
(click)="onDelete()">
{
getJointGraphWrapper: vi.fn(),
getWorkflowModificationEnabledStream: vi.fn(),
deleteOperatorsAndLinks: vi.fn(),
+ deleteMacros: vi.fn(),
deleteCommentBox: vi.fn(),
getWorkflowMetadata: vi.fn(),
getTexeraGraph: vi.fn(),
@@ -74,6 +75,7 @@ describe("ContextMenuComponent", () => {
workflowActionServiceSpy.getWorkflowModificationEnabledStream.mockReturnValue(of(true));
workflowActionServiceSpy.getTexeraGraph.mockReturnValue(texeraGraphSpy);
workflowActionServiceSpy.deleteOperatorsAndLinks.mockReturnValue(undefined);
+ workflowActionServiceSpy.deleteMacros.mockReturnValue(undefined);
workflowActionServiceSpy.deleteCommentBox.mockReturnValue(undefined);
workflowActionServiceSpy.deleteLinkWithID.mockReturnValue(undefined);
workflowActionServiceSpy.getWorkflowMetadata.mockReturnValue({ name: "Test Workflow" }); // Mock return value
@@ -101,6 +103,8 @@ describe("ContextMenuComponent", () => {
viewResultHighlightedOperators: vi.fn(),
reuseResultHighlightedOperator: vi.fn(),
executeUpToOperator: vi.fn(),
+ canRemoveHighlightedOperatorsFromMacro: vi.fn().mockReturnValue(false),
+ removeHighlightedOperatorsFromMacro: vi.fn(),
} as unknown as Mocked;
const validationWorkflowServiceSpy = { validateOperator: vi.fn() };
diff --git a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
index 019f77f7afa..5839fe76e59 100644
--- a/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
+++ b/frontend/src/app/workspace/component/workflow-editor/context-menu/context-menu/context-menu.component.ts
@@ -31,6 +31,7 @@ import { NzMenuDirective, NzMenuItemComponent } from "ng-zorro-antd/menu";
import { NgIf } from "@angular/common";
import { ɵNzTransitionPatchDirective } from "ng-zorro-antd/core/transition-patch";
import { NzIconDirective } from "ng-zorro-antd/icon";
+import { JointGraphWrapper } from "src/app/workspace/service/workflow-graph/model/joint-graph-wrapper";
@UntilDestroy()
@Component({
@@ -90,6 +91,13 @@ export class ContextMenuComponent {
return this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedLinkIDs().length > 0;
}
+ public hasHighlightedMacroNode(): boolean {
+ return this.workflowActionService
+ .getJointGraphWrapper()
+ .getCurrentHighlightedOperatorIDs()
+ .some(operatorID => JointGraphWrapper.isMacroNodeID(operatorID));
+ }
+
public onCopy(): void {
this.operatorMenuService.saveHighlightedElements();
}
@@ -107,7 +115,12 @@ export class ContextMenuComponent {
// Capture all highlighted IDs before starting deletion to avoid modification during iteration
const highlightedOperatorIDs = Array.from(
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()
- );
+ ).filter(operatorID => !JointGraphWrapper.isMacroNodeID(operatorID));
+ const highlightedMacroIDs = Array.from(
+ this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()
+ )
+ .filter(operatorID => JointGraphWrapper.isMacroNodeID(operatorID))
+ .map(operatorID => JointGraphWrapper.getMacroIDFromNodeID(operatorID));
const highlightedCommentBoxIDs = Array.from(
this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedCommentBoxIDs()
);
@@ -117,6 +130,8 @@ export class ContextMenuComponent {
// Bundle all deletions together for proper undo/redo support
this.workflowActionService.getTexeraGraph().bundleActions(() => {
+ this.workflowActionService.deleteMacros(highlightedMacroIDs);
+
// Delete operators and their connected links
this.workflowActionService.deleteOperatorsAndLinks(highlightedOperatorIDs);
diff --git a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
index 979f131ad3c..92d98d95d05 100644
--- a/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
+++ b/frontend/src/app/workspace/component/workflow-editor/workflow-editor.component.ts
@@ -27,8 +27,9 @@ import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-w
import { fromJointPaperEvent, JointUIService, linkPathStrokeColor } from "../../service/joint-ui/joint-ui.service";
import { ValidationWorkflowService } from "../../service/validation/validation-workflow.service";
import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service";
+import { JointGraphWrapper } from "../../service/workflow-graph/model/joint-graph-wrapper";
import { WorkflowStatusService } from "../../service/workflow-status/workflow-status.service";
-import { ExecutionState, OperatorState } from "../../types/execute-workflow.interface";
+import { ExecutionState, OperatorState, OperatorStatistics } from "../../types/execute-workflow.interface";
import { LogicalPort, OperatorLink, OperatorPredicate } from "../../types/workflow-common.interface";
import { auditTime, filter, map, takeUntil, withLatestFrom } from "rxjs/operators";
import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
@@ -333,6 +334,7 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
this.isSink(op.operatorID)
);
});
+ this.updateCollapsedMacroStates(status);
});
this.executeWorkflowService
@@ -358,6 +360,43 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
});
}
});
+
+ this.workflowActionService
+ .getMacroVisualRefreshStream()
+ .pipe(untilDestroyed(this))
+ .subscribe(() => this.updateCollapsedMacroStates(this.workflowStatusService.getCurrentStatus()));
+ }
+
+ private updateCollapsedMacroStates(status: Record): void {
+ if (!this.paper) return;
+ const operators = this.workflowActionService.getTexeraGraph().getAllOperators();
+ this.workflowActionService
+ .getWorkflowMacros()
+ .filter(macro => macro.collapsed)
+ .forEach(macro => {
+ const internalStates = operators
+ .filter(operator => operator.macroIdParent === macro.macroID)
+ .map(operator => status[operator.operatorID]?.operatorState);
+ const macroNodeID = JointGraphWrapper.getMacroNodeID(macro.macroID);
+ if (this.paper.getModelById(macroNodeID)) {
+ this.jointUIService.changeOperatorState(this.paper, macroNodeID, this.getMacroAggregateState(internalStates));
+ }
+ });
+ }
+
+ private getMacroAggregateState(states: readonly (OperatorState | undefined)[]): OperatorState {
+ const knownStates = states.filter((state): state is OperatorState => state !== undefined);
+ if (!knownStates.length) return OperatorState.Uninitialized;
+ if (knownStates.includes(OperatorState.Running)) return OperatorState.Running;
+ if (knownStates.includes(OperatorState.Paused)) return OperatorState.Paused;
+ if (knownStates.includes(OperatorState.Pausing)) return OperatorState.Pausing;
+ if (knownStates.includes(OperatorState.Recovering)) return OperatorState.Recovering;
+ if (knownStates.includes(OperatorState.Resuming)) return OperatorState.Resuming;
+ if (knownStates.includes(OperatorState.Initializing)) return OperatorState.Initializing;
+ if (knownStates.includes(OperatorState.Ready)) return OperatorState.Ready;
+ return knownStates.every(state => state === OperatorState.Completed)
+ ? OperatorState.Completed
+ : OperatorState.Uninitialized;
}
private handleRegionEvents(): void {
@@ -592,7 +631,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
filter(
event =>
this.workflowActionService.getTexeraGraph().hasOperator(event[0].model.id.toString()) ||
- this.workflowActionService.getTexeraGraph().hasCommentBox(event[0].model.id.toString())
+ this.workflowActionService.getTexeraGraph().hasCommentBox(event[0].model.id.toString()) ||
+ JointGraphWrapper.isMacroNodeID(event[0].model.id.toString())
)
)
.pipe(untilDestroyed(this))
@@ -642,6 +682,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
// else only highlight a single operator or group
if (this.workflowActionService.getTexeraGraph().hasOperator(elementID)) {
this.workflowActionService.highlightOperators(event[1].shiftKey, elementID);
+ } else if (JointGraphWrapper.isMacroNodeID(elementID)) {
+ this.wrapper.highlightOperators(elementID);
} else if (this.workflowActionService.getTexeraGraph().hasCommentBox(elementID)) {
this.wrapper.highlightCommentBoxes(elementID);
}
@@ -678,7 +720,12 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
.pipe(untilDestroyed(this))
.subscribe(elementIDs =>
elementIDs.forEach(elementID => {
- this.paper.findViewByModel(elementID).highlight("rect.body", { highlighter: highlightOptions });
+ const elementView = this.paper.findViewByModel(elementID);
+ if (JointGraphWrapper.isMacroNodeID(elementID)) {
+ elementView?.$el.children(".joint-highlight-stroke").remove();
+ } else {
+ elementView.highlight("rect.body", { highlighter: highlightOptions });
+ }
})
);
@@ -911,7 +958,10 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
// Handle right-click on links
fromJointPaperEvent(this.paper, "link:contextmenu")
- .pipe(untilDestroyed(this))
+ .pipe(
+ filter(event => !JointGraphWrapper.isMacroProxyLinkID(event[0].model.id.toString())),
+ untilDestroyed(this)
+ )
.subscribe(event => {
const linkID = event[0].model.id.toString();
// Highlight the link when right-clicked
@@ -940,7 +990,8 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
fromJointPaperEvent(this.paper, "tool:remove")
.pipe(
filter(() => this.interactive),
- map(value => value[0])
+ map(value => value[0]),
+ filter(elementView => !JointGraphWrapper.isMacroProxyLinkID(elementView.model.id.toString()))
)
.pipe(untilDestroyed(this))
.subscribe(elementView => {
@@ -1063,12 +1114,19 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
private deleteElements(): void {
// Capture all highlighted IDs before starting deletion to avoid modification during iteration
- const highlightedOperatorIDs = Array.from(this.wrapper.getCurrentHighlightedOperatorIDs());
+ const highlightedOperatorIDs = Array.from(this.wrapper.getCurrentHighlightedOperatorIDs()).filter(
+ operatorID => !JointGraphWrapper.isMacroNodeID(operatorID)
+ );
+ const highlightedMacroIDs = Array.from(this.wrapper.getCurrentHighlightedOperatorIDs())
+ .filter(operatorID => JointGraphWrapper.isMacroNodeID(operatorID))
+ .map(operatorID => JointGraphWrapper.getMacroIDFromNodeID(operatorID));
const highlightedCommentBoxIDs = Array.from(this.wrapper.getCurrentHighlightedCommentBoxIDs());
const highlightedLinkIDs = Array.from(this.wrapper.getCurrentHighlightedLinkIDs());
// Bundle all deletions together for proper undo/redo support
this.workflowActionService.getTexeraGraph().bundleActions(() => {
+ this.workflowActionService.deleteMacros(highlightedMacroIDs);
+
// Delete operators and their connected links
this.workflowActionService.deleteOperatorsAndLinks(highlightedOperatorIDs);
@@ -1187,7 +1245,10 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
private handleLinkCursorHover(): void {
// When the cursor hovers over a link, the delete button and the breakpoint button appear
fromJointPaperEvent(this.paper, "link:mouseenter")
- .pipe(map(value => value[0]))
+ .pipe(
+ map(value => value[0]),
+ filter(linkView => !JointGraphWrapper.isMacroProxyLinkID(linkView.model.id.toString()))
+ )
.pipe(untilDestroyed(this))
.subscribe(linkView => {
// Create an array to hold the tools
@@ -1208,7 +1269,10 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
* otherwise, the breakpoint button is not changed.
*/
fromJointPaperEvent(this.paper, "link:mouseleave")
- .pipe(map(value => value[0]))
+ .pipe(
+ map(value => value[0]),
+ filter(elementView => !JointGraphWrapper.isMacroProxyLinkID(elementView.model.id.toString()))
+ )
.pipe(untilDestroyed(this))
.subscribe(elementView => {
// ensure that the link element exists
@@ -1238,7 +1302,11 @@ export class WorkflowEditorComponent implements OnInit, AfterViewInit, OnDestroy
private handleLinkBreakpointToolAttachment(): void {
this.wrapper
.getJointLinkCellAddStream()
- .pipe(this.wrapper.jointGraphContext.bufferWhileAsync, untilDestroyed(this))
+ .pipe(
+ filter(link => !JointGraphWrapper.isMacroProxyLinkID(link.id.toString())),
+ this.wrapper.jointGraphContext.bufferWhileAsync,
+ untilDestroyed(this)
+ )
.subscribe(link => {
const linkView = link.findView(this.paper);
const breakpointButtonTool = this.breakpointButton;
diff --git a/frontend/src/app/workspace/service/drag-drop/drag-drop.service.ts b/frontend/src/app/workspace/service/drag-drop/drag-drop.service.ts
index e7665582ba1..cfa7c66186b 100644
--- a/frontend/src/app/workspace/service/drag-drop/drag-drop.service.ts
+++ b/frontend/src/app/workspace/service/drag-drop/drag-drop.service.ts
@@ -26,6 +26,8 @@ import { Injectable } from "@angular/core";
import { filter, first, map } from "rxjs/operators";
import TinyQueue from "tinyqueue";
import * as joint from "jointjs";
+import { WORKFLOW_MACRO_OPERATOR_TYPE } from "../operator-metadata/operator-metadata.service";
+import { JointGraphWrapper } from "../workflow-graph/model/joint-graph-wrapper";
@Injectable({
providedIn: "root",
@@ -63,6 +65,14 @@ export class DragDropService {
.getMainJointPaper()
?.pageToLocalPoint(dropPoint.x, dropPoint.y)!;
+ if (this.op.operatorType === WORKFLOW_MACRO_OPERATOR_TYPE) {
+ const macroID = this.workflowActionService.createMacroAt(coordinates);
+ this.workflowActionService.getJointGraphWrapper().highlightOperators(JointGraphWrapper.getMacroNodeID(macroID));
+ this.resetSuggestions();
+ this.operatorDroppedSubject.next();
+ return;
+ }
+
// Check if the operator is dropped on top of an existing edge
const intersectedLink = this.findIntersectedLink(coordinates);
diff --git a/frontend/src/app/workspace/service/execute-workflow/execute-workflow.service.spec.ts b/frontend/src/app/workspace/service/execute-workflow/execute-workflow.service.spec.ts
index f38890eb2bf..b4237aa5d36 100644
--- a/frontend/src/app/workspace/service/execute-workflow/execute-workflow.service.spec.ts
+++ b/frontend/src/app/workspace/service/execute-workflow/execute-workflow.service.spec.ts
@@ -45,6 +45,8 @@ import { UserService } from "src/app/common/service/user/user.service";
import { StubUserService } from "src/app/common/service/user/stub-user.service";
import { MockComputingUnitStatusService } from "../../../common/service/computing-unit/computing-unit-status/mock-computing-unit-status.service";
import { commonTestProviders } from "../../../common/testing/test-utils";
+import { WorkflowGraph } from "../workflow-graph/model/workflow-graph";
+import { mockScanPredicate } from "../workflow-graph/model/mock-workflow-data";
class StubHttpClient {
public post(): Observable {
@@ -97,6 +99,14 @@ describe("ExecuteWorkflowService", () => {
expect(newLogicalPlan).toEqual(mockLogicalPlan_scan_result);
});
+ it("should keep macro parent metadata out of the logical execution plan", () => {
+ const graph = new WorkflowGraph([{ ...mockScanPredicate, macroIdParent: "macro-1" }], []);
+
+ const plan = ExecuteWorkflowService.getLogicalPlanRequest(graph);
+
+ expect((plan.operators[0] as any).macroIdParent).toBeUndefined();
+ });
+
it("should msg backend when executing workflow", fakeAsync(() => {
const logicalPlan: LogicalPlan = ExecuteWorkflowService.getLogicalPlanRequest(mockWorkflowPlan_scan_result);
const wsSendSpy = vi.spyOn((service as any).workflowWebsocketService, "send");
diff --git a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
index d5d8f78c584..63cc0438278 100644
--- a/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
+++ b/frontend/src/app/workspace/service/joint-ui/joint-ui.service.ts
@@ -269,6 +269,10 @@ export class JointUIService {
this.operatorMetadataService.getOperatorMetadata().subscribe(value => (this.operatorSchemas = value.operators));
}
+ public static getOperatorElementMarkup(dynamicInputPorts = false, dynamicOutputPorts = false): string {
+ return TexeraCustomJointElement.getMarkup(dynamicInputPorts, dynamicOutputPorts);
+ }
+
/**
* Gets the JointJS UI Element object based on the operator predicate.
* A JointJS Element could be added to the JointJS graph to let JointJS display the operator accordingly.
@@ -465,6 +469,10 @@ export class JointUIService {
}
public changeOperatorState(jointPaper: joint.dia.Paper, operatorID: string, operatorState: OperatorState): void {
+ const element = jointPaper.getModelById(operatorID) as joint.shapes.devs.Model | undefined;
+ if (!element) {
+ return;
+ }
let fillColor: string;
switch (operatorState) {
case OperatorState.Ready:
@@ -484,13 +492,12 @@ export class JointUIService {
fillColor = "gray";
break;
}
- jointPaper.getModelById(operatorID).attr({
+ element.attr({
[`.${operatorStateClass}`]: { text: operatorState.toString(), fill: fillColor },
"rect.body": { stroke: fillColor },
[`.${operatorPortMetricsClass}`]: { fill: fillColor },
[`.${operatorWorkerCountClass}`]: { fill: fillColor },
});
- const element = jointPaper.getModelById(operatorID) as joint.shapes.devs.Model;
const allPorts = element.getPorts();
const inPorts = allPorts.filter(p => p.group === "in");
inPorts.forEach(p => {
@@ -944,7 +951,7 @@ export class JointUIService {
visibility: "hidden",
},
".texera-operator-icon": {
- "xlink:href": "assets/operator_images/" + operatorType + ".png",
+ "xlink:href": `assets/operator_images/${operatorType}.png`,
width: 35,
height: 35,
"ref-x": 0.5,
diff --git a/frontend/src/app/workspace/service/operator-menu/operator-menu.service.spec.ts b/frontend/src/app/workspace/service/operator-menu/operator-menu.service.spec.ts
index 88e4358028f..b9e027da914 100644
--- a/frontend/src/app/workspace/service/operator-menu/operator-menu.service.spec.ts
+++ b/frontend/src/app/workspace/service/operator-menu/operator-menu.service.spec.ts
@@ -184,4 +184,35 @@ describe("OperatorMenuService", () => {
expect(service.isToViewResult).toBe(false);
});
});
+
+ it("clears macroIdParent on highlighted operators via removeHighlightedOperatorsFromMacro", () => {
+ workflowActionService.addOperatorsAndLinks(
+ [
+ { op: mockScanPredicate, pos: mockPoint },
+ { op: mockSentimentPredicate, pos: mockPoint },
+ ],
+ []
+ );
+ const macroID = workflowActionService.createMacroAt(mockPoint);
+ workflowActionService.setOperatorsMacroParent(
+ [mockScanPredicate.operatorID, mockSentimentPredicate.operatorID],
+ macroID
+ );
+
+ const wrapper = workflowActionService.getJointGraphWrapper();
+ wrapper.unhighlightOperators(...wrapper.getCurrentHighlightedOperatorIDs());
+ wrapper.highlightOperators(mockScanPredicate.operatorID, mockSentimentPredicate.operatorID);
+
+ expect(service.canRemoveHighlightedOperatorsFromMacro()).toBe(true);
+
+ service.removeHighlightedOperatorsFromMacro();
+
+ expect(
+ workflowActionService.getTexeraGraph().getOperator(mockScanPredicate.operatorID).macroIdParent
+ ).toBeUndefined();
+ expect(
+ workflowActionService.getTexeraGraph().getOperator(mockSentimentPredicate.operatorID).macroIdParent
+ ).toBeUndefined();
+ expect(service.canRemoveHighlightedOperatorsFromMacro()).toBe(false);
+ });
});
diff --git a/frontend/src/app/workspace/service/operator-menu/operator-menu.service.ts b/frontend/src/app/workspace/service/operator-menu/operator-menu.service.ts
index 1b800e9f7b7..37539fb1c98 100644
--- a/frontend/src/app/workspace/service/operator-menu/operator-menu.service.ts
+++ b/frontend/src/app/workspace/service/operator-menu/operator-menu.service.ts
@@ -92,7 +92,9 @@ export class OperatorMenuService {
)
.pipe(untilDestroyed(this))
.subscribe(() => {
- this._highlightedOperators$.next(jointGraphWrapper.getCurrentHighlightedOperatorIDs());
+ this._highlightedOperators$.next(
+ jointGraphWrapper.getCurrentHighlightedOperatorIDs().filter(operatorID => texeraGraph.hasOperator(operatorID))
+ );
this.recomputeMenuState();
});
@@ -264,6 +266,20 @@ export class OperatorMenuService {
this.executeWorkflowService.executeWorkflow("", targetOperatorId);
}
+ public canRemoveHighlightedOperatorsFromMacro(): boolean {
+ const texeraGraph = this.workflowActionService.getTexeraGraph();
+ return this._highlightedOperators$.value.some(operatorID =>
+ Boolean(texeraGraph.getOperator(operatorID).macroIdParent)
+ );
+ }
+
+ public removeHighlightedOperatorsFromMacro(): void {
+ const operatorIDs = this._highlightedOperators$.value;
+ if (!operatorIDs.length) return;
+ this.workflowActionService.setOperatorsMacroParent(operatorIDs, undefined);
+ this.notificationService.info("Removed selected operators from macro.");
+ }
+
public performPasteOperation() {
// by reading from the clipboard, permission needs to be granted
// a permission prompt automatically shows up by calling readText()
diff --git a/frontend/src/app/workspace/service/operator-metadata/operator-metadata.service.ts b/frontend/src/app/workspace/service/operator-metadata/operator-metadata.service.ts
index 484a3c09818..9126f19d9d9 100644
--- a/frontend/src/app/workspace/service/operator-metadata/operator-metadata.service.ts
+++ b/frontend/src/app/workspace/service/operator-metadata/operator-metadata.service.ts
@@ -22,9 +22,23 @@ import { Injectable } from "@angular/core";
import { Observable } from "rxjs";
import { AppSettings } from "../../../common/app-setting";
import { OperatorMetadata, OperatorSchema } from "../../types/operator-schema.interface";
-import { shareReplay } from "rxjs/operators";
+import { map, shareReplay } from "rxjs/operators";
export const OPERATOR_METADATA_ENDPOINT = "resources/operator-metadata";
+export const WORKFLOW_MACRO_OPERATOR_TYPE = "WorkflowMacro";
+
+export const WORKFLOW_MACRO_OPERATOR_SCHEMA: OperatorSchema = {
+ operatorType: WORKFLOW_MACRO_OPERATOR_TYPE,
+ operatorVersion: "frontend-macro",
+ jsonSchema: { type: "object", properties: {} },
+ additionalMetadata: {
+ userFriendlyName: "Workflow Macro",
+ operatorGroupName: "Workflow",
+ operatorDescription: "Import an existing workflow as a reusable macro",
+ inputPorts: [],
+ outputPorts: [],
+ },
+};
const addDictionaryAPIAddress = "/api/resources/dictionary/";
const getDictionaryAPIAddress = "/api/upload/dictionary/";
@@ -54,7 +68,10 @@ export class OperatorMetadataService {
private operatorMetadataObservable = this.httpClient
.get(`${AppSettings.getApiEndpoint()}/${OPERATOR_METADATA_ENDPOINT}`)
- .pipe(shareReplay(1));
+ .pipe(
+ map(metadata => this.withWorkflowMacroOperator(metadata)),
+ shareReplay(1)
+ );
constructor(private httpClient: HttpClient) {
this.getOperatorMetadata().subscribe(data => {
@@ -119,4 +136,18 @@ export class OperatorMetadataService {
}
return true;
}
+
+ private withWorkflowMacroOperator(metadata: OperatorMetadata): OperatorMetadata {
+ if (metadata.operators.some(operator => operator.operatorType === WORKFLOW_MACRO_OPERATOR_TYPE)) {
+ return metadata;
+ }
+ return {
+ operators: [...metadata.operators, WORKFLOW_MACRO_OPERATOR_SCHEMA],
+ groups: metadata.groups.some(
+ group => group.groupName === WORKFLOW_MACRO_OPERATOR_SCHEMA.additionalMetadata.operatorGroupName
+ )
+ ? metadata.groups
+ : [...metadata.groups, { groupName: WORKFLOW_MACRO_OPERATOR_SCHEMA.additionalMetadata.operatorGroupName }],
+ };
+ }
}
diff --git a/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.spec.ts b/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.spec.ts
index 495208f5dce..783103ccf07 100644
--- a/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.spec.ts
+++ b/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.spec.ts
@@ -40,6 +40,7 @@ import { map, share, tap } from "rxjs/operators";
import { commonTestProviders } from "../../../../common/testing/test-utils";
import { GuiConfigService } from "../../../../common/service/gui-config.service";
import { MockGuiConfigService } from "../../../../common/service/gui-config.service.mock";
+import { OperatorState } from "../../../types/execute-workflow.interface";
describe("JointGraphWrapperService", () => {
let jointGraph: joint.dia.Graph;
@@ -568,6 +569,118 @@ describe("JointGraphWrapperService", () => {
expect(localJointGraphWrapper.getElementPosition(mockResultPredicate.operatorID)).toEqual(expectedPosition);
});
+ it("should anchor expanded macro tab and keep collapsed boundary ports", () => {
+ const macroID = "macro-1";
+ const externalSource = { ...mockScanPredicate };
+ const internalOperator = { ...mockSentimentPredicate, macroIdParent: macroID };
+ const secondInternalOperator = { ...mockResultPredicate, operatorID: "internal-output", macroIdParent: macroID };
+ const externalSink = { ...mockResultPredicate };
+ const operators = [externalSource, internalOperator, secondInternalOperator, externalSink];
+ const links = [mockScanSentimentLink, mockSentimentResultLink];
+
+ jointGraph.addCell(jointUIService.getJointOperatorElement(externalSource, { x: 100, y: 180 }));
+ jointGraph.addCell(jointUIService.getJointOperatorElement(internalOperator, { x: 300, y: 180 }));
+ jointGraph.addCell(jointUIService.getJointOperatorElement(secondInternalOperator, { x: 450, y: 180 }));
+ jointGraph.addCell(jointUIService.getJointOperatorElement(externalSink, { x: 500, y: 180 }));
+ links.forEach(link => jointGraph.addCell(JointUIService.getJointLinkCell(link)));
+
+ jointGraphWrapper.refreshMacroFrames(operators, [{ macroID, name: "Macro 1", position: { x: 0, y: 0 } }], links);
+
+ const frame = jointGraph.getCell(JointGraphWrapper.getMacroFrameID(macroID)) as joint.dia.Element;
+ const node = jointGraph.getCell(JointGraphWrapper.getMacroNodeID(macroID)) as joint.dia.Element;
+ expect(node.position()).toEqual(frame.position());
+ expect(
+ (jointGraph.getCell(internalOperator.operatorID) as joint.dia.Element).position().y - frame.position().y
+ ).toBe(85);
+ expect(node.getPorts()).toEqual([]);
+
+ const originalFramePosition = frame.position();
+ const originalFrameSize = frame.size();
+ (jointGraph.getCell(internalOperator.operatorID) as joint.dia.Element).position(220, 180);
+ jointGraphWrapper.refreshMacroFrames(operators, [{ macroID, name: "Macro 1", position: node.position() }], links);
+ expect(frame.position().x).toBeLessThan(originalFramePosition.x);
+ expect(frame.size().width).toBeGreaterThan(originalFrameSize.width);
+ expect(node.position()).toEqual(frame.position());
+ expect(
+ (jointGraph.getCell(internalOperator.operatorID) as joint.dia.Element).position().y - frame.position().y
+ ).toBe(85);
+
+ jointGraphWrapper.refreshMacroFrames(
+ operators,
+ [{ macroID, name: "Macro 1", position: node.position(), collapsed: true }],
+ links
+ );
+
+ const collapsedNode = jointGraph.getCell(JointGraphWrapper.getMacroNodeID(macroID)) as joint.dia.Element;
+ expect(jointGraph.getCell(JointGraphWrapper.getMacroFrameID(macroID))).toBeUndefined();
+ expect(collapsedNode.size()).toEqual({
+ width: JointUIService.DEFAULT_OPERATOR_WIDTH,
+ height: JointUIService.DEFAULT_OPERATOR_HEIGHT,
+ });
+ expect(collapsedNode.attr(".texera-operator-friendly-name/text")).toBe("Workflow Macro");
+ expect(collapsedNode.attr("rect.body/stroke")).toBe("#CFCFCF");
+ expect(collapsedNode.getPorts().map(port => port.group)).toEqual(["in", "out"]);
+ jointUIService.changeOperatorState(
+ jointGraphWrapper.getMainJointPaper() ?? ({ getModelById: (id: string) => jointGraph.getCell(id) } as any),
+ collapsedNode.id.toString(),
+ OperatorState.Running
+ );
+ expect(collapsedNode.attr("rect.body/stroke")).toBe("orange");
+ expect(jointGraph.getLinks().filter(link => JointGraphWrapper.isMacroProxyLinkID(link.id.toString())).length).toBe(
+ 2
+ );
+ expect(jointGraph.getCell(internalOperator.operatorID).attr("root/display")).toBe("none");
+ });
+
+ it("should route collapsed macro boundary links only through visible endpoints", () => {
+ const sourceMacroID = "macro-source";
+ const targetMacroID = "macro-target";
+ const sourceOperator = { ...mockSentimentPredicate, macroIdParent: sourceMacroID };
+ const targetOperator = { ...mockResultPredicate, macroIdParent: targetMacroID };
+ const crossMacroLink = {
+ linkID: "macro-cross-link",
+ source: {
+ operatorID: sourceOperator.operatorID,
+ portID: sourceOperator.outputPorts[0].portID,
+ },
+ target: {
+ operatorID: targetOperator.operatorID,
+ portID: targetOperator.inputPorts[0].portID,
+ },
+ };
+
+ jointGraph.addCell(jointUIService.getJointOperatorElement(sourceOperator, { x: 100, y: 100 }));
+ jointGraph.addCell(jointUIService.getJointOperatorElement(targetOperator, { x: 300, y: 100 }));
+ jointGraph.addCell(JointUIService.getJointLinkCell(crossMacroLink));
+
+ jointGraphWrapper.refreshMacroFrames(
+ [sourceOperator, targetOperator],
+ [
+ { macroID: sourceMacroID, name: "Source Macro", position: { x: 20, y: 20 }, collapsed: true },
+ { macroID: targetMacroID, name: "Target Macro", position: { x: 250, y: 20 }, collapsed: true },
+ ],
+ [crossMacroLink]
+ );
+
+ const proxyLinks = jointGraph.getLinks().filter(link => JointGraphWrapper.isMacroProxyLinkID(link.id.toString()));
+ expect(proxyLinks.length).toBe(1);
+ expect(proxyLinks[0].source()).toEqual({
+ id: JointGraphWrapper.getMacroNodeID(sourceMacroID),
+ port: "macro-out-macro-cross-link",
+ });
+ expect(proxyLinks[0].target()).toEqual({
+ id: JointGraphWrapper.getMacroNodeID(targetMacroID),
+ port: "macro-in-macro-cross-link",
+ });
+ expect(
+ (jointGraph.getCell(JointGraphWrapper.getMacroNodeID(sourceMacroID)) as joint.dia.Element).getPorts().length
+ ).toBe(1);
+ expect(
+ (jointGraph.getCell(JointGraphWrapper.getMacroNodeID(targetMacroID)) as joint.dia.Element).getPorts().length
+ ).toBe(1);
+ expect(jointGraph.getCell(crossMacroLink.linkID).attr("root/display")).toBe("none");
+ });
+
describe("when linkBreakpoint is enabled", () => {
let mockConfigService: MockGuiConfigService;
diff --git a/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.ts b/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.ts
index f47aa8ab87d..73c61ad4312 100644
--- a/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.ts
+++ b/frontend/src/app/workspace/service/workflow-graph/model/joint-graph-wrapper.ts
@@ -19,13 +19,23 @@
import { fromEvent, Observable, ReplaySubject, Subject } from "rxjs";
import { filter, map } from "rxjs/operators";
-import { LogicalPort, Point } from "../../../types/workflow-common.interface";
+import {
+ LogicalPort,
+ OperatorLink,
+ OperatorPredicate,
+ Point,
+ WorkflowMacro,
+} from "../../../types/workflow-common.interface";
import * as joint from "jointjs";
import * as dagre from "dagre";
import * as graphlib from "graphlib";
import { ObservableContextManager } from "src/app/common/util/context";
import { Coeditor, User } from "../../../../common/type/user";
-import { operatorCoeditorChangedPropertyClass, operatorCoeditorEditingClass } from "../../joint-ui/joint-ui.service";
+import {
+ JointUIService,
+ operatorCoeditorChangedPropertyClass,
+ operatorCoeditorEditingClass,
+} from "../../joint-ui/joint-ui.service";
import { dia } from "jointjs/types/joint";
import * as _ from "lodash";
import Selectors = dia.Cell.Selectors;
@@ -51,7 +61,7 @@ type JointModelEvent = [joint.dia.Cell, { graph: joint.dia.Graph; models: joint.
type JointLinkChangeEvent = [joint.dia.Link, { x: number; y: number }, { ui: boolean; updateConnectionOnly: boolean }];
-type JointPositionChangeEvent = [joint.dia.Element, { x: number; y: number }];
+type JointPositionChangeEvent = [joint.dia.Element, { x: number; y: number }, { macroVisualSync?: boolean }?];
type PositionInfo = {
currPos: Point;
@@ -91,6 +101,19 @@ const DefaultContext: JointGraphContextType = {
* For an overview of the services in WorkflowGraphModule, see workflow-graph-design.md
*/
export class JointGraphWrapper {
+ private static readonly MACRO_FRAME_PREFIX = "texera-macro-frame-";
+ private static readonly MACRO_NODE_PREFIX = "texera-macro-node-";
+ private static readonly MACRO_PROXY_LINK_PREFIX = "texera-macro-proxy-link-";
+ private static readonly MACRO_FRAME_PADDING_X = 43;
+ private static readonly MACRO_NODE_WIDTH = 150;
+ private static readonly MACRO_NODE_HEIGHT = 48;
+ private static readonly MACRO_INTERNAL_TOP_GAP = 37;
+ private static readonly MACRO_FRAME_PADDING_TOP =
+ JointGraphWrapper.MACRO_NODE_HEIGHT + JointGraphWrapper.MACRO_INTERNAL_TOP_GAP;
+ private static readonly MACRO_FRAME_PADDING_BOTTOM = 70;
+ private static readonly MACRO_NODE_INSET = 0;
+ private static readonly MACRO_NODE_VIEW_KEY = "texeraMacroNodeView";
+
// zoom diff represents the ratio that is zoom in/out everytime, for clicking +/- buttons or using mousewheel
public static readonly ZOOM_CLICK_DIFF: number = 0.05;
public static readonly INIT_ZOOM_VALUE: number = 1;
@@ -110,6 +133,9 @@ export class JointGraphWrapper {
private multiSelect: boolean = false;
private reloadingWorkflow: boolean = false;
+ private collapsedMacroIDs = new Set();
+ private workflowMacros: readonly WorkflowMacro[] = [];
+ private workflowLinks: readonly OperatorLink[] = [];
// the currently highlighted operators' IDs
private currentHighlightedOperators: string[] = [];
@@ -260,6 +286,34 @@ export class JointGraphWrapper {
};
}
+ public static getMacroFrameID(macroId: string): string {
+ return `${JointGraphWrapper.MACRO_FRAME_PREFIX}${macroId}`;
+ }
+
+ public static getMacroNodeID(macroId: string): string {
+ return `${JointGraphWrapper.MACRO_NODE_PREFIX}${macroId}`;
+ }
+
+ public static getMacroIDFromNodeID(elementID: string): string {
+ return elementID.substring(JointGraphWrapper.MACRO_NODE_PREFIX.length);
+ }
+
+ public static isMacroFrameID(elementID: string): boolean {
+ return elementID.startsWith(JointGraphWrapper.MACRO_FRAME_PREFIX);
+ }
+
+ public static isMacroNodeID(elementID: string): boolean {
+ return elementID.startsWith(JointGraphWrapper.MACRO_NODE_PREFIX);
+ }
+
+ public static isMacroElementID(elementID: string): boolean {
+ return JointGraphWrapper.isMacroFrameID(elementID) || JointGraphWrapper.isMacroNodeID(elementID);
+ }
+
+ public static isMacroProxyLinkID(linkID: string): boolean {
+ return linkID.startsWith(JointGraphWrapper.MACRO_PROXY_LINK_PREFIX);
+ }
+
public getCurrentHighlightedIDs(): readonly string[] {
return [
...this.currentHighlightedOperators,
@@ -282,6 +336,7 @@ export class JointGraphWrapper {
newPosition: Point;
}> {
return fromEvent(this.jointGraph, "change:position").pipe(
+ filter(e => !JointGraphWrapper.isMacroElementID(e[0].id.toString())),
map(e => {
const elementID = e[0].id.toString();
const oldPosition = this.elementPositions.get(elementID);
@@ -309,6 +364,28 @@ export class JointGraphWrapper {
);
}
+ public getMacroNodePositionChangeEvent(): Observable<{ macroID: string; oldPosition: Point; newPosition: Point }> {
+ return fromEvent(this.jointGraph, "change:position").pipe(
+ filter(e => JointGraphWrapper.isMacroNodeID(e[0].id.toString())),
+ filter(e => !(e[2]?.macroVisualSync ?? false)),
+ map(e => {
+ const elementID = e[0].id.toString();
+ const newPosition = { x: e[1].x, y: e[1].y };
+ const oldPosition = this.elementPositions.get(elementID);
+ const previousPosition = oldPosition?.currPos ?? newPosition;
+ this.elementPositions.set(elementID, {
+ currPos: newPosition,
+ lastPos: previousPosition,
+ });
+ return {
+ macroID: JointGraphWrapper.getMacroIDFromNodeID(elementID),
+ oldPosition: previousPosition,
+ newPosition,
+ };
+ })
+ );
+ }
+
public unhighlightElements(elements: JointHighlights): void {
this.unhighlightOperators(...elements.operators);
this.unhighlightLinks(...elements.links);
@@ -586,7 +663,12 @@ export class JointGraphWrapper {
public autoLayoutJoint(): void {
joint.layout.DirectedGraph.layout(
- [...this.jointGraph.getElements().filter(el => el.attributes.type !== "region"), ...this.jointGraph.getLinks()],
+ [
+ ...this.jointGraph
+ .getElements()
+ .filter(el => el.attributes.type !== "region" && !JointGraphWrapper.isMacroElementID(el.id.toString())),
+ ...this.jointGraph.getLinks().filter(link => !JointGraphWrapper.isMacroProxyLinkID(link.id.toString())),
+ ],
{
dagre: dagre,
graphlib: graphlib,
@@ -598,6 +680,47 @@ export class JointGraphWrapper {
resizeClusters: true,
}
);
+ this.refreshPaperViews();
+ }
+
+ public autoLayoutMacroInternals(
+ macroID: string,
+ operators: readonly OperatorPredicate[],
+ links: readonly OperatorLink[],
+ origin: Point
+ ): string[] {
+ const internalOperatorIDs = new Set(
+ operators.filter(operator => operator.macroIdParent === macroID).map(operator => operator.operatorID)
+ );
+ const internalElements = Array.from(internalOperatorIDs)
+ .map(operatorID => this.jointGraph.getCell(operatorID))
+ .filter((cell): cell is joint.dia.Element => Boolean(cell?.isElement()));
+ if (!internalElements.length) return [];
+
+ const internalLinks = links
+ .filter(
+ link => internalOperatorIDs.has(link.source.operatorID) && internalOperatorIDs.has(link.target.operatorID)
+ )
+ .map(link => this.jointGraph.getCell(link.linkID))
+ .filter((cell): cell is joint.dia.Link => Boolean(cell?.isLink()));
+
+ joint.layout.DirectedGraph.layout([...internalElements, ...internalLinks], {
+ dagre: dagre,
+ graphlib: graphlib,
+ nodeSep: 90,
+ edgeSep: 100,
+ rankSep: 80,
+ ranker: "tight-tree",
+ rankDir: "LR",
+ });
+
+ const minX = Math.min(...internalElements.map(element => element.position().x));
+ const minY = Math.min(...internalElements.map(element => element.position().y));
+ const offsetX = origin.x + JointGraphWrapper.MACRO_NODE_WIDTH + 70 - minX;
+ const offsetY = origin.y + JointGraphWrapper.MACRO_NODE_HEIGHT + JointGraphWrapper.MACRO_INTERNAL_TOP_GAP - minY;
+ internalElements.forEach(element => element.translate(offsetX, offsetY));
+ this.refreshPaperViews();
+ return internalElements.map(element => element.id.toString());
}
/**
@@ -646,6 +769,15 @@ export class JointGraphWrapper {
return { x: position.x, y: position.y };
}
+ public getMacroFramePosition(macroID: string): Point | undefined {
+ const cell = this.jointGraph.getCell(JointGraphWrapper.getMacroFrameID(macroID));
+ if (!cell?.isElement()) {
+ return undefined;
+ }
+ const position = (cell as joint.dia.Element).position();
+ return { x: position.x, y: position.y };
+ }
+
/**
* This method repositions the element according to given offsets.
* An element can be an operator or a group.
@@ -678,6 +810,249 @@ export class JointGraphWrapper {
element.position(posX, poY);
}
+ public refreshMacroFrames(
+ operators: readonly OperatorPredicate[],
+ macros?: readonly WorkflowMacro[],
+ links?: readonly OperatorLink[]
+ ): void {
+ if (macros) this.workflowMacros = macros;
+ if (links) this.workflowLinks = links;
+ this.collapsedMacroIDs = new Set(this.workflowMacros.filter(macro => macro.collapsed).map(macro => macro.macroID));
+
+ const operatorGroups = new Map();
+ const expandedMacroBounds = new Map();
+ operators.forEach(operator => {
+ const macroId = operator.macroIdParent?.trim();
+ const cell = this.jointGraph.getCell(operator.operatorID);
+ if (!macroId || this.collapsedMacroIDs.has(macroId) || !cell?.isElement()) return;
+ const position = (cell as joint.dia.Element).position();
+ operatorGroups.set(macroId, [...(operatorGroups.get(macroId) ?? []), { x: position.x, y: position.y }]);
+ });
+
+ const activeFrameIDs = new Set();
+ const activeNodeIDs = new Set();
+ operatorGroups.forEach((positions, macroId) => {
+ const frameID = JointGraphWrapper.getMacroFrameID(macroId);
+ activeFrameIDs.add(frameID);
+ const minX = Math.min(...positions.map(pos => pos.x));
+ const minY = Math.min(...positions.map(pos => pos.y));
+ const maxX = Math.max(...positions.map(pos => pos.x + JointUIService.DEFAULT_OPERATOR_WIDTH));
+ const maxY = Math.max(...positions.map(pos => pos.y + JointUIService.DEFAULT_OPERATOR_HEIGHT));
+ const bounds = {
+ x: minX - JointGraphWrapper.MACRO_FRAME_PADDING_X,
+ y: minY - JointGraphWrapper.MACRO_FRAME_PADDING_TOP,
+ width: maxX - minX + JointGraphWrapper.MACRO_FRAME_PADDING_X * 2,
+ height: maxY - minY + JointGraphWrapper.MACRO_FRAME_PADDING_TOP + JointGraphWrapper.MACRO_FRAME_PADDING_BOTTOM,
+ };
+ expandedMacroBounds.set(macroId, bounds);
+ const existingFrame = this.jointGraph.getCell(frameID);
+ const frame = existingFrame?.isElement()
+ ? (existingFrame as joint.dia.Element)
+ : new joint.shapes.standard.Rectangle();
+
+ frame.set("id", frameID);
+ frame.position(bounds.x, bounds.y, { macroVisualSync: true } as any);
+ frame.resize(bounds.width, bounds.height);
+ JointGraphWrapper.styleMacroFrame(frame);
+ if (!existingFrame) {
+ this.jointGraph.addCell(frame);
+ }
+ frame.toBack();
+ });
+
+ if (this.workflowMacros) {
+ this.workflowMacros.forEach(macro => {
+ const nodeID = JointGraphWrapper.getMacroNodeID(macro.macroID);
+ activeNodeIDs.add(nodeID);
+ const collapsed = macro.collapsed ?? false;
+ const viewMode = collapsed ? "collapsed" : "expanded";
+ const existingNode = this.jointGraph.getCell(nodeID);
+ let node =
+ existingNode?.isElement() && existingNode.get(JointGraphWrapper.MACRO_NODE_VIEW_KEY) === viewMode
+ ? (existingNode as joint.dia.Element)
+ : undefined;
+ if (!node) {
+ existingNode?.remove();
+ node = JointGraphWrapper.createMacroNode(collapsed);
+ node.set("id", nodeID);
+ node.set(JointGraphWrapper.MACRO_NODE_VIEW_KEY, viewMode);
+ this.jointGraph.addCell(node);
+ }
+ const nodePosition =
+ collapsed || !expandedMacroBounds.has(macro.macroID)
+ ? macro.position
+ : JointGraphWrapper.getExpandedMacroTabPosition(
+ expandedMacroBounds.get(macro.macroID) as { x: number; y: number; width: number; height: number }
+ );
+
+ node.set("id", nodeID);
+ node.position(nodePosition.x, nodePosition.y, { macroVisualSync: true } as any);
+ node.resize(
+ collapsed ? JointUIService.DEFAULT_OPERATOR_WIDTH : JointGraphWrapper.MACRO_NODE_WIDTH,
+ collapsed ? JointUIService.DEFAULT_OPERATOR_HEIGHT : JointGraphWrapper.MACRO_NODE_HEIGHT
+ );
+ JointGraphWrapper.styleMacroNode(node, macro.name, collapsed);
+ this.elementPositions.set(nodeID, { currPos: nodePosition, lastPos: undefined });
+ });
+ }
+
+ this.jointGraph
+ .getCells()
+ .filter(cell => JointGraphWrapper.isMacroFrameID(cell.id.toString()) && !activeFrameIDs.has(cell.id.toString()))
+ .forEach(cell => cell.remove());
+ if (this.workflowMacros) {
+ this.jointGraph
+ .getCells()
+ .filter(cell => JointGraphWrapper.isMacroNodeID(cell.id.toString()) && !activeNodeIDs.has(cell.id.toString()))
+ .forEach(cell => cell.remove());
+ }
+ this.applyMacroCollapseState(operators);
+ this.refreshMacroBoundaryConnections(operators, this.workflowLinks, this.workflowMacros);
+ this.clearMacroSelectionChrome();
+ this.refreshPaperViews();
+ }
+
+ private refreshMacroBoundaryConnections(
+ operators: readonly OperatorPredicate[],
+ links: readonly OperatorLink[],
+ macros: readonly WorkflowMacro[]
+ ): void {
+ this.jointGraph
+ .getLinks()
+ .filter(link => JointGraphWrapper.isMacroProxyLinkID(link.id.toString()))
+ .forEach(link => link.remove());
+
+ const macroByOperatorID = new Map(
+ operators
+ .filter(operator => operator.macroIdParent?.trim())
+ .map(operator => [operator.operatorID, operator.macroIdParent?.trim() as string])
+ );
+ const collapsedMacroIDs = new Set(macros.filter(macro => macro.collapsed).map(macro => macro.macroID));
+ const portsByMacroID = new Map();
+ const proxyLinks: joint.dia.Link[] = [];
+ const addPort = (macroID: string, direction: "in" | "out", linkID: string): string => {
+ const ports = portsByMacroID.get(macroID) ?? [];
+ portsByMacroID.set(macroID, ports);
+ const portID = JointGraphWrapper.getMacroBoundaryPortID(direction, linkID);
+ if (!ports.some(port => port.id === portID)) {
+ ports.push({
+ id: portID,
+ group: direction,
+ attrs: {
+ ".port-label": {
+ text: String(ports.filter(port => port.group === direction).length + 1),
+ },
+ },
+ });
+ }
+ return portID;
+ };
+
+ links.forEach(link => {
+ const sourceMacroID = macroByOperatorID.get(link.source.operatorID);
+ const targetMacroID = macroByOperatorID.get(link.target.operatorID);
+ const sourceCollapsed = sourceMacroID !== undefined && collapsedMacroIDs.has(sourceMacroID);
+ const targetCollapsed = targetMacroID !== undefined && collapsedMacroIDs.has(targetMacroID);
+ if ((!sourceCollapsed && !targetCollapsed) || sourceMacroID === targetMacroID) return;
+
+ const source = sourceCollapsed
+ ? {
+ operatorID: JointGraphWrapper.getMacroNodeID(sourceMacroID as string),
+ portID: addPort(sourceMacroID as string, "out", link.linkID),
+ }
+ : link.source;
+ const target = targetCollapsed
+ ? {
+ operatorID: JointGraphWrapper.getMacroNodeID(targetMacroID as string),
+ portID: addPort(targetMacroID as string, "in", link.linkID),
+ }
+ : link.target;
+ proxyLinks.push(
+ JointGraphWrapper.getMacroBoundaryLink(
+ JointGraphWrapper.getMacroProxyLinkID(sourceCollapsed ? "out" : "in", link.linkID),
+ source.operatorID,
+ source.portID,
+ target.operatorID,
+ target.portID
+ )
+ );
+ });
+
+ macros.forEach(macro => {
+ const node = this.jointGraph.getCell(JointGraphWrapper.getMacroNodeID(macro.macroID)) as
+ | joint.dia.Element
+ | undefined;
+ if (node?.isElement()) {
+ JointGraphWrapper.setMacroBoundaryPorts(node, portsByMacroID.get(macro.macroID) ?? []);
+ }
+ });
+ this.jointGraph.addCells(proxyLinks);
+ }
+
+ private applyMacroCollapseState(operators: readonly OperatorPredicate[]): void {
+ const collapsedOperatorIDs = new Set(
+ operators
+ .filter(operator => {
+ const macroID = operator.macroIdParent?.trim();
+ return macroID !== undefined && this.collapsedMacroIDs.has(macroID);
+ })
+ .map(operator => operator.operatorID)
+ );
+
+ operators.forEach(operator => {
+ this.setCellHidden(this.jointGraph.getCell(operator.operatorID), collapsedOperatorIDs.has(operator.operatorID));
+ });
+ this.jointGraph.getLinks().forEach(link => {
+ const sourceID = link.source()?.id;
+ const targetID = link.target()?.id;
+ this.setCellHidden(
+ link,
+ Boolean(
+ (sourceID && collapsedOperatorIDs.has(sourceID.toString())) ||
+ (targetID && collapsedOperatorIDs.has(targetID.toString()))
+ )
+ );
+ });
+ }
+
+ private setCellHidden(cell: joint.dia.Cell | undefined, hidden: boolean): void {
+ if (!cell) return;
+ if (JointGraphWrapper.isMacroProxyLinkID(cell.id.toString())) return;
+ cell.attr("root/display", hidden ? "none" : null);
+ const view = this.getMainJointPaper()?.findViewByModel(cell);
+ if (view) {
+ view.el.style.display = hidden ? "none" : "";
+ }
+ }
+
+ private refreshPaperViews(): void {
+ if (!this.mainPaper) return;
+ const refresh = () => {
+ this.clearMacroSelectionChrome();
+ this.mainPaper.updateViews();
+ this.jointGraph.getLinks().forEach(link => {
+ const linkView = this.mainPaper.findViewByModel(link) as
+ | (joint.dia.LinkView & {
+ requestConnectionUpdate?: () => void;
+ })
+ | null;
+ linkView?.requestConnectionUpdate?.();
+ });
+ };
+ refresh();
+ if (typeof requestAnimationFrame === "function") {
+ requestAnimationFrame(refresh);
+ }
+ }
+
+ private clearMacroSelectionChrome(): void {
+ if (!this.mainPaper) return;
+ this.workflowMacros.forEach(macro => {
+ const view = this.mainPaper.findViewByModel(JointGraphWrapper.getMacroNodeID(macro.macroID));
+ view?.el.querySelectorAll(".joint-highlight-stroke").forEach(element => element.remove());
+ });
+ }
+
/**
* Highlights the link with given linkID.
* Emits an event to the link highlight stream.
@@ -747,6 +1122,189 @@ export class JointGraphWrapper {
public setListenPositionChange(listenPositionChange: boolean): void {
this.listenPositionChange = listenPositionChange;
}
+
+ private static styleMacroFrame(frame: joint.dia.Element): void {
+ frame.set("type", "macro-frame");
+ frame.set("z", 0);
+ frame.attr({
+ body: {
+ fill: "rgba(47, 84, 235, 0.04)",
+ stroke: "#2f54eb",
+ "stroke-width": 2,
+ "stroke-dasharray": "8 4",
+ rx: 8,
+ ry: 8,
+ "pointer-events": "none",
+ },
+ label: { text: "" },
+ });
+ }
+
+ private static getExpandedMacroTabPosition(bounds: { x: number; y: number; width: number; height: number }): Point {
+ return {
+ x: bounds.x + JointGraphWrapper.MACRO_NODE_INSET,
+ y: bounds.y + JointGraphWrapper.MACRO_NODE_INSET,
+ };
+ }
+
+ private static createMacroNode(collapsed: boolean): joint.dia.Element {
+ return collapsed
+ ? (new joint.shapes.devs.Model({
+ markup: JointUIService.getOperatorElementMarkup(),
+ ports: { groups: JointGraphWrapper.getMacroBoundaryPortGroups() },
+ }) as joint.dia.Element)
+ : new joint.shapes.standard.Rectangle();
+ }
+
+ private static styleMacroNode(node: joint.dia.Element, name: string, collapsed: boolean): void {
+ node.set("type", "macro-node");
+ node.set("z", 2);
+ if (collapsed) {
+ const macroOperator = {
+ operatorID: node.id.toString(),
+ operatorType: "WorkflowMacro",
+ operatorVersion: "",
+ operatorProperties: {},
+ inputPorts: [],
+ outputPorts: [],
+ showAdvanced: false,
+ customDisplayName: name,
+ } as OperatorPredicate;
+ node.attr(
+ JointUIService.getCustomOperatorStyleAttrs(
+ macroOperator,
+ JointUIService.truncateOperatorDisplayName(name),
+ "WorkflowMacro",
+ "Workflow Macro"
+ )
+ );
+ node.attr({
+ "rect.body": { stroke: "#CFCFCF", cursor: "move" },
+ ".texera-operator-icon": { cursor: "move" },
+ ".texera-operator-name": { cursor: "move" },
+ ".texera-operator-friendly-name": { cursor: "move" },
+ });
+ JointGraphWrapper.setMacroBoundaryPortGroups(node);
+ return;
+ }
+ node.attr({
+ body: {
+ fill: collapsed ? "#fff7e6" : "#e6f4ff",
+ stroke: collapsed ? "#fa8c16" : "#1677ff",
+ "stroke-width": 2,
+ rx: 8,
+ ry: 8,
+ cursor: "move",
+ },
+ label: {
+ text: collapsed ? `${name}\ncollapsed` : name,
+ fill: collapsed ? "#ad6800" : "#2f54eb",
+ "font-size": 13,
+ "font-weight": 600,
+ "text-anchor": "middle",
+ "x-alignment": "middle",
+ "y-alignment": "middle",
+ refX: 0.5,
+ refY: 0.5,
+ ref: "body",
+ cursor: "move",
+ },
+ });
+ JointGraphWrapper.setMacroBoundaryPortGroups(node);
+ }
+
+ private static getMacroBoundaryPortID(direction: "in" | "out", linkID: string): string {
+ return `macro-${direction}-${linkID}`;
+ }
+
+ private static getMacroProxyLinkID(direction: "in" | "out", linkID: string): string {
+ return `${JointGraphWrapper.MACRO_PROXY_LINK_PREFIX}${direction}-${linkID}`;
+ }
+
+ private static getMacroBoundaryLink(
+ linkID: string,
+ sourceOperatorID: string,
+ sourcePortID: string,
+ targetOperatorID: string,
+ targetPortID: string
+ ): joint.dia.Link {
+ const link = JointUIService.getDefaultLinkCell();
+ link.set("id", linkID);
+ link.set("type", "macro-proxy-link");
+ link.set("source", { id: sourceOperatorID, port: sourcePortID });
+ link.set("target", { id: targetOperatorID, port: targetPortID });
+ link.attr({
+ ".connection": {
+ stroke: "#919191",
+ "stroke-width": "2px",
+ "stroke-dasharray": "6 3",
+ },
+ ".connection-wrap": {
+ "stroke-width": "0px",
+ },
+ ".tool-remove": {
+ display: "none",
+ },
+ ".marker-source": {
+ display: "none",
+ },
+ ".marker-arrowhead-group-source": {
+ display: "none",
+ },
+ ".marker-arrowhead-group-target": {
+ display: "none",
+ },
+ });
+ return link;
+ }
+
+ private static setMacroBoundaryPortGroups(node: joint.dia.Element): void {
+ node.set("portMarkup", '');
+ node.set("portLabelMarkup", '');
+ if (!node.prop("ports/groups")) {
+ node.prop("ports/groups", JointGraphWrapper.getMacroBoundaryPortGroups());
+ }
+ }
+
+ private static setMacroBoundaryPorts(node: joint.dia.Element, ports: joint.dia.Element.Port[]): void {
+ node.set("ports", {
+ groups: JointGraphWrapper.getMacroBoundaryPortGroups(),
+ items: ports,
+ });
+ }
+
+ private static getMacroBoundaryPortGroups(): Record {
+ return {
+ in: JointGraphWrapper.getMacroBoundaryPortGroup("left"),
+ out: JointGraphWrapper.getMacroBoundaryPortGroup("right"),
+ };
+ }
+
+ private static getMacroBoundaryPortGroup(position: "left" | "right"): joint.dia.Element.PortGroup {
+ return {
+ position: { name: position },
+ attrs: {
+ ".port-body": {
+ fill: "#8c8c8c",
+ stroke: "#ffffff",
+ "stroke-width": 1,
+ r: 5,
+ magnet: false,
+ },
+ ".port-label": {
+ fill: "#595959",
+ "font-size": 10,
+ },
+ },
+ label: {
+ position: {
+ name: position,
+ args: { y: 7 },
+ },
+ },
+ };
+ }
+
/**
* Highlights the element with given elementID.
*
diff --git a/frontend/src/app/workspace/service/workflow-graph/model/shared-model-change-handler.ts b/frontend/src/app/workspace/service/workflow-graph/model/shared-model-change-handler.ts
index e5eab7a812e..bf9af1c2cb4 100644
--- a/frontend/src/app/workspace/service/workflow-graph/model/shared-model-change-handler.ts
+++ b/frontend/src/app/workspace/service/workflow-graph/model/shared-model-change-handler.ts
@@ -44,6 +44,7 @@ import { GuiConfigService } from "../../../../common/service/gui-config.service"
export class SharedModelChangeHandler {
private config: GuiConfigService | null = null;
+ private macroFrameRefreshScheduled = false;
constructor(
private texeraGraph: WorkflowGraph,
@@ -137,6 +138,10 @@ export class SharedModelChangeHandler {
this.jointGraphWrapper.setMultiSelectMode(newOpIDs.length > 1);
this.jointGraphWrapper.highlightOperators(...newOpIDs);
}
+ this.refreshMacroFrames();
+ if (newOpIDs.length > 0) {
+ this.scheduleMacroFrameRefresh();
+ }
});
}
@@ -200,6 +205,10 @@ export class SharedModelChangeHandler {
// // Only highlight when this is added by current user.
// this.jointGraphWrapper.highlightLinks(...linksToAdd.map(link => link.linkID));
// }
+ if (linksToAdd.length > 0 || linksToDelete.length > 0) {
+ this.refreshMacroFrames();
+ this.scheduleMacroFrameRefresh();
+ }
});
}
@@ -236,6 +245,7 @@ export class SharedModelChangeHandler {
*/
private handleElementPositionChange(): void {
this.texeraGraph.sharedModel.elementPositionMap?.observe((event: Y.YMapEvent) => {
+ let shouldRefreshMacroFrames = false;
event.changes.keys.forEach((change, key) => {
if (change.action === "update") {
this.texeraGraph.setSyncTexeraGraph(false);
@@ -244,10 +254,15 @@ export class SharedModelChangeHandler {
this.jointGraphWrapper.setListenPositionChange(false);
this.jointGraphWrapper.setAbsolutePosition(key, newPosition.x, newPosition.y);
this.jointGraphWrapper.setListenPositionChange(true);
+ shouldRefreshMacroFrames = true;
}
this.texeraGraph.setSyncTexeraGraph(true);
}
});
+ if (shouldRefreshMacroFrames) {
+ this.refreshMacroFrames();
+ this.scheduleMacroFrameRefresh();
+ }
});
}
@@ -336,6 +351,9 @@ export class SharedModelChangeHandler {
}
} else if (contentKey === "operatorProperties") {
this.onOperatorPropertyChanged(operatorID, event.transaction.local);
+ } else if (contentKey === "macroIdParent") {
+ this.refreshMacroFrames();
+ this.scheduleMacroFrameRefresh();
}
}
} else if (event.path[event.path.length - 1] === "customDisplayName") {
@@ -429,6 +447,30 @@ export class SharedModelChangeHandler {
}
}
+ private refreshMacroFrames(): void {
+ this.jointGraphWrapper.refreshMacroFrames(
+ this.texeraGraph.getAllOperators(),
+ undefined,
+ this.texeraGraph.getAllLinks()
+ );
+ }
+
+ private scheduleMacroFrameRefresh(): void {
+ if (this.macroFrameRefreshScheduled) {
+ return;
+ }
+ this.macroFrameRefreshScheduled = true;
+ const refresh = () => {
+ this.macroFrameRefreshScheduled = false;
+ this.refreshMacroFrames();
+ };
+ if (typeof requestAnimationFrame === "function") {
+ requestAnimationFrame(refresh);
+ } else {
+ setTimeout(refresh, 0);
+ }
+ }
+
private onPortAdded(operatorID: string, isInput: boolean, port: PortDescription) {
const operatorJointElement = this.jointGraph.getCell(operatorID);
const portGroup = isInput ? "in" : "out";
diff --git a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
index 4ddf3b90068..32f76306a97 100644
--- a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
+++ b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.spec.ts
@@ -40,6 +40,7 @@ import { WorkflowActionService } from "./workflow-action.service";
import { OperatorPredicate } from "../../../types/workflow-common.interface";
import { WorkflowUtilService } from "../util/workflow-util.service";
import { commonTestProviders } from "../../../../common/testing/test-utils";
+import { JointGraphWrapper } from "./joint-graph-wrapper";
describe("WorkflowActionService", () => {
let service: WorkflowActionService;
@@ -250,6 +251,104 @@ describe("WorkflowActionService", () => {
expect(texeraGraph.getAllLinks().length).toEqual(0);
});
+ it("should adopt an external operator only when its edges are fully internal to one macro", () => {
+ const macroID = "macro-1";
+ const internalSource = { ...mockScanPredicate, operatorID: "internal-source", macroIdParent: macroID };
+ const candidate = { ...mockSentimentPredicate, operatorID: "candidate" };
+ const internalSink = { ...mockResultPredicate, operatorID: "internal-sink", macroIdParent: macroID };
+
+ service.addOperator(internalSource, mockPoint);
+ service.addOperator(candidate, mockPoint);
+ service.addOperator(internalSink, mockPoint);
+
+ service.addLink({
+ linkID: "macro-link-1",
+ source: { operatorID: internalSource.operatorID, portID: internalSource.outputPorts[0].portID },
+ target: { operatorID: candidate.operatorID, portID: candidate.inputPorts[0].portID },
+ });
+ expect(texeraGraph.getOperator(candidate.operatorID)?.macroIdParent).toBeUndefined();
+
+ service.addLink({
+ linkID: "macro-link-2",
+ source: { operatorID: candidate.operatorID, portID: candidate.outputPorts[0].portID },
+ target: { operatorID: internalSink.operatorID, portID: internalSink.inputPorts[0].portID },
+ });
+ expect(texeraGraph.getOperator(candidate.operatorID)?.macroIdParent).toBe(macroID);
+ });
+
+ it("should not adopt macro boundary source or sink operators", () => {
+ const macroID = "macro-1";
+ const internalSource = { ...mockScanPredicate, operatorID: "internal-source", macroIdParent: macroID };
+ const internalSink = { ...mockResultPredicate, operatorID: "internal-sink", macroIdParent: macroID };
+ const externalSource = { ...mockScanPredicate, operatorID: "external-source" };
+ const externalSink = { ...mockResultPredicate, operatorID: "external-sink" };
+
+ service.addOperator(internalSource, mockPoint);
+ service.addOperator(internalSink, mockPoint);
+ service.addOperator(externalSource, mockPoint);
+ service.addOperator(externalSink, mockPoint);
+
+ service.addLink({
+ linkID: "boundary-link-1",
+ source: { operatorID: externalSource.operatorID, portID: externalSource.outputPorts[0].portID },
+ target: { operatorID: internalSink.operatorID, portID: internalSink.inputPorts[0].portID },
+ });
+ service.addLink({
+ linkID: "boundary-link-2",
+ source: { operatorID: internalSource.operatorID, portID: internalSource.outputPorts[0].portID },
+ target: { operatorID: externalSink.operatorID, portID: externalSink.inputPorts[0].portID },
+ });
+
+ expect(texeraGraph.getOperator(externalSource.operatorID)?.macroIdParent).toBeUndefined();
+ expect(texeraGraph.getOperator(externalSink.operatorID)?.macroIdParent).toBeUndefined();
+ });
+
+ it("should move expanded macro internals from the current frame position when dragging the macro tab", () => {
+ const macroID = service.createMacroAt({ x: 100, y: 100 }, "Macro 1");
+ const internalSource = { ...mockScanPredicate, operatorID: "internal-source", macroIdParent: macroID };
+ const internalSink = { ...mockResultPredicate, operatorID: "internal-sink", macroIdParent: macroID };
+
+ service.addOperator(internalSource, { x: 500, y: 300 });
+ service.addOperator(internalSink, { x: 650, y: 300 });
+ service.addLink({
+ linkID: "macro-internal-link",
+ source: { operatorID: internalSource.operatorID, portID: internalSource.outputPorts[0].portID },
+ target: { operatorID: internalSink.operatorID, portID: internalSink.inputPorts[0].portID },
+ });
+
+ const framePosition = service.getJointGraphWrapper().getMacroFramePosition(macroID) as { x: number; y: number };
+ const sourcePosition = service.getJointGraphWrapper().getElementPosition(internalSource.operatorID);
+ const macroNode = jointGraph.getCell(JointGraphWrapper.getMacroNodeID(macroID)) as joint.dia.Element;
+ macroNode.position(framePosition.x - 120, framePosition.y + 40);
+
+ expect(service.getJointGraphWrapper().getElementPosition(internalSource.operatorID)).toEqual({
+ x: sourcePosition.x - 120,
+ y: sourcePosition.y + 40,
+ });
+ });
+
+ it("should keep macro dragging enabled after centering an empty workflow", () => {
+ service.calculateTopLeftOperatorPosition();
+
+ const macroID = service.createMacroAt({ x: 100, y: 100 }, "Macro 1");
+ const internalSource = { ...mockScanPredicate, operatorID: "internal-source", macroIdParent: macroID };
+ const internalSink = { ...mockResultPredicate, operatorID: "internal-sink", macroIdParent: macroID };
+
+ service.addOperator(internalSource, { x: 500, y: 300 });
+ service.addOperator(internalSink, { x: 650, y: 300 });
+
+ const framePosition = service.getJointGraphWrapper().getMacroFramePosition(macroID) as { x: number; y: number };
+ const sourcePosition = service.getJointGraphWrapper().getElementPosition(internalSource.operatorID);
+ const macroNode = jointGraph.getCell(JointGraphWrapper.getMacroNodeID(macroID)) as joint.dia.Element;
+ macroNode.position(framePosition.x - 80, framePosition.y + 30);
+
+ expect(undoRedo.listenJointCommand).toBeTruthy();
+ expect(service.getJointGraphWrapper().getElementPosition(internalSource.operatorID)).toEqual({
+ x: sourcePosition.x - 80,
+ y: sourcePosition.y + 30,
+ });
+ });
+
it("should reformat the workflow", () => {
service.addOperator(mockScanPredicate, mockPoint);
service.addOperator(mockSentimentPredicate, mockPoint);
diff --git a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts
index e3ea66c024c..8416b933886 100644
--- a/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts
+++ b/frontend/src/app/workspace/service/workflow-graph/model/workflow-action.service.ts
@@ -31,6 +31,7 @@ import {
OperatorPredicate,
Point,
PortDescription,
+ WorkflowMacro,
} from "../../../types/workflow-common.interface";
import { JointUIService } from "../../joint-ui/joint-ui.service";
import { OperatorMetadataService } from "../../operator-metadata/operator-metadata.service";
@@ -96,7 +97,10 @@ export class WorkflowActionService {
public readonly resultPanelOpen$: Observable = this.resultPanelOpenSubject.asObservable();
private workflowSettings: WorkflowSettings;
+ private workflowMacros: WorkflowMacro[] = [];
private workflowResetSubject = new Subject();
+ private macroChangeSubject = new Subject();
+ private macroVisualRefreshSubject = new Subject();
constructor(
private operatorMetadataService: OperatorMetadataService,
@@ -122,6 +126,7 @@ export class WorkflowActionService {
this.undoRedoService.setUndoManager(this.texeraGraph.sharedModel.undoManager);
this.handleJointElementDrag();
+ this.handleMacroNodeDrag();
}
private getDefaultSettings(): WorkflowSettings {
@@ -354,6 +359,7 @@ export class WorkflowActionService {
this.deleteOperator(operatorID);
});
});
+ this.refreshMacroVisuals();
}
/**
@@ -390,27 +396,29 @@ export class WorkflowActionService {
public calculateTopLeftOperatorPosition(): void {
this.texeraGraph.bundleActions(() => {
this.undoRedoService.setListenJointCommand(false);
- const allOperators = this.getTexeraGraph().getAllOperators();
- if (allOperators.length === 0) return;
+ try {
+ const allOperators = this.getTexeraGraph().getAllOperators();
+ if (allOperators.length === 0) return;
- let minX = Infinity;
- let minY = Infinity;
+ let minX = Infinity;
+ let minY = Infinity;
- for (const operator of allOperators) {
- const operatorID = operator.operatorID;
- const position = this.jointGraphWrapper.getElementPosition(operatorID);
+ for (const operator of allOperators) {
+ const operatorID = operator.operatorID;
+ const position = this.jointGraphWrapper.getElementPosition(operatorID);
- if (position.x < minX) {
- minX = position.x;
- }
- if (position.y < minY) {
- minY = position.y;
+ if (position.x < minX) {
+ minX = position.x;
+ }
+ if (position.y < minY) {
+ minY = position.y;
+ }
}
- }
- this.centerPoint = { x: minX, y: minY };
-
- this.undoRedoService.setListenJointCommand(true);
+ this.centerPoint = { x: minX, y: minY };
+ } finally {
+ this.undoRedoService.setListenJointCommand(true);
+ }
});
}
@@ -423,6 +431,10 @@ export class WorkflowActionService {
this.texeraGraph.assertLinkNotExists(link);
this.texeraGraph.assertLinkIsValid(link);
this.texeraGraph.addLink(link);
+ if (this.adoptFullyInternalMacroOperators(link)) {
+ this.refreshMacroVisuals();
+ this.macroChangeSubject.next();
+ }
}
/**
@@ -457,6 +469,140 @@ export class WorkflowActionService {
});
}
+ public setOperatorsMacroParent(operatorIDs: readonly string[], macroIdParent?: string): void {
+ this.texeraGraph.bundleActions(() => {
+ operatorIDs.forEach(operatorID => {
+ this.texeraGraph.assertOperatorExists(operatorID);
+ const sharedOperator = this.texeraGraph.getSharedOperatorType(operatorID) as any;
+ if (macroIdParent) {
+ sharedOperator.set("macroIdParent", macroIdParent);
+ } else {
+ sharedOperator.delete("macroIdParent");
+ }
+ });
+ });
+ this.refreshMacroVisuals();
+ this.macroChangeSubject.next();
+ }
+
+ public createMacroAt(position: Point, name = "Workflow Macro"): string {
+ const macroID = this.workflowUtilService.getGroupRandomUUID().replace(/^group-/, "macro-");
+ this.workflowMacros = [...this.workflowMacros, { macroID, name, position }];
+ this.refreshMacroVisuals();
+ this.macroChangeSubject.next();
+ return macroID;
+ }
+
+ public getWorkflowMacro(macroID: string): WorkflowMacro | undefined {
+ return this.workflowMacros.find(macro => macro.macroID === macroID);
+ }
+
+ public getWorkflowMacros(): readonly WorkflowMacro[] {
+ return this.workflowMacros;
+ }
+
+ public getMacroVisualRefreshStream(): Observable {
+ return this.macroVisualRefreshSubject.asObservable();
+ }
+
+ public setMacroCollapsed(macroID: string, collapsed: boolean): void {
+ const visiblePosition = collapsed ? this.getCurrentMacroNodePosition(macroID) : undefined;
+ this.workflowMacros = this.workflowMacros.map(macro =>
+ macro.macroID === macroID ? { ...macro, collapsed, position: visiblePosition ?? macro.position } : macro
+ );
+ this.refreshMacroVisuals();
+ this.macroChangeSubject.next();
+ }
+
+ public deleteMacros(macroIDs: readonly string[]): void {
+ if (!macroIDs.length) return;
+ const macroIDSet = new Set(macroIDs);
+ const operatorIDs = this.texeraGraph
+ .getAllOperators()
+ .filter(operator => operator.macroIdParent !== undefined && macroIDSet.has(operator.macroIdParent))
+ .map(operator => operator.operatorID);
+ if (operatorIDs.length) {
+ this.deleteOperatorsAndLinks(operatorIDs);
+ }
+ this.workflowMacros = this.workflowMacros.filter(macro => !macroIDSet.has(macro.macroID));
+ this.refreshMacroVisuals();
+ this.macroChangeSubject.next();
+ }
+
+ public replaceMacroWorkflow(
+ macroID: string,
+ workflowContent: WorkflowContent,
+ workflowId?: number,
+ workflowName?: string
+ ): void {
+ this.deleteOperatorsAndLinks(
+ this.texeraGraph
+ .getAllOperators()
+ .filter(operator => operator.macroIdParent === macroID)
+ .map(operator => operator.operatorID)
+ );
+ this.workflowMacros = this.workflowMacros.map(macro =>
+ macro.macroID === macroID
+ ? {
+ ...macro,
+ name: workflowName ?? macro.name,
+ workflowId,
+ workflowName,
+ }
+ : macro
+ );
+ this.addWorkflowContentToMacro(
+ macroID,
+ workflowContent,
+ this.getWorkflowMacro(macroID)?.position ?? this.centerPoint
+ );
+ this.refreshMacroVisuals();
+ this.macroChangeSubject.next();
+ }
+
+ private addWorkflowContentToMacro(macroID: string, workflowContent: WorkflowContent, origin: Point): void {
+ const idMap = new Map();
+ const sourcePositions = Object.values(workflowContent.operatorPositions ?? {});
+ const minX = Math.min(...sourcePositions.map(pos => pos.x), 0);
+ const minY = Math.min(...sourcePositions.map(pos => pos.y), 0);
+
+ const operatorsAndPositions = workflowContent.operators.map(operator => {
+ const operatorID = `${operator.operatorType}-${this.workflowUtilService.getOperatorRandomUUID()}`;
+ idMap.set(operator.operatorID, operatorID);
+ const sourcePosition = workflowContent.operatorPositions[operator.operatorID] ?? { x: minX, y: minY };
+ return {
+ op: this.workflowUtilService.updateOperatorVersion({
+ ...operator,
+ operatorID,
+ macroIdParent: macroID,
+ }),
+ pos: {
+ x: origin.x + sourcePosition.x - minX,
+ y: origin.y + sourcePosition.y - minY + 80,
+ },
+ };
+ });
+
+ const links = workflowContent.links
+ .filter(link => idMap.has(link.source.operatorID) && idMap.has(link.target.operatorID))
+ .map(link => ({
+ linkID: this.workflowUtilService.getLinkRandomUUID(),
+ source: { ...link.source, operatorID: idMap.get(link.source.operatorID) as string },
+ target: { ...link.target, operatorID: idMap.get(link.target.operatorID) as string },
+ }));
+
+ const commentBoxes = (workflowContent.commentBoxes ?? []).map(commentBox => ({
+ ...commentBox,
+ commentBoxID: this.workflowUtilService.getCommentBoxRandomUUID(),
+ commentBoxPosition: {
+ x: origin.x + commentBox.commentBoxPosition.x - minX,
+ y: origin.y + commentBox.commentBoxPosition.y - minY + 80,
+ },
+ }));
+
+ this.addOperatorsAndLinks(operatorsAndPositions, links, commentBoxes);
+ }
+
public setPortProperty(operatorPortID: LogicalPort, newProperty: object) {
this.texeraGraph.bundleActions(() => {
this.texeraGraph.setPortProperty(operatorPortID, newProperty);
@@ -641,12 +787,14 @@ export class WorkflowActionService {
this.jointGraphWrapper.jointGraph.clear();
if (workflow === undefined) {
+ this.workflowMacros = [];
this.setNewSharedModel();
return;
}
const workflowContent: WorkflowContent = workflow.content;
this.workflowSettings = workflowContent.settings || this.getDefaultSettings();
+ this.workflowMacros = workflowContent.macros ?? [];
let operatorsAndPositions: { op: OperatorPredicate; pos: Point }[] = [];
workflowContent.operators.forEach(op => {
@@ -664,6 +812,7 @@ export class WorkflowActionService {
operatorsAndPositions = this.updateOperatorVersions(operatorsAndPositions);
this.addOperatorsAndLinks(operatorsAndPositions, links, commentBoxes);
+ this.refreshMacroVisuals();
// restore the view point
if (restoreViewport) {
@@ -701,6 +850,7 @@ export class WorkflowActionService {
this.getTexeraGraph().getOperatorVersionChangedStream(),
this.getTexeraGraph().getPortDisplayNameChangedSubject(),
this.getTexeraGraph().getPortPropertyChangedStream(),
+ this.macroChangeSubject.asObservable(),
this.workflowResetSubject.asObservable()
);
}
@@ -748,6 +898,7 @@ export class WorkflowActionService {
const operatorPositions: { [key: string]: Point } = {};
const commentBoxes = texeraGraph.getAllCommentBoxes();
const settings = this.workflowSettings;
+ const macros = this.workflowMacros;
texeraGraph
.getAllOperators()
@@ -763,6 +914,7 @@ export class WorkflowActionService {
links,
commentBoxes,
settings,
+ macros,
};
}
@@ -859,56 +1011,174 @@ export class WorkflowActionService {
.pipe(
filter(() => this.jointGraphWrapper.getListenPositionChange()),
filter(() => this.undoRedoService.listenJointCommand),
- filter(() => this.texeraGraph.getSyncTexeraGraph()),
- filter(movedElement =>
- this.jointGraphWrapper
- .getCurrentHighlightedOperatorIDs()
- .concat(this.jointGraphWrapper.getCurrentHighlightedCommentBoxIDs())
- .includes(movedElement.elementID)
- )
+ filter(() => this.texeraGraph.getSyncTexeraGraph())
)
.subscribe(movedElement => {
- this.texeraGraph.bundleActions(() => {
- if (
- this.texeraGraph.sharedModel.elementPositionMap.get(movedElement.elementID) !== movedElement.newPosition
- ) {
- // For syncing ops/comment boxes in shared editing
+ const movedOperator = this.texeraGraph.sharedModel.operatorIDMap.has(movedElement.elementID)
+ ? this.texeraGraph.getOperator(movedElement.elementID)
+ : undefined;
+ const selectedElements = this.jointGraphWrapper
+ .getCurrentHighlightedOperatorIDs()
+ .concat(this.jointGraphWrapper.getCurrentHighlightedCommentBoxIDs());
+
+ if (selectedElements.includes(movedElement.elementID)) {
+ this.texeraGraph.bundleActions(() => {
+ if (
+ this.texeraGraph.sharedModel.elementPositionMap.get(movedElement.elementID) !== movedElement.newPosition
+ ) {
+ // For syncing ops/comment boxes in shared editing
+ this.texeraGraph.sharedModel.elementPositionMap.set(movedElement.elementID, movedElement.newPosition);
+ // For moving all highlighted operators
+ const selectedElements = this.jointGraphWrapper
+ .getCurrentHighlightedOperatorIDs()
+ .concat(this.jointGraphWrapper.getCurrentHighlightedCommentBoxIDs());
+ const offsetX = movedElement.newPosition.x - movedElement.oldPosition.x;
+ const offsetY = movedElement.newPosition.y - movedElement.oldPosition.y;
+ this.jointGraphWrapper.setListenPositionChange(false);
+ this.undoRedoService.setListenJointCommand(false);
+ // Persistence and shared-editing syncing for comment boxes have different interfaces.
+ // Setting positions inside commentBoxes here only for persistence.
+ // Syncing uses elementPositionMap.
+ selectedElements
+ .filter(elementID => elementID.includes("commentBox"))
+ .forEach(elementID => {
+ this.texeraGraph.sharedModel.commentBoxMap
+ .get(elementID)
+ ?.set("commentBoxPosition", this.jointGraphWrapper.getElementPosition(elementID));
+ });
+ // Move other highlighted operators.
+ selectedElements
+ .filter(elementID => elementID !== movedElement.elementID)
+ .forEach(elementID => {
+ this.jointGraphWrapper.setElementPosition(elementID, offsetX, offsetY);
+ this.texeraGraph.sharedModel.elementPositionMap.set(
+ elementID,
+ this.jointGraphWrapper.getElementPosition(elementID)
+ );
+ });
+ this.jointGraphWrapper.setListenPositionChange(true);
+ this.undoRedoService.setListenJointCommand(true);
+ }
+ });
+ } else {
+ if (movedOperator && this.texeraGraph.sharedModel.elementPositionMap.has(movedElement.elementID)) {
this.texeraGraph.sharedModel.elementPositionMap.set(movedElement.elementID, movedElement.newPosition);
- // For moving all highlighted operators
- const selectedElements = this.jointGraphWrapper
- .getCurrentHighlightedOperatorIDs()
- .concat(this.jointGraphWrapper.getCurrentHighlightedCommentBoxIDs());
- const offsetX = movedElement.newPosition.x - movedElement.oldPosition.x;
- const offsetY = movedElement.newPosition.y - movedElement.oldPosition.y;
+ }
+ }
+
+ if (movedOperator?.macroIdParent) {
+ this.refreshMacroVisuals();
+ }
+ });
+ }
+
+ private handleMacroNodeDrag(): void {
+ this.jointGraphWrapper
+ .getMacroNodePositionChangeEvent()
+ .pipe(
+ filter(() => this.jointGraphWrapper.getListenPositionChange()),
+ filter(() => this.undoRedoService.listenJointCommand)
+ )
+ .subscribe(({ macroID, oldPosition, newPosition }) => {
+ const macro = this.getWorkflowMacro(macroID);
+ if (!macro) return;
+ const referencePosition =
+ macro.collapsed ?? false ? oldPosition : this.jointGraphWrapper.getMacroFramePosition(macroID) ?? oldPosition;
+ const offsetX = newPosition.x - referencePosition.x;
+ const offsetY = newPosition.y - referencePosition.y;
+
+ this.workflowMacros = this.workflowMacros.map(macro =>
+ macro.macroID === macroID ? { ...macro, position: newPosition } : macro
+ );
+ if ((macro.collapsed ?? false) && (offsetX !== 0 || offsetY !== 0)) {
+ this.refreshMacroVisuals();
+ } else if (offsetX !== 0 || offsetY !== 0) {
+ const internalOperatorIDs = this.texeraGraph
+ .getAllOperators()
+ .filter(operator => operator.macroIdParent === macroID)
+ .map(operator => operator.operatorID);
+ this.texeraGraph.bundleActions(() => {
this.jointGraphWrapper.setListenPositionChange(false);
this.undoRedoService.setListenJointCommand(false);
- // Persistence and shared-editing syncing for comment boxes have different interfaces.
- // Setting positions inside commentBoxes here only for persistence.
- // Syncing uses elementPositionMap.
- selectedElements
- .filter(elementID => elementID.includes("commentBox"))
- .forEach(elementID => {
- this.texeraGraph.sharedModel.commentBoxMap
- .get(elementID)
- ?.set("commentBoxPosition", this.jointGraphWrapper.getElementPosition(elementID));
- });
- // Move other highlighted operators.
- selectedElements
- .filter(elementID => elementID !== movedElement.elementID)
- .forEach(elementID => {
- this.jointGraphWrapper.setElementPosition(elementID, offsetX, offsetY);
+ try {
+ internalOperatorIDs.forEach(operatorID => {
+ this.jointGraphWrapper.setElementPosition(operatorID, offsetX, offsetY);
this.texeraGraph.sharedModel.elementPositionMap.set(
- elementID,
- this.jointGraphWrapper.getElementPosition(elementID)
+ operatorID,
+ this.jointGraphWrapper.getElementPosition(operatorID)
);
});
- this.jointGraphWrapper.setListenPositionChange(true);
- this.undoRedoService.setListenJointCommand(true);
- }
- });
+ } finally {
+ this.jointGraphWrapper.setListenPositionChange(true);
+ this.undoRedoService.setListenJointCommand(true);
+ }
+ });
+ this.refreshMacroVisuals();
+ }
+ this.macroChangeSubject.next();
});
}
+ private refreshMacroVisuals(): void {
+ this.jointGraphWrapper.refreshMacroFrames(
+ this.texeraGraph.getAllOperators(),
+ this.workflowMacros,
+ this.texeraGraph.getAllLinks()
+ );
+ this.macroVisualRefreshSubject.next();
+ }
+
+ private adoptFullyInternalMacroOperators(link: OperatorLink): boolean {
+ const adoptedOperatorIDs = [link.source.operatorID, link.target.operatorID].filter(
+ (operatorID, index, ids) =>
+ ids.indexOf(operatorID) === index && this.getMacroIDForFullyInternalOperator(operatorID)
+ );
+ adoptedOperatorIDs.forEach(operatorID => {
+ const macroID = this.getMacroIDForFullyInternalOperator(operatorID);
+ if (macroID) {
+ (this.texeraGraph.getSharedOperatorType(operatorID) as any).set("macroIdParent", macroID);
+ }
+ });
+ return adoptedOperatorIDs.length > 0;
+ }
+
+ private getMacroIDForFullyInternalOperator(operatorID: string): string | undefined {
+ const operator = this.texeraGraph.getOperator(operatorID);
+ if (!operator || operator.macroIdParent) return undefined;
+
+ const operatorsByID = new Map(this.texeraGraph.getAllOperators().map(operator => [operator.operatorID, operator]));
+ const incidentLinks = this.texeraGraph
+ .getAllLinks()
+ .filter(link => link.source.operatorID === operatorID || link.target.operatorID === operatorID);
+ if (!incidentLinks.length) return undefined;
+
+ const neighborMacroIDs = incidentLinks.map(link => {
+ const neighborID = link.source.operatorID === operatorID ? link.target.operatorID : link.source.operatorID;
+ return operatorsByID.get(neighborID)?.macroIdParent;
+ });
+ const macroIDs = Array.from(new Set(neighborMacroIDs.filter((macroID): macroID is string => Boolean(macroID))));
+ if (macroIDs.length !== 1 || neighborMacroIDs.some(macroID => macroID !== macroIDs[0])) return undefined;
+
+ const macroID = macroIDs[0];
+ const hasInternalInput = incidentLinks.some(
+ link =>
+ link.target.operatorID === operatorID && operatorsByID.get(link.source.operatorID)?.macroIdParent === macroID
+ );
+ const hasInternalOutput = incidentLinks.some(
+ link =>
+ link.source.operatorID === operatorID && operatorsByID.get(link.target.operatorID)?.macroIdParent === macroID
+ );
+ return hasInternalInput && hasInternalOutput ? macroID : undefined;
+ }
+
+ private getCurrentMacroNodePosition(macroID: string): Point | undefined {
+ try {
+ return this.jointGraphWrapper.getElementPosition(JointGraphWrapper.getMacroNodeID(macroID));
+ } catch {
+ return undefined;
+ }
+ }
+
private updateOperatorVersions(operatorsAndPositions: { op: OperatorPredicate; pos: Point }[]) {
const updatedOperators: { op: OperatorPredicate; pos: Point }[] = [];
for (const operatorsAndPosition of operatorsAndPositions) {
diff --git a/frontend/src/app/workspace/types/workflow-common.interface.ts b/frontend/src/app/workspace/types/workflow-common.interface.ts
index 3fb3aaa3d4b..3e711776839 100644
--- a/frontend/src/app/workspace/types/workflow-common.interface.ts
+++ b/frontend/src/app/workspace/types/workflow-common.interface.ts
@@ -76,6 +76,7 @@ export interface OperatorPredicate
viewResult?: boolean;
markedForReuse?: boolean;
customDisplayName?: string;
+ macroIdParent?: string;
}> {}
export interface Comment
@@ -92,6 +93,16 @@ export interface CommentBox {
commentBoxPosition: Point;
}
+export interface WorkflowMacro
+ extends Readonly<{
+ macroID: string;
+ name: string;
+ position: Point;
+ workflowId?: number;
+ workflowName?: string;
+ collapsed?: boolean;
+ }> {}
+
export interface OperatorLink
extends Readonly<{
linkID: string;
diff --git a/frontend/src/assets/operator_images/WorkflowMacro.png b/frontend/src/assets/operator_images/WorkflowMacro.png
new file mode 100644
index 00000000000..6ca97e62b05
Binary files /dev/null and b/frontend/src/assets/operator_images/WorkflowMacro.png differ