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-8318-1facefb183c96e7b426ff6be35e5b6aaa7b33345
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 5de83017defe685cc29703eb37628383fbbf3bfb
Author: yangzhang75 <[email protected]>
AuthorDate: Thu Sep 3 22:34:05 2026 +0000

    feat(property-editor): choose which properties the Form View exposes (#8318)
    
    ### What changes were proposed in this PR?
    
    Part of the Form View feature stack (parent issue #8011). This PR is the
    authoring affordance for choosing which operator properties the Form
    View exposes -- frontend only.
    
    * A toggle on the operator property panel (shown wherever the feature
    flag is on) turns on a "choosing" mode, kept in the form-binding service
    so the canvas toolbar and the Form View share the same state.
    * In that mode the operator property frame puts a tick box beside each
    top-level property (`ExposePropertyWrapper`) -- never on nested
    array/object fields; ticking it exposes or unexposes that property
    through the form-binding service, which records it in the workflow's
    form binding.
    
    Reader-facing rendering of the exposed inputs, the editable-label
    wrapper (rename/hide), and the property panel's reuse in the Form View
    are added by later PRs in the stack, each alongside the code that
    consumes them.
    
    ### Any related issues, documentation, discussions?
    
    Closes #8017.
    
    Part of the Form View feature (parent issue #8011); builds on the
    form-binding service (#8016) and the shared types (#8015), both merged.
    
    ### How was this PR tested?
    
    Unit tests (vitest): the new `ExposePropertyWrapper` at 100% statements,
    branches, and functions; the property editor's choosing logic; and the
    operator frame's tick-box decoration -- including a regression that
    nested fields never receive a toggle, even one whose key collides with a
    top-level property name. Coverage is 100% of the lines this PR adds.
    
    ### Screenshots
    
    The property panel gets a "Choose Form View fields" toggle; turning it
    on puts a tick box beside each top-level property so the author can mark
    which ones the form exposes.
    
    Choosing toggle (off):
    
    <img width="320" height="476" alt="Screenshot 2026-09-02 at 9 49 09 PM"
    
src="https://github.com/user-attachments/assets/ca01544d-c475-4994-a29d-81731061f474";
    />
    
    
    Choosing on, with a property exposed (tick boxes appear on top-level
    properties only, never on nested rows):
    
    <img width="315" height="474" alt="Screenshot 2026-09-02 at 9 49 25 PM"
    
src="https://github.com/user-attachments/assets/746e4468-e166-4976-bb45-94ce1cc751f1";
    />
    
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes. Co-authored with Claude (Anthropic), reviewed line by line by the
    author before submission.
    
    Co-authored-by: Claude Opus 4.8 <[email protected]>
---
 .../expose-property-wrapper.component.html         |  33 +++++++
 .../expose-property-wrapper.component.scss         |  43 +++++++++
 .../expose-property-wrapper.component.spec.ts      |  83 +++++++++++++++++
 .../expose-property-wrapper.component.ts           |  46 ++++++++++
 frontend/src/app/common/formly/formly-config.ts    |   2 +
 .../operator-property-edit-frame.component.spec.ts |  53 +++++++++++
 .../operator-property-edit-frame.component.ts      |  24 +++++
 .../property-editor/property-editor.component.html |  18 ++++
 .../property-editor.component.spec.ts              | 100 +++++++++++++++++++++
 .../property-editor/property-editor.component.ts   |  91 ++++++++++++++++++-
 10 files changed, 489 insertions(+), 4 deletions(-)

diff --git 
a/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.html
 
b/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.html
new file mode 100644
index 0000000000..09f9d29d8c
--- /dev/null
+++ 
b/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.html
@@ -0,0 +1,33 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+-->
+
+<div class="expose-row choosing">
+  <label
+    class="expose-box"
+    [title]="'Show ' + (props.label || 'this property') + ' on the Form View'">
+    <input
+      type="checkbox"
+      [checked]="props.exposed"
+      [attr.aria-label]="'Show ' + (props.label || 'this property') + ' on the 
Form View'"
+      (change)="onToggle($event)" />
+  </label>
+  <div class="expose-field">
+    <ng-container #fieldComponent></ng-container>
+  </div>
+</div>
diff --git 
a/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.scss
 
b/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.scss
new file mode 100644
index 0000000000..29421870fc
--- /dev/null
+++ 
b/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.scss
@@ -0,0 +1,43 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+/* Only the checkbox is new; the field itself keeps the property editor's own 
layout. */
+.expose-row {
+  display: flex;
+  align-items: flex-start;
+  gap: 8px;
+}
+
+.expose-field {
+  flex: 1;
+  min-width: 0;
+}
+
+.expose-box {
+  padding-top: 6px;
+  cursor: pointer;
+  flex: none;
+
+  input {
+    accent-color: #1890ff;
+    width: 14px;
+    height: 14px;
+    cursor: pointer;
+  }
+}
diff --git 
a/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.spec.ts
 
b/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.spec.ts
new file mode 100644
index 0000000000..8374b314ff
--- /dev/null
+++ 
b/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.spec.ts
@@ -0,0 +1,83 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+import { FormlyFieldConfig } from "@ngx-formly/core";
+import { ExposePropertyWrapperComponent } from 
"./expose-property-wrapper.component";
+
+describe("ExposePropertyWrapperComponent", () => {
+  let component: ExposePropertyWrapperComponent;
+  let fixture: ComponentFixture<ExposePropertyWrapperComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [ExposePropertyWrapperComponent],
+    }).compileComponents();
+    fixture = TestBed.createComponent(ExposePropertyWrapperComponent);
+    component = fixture.componentInstance;
+  });
+
+  describe("decorate", () => {
+    it("keeps form-field outermost and appends the wrapper when no wrappers 
are set", () => {
+      const config: FormlyFieldConfig = { key: "k" };
+      ExposePropertyWrapperComponent.decorate(config, true, () => {});
+      expect(config.wrappers).toEqual(["form-field", 
"expose-property-wrapper"]);
+    });
+
+    it("appends after existing wrappers without dropping them", () => {
+      const config: FormlyFieldConfig = { key: "k", wrappers: ["custom"] };
+      ExposePropertyWrapperComponent.decorate(config, false, () => {});
+      expect(config.wrappers).toEqual(["custom", "expose-property-wrapper"]);
+    });
+
+    it("carries the exposed state and toggle callback in props, preserving 
existing props", () => {
+      const toggle = vi.fn();
+      const config: FormlyFieldConfig = { key: "k", props: { label: "kept" } };
+      ExposePropertyWrapperComponent.decorate(config, true, toggle);
+      expect(config.props?.["label"]).toBe("kept");
+      expect(config.props?.["exposed"]).toBe(true);
+      expect(config.props?.["toggleExposed"]).toBe(toggle);
+    });
+  });
+
+  describe("onToggle", () => {
+    it("forwards the checkbox checked state to toggleExposed", () => {
+      const toggle = vi.fn();
+      component.field = { props: { exposed: false, toggleExposed: toggle } } 
as unknown as FormlyFieldConfig;
+      component.onToggle({ target: { checked: true } } as unknown as Event);
+      expect(toggle).toHaveBeenCalledWith(true);
+    });
+  });
+
+  describe("template", () => {
+    it("renders a checkbox reflecting props.exposed and drives onToggle on 
change", () => {
+      const toggle = vi.fn();
+      component.field = { props: { exposed: true, toggleExposed: toggle } } as 
unknown as FormlyFieldConfig;
+      fixture.detectChanges();
+
+      const box = fixture.nativeElement.querySelector("input[type=checkbox]") 
as HTMLInputElement;
+      expect(box).toBeTruthy();
+      expect(box.checked).toBe(true);
+
+      box.checked = false;
+      box.dispatchEvent(new Event("change"));
+      expect(toggle).toHaveBeenCalledWith(false);
+    });
+  });
+});
diff --git 
a/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.ts
 
