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 } + : {}), }; }
