This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-8275-b28faee36ff27bca7d47acb13de27c99828f6981
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 8aea604ab34acba553b2c438810abcabef550e67
Author: yangzhang75 <[email protected]>
AuthorDate: Tue Sep 1 18:02:18 2026 +0000

    feat(workflow): shared types and collab-model support for Form View (#8275)
    
    ### What changes were proposed in this PR?
    
    Give the frontend (and agent-service) the shared types and
    collaborative-graph hooks so a Form View loads into the same live
    collaborative workflow graph the canvas uses. No form UI here; that
    builds on this.
    
    - **Types** (`common/type/workflow.ts`): `FormFieldBinding` (one exposed
    input bound to an operator property), `FormFieldOverride` (per-sub-field
    hide/rename), `FormBindingConfig` (instruction + fields + result
    operators), and `getDefaultFormBinding()`. `WorkflowContent.formBinding`
    rides in `content`, so it is saved/cloned/versioned/published with the
    workflow. A `DefaultView` enum (canvas | form) and
    `WorkflowMetadata.defaultView` mirror the backend column from #8125.
    - **Collab model** (`WorkflowActionService`, `shared-model.ts`):
    form-binding state (`get`/`set`/`hydrateFormBinding` +
    `formBindingChanged$`), hydrated on load and serialized into content.
    Opening a workflow does not announce an edit, so it never triggers a
    save, and `reloadWorkflow(undefined)` resets the definition so a blank
    workflow cannot inherit the previous one's. A coeditor cursor now starts
    inactive until the mouse enters a canvas (a stray dot otherwise lingered
    on the Form View, which has no canvas to move over).
    - **Client** (`WorkflowPersistService.setDefaultView`): PUTs the chosen
    view to the set-default-view endpoint from #8125.
    - **agent-service**: carries `formBinding` through opaquely (captured on
    load, re-emitted on save only when present, cleared on reset), so an
    agent save cannot drop it.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8015. Part of the Form View parent issue #8011; builds on #8125.
    
    ### How was this PR tested?
    
    Frontend vitest (`workflow-action.service`, `shared-model`,
    `workflow-persist`) and agent-service `bun test` (`workflow-state`),
    including a test that a blank workflow clears any leftover form
    definition; the affected suites run green.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Co-authored with Claude Code.
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
    Co-authored-by: Meng Wang <[email protected]>
---
 agent-service/src/agent/workflow-state.spec.ts     |  33 +++++++
 agent-service/src/agent/workflow-state.ts          |   9 ++
 agent-service/src/types/workflow.ts                |   5 +
 .../workflow-persist.service.spec.ts               |  22 +++++
 .../workflow-persist/workflow-persist.service.ts   |   8 ++
 frontend/src/app/common/type/workflow.ts           |  48 +++++++++
 .../dashboard/type/workflow-metadata.interface.ts  |  10 ++
 .../workflow-graph/model/shared-model.spec.ts      |  13 ++-
 .../service/workflow-graph/model/shared-model.ts   |   5 +-
 .../model/workflow-action.service.spec.ts          | 110 +++++++++++++++++++++
 .../model/workflow-action.service.ts               |  59 ++++++++++-
 11 files changed, 319 insertions(+), 3 deletions(-)

diff --git a/agent-service/src/agent/workflow-state.spec.ts 
b/agent-service/src/agent/workflow-state.spec.ts
index 3c17b352de..8dca5ee03e 100644
--- a/agent-service/src/agent/workflow-state.spec.ts
+++ b/agent-service/src/agent/workflow-state.spec.ts
@@ -398,4 +398,37 @@ describe("WorkflowState - workflow content round-trip", () 
=> {
     expect(out.settings).toEqual({ dataTransferBatchSize: 400 }); // 
DEFAULT_WORKFLOW_SETTINGS
     expect(out.commentBoxes).toEqual([]);
   });
+
+  test("carries the Form View definition through an agent save untouched", () 
=> {
+    const state = new WorkflowState();
+    const formBinding = {
+      fields: [{ id: "f1", operatorID: "op1", propertyKey: "limit", 
displayName: "Limit" }],
+      resultOperatorIds: ["op2"],
+    };
+    state.setWorkflowContent({
+      operators: [makeOperator("op1"), makeOperator("op2")],
+      operatorPositions: {},
+      links: [],
+      commentBoxes: [],
+      settings: { dataTransferBatchSize: 400 },
+      formBinding,
+    });
+
+    // The agent edits the graph but never the form definition; it must come 
back intact.
+    state.addOperator(makeOperator("op3"));
+    expect(state.getWorkflowContent().formBinding).toEqual(formBinding);
+  });
+
+  test("adds no formBinding key when the workflow never had one", () => {
+    const state = new WorkflowState();
+    state.setWorkflowContent({
+      operators: [],
+      operatorPositions: {},
+      links: [],
+      commentBoxes: [],
+      settings: { dataTransferBatchSize: 400 },
+    });
+
+    expect("formBinding" in state.getWorkflowContent()).toBe(false);
+  });
 });