b/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.ts
new file mode 100644
index 0000000000..ade80e1e0f
--- /dev/null
+++ 
b/frontend/src/app/common/formly/expose-property-wrapper/expose-property-wrapper.component.ts
@@ -0,0 +1,46 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { Component } from "@angular/core";
+import { FieldWrapper, FormlyFieldConfig } from "@ngx-formly/core";
+import { merge } from "lodash-es";
+
+/**
+ * A tick box beside an operator property, in the property editor, for an 
author to choose
+ * whether it appears on the Form View. Rendered only while choosing.
+ */
+@Component({
+  selector: "texera-expose-property-wrapper",
+  templateUrl: "./expose-property-wrapper.component.html",
+  styleUrls: ["./expose-property-wrapper.component.scss"],
+})
+export class ExposePropertyWrapperComponent extends FieldWrapper {
+  /** Add this wrapper to a field, carrying state + callback in `props`; 
`form-field` stays
+   *  outermost so label/error rendering is untouched. */
+  public static decorate(config: FormlyFieldConfig, exposed: boolean, toggle: 
(checked: boolean) => void): void {
+    merge(config, {
+      wrappers: [...(config.wrappers ?? ["form-field"]), 
"expose-property-wrapper"],
+      props: { ...config.props, exposed, toggleExposed: toggle },
+    });
+  }
+
+  public onToggle(event: Event): void {
+    this.props["toggleExposed"]((event.target as HTMLInputElement).checked);
+  }
+}
diff --git a/frontend/src/app/common/formly/formly-config.ts 
b/frontend/src/app/common/formly/formly-config.ts
index c4fc54fd77..61cc13e639 100644
--- a/frontend/src/app/common/formly/formly-config.ts
+++ b/frontend/src/app/common/formly/formly-config.ts
@@ -26,6 +26,7 @@ import { CodeareaCustomTemplateComponent } from 
"../../workspace/component/codea
 import { PresetWrapperComponent } from 
