This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch release/v1.3
in repository https://gitbox.apache.org/repos/asf/texera.git
The following commit(s) were added to refs/heads/release/v1.3 by this push:
new 1e56e55fff fix(frontend, v1.3): suppress newline on Enter in title
editors (#8553)
1e56e55fff is described below
commit 1e56e55fff20a2ef7f60be46dcf3f6c9cb150daf
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Sep 21 07:20:09 2026 +0000
fix(frontend, v1.3): suppress newline on Enter in title editors (#8553)
### What changes were proposed in this PR?
Automated backport of #8055 to `release/v1.3`.
Source: 2db7db0c1e5ddf168cd89ef5875d2f91b84600a2 · [automation
run](https://github.com/apache/texera/actions/runs/35168101766)
### Any related issues, documentation, discussions?
Backport of #8055. Originally linked #8053.
### How was this PR tested?
Release-branch CI runs on this branch once the conflicts are resolved
and this PR is marked ready for review.
### Was this PR authored or co-authored using generative AI tooling?
No.
---------
Co-authored-by: Xinyuan Lin <[email protected]>
Co-authored-by: mengw15 <[email protected]>
Co-authored-by: Meng Wang <[email protected]>
---
.../operator-property-edit-frame.component.spec.ts | 163 +++++++++++++++++++++
.../operator-property-edit-frame.component.ts | 4 +-
.../port-property-edit-frame.component.spec.ts | 88 ++++++++++-
.../port-property-edit-frame.component.ts | 4 +-
frontend/src/jsdom-svg-polyfill.ts | 12 ++
5 files changed, 266 insertions(+), 5 deletions(-)
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 daea90d2d6..3e8d10cb1e 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
@@ -68,6 +68,7 @@ import { of, Subject, throwError } from "rxjs";
import { WorkflowVersionService } from
"../../../../dashboard/service/user/workflow-version/workflow-version.service";
import { GuiConfigService } from
"../../../../common/service/gui-config.service";
import { PresetWrapperComponent } from
"src/app/common/formly/preset-wrapper/preset-wrapper.component";
+import * as Y from "yjs";
const { marbles } = configure({ run: false });
@@ -2899,4 +2900,166 @@ describe("OperatorPropertyEditFrameComponent", () => {
);
});
});
+
+ /**
+ * The spec's default TestBed swaps the template for a stub, so the
collaborative title editor —
+ * the Quill instance the component mounts on `#customName` and the keyboard
bindings it configures
+ * — is never built by anything above. This block renders the real template
and drives that editor.
+ * It comes last in the file deliberately: Quill 2 has no `destroy()`, so
each instance leaves a
+ * MutationObserver and document listeners behind for `fixture.destroy()` to
detach, and it must not
+ * be combined with `fakeAsync` (zone.js patches MutationObserver).
+ */
+ describe("quill title editing", () => {
+ let quillFixture: ComponentFixture<OperatorPropertyEditFrameComponent>;
+ let quillComponent: OperatorPropertyEditFrameComponent;
+ let texeraGraph: WorkflowGraph;
+
+ beforeEach(async () => {
+ TestBed.resetTestingModule();
+ await TestBed.configureTestingModule({
+ providers: [
+ WorkflowActionService,
+ { provide: OperatorMetadataService, useClass:
StubOperatorMetadataService },
+ { provide: ComputingUnitStatusService, useClass:
MockComputingUnitStatusService },
+ DatePipe,
+ ...commonTestProviders,
+ ],
+ imports: [
+ OperatorPropertyEditFrameComponent,
+ BrowserAnimationsModule,
+ FormsModule,
+ FormlyModule.forRoot(TEXERA_FORMLY_CONFIG),
+ FormlyNgZorroAntdModule,
+ ReactiveFormsModule,
+ HttpClientTestingModule,
+ ],
+ }).compileComponents();
+
+ quillFixture =
TestBed.createComponent(OperatorPropertyEditFrameComponent);
+ quillComponent = quillFixture.componentInstance;
+ // Downcast to the concrete graph: `getSharedOperatorType` is not on the
readonly view.
+ texeraGraph = TestBed.inject(WorkflowActionService).getTexeraGraph() as
WorkflowGraph;
+ quillFixture.detectChanges();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ quillFixture.destroy();
+ });
+
+ /** Opens the editor the only way a user can — the title section's edit
button. */
+ function openEditorFromButton(): Y.Text {
+ const sharedOperator = new Y.Doc().getMap("operator");
+ quillComponent.currentOperatorId = mockScanPredicate.operatorID;
+ // `interactive` defaults to false, which renders the edit button
disabled — a disabled button
+ // swallows the click silently and the editor would never mount.
+ quillComponent.interactive = true;
+ vi.spyOn(texeraGraph,
"getSharedOperatorType").mockReturnValue(sharedOperator as any);
+ quillFixture.detectChanges();
+
+ quillFixture.debugElement.query(By.css("#formly-title
button")).nativeElement.click();
+ quillFixture.detectChanges();
+
+ expect(quillComponent.editingTitle).toBe(true);
+ expect(quillComponent.quillBinding).toBeDefined();
+ // The map genuinely lacked the key, so this Y.Text can only have come
from the connect call.
+ const sharedTitle = sharedOperator.get("customDisplayName");
+ expect(sharedTitle).toBeInstanceOf(Y.Text);
+ return sharedTitle as Y.Text;
+ }
+
+ /**
+ * An operator display name is a single-line value, so the editor binds
Enter to "commit and
+ * close" instead of letting Quill insert a newline. Everything typed here
goes straight into the
+ * shared Y.Text, so a stray "\n" is published to every co-editor,
persisted with the workflow,
+ * and read back on each later open. Quill 2 buckets keyboard bindings by
`event.key` and runs its
+ * built-in `handleEnter` ahead of anything registered under the legacy
`13` keycode, which is how
+ * the suppression stopped working when the frontend moved to Quill 2
(#8053). The template's
+ * `(keyup.enter)` closes the editor either way — a keydown-only press is
what separates a working
+ * binding from a broken one, in both the text left behind and
`editingTitle`.
+ */
+ const ENTER: KeyboardEventInit = { key: "Enter", keyCode: 13, which: 13 };
+
+ /**
+ * Quill's keydown listener returns early unless the editor `hasFocus()`
and reports a selection,
+ * so both have to be real here — without them no binding runs at all and
every assertion below
+ * would pass vacuously. `keyCode` / `which` mirror what a browser sends:
Quill matches a binding
+ * on either, and jsdom's default of 0 would make a `13`-keyed binding
unreachable rather than
+ * merely out-prioritised, hiding the very defect these tests pin.
+ */
+ function pressKey(caret: number, init: KeyboardEventInit): KeyboardEvent {
+ quillComponent.quill.root.focus();
+ quillComponent.quill.setSelection(caret, 0);
+ const keyPress = new KeyboardEvent("keydown", { bubbles: true,
cancelable: true, ...init });
+ quillComponent.quill.root.dispatchEvent(keyPress);
+ quillFixture.detectChanges();
+ return keyPress;
+ }
+
+ it("should show a remote rename in the mounted editor", () => {
+ const sharedTitle = openEditorFromButton();
+
+ sharedTitle.insert(0, "renamed remotely");
+
+ // Without this the keyboard tests below could pass against an editor
wired to nothing.
+ expect((document.getElementById("customName") as
HTMLElement).textContent).toContain("renamed remotely");
+ });
+
+ it("should commit the rename on Enter without appending a newline to the
shared name", () => {
+ const sharedTitle = openEditorFromButton();
+ sharedTitle.insert(0, "renamed");
+
+ pressKey(sharedTitle.length, ENTER);
+
+ expect(sharedTitle.toString()).toBe("renamed");
+ // `editingTitle` is what proves the editor's own binding ran: no keyup
was dispatched, so the
+ // template's fallback cannot be the thing that closed the editor.
+ expect(quillComponent.editingTitle).toBe(false);
+ expect(quillComponent.quillBinding).toBeUndefined();
+ });
+
+ it("should not split the shared name when Enter is pressed mid-word", ()
=> {
+ const sharedTitle = openEditorFromButton();
+ sharedTitle.insert(0, "renamed");
+
+ pressKey(3, ENTER);
+
+ expect(sharedTitle.toString()).toBe("renamed");
+ expect(quillComponent.editingTitle).toBe(false);
+ });
+
+ it("should leave an untouched name empty when Enter commits it", () => {
+ const sharedTitle = openEditorFromButton();
+
+ pressKey(0, ENTER);
+
+ // The empty document is the case Quill's own handler turns into a lone
"\n" — a display name
+ // that reads as blank but is no longer equal to the empty string it
started as.
+ expect(sharedTitle.toString()).toBe("");
+ expect(quillComponent.editingTitle).toBe(false);
+ });
+
+ it("should commit on Shift+Enter without appending a newline either", ()
=> {
+ const sharedTitle = openEditorFromButton();
+ sharedTitle.insert(0, "renamed");
+
+ pressKey(sharedTitle.length, { ...ENTER, shiftKey: true });
+
+ expect(sharedTitle.toString()).toBe("renamed");
+ expect(quillComponent.editingTitle).toBe(false);
+ });
+
+ it("should leave ordinary typing alone", () => {
+ const sharedTitle = openEditorFromButton();
+ sharedTitle.insert(0, "renamed");
+
+ const keyPress = pressKey(sharedTitle.length, { key: "a", keyCode: 65,
which: 65 });
+
+ // Suppressing Enter must not become suppressing the keyboard: an
ordinary character is left
+ // for the browser to insert, and the editor stays open so the rename
can continue.
+ expect(keyPress.defaultPrevented).toBe(false);
+ expect(quillComponent.editingTitle).toBe(true);
+ expect(quillComponent.quillBinding).toBeDefined();
+ });
+ });
});
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 beedbabd90..9c8287b8a8 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
@@ -1415,11 +1415,11 @@ export class OperatorPropertyEditFrameComponent
implements OnInit, OnChanges, On
keyboard: {
bindings: {
enter: {
- key: 13,
+ key: "Enter",
handler: () => this.disconnectQuillFromText(),
},
shift_enter: {
- key: 13,
+ key: "Enter",
shiftKey: true,
handler: () => this.disconnectQuillFromText(),
},
diff --git
a/frontend/src/app/workspace/component/property-editor/port-property-edit-frame/port-property-edit-frame.component.spec.ts
b/frontend/src/app/workspace/component/property-editor/port-property-edit-frame/port-property-edit-frame.component.spec.ts
index d12b316d33..3d751dffec 100644
---
a/frontend/src/app/workspace/component/property-editor/port-property-edit-frame/port-property-edit-frame.component.spec.ts
+++
b/frontend/src/app/workspace/component/property-editor/port-property-edit-frame/port-property-edit-frame.component.spec.ts
@@ -512,7 +512,7 @@ describe("PortPropertyEditFrameComponent", () => {
* `(focusout)` bindings — unexercised. Without these the whole feature
can be unwired from the
* UI without a single test noticing.
*/
- function openEditorFromButton(): void {
+ function openEditorFromButton(): Y.Map<unknown> {
const sharedPortDescription = new Y.Doc().getMap("portDescription");
component.currentPortID = inputPort;
vi.spyOn(texeraGraph,
"getSharedPortDescriptionType").mockReturnValue(sharedPortDescription);
@@ -522,6 +522,7 @@ describe("PortPropertyEditFrameComponent", () => {
expect(component.editingTitle).toBe(true);
expect(component.quillBinding).toBeDefined();
+ return sharedPortDescription;
}
it("should open the collaborative editor from the edit button and close it
on Enter", () => {
@@ -543,5 +544,90 @@ describe("PortPropertyEditFrameComponent", () => {
expect(component.editingTitle).toBe(false);
expect(component.quillBinding).toBeUndefined();
});
+
+ /**
+ * A port display name is a single-line value, so the editor binds Enter
to "commit and close"
+ * instead of letting Quill insert a newline. Everything typed here goes
straight into the shared
+ * Y.Text, so a stray "\n" is published to every co-editor, persisted with
the workflow, and read
+ * back on each later open. Quill 2 buckets keyboard bindings by
`event.key` and runs its built-in
+ * `handleEnter` ahead of anything registered under the legacy `13`
keycode, which is how the
+ * suppression stopped working when the frontend moved to Quill 2 (#8053).
The template's
+ * `(keyup.enter)` closes the editor either way — a keydown-only press is
what separates a working
+ * binding from a broken one, in both the text left behind and
`editingTitle`.
+ */
+ const ENTER: KeyboardEventInit = { key: "Enter", keyCode: 13, which: 13 };
+
+ /**
+ * Quill's keydown listener returns early unless the editor `hasFocus()`
and reports a selection,
+ * so both have to be real here — without them no binding runs at all and
every assertion below
+ * would pass vacuously. `keyCode` / `which` mirror what a browser sends:
Quill matches a binding
+ * on either, and jsdom's default of 0 would make a `13`-keyed binding
unreachable rather than
+ * merely out-prioritised, hiding the very defect these tests pin.
+ */
+ function pressKey(caret: number, init: KeyboardEventInit): KeyboardEvent {
+ component.quill.root.focus();
+ component.quill.setSelection(caret, 0);
+ const keyPress = new KeyboardEvent("keydown", { bubbles: true,
cancelable: true, ...init });
+ component.quill.root.dispatchEvent(keyPress);
+ fixture.detectChanges();
+ return keyPress;
+ }
+
+ it("should commit the rename on Enter without appending a newline to the
shared name", () => {
+ const sharedTitle = openEditorFromButton().get("displayName") as Y.Text;
+ sharedTitle.insert(0, "renamed");
+
+ pressKey(sharedTitle.length, ENTER);
+
+ expect(sharedTitle.toString()).toBe("renamed");
+ // `editingTitle` is what proves the editor's own binding ran: no keyup
was dispatched, so the
+ // template's fallback cannot be the thing that closed the editor.
+ expect(component.editingTitle).toBe(false);
+ expect(component.quillBinding).toBeUndefined();
+ });
+
+ it("should not split the shared name when Enter is pressed mid-word", ()
=> {
+ const sharedTitle = openEditorFromButton().get("displayName") as Y.Text;
+ sharedTitle.insert(0, "renamed");
+
+ pressKey(3, ENTER);
+
+ expect(sharedTitle.toString()).toBe("renamed");
+ expect(component.editingTitle).toBe(false);
+ });
+
+ it("should leave an untouched name empty when Enter commits it", () => {
+ const sharedTitle = openEditorFromButton().get("displayName") as Y.Text;
+
+ pressKey(0, ENTER);
+
+ // The empty document is the case Quill's own handler turns into a lone
"\n" — a display name
+ // that reads as blank but is no longer equal to the empty string it
started as.
+ expect(sharedTitle.toString()).toBe("");
+ expect(component.editingTitle).toBe(false);
+ });
+
+ it("should commit on Shift+Enter without appending a newline either", ()
=> {
+ const sharedTitle = openEditorFromButton().get("displayName") as Y.Text;
+ sharedTitle.insert(0, "renamed");
+
+ pressKey(sharedTitle.length, { ...ENTER, shiftKey: true });
+
+ expect(sharedTitle.toString()).toBe("renamed");
+ expect(component.editingTitle).toBe(false);
+ });
+
+ it("should leave ordinary typing alone", () => {
+ const sharedTitle = openEditorFromButton().get("displayName") as Y.Text;
+ sharedTitle.insert(0, "renamed");
+
+ const keyPress = pressKey(sharedTitle.length, { key: "a", keyCode: 65,
which: 65 });
+
+ // Suppressing Enter must not become suppressing the keyboard: an
ordinary character is left
+ // for the browser to insert, and the editor stays open so the rename
can continue.
+ expect(keyPress.defaultPrevented).toBe(false);
+ expect(component.editingTitle).toBe(true);
+ expect(component.quillBinding).toBeDefined();
+ });
});
});
diff --git
a/frontend/src/app/workspace/component/property-editor/port-property-edit-frame/port-property-edit-frame.component.ts
b/frontend/src/app/workspace/component/property-editor/port-property-edit-frame/port-property-edit-frame.component.ts
index 3eeb4e3fc1..e8808c2279 100644
---
a/frontend/src/app/workspace/component/property-editor/port-property-edit-frame/port-property-edit-frame.component.ts
+++
b/frontend/src/app/workspace/component/property-editor/port-property-edit-frame/port-property-edit-frame.component.ts
@@ -248,11 +248,11 @@ export class PortPropertyEditFrameComponent implements
OnInit, OnChanges {
keyboard: {
bindings: {
enter: {
- key: 13,
+ key: "Enter",
handler: () => this.disconnectQuillFromText(),
},
shift_enter: {
- key: 13,
+ key: "Enter",
shiftKey: true,
handler: () => this.disconnectQuillFromText(),
},
diff --git a/frontend/src/jsdom-svg-polyfill.ts
b/frontend/src/jsdom-svg-polyfill.ts
index 7aef063d7d..881a9c5c0f 100644
--- a/frontend/src/jsdom-svg-polyfill.ts
+++ b/frontend/src/jsdom-svg-polyfill.ts
@@ -111,6 +111,18 @@ installIfMissing(G.SVGGraphicsElement?.prototype, {
getBBox: fakeRect as AnyFn,
});
+// `Range.prototype.getBoundingClientRect` — jsdom has no layout engine and
+// doesn't implement it. Quill's `Selection#getBounds` calls it on a live
+// Range to place the caret, and quill-cursors calls it again for every
+// remote cursor an awareness update carries, so any spec that mounts a
+// collaborative editor throws `range.getBoundingClientRect is not a
+// function`. A zeroed rect is enough — jsdom never paints, and specs that
+// need real caret geometry belong in browser mode. `top` / `left` / `right`
+// / `height` are the four fields `getBounds` reads back off it.
+installIfMissing(G.Range?.prototype, {
+ getBoundingClientRect: (() => ({ ...fakeRect(), top: 0, right: 0, bottom: 0,
left: 0 })) as AnyFn,
+});
+
// Constructable Stylesheets API (`new CSSStyleSheet().replaceSync(...)`) —
// jsdom doesn't ship it, but @codingame/monaco-vscode-api v25 calls it at
// module load. Stub with an inert constructor; specs don't visually render