diff --git a/agent-service/src/agent/workflow-state.ts 
b/agent-service/src/agent/workflow-state.ts
index ef408eee24..7d27427642 100644
--- a/agent-service/src/agent/workflow-state.ts
+++ b/agent-service/src/agent/workflow-state.ts
@@ -56,6 +56,9 @@ export class WorkflowState {
   private operatorPositions: Map<string, Point> = new Map();
   private commentBoxes: CommentBox[] = [];
   private settings: WorkflowSettings = { ...DEFAULT_WORKFLOW_SETTINGS };
+  // Opaque Form View definition carried through unchanged (the agent never 
touches it);
+  // see WorkflowContent.formBinding.
+  private formBinding: unknown = undefined;
   private operatorsToViewResult: Set<string> = new Set();
 
   private operatorIdCounter: number = 0;
@@ -398,6 +401,9 @@ export class WorkflowState {
       links: this.getAllLinks(),
       commentBoxes: [...this.commentBoxes],
       settings: { ...this.settings },
+      // Only re-emit the key when the loaded workflow carried one, so plain 
workflows
+      // stay byte-identical (JSON.stringify would otherwise add 
"formBinding":null).
+      ...(this.formBinding !== undefined ? { formBinding: this.formBinding } : 
{}),
     };
   }
 