"./preset-wrapper/preset-wrapper.component";
 import { DatasetFileSelectorComponent } from 
"../../workspace/component/dataset-file-selector/dataset-file-selector.component";
 import { CollabWrapperComponent } from 
"./collab-wrapper/collab-wrapper/collab-wrapper.component";
+import { ExposePropertyWrapperComponent } from 
"./expose-property-wrapper/expose-property-wrapper.component";
 import { FormlyRepeatDndComponent } from "./repeat-dnd/repeat-dnd.component";
 import { UiUdfParametersComponent } from 
"../../workspace/component/ui-udf-parameters/ui-udf-parameters.component";
 import { DatasetVersionSelectorComponent } from 
"../../workspace/component/dataset-version-selector/dataset-version-selector.component";
@@ -92,6 +93,7 @@ export const TEXERA_FORMLY_CONFIG = {
   wrappers: [
     { name: "preset-wrapper", component: PresetWrapperComponent },
     { name: "collab-wrapper", component: CollabWrapperComponent },
+    { name: "expose-property-wrapper", component: 
ExposePropertyWrapperComponent },
   ],
 };
 
diff --git 
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
 
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
index 2831a9943f..f320f5495d 100644
--- 
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.spec.ts
@@ -45,6 +45,7 @@ import {
 } from "../../../service/operator-metadata/mock-operator-metadata.data";
 import { configure } from "rxjs-marbles";
 import { SimpleChange } from "@angular/core";
+import { FormBindingService } from 
"../../../service/form-binding/form-binding.service";
 import { cloneDeep } from "lodash-es";
 
 import Ajv from "ajv";
@@ -2936,4 +2937,56 @@ describe("OperatorPropertyEditFrameComponent", () => {
       );
     });
   });
