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
Expand Up @@ -59,6 +59,50 @@
padding: 0 !important;
}

.condition-syntax-error {
display: flex;
flex-shrink: 0;
align-items: flex-start;
gap: 0.25rem;
margin-top: 0.25rem;
font-size: 0.75rem;
line-height: 1.2;
text-align: left;
overflow-wrap: anywhere;
color: var(--p-red-500, #ef4444);
}

.condition-syntax-error .pi {
font-size: 0.75rem;
line-height: 1.2;
}

.condition-form {
position: relative;
}

.condition-syntax-error--floating {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
pointer-events: none;
padding: 0.125rem 0.375rem;
background: var(--p-card-background, #ffffff);
border: 1px solid var(--p-textarea-invalid-border-color, #f87171);
border-radius: 0.375rem;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}

.condition-syntax-error--floating span {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
line-clamp: 4;
overflow: hidden;
}

.condition-dialog {

}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<div class="condition-editor-root" (mousedown)="$event.stopImmediatePropagation()">
<div (focusout)="conditionEditingFinished.emit()">
<form>
<div (focusout)="onEditingFinished()">
<form class="condition-form">
<p-float-label variant="on" class="flex flex-row">
<textarea
[ngModelOptions]="{ standalone: true }"
Expand All @@ -14,6 +14,9 @@
(ngModelChange)="onConditionChange($event)"
variant="filled"
[disabled]="!editable"
[invalid]="!!syntaxCheck.error()"
[attr.aria-invalid]="!!syntaxCheck.error()"
[attr.aria-describedby]="syntaxCheck.error() ? syntaxErrorId : null"
[dt]="{
root: {
borderColor: redConditions.includes((condition | async)!)
Expand Down Expand Up @@ -56,7 +59,13 @@
</p-button>
</div>
</p-float-label>

@if (syntaxCheck.error(); as error) {
<small class="condition-syntax-error condition-syntax-error--floating"
[id]="syntaxErrorId" aria-live="polite">
<i class="pi pi-exclamation-circle"></i>
<span>{{ error }}</span>
</small>
}
</form>
</div>
<p-dialog
Expand All @@ -78,10 +87,19 @@
class="condition-textarea-dialog"
spellcheck="false"
[ngModel]="dialogConditionText"
(ngModelChange)="dialogConditionText = $event"
(ngModelChange)="onDialogConditionChange($event)"
(focusout)="dialogSyntaxCheck.checkNow(dialogConditionText)"
variant="filled"
[invalid]="!!dialogSyntaxCheck.error()"
[attr.aria-invalid]="!!dialogSyntaxCheck.error()"
>
</textarea>
@if (dialogSyntaxCheck.error(); as error) {
<small class="condition-syntax-error" aria-live="polite">
<i class="pi pi-exclamation-circle"></i>
<span>{{ error }}</span>
</small>
}
<div class="condition-dialog-buttons">
<p-button
severity="secondary"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,232 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from "@angular/core/testing";

import { ConditionEditorComponent } from './condition-editor.component';
import { Condition } from '../../../../types/condition/condition';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient } from '@angular/common/http';
import {
ConditionEditorComponent,
SYNTAX_CHECK_DELAY_MS,
} from "./condition-editor.component";
import { Condition, ICondition } from "../../../../types/condition/condition";
import { provideAnimations } from "@angular/platform-browser/animations";
import { provideHttpClient } from "@angular/common/http";
import { BehaviorSubject } from "rxjs";
import {
GREEN_COLOURED_CONDITIONS,
RED_COLOURED_CONDITIONS,
} from "../../editor.component";

describe('ConditionEditorComponent', () => {
let component: ConditionEditorComponent;
let fixture: ComponentFixture<ConditionEditorComponent>;
type Editor = ComponentFixture<ConditionEditorComponent>;

describe("ConditionEditorComponent", () => {
let fixture: Editor;
let allEditors: Editor[];

const createEditor = (condition: BehaviorSubject<ICondition>): Editor => {
const editor = TestBed.createComponent(ConditionEditorComponent);
editor.componentRef.setInput("condition", condition);
editor.detectChanges();
allEditors.push(editor);
return editor;
};

const setCondition = (text: string) => {
const condition = new BehaviorSubject<ICondition>(new Condition(text));
fixture.componentRef.setInput("condition", condition);
fixture.detectChanges();
return condition;
};

const type = (editor: Editor, text: string) => {
editor.componentInstance.onConditionChange(text);
allEditors.forEach((e) => e.detectChanges());
};

const shownError = (editor: Editor = fixture): string | null => {
editor.detectChanges();
return (
editor.nativeElement
.querySelector(".condition-syntax-error")
?.textContent.trim() ?? null
);
};

const textareaIsInvalid = (): boolean =>
fixture.nativeElement
.querySelector("textarea")
.classList.contains("p-invalid");

const leaveField = (editor: Editor) =>
editor.nativeElement
.querySelector("textarea")
.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConditionEditorComponent],
providers: [provideHttpClient(),provideAnimations()]
})
.compileComponents();

providers: [
provideHttpClient(),
provideAnimations(),
{ provide: GREEN_COLOURED_CONDITIONS, useValue: [] },
{ provide: RED_COLOURED_CONDITIONS, useValue: [] },
],
}).compileComponents();

fixture = TestBed.createComponent(ConditionEditorComponent);
component = fixture.componentInstance;
component.condition.set(new Condition(""))
fixture.detectChanges();
allEditors = [fixture];
});

