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-8843-4c982a7a8abba119b326b19456f4ef7fda78f059
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 397de92d779b3d8cb235116609cb4fe76d0c7da7
Author: yangzhang75 <[email protected]>
AuthorDate: Wed Oct 7 19:32:00 2026 +0000

    feat(workflow-form): render Projection's drag-reorder list in the form, 
with the drag (#8843)
    
    ### What changes were proposed in this PR?
    
    Closes #8761. Part of the Form View feature (#8011); from the #8439
    refinements (item 2).
    
    An exposed drag-reorder property -- Projection's columns, formly type
    `repeat-section-dnd` -- fell back to a plain repeated list in the Form
    View (`CANVAS_ONLY_FORMLY_TYPES`): a reader could edit the rows but not
    reorder them, which is what the widget is for. The fallback assumed the
    drag had nowhere to attach on a form; the widget carries its own CDK
    drop list, and the row template it needs comes from the operator's own
    schema (the per-instance one, with the upstream columns), which the form
    already builds.
    
    - `repeat-section-dnd` leaves `CANVAS_ONLY_FORMLY_TYPES`; the set now
    holds the code editor only. On the form the widget renders exactly as on
    the operator property panel, drag handle and all, and a drop reaches the
    operator through the card's `valueChanges` write-back; the panel's
    `reorder` callback stays optional (the widget calls it only when the
    host provides one), so the same widget serves both hosts without a
    form-specific branch. Nothing is added to the widget's UI.
    - Two defects in the widget itself, found while reviewing this and in
    reach of the form for the first time: every button now has
    `type="button"` -- `nz-button` sets no type, so inside a form (the
    card's, the panel's) a typeless button is the submit button and Enter in
    a row's input clicked it, removing the first row (the fallback the form
    used before had typed buttons; the panel has had this since the widget
    exists); and the rows are re-keyed to their positions after a drop, as
    formly's own `remove()` does -- formly keys rows by position and writes
    a cell's value into the model by that key path, so a value typed into a
    moved row landed on the row that used to be there (the same after a drag
    on the panel, until the operator was re-selected).
    
    ### Any related issues, documentation, discussions?
    
    Closes #8761. Raised by @mengw15 on #8436; filed from #8439. Builds on
    the sub-field walk from #8438. #8760 (proper) also shrinks
    `CANVAS_ONLY_FORMLY_TYPES`; whichever lands second deletes the
    then-empty set. Known limitation, not this PR's code: the Form View
    rebuilds every card on every compilation, including the one a card's own
    write triggers, so on the form a drop's write redraws the rows about a
    second later, and a second drop made in the last 150 ms before that
    redraw is undone. The canvas panel has no such rebuild. Filed as #8844
    and fixed in #8845 (the form rebuilds only when the schema, the set of
    inputs or a value it does not hold changed); this PR is independent of
    it. Other follow-ups: a card's model is seeded once and never re-seeded
    from a co-editor's edit of the same property, so a reorder writes the
    whole array back over such an edit (pre-existing for every array/object
    property; a reorder is one more trigger); the HuggingFace and codearea
    widgets also carry typeless buttons.
    
    ### How was this PR tested?
    
    Unit tests (vitest). The widget's spec: a drop reorders the model, the
    field configs and the form controls and then tells the parent; a section
    without a reorder callback still reorders; a keyless row's children are
    re-keyed to the row's new position, as formly's own remove() does; every
    button carries `type="button"`; the existing rendering tests (one row
    per entry, draggable rows, remove, add, disabled section) still hold. A
    second spec stands the widget up under real formly
    (`FormlyModule.forRoot`, a form, a model): a drop changes the model and
    the cells and keeps the rows' keys in step with their positions, and a
    value typed into a moved row lands on that row's entry. The form's spec:
    Projection's `attributes` renders through `repeat-section-dnd`.
    `custom-formly-type`'s spec pins the set. Each guard was
    deletion-checked (the re-keying dropped; the callback called unguarded;
    the remove button typeless; the type back in the canvas-only set) and
    turned a named test red. eslint, prettier and the production (AOT) build
    pass; the full frontend suite passes; every statement, function and
    branch this PR adds is covered.
    
    Checked on a flag-on instance (main-demo; a Python source with four
    columns feeding a Projection whose `attributes` is exposed), with the
    canvas and the form open side by side: on main the card is a plain list
    (no handle); with this PR the card shows the drag handles, as the panel
    does. Dragging the last row to the top: the operator's order follows in
    the canvas tab's panel within a second and in the saved workflow. An
    alias typed into the moved row lands on that column, not its neighbour;
    Enter in an alias input removes no row; a reload shows the same order. A
    drop made on the canvas panel reaches the form within a second.
    
    #### Video
    
    **Before**
    
    
    