+
+  describe("choosing which properties the Form View exposes", () => {
+    it("wires each top-level tick box to the exposure service", () => {
+      const formBindingService = TestBed.inject(FormBindingService);
+      const setExposed = vi.spyOn(formBindingService, "setExposed");
+      component.exposeChoosing = true;
+      workflowActionService.addOperator(mockScanPredicate, mockPoint);
+
+      component.ngOnChanges({
+        currentOperatorId: new SimpleChange(undefined, 
mockScanPredicate.operatorID, true),
+      });
+      fixture.detectChanges();
+
+      const field = component.formlyFields?.[0]?.fieldGroup?.find(f => 
f.props?.["toggleExposed"] !== undefined);
+      (field!.props as any).toggleExposed(true);
+
+      expect(setExposed).toHaveBeenCalledWith(mockScanPredicate.operatorID, 
field!.key, true);
+    });
+
+    // The tick box belongs to top-level properties only; a nested field must 
not get one,
+    // not even one whose key collides with a top-level property name.
+    it("never puts a tick box on a nested field, including one whose name 
collides with a root property", () => {
+      const formBindingService = TestBed.inject(FormBindingService);
+      vi.spyOn(formBindingService, "isExposed").mockReturnValue(false);
+      component.exposeChoosing = true;
+      component.currentOperatorId = "op-nested";
+
+      component.setFormlyFormBinding({
+        type: "object",
+        properties: {
+          tableName: { type: "string" },
+          group: {
+            type: "object",
+            properties: { tableName: { type: "string" }, value: { type: 
"string" } },
+          },
+        },
+      });
+
+      const topLevel = component.formlyFields?.[0]?.fieldGroup ?? [];
+      const decoratedTop = topLevel
+        .filter(f => f.props?.["toggleExposed"] !== undefined)
+        .map(f => f.key)
+        .sort();
+      expect(decoratedTop).toEqual(["group", "tableName"]);
+
+      // the nested tableName (same name as a root property) is not decorated
+      const nested = topLevel.find(f => f.key === "group")?.fieldGroup ?? [];
+      const nestedTableName = nested.find(f => f.key === "tableName");
+      expect(nestedTableName).toBeDefined();
+      expect(nestedTableName?.props?.["toggleExposed"]).toBeUndefined();
+    });
+  });
 });
diff --git 
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
 
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
index b637925519..daaa137536 100644
--- 
a/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
+++ 
b/frontend/src/app/workspace/component/property-editor/operator-property-edit-frame/operator-property-edit-frame.component.ts
@@ -18,6 +18,8 @@
  */
 
 import { ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, 
SimpleChanges } from "@angular/core";
+import { ExposePropertyWrapperComponent } from 
"../../../../common/formly/expose-property-wrapper/expose-property-wrapper.component";
+import { FormBindingService } from 
"../../../service/form-binding/form-binding.service";
 import { ExecuteWorkflowService } from 
"../../../service/execute-workflow/execute-workflow.service";
 import { WorkflowStatusService } from 
"../../../service/workflow-status/workflow-status.service";
 import { Subject } from "rxjs";
@@ -172,6 +174,9 @@ export function conditionalRequiredRules(schema: unknown): 
Map<string, Condition
 })
 export class OperatorPropertyEditFrameComponent implements OnInit, OnChanges, 
