Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* 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, OnDestroy } from "@angular/core";
import { TestBed } from "@angular/core/testing";
import { CodeEditorHostDirective } from "./code-editor-host.directive";
import { CodeEditorService } from "../../service/code-editor/code-editor.service";

/** A view that hosts the editor, as the operator canvas and the Form View do. */
@Component({
template: "<ng-template texeraCodeEditorHost></ng-template>",
imports: [CodeEditorHostDirective],
})
class HostingViewComponent {}

/** Stands in for the editor: what the host holds, and what must be torn down with the view. */
@Component({ template: "editor" })
class HostedComponent implements OnDestroy {
static destroyed = 0;
ngOnDestroy(): void {
HostedComponent.destroyed++;
}
}
Comment on lines +26 to +39

describe("CodeEditorHostDirective", () => {
let service: CodeEditorService;

beforeEach(async () => {
HostedComponent.destroyed = 0;
await TestBed.configureTestingModule({ imports: [HostingViewComponent, HostedComponent] }).compileComponents();
service = TestBed.inject(CodeEditorService);
});

it("attaches its outlet as the editor's host when the view is built", () => {
expect(service.host).toBeUndefined();

const fixture = TestBed.createComponent(HostingViewComponent);
fixture.detectChanges();

expect(service.host).toBeDefined();
// The real outlet: the editor is created into it.
const ref = service.host!.createComponent(HostedComponent);
expect(ref.location.nativeElement.textContent).toBe("editor");
});

it("closes what it hosts and lets go of the host when the view is destroyed", () => {
const fixture = TestBed.createComponent(HostingViewComponent);
fixture.detectChanges();
service.host!.createComponent(HostedComponent);

fixture.destroy();

// The editor's own teardown ran, by the view's (it writes awareness and saves its geometry
// there) ...
expect(HostedComponent.destroyed).toBe(1);
// ... and nothing claims to host an editor any more.
expect(service.host).toBeUndefined();
});

it("lets go only of its own outlet, not of a host that has since replaced it", () => {
const first = TestBed.createComponent(HostingViewComponent);
first.detectChanges();
const firstHost = service.host;
const second = TestBed.createComponent(HostingViewComponent);
second.detectChanges();
expect(service.host).not.toBe(firstHost);

first.destroy();

expect(service.host).toBeDefined();
expect(service.host).not.toBe(firstHost);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* 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 { Directive, OnDestroy, ViewContainerRef } from "@angular/core";
import { CodeEditorService } from "../../service/code-editor/code-editor.service";

/**
* Marks the outlet the code editor opens into, on an `<ng-template>` in a view's template. Each view
* that shows operator properties -- the operator canvas and the Form View -- carries one, and the
* view in front is the editor's host: its outlet is attached to CodeEditorService when the view's
* template is built, and detached when the view is destroyed -- which closes an editor still open in
* it too, since the view's teardown destroys what its outlet holds, before this hook runs. The views
* are sibling routes and never overlap, so there is one host at a time; a late destroy still lets go
* only of its own.
*/
@Directive({
selector: "[texeraCodeEditorHost]",
standalone: true,
})
export class CodeEditorHostDirective implements OnDestroy {
constructor(
private readonly outlet: ViewContainerRef,
private readonly codeEditorService: CodeEditorService
) {
this.codeEditorService.attachHost(this.outlet);
}
Comment on lines +36 to +42

ngOnDestroy(): void {
this.codeEditorService.detachHost(this.outlet);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -232,28 +232,41 @@ describe("CodeEditorComponent (browser)", () => {
vi.restoreAllMocks();
});

// Builds a fixture for the highlighted operator, but defers the
// detectChanges/ngAfterViewInit step so the caller can stage `code` /
// `formControl` (and the EditorApp's fake editor) before the subscribe body
// fires. Returns the fixture, the fake editor, and the component instance.
function makeFixtureWithFakes() {
const predicate = { ...mockJavaUDFPredicate };
// Builds a fixture for a Java UDF operator, told its operator the way the
// codearea button tells it, but defers the detectChanges step (ngOnInit +
// ngAfterViewInit) so the caller can stage `formControl`, the shared text
// and the EditorApp's fake editor before the subscribe body fires. The
// shared text is staged on the graph, where ngOnInit reads it from: a `code`
// property on the predicate becomes a Y.Text on the shared
// operator-properties map, and that is what the subscribe body binds to (and
// crosses the `if (!this.code) return;` gate with). Returns the fixture, the
// fake editor, the component instance and that shared text (undefined when
// staged without one).
function makeFixtureWithFakes(options: { withSharedCode?: boolean } = {}) {
const { withSharedCode = true } = options;
const predicate = { ...mockJavaUDFPredicate, operatorProperties: withSharedCode ? { code: "" } : {} };
workflowActionService.addOperator(predicate, mockPoint);
workflowActionService.getJointGraphWrapper().highlightOperators(predicate.operatorID);
const sharedCode = withSharedCode
? ((
workflowActionService
.getTexeraGraph()
.getSharedOperatorType(predicate.operatorID)
.get("operatorProperties") as any
).get("code") as Y.Text)
: undefined;

const fixture = TestBed.createComponent(CodeEditorComponent);
// The opener names the operator; see makeFixture in the jsdom spec.
fixture.componentRef.setInput("currentOperatorId", predicate.operatorID);
const editor = makeFakeEditor();
// The component pulls the editor back out of `EditorApp.getEditor()` inside
// its start path (and again from `adjustEditorSize`), so the mocked
// EditorApp must hand back this same fake.
editorAppMock.getEditor.mockReturnValue(editor);
const c = fixture.componentInstance as any;
c.formControl = new FormControl({ value: "", disabled: false });
// A YText must live inside a Y.Doc to be useful; the binding stub doesn't
// care, but we stage it as if it came from the shared model so the
// subscribe body crosses the `if (!this.code) return;` gate.
c.code = new Y.Doc().getText("code");
return { fixture, editor, c: c as CodeEditorComponent };
return { fixture, editor, c: c as CodeEditorComponent, sharedCode };
}

// The component's subscribe path runs `from(startEditor()).pipe(...)`,
Expand Down Expand Up @@ -367,8 +380,7 @@ describe("CodeEditorComponent (browser)", () => {
});

it("initializeMonacoEditor: leaves the binding unwired when the operator has no shared code", async () => {
const { fixture, editor, c } = makeFixtureWithFakes();
(c as any).code = undefined;
const { fixture, editor } = makeFixtureWithFakes({ withSharedCode: false });

fixture.detectChanges();
await flush();
Expand Down Expand Up @@ -412,10 +424,8 @@ describe("CodeEditorComponent (browser)", () => {
});

it("initializeDiffEditor: diffs this operator's latest-version code against the shared text", async () => {
const { fixture, c } = makeFixtureWithFakes();
const sharedText = new Y.Doc().getText("code");
sharedText.insert(0, "shared editing text");
(c as any).code = sharedText;
const { fixture, sharedCode } = makeFixtureWithFakes();
sharedCode!.insert(0, "shared editing text");
// The decoy operator is listed FIRST, so picking anything other than the
// entry matching `currentOperatorId` surfaces the wrong version.
vi.spyOn(workflowActionService, "getTempWorkflow").mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
id="code-editor-container"
class="box"
cdkDrag
cdkDragBoundary="texera-workspace"
cdkDragBoundary="texera-workspace, texera-workflow-form"
(focusin)="onFocus()">
<h5
id="title"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -244,16 +244,38 @@ describe("CodeEditorComponent", () => {
workflowActionService.addOperator(predicate, mockPoint);
workflowActionService.getJointGraphWrapper().highlightOperators(predicate.operatorID);
const fixture = TestBed.createComponent(CodeEditorComponent);
// The opener names the operator (the panel's button reads the highlight, the form's its
// binding); the highlight above is for what still reads it, such as onFocus.
fixture.componentRef.setInput("currentOperatorId", predicate.operatorID);
// The opener hands the editor the field's control before the first check; the AI actions read
// its disabled state. Editable here; the read-only test stages its own.
fixture.componentInstance.formControl = new FormControl("");
fixture.detectChanges();
return fixture;
}

it("creates with the highlighted operator", () => {
it("binds to the operator it is given", () => {
const fixture = makeFixture(mockJavaUDFPredicate);
expect(fixture.componentInstance).toBeTruthy();
expect(fixture.componentInstance.currentOperatorId).toBe(mockJavaUDFPredicate.operatorID);
});

it("keeps the operator it is given when another one is highlighted (a Form View editor)", () => {
// The form's field names its operator; the highlight, if any, is a step the reader is looking at.
const given = { ...buildPredicateWithCode("given-op", "PythonUDFV2", "x = 1"), customDisplayName: "Given" };
const highlighted = buildPredicate("highlighted-op", "PythonUDFV2");
workflowActionService.addOperator(given, mockPoint);
workflowActionService.addOperator(highlighted, mockPoint);
workflowActionService.getJointGraphWrapper().highlightOperators(highlighted.operatorID);
const fixture = TestBed.createComponent(CodeEditorComponent);
fixture.componentRef.setInput("currentOperatorId", given.operatorID);

fixture.detectChanges();

expect(fixture.componentInstance.currentOperatorId).toBe(given.operatorID);
expect(fixture.componentInstance.title).toBe("Given");
});

it("shows UI parameter parser errors for the edited operator", () => {
const predicate = buildPredicate("python-with-invalid-parameters", "PythonUDFV2");
makeFixture(predicate);
Expand All @@ -278,9 +300,9 @@ describe("CodeEditorComponent", () => {
expect(notificationServiceMock.error).not.toHaveBeenCalled();
});

// Language detection — the constructor maps `RUDFSource` / `RUDF` to `r`,
// Language detection — ngOnInit maps `RUDFSource` / `RUDF` to `r`,
// the three V2-era Python operator types to `python`, and anything else
// to `java`. The exact branch lives in the constructor; the public
// to `java`. The exact branch lives in ngOnInit; the public
// `language` field is what the rest of the editor (LSP wiring, file-
// suffix selection) keys off.

Expand Down Expand Up @@ -381,6 +403,7 @@ describe("CodeEditorComponent", () => {
fixture.componentInstance.onFocus();
expect(highlightSpy).toHaveBeenCalledWith(mockJavaUDFPredicate.operatorID);
});

});

describe("rejectCurrentAnnotation", () => {
Expand Down Expand Up @@ -454,6 +477,21 @@ describe("CodeEditorComponent", () => {
});

describe("AI assistant action wiring", () => {
// Monaco's readOnly stops typing, not the context-menu actions, and accepting a type annotation
// inserts into the shared code; a read-only viewer's editor must not offer them (#8760).
it("registers no AI actions for a read-only editor, and both for an editable one", () => {
const fixture = makeFixture(mockJavaUDFPredicate);
const readOnly = makeFakeEditor();
fixture.componentInstance.formControl = new FormControl({ value: "", disabled: true });
(fixture.componentInstance as any).setupAIAssistantActions(asEditor(readOnly));
expect(readOnly.addAction).not.toHaveBeenCalled();

const editable = makeFakeEditor();
fixture.componentInstance.formControl = new FormControl("");
(fixture.componentInstance as any).setupAIAssistantActions(asEditor(editable));
expect(editable.addAction).toHaveBeenCalledTimes(2);
});

// setupAIAssistantActions checks an AI-provider flag (OpenAI vs others)
// before deciding whether to register the per-selection 'Add Type
// Annotation' action. We can't drive the action body without a real
Expand All @@ -477,6 +515,7 @@ describe("CodeEditorComponent", () => {
wfActions.addOperator(mockJavaUDFPredicate, mockPoint);
wfActions.getJointGraphWrapper().highlightOperators(mockJavaUDFPredicate.operatorID);
const fixture = TestBed.createComponent(CodeEditorComponent);
fixture.componentRef.setInput("currentOperatorId", mockJavaUDFPredicate.operatorID);
fixture.detectChanges();
const checked = await TestBed.inject(AIAssistantService).checkAIAssistantEnabled().toPromise();
expect(checked).toBe("OpenAI");
Expand Down Expand Up @@ -1313,6 +1352,7 @@ describe("CodeEditorComponent", () => {
if (tempOperators) stageTempWorkflow(tempOperators);
TestBed.inject(WorkflowVersionService).setDisplayParticularVersion(true);
const fixture = TestBed.createComponent(CodeEditorComponent);
fixture.componentRef.setInput("currentOperatorId", predicate.operatorID);
fixture.detectChanges();
await waitFor(() => Boolean(diffModelOf(fixture.componentInstance)), "the mounted diff editor");
return fixture.componentInstance;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import {
ComponentRef,
ElementRef,
HostListener,
Input,
OnDestroy,
OnInit,
Type,
ViewChild,
} from "@angular/core";
Expand Down Expand Up @@ -93,14 +95,18 @@ export const LANGUAGE_SERVER_CONNECTION_TIMEOUT_MS = 1000;
AnnotationSuggestionComponent,
],
})
export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy {
export class CodeEditorComponent implements OnInit, AfterViewInit, SafeStyle, OnDestroy {
@ViewChild("editor", { static: true }) editorElement!: ElementRef;
@ViewChild("container", { static: true }) containerElement!: ElementRef;
@ViewChild(AnnotationSuggestionComponent) annotationSuggestion!: AnnotationSuggestionComponent;
@ViewChild(BreakpointConditionInputComponent) breakpointConditionInput!: BreakpointConditionInputComponent;
private code?: YText;
private workflowVersionStreamSubject: Subject<void> = new Subject<void>();
public currentOperatorId!: string;
/**
* The operator whose code this edits, given by the opener: the operator property panel's button
* reads the highlighted operator, the Form View's the operator its field is bound to.
*/
@Input() currentOperatorId!: string;

public title: string | undefined;
public formControl!: FormControl;
Expand Down Expand Up @@ -150,8 +156,10 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
private config: GuiConfigService,
private uiUdfParametersSyncService: UiUdfParametersSyncService,
private notificationService: NotificationService
) {
this.currentOperatorId = this.workflowActionService.getJointGraphWrapper().getCurrentHighlightedOperatorIDs()[0];
) {}

ngOnInit(): void {
// Everything here keys on currentOperatorId, an input set after construction.
const operatorType = this.workflowActionService.getTexeraGraph().getOperator(this.currentOperatorId).operatorType;
Comment on lines +161 to 163
if (CodeEditorComponent.PYTHON_OPERATOR_TYPES.has(operatorType)) {
this.language = "python";
Expand Down Expand Up @@ -468,6 +476,11 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
}

private setupAIAssistantActions(editor: MonacoEditor) {
// Monaco's readOnly stops typing, not the context-menu actions, and accepting a type annotation
// inserts into the shared code: a read-only viewer's editor offers none (#8760).
if (this.formControl.disabled) {
return;
}
// Check if the AI provider is "openai"
this.aiAssistantService
.checkAIAssistantEnabled()
Expand Down Expand Up @@ -679,6 +692,8 @@ export class CodeEditorComponent implements AfterViewInit, SafeStyle, OnDestroy
this.editorApp?.getEditor()?.layout();
}
onFocus() {
// The wrapper's own highlight, as it always was, not WorkflowActionService's: that one also
// announces the highlight to co-editors and switches multi-select mode, neither wanted on focus.
this.workflowActionService.getJointGraphWrapper().highlightOperators(this.currentOperatorId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
nzShape="round"
nzType="primary"
[disabled]="isEditorOpen"
[ngClass]="{ 'readonly': this.field.formControl.disabled }">
<div *ngIf="!this.field.formControl.disabled">Edit code content</div>
<div *ngIf="this.field.formControl.disabled">View code content</div>
[ngClass]="{ 'readonly': this.field.formControl.disabled || inspect }">
<div *ngIf="!viewOnly">Edit code content</div>
<div *ngIf="viewOnly">View code content</div>
</button>
</div>
Loading
Loading