https://github.com/user-attachments/assets/5a78f6f5-3b27-4b93-955e-bb15b40fd854
    
    
    
    **After**
    
    
    
https://github.com/user-attachments/assets/b84c79fa-a0a4-4bbc-b734-e75147bdf0d6
    
    
    
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Yes. Generated-by: Claude Code (Claude Fable 5.1, Anthropic).
    Co-authored with Claude, reviewed line by line by the author before
    submission.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    Co-authored-by: Claude Fable 5.1 <[email protected]>
    Co-authored-by: Meng Wang <[email protected]>
---
 .../repeat-dnd/repeat-dnd.component.formly.spec.ts | 104 +++++++++++++++++++++
 .../formly/repeat-dnd/repeat-dnd.component.html    |   5 +
 .../formly/repeat-dnd/repeat-dnd.component.spec.ts |  58 ++++++++++--
 .../formly/repeat-dnd/repeat-dnd.component.ts      |  44 ++++++---
 .../workflow-form/workflow-form.component.spec.ts  |  16 ++++
 .../workflow-form/workflow-form.component.ts       |   6 +-
 .../app/workspace/util/custom-formly-type.spec.ts  |   8 +-
 .../src/app/workspace/util/custom-formly-type.ts   |  12 +--
 8 files changed, 216 insertions(+), 37 deletions(-)

diff --git 
a/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.formly.spec.ts 
b/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.formly.spec.ts
new file mode 100644
index 0000000000..d19552cffd
--- /dev/null
+++ 
b/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.formly.spec.ts
@@ -0,0 +1,104 @@
+/**
+ * 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 { CdkDragDrop } from "@angular/cdk/drag-drop";
+import { Component } from "@angular/core";
+import { TestBed } from "@angular/core/testing";
+import { FormArray, ReactiveFormsModule, UntypedFormGroup } from 
"@angular/forms";
+import { By } from "@angular/platform-browser";
+import { FieldType, FieldTypeConfig, FormlyFieldConfig, FormlyForm, 
FormlyModule } from "@ngx-formly/core";
+import { FormlyRepeatDndComponent } from "./repeat-dnd.component";
+
+/** The plainest cell formly can build a row's sub-field into. */
+@Component({
+  template: '<input [formControl]="formControl" />',
+  imports: [ReactiveFormsModule],
+})
+class PlainInputType extends FieldType<FieldTypeConfig> {}
+
+/** A form holding one repeated section, as the Form View's card and the 
operator property panel do. */
+@Component({
+  template:
+    '<form [formGroup]="form"><formly-form [form]="form" [model]="model" 
[fields]="fields"></formly-form></form>',
+  imports: [ReactiveFormsModule, FormlyForm],
+})
+class CardComponent {
+  form = new UntypedFormGroup({});
+  model: { attributes: { alias: string }[] } = { attributes: [{ alias: "a" }, 
{ alias: "b" }, { alias: "c" }] };
+  fields: FormlyFieldConfig[] = [
+    {
+      key: "attributes",
+      type: "repeat-section-dnd",
+      fieldArray: () => ({ fieldGroup: [{ key: "alias", type: "plain" }] }),
+    },
+  ];
+}
+
+/**
+ * Real formly, no mocks: formly keys a repeated section's rows by position 
and writes a cell's value
+ * into the model by that key path, so a move that leaves the keys where they 
were sends a later edit
+ * to the wrong row. The widget's own spec drives the drops on flat rows; this 
one proves the model.
+ */
+describe("FormlyRepeatDndComponent under real formly", () => {
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [
+        CardComponent,
+        FormlyModule.forRoot({
+          types: [
+            { name: "repeat-section-dnd", component: FormlyRepeatDndComponent 
},
+            { name: "plain", component: PlainInputType },
+          ],
+        }),
+      ],
+    }).compileComponents();
+  });
+
+  function renderCard() {
+    const fixture = TestBed.createComponent(CardComponent);
+    fixture.detectChanges();
+    const widget = 
fixture.debugElement.query(By.directive(FormlyRepeatDndComponent))
+      .componentInstance as FormlyRepeatDndComponent;
+    const aliases = () => fixture.componentInstance.model.attributes.map(row 
=> row.alias);
+    const cells = () => 
Array.from(fixture.nativeElement.querySelectorAll("input") as 
NodeListOf<HTMLInputElement>);
+    return { fixture, widget, aliases, cells };
+  }
+
+  it("moves the row in the model and on screen, and keeps the rows' keys in 
step with their positions", () => {
+    const { fixture, widget, aliases, cells } = renderCard();
+
+    widget.onDrop({ previousIndex: 0, currentIndex: 1 } as 
CdkDragDrop<string[]>);
+    fixture.detectChanges();
+
+    expect(aliases()).toEqual(["b", "a", "c"]);
+    expect(cells().map(cell => cell.value)).toEqual(["b", "a", "c"]);
+    expect(widget.field.fieldGroup!.map(row => row.key)).toEqual(["0", "1", 
"2"]);
+  });
+
+  it("writes a value typed into a moved row onto that row's entry, not onto 
the row that used to be there", () => {
+    const { fixture, widget, aliases } = renderCard();
+    widget.onDrop({ previousIndex: 0, currentIndex: 1 } as 
CdkDragDrop<string[]>);
+    fixture.detectChanges();
+
+    // The first row now shows "b"; typing there edits b's entry.
+    (fixture.componentInstance.form.get("attributes") as 
FormArray).at(0).get("alias")!.setValue("typed");
+
+    expect(aliases()).toEqual(["typed", "a", "c"]);
+  });
+});
diff --git 
a/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.html 
b/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.html
index 7f53df1c67..88765e1ba1 100644
--- a/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.html
+++ b/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.html
@@ -40,9 +40,13 @@
         [field]="subField"></formly-field>
     </div>
 