OnDestroy {
   @Input() currentOperatorId?: string;
+  /** True while an author is choosing which properties appear on the Form 
View; adds a tick
+   *  box beside each. Off, the property editor is unchanged. */
+  @Input() exposeChoosing = false;
 
   currentOperatorSchema?: OperatorSchema;
 
@@ -491,6 +496,7 @@ export class OperatorPropertyEditFrameComponent implements 
OnInit, OnChanges, On
   }
 
   constructor(
+    private formBindingService: FormBindingService,
     private formlyJsonschema: FormlyJsonschema,
     private workflowActionService: WorkflowActionService,
     public executeWorkflowService: ExecuteWorkflowService,
@@ -1361,6 +1367,24 @@ export class OperatorPropertyEditFrameComponent 
implements OnInit, OnChanges, On
     const schemaProperties = schema.properties;
     const fields = field.fieldGroup;
 
+    // A tick box beside each TOP-LEVEL property only, added over the root 
field group rather
+    // than inside the per-field map (which runs at every depth and so could 
not tell a nested
+    // field from a same-named top-level one -- an array-of-objects property 
would otherwise
+    // sprout boxes on the array, each item and each nested field).
+    if (this.exposeChoosing && this.currentOperatorId && fields) {
+      const operatorId = this.currentOperatorId;
+      for (const topLevelField of fields) {
+        if (typeof topLevelField.key === "string") {
+          const propertyKey = topLevelField.key;
+          ExposePropertyWrapperComponent.decorate(
+            topLevelField,
+            this.formBindingService.isExposed(operatorId, propertyKey),
+            (checked: boolean) => 
this.formBindingService.setExposed(operatorId, propertyKey, checked)
+          );
+        }
+      }
+    }
+
     // adding custom options, relational N-to-M mapping.
     if (schemaProperties && fields) {
       Object.entries(schemaProperties).forEach(([propertyName, propertyValue]) 
=> {
diff --git 
a/frontend/src/app/workspace/component/property-editor/property-editor.component.html
 
b/frontend/src/app/workspace/component/property-editor/property-editor.component.html
index 7ca4a328a8..26ca023296 100644
--- 
a/frontend/src/app/workspace/component/property-editor/property-editor.component.html
+++ 
b/frontend/src/app/workspace/component/property-editor/property-editor.component.html
@@ -60,6 +60,24 @@
     nz-menu
     id="property-buttons"
     [ngClass]="{'shadow':  !width}">
+    <!-- Choosing which settings the form offers happens by ticking them in 
this very
+         panel, so the switch that turns those tick boxes on belongs here 
rather than in
+         a row of file and delete icons across the toolbar. Offered wherever 
the Form View
+         feature is enabled. -->
+    <button
+      nz-button
+      [nzType]="choosing ? 'primary' : 'text'"
+      class="choose-fields"
+      (click)="toggleChoosing()"
+      *ngIf="width && formViewFeatureEnabled"
+      nz-tooltip
+      nzTooltipPlacement="bottomRight"
+      [attr.aria-label]="choosing ? 'Done choosing fields' : 'Choose Form View 
fields'"
+      [nzTooltipTitle]="choosing ? 'Done choosing fields' : 'Choose Form View 
fields'">
+      <span
+        nz-icon
+        nzType="check-square"></span>
+    </button>
     <button
       nz-button
       nzType="text"
diff --git 
a/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts
 
b/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts
index e0713c37b1..67ea6d0e7d 100644
--- 
a/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts
+++ 
b/frontend/src/app/workspace/component/property-editor/property-editor.component.spec.ts
@@ -30,6 +30,7 @@ import { WorkflowActionService } from 
"../../service/workflow-graph/model/workfl
 import { OperatorPropertyEditFrameComponent } from 
"./operator-property-edit-frame/operator-property-edit-frame.component";
 import { PortPropertyEditFrameComponent } from 
"./port-property-edit-frame/port-property-edit-frame.component";
 import { PanelService } from "../../service/panel/panel.service";
+import { FormBindingService } from 
"../../service/form-binding/form-binding.service";
 import { HttpClientTestingModule } from "@angular/common/http/testing";
 import { OperatorMetadataService } from 
"../../service/operator-metadata/operator-metadata.service";
 import { StubOperatorMetadataService } from 
"../../service/operator-metadata/stub-operator-metadata.service";
@@ -96,6 +97,7 @@ describe("PropertyEditorComponent", () => {
     
expect(component.currentComponent).toBe(OperatorPropertyEditFrameComponent);
     expect(component.componentInputs).toEqual({
       currentOperatorId: mockScanPredicate.operatorID,
+      exposeChoosing: false,
     });
 
     // unhighlight the operator
@@ -145,6 +147,7 @@ describe("PropertyEditorComponent", () => {
     
expect(component.currentComponent).toBe(OperatorPropertyEditFrameComponent);
     expect(component.componentInputs).toEqual({
       currentOperatorId: mockScanPredicate.operatorID,
+      exposeChoosing: false,
     });
 
     // unhighlight the operator
@@ -160,6 +163,7 @@ describe("PropertyEditorComponent", () => {
     
expect(component.currentComponent).toBe(OperatorPropertyEditFrameComponent);
     expect(component.componentInputs).toEqual({
       currentOperatorId: mockResultPredicate.operatorID,
+      exposeChoosing: false,
     });
   });
 
@@ -423,4 +427,100 @@ describe("PropertyEditorComponent", () => {
       expect(component.width).toBe(280);
     });
   });
+
+  describe("choosing which properties the Form View exposes", () => {
+    it("toggles choosing through the shared service", () => {
+      const service = TestBed.inject(FormBindingService);
+      vi.spyOn(service, "isChoosing").mockReturnValue(false);
+      const setChoosing = vi.spyOn(service, "setChoosing");
+
+      component.toggleChoosing();
+
+      expect(setChoosing).toHaveBeenCalledWith(true);
+    });
+
+    it("renders the choose-fields button and toggles choosing when it is 
clicked", () => {
+      const service = TestBed.inject(FormBindingService);
+      vi.spyOn(service, "isChoosing").mockReturnValue(false);
+      const setChoosing = vi.spyOn(service, "setChoosing");
+      // The button is offered only where the Form View flag is on and the 
panel is open.
+      vi.spyOn(component, "formViewFeatureEnabled", 
"get").mockReturnValue(true);
+      component.width = 300;
+      fixture.detectChanges();
+
+      const button = 
fixture.nativeElement.querySelector("button.choose-fields") as 
HTMLButtonElement;
+      expect(button).toBeTruthy();
+      button.click();
+
+      expect(setChoosing).toHaveBeenCalledWith(true);
+    });
+
+    it("hides the choose-fields button when the Form View flag is off", () => {
+      vi.spyOn(component, "formViewFeatureEnabled", 
"get").mockReturnValue(false);
+      component.width = 300;
+      fixture.detectChanges();
+
+      
expect(fixture.nativeElement.querySelector("button.choose-fields")).toBeNull();
+    });
+
+    it("remounts the operator frame when the expose-choosing input changes 
after first render", fakeAsync(() => {
+      component.currentComponent = OperatorPropertyEditFrameComponent;
+
+      component.ngOnChanges({
+        exposeChoosing: { firstChange: false, currentValue: true, 
previousValue: false, isFirstChange: () => false },
+      });
+      expect(component.currentComponent).toBeNull();
+      tick();
+
+      
expect(component.currentComponent).toBe(OperatorPropertyEditFrameComponent);
+    }));
+
+    // The rebuild is deferred to a timer; if the panel is destroyed before it 
fires, the
+    // timer must not run detectChanges on the destroyed view (which throws).
+    it("skips the deferred frame rebuild when the view is destroyed before the 
timer fires", fakeAsync(() => {
+      const f = TestBed.createComponent(PropertyEditorComponent);
+      const comp = f.componentInstance;
+      f.detectChanges();
+      comp.currentComponent = OperatorPropertyEditFrameComponent;
+
+      comp.ngOnChanges({
+        exposeChoosing: { firstChange: false, currentValue: true, 
previousValue: false, isFirstChange: () => false },
+      });
+      expect(comp.currentComponent).toBeNull();
+
+      f.destroy();
+
+      expect(() => tick()).not.toThrow();
+    }));
+
+    it("does not remount when no operator frame is showing", () => {
+      component.currentComponent = null;
+
+      component.ngOnChanges({
+        exposeChoosing: { firstChange: false, currentValue: true, 
previousValue: false, isFirstChange: () => false },
+      });
+
+      expect(component.currentComponent).toBeNull();
+    });
+
+    it("leaves the frame alone on the input's first change", () => {
+      component.currentComponent = OperatorPropertyEditFrameComponent;
+
+      component.ngOnChanges({
+        exposeChoosing: { firstChange: true, currentValue: true, 
previousValue: undefined, isFirstChange: () => true },
+      });
+
+      
expect(component.currentComponent).toBe(OperatorPropertyEditFrameComponent);
+    });
+
+    it("remounts the frame when the toolbar toggles choosing", fakeAsync(() => 
{
+      component.currentComponent = OperatorPropertyEditFrameComponent;
+
+      TestBed.inject(FormBindingService).setChoosing(true);
+      expect(component.currentComponent).toBeNull();
+      tick();
+
+      
expect(component.currentComponent).toBe(OperatorPropertyEditFrameComponent);
+    }));
+  });
 });