@@ -422,6 +428,8 @@ export class WorkflowState {
     this.commentBoxes = content.commentBoxes ? [...content.commentBoxes] : [];
 
     this.settings = content.settings ? { ...content.settings } : { 
...DEFAULT_WORKFLOW_SETTINGS };
+
+    this.formBinding = content.formBinding;
   }
 
   toLogicalPlan(): LogicalPlan {
@@ -472,6 +480,7 @@ export class WorkflowState {
     this.operatorPositions.clear();
     this.commentBoxes = [];
     this.settings = { ...DEFAULT_WORKFLOW_SETTINGS };
+    this.formBinding = undefined;
     this.operatorsToViewResult.clear();
     this.validationErrors = {};
     this.workflowEmpty = true;
diff --git a/agent-service/src/types/workflow.ts 
b/agent-service/src/types/workflow.ts
index 52c6493cf5..e6f9b0c15c 100644
--- a/agent-service/src/types/workflow.ts
+++ b/agent-service/src/types/workflow.ts
@@ -114,6 +114,11 @@ export interface WorkflowContent {
   readonly links: OperatorLink[];
   readonly commentBoxes: CommentBox[];
   readonly settings: WorkflowSettings;
+  // The Form View definition (which properties are exposed as form fields). 
The agent
+  // never reads or edits it, but it lives in workflow.content and must 
survive an agent
+  // save untouched -- rebuilding content without it would leave a workflow 
whose default_view
+  // is form with a form that has no fields. Carried through opaquely.
+  readonly formBinding?: unknown;
 }
 
 type AttributeType = "string" | "integer" | "double" | "boolean" | "long" | 
"timestamp" | "binary";
diff --git 
a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts
 
b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts
index 6638aa8a6e..a5e4463037 100644
--- 
a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts
+++ 
b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.spec.ts
@@ -29,6 +29,7 @@ import {
   WORKFLOW_CREATE_URL,
   WORKFLOW_DUPLICATE_URL,
   WORKFLOW_DELETE_URL,
+  WORKFLOW_SET_DEFAULT_VIEW_URL,
   WORKFLOW_LIST_URL,
   WORKFLOW_UPDATENAME_URL,
   WORKFLOW_UPDATEDESCRIPTION_URL,
@@ -42,6 +43,7 @@ import { jsonCast } from "../../util/storage";
 import { Workflow, WorkflowContent } from "../../type/workflow";
 import { AppSettings } from "../../app-setting";
 import { DashboardWorkflow } from 
"../../../dashboard/type/dashboard-workflow.interface";
+import { DefaultView } from 
"../../../dashboard/type/workflow-metadata.interface";
 import { SearchFilterParameters, toQueryStrings } from 
"../../../dashboard/type/search-filter-parameters";
 import { NotificationService } from "../notification/notification.service";
 import { last } from "rxjs/operators";
@@ -489,5 +491,25 @@ describe("WorkflowPersistService", () => {
       req.flush(sizes);
       expect(result).toEqual(sizes);
     });
+
+    it("setDefaultView PUTs the chosen view to the set-default-view url", () 
=> {
+      let responded = false;
+      service.setDefaultView(30, DefaultView.FORM).subscribe(() => (responded 
= true));
+
+      const req = 
httpTestingController.expectOne(`${API}/${WORKFLOW_SET_DEFAULT_VIEW_URL}/30`);
+      expect(req.request.method).toBe("PUT");
+      expect(req.request.body).toEqual({ view: DefaultView.FORM });
+      req.flush(null);
+      expect(responded).toBe(true);
+    });
+
+    it("setDefaultView can set the default back to canvas", () => {
+      service.setDefaultView(31, DefaultView.CANVAS).subscribe();
+
+      const req = 
httpTestingController.expectOne(`${API}/${WORKFLOW_SET_DEFAULT_VIEW_URL}/31`);
+      expect(req.request.method).toBe("PUT");
+      expect(req.request.body).toEqual({ view: DefaultView.CANVAS });
+      req.flush(null);
+    });
   });
 });
diff --git 
a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts 
b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts
index 0e3c8d34c2..8e2203addd 100644
--- 
a/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts
+++ 
b/frontend/src/app/common/service/workflow-persist/workflow-persist.service.ts
@@ -24,6 +24,7 @@ import { catchError, filter, map } from "rxjs/operators";
 import { AppSettings } from "../../app-setting";
 import { Workflow, WorkflowContent } from "../../type/workflow";
 import { DashboardWorkflow } from 
"../../../dashboard/type/dashboard-workflow.interface";
+import { DefaultView } from 
"../../../dashboard/type/workflow-metadata.interface";
 import { WorkflowUtilService } from 
"../../../workspace/service/workflow-graph/util/workflow-util.service";
 import { NotificationService } from "../notification/notification.service";
 import { SearchFilterParameters, toQueryStrings } from 
"../../../dashboard/type/search-filter-parameters";
@@ -47,6 +48,7 @@ export const WORKFLOW_PUBLIC_WORKFLOW = WORKFLOW_BASE_URL + 
"/publicised";
 export const WORKFLOW_DESCRIPTION = WORKFLOW_BASE_URL + 
"/workflow_description";
 export const WORKFLOW_USER_ACCESS = WORKFLOW_BASE_URL + 
"/workflow_user_access";
 export const WORKFLOW_SIZE = WORKFLOW_BASE_URL + "/size";
+export const WORKFLOW_SET_DEFAULT_VIEW_URL = WORKFLOW_BASE_URL + 
"/set-default-view";
 
 export const DEFAULT_WORKFLOW_NAME = "Untitled workflow";
 
@@ -290,4 +292,10 @@ export class WorkflowPersistService {
     });
     return this.http.get<Record<number, 