+    <!-- type="button" on every button here: nz-button sets no type, so inside 
a form (the Form View's
+         card, the operator property panel) a typeless button is the form's 
submit button, and Enter
+         in a row's input would click it -- here, removing the first row. -->
     <button
       nz-button
       nzType="default"
+      type="button"
       (click)="remove(i)"
       class="dnd-remove-button"
       [disabled]="field.templateOptions?.disabled">
@@ -57,6 +61,7 @@
 <button
   nz-button
   nzType="default"
+  type="button"
   (click)="add()"
   [disabled]="field.templateOptions?.disabled">
   {{ field.templateOptions?.addText || "Add" }}
diff --git 
a/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.spec.ts 
b/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.spec.ts
index cb37171b92..e1b5e2c214 100644
--- a/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.spec.ts
+++ b/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.spec.ts
@@ -35,7 +35,12 @@ describe("FormlyRepeatDndComponent", () => {
 
     component.field = {
       model: ["a", "b", "c"],
-      fieldGroup: [{ key: "a" }, { key: "b" }, { key: "c" }],
+      // Keyed by position, as formly keys rows; the id is the test's own 
marker for which row is which.
+      fieldGroup: [
+        { key: "0", id: "a" },
+        { key: "1", id: "b" },
+        { key: "2", id: "c" },
+      ],
       formControl,
       props: { reorder },
     } as any;
@@ -64,7 +69,7 @@ describe("FormlyRepeatDndComponent", () => {
     component.onDrop(createDropEvent(1, 1));
 
     expect(component.model).toEqual(["a", "b", "c"]);
-    expect(component.field.fieldGroup?.map(field => field.key)).toEqual(["a", 
"b", "c"]);
+    expect(component.field.fieldGroup?.map(field => field.id)).toEqual(["a", 
"b", "c"]);
     expect((component.formControl as FormArray).controls.map(control => 
control.value)).toEqual(["a", "b", "c"]);
     expect(reorder).not.toHaveBeenCalled();
   });
@@ -78,7 +83,7 @@ describe("FormlyRepeatDndComponent", () => {
 
     component.onDrop(createDropEvent(0, 2));
 
-    expect(component.field.fieldGroup?.map(field => field.key)).toEqual(["a", 
"b", "c"]);
+    expect(component.field.fieldGroup?.map(field => field.id)).toEqual(["a", 
"b", "c"]);
     expect((component.formControl as FormArray).controls.map(control => 
control.value)).toEqual(["a", "b", "c"]);
     expect(reorder).not.toHaveBeenCalled();
   });
@@ -90,12 +95,12 @@ describe("FormlyRepeatDndComponent", () => {
     // what makes that ordering observable — the final assertions below are 
order-insensitive,
     // and moveItemInArray mutates in place, so the captures must be copies.
     let seenModel: string[] | undefined;
-    let seenFieldKeys: unknown[] | undefined;
+    let seenFieldIds: unknown[] | undefined;
     let seenControls: unknown[] | undefined;
     const reorder = setComponentState(
       vi.fn(() => {
         seenModel = [...(component.model as string[])];
-        seenFieldKeys = component.field.fieldGroup?.map(field => field.key);
+        seenFieldIds = component.field.fieldGroup?.map(field => field.id);
         seenControls = (component.formControl as 
FormArray).controls.map(control => control.value);
       })
     );
@@ -103,11 +108,11 @@ describe("FormlyRepeatDndComponent", () => {
     component.onDrop(createDropEvent(0, 2));
 
     expect(component.model).toEqual(["b", "c", "a"]);
-    expect(component.field.fieldGroup?.map(field => field.key)).toEqual(["b", 
"c", "a"]);
+    expect(component.field.fieldGroup?.map(field => field.id)).toEqual(["b", 
"c", "a"]);
     expect((component.formControl as FormArray).controls.map(control => 
control.value)).toEqual(["b", "c", "a"]);
     expect(reorder).toHaveBeenCalledOnce();
     expect(seenModel).toEqual(["b", "c", "a"]);
-    expect(seenFieldKeys).toEqual(["b", "c", "a"]);
+    expect(seenFieldIds).toEqual(["b", "c", "a"]);
     expect(seenControls).toEqual(["b", "c", "a"]);
   });
 
@@ -122,9 +127,29 @@ describe("FormlyRepeatDndComponent", () => {
 
     expect(() => component.onDrop(createDropEvent(0, 2))).not.toThrow();
     expect(component.model).toEqual(["b", "c", "a"]);
-    expect(component.field.fieldGroup?.map(field => field.key)).toEqual(["b", 
"c", "a"]);
+    expect(component.field.fieldGroup?.map(field => field.id)).toEqual(["b", 
"c", "a"]);
     expect((component.formControl as FormArray).controls.map(control => 
control.value)).toEqual(["b", "c", "a"]);
   });
+  // formly keys a row by position unless the template says `key: null`; then 
the row's keyed children
+  // carry the position instead, and formly's own remove() re-keys those. The 
move mirrors it.
+  it("re-keys a keyless row's children to the row's new position, as formly 
does", () => {
+    setComponentState();
+    component.field = {
+      ...component.field,
+      fieldGroup: [
+        { key: null, id: "a", fieldGroup: [{ key: "0" }] },
+        { key: null, id: "b", fieldGroup: [{ key: "1" }] },
+        { key: null, id: "c", fieldGroup: [{ key: "2" }] },
+      ],
+    } as any;
+
+    component.onDrop(createDropEvent(0, 2));
+
+    expect(component.field.fieldGroup?.map(row => row.id)).toEqual(["b", "c", 
"a"]);
+    expect(component.field.fieldGroup?.map(row => 
row.fieldGroup?.[0].key)).toEqual(["0", "1", "2"]);
+    expect(component.field.fieldGroup?.every(row => row.key === 
null)).toBe(true);
+  });
+
   /**
    * The class-level tests above drive onDrop directly and never render. The 
template owns the rest
    * of the control: one row per entry, which index a row's remove button 
carries, and whether the
@@ -136,7 +161,12 @@ describe("FormlyRepeatDndComponent", () => {
       setComponentState();
       component.field = {
         ...component.field,
-        fieldGroup: [{ key: "a" }, { key: "b" }, { key: "c" }],
+        // Keyed by position, as formly keys rows; the id is the test's own 
marker for which row is which.
+        fieldGroup: [
+          { key: "0", id: "a" },
+          { key: "1", id: "b" },
+          { key: "2", id: "c" },
+        ],
         templateOptions,
       } as any;
       fixture.detectChanges();
@@ -156,6 +186,16 @@ describe("FormlyRepeatDndComponent", () => {
       )!;
     }
 
+    // nz-button sets no type, so a typeless button inside a form is its 
submit button and Enter in a
+    // row's input clicks it (HTML implicit submission) -- which here used to 
remove the first row.
+    it("gives every button an explicit type, so Enter in a row's input submits 
nothing", () => {
+      const el = render();
+
+      const buttons = Array.from(el.querySelectorAll("button"));
+      expect(buttons.length).toBeGreaterThan(3);
+      expect(buttons.every(button => button.getAttribute("type") === 
"button")).toBe(true);
+    });
+
     it("renders one row per entry", () => {
       const el = render();
 
diff --git a/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.ts 
b/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.ts
index bd4782d8fd..118434b261 100644
--- a/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.ts
+++ b/frontend/src/app/common/formly/repeat-dnd/repeat-dnd.component.ts
@@ -18,7 +18,7 @@
  */
 
 import { Component } from "@angular/core";
-import { FieldArrayType, FormlyModule } from "@ngx-formly/core";
+import { FieldArrayType, FormlyFieldConfig, FormlyModule } from 
"@ngx-formly/core";
 import { CdkDragDrop, moveItemInArray, CdkDropList, CdkDrag, CdkDragHandle } 
from "@angular/cdk/drag-drop";
 import { NgFor } from "@angular/common";
 import { ɵNzTransitionPatchDirective } from 
"ng-zorro-antd/core/transition-patch";
@@ -45,25 +45,39 @@ import { NzWaveDirective } from "ng-zorro-antd/core/wave";
   ],
 })
 export class FormlyRepeatDndComponent extends FieldArrayType {
+  /**
+   * Move the dropped row, in step: the data model (the source of truth for 
the backend), the formly
+   * field configs (the UI definition) and the FormArray controls (the live 
form state), then the
+   * parent's optional reorder callback (the operator property panel's, which 
saves without redrawing
+   * the form; the Form View's card has none, its form's valueChanges persists 
the move). Nothing
+   * moves for no model or a drop on the same position.
+   */
   onDrop(event: CdkDragDrop<string[]>) {
-    if (!this.model || event.previousIndex === event.currentIndex) {
+    const from = event.previousIndex;
+    const to = event.currentIndex;
+    if (!this.model || from === to) {
       return;
     }
-
-    // 1. Reorder the data model. This is the source of truth for the backend.
-    moveItemInArray(this.model, event.previousIndex, event.currentIndex);
-
-    // 2. Reorder the Formly field configurations. This keeps the UI 
definition in sync with the data.
-    moveItemInArray(this.field.fieldGroup!, event.previousIndex, 
event.currentIndex);
-
-    // 3. Reorder the actual Angular FormArray controls. This keeps the live 
form state in sync.
-    const control = this.formControl.at(event.previousIndex);
-    this.formControl.removeAt(event.previousIndex);
-    this.formControl.insert(event.currentIndex, control);
-
-    // 4. Notify the parent to save the changes. The parent should NOT redraw 
the form.
+    moveItemInArray(this.model, from, to);
+    moveItemInArray(this.field.fieldGroup!, from, to);
+    // formly keys a row by its position when it builds the rows, and writes a 
cell's value into
+    // the model by that key path; so the rows are re-keyed to where they now 
are, as formly's own
+    // remove() does, or a value typed into a moved row would land on the row 
that used to be there.
+    this.field.fieldGroup!.forEach((row, index) => this.rekey(row, 
`${index}`));
+    const control = this.formControl.at(from);
+    this.formControl.removeAt(from);
+    this.formControl.insert(to, control);
     if (this.props.reorder) {
       this.props.reorder();
     }
   }
+
+  /** formly's updateArrayElementKey, which it keeps private: the row's own 
key, else its keyed children's. */
+  private rekey(row: FormlyFieldConfig, key: string): void {
+    if (row.key !== undefined && row.key !== null && row.key !== "") {
+      row.key = key;
+      return;
+    }
+    row.fieldGroup?.forEach(child => this.rekey(child, key));
+  }
 }
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 d276b87494..6273ffc92b 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
@@ -954,6 +954,22 @@ describe("WorkflowFormComponent", () => {
       expect(component.rendered[0].fields[0].type).toBe("huggingface");
     });
 
+    // Projection's column list is the drag-reorder widget on the canvas; the 
form renders the same
+    // widget, with its drag, rather than falling back to a plain list (#8761).
+    it("renders Projection's attributes through the drag-reorder widget, as 
the canvas does", () => {
+      build(formViewWorkflow).ngOnInit();
+      h.graphOperators.push({ operatorID: "op-1", operatorType: "Projection" 
});
+      (component as any).formlyJsonschema = {
+        toFieldConfig: (_schema: any, opts: any) => ({
+          fieldGroup: [{ key: "attributes", type: "array", props: { label: 
"Attributes" } }].map(opts.map),
+        }),
+      };
+
+      renderOne("attributes");
+
+      expect(component.rendered[0].fields[0].type).toBe("repeat-section-dnd");
+    });
+
     it("renders a file property through its own picker type", () => {
       build(formViewWorkflow).ngOnInit();
 
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 d783c43b1c..30a780d0b9 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
@@ -869,9 +869,9 @@ export class WorkflowFormComponent implements OnInit, 
OnDestroy {
           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.
+        // A canvas-only widget (the code editor) does 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;
         }
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 85ff479733..3f3de31c6e 100644
--- a/frontend/src/app/workspace/util/custom-formly-type.spec.ts
+++ b/frontend/src/app/workspace/util/custom-formly-type.spec.ts
@@ -22,16 +22,16 @@ import { customFormlyFieldType, NON_FORM_FIELD_TYPES, 
CANVAS_ONLY_FORMLY_TYPES }
 describe("NON_FORM_FIELD_TYPES", () => {
   it("blocks only the code editor from being a form field, not the 
drag-reorder list", () => {
     expect(NON_FORM_FIELD_TYPES.has("codearea")).toBe(true);
-    // a drag-reorder property is still a valid form field (it just renders 
without the drag)
+    // a drag-reorder property is still a valid form field, and the form 
renders it with its drag (#8761)
     expect(NON_FORM_FIELD_TYPES.has("repeat-section-dnd")).toBe(false);
   });
 });
 
 describe("CANVAS_ONLY_FORMLY_TYPES", () => {
-  it("holds the widgets the form falls back from: the code editor and the 
drag-reorder list", () => {
+  it("holds the widgets the form falls back from: the code editor, and the 
code editor only", () => {
     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);
+    // the drag-reorder list carries its own drop list, so the form renders 
it, drag and all (#8761)
+    expect(CANVAS_ONLY_FORMLY_TYPES.has("repeat-section-dnd")).toBe(false);
     // an ordinary custom widget (a picker/uploader) is rendered as itself, 
not fallen back from
     expect(CANVAS_ONLY_FORMLY_TYPES.has("datasetversionselector")).toBe(false);
   });
diff --git a/frontend/src/app/workspace/util/custom-formly-type.ts 
b/frontend/src/app/workspace/util/custom-formly-type.ts
index 0ce9086450..df9766afd3 100644
--- a/frontend/src/app/workspace/util/custom-formly-type.ts
+++ b/frontend/src/app/workspace/util/custom-formly-type.ts
@@ -21,18 +21,18 @@
  * Widget types that cannot be a form field at all, so the property is not 
offered for exposure on
  * the Form View. Only the code editor: editing code is not "filling in a 
value", and a form reader
  * should not be writing code. (A drag-reorder property such as Projection's 
columns stays exposable
- * -- it just renders without the drag in the form -- so it is deliberately 
NOT in this set.)
+ * and renders with its drag in the form, so it is deliberately NOT in this 
set.)
  */
 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.
+ * back to formly's default control instead. Only the code editor (also 
blocked from exposure by
+ * {@link NON_FORM_FIELD_TYPES}), which opens into an outlet the canvas owns. 
The drag-reorder list
+ * used to be here too, as if its drag had nowhere to attach on a form; it 
carries its own drop
+ * list, and renders on the form with the drag, as on the panel (#8761).
  */
-export const CANVAS_ONLY_FORMLY_TYPES: ReadonlySet<string> = new 
Set(["codearea", "repeat-section-dnd"]);
+export const CANVAS_ONLY_FORMLY_TYPES: ReadonlySet<string> = new 
Set(["codearea"]);
 
 /**
  * The custom formly widget an operator-schema property renders as, decided 
from the property key

Reply via email to