diff --git 
a/frontend/src/app/workspace/component/property-editor/property-editor.component.ts
 
b/frontend/src/app/workspace/component/property-editor/property-editor.component.ts
index 0db264732c..e600692604 100644
--- 
a/frontend/src/app/workspace/component/property-editor/property-editor.component.ts
+++ 
b/frontend/src/app/workspace/component/property-editor/property-editor.component.ts
@@ -22,20 +22,26 @@ import {
   Component,
   ElementRef,
   HostListener,
+  Input,
+  OnChanges,
   OnDestroy,
   OnInit,
+  SimpleChanges,
   Type,
   ViewChild,
+  ViewRef,
 } from "@angular/core";
 import { merge } from "rxjs";
 import { WorkflowActionService } from 
"../../service/workflow-graph/model/workflow-action.service";
 import { OperatorPropertyEditFrameComponent } from 
"./operator-property-edit-frame/operator-property-edit-frame.component";
 import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy";
-import { filter } from "rxjs/operators";
+import { distinctUntilChanged, filter } from "rxjs/operators";
 import { PortPropertyEditFrameComponent } from 
"./port-property-edit-frame/port-property-edit-frame.component";
 import { NzResizeEvent, NzResizableDirective, NzResizeHandlesComponent } from 
"ng-zorro-antd/resizable";
 import { calculateTotalTranslate3d } from "../../../common/util/panel-dock";
 import { PanelService } from "../../service/panel/panel.service";
