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] };