it("should create", () => {
setCondition("");
expect(fixture.componentInstance).toBeTruthy();
});

it("shows no error for an empty condition", () => {
setCondition("");
expect(shownError()).toBeNull();
expect(textareaIsInvalid()).toBeFalse();
});

it("shows no error for a valid condition", () => {
setCondition("A.length > 0 && i == 0");
expect(shownError()).toBeNull();
expect(textareaIsInvalid()).toBeFalse();
});

it("shows only the message of the error", () => {
setCondition("a > b c");
expect(shownError()).toBe(
"Unexpected 'c', expected an operator or the end of the condition",
);
});

it("shows the error of an invalid condition immediately after loading", () => {
setCondition("a > b c");
expect(shownError()).not.toBeNull();
expect(textareaIsInvalid()).toBeTrue();
});

it('should create', () => {
expect(component).toBeTruthy();
it("does not check the syntax if validation is disabled", () => {
fixture.componentRef.setInput("validateJml", false);
setCondition("i = i + 1;");
expect(shownError()).toBeNull();
expect(textareaIsInvalid()).toBeFalse();
});

it("shows the error of typed text after a pause", fakeAsync(() => {
setCondition("");
type(fixture, "a >");
expect(shownError()).toBeNull();

tick(SYNTAX_CHECK_DELAY_MS - 1);
expect(shownError()).toBeNull();

tick(1);
expect(shownError()).toBe(
"Unexpected end of condition, expected an expression",
);
}));

it("restarts the delay on each typed character", fakeAsync(() => {
setCondition("");
type(fixture, "a >");
tick(SYNTAX_CHECK_DELAY_MS - 100);
type(fixture, "a > (");
tick(SYNTAX_CHECK_DELAY_MS - 100);
expect(shownError()).toBeNull();

tick(100);
expect(shownError()).toContain("expected an expression");
}));

it("removes the error immediately when the typed text is valid", fakeAsync(() => {
setCondition("a >");
expect(shownError()).not.toBeNull();

type(fixture, "a > b");
expect(shownError()).toBeNull();
expect(textareaIsInvalid()).toBeFalse();
}));

it("shows the error immediately when leaving the field", fakeAsync(() => {
setCondition("");
type(fixture, "a >");
leaveField(fixture);
expect(shownError()).toContain("expected an expression");
tick(SYNTAX_CHECK_DELAY_MS);
}));

it("updates the condition when typing", () => {
const condition = setCondition("");
type(fixture, "a > b");
expect(condition.getValue().condition).toBe("a > b");
});

it("does not react to clicks on the error message", () => {
setCondition("a >");
fixture.detectChanges();
const message = fixture.nativeElement.querySelector(
".condition-syntax-error",
);
expect(getComputedStyle(message).pointerEvents).toBe("none");
});

for (const sharing of ["subject", "condition object"]) {
describe(`with a condition shared between editors by the ${sharing}`, () => {
let typingEditor: Editor;
let otherEditor: Editor;

beforeEach(() => {
const condition = new Condition("i == 0");
const subject = new BehaviorSubject<ICondition>(condition);
typingEditor = createEditor(subject);
otherEditor = createEditor(
sharing === "subject"
? subject
: new BehaviorSubject<ICondition>(condition),
);
});

it("shows the error of typed text in all editors after the same pause", fakeAsync(() => {
type(typingEditor, "i = 0");
tick(SYNTAX_CHECK_DELAY_MS - 1);
expect(shownError(typingEditor)).toBeNull();
expect(shownError(otherEditor)).toBeNull();

tick(1);
expect(shownError(typingEditor)).toContain("Assignment '='");
expect(shownError(otherEditor)).toContain("Assignment '='");
}));

it("removes the error in all editors immediately when fixed", fakeAsync(() => {
type(typingEditor, "i = 0");
tick(SYNTAX_CHECK_DELAY_MS);
type(typingEditor, "i == 0");
expect(shownError(typingEditor)).toBeNull();
expect(shownError(otherEditor)).toBeNull();
}));

it("shows the error in all editors immediately when leaving the field", fakeAsync(() => {
type(typingEditor, "i = 0");
leaveField(typingEditor);
expect(shownError(typingEditor)).toContain("Assignment '='");
expect(shownError(otherEditor)).toContain("Assignment '='");
tick(SYNTAX_CHECK_DELAY_MS);
}));

it("does not affect editors of other conditions when leaving the field", fakeAsync(() => {
const unrelatedEditor = createEditor(
new BehaviorSubject<ICondition>(new Condition("a > b")),
);
type(unrelatedEditor, "a >");
type(typingEditor, "i = 0");
leaveField(typingEditor);
expect(shownError(unrelatedEditor)).toBeNull();
tick(SYNTAX_CHECK_DELAY_MS);
expect(shownError(unrelatedEditor)).not.toBeNull();
}));
});
}
});
Loading
Loading