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-8437-27f21a9ba2d215767aec385a77d82d426deaf77c in repository https://gitbox.apache.org/repos/asf/texera.git
commit 357d9013e14bac3bcfc7a8bf6ddfdb13937bd55a Author: yangzhang75 <[email protected]> AuthorDate: Sun Sep 6 08:47:16 2026 +0000 feat(workflow-form): render the exposed inputs and write values back (#8437) ### Purpose Closes #8370. Part of the Form View stack (#8011), stacked on #8436 (PR9). The Form View page already has the title bar and the collapsible read-only workflow preview. This PR fills in the middle: it renders the inputs an author exposed and writes filled-in values straight back to their operators. ### Changes - Each exposed binding is turned into its operator's own ngx-formly field, built from the operator's JSON schema (not guessed from the value), so a file property gets the real file picker and an attribute property a column dropdown instead of degrading to a plain text box. - The widget is decided by the shared `customFormlyFieldType` (extracted in #8436). Two widgets that only work on the operator canvas, the code editor and the drag-reorder list, are collected in a new `CANVAS_ONLY_FORMLY_TYPES` and fall back to formly's default editable control, since they cannot function on a form. - A changed value writes straight back to its operator, the same edit the canvas makes, guarded so formly's build-time empty default never silently wipes a real value. Only a viewer with write access can edit; a read-only viewer sees the values disabled and the page never persists for them. - The inputs rebuild on the compilation stream (attribute boxes become dropdowns once upstream columns are known) and on `formBindingChanged$` (exposing or un-exposing a property, a co-editor's change included, is reflected at once), both skipped while the cursor is in a field so an in-progress edit is not thrown away. - Broken bindings (the operator was deleted, or the key no longer exists) are left out of what a reader sees. Nested and array sub-field overrides are added in the next PR (#8022); running the workflow and showing results follow after that. ### Tests - `workflow-form.component.spec.ts`: direct-construction unit tests for rendering, widget selection, the write-back guards, read-only locking, the compilation/binding rebuild triggers, and the typing guard, for both the single-user and the collaboration paths. - `workflow-form.rendered.spec.ts`: TestBed test standing up the real template so the inputs markup (section head, empty state, input card and form wrapper) is covered. - `custom-formly-type.spec.ts`: covers the new `CANVAS_ONLY_FORMLY_TYPES`. - 100% statement and function coverage on the changed source. `ng test` (71 tests here), `ng build gui`, eslint and prettier all pass. ### Was AI used? Yes, co-authored with Claude (Claude Code). #### Screenshot The **Inputs** area: each exposed property rendered as its operator's own control (the workflow preview and mini-map below it are the earlier PR's). <img width="1267" height="391" alt="Screenshot 2026-09-05 at 11 41 41 AM" src="https://github.com/user-attachments/assets/4572fa60-3124-4140-8f7c-ddd7f22133fd" /> Co-authored-by: Claude Opus 4.8 <[email protected]> --- .../workflow-form/workflow-form.component.html | 42 ++- .../workflow-form/workflow-form.component.scss | 123 ++++++- .../workflow-form/workflow-form.component.spec.ts | 362 ++++++++++++++++++++- .../workflow-form/workflow-form.component.ts | 250 +++++++++++++- .../workflow-form/workflow-form.rendered.spec.ts | 80 ++++- .../workflow-form/workflow-form.spec-harness.ts | 69 +++- .../app/workspace/util/custom-formly-type.spec.ts | 12 +- .../src/app/workspace/util/custom-formly-type.ts | 9 + 8 files changed, 929 insertions(+), 18 deletions(-) diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html index 523e32b5d9..c924532d05 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.html @@ -70,8 +70,46 @@ </div> <div [hidden]="loading"> - <!-- The workflow, out of the way unless the reader goes looking. The inputs, running and - results are added on top of this by the following PRs. --> + <!-- The inputs an author exposed, each rendered as its operator's own field. --> + <div class="pc-section-head"> + <span class="label">Inputs</span> + </div> + + <div + class="empty" + *ngIf="visibleFields.length === 0"> + This workflow has no inputs to fill in. + </div> + + <div class="params"> + <section + class="card param" + [class.read-only]="!canEdit" + *ngFor="let r of rendered; trackBy: trackByRendered"> + <!-- The operator's own field, so a file property gets the real file picker and an + attribute property a column dropdown. --> + <form + [formGroup]="r.form" + class="param-form"> + <formly-form + [model]="r.model" + [fields]="r.fields" + [form]="r.form"></formly-form> + </form> + + <!-- The one line of guidance a reader gets, when the author wrote one. The operator + schema's own field descriptions are dropped (author notes, not reader guidance), so + this is the only help text under an input. --> + <p + *ngIf="r.resolved.binding.helpText" + class="param-help-text"> + {{ r.resolved.binding.helpText }} + </p> + </section> + </div> + + <!-- The workflow, out of the way unless the reader goes looking. Running and results are added + on top of this by the following PRs. --> <section class="card wf" [class.open]="workflowOpen"> diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss index 6110a0f2f7..54e7ffd7bc 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.scss @@ -183,13 +183,134 @@ $shell: #fafafa; padding: 40px 0; } -/* A section of the page: the workflow preview here, the inputs and results in later PRs. */ +/* A section of the page: the inputs and the workflow preview here, results in later PRs. */ .card { border: 1px solid $border; border-radius: 8px; background: #fff; } +/* ---------- inputs ---------- */ + +.pc-section-head { + display: flex; + align-items: baseline; + gap: 12px; + margin-bottom: 12px; +} + +.label { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: $text-2; +} + +.empty { + padding: 28px; + text-align: center; + color: $text-2; + border: 1px dashed $border; + border-radius: 8px; +} + +.params { + display: flex; + flex-direction: column; + gap: 12px; +} + +.param { + padding: 16px 18px; + + /* A read-only viewer sees the values but cannot change them. The bound controls are already + disabled through props.disabled; this blocks every other interactive element a custom widget + draws of its own -- a file picker's Browse button, an uploader, a picker's dropdown -- which + do not consult the form's disabled state. */ + &.read-only { + pointer-events: none; + } + + /* The author's one line of guidance under an input, when they wrote one. */ + .param-help-text { + margin: 8px 0 0; + color: $text-2; + font-size: 13px; + } + + /* The operator's property editor lays its fields out for a narrow docked panel: a fixed label + column on the left, controls squeezed into what is left. Dropped into a full-width card that + reads as a third of a form floating in an empty page. Here the label sits above its control + and both run the width of the card. formly's own structural elements are inline by default, + so each field shrank to its content until they are told to fill first. */ + .param-form, + ::ng-deep formly-form, + ::ng-deep formly-field, + ::ng-deep formly-group, + ::ng-deep formly-wrapper-nz-form-field, + ::ng-deep formly-field-nz-input, + ::ng-deep nz-form-item, + ::ng-deep nz-form-control { + display: block; + width: 100%; + max-width: none; + } + + ::ng-deep .ant-form-item { + margin-bottom: 0; + width: 100%; + } + + ::ng-deep .ant-form-item-row, + ::ng-deep nz-form-item > .ant-row { + display: block; + } + + ::ng-deep .ant-form-item-label { + width: auto !important; + max-width: none !important; + flex: none !important; + text-align: left !important; + padding: 0 0 2px !important; + line-height: 1.5; + + > label { + height: auto; + font-size: 13px; + color: $text; + + &::after { + display: none; + } + } + } + + ::ng-deep .ant-form-item-control { + width: 100% !important; + max-width: none !important; + flex: 1 1 auto !important; + } + + /* Inputs, selects and the file picker all stretch instead of sitting at their default widths + against a wall of white. */ + ::ng-deep .ant-form-item-control-input-content > input, + ::ng-deep .ant-form-item-control-input-content > textarea, + ::ng-deep .ant-form-item-control-input-content > nz-select, + ::ng-deep .ant-input, + ::ng-deep .ant-input-number, + ::ng-deep .ant-select { + width: 100%; + } + + /* The file picker's button sits under its box; give them the same left edge and a little air, + rather than the button hugging the input. */ + ::ng-deep texera-dataset-file-selector button, + ::ng-deep .ant-form-item-control-input-content > button { + margin-top: 6px; + } +} + /* ---------- workflow preview ---------- */ .wf { diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts index f081b4b946..3c747ae58b 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.spec.ts @@ -17,13 +17,15 @@ * under the License. */ +import { FormControl } from "@angular/forms"; import { Router } from "@angular/router"; import { of, throwError } from "rxjs"; import { WorkflowFormComponent } from "./workflow-form.component"; -import { setupHarness, formViewWorkflow } from "./workflow-form.spec-harness"; +import { setupHarness, formViewWorkflow, resolved } from "./workflow-form.spec-harness"; import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant"; import { DefaultView } from "../../../dashboard/type/workflow-metadata.interface"; +import { FORM_DEBOUNCE_TIME_MS } from "../../service/execute-workflow/execute-workflow.service"; /** * These exercise the page's own decisions -- what a reader is shown, where an ordinary @@ -38,6 +40,7 @@ describe("WorkflowFormComponent", () => { let router: { navigate: ReturnType<typeof vi.fn> }; let workflowActionService: any; let workflowPersistService: any; + let formBindingService: any; const build = (workflow: any) => { h.useWorkflow(workflow); @@ -48,11 +51,15 @@ describe("WorkflowFormComponent", () => { h.workflowActionService as any, h.workflowPersistService as any, h.operatorMetadataService as any, + h.formBindingService as any, h.executeWorkflowService as any, h.workflowResultService as any, h.notificationService as any, h.userService as any, + h.formlyJsonschema as any, h.cdr as any, + h.dynamicSchemaService as any, + h.workflowCompilingService as any, h.computingUnitStatusService as any, h.workflowConsoleService as any, h.host as any, @@ -67,6 +74,7 @@ describe("WorkflowFormComponent", () => { router = h.router; workflowActionService = h.workflowActionService; workflowPersistService = h.workflowPersistService; + formBindingService = h.formBindingService; }); describe("who this page is for", () => { @@ -131,6 +139,15 @@ describe("WorkflowFormComponent", () => { expect(h.notificationService.error).toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith([USER_WORKFLOW]); }); + + // Write access decides whether a filled-in value writes back and whether the page saves. + it("has write access for a writable workflow and none for a read-only one", () => { + build(formViewWorkflow).ngOnInit(); + expect(component.canEdit).toBe(true); + + build({ ...formViewWorkflow, readonly: true }).ngOnInit(); + expect(component.canEdit).toBe(false); + }); }); describe("leaving the page", () => { @@ -263,6 +280,17 @@ describe("WorkflowFormComponent", () => { expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled(); }); + it("does not save when the viewer only has read access", () => { + h.userService.isLogin.mockReturnValue(true); + h.workflowPersistService.isWorkflowPersistEnabled.mockReturnValue(true); + build({ ...formViewWorkflow, readonly: true }).ngOnInit(); + workflowPersistService.persistWorkflow.mockClear(); + + (component as any).save(); + + expect(workflowPersistService.persistWorkflow).not.toHaveBeenCalled(); + }); + it("does not save a workflow that is not the one this page opened", () => { enableSave(); workflowActionService.getWorkflow.mockReturnValue({ wid: 99, content: { operators: [], operatorPositions: {} } }); @@ -402,4 +430,336 @@ describe("WorkflowFormComponent", () => { expect(component.workflowEverOpened).toBe(false); }); }); + + // The heart of this slice: turn each exposed binding into its operator's own formly field, and + // write a filled-in value straight back to the operator. + describe("the exposed inputs", () => { + // Put op-1 on the graph and expose one of its properties, then read the config. + const renderOne = (id: string, extra: any = {}) => { + h.hasOperatorIds.add("op-1"); + formBindingService.resolveFields.mockReturnValue([resolved(id, id, extra)]); + (component as any).readConfig(); + }; + + it("renders a healthy input as a real formly field keyed by its binding id", () => { + build(formViewWorkflow).ngOnInit(); + + renderOne("n_hvg"); + + expect(component.rendered).toHaveLength(1); + expect(component.rendered[0].fields[0].key).toBe(component.rendered[0].resolved.binding.id); + }); + + it("renders nothing for an input whose operator is no longer on the graph", () => { + build(formViewWorkflow).ngOnInit(); + // op-1 deliberately not added to the graph. + formBindingService.resolveFields.mockReturnValue([resolved("n_hvg", "Genes")]); + + (component as any).readConfig(); + + expect(component.rendered).toHaveLength(0); + }); + + it("skips an exposed property that has no matching schema field", () => { + build(formViewWorkflow).ngOnInit(); + + renderOne("nonesuch"); + + expect(component.rendered).toHaveLength(0); + }); + + it("leaves broken inputs out of what a reader sees", () => { + build(formViewWorkflow).ngOnInit(); + h.hasOperatorIds.add("op-1"); + formBindingService.resolveFields.mockReturnValue([ + resolved("n_hvg", "Genes"), + resolved("gone", "Gone", { brokenReason: "the step it belonged to was removed" }), + ]); + + (component as any).readConfig(); + + expect(component.visibleFields).toHaveLength(1); + expect(component.rendered).toHaveLength(1); + }); + + it("gives an exposed property its custom widget instead of a text box", () => { + build(formViewWorkflow).ngOnInit(); + + renderOne("datasetVersionPath"); + + expect(component.rendered[0].fields[0].type).toBe("datasetversionselector"); + }); + + it("uses the operator type to pick a widget (the HuggingFace model picker)", () => { + build(formViewWorkflow).ngOnInit(); + h.graphOperators.push({ operatorID: "op-1", operatorType: "HuggingFace" }); + + renderOne("modelId"); + + expect(component.rendered[0].fields[0].type).toBe("huggingface"); + }); + + it("renders a file property through its own picker type", () => { + build(formViewWorkflow).ngOnInit(); + + renderOne("fileName"); + + expect(component.rendered[0].fields[0].type).toBe("inputautocomplete"); + }); + + it("seeds the field model with the operator's other properties as read-only context", () => { + build(formViewWorkflow).ngOnInit(); + h.hasOperatorIds.add("op-1"); + // A HuggingFace operator whose model picker (modelId) needs the sibling `task` to work. + h.graphOperators.push({ + operatorID: "op-1", + operatorType: "HuggingFace", + operatorProperties: { task: "image-classification", modelId: "seed" }, + }); + formBindingService.resolveFields.mockReturnValue([resolved("modelId", "Model")]); + + (component as any).readConfig(); + + const card = component.rendered[0]; + // The sibling context is present (so the widget reads the right task) ... + expect(card.model.task).toBe("image-classification"); + // ... alongside this input's own value, keyed by the binding id, which is what writes back. + expect(card.model[card.resolved.binding.id]).toBe("seed"); + }); + + it("prefers the per-instance schema, falling back to the static one when it is unavailable", () => { + build(formViewWorkflow).ngOnInit(); + h.graphOperators.push({ operatorID: "op-1", operatorType: "X" }); + (component as any).dynamicSchemaService = { + getDynamicSchema: () => { + throw new Error("no dynamic schema"); + }, + }; + (component as any).operatorMetadataService = { + getOperatorSchema: () => ({ jsonSchema: { properties: { n_hvg: {} } } }), + }; + + renderOne("n_hvg"); + + expect(component.rendered).toHaveLength(1); + }); + + it("renders nothing when neither the per-instance nor the static schema is available", () => { + build(formViewWorkflow).ngOnInit(); + h.graphOperators.push({ operatorID: "op-1", operatorType: "X" }); + (component as any).dynamicSchemaService = { + getDynamicSchema: () => { + throw new Error("no dynamic schema"); + }, + }; + (component as any).operatorMetadataService = { + getOperatorSchema: () => { + throw new Error("no static schema"); + }, + }; + + renderOne("n_hvg"); + + expect(component.rendered).toHaveLength(0); + }); + + it("identifies a rendered card by its binding id", () => { + build(formViewWorkflow); + + const key = component.trackByRendered(0, { resolved: { binding: { id: "b-1" } } } as any); + + expect(key).toBe("b-1"); + }); + + it("locks the inputs for a read-only viewer", () => { + build({ ...formViewWorkflow, readonly: true }).ngOnInit(); + + renderOne("n_hvg"); + + expect(component.canEdit).toBe(false); + // The field carries props.disabled, which is what actually disables the control formly builds + // (a form.disable() on the still-empty group does not, and does not persist). It cascades to + // a nested property's sub-fields. + expect((component.rendered[0].fields[0].props as any).disabled).toBe(true); + }); + + it("writes a dirtied value back to the operator", () => { + build(formViewWorkflow).ngOnInit(); + renderOne("n_hvg"); + const card = component.rendered[0]; + const key = card.resolved.binding.id; + vi.useFakeTimers(); + + card.model[key] = "typed"; + card.form.addControl(key, new FormControl("typed")); + card.form.markAsDirty(); + vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50); + vi.useRealTimers(); + + expect(formBindingService.writeValue).toHaveBeenCalled(); + }); + + it("ignores an unchanged form emission", () => { + build(formViewWorkflow).ngOnInit(); + formBindingService.readValue.mockReturnValue("seed"); + renderOne("n_hvg"); + const card = component.rendered[0]; + const key = card.resolved.binding.id; + vi.useFakeTimers(); + + card.model[key] = "seed"; + card.form.addControl(key, new FormControl("seed")); + vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50); + vi.useRealTimers(); + + expect(formBindingService.writeValue).not.toHaveBeenCalled(); + }); + + it("keeps a still-set value when formly emits a blank before an edit", () => { + build(formViewWorkflow).ngOnInit(); + formBindingService.readValue.mockReturnValue("seed"); + renderOne("n_hvg"); + const card = component.rendered[0]; + const key = card.resolved.binding.id; + vi.useFakeTimers(); + + card.model[key] = ""; + card.form.addControl(key, new FormControl("")); + vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50); + vi.useRealTimers(); + + expect(formBindingService.writeValue).not.toHaveBeenCalled(); + }); + + it("refreshes the card's snapshot after a write-back", () => { + build(formViewWorkflow).ngOnInit(); + renderOne("n_hvg"); + const card = component.rendered[0]; + const key = card.resolved.binding.id; + // The re-read after a write returns the new value on the same binding. + formBindingService.resolveFields.mockReturnValue([resolved("n_hvg", "n_hvg", { value: "typed" })]); + vi.useFakeTimers(); + + card.model[key] = "typed"; + card.form.addControl(key, new FormControl("typed")); + card.form.markAsDirty(); + vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50); + vi.useRealTimers(); + + expect(component.rendered[0].resolved.value).toBe("typed"); + }); + + it("leaves the card unchanged when the re-read no longer carries the binding", () => { + build(formViewWorkflow).ngOnInit(); + renderOne("n_hvg"); + const card = component.rendered[0]; + const before = card.resolved; + const key = card.resolved.binding.id; + // The write succeeds, but the following resolve returns nothing for this binding. + formBindingService.resolveFields.mockReturnValue([]); + vi.useFakeTimers(); + + card.model[key] = "typed"; + card.form.addControl(key, new FormControl("typed")); + card.form.markAsDirty(); + vi.advanceTimersByTime(FORM_DEBOUNCE_TIME_MS + 50); + vi.useRealTimers(); + + expect(formBindingService.writeValue).toHaveBeenCalled(); + expect(component.rendered[0].resolved).toBe(before); + }); + + it("labels an unnamed input by its schema title, not the raw key", () => { + build(formViewWorkflow).ngOnInit(); + h.hasOperatorIds.add("op-1"); + formBindingService.resolveFields.mockReturnValue([ + resolved("n_hvg", "", { + binding: { id: "b", operatorID: "op-1", propertyKey: "n_hvg", displayName: "" } as any, + }), + ]); + + (component as any).readConfig(); + + // The schema's own title ("N"), not "n_hvg". + expect((component.rendered[0].fields[0].props as any).label).toBe("N"); + }); + }); + + describe("keeping the inputs in step with the workflow", () => { + it("rebuilds the inputs when compilation reports a new state", async () => { + build(formViewWorkflow).ngOnInit(); + const rebuild = vi.spyOn(component as any, "readConfig"); + + h.compilationChanged.next("Succeeded"); + await new Promise(r => setTimeout(r, FORM_DEBOUNCE_TIME_MS + 50)); + + expect(rebuild).toHaveBeenCalled(); + }); + + it("does not rebuild under the cursor of someone typing", async () => { + build(formViewWorkflow).ngOnInit(); + vi.spyOn(component as any, "isTypingInTheForm").mockReturnValue(true); + const rebuild = vi.spyOn(component as any, "readConfig"); + + h.compilationChanged.next("Succeeded"); + await new Promise(r => setTimeout(r, FORM_DEBOUNCE_TIME_MS + 50)); + + expect(rebuild).not.toHaveBeenCalled(); + }); + + it("re-reads the config when a property is exposed or un-exposed", () => { + build(formViewWorkflow).ngOnInit(); + const before = formBindingService.resolveFields.mock.calls.length; + + workflowActionService.formBindingChanged$.next(undefined); + + expect(formBindingService.resolveFields.mock.calls.length).toBeGreaterThan(before); + }); + + // Once #8351 makes this stream fire for a co-editor's change, a rebuild under the cursor would + // discard a half-entered value -- so the binding path skips typing, like the compilation path. + it("does not re-read the config on a binding change while the reader is typing", () => { + build(formViewWorkflow).ngOnInit(); + vi.spyOn(component as any, "isTypingInTheForm").mockReturnValue(true); + const rebuild = vi.spyOn(component as any, "readConfig"); + + workflowActionService.formBindingChanged$.next(undefined); + + expect(rebuild).not.toHaveBeenCalled(); + }); + + it("reports typing when a form field inside the page is focused", () => { + build(formViewWorkflow).ngOnInit(); + const input = document.createElement("input"); + document.body.appendChild(input); + (component as any).host = { nativeElement: { contains: () => true, querySelector: () => null } }; + input.focus(); + + expect((component as any).isTypingInTheForm()).toBe(true); + + document.body.removeChild(input); + }); + + it("reports no typing when the focus is outside the page", () => { + build(formViewWorkflow).ngOnInit(); + (component as any).host = { nativeElement: { contains: () => false, querySelector: () => null } }; + + expect((component as any).isTypingInTheForm()).toBe(false); + }); + + it("reports typing when a content-editable element inside the page is focused", () => { + build(formViewWorkflow).ngOnInit(); + const editable = document.createElement("div"); + editable.tabIndex = 0; + // jsdom does not derive isContentEditable from the attribute; set it directly. + Object.defineProperty(editable, "isContentEditable", { value: true }); + document.body.appendChild(editable); + (component as any).host = { nativeElement: { contains: () => true, querySelector: () => null } }; + editable.focus(); + + expect((component as any).isTypingInTheForm()).toBe(true); + + document.body.removeChild(editable); + }); + }); }); diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts index d438572133..02eed5be3f 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.component.ts @@ -19,14 +19,17 @@ import { ChangeDetectorRef, Component, ElementRef, HostListener, OnDestroy, OnInit } from "@angular/core"; import { CommonModule, DatePipe } from "@angular/common"; -import { FormsModule } from "@angular/forms"; +import { FormGroup, FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { FormlyFieldConfig, FormlyModule } from "@ngx-formly/core"; +import { FormlyJsonschema } from "@ngx-formly/core/json-schema"; import { ActivatedRoute, Router } from "@angular/router"; import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { NzAvatarModule } from "ng-zorro-antd/avatar"; import { NzIconModule } from "ng-zorro-antd/icon"; import { UserIconComponent } from "../../../dashboard/component/user/user-icon/user-icon.component"; -import { forkJoin } from "rxjs"; -import { debounceTime } from "rxjs/operators"; +import { cloneDeep } from "lodash-es"; +import { forkJoin, Subject } from "rxjs"; +import { debounceTime, takeUntil } from "rxjs/operators"; import { USER_WORKFLOW, USER_WORKSPACE } from "../../../app-routing.constant"; import { Workflow, WorkflowContent } from "../../../common/type/workflow"; @@ -34,8 +37,12 @@ import { ComputingUnitStatusService } from "../../../common/service/computing-un import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; import { NotificationService } from "../../../common/service/notification/notification.service"; import { UserService } from "../../../common/service/user/user.service"; -import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service"; +import { DynamicSchemaService } from "../../service/dynamic-schema/dynamic-schema.service"; +import { customFormlyFieldType, CANVAS_ONLY_FORMLY_TYPES } from "../../util/custom-formly-type"; +import { WorkflowCompilingService } from "../../service/compile-workflow/workflow-compiling.service"; +import { ExecuteWorkflowService, FORM_DEBOUNCE_TIME_MS } from "../../service/execute-workflow/execute-workflow.service"; import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service"; +import { FormBindingService, ResolvedField } from "../../service/form-binding/form-binding.service"; import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; import { GuiConfigService } from "../../../common/service/gui-config.service"; import { WorkflowConsoleService } from "../../service/workflow-console/workflow-console.service"; @@ -48,11 +55,24 @@ import { CoeditorPresenceService } from "../../service/workflow-graph/model/coed import { SAVE_DEBOUNCE_TIME_IN_MS } from "../workspace.component"; /** - * The Form View: a second way to use a workflow. On top of the title-bar frame, this PR adds - * the collapsible read-only workflow preview -- the same workflow editor and mini-map the canvas - * uses, embedded here with the graph shape locked (its own `structureLocked`), built the first - * time the reader opens the strip. The inputs, running and results are added by later PRs. A - * view, not a new object: it opens the same workflow the canvas does. + * One rendered input: the resolved binding plus the operator's own formly field for that property. + * Building the field from the operator's JSON schema (not guessing from the value) is what gives a + * file its picker and an attribute its column dropdown. + */ +interface RenderedField { + resolved: ResolvedField; + fields: FormlyFieldConfig[]; + form: FormGroup; + model: Record<string, unknown>; +} + +/** + * The Form View: a second way to use a workflow. On top of the title-bar frame and the collapsible + * read-only workflow preview, this PR renders the inputs an author exposed -- each as its + * operator's own formly field, so a file property gets the real picker and an attribute a column + * dropdown -- and writes a filled-in value straight back to its operator, the same edit the canvas + * makes. Nested sub-field overrides, running and results are added by later PRs. A view, not a new + * object: it opens the same workflow the canvas does. */ @UntilDestroy() @Component({ @@ -62,6 +82,8 @@ import { SAVE_DEBOUNCE_TIME_IN_MS } from "../workspace.component"; imports: [ CommonModule, FormsModule, + ReactiveFormsModule, + FormlyModule, NzAvatarModule, NzIconModule, UserIconComponent, @@ -76,6 +98,14 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { public loading = true; /** "Saved at …", worded and formatted exactly as on the operator canvas. */ public autoSaveState = ""; + /** Write access: only then does a filled-in value write back, and only then does the page save. */ + public canEdit = false; + + /** The exposed inputs, resolved against the live graph, and the formly field built for each. */ + private parameters: ResolvedField[] = []; + public rendered: RenderedField[] = []; + /** Torn down and replaced whenever the form is rebuilt, so an old field's write-back stops. */ + private formsRebuilt = new Subject<void>(); /** The collapsible workflow preview: closed until the reader opens it. */ public workflowOpen = false; @@ -101,11 +131,22 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { private workflowActionService: WorkflowActionService, private workflowPersistService: WorkflowPersistService, private operatorMetadataService: OperatorMetadataService, + private formBindingService: FormBindingService, private executeWorkflowService: ExecuteWorkflowService, private workflowResultService: WorkflowResultService, private notificationService: NotificationService, private userService: UserService, + private formlyJsonschema: FormlyJsonschema, private cdr: ChangeDetectorRef, + // Injected for its side effect: it fills its map from the operator-add stream, so it has to + // exist before the workflow loads or every operator arrives unregistered and anything asking + // for a schema later throws. It also carries the per-instance schema (upstream column names) + // that turns an attribute box into a dropdown. + private dynamicSchemaService: DynamicSchemaService, + // Injected for its side effect: it compiles on graph changes and writes column names into each + // operator's dynamic schema. Nothing else on this page injects it, so without this line it + // never runs and an attribute input stays a plain text box. + private workflowCompilingService: WorkflowCompilingService, private computingUnitStatusService: ComputingUnitStatusService, private workflowConsoleService: WorkflowConsoleService, private host: ElementRef<HTMLElement>, @@ -121,6 +162,34 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { } this.wid = wid; this.load(wid); + + // Attribute boxes become dropdowns only after compilation writes the column enums into each + // operator's dynamic schema -- which lands after these cards were built. Rebuild on the + // compilation-state stream, a ReplaySubject(1) so a late subscriber (this page reloads fresh + // on every Canvas<->Form switch) gets the current state at once. Skip it while someone is + // typing, so a rebuild does not throw away a half-entered value under the cursor. + this.workflowCompilingService + .getCompilationStateInfoChangedStream() + .pipe(debounceTime(FORM_DEBOUNCE_TIME_MS), untilDestroyed(this)) + .subscribe(() => { + if (this.isTypingInTheForm()) { + return; + } + this.readConfig(); + }); + + // Exposing or un-exposing a property in the panel changes the definition; the inputs above have + // to follow at once, which is the whole point of editing them side by side. Today this fires for + // this client's own edits; once #8351 moves formBinding into the shared model it also fires for + // a co-editor's -- so, like the compilation path, skip the rebuild while the reader is typing, or + // a remote change would throw away a half-entered value under the cursor. + this.workflowActionService.formBindingChanged$.pipe(untilDestroyed(this)).subscribe(() => { + if (this.isTypingInTheForm()) { + return; + } + this.readConfig(); + this.cdr.detectChanges(); + }); } private load(wid: number): void { @@ -146,6 +215,7 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { // back for any canvas-default workflow. this.workflowName = workflow.name; this.storedPositions = { ...(workflow.content?.operatorPositions ?? {}) }; + this.canEdit = !workflow.readonly; this.workflowActionService.setNewSharedModel(wid, this.userService.getCurrentUser()); this.workflowActionService.reloadWorkflow(workflow); // The workflow is shown, not edited, from here: dragging operators around or @@ -153,6 +223,7 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { this.applyEditability(); this.refreshSavedState(); this.later(() => this.adjustWorkflowNameWidth(), 0); + this.readConfig(); this.registerMetadataRefresh(); this.registerAutoPersist(); this.loading = false; @@ -175,6 +246,161 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { this.workflowActionService.disableWorkflowModification(); } + // --------------------------------------------------------------------------- + // Inputs: the exposed properties, rendered as their operators' own fields + // --------------------------------------------------------------------------- + + /** Whether the cursor is currently inside one of this page's inputs. */ + private isTypingInTheForm(): boolean { + const active = document.activeElement as HTMLElement | null; + if (!active || !this.host.nativeElement.contains(active)) { + return false; + } + return ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName) || active.isContentEditable; + } + + private readConfig(): void { + this.parameters = this.formBindingService.resolveFields(); + this.buildForm(); + } + + /** + * Build the form from the operators' JSON schemas (FormlyJsonschema), keeping the one field per + * exposed property. Each input gets its own form keyed by binding id. + */ + private buildForm(): void { + this.formsRebuilt.next(); + this.rendered = this.visibleFields + .map(field => this.renderField(field)) + .filter((r): r is RenderedField => r !== undefined); + } + + private renderField(resolved: ResolvedField): RenderedField | undefined { + const { binding } = resolved; + const schema = this.operatorSchemaFor(binding.operatorID); + if (!schema) { + return undefined; + } + const operator = this.workflowActionService.getTexeraGraph().getOperator(binding.operatorID); + const operatorType = operator?.operatorType; + const full = this.formlyJsonschema.toFieldConfig(cloneDeep(schema) as never, { + map: (mapped, source) => { + // Render the exact custom widget the operator property panel would (file/model/dataset + // pickers, image/audio uploaders, ...), shared via customFormlyFieldType so an exposed + // property shows its real control instead of degrading to a text box. + const customType = customFormlyFieldType({ + key: mapped.key, + operatorType, + description: (source as { description?: string })?.description, + currentType: mapped.type, + }); + // Canvas-only widgets (code editor, drag-reorder) do not work here; an older workflow may + // already carry one, so leave it to formly's default editable control rather than a widget + // that cannot function on a form. + if (customType && !CANVAS_ONLY_FORMLY_TYPES.has(customType)) { + mapped.type = customType; + } + return mapped; + }, + }); + const source = (full.fieldGroup ?? []).find(child => child.key === binding.propertyKey); + if (!source) { + return undefined; + } + + const field = cloneDeep(source); + // The schema's own title ("Attributes", "Limit", "File") -- the reader's title when unnamed. + // Falls back to this, not the lower-camel key ("fileName"), which would read inconsistently. + const schemaLabel = (source.props?.label as string) || binding.propertyKey; + field.key = binding.id; + field.props = { + ...(field.props ?? {}), + label: binding.displayName || schemaLabel, + // The schema's own description is the operator author's note to whoever wired the operator + // up; it is not guidance to a form reader, and formly shows it once per scalar field. Drop it + // here so it does not appear unbidden under the input. + description: "", + }; + + const form = new FormGroup({}); + // Seed the model with the operator's other properties as read-only context, not just this + // input's own value: some custom widgets read a sibling to decide what to show -- the + // HuggingFace model picker reads `task` to load the right models and label the field. Only + // this binding's value is ever written back (see below); the context is never persisted, and + // it is cloned so a widget that mutates it cannot reach through to the real operator. + const model: Record<string, unknown> = { + ...cloneDeep(operator?.operatorProperties ?? {}), + [binding.id]: cloneDeep(resolved.value), + }; + if (this.canEdit) { + form.valueChanges + .pipe(debounceTime(FORM_DEBOUNCE_TIME_MS), takeUntil(this.formsRebuilt), untilDestroyed(this)) + .subscribe(() => { + // Formly emits the schema's empty default while building the control, before any edit; + // writing that back silently wiped the operator's real value (both views edit one + // workflow). So only accept a dirtied form, or a value that differs from the operator's + // without being emptier (some controls set values without marking dirty). + const next = model[binding.id]; + const current = this.formBindingService.readValue(binding.operatorID, binding.propertyKey); + const isEmpty = (v: unknown) => v === undefined || v === null || v === ""; + const unchanged = JSON.stringify(next ?? null) === JSON.stringify(current ?? null); + if (unchanged || (!form.dirty && isEmpty(next) && !isEmpty(current))) { + return; + } + // Write straight onto the operator (the same edit the canvas makes) and refresh this + // card's snapshot, which the template reads. + this.formBindingService.writeValue(binding, next); + this.parameters = this.formBindingService.resolveFields(); + const refreshed = this.parameters.find(p => p.binding.id === binding.id); + const card = this.rendered.find(r => r.resolved.binding.id === binding.id); + if (refreshed && card) { + card.resolved = refreshed; + } + this.cdr.detectChanges(); + }); + } else { + // A read-only viewer sees the author's values and can run with them, but cannot change them. + // Disable at the field level, not with form.disable(): formly builds its controls into the + // form after this, and a FormGroup disabled while still empty does not disable controls added + // later (it re-enables itself), so the input stayed editable. props.disabled is what formly + // honours, and it cascades to a nested property's sub-fields. No write-back is wired either. + field.props = { ...(field.props ?? {}), disabled: true }; + } + + return { resolved, fields: [field], form, model }; + } + + private operatorSchemaFor(operatorID: string): object | undefined { + const graph = this.workflowActionService.getTexeraGraph(); + if (!graph.hasOperator(operatorID)) { + return undefined; + } + try { + // Prefer the per-instance schema: it carries the upstream column names, so an attribute + // picker renders as a dropdown of real columns rather than a text box. + return this.dynamicSchemaService.getDynamicSchema(operatorID).jsonSchema; + } catch { + try { + return this.operatorMetadataService.getOperatorSchema(graph.getOperator(operatorID).operatorType).jsonSchema; + } catch { + return undefined; + } + } + } + + /** + * The inputs a reader is offered. Broken bindings (the operator was deleted, or the property key + * no longer exists) are left out, since filling one in could not affect a run; the author's view + * of them, to repair them, is added by the authoring PR. + */ + public get visibleFields(): ResolvedField[] { + return this.parameters.filter(field => !field.brokenReason); + } + + public trackByRendered(_: number, rendered: RenderedField): string { + return rendered.resolved.binding.id; + } + /** Open or close the workflow preview; opening it builds the canvas the first time. */ public toggleWorkflow(): void { this.workflowOpen = !this.workflowOpen; @@ -304,6 +530,12 @@ export class WorkflowFormComponent implements OnInit, OnDestroy { * stray "Untitled workflow" rows when the page is left before its workflow loaded. */ private save(): void { + // A read-only viewer can open and run the form (execution is gated on computing-unit access, + // not workflow access) but must never persist: every such save is a guaranteed 403 that would + // spam "Could not save" on each debounce. Their inputs are non-editable, so nothing is lost. + if (!this.canEdit) { + return; + } if (!this.userService.isLogin() || !this.workflowPersistService.isWorkflowPersistEnabled()) { return; } diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts index fc50486634..d6e6a4cb93 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.rendered.spec.ts @@ -18,8 +18,11 @@ */ import { DatePipe } from "@angular/common"; +import { FormGroup } from "@angular/forms"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ActivatedRoute, Router } from "@angular/router"; +import { FormlyForm, FormlyModule } from "@ngx-formly/core"; +import { FormlyJsonschema } from "@ngx-formly/core/json-schema"; import { EMPTY, of, Subject } from "rxjs"; import { WorkflowFormComponent } from "./workflow-form.component"; @@ -29,6 +32,9 @@ import { CoeditorPresenceService } from "../../service/workflow-graph/model/coed import { WorkflowActionService } from "../../service/workflow-graph/model/workflow-action.service"; import { WorkflowPersistService } from "../../../common/service/workflow-persist/workflow-persist.service"; import { OperatorMetadataService } from "../../service/operator-metadata/operator-metadata.service"; +import { FormBindingService } from "../../service/form-binding/form-binding.service"; +import { DynamicSchemaService } from "../../service/dynamic-schema/dynamic-schema.service"; +import { WorkflowCompilingService } from "../../service/compile-workflow/workflow-compiling.service"; import { ExecuteWorkflowService } from "../../service/execute-workflow/execute-workflow.service"; import { WorkflowResultService } from "../../service/workflow-result/workflow-result.service"; import { NotificationService } from "../../../common/service/notification/notification.service"; @@ -60,10 +66,18 @@ describe("WorkflowFormComponent (rendered template)", () => { /* eslint-disable no-restricted-syntax */ TestBed.overrideComponent(UserIconComponent, { set: { template: "" } }); TestBed.overrideComponent(CoeditorUserIconComponent, { set: { template: "" } }); + // Blank the formly-form child too: rendering real fields needs the ng-zorro type registry the + // property panel sets up, which is out of scope here. Blanking the child (not the page) keeps + // the page's own inputs markup -- the section head, the empty state, the card and the form + // wrapper -- rendered and covered. + TestBed.overrideComponent(FormlyForm, { set: { template: "" } }); /* eslint-enable no-restricted-syntax */ await TestBed.configureTestingModule({ - imports: [WorkflowFormComponent], + // forRoot registers the FormlyConfig the form builder needs: the page imports FormlyModule + // (standalone) but the root config lives with the app; supply it here so the blanked + // formly-form still builds instead of throwing "missing forRoot()". + imports: [WorkflowFormComponent, FormlyModule.forRoot()], providers: [ // One co-editor so the collaborator row (the *ngFor) renders and is covered. { @@ -85,6 +99,12 @@ describe("WorkflowFormComponent (rendered template)", () => { setWorkflowName: vi.fn(), workflowChanged: () => EMPTY, workflowMetaDataChanged: () => EMPTY, + formBindingChanged$: EMPTY, + getTexeraGraph: () => ({ + triggerCenterEvent: vi.fn(), + hasOperator: () => false, + getOperator: () => undefined, + }), }, }, { @@ -96,6 +116,16 @@ describe("WorkflowFormComponent (rendered template)", () => { }, }, { provide: OperatorMetadataService, useValue: { getOperatorMetadata: () => of({}) } }, + { + provide: FormBindingService, + useValue: { resolveFields: () => [], readValue: () => undefined, writeValue: vi.fn() }, + }, + { provide: FormlyJsonschema, useValue: { toFieldConfig: () => ({ fieldGroup: [] }) } }, + { provide: DynamicSchemaService, useValue: { getDynamicSchema: () => ({ jsonSchema: {} }) } }, + { + provide: WorkflowCompilingService, + useValue: { getCompilationStateInfoChangedStream: () => EMPTY }, + }, { provide: ExecuteWorkflowService, useValue: { resetExecutionAndWorkers: vi.fn() } }, { provide: WorkflowResultService, useValue: { clearResults: vi.fn() } }, { provide: NotificationService, useValue: { error: vi.fn() } }, @@ -175,6 +205,54 @@ describe("WorkflowFormComponent (rendered template)", () => { expect(spy).toHaveBeenCalled(); }); + it("shows the empty state when there are no inputs to fill in", () => { + fixture.detectChanges(); + finishLoad(); + + expect(el(".pc-section-head .label")?.textContent?.trim()).toBe("Inputs"); + expect(el(".empty")).not.toBeNull(); + expect(el(".params .param")).toBeNull(); + }); + + it("renders an exposed input as a card holding its formly field", () => { + fixture.detectChanges(); + finishLoad(); + const c = fixture.componentInstance; + // One resolved input with a field; the formly-form child is blanked, so this covers the page's + // own card + form wrapper markup without standing up the field registry. `parameters` is + // internal (drives the empty-state getter), reached here through a cast. + (c as any).parameters = [{ binding: { id: "b1" } }]; + c.rendered = [ + { resolved: { binding: { id: "b1" } }, fields: [{ key: "b1" }], form: new FormGroup({}), model: {} }, + ] as any; + fixture.detectChanges(); + + expect(el(".empty")).toBeNull(); + expect(el(".params .param")).not.toBeNull(); + expect(el(".param .param-form formly-form")).not.toBeNull(); + }); + + it("shows the author's help text under an input and locks a read-only viewer's card", () => { + fixture.detectChanges(); + finishLoad(); + const c = fixture.componentInstance; + c.canEdit = false; + (c as any).parameters = [{ binding: { id: "b1" } }]; + c.rendered = [ + { + resolved: { binding: { id: "b1", helpText: "Pick a small model." } }, + fields: [{ key: "b1" }], + form: new FormGroup({}), + model: {}, + }, + ] as any; + fixture.detectChanges(); + + expect(el(".param .param-help-text")?.textContent?.trim()).toBe("Pick a small model."); + // A read-only viewer's card blocks pointer interaction (covers the extra widget buttons too). + expect(el(".param.read-only")).not.toBeNull(); + }); + it("tears the workflow down when the browser unloads (the beforeunload host binding)", () => { fixture.detectChanges(); finishLoad(); diff --git a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts index ed0a1c977b..b3a210ab54 100644 --- a/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts +++ b/frontend/src/app/workspace/component/workflow-form/workflow-form.spec-harness.ts @@ -21,10 +21,28 @@ import { of, Subject } from "rxjs"; import { vi } from "vitest"; import { DefaultView } from "../../../dashboard/type/workflow-metadata.interface"; +import { ResolvedField } from "../../service/form-binding/form-binding.service"; /** The workflow every test opens by default: a form-default workflow, writable, empty content. */ export const formViewWorkflow = { name: "scGPT", defaultView: DefaultView.FORM, readonly: false, content: {} }; +/** A binding for one operator property, keyed by id (operator "op-1"). */ +export const binding = (id: string, displayName: string) => ({ + id, + operatorID: "op-1", + propertyKey: id, + displayName, +}); + +/** A resolved (non-broken) input, ready to render. Override `binding`/`brokenReason` per test. */ +export const resolved = (id: string, displayName: string, extra: Partial<ResolvedField> = {}): ResolvedField => ({ + binding: binding(id, displayName), + value: "seed", + operatorLabel: "Source: Scan", + schema: { type: "string" } as any, + ...extra, +}); + /** * Mocks shared by every workflow-form spec, plus the component factory. Only what the current * slices exercise is mocked; later slices add the dependencies (and streams) they introduce, so @@ -36,6 +54,12 @@ export function setupHarness() { const router = { navigate: vi.fn() }; const workflowChangedStream = new Subject<unknown>(); const workflowMetaDataChangedStream = new Subject<unknown>(); + // Compilation reports column names late; the form rebuilds its inputs off this stream. + const compilationChanged = new Subject<unknown>(); + // The operators the graph holds: `hasOperatorIds` gates operatorSchemaFor, `graphOperators` + // supplies each operator's type (which picks the custom widget). Tests add to them as needed. + const hasOperatorIds = new Set<string>(); + const graphOperators: any[] = []; // The preview centres the embedded graph once it is built; tests assert this fired. const triggerCenterEvent = vi.fn(); @@ -52,7 +76,38 @@ export function setupHarness() { getWorkflowMetadata: () => ({ name: "scGPT", lastModifiedTime: 1767225600000 }), setWorkflowName: vi.fn(), setWorkflowMetadata: vi.fn(), - getTexeraGraph: () => ({ triggerCenterEvent }), + getTexeraGraph: () => ({ + triggerCenterEvent, + hasOperator: (id: string) => hasOperatorIds.has(id), + getOperator: (id: string) => graphOperators.find(o => o.operatorID === id), + }), + // Exposing or un-exposing a property announces on this stream; the form re-reads its config. + formBindingChanged$: new Subject<unknown>(), + }; + // Resolves the exposed inputs and reads/writes their values. Tests point `resolveFields` at the + // inputs they want rendered; `readValue` seeds the write-back guard. + const formBindingService = { + resolveFields: vi.fn().mockReturnValue([]), + readValue: vi.fn().mockReturnValue(undefined), + writeValue: vi.fn(), + }; + // A field per property the tests expose. Real formly json-schema conversion is exercised by the + // property panel's own spec; here a deterministic map keeps these tests about the component's + // own decisions (which field, which widget, the write-back), and drives the `map` callback. + const formlyJsonschema = { + toFieldConfig: (_schema: any, opts: any) => { + const fields = [ + { key: "n_hvg", props: { label: "N" } }, + { key: "fileName", props: { label: "File" } }, + { key: "modelId", props: { label: "Model" } }, + { key: "datasetVersionPath", props: { label: "Dataset" } }, + ]; + return { fieldGroup: opts?.map ? fields.map(opts.map) : fields }; + }, + }; + const dynamicSchemaService = { getDynamicSchema: () => ({ jsonSchema: {} }) }; + const workflowCompilingService = { + getCompilationStateInfoChangedStream: () => compilationChanged.asObservable(), }; const workflowPersistService = { retrieveWorkflow: vi.fn().mockReturnValue(of(formViewWorkflow)), @@ -72,8 +127,9 @@ export function setupHarness() { const computingUnitStatusService = { disconnect: vi.fn() }; const workflowConsoleService = { clearConsoleMessages: vi.fn() }; // The name field is measured off the host; querySelector returns null so the measuring - // (DOM-layout, jsdom has none) short-circuits. - const host = { nativeElement: { querySelector: () => null } }; + // (DOM-layout, jsdom has none) short-circuits. `contains` drives isTypingInTheForm; false by + // default so a rebuild is never suppressed, and overridden by the tests that probe typing. + const host = { nativeElement: { querySelector: () => null, contains: () => false } }; const datePipe = { transform: () => "01/01/2026 00:00:00" }; const config = { env: { formViewEnabled: true } }; @@ -92,11 +148,15 @@ export function setupHarness() { workflowActionService, workflowPersistService, operatorMetadataService, + formBindingService, executeWorkflowService, workflowResultService, notificationService, userService, + formlyJsonschema, cdr, + dynamicSchemaService, + workflowCompilingService, computingUnitStatusService, workflowConsoleService, host, @@ -104,6 +164,9 @@ export function setupHarness() { config, workflowChangedStream, workflowMetaDataChangedStream, + compilationChanged, + hasOperatorIds, + graphOperators, triggerCenterEvent, }; } diff --git a/frontend/src/app/workspace/util/custom-formly-type.spec.ts b/frontend/src/app/workspace/util/custom-formly-type.spec.ts index 4417ca3f44..85ff479733 100644 --- a/frontend/src/app/workspace/util/custom-formly-type.spec.ts +++ b/frontend/src/app/workspace/util/custom-formly-type.spec.ts @@ -17,7 +17,7 @@ * under the License. */ -import { customFormlyFieldType, NON_FORM_FIELD_TYPES } from "./custom-formly-type"; +import { customFormlyFieldType, NON_FORM_FIELD_TYPES, CANVAS_ONLY_FORMLY_TYPES } from "./custom-formly-type"; describe("NON_FORM_FIELD_TYPES", () => { it("blocks only the code editor from being a form field, not the drag-reorder list", () => { @@ -27,6 +27,16 @@ describe("NON_FORM_FIELD_TYPES", () => { }); }); +describe("CANVAS_ONLY_FORMLY_TYPES", () => { + it("holds the widgets the form falls back from: the code editor and the drag-reorder list", () => { + expect(CANVAS_ONLY_FORMLY_TYPES.has("codearea")).toBe(true); + // the drag has nowhere to attach on a form, so an exposed one degrades to the default control + expect(CANVAS_ONLY_FORMLY_TYPES.has("repeat-section-dnd")).toBe(true); + // an ordinary custom widget (a picker/uploader) is rendered as itself, not fallen back from + expect(CANVAS_ONLY_FORMLY_TYPES.has("datasetversionselector")).toBe(false); + }); +}); + describe("customFormlyFieldType", () => { it("maps a fileName property to the autocomplete input", () => { expect(customFormlyFieldType({ key: "fileName", operatorType: "CSVFileScan" })).toBe("inputautocomplete"); diff --git a/frontend/src/app/workspace/util/custom-formly-type.ts b/frontend/src/app/workspace/util/custom-formly-type.ts index 6b6e666be9..0ce9086450 100644 --- a/frontend/src/app/workspace/util/custom-formly-type.ts +++ b/frontend/src/app/workspace/util/custom-formly-type.ts @@ -25,6 +25,15 @@ */ export const NON_FORM_FIELD_TYPES: ReadonlySet<string> = new Set(["codearea"]); +/** + * Widgets that only work on the operator canvas, so the Form View does not render them: it falls + * back to formly's default control instead. The code editor (also blocked from exposure by + * {@link NON_FORM_FIELD_TYPES}) and the drag-reorder list, whose drag has nowhere to attach on a + * form -- a workflow may still carry an exposed drag-reorder property from before, and it degrades + * to a plain editable list rather than a control that cannot function here. + */ +export const CANVAS_ONLY_FORMLY_TYPES: ReadonlySet<string> = new Set(["codearea", "repeat-section-dnd"]); + /** * The custom formly widget an operator-schema property renders as, decided from the property key * and its operator. A single source of truth extracted from the operator property panel so that a