number>>(`${AppSettings.getApiEndpoint()}/${WORKFLOW_SIZE}`, { params });
   }
+
+  /** Set which view the workflow opens in by default. Only this preference 
moves; the Form
+   *  View definition lives in the workflow content, so switching the default 
keeps the setup. */
+  public setDefaultView(wid: number, view: DefaultView): Observable<void> {
+    return 
this.http.put<void>(`${AppSettings.getApiEndpoint()}/${WORKFLOW_SET_DEFAULT_VIEW_URL}/${wid}`,
 { view });
+  }
 }
diff --git a/frontend/src/app/common/type/workflow.ts 
b/frontend/src/app/common/type/workflow.ts
index 8e1c1c7e85..d0da775756 100644
--- a/frontend/src/app/common/type/workflow.ts
+++ b/frontend/src/app/common/type/workflow.ts
@@ -30,6 +30,51 @@ export interface WorkflowSettings {
   executionMode: ExecutionMode;
 }
 
+/**
+ * One input exposed on the Form View: a binding to a single operator 
property. That property
+ * is always the live value (filling the form is the same edit as changing it 
on the canvas);
+ * the rest is presentation. `id` is a stable identity so reorder/remove never 
use the raw key.
+ */
+export interface FormFieldBinding {
+  id: string;
+  operatorID: string;
+  /** The operator property this input writes to. */
+  propertyKey: string;
+  displayName: string;
+  helpText?: string;
+  /** Per-sub-field overrides within the property, keyed by field path 
(`alias`, `predicates.value`
+   *  -- array indices dropped, so one entry covers every row). Only where the 
author changed it. */
+  overrides?: { [path: string]: FormFieldOverride };
+}
+
+export interface FormFieldOverride {
+  /** Kept out of the reader's form. The value the author set still applies. */
+  hidden?: boolean;
+  /** Replaces the schema's label. Empty or absent keeps the schema's own. */
+  displayName?: string;
+}
+
+/**
+ * How a workflow presents itself on the Form View. Which view a workflow 
opens in by default
+ * lives in `workflow.default_view` (canvas or form), and nothing here affects 
execution.
+ */
+export interface FormBindingConfig {
+  instruction?: {
+    /** Empty title hides the heading rather than showing a placeholder. */
+    title?: string;
+    /** Markdown. */
+    body: string;
+  };
+  /** Array order is display order; the author reorders by dragging. */
+  fields: FormFieldBinding[];
+  /** Operators whose results are shown under the workflow after a run. */
+  resultOperatorIds: string[];
+}
+
+export function getDefaultFormBinding(): FormBindingConfig {
+  return { fields: [], resultOperatorIds: [] };
+}
+
 /**
  * WorkflowContent is used to store the information of the workflow
  *  1. all existing operators and their properties
@@ -49,6 +94,9 @@ export interface WorkflowContent
     links: OperatorLink[];
     commentBoxes: CommentBox[];
     settings: WorkflowSettings;
+    /** Present once an author set up the Form View. Rides in the content 
(like `settings`),
+     *  so it is saved/cloned/versioned/published with the workflow for free. 
*/
+    formBinding?: FormBindingConfig;
   }> {}
 
 export type Workflow = { content: WorkflowContent } & WorkflowMetadata;
diff --git a/frontend/src/app/dashboard/type/workflow-metadata.interface.ts 
b/frontend/src/app/dashboard/type/workflow-metadata.interface.ts
index 69df43f34f..bc310180b7 100644
--- a/frontend/src/app/dashboard/type/workflow-metadata.interface.ts
+++ b/frontend/src/app/dashboard/type/workflow-metadata.interface.ts
@@ -17,6 +17,13 @@
  * under the License.
  */
 