+import { FormBindingService } from 
"../../service/form-binding/form-binding.service";
+import { GuiConfigService } from "../../../common/service/gui-config.service";
 import { NzMenuDirective, NzMenuItemComponent, NzMenuDividerDirective } from 
"ng-zorro-antd/menu";
 import { NgClass, NgIf, NgComponentOutlet } from "@angular/common";
 import { ɵNzTransitionPatchDirective } from 
"ng-zorro-antd/core/transition-patch";
@@ -74,26 +80,62 @@ import { NzButtonComponent } from "ng-zorro-antd/button";
     NzResizeHandlesComponent,
   ],
 })
-export class PropertyEditorComponent implements OnInit, OnDestroy {
+export class PropertyEditorComponent implements OnInit, OnDestroy, OnChanges {
   @ViewChild("contentWrapper") contentWrapperRef!: ElementRef;
   protected readonly window = window;
   id = -1;
   width = 260;
   height = Math.max(300, window.innerHeight * 0.6);
   currentComponent: Type<any> | null = null;
+  /**
+   * Set while an author is choosing which properties the Form View offers.
+   * Forwarded to the operator frame, which puts a tick box beside each 
property.
+   */
+  @Input() exposeChoosing = false;
+  /** Set from the toolbar toggle on the operator canvas; the input covers the 
form view. */
+  private choosingFromToolbar = false;
+
+  /** The choose-what-to-expose affordance appears wherever the feature flag 
is on: any
+   *  workflow can expose inputs to its Form View, independent of the 
default-view bit. Named
+   *  for the flag (not a per-workflow capability) so the gating reads clearly 
at the call site. */
+  public get formViewFeatureEnabled(): boolean {
+    return this.config.env.formViewEnabled;
+  }
+
+  public toggleChoosing(): void {
+    this.formBindingService.setChoosing(!this.formBindingService.isChoosing());
+  }
+
+  public get choosing(): boolean {
+    return this.exposeChoosing || this.choosingFromToolbar;
+  }
   componentInputs = {};
   dragPosition = { x: 0, y: 0 };
   returnPosition = { x: 0, y: 0 };
   constructor(
     public workflowActionService: WorkflowActionService,
     private changeDetectorRef: ChangeDetectorRef,
-    private panelService: PanelService
+    private panelService: PanelService,
+    private formBindingService: FormBindingService,
+    private config: GuiConfigService
   ) {
     const width = localStorage.getItem("right-panel-width");
     if (width) this.width = Number(width);
     this.height = Number(localStorage.getItem("right-panel-height")) || 
this.height;
   }
 
+  /**
+   * The Form View turns tick boxes on by setting this input, and it flips 
whenever the author
+   * enters or leaves edit mode. The frame builds its formly fields once, so 
without remounting
+   * here the boxes only appeared if the mode was already on when the panel 
opened -- entering
+   * edit mode with a step already selected showed none.
+   */
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes["exposeChoosing"] && !changes["exposeChoosing"].firstChange) {
+      this.remountOperatorFrame();
+    }
+  }
+
   ngOnInit(): void {
     const style = localStorage.getItem("right-panel-style");
     if (style) document.getElementById("right-container")!.style.cssText = 
style;
@@ -101,6 +143,21 @@ export class PropertyEditorComponent implements OnInit, 
OnDestroy {
     const [xOffset, yOffset, _] = calculateTotalTranslate3d(translates);
     this.returnPosition = { x: -xOffset, y: -yOffset };
     this.registerHighlightEventsHandler();
+    // The toolbar's "choose fields" toggle lives in the service so both the 
canvas toolbar
+    // and this panel see the same state. Re-emit the frame's inputs when it 
changes, so tick
+    // boxes appear and disappear without needing a re-selection.
+    this.formBindingService.choosing$.pipe(distinctUntilChanged(), 
untilDestroyed(this)).subscribe(choosing => {
+      const wasChoosing = this.choosingFromToolbar;
+      this.choosingFromToolbar = choosing;
+      // Only an actual change needs the frame rebuilt. The stream is a 
BehaviorSubject, so it
+      // replays its current value on subscribe; remounting for that would 
tear the panel down
+      // during the page's first change-detection pass.
+      if (wasChoosing === choosing || this.currentComponent !== 
OperatorPropertyEditFrameComponent) {
+        return;
+      }
+      // Rebuild the frame so the tick boxes appear/disappear (see 
remountOperatorFrame).
+      this.remountOperatorFrame();
+    });
     this.panelService.closePanelStream.pipe(untilDestroyed(this)).subscribe(() 
=> this.closePanel());
     this.panelService.resetPanelStream.pipe(untilDestroyed(this)).subscribe(() 
=> {
       this.resetPanelPosition();
@@ -120,6 +177,32 @@ export class PropertyEditorComponent implements OnInit, 
OnDestroy {
     });
   }
 
+  /**
+   * Rebuild the operator frame so a changed tick-box mode takes effect. The 
frame builds its
+   * formly fields once when created, so a new input alone would not add or 
remove the tick
+   * boxes -- it has to be torn down and recreated. The recreation is deferred 
to a timer rather
+   * than run straight after the teardown so it lands in its own 
change-detection pass (a
+   * synchronous rebuild here fought the pass already running). The frame is 
restored before
+   * detectChanges, so the panel is never left on the `null` state the 
template hides on; and a
+   * timer that outlives the view (destroy within the same tick) is skipped, 
since detectChanges
+   * on a destroyed view throws.
+   */
+  private remountOperatorFrame(): void {
+    if (this.currentComponent !== OperatorPropertyEditFrameComponent) {
+      return;
+    }
+    const inputs = { ...this.componentInputs, exposeChoosing: this.choosing };
+    this.currentComponent = null;
+    setTimeout(() => {
+      if ((this.changeDetectorRef as ViewRef).destroyed) {
+        return;
+      }
+      this.componentInputs = inputs;
+      this.currentComponent = OperatorPropertyEditFrameComponent;
+      this.changeDetectorRef.detectChanges();
+    });
+  }
+
   @HostListener("window:beforeunload")
   ngOnDestroy(): void {
     localStorage.setItem("right-panel-width", String(this.width));
@@ -165,7 +248,7 @@ export class PropertyEditorComponent implements OnInit, 
OnDestroy {
 
         if (highlightedOperators.length === 1 && highlightLinks.length === 0 
&& highlightedPorts.length === 0) {
           this.currentComponent = OperatorPropertyEditFrameComponent;
-          this.componentInputs = { currentOperatorId: highlightedOperators[0] 
};
+          this.componentInputs = { currentOperatorId: highlightedOperators[0], 
exposeChoosing: this.choosing };
         } else if (highlightedPorts.length === 1 && highlightLinks.length === 
0) {
           this.currentComponent = PortPropertyEditFrameComponent;
           this.componentInputs = { currentPortID: highlightedPorts[0] };

Reply via email to