+/** Which view a workflow opens in by default. Both views stay reachable from 
each other;
+ *  this only picks the landing view. */
+export enum DefaultView {
+  CANVAS = "CANVAS",
+  FORM = "FORM",
+}
+
 export interface WorkflowMetadata {
   name: string;
   description: string | undefined;
@@ -25,4 +32,7 @@ export interface WorkflowMetadata {
   lastModifiedTime: number | undefined;
   isPublished: number;
   readonly: boolean;
+  /** Which view this workflow opens in by default. From the workflow row, so 
listings need
+   *  not load content. Absent on payloads predating the column (treat as 
CANVAS). */
+  defaultView?: DefaultView;
 }
diff --git 
a/frontend/src/app/workspace/service/workflow-graph/model/shared-model.spec.ts 
b/frontend/src/app/workspace/service/workflow-graph/model/shared-model.spec.ts
index d2254ce966..b6cf119b0d 100644
--- 
a/frontend/src/app/workspace/service/workflow-graph/model/shared-model.spec.ts
+++ 
b/frontend/src/app/workspace/service/workflow-graph/model/shared-model.spec.ts
@@ -58,10 +58,21 @@ describe("SharedModel", () => {
 
       const state = sharedModel.awareness.getLocalState() as unknown as 
CoeditorState;
       expect(state.user).toEqual({ ...user, clientId: sharedModel.clientId });
-      expect(state.isActive).toBe(true);
+      // isActive ("my mouse is on a canvas") starts false: starting true 
published a pointer
+      // at the origin that nobody placed. The canvas overwrote it on first 
move, but the Form
+      // View keeps the workflow collapsed, so a stray dot sat on coeditors' 
screens.
+      expect(state.isActive).toBe(false);
       expect(state.userCursor).toEqual({ x: 0, y: 0 });
     });
 
+    it("becomes active once the mouse enters a canvas", () => {
+      const sharedModel = build(1, user);
+
+      sharedModel.updateAwareness("isActive", true);
+
+      expect((sharedModel.awareness.getLocalState() as unknown as 
CoeditorState).isActive).toBe(true);
+    });
+
     it("publishes no coeditor state when constructed anonymously", () => {
       const sharedModel = build(1);
       // Awareness starts out with an empty local state; without a user 
nothing is added.
diff --git 
a/frontend/src/app/workspace/service/workflow-graph/model/shared-model.ts 
b/frontend/src/app/workspace/service/workflow-graph/model/shared-model.ts
index 0974426bcf..f9546a64a8 100644
--- a/frontend/src/app/workspace/service/workflow-graph/model/shared-model.ts
+++ b/frontend/src/app/workspace/service/workflow-graph/model/shared-model.ts
@@ -88,7 +88,10 @@ export class SharedModel {
     if (this.user) {
       const userState: CoeditorState = {
         user: { ...this.user, clientId: this.clientId },
-        isActive: true,
+        // false until the mouse enters a canvas: starting true published a 
pointer at the
+        // origin that nobody placed. The canvas overwrote it on first move, 
but the Form
+        // View keeps the workflow collapsed, so a stray dot sat on coeditors' 
screens.
+        isActive: false,
         userCursor: { x: 0, y: 0 },
       };
       this.awareness.setLocalState(userState);
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 7dbfaf788d..52908fdb36 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
@@ -794,6 +794,116 @@ describe("WorkflowActionService", () => {
     expect(workflow.content.links.length).toEqual(1);
   });
 
+  describe("formBinding (Form View definition)", () => {
+    const config = {
+      instruction: { title: "How to use this", body: "Pick a file, then Run." 
},
+      fields: [
+        {
+          id: "f1",
+          operatorID: mockScanPredicate.operatorID,
+          propertyKey: "tableName",
+          displayName: "Input table",
+          helpText: "Which table to read.",
+        },
+      ],
+      resultOperatorIds: [mockResultPredicate.operatorID],
+    };
+
+    it("should start empty for a workflow that was never set up", () => {
+      expect(service.getFormBinding()).toEqual({ fields: [], 
resultOperatorIds: [] });
+    });
+
+    it("should round-trip through workflow content", () => {
+      service.addOperator(mockScanPredicate, { x: 10, y: 20 });
+      service.setFormBinding(config);
+
+      expect(service.getWorkflowContent().formBinding).toEqual(config);
+    });
+
+    // The definition must survive being saved and opened again, which is what 
makes it
+    // travel with clone, version and publish for free.
+    it("should be restored when a workflow carrying one is reloaded", () => {
+      const workflow: Workflow = {
+        ...DEFAULT_WORKFLOW,
+        content: {
+          operators: [mockScanPredicate],
+          operatorPositions: { [mockScanPredicate.operatorID]: mockPoint },
+          links: [],
+          commentBoxes: [],
+          settings: undefined as any,
+          formBinding: config,
+        },
+      };
+
+      service.reloadWorkflow(workflow, false, false);
+
+      expect(service.getFormBinding()).toEqual(config);
+    });
+
+    it("should fall back to an empty definition for a workflow without one", 
() => {
+      service.setFormBinding(config);
+
+      service.reloadWorkflow(
+        {
+          ...DEFAULT_WORKFLOW,
+          content: {
+            operators: [],
+            operatorPositions: {},
+            links: [],
+            commentBoxes: [],
+            settings: undefined as any,
+          },
+        },
+        false,
+        false
+      );
+
+      expect(service.getFormBinding()).toEqual({ fields: [], 
resultOperatorIds: [] });
+    });
+
+    // Starting a blank workflow must not carry the previous one's definition, 
or it would
+    // leak into the new workflow's first save.
+    it("should clear a leftover definition when reset to a blank workflow", () 
=> {
+      service.setFormBinding(config);
+      expect(service.getFormBinding()).toEqual(config);
+
+      service.reloadWorkflow(undefined, false, false);
+
+      expect(service.getFormBinding()).toEqual({ fields: [], 
resultOperatorIds: [] });
+    });
+
+    // A plain workflow must not stamp an empty formBinding into its content, 
or every existing
+    // workflow's first save would diff and cut a needless version. Mirrors 
the agent-service rule.
+    it("should omit formBinding from content for a workflow that never had 
one", () => {
+      service.reloadWorkflow(undefined, false, false);
+
+      expect("formBinding" in service.getWorkflowContent()).toBe(false);
+    });
+
+    // Editing the form has to reach the same autosave that canvas edits use.
+    it("should report an edit through workflowChanged", () => {
+      const seen: unknown[] = [];
+      const sub = service.workflowChanged().subscribe(v => seen.push(v));
+
+      service.setFormBinding(config);
+
+      expect(seen.length).toEqual(1);
+      sub.unsubscribe();
+    });
+
+    // Opening a workflow is not an edit; announcing it would save on every 
open.
+    it("should stay silent while a workflow is being opened", () => {
+      const seen: unknown[] = [];
+      const sub = service.formBindingChanged$.subscribe(v => seen.push(v));
+
+      service.hydrateFormBinding(config);
+
+      expect(seen.length).toEqual(0);
+      expect(service.getFormBinding()).toEqual(config);
+      sub.unsubscribe();
+    });
+  });
+
   it("should clear pre-existing comment boxes and fall back to default 
settings when reloading", () => {
     service.addCommentBox({ ...mockCommentBox, commentBoxID: "commentBox-old" 
});
     expect(texeraGraph.hasCommentBox("commentBox-old")).toBeTruthy();
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 e3ea66c024..f531336593 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
@@ -21,7 +21,14 @@ import { Injectable } from "@angular/core";
 
 import * as joint from "jointjs";
 import { BehaviorSubject, merge, Observable, Subject } from "rxjs";
-import { ExecutionMode, Workflow, WorkflowContent, WorkflowSettings } from 
"../../../../common/type/workflow";
+import {
+  ExecutionMode,
+  getDefaultFormBinding,
+  FormBindingConfig,
+  Workflow,
+  WorkflowContent,
+  WorkflowSettings,
+} from "../../../../common/type/workflow";
 import { WorkflowMetadata } from 
"../../../../dashboard/type/workflow-metadata.interface";
 import {
   Comment,
@@ -98,6 +105,17 @@ export class WorkflowActionService {
   private workflowSettings: WorkflowSettings;
   private workflowResetSubject = new Subject<void>();
 
+  // The Form View definition. Presentation, not structure, so it stays out of 
the shared
+  // graph (no collaborative merge) and is handled like workflowSettings -- 
hydrated by
+  // reloadWorkflow, emitted by getWorkflowContent.
+  private formBinding: FormBindingConfig = getDefaultFormBinding();
+  // Whether the opened workflow's content carried a formBinding. Kept so 
getWorkflowContent
+  // re-emits the key only when it was there (or an author has since populated 
it), leaving a
+  // plain workflow's content byte-identical -- the same rule agent-service 
follows.
+  private formBindingLoaded = false;
+  private formBindingChangeSubject = new Subject<FormBindingConfig>();
+  public readonly formBindingChanged$: Observable<FormBindingConfig> = 
this.formBindingChangeSubject.asObservable();
+
   constructor(
     private operatorMetadataService: OperatorMetadataService,
     private jointUIService: JointUIService,
@@ -641,12 +659,16 @@ export class WorkflowActionService {
       this.jointGraphWrapper.jointGraph.clear();
 
       if (workflow === undefined) {
+        // A blank workflow carries no Form View definition; drop any left 
over from the
+        // previously open one so it cannot leak into this workflow's next 
save.
+        this.hydrateFormBinding(undefined);
         this.setNewSharedModel();
         return;
       }
 
       const workflowContent: WorkflowContent = workflow.content;
       this.workflowSettings = workflowContent.settings || 
this.getDefaultSettings();
+      this.hydrateFormBinding(workflowContent.formBinding);
 
       let operatorsAndPositions: { op: OperatorPredicate; pos: Point }[] = [];
       workflowContent.operators.forEach(op => {
@@ -701,6 +723,7 @@ export class WorkflowActionService {
       this.getTexeraGraph().getOperatorVersionChangedStream(),
       this.getTexeraGraph().getPortDisplayNameChangedSubject(),
       this.getTexeraGraph().getPortPropertyChangedStream(),
+      this.formBindingChanged$,
       this.workflowResetSubject.asObservable()
     );
   }
@@ -736,6 +759,34 @@ export class WorkflowActionService {
     return this.workflowSettings;
   }
 
+  /**
+   * Load a definition without announcing an edit. Used while opening a 
workflow, so
+   * that merely reading one does not look like a change and trigger a save.
+   */
+  public hydrateFormBinding(formBinding: FormBindingConfig | undefined): void {
+    this.formBindingLoaded = formBinding !== undefined;
+    this.formBinding = formBinding ?? getDefaultFormBinding();
+  }
+
+  /** A form binding worth persisting: an author populated it (fields, 
results, or an
+   *  instruction), as opposed to the empty default a plain workflow carries. 
*/
+  private isFormBindingNonEmpty(fb: FormBindingConfig): boolean {
+    return fb.fields.length > 0 || fb.resultOperatorIds.length > 0 || 
fb.instruction !== undefined;
+  }
+
+  /**
+   * Replace the definition as an edit: announced on `formBindingChanged$`, 
which
+   * feeds workflowChanged() and so reaches the existing autosave.
+   */
+  public setFormBinding(formBinding: FormBindingConfig): void {
+    this.formBinding = formBinding;
+    this.formBindingChangeSubject.next(this.formBinding);
+  }
+
+  public getFormBinding(): FormBindingConfig {
+    return this.formBinding;
+  }
+
   public getWorkflowMetadata(): WorkflowMetadata {
     return this.workflowMetadata;
   }
@@ -763,6 +814,12 @@ export class WorkflowActionService {
       links,
       commentBoxes,
       settings,
+      // Carry formBinding only when the workflow has one (loaded with it, or 
an author
+      // populated it), so a plain workflow's content is unchanged and its 
save cuts no
+      // needless version.
+      ...(this.formBindingLoaded || 
this.isFormBindingNonEmpty(this.formBinding)
+        ? { formBinding: this.formBinding }
+        : {}),
     };
   }
 

Reply via email to