From 12e1db502cd7a0e9f19e467706aa25fe686ab0ad Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Tue, 7 Jul 2026 14:17:39 +1000 Subject: [PATCH 1/9] refactor: update job status display and improve samplesheet ID handling --- .../features/jobs/jobs-list/jobs-list.html | 17 ++++++--------- .../features/jobs/jobs-list/jobs-list.spec.ts | 13 ++++++------ src/app/features/jobs/jobs-list/jobs-list.ts | 14 +++++-------- .../single-prediction.spec.ts | 2 +- .../single-prediction/single-prediction.ts | 21 +++++++++---------- 5 files changed, 29 insertions(+), 38 deletions(-) diff --git a/src/app/features/jobs/jobs-list/jobs-list.html b/src/app/features/jobs/jobs-list/jobs-list.html index c30209d4..1b703555 100644 --- a/src/app/features/jobs/jobs-list/jobs-list.html +++ b/src/app/features/jobs/jobs-list/jobs-list.html @@ -334,21 +334,16 @@

All jobs

{{ job.tool || "—" }} - @if (isInProgress(job.status)) { - - - In progress... - - } @else { - {{ job.status }} + @if (job.status === 'In progress') { + + } {{ job.status }} - } {{ job.submittedAt | date:'dd/MM/yyyy' }} diff --git a/src/app/features/jobs/jobs-list/jobs-list.spec.ts b/src/app/features/jobs/jobs-list/jobs-list.spec.ts index 040a2c34..2dbda09a 100644 --- a/src/app/features/jobs/jobs-list/jobs-list.spec.ts +++ b/src/app/features/jobs/jobs-list/jobs-list.spec.ts @@ -421,18 +421,19 @@ describe("JobsListComponent", () => { expect(component.getStatusClass("Completed")).toBe( "bg-green-100 text-green-800" ); - expect(component.getStatusClass("In progress")).toBe("text-gray-700"); + expect(component.getStatusClass("In progress")).toBe( + "bg-sky-100 text-sky-800" + ); expect(component.getStatusClass("In queue")).toBe( - "bg-white text-black border border-black" + "bg-gray-100 text-gray-800" ); - expect(component.getStatusClass("Failed")).toBe("bg-red-600 text-white"); + expect(component.getStatusClass("Failed")).toBe("bg-red-100 text-red-800"); expect(component.getStatusClass("Stopped")).toBe( - "bg-amber-100 text-amber-700" + "bg-amber-100 text-amber-800" ); expect(component.getStatusClass("Unknown")).toBe( - "bg-gray-100 text-gray-900" + "bg-gray-100 text-gray-800" ); - expect(component.isInProgress("In progress")).toBeTrue(); }); it("should toggle individual and all job selections", () => { diff --git a/src/app/features/jobs/jobs-list/jobs-list.ts b/src/app/features/jobs/jobs-list/jobs-list.ts index e3ea3231..919a9fad 100644 --- a/src/app/features/jobs/jobs-list/jobs-list.ts +++ b/src/app/features/jobs/jobs-list/jobs-list.ts @@ -372,22 +372,18 @@ export default class JobsListComponent implements OnInit, OnDestroy { case "Completed": return "bg-green-100 text-green-800"; case "In progress": - return "text-gray-700"; + return "bg-sky-100 text-sky-800"; case "In queue": - return "bg-white text-black border border-black"; + return "bg-gray-100 text-gray-800"; case "Failed": - return "bg-red-600 text-white"; + return "bg-red-100 text-red-800"; case "Stopped": - return "bg-amber-100 text-amber-700"; + return "bg-amber-100 text-amber-800"; default: - return "bg-gray-100 text-gray-900"; + return "bg-gray-100 text-gray-800"; } } - isInProgress(status: string): boolean { - return status === "In progress"; - } - /** * Toggle selection for a single job */ diff --git a/src/app/features/workflows/single-prediction/single-prediction.spec.ts b/src/app/features/workflows/single-prediction/single-prediction.spec.ts index 5b13bff5..b8af5a39 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.spec.ts +++ b/src/app/features/workflows/single-prediction/single-prediction.spec.ts @@ -488,7 +488,7 @@ describe("SinglePredictionComponent", () => { const datasetUploadRequest = datasetUploadService.uploadDataset.calls.mostRecent().args[0]; const samplesheetId = datasetUploadRequest.formData["id"]; - expect(samplesheetId).toMatch(/^single-prediction-[a-z0-9]{8}$/); + expect(samplesheetId).toBe("test-run"); expect(datasetUploadRequest.formData["fasta"]).toBe( MOCK_FASTA_RESPONSE.s3Uri ); diff --git a/src/app/features/workflows/single-prediction/single-prediction.ts b/src/app/features/workflows/single-prediction/single-prediction.ts index 0baa1783..0d0bb012 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.ts +++ b/src/app/features/workflows/single-prediction/single-prediction.ts @@ -120,7 +120,6 @@ export default class SinglePredictionComponent extends WorkflowPageBase { CCD_COMPOUNDS ).map(([code, name]) => ({ value: code, label: `${code} - ${name}` })); - private readonly samplesheetId = `single-prediction-${this.generateRandomSuffix()}`; private nextRowId = 1; ccdLookupState = signal>({}); ccdLookupNames = signal>({}); // compound name resolved from the local CCD dictionary via lookupCcdCompound() @@ -128,6 +127,10 @@ export default class SinglePredictionComponent extends WorkflowPageBase { private preparedFastaContent = signal(null); private preparedFastaUrl = signal(null); private preparedSamplesheetS3Key = signal(null); + private preparedSamplesheetId = signal(null); + private get samplesheetId(): string { + return this.jobName().trim(); + } readonly tools: ToolChip[] = [ { id: "colabfold", label: "ColabFold" }, @@ -683,18 +686,20 @@ export default class SinglePredictionComponent extends WorkflowPageBase { onPrepared: (fastaUrl: string, s3InputKey: string) => void ): void { const fastaContent = this.generatedFastaContent(); + const samplesheetId = this.samplesheetId; const cachedS3InputKey = this.preparedSamplesheetS3Key(); const cachedFastaUrl = this.preparedFastaUrl(); if ( cachedS3InputKey && cachedFastaUrl && - this.preparedFastaContent() === fastaContent + this.preparedFastaContent() === fastaContent && + this.preparedSamplesheetId() === samplesheetId ) { onPrepared(cachedFastaUrl, cachedS3InputKey); return; } - const fastaFile = new File([fastaContent], `${this.samplesheetId}.fasta`, { + const fastaFile = new File([fastaContent], `${samplesheetId}.fasta`, { type: "text/plain", }); @@ -712,7 +717,7 @@ export default class SinglePredictionComponent extends WorkflowPageBase { } return this.datasetUploadService .uploadDataset({ - formData: { id: this.samplesheetId, fasta: response.s3Uri }, + formData: { id: samplesheetId, fasta: response.s3Uri }, }) .pipe( map((datasetResponse) => ({ @@ -735,6 +740,7 @@ export default class SinglePredictionComponent extends WorkflowPageBase { this.preparedFastaContent.set(fastaContent); this.preparedFastaUrl.set(fastaUrl); this.preparedSamplesheetS3Key.set(s3InputKey); + this.preparedSamplesheetId.set(samplesheetId); onPrepared(fastaUrl, s3InputKey); }, error: (error: unknown) => { @@ -761,13 +767,6 @@ export default class SinglePredictionComponent extends WorkflowPageBase { ); } - private generateRandomSuffix(): string { - return ( - globalThis.crypto?.randomUUID?.().replace(/-/g, "") ?? - Math.random().toString(36).slice(2) - ).slice(0, 8); - } - private buildWorkflowPayload(): Omit< SinglePredictionPayload, "fastaFileUrl" | "sample_id" From 7c1aa8e18a406de32fddf317e037d89fb728641b Mon Sep 17 00:00:00 2001 From: Marius Mather Date: Wed, 8 Jul 2026 10:17:52 +1000 Subject: [PATCH 2/9] feat: add Pending status --- src/app/features/jobs/jobs-list/jobs-list.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/features/jobs/jobs-list/jobs-list.ts b/src/app/features/jobs/jobs-list/jobs-list.ts index 919a9fad..5a3208ec 100644 --- a/src/app/features/jobs/jobs-list/jobs-list.ts +++ b/src/app/features/jobs/jobs-list/jobs-list.ts @@ -371,6 +371,8 @@ export default class JobsListComponent implements OnInit, OnDestroy { switch (status) { case "Completed": return "bg-green-100 text-green-800"; + case "Pending": + return "bg-blue-100 text-blue-800"; case "In progress": return "bg-sky-100 text-sky-800"; case "In queue": From 5bbb418e446b951b13c065c79439c25c04dafef7 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 8 Jul 2026 11:34:53 +1000 Subject: [PATCH 3/9] refactor: consistent reactive forms --- angular.json | 12 +- .../bulk-prediction/bulk-prediction.ts | 17 +- .../de-novo-design/de-novo-design.html | 10 +- .../de-novo-design/de-novo-design.spec.ts | 165 ++++++++++++++++++ .../de-novo-design/de-novo-design.ts | 134 +++++--------- .../interaction-screening.ts | 10 +- .../single-prediction/single-prediction.html | 10 +- .../single-prediction.spec.ts | 40 +++-- .../single-prediction/single-prediction.ts | 41 +++-- 9 files changed, 286 insertions(+), 153 deletions(-) create mode 100644 src/app/features/workflows/de-novo-design/de-novo-design.spec.ts diff --git a/angular.json b/angular.json index 45a76b82..2816133a 100644 --- a/angular.json +++ b/angular.json @@ -117,7 +117,17 @@ "styles": ["src/styles.scss"], "scripts": [], "karmaConfig": "karma.conf.js", - "watch": false + "watch": false, + "fileReplacements": [ + { + "replace": "node_modules/h264-mp4-encoder/embuild/dist/h264-mp4-encoder.node.js", + "with": "src/stubs/h264-mp4-encoder.stub.js" + } + ], + "loader": { + ".jpg": "file", + ".png": "file" + } } }, "lint": { diff --git a/src/app/features/workflows/bulk-prediction/bulk-prediction.ts b/src/app/features/workflows/bulk-prediction/bulk-prediction.ts index 1be8c68d..e3146ad8 100644 --- a/src/app/features/workflows/bulk-prediction/bulk-prediction.ts +++ b/src/app/features/workflows/bulk-prediction/bulk-prediction.ts @@ -1,12 +1,5 @@ import { CommonModule } from "@angular/common"; -import { - ChangeDetectionStrategy, - Component, - computed, - inject, - Signal, - signal, -} from "@angular/core"; +import { Component, computed, inject, Signal, signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; import { AbstractControl, @@ -52,7 +45,6 @@ interface ToolChip extends ToolOption { @Component({ selector: "app-bulk-prediction", - changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, ReactiveFormsModule, @@ -93,7 +85,8 @@ export default class BulkPredictionComponent extends WorkflowPageBase { fasta: ["", bulkFastaValidator], }); private formStatus = toSignal( - this.form.statusChanges.pipe(startWith(this.form.status)) + this.form.statusChanges.pipe(startWith(this.form.status)), + { requireSync: true } ); private formValue: Signal<{ jobName: string; fasta: string }> = toSignal( this.form.valueChanges.pipe( @@ -181,7 +174,7 @@ export default class BulkPredictionComponent extends WorkflowPageBase { // Submission private buildBulkPayload(): { id: string; sequence: string }[] { - const entries = parseMultiFasta(this.form.value.fasta ?? ""); + const entries = parseMultiFasta(this.form.getRawValue().fasta); return entries.map((e) => ({ id: e.header, sequence: e.sequence })); } @@ -190,7 +183,7 @@ export default class BulkPredictionComponent extends WorkflowPageBase { } protected performSubmit(): void { - const jobName = this.form.value.jobName ?? ""; + const jobName = this.form.getRawValue().jobName; const sequences = this.buildBulkPayload(); this.workflowSubmission.isSubmitting.set(true); diff --git a/src/app/features/workflows/de-novo-design/de-novo-design.html b/src/app/features/workflows/de-novo-design/de-novo-design.html index 71b4b8eb..1ed21d85 100644 --- a/src/app/features/workflows/de-novo-design/de-novo-design.html +++ b/src/app/features/workflows/de-novo-design/de-novo-design.html @@ -69,15 +69,13 @@ id="jobNameInput" type="text" class="mt-2 block h-10 w-full px-4 border rounded-md text-sm text-gray-900 placeholder-gray-400 transition-colors focus:outline-none focus:ring-2 focus:ring-sky-100" - [class.border-red-500]="jobNameTouched() && jobNameError()" - [value]="jobName()" - (input)="onJobNameChange($any($event.target).value)" - (blur)="jobNameTouched.set(true)" + [class.border-red-500]="hasJobNameError()" + [formControl]="form.controls.jobName" placeholder="e.g. my-binder-job" maxlength="60" /> - @if (jobNameTouched() && jobNameError()) { -

{{ jobNameError() }}

+ @if (hasJobNameError()) { +

{{ getJobNameError() }}

} diff --git a/src/app/features/workflows/de-novo-design/de-novo-design.spec.ts b/src/app/features/workflows/de-novo-design/de-novo-design.spec.ts new file mode 100644 index 00000000..785e6e8b --- /dev/null +++ b/src/app/features/workflows/de-novo-design/de-novo-design.spec.ts @@ -0,0 +1,165 @@ +import { signal } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { provideHttpClient } from "@angular/common/http"; +import { provideHttpClientTesting } from "@angular/common/http/testing"; +import { Observable, of } from "rxjs"; +import { AuthService } from "../../../core/services/auth.service"; +import { CreditsService } from "../../../core/services/credits.service"; +import { DatasetUploadService } from "../services/dataset-upload.service"; +import { PdbUploadService } from "../services/pdb-upload.service"; +import { + InputRow, + SchemaLoaderService, +} from "../services/schema-loader.service"; +import { InputSchemaField } from "../services/input-schema.service"; +import { WorkflowSubmissionService } from "../services/workflow-submission.service"; +import DeNovoDesignComponent from "./de-novo-design"; + +function requiredField(name: string): InputSchemaField { + return { name } as unknown as InputSchemaField; +} + +function rowWith(values: Record): InputRow { + return { id: "row1", values }; +} + +describe("DeNovoDesignComponent", () => { + let component: DeNovoDesignComponent; + let fixture: ComponentFixture; + let schemaLoader: { + inputSchemaData: ReturnType>; + inputSchemaFields: ReturnType>; + requiredInputFields: ReturnType>; + optionalInputFields: ReturnType>; + inputRows: ReturnType>; + loadInputSchema: jasmine.Spy; + initializeDefaultRow: jasmine.Spy; + generateDefaultValues: jasmine.Spy; + getFirstRowValues: jasmine.Spy; + getRowValue: jasmine.Spy; + updateRowValue: jasmine.Spy; + inputSchemaService: { validateFieldValue: jasmine.Spy }; + }; + + beforeEach(async () => { + schemaLoader = { + inputSchemaData: signal(null), + inputSchemaFields: signal([]), + requiredInputFields: signal([]), + optionalInputFields: signal([]), + inputRows: signal([]), + loadInputSchema: jasmine.createSpy("loadInputSchema"), + initializeDefaultRow: jasmine.createSpy("initializeDefaultRow"), + generateDefaultValues: jasmine + .createSpy("generateDefaultValues") + .and.returnValue({}), + getFirstRowValues: jasmine + .createSpy("getFirstRowValues") + .and.returnValue({}), + getRowValue: jasmine.createSpy("getRowValue").and.returnValue(""), + updateRowValue: jasmine.createSpy("updateRowValue"), + inputSchemaService: { + validateFieldValue: jasmine + .createSpy("validateFieldValue") + .and.returnValue({ valid: true, errors: [] }), + }, + }; + + const authService: { + isAuthenticated$: Observable; + isLoading$: Observable; + canExecuteWorkflows$: Observable; + profileUrl: string; + login: jasmine.Spy; + } = { + isAuthenticated$: of(true), + isLoading$: of(true), + canExecuteWorkflows$: of(true), + profileUrl: "https://example.com/profile", + login: jasmine.createSpy("login"), + }; + + const workflowSubmissionService = { + isSubmitting: signal(false), + showSuccessDialog: signal(false), + successDialogData: signal(null), + submitWorkflowWithDataset: jasmine.createSpy("submitWorkflowWithDataset"), + goToJobs: jasmine.createSpy("goToJobs"), + }; + + await TestBed.configureTestingModule({ + imports: [DeNovoDesignComponent], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + { provide: AuthService, useValue: authService }, + { + provide: CreditsService, + useValue: jasmine.createSpyObj("CreditsService", [ + "getWorkflowCredits", + "getMyCredit", + ]), + }, + { provide: SchemaLoaderService, useValue: schemaLoader }, + { + provide: DatasetUploadService, + useValue: jasmine.createSpyObj( + "DatasetUploadService", + ["uploadDataset"] + ), + }, + { + provide: PdbUploadService, + useValue: jasmine.createSpyObj("PdbUploadService", [ + "validatePdbFile", + "uploadPdbFile", + ]), + }, + { + provide: WorkflowSubmissionService, + useValue: workflowSubmissionService, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(DeNovoDesignComponent); + component = fixture.componentInstance; + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("isFormValid (derived)", () => { + beforeEach(() => { + schemaLoader.requiredInputFields.set([requiredField("starting_pdb")]); + schemaLoader.inputRows.set([rowWith({ starting_pdb: "target.pdb" })]); + component.form.controls.jobName.setValue("valid-job"); + component.formErrors.set({}); + }); + + it("is true when job name is valid, the row is complete, and there are no errors", () => { + expect(component.isFormValid()).toBe(true); + }); + + it("is false when the job name is invalid", () => { + component.form.controls.jobName.setValue("1bad"); + expect(component.isFormValid()).toBe(false); + }); + + it("is false when a required row field is empty", () => { + schemaLoader.inputRows.set([rowWith({ starting_pdb: "" })]); + expect(component.isFormValid()).toBe(false); + }); + + it("is false when a field-level error is present", () => { + component.formErrors.set({ row1_chains: "Invalid chain" }); + expect(component.isFormValid()).toBe(false); + }); + + it("is false when there are no input rows", () => { + schemaLoader.inputRows.set([]); + expect(component.isFormValid()).toBe(false); + }); + }); +}); diff --git a/src/app/features/workflows/de-novo-design/de-novo-design.ts b/src/app/features/workflows/de-novo-design/de-novo-design.ts index 141e960c..48d360cd 100644 --- a/src/app/features/workflows/de-novo-design/de-novo-design.ts +++ b/src/app/features/workflows/de-novo-design/de-novo-design.ts @@ -17,7 +17,8 @@ import { Signal, signal, } from "@angular/core"; -import { FormControl, FormsModule } from "@angular/forms"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { NonNullableFormBuilder, ReactiveFormsModule } from "@angular/forms"; import { JOB_NAME_VALIDATORS, jobNameErrorMessage, @@ -26,7 +27,7 @@ import { ButtonComponent } from "../../../components/button/button.component"; import { MolstarViewerComponent } from "../components/molstar-viewer/molstar-viewer.component"; import { LengthRangeSliderComponent } from "../components/length-range-slider/length-range-slider.component"; -import { filter, Subscription, take } from "rxjs"; +import { filter, startWith, Subscription, take } from "rxjs"; import { FormFieldComponent } from "../components/form-field/form-field.component"; import { StepContentComponent } from "../components/step-content/step-content.component"; import { WorkflowLayoutComponent } from "../layout/workflow-layout/workflow-layout.component"; @@ -58,7 +59,7 @@ interface ToolChip extends ToolOption { selector: "app-de-novo-design", imports: [ CommonModule, - FormsModule, + ReactiveFormsModule, ButtonComponent, ToolSelectionComponent, WorkflowFormComponent, @@ -108,19 +109,45 @@ export default class DeNovoDesignComponent private readonly inputSchemaUrl = "https://raw.githubusercontent.com/Australian-Structural-Biology-Computing/bindflow/refs/heads/dev/assets/schema_input.json"; - // Job Name (custom signal-based field, replaces schema 'id' field in UI) - jobName = signal(""); - jobNameTouched = signal(false); - readonly jobNameError = computed(() => - jobNameErrorMessage( - new FormControl(this.jobName().trim(), JOB_NAME_VALIDATORS).errors - ) + // Job Name (reactive form field) + private readonly fb = inject(NonNullableFormBuilder); + readonly form = this.fb.group({ + jobName: ["", JOB_NAME_VALIDATORS], + }); + readonly jobName = toSignal( + this.form.controls.jobName.valueChanges.pipe( + startWith(this.form.controls.jobName.value) + ), + { initialValue: this.form.controls.jobName.value } ); + hasJobNameError(): boolean { + const ctrl = this.form.controls.jobName; + return ctrl.touched && ctrl.invalid; + } + + getJobNameError(): string { + return jobNameErrorMessage(this.form.controls.jobName.errors); + } + // Form data and validation formData = signal>({}); formErrors = signal<{ [key: string]: string }>({}); - isFormValid = signal(false); + readonly isFormValid = computed(() => { + this.jobName(); + if (this.form.controls.jobName.invalid) return false; + if (Object.keys(this.formErrors()).length > 0) return false; + const rows = this.schemaLoader.inputRows(); + if (rows.length === 0) return false; + const requiredFields = this.schemaLoader.requiredInputFields(); + return rows.every((row) => + requiredFields.every((field) => { + if (field.name === "binder_name" || field.name === "id") return true; + const value = row.values[field.name]; + return value !== undefined && value !== null && value !== ""; + }) + ); + }); // Tools readonly tools: ToolChip[] = [ @@ -438,7 +465,6 @@ export default class DeNovoDesignComponent delete updated[errorKey]; this.formErrors.set(updated); } - this.validateAllRows(); } onLengthRangeChange( @@ -584,7 +610,6 @@ export default class DeNovoDesignComponent // After row is created, sync to form data this.syncRowsToFormData(); - this.validateAllRows(); }); }, (error) => { @@ -594,13 +619,12 @@ export default class DeNovoDesignComponent } protected validateAll(): void { - this.jobNameTouched.set(true); + this.form.markAllAsTouched(); this.validateAllRequiredFields(); for (const row of this.schemaLoader.inputRows()) { this.validateRowField(row.id, "target_hotspot_residues"); this.validateRowField(row.id, "chains"); } - this.validateForm(); } protected performSubmit(): void { @@ -712,7 +736,6 @@ export default class DeNovoDesignComponent // Initialize form data with default values from schema private initializeFormData(defaultValues: Record): void { this.formData.set(defaultValues); - this.validateForm(); } // Update form data for a specific field @@ -721,12 +744,6 @@ export default class DeNovoDesignComponent const updatedData = { ...currentData, [fieldName]: value }; this.formData.set(updatedData); this.validateField(fieldName, value); - this.validateForm(); - } - - onJobNameChange(value: string): void { - this.jobName.set(value); - this.validateAllRows(); } // Handle input events @@ -794,7 +811,6 @@ export default class DeNovoDesignComponent const currentData = this.formData(); const value = currentData[fieldName]; this.validateField(fieldName, value); - this.validateForm(); // Re-validate entire form after single field validation } // Validate all required fields and show errors @@ -808,38 +824,7 @@ export default class DeNovoDesignComponent const value = currentData[field.name]; this.validateField(field.name, value); } - this.jobNameTouched.set(true); - } - - // Validate the entire form - private validateForm(): void { - const requiredFields = this.schemaLoader.requiredInputFields(); - const currentData = this.formData(); - - let isValid = true; - - // Check if all required fields have values - for (const field of requiredFields) { - if (field.name === "binder_name" || field.name === "id") continue; - const value = currentData[field.name]; - if (value === undefined || value === null || value === "") { - isValid = false; - break; - } - } - - // Check Job Name validity - if (this.jobNameError()) { - isValid = false; - } - - // Check if there are any validation errors - const errors = this.formErrors(); - if (Object.keys(errors).length > 0) { - isValid = false; - } - - this.isFormValid.set(isValid); + this.form.markAllAsTouched(); } // Get current form data for submission @@ -992,7 +977,6 @@ export default class DeNovoDesignComponent // Preserve existing formData (like default URLs) and merge with row values const currentData = this.formData(); this.formData.set({ ...currentData, ...rowValues }); - this.validateForm(); } } @@ -1047,7 +1031,6 @@ export default class DeNovoDesignComponent } if (customError) { this.formErrors.set({ ...currentErrors, [errorKey]: customError }); - this.validateAllRows(); return; } // Remove error for this specific cell @@ -1061,41 +1044,6 @@ export default class DeNovoDesignComponent [errorKey]: validationResult.errors[0] || "Invalid value", }); } - - this.validateAllRows(); - } - - // Validate all rows and update overall form validity - validateAllRows(): void { - const rows = this.schemaLoader.inputRows(); - const requiredFields = this.schemaLoader.requiredInputFields(); - let hasErrors = false; - - // Check each row for required field completeness - for (const row of rows) { - for (const field of requiredFields) { - if (field.name === "binder_name" || field.name === "id") continue; - const value = row.values[field.name]; - if (value === undefined || value === null || value === "") { - hasErrors = true; - break; - } - } - if (hasErrors) break; - } - - // Check Job Name validity - if (this.jobNameError()) { - hasErrors = true; - } - - // Check if there are validation errors - const errors = this.formErrors(); - if (Object.keys(errors).length > 0) { - hasErrors = true; - } - - this.isFormValid.set(!hasErrors && rows.length > 0); } // Get validation error for a specific cell @@ -1111,7 +1059,7 @@ export default class DeNovoDesignComponent /** Returns true when any field inside the collapsible config section has a validation error. */ hasConfigSectionErrors(rowId: string): boolean { - if (this.jobNameTouched() && !!this.jobNameError()) return true; + if (this.hasJobNameError()) return true; return [ "target_hotspot_residues", "chains", diff --git a/src/app/features/workflows/interaction-screening/interaction-screening.ts b/src/app/features/workflows/interaction-screening/interaction-screening.ts index 1a93f2c5..31fab4ba 100644 --- a/src/app/features/workflows/interaction-screening/interaction-screening.ts +++ b/src/app/features/workflows/interaction-screening/interaction-screening.ts @@ -133,7 +133,8 @@ export default class InteractionScreeningComponent extends WorkflowPageBase { } ); private formStatus = toSignal( - this.form.statusChanges.pipe(startWith(this.form.status)) + this.form.statusChanges.pipe(startWith(this.form.status)), + { requireSync: true } ); private formValue: Signal<{ jobName: string; @@ -289,8 +290,9 @@ export default class InteractionScreeningComponent extends WorkflowPageBase { sequence: string; group: "query" | "target"; }[] { - const queryEntries = parseMultiFasta(this.form.value.queryFasta ?? ""); - const targetEntries = parseMultiFasta(this.form.value.targetFasta ?? ""); + const raw = this.form.getRawValue(); + const queryEntries = parseMultiFasta(raw.queryFasta); + const targetEntries = parseMultiFasta(raw.targetFasta); return [ ...queryEntries.map((e) => ({ id: e.header, @@ -306,7 +308,7 @@ export default class InteractionScreeningComponent extends WorkflowPageBase { } protected performSubmit(): void { - const jobName = this.form.value.jobName ?? ""; + const jobName = this.form.getRawValue().jobName; const sequences = this.buildWispsPayload(); this.workflowSubmission.isSubmitting.set(true); diff --git a/src/app/features/workflows/single-prediction/single-prediction.html b/src/app/features/workflows/single-prediction/single-prediction.html index 7ff4885f..2aa38c94 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.html +++ b/src/app/features/workflows/single-prediction/single-prediction.html @@ -59,21 +59,19 @@ id="job-name" type="text" autocomplete="off" - [value]="jobName()" - (input)="jobName.set($any($event.target).value)" - (blur)="jobNameTouched.set(true)" + [formControl]="form.controls.jobName" placeholder="e.g. my-prediction" [class]=" - jobNameTouched() && jobNameError() + hasJobNameError() ? 'border-red-500 focus:ring-red-100' : 'focus:ring-sky-100' " class="mt-2 block h-10 w-full rounded-md border px-4 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 transition-colors" maxlength="60" /> - @if (jobNameTouched() && jobNameError()) { + @if (hasJobNameError()) { } diff --git a/src/app/features/workflows/single-prediction/single-prediction.spec.ts b/src/app/features/workflows/single-prediction/single-prediction.spec.ts index b8af5a39..93bcd615 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.spec.ts +++ b/src/app/features/workflows/single-prediction/single-prediction.spec.ts @@ -127,7 +127,7 @@ describe("SinglePredictionComponent", () => { copyNumber = "1" ): number { const rowId = component.entityRows()[0].id; - component.jobName.set("test-run"); + component.form.controls.jobName.setValue("test-run"); component.updateRowSequence(rowId, sequence); component.updateRowCopyNumber(rowId, copyNumber); component.updateRowMoleculeType(rowId, "protein"); @@ -213,7 +213,7 @@ describe("SinglePredictionComponent", () => { it("should validate DNA, RNA, and ligand formats", () => { const rowId = component.entityRows()[0].id; - component.jobName.set("test-run"); + component.form.controls.jobName.setValue("test-run"); component.selectTool("boltz"); component.updateRowSequence(rowId, "ACGT"); @@ -244,7 +244,7 @@ describe("SinglePredictionComponent", () => { it("should mark CCD row valid when code is in the supported list", () => { const rowId = component.entityRows()[0].id; - component.jobName.set("test-run"); + component.form.controls.jobName.setValue("test-run"); component.selectTool("boltz"); component.updateRowMoleculeType(rowId, "ccd"); component.updateRowSequence(rowId, "ATP"); @@ -355,7 +355,7 @@ describe("SinglePredictionComponent", () => { it("should allow Boltz with non-protein molecules and generate FASTA-like content", () => { const rowId = component.entityRows()[0].id; - component.jobName.set("test-run"); + component.form.controls.jobName.setValue("test-run"); component.selectTool("boltz"); component.updateRowSequence(rowId, "ACGT"); @@ -623,10 +623,10 @@ describe("SinglePredictionComponent", () => { const rowId = component.entityRows()[0].id; component.updateRowSequence(rowId, "ACDEFGHIK"); - component.jobName.set(""); + component.form.controls.jobName.setValue(""); expect(component.isStep1Valid()).toBe(false); - component.jobName.set("my-job"); + component.form.controls.jobName.setValue("my-job"); expect(component.isStep1Valid()).toBe(true); }); @@ -634,45 +634,49 @@ describe("SinglePredictionComponent", () => { const rowId = component.entityRows()[0].id; component.updateRowSequence(rowId, "ACDEFGHIK"); - component.jobName.set("1invalid"); + component.form.controls.jobName.setValue("1invalid"); expect(component.isStep1Valid()).toBe(false); - expect(component.jobNameError()).toContain("must not start with a number"); + expect(component.getJobNameError()).toContain( + "must not start with a number" + ); }); it("should reject jobName with invalid characters", () => { const rowId = component.entityRows()[0].id; component.updateRowSequence(rowId, "ACDEFGHIK"); - component.jobName.set("job@name!"); + component.form.controls.jobName.setValue("job@name!"); expect(component.isStep1Valid()).toBe(false); - expect(component.jobNameError()).toContain("must not start with a number"); + expect(component.getJobNameError()).toContain( + "must not start with a number" + ); }); it("should reject jobName longer than 60 characters", () => { const rowId = component.entityRows()[0].id; component.updateRowSequence(rowId, "ACDEFGHIK"); - component.jobName.set("a".repeat(61)); + component.form.controls.jobName.setValue("a".repeat(61)); expect(component.isStep1Valid()).toBe(false); - expect(component.jobNameError()).toContain("60 characters or fewer"); + expect(component.getJobNameError()).toContain("60 characters or fewer"); }); it("should show job name required error after touching", () => { - component.jobNameTouched.set(true); - component.jobName.set(""); - expect(component.jobNameTouched()).toBe(true); + component.form.controls.jobName.markAsTouched(); + component.form.controls.jobName.setValue(""); + expect(component.form.controls.jobName.touched).toBe(true); component.submitWorkflow(); - expect(component.jobNameTouched()).toBe(true); + expect(component.form.controls.jobName.touched).toBe(true); }); it("should keep input-config invalid until jobName is filled", () => { const rowId = component.entityRows()[0].id; component.updateRowSequence(rowId, "ACDEFGHIK"); - component.jobName.set(""); + component.form.controls.jobName.setValue(""); expect(component.isSectionValid("input-config")).toBe(false); - component.jobName.set("valid-run"); + component.form.controls.jobName.setValue("valid-run"); expect(component.isSectionValid("input-config")).toBe(true); }); diff --git a/src/app/features/workflows/single-prediction/single-prediction.ts b/src/app/features/workflows/single-prediction/single-prediction.ts index 0d0bb012..df380f25 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.ts +++ b/src/app/features/workflows/single-prediction/single-prediction.ts @@ -1,4 +1,4 @@ -import { switchMap, map } from "rxjs"; +import { switchMap, map, startWith } from "rxjs"; import { CommonModule } from "@angular/common"; import { CdkDragDrop, @@ -6,7 +6,8 @@ import { moveItemInArray, } from "@angular/cdk/drag-drop"; import { Component, computed, inject, Signal, signal } from "@angular/core"; -import { FormControl } from "@angular/forms"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { NonNullableFormBuilder, ReactiveFormsModule } from "@angular/forms"; import { NgIconComponent, provideIcons } from "@ng-icons/core"; import { bootstrapGripVertical } from "@ng-icons/bootstrap-icons"; import { heroTrash } from "@ng-icons/heroicons/outline"; @@ -87,6 +88,7 @@ interface ToolSettingErrors { imports: [ CommonModule, DragDropModule, + ReactiveFormsModule, ButtonComponent, ToolSelectionComponent, ListboxSelectComponent, @@ -159,13 +161,24 @@ export default class SinglePredictionComponent extends WorkflowPageBase { stepOneTouched = signal(false); stepTwoTouched = signal(false); - jobName = signal(""); - jobNameTouched = signal(false); - readonly jobNameError = computed(() => - jobNameErrorMessage( - new FormControl(this.jobName().trim(), JOB_NAME_VALIDATORS).errors - ) + private readonly fb = inject(NonNullableFormBuilder); + readonly form = this.fb.group({ + jobName: ["", JOB_NAME_VALIDATORS], + }); + readonly jobName = toSignal( + this.form.controls.jobName.valueChanges.pipe( + startWith(this.form.controls.jobName.value) + ), + { initialValue: this.form.controls.jobName.value } ); + hasJobNameError(): boolean { + const ctrl = this.form.controls.jobName; + return ctrl.touched && ctrl.invalid; + } + + getJobNameError(): string { + return jobNameErrorMessage(this.form.controls.jobName.errors); + } alphafold2RandomSeed = signal("42"); alphafold2FullDbs = signal(false); @@ -187,14 +200,16 @@ export default class SinglePredictionComponent extends WorkflowPageBase { this.entityRows().map((row) => this.validateEntityRow(row)) ); readonly toolSettingErrors = computed(() => this.validateToolSettings()); - readonly isStep1Valid = computed( - () => - !this.jobNameError() && + readonly isStep1Valid = computed(() => { + this.jobName(); + return ( + this.form.controls.jobName.valid && this.entityRows().length > 0 && this.entityValidationResults().every( (errors) => !errors.sequence && !errors.copyNumber && !errors.tool ) - ); + ); + }); readonly isStep2Valid = computed( () => Object.keys(this.toolSettingErrors()).length === 0 ); @@ -551,7 +566,7 @@ export default class SinglePredictionComponent extends WorkflowPageBase { private touchAllEntityRows(): void { this.stepOneTouched.set(true); - this.jobNameTouched.set(true); + this.form.markAllAsTouched(); this.entityRows.update((rows) => rows.map((row) => ({ ...row, From e269693476221912443fa6b01a189dcaedd78701 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 8 Jul 2026 12:24:20 +1000 Subject: [PATCH 4/9] style: update jobs list badge colors --- .../features/jobs/jobs-list/jobs-list.html | 2 +- .../features/jobs/jobs-list/jobs-list.spec.ts | 49 ++++++++++--------- src/app/features/jobs/jobs-list/jobs-list.ts | 14 +++--- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/app/features/jobs/jobs-list/jobs-list.html b/src/app/features/jobs/jobs-list/jobs-list.html index 1b703555..de9770ec 100644 --- a/src/app/features/jobs/jobs-list/jobs-list.html +++ b/src/app/features/jobs/jobs-list/jobs-list.html @@ -340,7 +340,7 @@

All jobs

> @if (job.status === 'In progress') { } {{ job.status }} diff --git a/src/app/features/jobs/jobs-list/jobs-list.spec.ts b/src/app/features/jobs/jobs-list/jobs-list.spec.ts index 2dbda09a..c5522e8c 100644 --- a/src/app/features/jobs/jobs-list/jobs-list.spec.ts +++ b/src/app/features/jobs/jobs-list/jobs-list.spec.ts @@ -99,10 +99,10 @@ describe("JobsListComponent", () => { }; mockJobsService.listJobs.and.returnValue(of(mockResponse)); mockJobsService.cancelJob.and.returnValue( - of({ message: "Cancelled", runId: mockJob.id, status: "Stopped" }) + of({ message: "Cancelled", runId: mockJob.id, status: "Stopped" }), ); mockJobsService.bulkDeleteJobs.and.returnValue( - of({ deleted: [mockJob.id], failed: {} }) + of({ deleted: [mockJob.id], failed: {} }), ); await TestBed.configureTestingModule({ imports: [JobsListComponent], @@ -119,18 +119,18 @@ describe("JobsListComponent", () => { mockResultsService.getJobReport.and.returnValue( of( sanitizer.bypassSecurityTrustResourceUrl( - "https://example.test/report.html" - ) - ) + "https://example.test/report.html", + ), + ), ); mockResultsService.getJobDownloads.and.returnValue( - of({ runId: mockJob.id, downloads: [] }) + of({ runId: mockJob.id, downloads: [] }), ); mockResultsService.getJobSettingParams.and.returnValue( - of({ runId: mockJob.id, settingParams: { binder_name: "PDL1" } }) + of({ runId: mockJob.id, settingParams: { binder_name: "PDL1" } }), ); mockResultsService.getJobLogs.and.returnValue( - of({ runId: mockJob.id, logs: [], entries: [], formattedEntries: [] }) + of({ runId: mockJob.id, logs: [], entries: [], formattedEntries: [] }), ); fixture = TestBed.createComponent(JobsListComponent); @@ -154,7 +154,7 @@ describe("JobsListComponent", () => { overallStatus: "degraded", checkedAt: "2026-06-25T03:12:55Z", message: "Some workflow services are currently unavailable.", - }) + }), ); fixture = TestBed.createComponent(JobsListComponent); @@ -163,13 +163,13 @@ describe("JobsListComponent", () => { expect(component.systemUnhealthy()).toBeTrue(); expect(component.healthMessage()).toBe( - "Some workflow services are currently unavailable." + "Some workflow services are currently unavailable.", ); }); it("does not surface a warning when the health check fails", async () => { mockHealthService.getComponentsHealth.and.returnValue( - throwError(() => new Error("network error")) + throwError(() => new Error("network error")), ); fixture = TestBed.createComponent(JobsListComponent); @@ -195,7 +195,7 @@ describe("JobsListComponent", () => { total: 2, limit: 50, offset: 0, - }) + }), ); component.loadJobs(); @@ -219,7 +219,7 @@ describe("JobsListComponent", () => { total: 1, limit: 50, offset: 0, - }) + }), ); component.loadJobs(); @@ -230,7 +230,7 @@ describe("JobsListComponent", () => { it("should set an error when loading jobs fails", () => { mockJobsService.listJobs.and.returnValue( - throwError(() => new Error("load failed")) + throwError(() => new Error("load failed")), ); component.loadJobs(); @@ -419,20 +419,21 @@ describe("JobsListComponent", () => { it("should return status classes and helpers", () => { expect(component.getStatusClass("Completed")).toBe( - "bg-green-100 text-green-800" + "bg-green-100 text-green-800", ); + expect(component.getStatusClass("Pending")).toBe("bg-sky-100 text-sky-800"); expect(component.getStatusClass("In progress")).toBe( - "bg-sky-100 text-sky-800" + "bg-blue-100 text-blue-800", ); expect(component.getStatusClass("In queue")).toBe( - "bg-gray-100 text-gray-800" + "bg-gray-100 text-gray-800", ); expect(component.getStatusClass("Failed")).toBe("bg-red-100 text-red-800"); expect(component.getStatusClass("Stopped")).toBe( - "bg-amber-100 text-amber-800" + "bg-amber-100 text-amber-800", ); expect(component.getStatusClass("Unknown")).toBe( - "bg-gray-100 text-gray-800" + "bg-gray-100 text-gray-800", ); }); @@ -492,7 +493,7 @@ describe("JobsListComponent", () => { it("should clear selection and close the delete dialog when no jobs are selected", () => { const closeDeleteDialogSpy = spyOn( component, - "closeDeleteDialog" + "closeDeleteDialog", ).and.callThrough(); component.confirmDelete(); @@ -506,11 +507,11 @@ describe("JobsListComponent", () => { const loadJobsSpy = spyOn(component, "loadJobs").and.stub(); const closeDeleteDialogSpy = spyOn( component, - "closeDeleteDialog" + "closeDeleteDialog", ).and.callThrough(); component.selectedJobs.set([mockJob.id, secondJob.id]); mockJobsService.bulkDeleteJobs.and.returnValue( - of({ deleted: [mockJob.id], failed: {} }) + of({ deleted: [mockJob.id], failed: {} }), ); component.confirmDelete(); @@ -530,7 +531,7 @@ describe("JobsListComponent", () => { const loadJobsSpy = spyOn(component, "loadJobs").and.stub(); component.selectedJobs.set([mockJob.id, secondJob.id]); mockJobsService.bulkDeleteJobs.and.returnValue( - of({ deleted: [mockJob.id], failed: { [secondJob.id]: "failed" } }) + of({ deleted: [mockJob.id], failed: { [secondJob.id]: "failed" } }), ); component.confirmDelete(); @@ -542,7 +543,7 @@ describe("JobsListComponent", () => { it("should handle delete request failures", () => { mockJobsService.bulkDeleteJobs.and.returnValue( - throwError(() => new Error("delete failed")) + throwError(() => new Error("delete failed")), ); component.selectedJobs.set([mockJob.id]); diff --git a/src/app/features/jobs/jobs-list/jobs-list.ts b/src/app/features/jobs/jobs-list/jobs-list.ts index 5a3208ec..dc548e20 100644 --- a/src/app/features/jobs/jobs-list/jobs-list.ts +++ b/src/app/features/jobs/jobs-list/jobs-list.ts @@ -151,7 +151,7 @@ export default class JobsListComponent implements OnInit, OnDestroy { catchError((err) => { console.error("Error checking system health:", err); return EMPTY; - }) + }), ) .subscribe((health) => { const unhealthy = health.overallStatus !== "healthy"; @@ -189,7 +189,7 @@ export default class JobsListComponent implements OnInit, OnDestroy { this.error.set("Failed to load jobs. Please try again."); this.loading.set(false); return EMPTY; - }) + }), ) .subscribe((response) => { const normalizedJobs = response.jobs.map((job) => { @@ -303,7 +303,7 @@ export default class JobsListComponent implements OnInit, OnDestroy { get totalFinalDesigns(): number { return this.jobs().reduce( (sum, job) => sum + (job.finalDesignCount ?? 0), - 0 + 0, ); } @@ -319,7 +319,7 @@ export default class JobsListComponent implements OnInit, OnDestroy { toggleSubmittedSort(): void { if (this.activeSort() === "submitted") { this.submittedSortDirection.update((d) => - d === "desc" ? "asc" : "desc" + d === "desc" ? "asc" : "desc", ); } else { this.activeSort.set("submitted"); @@ -372,9 +372,9 @@ export default class JobsListComponent implements OnInit, OnDestroy { case "Completed": return "bg-green-100 text-green-800"; case "Pending": - return "bg-blue-100 text-blue-800"; - case "In progress": return "bg-sky-100 text-sky-800"; + case "In progress": + return "bg-blue-100 text-blue-800"; case "In queue": return "bg-gray-100 text-gray-800"; case "Failed": @@ -473,7 +473,7 @@ export default class JobsListComponent implements OnInit, OnDestroy { if (Object.keys(response.failed || {}).length > 0) { const failedCount = Object.keys(response.failed).length; this.error.set( - `Failed to delete ${failedCount} job${failedCount > 1 ? "s" : ""}.` + `Failed to delete ${failedCount} job${failedCount > 1 ? "s" : ""}.`, ); } this.selectedJobs.set([]); From e04f61816667cbe5537522c7f2dbc674d26c8ad4 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 8 Jul 2026 12:42:32 +1000 Subject: [PATCH 5/9] test: fix tests & lint --- angular.json | 3 + .../features/jobs/jobs-list/jobs-list.spec.ts | 48 +- src/app/features/jobs/jobs-list/jobs-list.ts | 10 +- .../de-novo-design/de-novo-design.spec.ts | 758 +++++++++++++++++- .../services/schema-loader.service.spec.ts | 232 ++++++ 5 files changed, 990 insertions(+), 61 deletions(-) create mode 100644 src/app/features/workflows/services/schema-loader.service.spec.ts diff --git a/angular.json b/angular.json index 2816133a..1c2911e0 100644 --- a/angular.json +++ b/angular.json @@ -108,6 +108,9 @@ "options": { "polyfills": ["zone.js", "zone.js/testing"], "tsConfig": "tsconfig.spec.json", + "codeCoverageExclude": [ + "src/app/features/workflows/components/molstar-viewer/molstar-viewer.component.ts" + ], "assets": [ { "glob": "**/*", diff --git a/src/app/features/jobs/jobs-list/jobs-list.spec.ts b/src/app/features/jobs/jobs-list/jobs-list.spec.ts index c5522e8c..aff45d8e 100644 --- a/src/app/features/jobs/jobs-list/jobs-list.spec.ts +++ b/src/app/features/jobs/jobs-list/jobs-list.spec.ts @@ -99,10 +99,10 @@ describe("JobsListComponent", () => { }; mockJobsService.listJobs.and.returnValue(of(mockResponse)); mockJobsService.cancelJob.and.returnValue( - of({ message: "Cancelled", runId: mockJob.id, status: "Stopped" }), + of({ message: "Cancelled", runId: mockJob.id, status: "Stopped" }) ); mockJobsService.bulkDeleteJobs.and.returnValue( - of({ deleted: [mockJob.id], failed: {} }), + of({ deleted: [mockJob.id], failed: {} }) ); await TestBed.configureTestingModule({ imports: [JobsListComponent], @@ -119,18 +119,18 @@ describe("JobsListComponent", () => { mockResultsService.getJobReport.and.returnValue( of( sanitizer.bypassSecurityTrustResourceUrl( - "https://example.test/report.html", - ), - ), + "https://example.test/report.html" + ) + ) ); mockResultsService.getJobDownloads.and.returnValue( - of({ runId: mockJob.id, downloads: [] }), + of({ runId: mockJob.id, downloads: [] }) ); mockResultsService.getJobSettingParams.and.returnValue( - of({ runId: mockJob.id, settingParams: { binder_name: "PDL1" } }), + of({ runId: mockJob.id, settingParams: { binder_name: "PDL1" } }) ); mockResultsService.getJobLogs.and.returnValue( - of({ runId: mockJob.id, logs: [], entries: [], formattedEntries: [] }), + of({ runId: mockJob.id, logs: [], entries: [], formattedEntries: [] }) ); fixture = TestBed.createComponent(JobsListComponent); @@ -154,7 +154,7 @@ describe("JobsListComponent", () => { overallStatus: "degraded", checkedAt: "2026-06-25T03:12:55Z", message: "Some workflow services are currently unavailable.", - }), + }) ); fixture = TestBed.createComponent(JobsListComponent); @@ -163,13 +163,13 @@ describe("JobsListComponent", () => { expect(component.systemUnhealthy()).toBeTrue(); expect(component.healthMessage()).toBe( - "Some workflow services are currently unavailable.", + "Some workflow services are currently unavailable." ); }); it("does not surface a warning when the health check fails", async () => { mockHealthService.getComponentsHealth.and.returnValue( - throwError(() => new Error("network error")), + throwError(() => new Error("network error")) ); fixture = TestBed.createComponent(JobsListComponent); @@ -195,7 +195,7 @@ describe("JobsListComponent", () => { total: 2, limit: 50, offset: 0, - }), + }) ); component.loadJobs(); @@ -219,7 +219,7 @@ describe("JobsListComponent", () => { total: 1, limit: 50, offset: 0, - }), + }) ); component.loadJobs(); @@ -230,7 +230,7 @@ describe("JobsListComponent", () => { it("should set an error when loading jobs fails", () => { mockJobsService.listJobs.and.returnValue( - throwError(() => new Error("load failed")), + throwError(() => new Error("load failed")) ); component.loadJobs(); @@ -419,21 +419,21 @@ describe("JobsListComponent", () => { it("should return status classes and helpers", () => { expect(component.getStatusClass("Completed")).toBe( - "bg-green-100 text-green-800", + "bg-green-100 text-green-800" ); expect(component.getStatusClass("Pending")).toBe("bg-sky-100 text-sky-800"); expect(component.getStatusClass("In progress")).toBe( - "bg-blue-100 text-blue-800", + "bg-blue-100 text-blue-800" ); expect(component.getStatusClass("In queue")).toBe( - "bg-gray-100 text-gray-800", + "bg-gray-100 text-gray-800" ); expect(component.getStatusClass("Failed")).toBe("bg-red-100 text-red-800"); expect(component.getStatusClass("Stopped")).toBe( - "bg-amber-100 text-amber-800", + "bg-amber-100 text-amber-800" ); expect(component.getStatusClass("Unknown")).toBe( - "bg-gray-100 text-gray-800", + "bg-gray-100 text-gray-800" ); }); @@ -493,7 +493,7 @@ describe("JobsListComponent", () => { it("should clear selection and close the delete dialog when no jobs are selected", () => { const closeDeleteDialogSpy = spyOn( component, - "closeDeleteDialog", + "closeDeleteDialog" ).and.callThrough(); component.confirmDelete(); @@ -507,11 +507,11 @@ describe("JobsListComponent", () => { const loadJobsSpy = spyOn(component, "loadJobs").and.stub(); const closeDeleteDialogSpy = spyOn( component, - "closeDeleteDialog", + "closeDeleteDialog" ).and.callThrough(); component.selectedJobs.set([mockJob.id, secondJob.id]); mockJobsService.bulkDeleteJobs.and.returnValue( - of({ deleted: [mockJob.id], failed: {} }), + of({ deleted: [mockJob.id], failed: {} }) ); component.confirmDelete(); @@ -531,7 +531,7 @@ describe("JobsListComponent", () => { const loadJobsSpy = spyOn(component, "loadJobs").and.stub(); component.selectedJobs.set([mockJob.id, secondJob.id]); mockJobsService.bulkDeleteJobs.and.returnValue( - of({ deleted: [mockJob.id], failed: { [secondJob.id]: "failed" } }), + of({ deleted: [mockJob.id], failed: { [secondJob.id]: "failed" } }) ); component.confirmDelete(); @@ -543,7 +543,7 @@ describe("JobsListComponent", () => { it("should handle delete request failures", () => { mockJobsService.bulkDeleteJobs.and.returnValue( - throwError(() => new Error("delete failed")), + throwError(() => new Error("delete failed")) ); component.selectedJobs.set([mockJob.id]); diff --git a/src/app/features/jobs/jobs-list/jobs-list.ts b/src/app/features/jobs/jobs-list/jobs-list.ts index dc548e20..e85a2e2d 100644 --- a/src/app/features/jobs/jobs-list/jobs-list.ts +++ b/src/app/features/jobs/jobs-list/jobs-list.ts @@ -151,7 +151,7 @@ export default class JobsListComponent implements OnInit, OnDestroy { catchError((err) => { console.error("Error checking system health:", err); return EMPTY; - }), + }) ) .subscribe((health) => { const unhealthy = health.overallStatus !== "healthy"; @@ -189,7 +189,7 @@ export default class JobsListComponent implements OnInit, OnDestroy { this.error.set("Failed to load jobs. Please try again."); this.loading.set(false); return EMPTY; - }), + }) ) .subscribe((response) => { const normalizedJobs = response.jobs.map((job) => { @@ -303,7 +303,7 @@ export default class JobsListComponent implements OnInit, OnDestroy { get totalFinalDesigns(): number { return this.jobs().reduce( (sum, job) => sum + (job.finalDesignCount ?? 0), - 0, + 0 ); } @@ -319,7 +319,7 @@ export default class JobsListComponent implements OnInit, OnDestroy { toggleSubmittedSort(): void { if (this.activeSort() === "submitted") { this.submittedSortDirection.update((d) => - d === "desc" ? "asc" : "desc", + d === "desc" ? "asc" : "desc" ); } else { this.activeSort.set("submitted"); @@ -473,7 +473,7 @@ export default class JobsListComponent implements OnInit, OnDestroy { if (Object.keys(response.failed || {}).length > 0) { const failedCount = Object.keys(response.failed).length; this.error.set( - `Failed to delete ${failedCount} job${failedCount > 1 ? "s" : ""}.`, + `Failed to delete ${failedCount} job${failedCount > 1 ? "s" : ""}.` ); } this.selectedJobs.set([]); diff --git a/src/app/features/workflows/de-novo-design/de-novo-design.spec.ts b/src/app/features/workflows/de-novo-design/de-novo-design.spec.ts index 785e6e8b..098971cf 100644 --- a/src/app/features/workflows/de-novo-design/de-novo-design.spec.ts +++ b/src/app/features/workflows/de-novo-design/de-novo-design.spec.ts @@ -2,7 +2,7 @@ import { signal } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { provideHttpClient } from "@angular/common/http"; import { provideHttpClientTesting } from "@angular/common/http/testing"; -import { Observable, of } from "rxjs"; +import { Observable, of, throwError } from "rxjs"; import { AuthService } from "../../../core/services/auth.service"; import { CreditsService } from "../../../core/services/credits.service"; import { DatasetUploadService } from "../services/dataset-upload.service"; @@ -19,6 +19,10 @@ function requiredField(name: string): InputSchemaField { return { name } as unknown as InputSchemaField; } +function field(partial: Partial): InputSchemaField { + return { name: "unnamed", type: "string", ...partial } as InputSchemaField; +} + function rowWith(values: Record): InputRow { return { id: "row1", values }; } @@ -40,6 +44,16 @@ describe("DeNovoDesignComponent", () => { updateRowValue: jasmine.Spy; inputSchemaService: { validateFieldValue: jasmine.Spy }; }; + let datasetUpload: jasmine.SpyObj; + let pdbUpload: jasmine.SpyObj; + let workflowSubmission: { + isSubmitting: ReturnType>; + showSuccessDialog: ReturnType>; + successDialogData: ReturnType>; + submitWorkflowWithDataset: jasmine.Spy; + goToJobs: jasmine.Spy; + }; + let credits: jasmine.SpyObj; beforeEach(async () => { schemaLoader = { @@ -55,9 +69,27 @@ describe("DeNovoDesignComponent", () => { .and.returnValue({}), getFirstRowValues: jasmine .createSpy("getFirstRowValues") - .and.returnValue({}), - getRowValue: jasmine.createSpy("getRowValue").and.returnValue(""), - updateRowValue: jasmine.createSpy("updateRowValue"), + .and.callFake(() => { + const rows = schemaLoader.inputRows(); + return rows.length ? { ...rows[0].values } : {}; + }), + getRowValue: jasmine + .createSpy("getRowValue") + .and.callFake((rowId: string, name: string) => { + const row = schemaLoader.inputRows().find((r) => r.id === rowId); + return row?.values[name] ?? ""; + }), + updateRowValue: jasmine + .createSpy("updateRowValue") + .and.callFake((rowId: string, name: string, value: unknown) => { + schemaLoader.inputRows.update((rows) => + rows.map((r) => + r.id === rowId + ? { ...r, values: { ...r.values, [name]: value } } + : r + ) + ); + }), inputSchemaService: { validateFieldValue: jasmine .createSpy("validateFieldValue") @@ -73,52 +105,47 @@ describe("DeNovoDesignComponent", () => { login: jasmine.Spy; } = { isAuthenticated$: of(true), - isLoading$: of(true), + isLoading$: of(false), canExecuteWorkflows$: of(true), profileUrl: "https://example.com/profile", login: jasmine.createSpy("login"), }; - const workflowSubmissionService = { + workflowSubmission = { isSubmitting: signal(false), showSuccessDialog: signal(false), - successDialogData: signal(null), + successDialogData: signal(null), submitWorkflowWithDataset: jasmine.createSpy("submitWorkflowWithDataset"), goToJobs: jasmine.createSpy("goToJobs"), }; + credits = jasmine.createSpyObj("CreditsService", [ + "getWorkflowCredits", + "getMyCredit", + ]); + credits.getWorkflowCredits.and.returnValue(of({ workflows: [] })); + credits.getMyCredit.and.returnValue(of({ userId: "u1", credit: 100 })); + + datasetUpload = jasmine.createSpyObj( + "DatasetUploadService", + ["uploadDataset"] + ); + pdbUpload = jasmine.createSpyObj("PdbUploadService", [ + "validatePdbFile", + "uploadPdbFile", + ]); + await TestBed.configureTestingModule({ imports: [DeNovoDesignComponent], providers: [ provideHttpClient(), provideHttpClientTesting(), { provide: AuthService, useValue: authService }, - { - provide: CreditsService, - useValue: jasmine.createSpyObj("CreditsService", [ - "getWorkflowCredits", - "getMyCredit", - ]), - }, + { provide: CreditsService, useValue: credits }, { provide: SchemaLoaderService, useValue: schemaLoader }, - { - provide: DatasetUploadService, - useValue: jasmine.createSpyObj( - "DatasetUploadService", - ["uploadDataset"] - ), - }, - { - provide: PdbUploadService, - useValue: jasmine.createSpyObj("PdbUploadService", [ - "validatePdbFile", - "uploadPdbFile", - ]), - }, - { - provide: WorkflowSubmissionService, - useValue: workflowSubmissionService, - }, + { provide: DatasetUploadService, useValue: datasetUpload }, + { provide: PdbUploadService, useValue: pdbUpload }, + { provide: WorkflowSubmissionService, useValue: workflowSubmission }, ], }).compileComponents(); @@ -130,6 +157,43 @@ describe("DeNovoDesignComponent", () => { expect(component).toBeTruthy(); }); + describe("tool selection", () => { + it("keeps BindCraft selected and reports the initial label", () => { + expect(component.selectedTool()).toBe("bindcraft"); + expect(component.isToolSelected("bindcraft")).toBe(true); + expect(component.selectedToolLabel()).toBe("BindCraft"); + expect(component.selectedToolData()?.id).toBe("bindcraft"); + expect(component.selectedToolHasParams()).toBe(false); + }); + + it("blocks selecting an unavailable tool and shows an error", () => { + component.selectTool("rfdiffusion"); + expect(component.selectedTool()).toBe("bindcraft"); + expect(component.showAlert()).toBe(true); + expect(component.alertMessage()).toContain("not available"); + }); + + it("allows selecting the available tool", () => { + component.selectTool("bindcraft"); + expect(component.selectedTool()).toBe("bindcraft"); + expect(component.isToolAvailable("bindcraft")).toBe(true); + expect(component.isToolAvailable("rfdiffusion")).toBe(false); + }); + }); + + describe("job name errors", () => { + it("reports no error when untouched", () => { + expect(component.hasJobNameError()).toBe(false); + }); + + it("reports an error message once touched and invalid", () => { + component.form.controls.jobName.setValue("1bad"); + component.form.controls.jobName.markAsTouched(); + expect(component.hasJobNameError()).toBe(true); + expect(component.getJobNameError().length).toBeGreaterThan(0); + }); + }); + describe("isFormValid (derived)", () => { beforeEach(() => { schemaLoader.requiredInputFields.set([requiredField("starting_pdb")]); @@ -152,6 +216,15 @@ describe("DeNovoDesignComponent", () => { expect(component.isFormValid()).toBe(false); }); + it("ignores binder_name and id required fields", () => { + schemaLoader.requiredInputFields.set([ + requiredField("binder_name"), + requiredField("id"), + ]); + schemaLoader.inputRows.set([rowWith({})]); + expect(component.isFormValid()).toBe(true); + }); + it("is false when a field-level error is present", () => { component.formErrors.set({ row1_chains: "Invalid chain" }); expect(component.isFormValid()).toBe(false); @@ -162,4 +235,625 @@ describe("DeNovoDesignComponent", () => { expect(component.isFormValid()).toBe(false); }); }); + + describe("config panel resizing", () => { + it("resizes with the divider drag", () => { + const start = new MouseEvent("mousedown", { clientX: 400 }); + component.onDividerMouseDown(start); + expect(component.isDragging()).toBe(true); + + // Dragging left grows the panel (delta = startX - clientX). + document.dispatchEvent(new MouseEvent("mousemove", { clientX: 350 })); + expect(component.panelWidth()).toBe(component.defaultPanelWidth + 50); + + document.dispatchEvent(new MouseEvent("mouseup")); + expect(component.isDragging()).toBe(false); + }); + + it("ignores drag start when the panel is collapsed", () => { + component.panelWidth.set(0); + component.onDividerMouseDown( + new MouseEvent("mousedown", { clientX: 10 }) + ); + expect(component.isDragging()).toBe(false); + }); + + it("resizes with the keyboard and clamps to bounds", () => { + component.panelWidth.set(300); + component.onDividerKeydown(new KeyboardEvent("keydown", { key: "Home" })); + expect(component.panelWidth()).toBe(component.maxPanelWidth); + + component.onDividerKeydown(new KeyboardEvent("keydown", { key: "End" })); + expect(component.panelWidth()).toBe(component.minPanelWidth); + + const before = component.panelWidth(); + component.onDividerKeydown(new KeyboardEvent("keydown", { key: "Tab" })); + expect(component.panelWidth()).toBe(before); + }); + + it("ignores keyboard resize when collapsed", () => { + component.panelWidth.set(0); + component.onDividerKeydown( + new KeyboardEvent("keydown", { key: "ArrowLeft" }) + ); + expect(component.panelWidth()).toBe(0); + }); + }); + + describe("PDB file handling", () => { + beforeEach(() => { + schemaLoader.inputSchemaFields.set([field({ name: "starting_pdb" })]); + schemaLoader.inputRows.set([rowWith({})]); + }); + + it("rejects an invalid PDB file", () => { + pdbUpload.validatePdbFile.and.returnValue({ + valid: false, + error: "bad file", + }); + component.onPdbFilePicked(new File([""], "x.pdb"), "row1"); + expect(component.showAlert()).toBe(true); + expect(component.localPdbFile()).toBeNull(); + }); + + it("accepts a valid PDB file", () => { + pdbUpload.validatePdbFile.and.returnValue({ valid: true }); + const file = new File(["data"], "target.pdb"); + component.onPdbFilePicked(file, "row1"); + expect(component.localPdbFile()).toBe(file); + expect(component.getRowValue("row1", "starting_pdb")).toBe("target.pdb"); + }); + + it("clears structure-derived fields when replacing an existing file", () => { + pdbUpload.validatePdbFile.and.returnValue({ valid: true }); + component.localPdbFile.set(new File(["old"], "old.pdb")); + component.programmaticViewerSelection.set("A10"); + component.onPdbFilePicked(new File(["new"], "new.pdb"), "row1"); + expect(component.programmaticViewerSelection()).toBe(""); + }); + + it("clears the local PDB state", () => { + component.localPdbFile.set(new File(["x"], "x.pdb")); + component.pdbResidueMap.set(new Map([["A", new Set([1])]])); + component.clearLocalPdb("row1"); + expect(component.localPdbFile()).toBeNull(); + expect(component.pdbResidueMap()).toBeNull(); + }); + + it("stores the detected residue map, or null when empty", () => { + const map = new Map([["A", new Set([1, 2])]]); + component.onStructureResiduesDetected(map); + expect(component.pdbResidueMap()).toBe(map); + component.onStructureResiduesDetected(new Map()); + expect(component.pdbResidueMap()).toBeNull(); + }); + }); + + describe("row field validation", () => { + beforeEach(() => { + schemaLoader.inputSchemaFields.set([ + field({ name: "chains" }), + field({ name: "target_hotspot_residues" }), + ]); + schemaLoader.inputRows.set([rowWith({})]); + }); + + it("passes valid chains present in the PDB", () => { + component.pdbResidueMap.set(new Map([["A", new Set([1])]])); + component.updateRowValueWithValidation("row1", "chains", "A"); + expect(component.getRowFieldError("row1", "chains")).toBeNull(); + }); + + it("rejects a chain with invalid characters", () => { + component.updateRowValueWithValidation("row1", "chains", "A1"); + expect(component.hasRowFieldError("row1", "chains")).toBe(true); + }); + + it("rejects a chain not found in the PDB", () => { + component.pdbResidueMap.set(new Map([["A", new Set([1])]])); + component.updateRowValueWithValidation("row1", "chains", "B"); + expect(component.getRowFieldError("row1", "chains")).toContain( + "not found in PDB" + ); + }); + + it("rejects a hotspot chain not listed in chains", () => { + component.pdbResidueMap.set( + new Map([ + ["A", new Set([1])], + ["B", new Set([12])], + ]) + ); + component.updateRowValue("row1", "target_hotspot_residues", "B12"); + component.updateRowValueWithValidation("row1", "chains", "A"); + expect(component.getRowFieldError("row1", "chains")).toContain( + "Target Hotspot Residues" + ); + }); + + it("passes a valid hotspot residue", () => { + component.pdbResidueMap.set(new Map([["A", new Set([56])]])); + component.updateRowValueWithValidation( + "row1", + "target_hotspot_residues", + "A56" + ); + expect( + component.getRowFieldError("row1", "target_hotspot_residues") + ).toBeNull(); + }); + + it("rejects a malformed hotspot token", () => { + component.updateRowValueWithValidation( + "row1", + "target_hotspot_residues", + "zzz" + ); + expect( + component.getRowFieldError("row1", "target_hotspot_residues") + ).toContain("Invalid format"); + }); + + it("rejects a hotspot chain missing from the PDB", () => { + component.pdbResidueMap.set(new Map([["A", new Set([56])]])); + component.updateRowValueWithValidation( + "row1", + "target_hotspot_residues", + "B12" + ); + expect( + component.getRowFieldError("row1", "target_hotspot_residues") + ).toContain("not found in PDB"); + }); + + it("rejects a hotspot residue missing from its chain", () => { + component.pdbResidueMap.set(new Map([["A", new Set([56])]])); + component.updateRowValueWithValidation( + "row1", + "target_hotspot_residues", + "A99" + ); + expect( + component.getRowFieldError("row1", "target_hotspot_residues") + ).toContain("Residue 99"); + }); + + it("rejects a hotspot range end missing from its chain", () => { + component.pdbResidueMap.set(new Map([["A", new Set([12])]])); + component.updateRowValueWithValidation( + "row1", + "target_hotspot_residues", + "A12-A14" + ); + expect( + component.getRowFieldError("row1", "target_hotspot_residues") + ).toContain("End residue 14"); + }); + + it("sets an error when the schema validator rejects the value", () => { + schemaLoader.inputSchemaService.validateFieldValue.and.returnValue({ + valid: false, + errors: ["required"], + }); + component.updateRowValueWithValidation("row1", "chains", "A"); + expect(component.getRowFieldError("row1", "chains")).toBe("required"); + }); + + it("reports config-section errors including the job name", () => { + component.form.controls.jobName.setValue("1bad"); + component.form.controls.jobName.markAsTouched(); + expect(component.hasConfigSectionErrors("row1")).toBe(true); + }); + }); + + describe("field-driven handlers", () => { + beforeEach(() => { + schemaLoader.inputSchemaFields.set([ + field({ name: "chains" }), + field({ name: "target_hotspot_residues" }), + field({ name: "min_length", type: "number" }), + field({ name: "max_length", type: "number" }), + ]); + schemaLoader.inputRows.set([rowWith({})]); + }); + + it("derives chains and pushes viewer selection on manual hotspot change", () => { + component.pdbResidueMap.set( + new Map([ + ["A", new Set([56])], + ["B", new Set([12])], + ]) + ); + component.onHotspotResiduesManualChange("row1", "A56,B12"); + expect(component.getRowValue("row1", "chains")).toBe("A,B"); + expect(component.programmaticViewerSelection()).toBe("A56,B12"); + }); + + it("auto-fills chains when residues are selected in the viewer", () => { + component.pdbResidueMap.set(new Map([["A", new Set([56])]])); + component.onResiduesSelected("row1", "A56"); + expect(component.getRowValue("row1", "chains")).toBe("A"); + }); + + it("stores detected chains", () => { + component.pdbResidueMap.set(new Map([["A", new Set([1])]])); + component.onChainsDetected("row1", "A"); + expect(component.getRowValue("row1", "chains")).toBe("A"); + }); + + it("flags a structure that is too small", () => { + component.onSequenceLengthDetected(10); + expect(component.getRowFieldError("row1", "starting_pdb")).toContain( + "Minimum 50" + ); + }); + + it("flags a structure that is too large", () => { + component.onSequenceLengthDetected(400); + expect(component.getRowFieldError("row1", "starting_pdb")).toContain( + "Maximum 300" + ); + }); + + it("clears the length error for an in-range structure", () => { + component.onSequenceLengthDetected(10); + component.onSequenceLengthDetected(150); + expect(component.getRowFieldError("row1", "starting_pdb")).toBeNull(); + }); + + it("updates min and max on length range change", () => { + component.onLengthRangeChange("row1", { min: 60, max: 120 }); + expect(component.getRowValue("row1", "min_length")).toBe(60); + expect(component.getRowValue("row1", "max_length")).toBe(120); + }); + + it("captures the selected input file name", () => { + const file = new File(["x"], "seq.fasta"); + component.onFileSelected({ + target: { files: [file] }, + } as unknown as Event); + expect(component.inputFileName()).toBe("seq.fasta"); + }); + }); + + describe("form data handlers", () => { + beforeEach(() => { + schemaLoader.inputSchemaFields.set([ + field({ name: "text_field", type: "string" }), + field({ name: "num_field", type: "number" }), + field({ name: "bool_field", type: "boolean" }), + ]); + }); + + it("updates and validates a field value", () => { + component.updateFieldValue("text_field", "hello"); + expect(component.formData()["text_field"]).toBe("hello"); + }); + + it("handles text, number, select and boolean inputs", () => { + component.onInputChange("text_field", { + target: { value: "abc" }, + } as unknown as Event); + component.onNumberChange("num_field", { + target: { value: "42" }, + } as unknown as Event); + component.onSelectChange("text_field", { + target: { value: "opt" }, + } as unknown as Event); + component.onBooleanChange("bool_field", { + target: { value: "true" }, + } as unknown as Event); + + expect(component.formData()["num_field"]).toBe(42); + expect(component.formData()["text_field"]).toBe("opt"); + expect(component.formData()["bool_field"]).toBe(true); + }); + + it("stores a chosen file on file change", () => { + const file = new File(["x"], "f.pdb"); + component.onFileChange("text_field", { + target: { files: [file] }, + } as unknown as Event); + expect(component.formData()["text_field"]).toBe(file); + }); + + it("records a validation error for an invalid field", () => { + schemaLoader.inputSchemaService.validateFieldValue.and.returnValue({ + valid: false, + errors: ["bad value"], + }); + component.updateFieldValue("text_field", "x"); + expect(component.formErrors()["text_field"]).toBe("bad value"); + }); + + it("ignores unknown fields on validation", () => { + component.validateSingleField("does_not_exist"); + expect(component.formErrors()["does_not_exist"]).toBeUndefined(); + }); + }); + + describe("getFormData", () => { + it("merges optional field defaults with current form data", () => { + schemaLoader.optionalInputFields.set([ + field({ name: "already", type: "string" }), + field({ name: "str", type: "string" }), + field({ name: "num", type: "number", validation: { min: 7 } }), + field({ name: "flag", type: "boolean" }), + field({ name: "arr", type: "array" }), + field({ name: "obj", type: "object" }), + field({ name: "preset", type: "string", default: "def" }), + ]); + component.formData.set({ already: "kept" }); + + const data = component.getFormData(); + expect(data["already"]).toBe("kept"); + expect(data["str"]).toBe(""); + expect(data["num"]).toBe(7); + expect(data["flag"]).toBe(false); + expect(data["arr"]).toEqual([]); + expect(data["obj"]).toEqual({}); + expect(data["preset"]).toBe("def"); + }); + }); + + describe("formSummary and configuration", () => { + beforeEach(() => { + schemaLoader.inputSchemaFields.set([ + field({ name: "starting_pdb", type: "string", label: "Target PDB" }), + field({ name: "flag", type: "boolean", label: "Flag" }), + field({ name: "count", type: "number", label: "Count" }), + field({ name: "settings_filters", type: "string" }), + ]); + schemaLoader.requiredInputFields.set([requiredField("starting_pdb")]); + component.form.controls.jobName.setValue("job-1"); + }); + + it("builds a summary excluding hidden fields and formatting values", () => { + component.formData.set({ + starting_pdb: "https://host/path/model.pdb", + flag: true, + count: 5, + settings_filters: "hidden", + }); + + const summary = component.formSummary(); + const byField = new Map(summary.map((s) => [s.fieldName, s])); + + expect(byField.get("id")?.value).toBe("job-1"); + expect(byField.get("settings_filters")).toBeUndefined(); + expect(byField.get("flag")?.value).toBe("Yes"); + expect(byField.get("count")?.value).toBe("5"); + expect(byField.get("starting_pdb")?.value).toBe("model.pdb"); + expect(byField.get("starting_pdb")?.url).toBe( + "https://host/path/model.pdb" + ); + }); + + it("prefers the local file name for the starting PDB", () => { + component.formData.set({ starting_pdb: "" }); + component.localPdbFile.set(new File(["x"], "local.pdb")); + const summary = component.formSummary(); + const pdb = summary.find((s) => s.fieldName === "starting_pdb"); + expect(pdb?.value).toBe("local.pdb"); + }); + + it("summarizes the configuration", () => { + const cfg = component.getConfigurationSummary(); + expect(cfg.tool).toBe("BindCraft"); + expect(cfg.totalFields).toBe(4); + expect(cfg.requiredFields).toBe(1); + }); + }); + + describe("row number values and credit cost", () => { + beforeEach(() => { + schemaLoader.inputRows.set([rowWith({})]); + }); + + it("reads numeric, numeric-string and fallback values", () => { + component.updateRowValue("row1", "n", 5); + expect(component.getRowNumberValue("row1", "n", 0)).toBe(5); + component.updateRowValue("row1", "n", "7"); + expect(component.getRowNumberValue("row1", "n", 0)).toBe(7); + component.updateRowValue("row1", "n", "abc"); + expect(component.getRowNumberValue("row1", "n", 3)).toBe(3); + }); + + it("computes credit cost from multiplier and design count", () => { + component["toolMultipliers"].set({ bindcraft: 10 }); + component.updateRowValue("row1", "number_of_final_designs", 2); + expect(component.creditCost()).toBe(20); + }); + + it("returns null credit cost when no multiplier is set", () => { + component["toolMultipliers"].set({}); + expect(component.creditCost()).toBeNull(); + }); + + it("returns null credit cost when there are no rows", () => { + schemaLoader.inputRows.set([]); + component["toolMultipliers"].set({ bindcraft: 10 }); + expect(component.creditCost()).toBeNull(); + }); + }); + + describe("section validity and validation summary", () => { + it("marks input-config and review by form validity, others always valid", () => { + schemaLoader.requiredInputFields.set([]); + schemaLoader.inputRows.set([rowWith({})]); + component.form.controls.jobName.setValue("job-1"); + expect(component.isSectionValid("input-config")).toBe(true); + expect(component.isSectionValid("select-tool")).toBe(true); + expect(component.isSectionValid("tool-settings")).toBe(true); + }); + + it("summarizes form validation state", () => { + schemaLoader.inputRows.set([rowWith({})]); + component.formErrors.set({ row1_chains: "err" }); + const summary = component.getFormValidationSummary(); + expect(summary.errorCount).toBe(1); + expect(summary.rowCount).toBe(1); + }); + }); + + describe("loadInputSchema", () => { + it("seeds form data and slider bounds on success", () => { + schemaLoader.inputRows.set([rowWith({})]); + schemaLoader.generateDefaultValues.and.returnValue({ + max_length: 250, + min_length: 40, + }); + schemaLoader.loadInputSchema.and.callFake( + (_url: string, onSuccess: () => void) => onSuccess() + ); + schemaLoader.initializeDefaultRow.and.callFake((cb: () => void) => cb()); + + component.loadInputSchema(); + + expect(component.pdbSequenceLength()).toBe(250); + expect(component.pdbSequenceMin()).toBe(40); + expect(schemaLoader.updateRowValue).toHaveBeenCalledWith( + "row1", + "number_of_final_designs", + 1 + ); + }); + + it("logs on failure", () => { + const errorSpy = spyOn(console, "error"); + schemaLoader.loadInputSchema.and.callFake( + (_url: string, _onSuccess: () => void, onError: (e: unknown) => void) => + onError(new Error("boom")) + ); + component.loadInputSchema(); + expect(errorSpy).toHaveBeenCalled(); + }); + }); + + describe("submission", () => { + beforeEach(() => { + schemaLoader.inputSchemaFields.set([field({ name: "starting_pdb" })]); + schemaLoader.inputRows.set([rowWith({})]); + component.form.controls.jobName.setValue("job-1"); + }); + + it("uploads the PDB then the dataset then launches the workflow", () => { + component.localPdbFile.set(new File(["data"], "target.pdb")); + pdbUpload.uploadPdbFile.and.returnValue( + of({ message: "", success: true, s3Uri: "s3://bucket/target.pdb" }) + ); + datasetUpload.uploadDataset.and.returnValue( + of({ message: "", success: true, s3Key: "key-123" }) + ); + + component["performSubmit"](); + + expect(pdbUpload.uploadPdbFile).toHaveBeenCalled(); + expect(datasetUpload.uploadDataset).toHaveBeenCalled(); + expect(workflowSubmission.submitWorkflowWithDataset).toHaveBeenCalled(); + }); + + it("falls back to the file name when the upload returns no URI", () => { + component.localPdbFile.set(new File(["data"], "target.pdb")); + pdbUpload.uploadPdbFile.and.returnValue( + of({ message: "", success: true }) + ); + datasetUpload.uploadDataset.and.returnValue( + of({ message: "", success: true, s3Key: "key-123" }) + ); + + component["performSubmit"](); + + expect(datasetUpload.uploadDataset).toHaveBeenCalled(); + }); + + it("surfaces a workflow launch failure after dataset upload", () => { + datasetUpload.uploadDataset.and.returnValue( + of({ message: "", success: true, s3Key: "key-123" }) + ); + workflowSubmission.submitWorkflowWithDataset.and.callFake( + ( + _payload: unknown, + _key: string, + onError: (e: { message?: string }) => void + ) => onError({}) + ); + + component["performSubmit"](); + + expect(component.showAlert()).toBe(true); + }); + + it("validates all required fields, skipping binder_name and id", () => { + schemaLoader.inputSchemaFields.set([field({ name: "starting_pdb" })]); + schemaLoader.requiredInputFields.set([ + requiredField("binder_name"), + requiredField("id"), + requiredField("starting_pdb"), + ]); + component["validateAll"](); + expect( + schemaLoader.inputSchemaService.validateFieldValue + ).toHaveBeenCalled(); + }); + + it("surfaces an error if the PDB upload fails", () => { + component.localPdbFile.set(new File(["data"], "target.pdb")); + pdbUpload.uploadPdbFile.and.returnValue( + throwError(() => new Error("upload failed")) + ); + + component["performSubmit"](); + + expect(component.showAlert()).toBe(true); + expect(workflowSubmission.isSubmitting()).toBe(false); + }); + + it("submits directly when no local PDB is staged", () => { + datasetUpload.uploadDataset.and.returnValue( + of({ message: "", success: true, s3Key: "key-123" }) + ); + component["performSubmit"](); + expect(pdbUpload.uploadPdbFile).not.toHaveBeenCalled(); + expect(workflowSubmission.submitWorkflowWithDataset).toHaveBeenCalled(); + }); + + it("shows an error when the dataset upload returns no key", () => { + datasetUpload.uploadDataset.and.returnValue( + of({ message: "", success: true }) + ); + component["performSubmit"](); + expect(component.showAlert()).toBe(true); + expect( + workflowSubmission.submitWorkflowWithDataset + ).not.toHaveBeenCalled(); + }); + + it("shows an error when the dataset upload fails", () => { + datasetUpload.uploadDataset.and.returnValue( + throwError(() => new Error("dataset failed")) + ); + component["performSubmit"](); + expect(component.showAlert()).toBe(true); + }); + + it("validateAll marks the form touched and validates rows", () => { + component["validateAll"](); + expect(component.form.controls.jobName.touched).toBe(true); + }); + + it("resets the form to schema defaults", () => { + schemaLoader.generateDefaultValues.and.returnValue({ foo: "bar" }); + component.resetForm(); + expect(component.formData()["number_of_final_designs"]).toBe(1); + }); + }); + + describe("lifecycle", () => { + it("loads the schema on init and tears down on destroy", () => { + component.ngOnInit(); + expect(schemaLoader.loadInputSchema).toHaveBeenCalled(); + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + }); }); diff --git a/src/app/features/workflows/services/schema-loader.service.spec.ts b/src/app/features/workflows/services/schema-loader.service.spec.ts new file mode 100644 index 00000000..e51f5b88 --- /dev/null +++ b/src/app/features/workflows/services/schema-loader.service.spec.ts @@ -0,0 +1,232 @@ +import { TestBed } from "@angular/core/testing"; +import { of, throwError } from "rxjs"; +import { SchemaLoaderService } from "./schema-loader.service"; +import { + InputSchemaField, + InputSchemaService, + ParsedInputSchema, +} from "./input-schema.service"; + +function field(partial: Partial): InputSchemaField { + return { name: "unnamed", type: "string", ...partial } as InputSchemaField; +} + +function schemaWithFields(fields: InputSchemaField[]): ParsedInputSchema { + return { sections: [{ name: "main", fields }] }; +} + +describe("SchemaLoaderService", () => { + let service: SchemaLoaderService; + let inputSchemaService: jasmine.SpyObj; + + beforeEach(() => { + inputSchemaService = jasmine.createSpyObj( + "InputSchemaService", + [ + "fetchInputSchema", + "parseInputSchema", + "getRequiredFields", + "generateDefaultValues", + ] + ); + + TestBed.configureTestingModule({ + providers: [ + SchemaLoaderService, + { provide: InputSchemaService, useValue: inputSchemaService }, + ], + }); + service = TestBed.inject(SchemaLoaderService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + describe("loadInputSchema", () => { + const rawSchema = { items: { properties: { a: {} } } }; + + it("populates signals and invokes onSuccess when the schema is valid", () => { + const required = field({ name: "id", required: true }); + const optional = field({ name: "note", required: false }); + const parsed = schemaWithFields([required, optional]); + + inputSchemaService.fetchInputSchema.and.returnValue(of(rawSchema)); + inputSchemaService.parseInputSchema.and.returnValue(of(parsed)); + inputSchemaService.getRequiredFields.and.returnValue([required]); + + const onSuccess = jasmine.createSpy("onSuccess"); + const onError = jasmine.createSpy("onError"); + service.loadInputSchema("http://schema", onSuccess, onError); + + expect(service.inputSchemaData()).toBe(parsed); + expect(service.inputSchemaFields()).toEqual([required, optional]); + expect(service.requiredInputFields()).toEqual([required]); + expect(service.optionalInputFields()).toEqual([optional]); + expect(onSuccess).toHaveBeenCalledWith(parsed); + expect(onError).not.toHaveBeenCalled(); + }); + + it("invokes onError when the schema lacks items/properties", () => { + inputSchemaService.fetchInputSchema.and.returnValue(of({ items: {} })); + const onError = jasmine.createSpy("onError"); + + service.loadInputSchema("http://schema", undefined, onError); + + expect(onError).toHaveBeenCalled(); + expect(inputSchemaService.parseInputSchema).not.toHaveBeenCalled(); + expect(service.inputSchemaData()).toBeNull(); + }); + + it("invokes onError when parsing fails", () => { + inputSchemaService.fetchInputSchema.and.returnValue(of(rawSchema)); + inputSchemaService.parseInputSchema.and.returnValue( + throwError(() => new Error("parse failed")) + ); + const onError = jasmine.createSpy("onError"); + + service.loadInputSchema("http://schema", undefined, onError); + + expect(onError).toHaveBeenCalled(); + }); + + it("invokes onError when fetching fails", () => { + inputSchemaService.fetchInputSchema.and.returnValue( + throwError(() => new Error("network")) + ); + const onError = jasmine.createSpy("onError"); + + service.loadInputSchema("http://schema", undefined, onError); + + expect(onError).toHaveBeenCalled(); + }); + }); + + describe("initializeDefaultRow", () => { + it("creates one row with type-based defaults", () => { + service.inputSchemaData.set( + schemaWithFields([ + field({ name: "s", type: "string" }), + field({ name: "n", type: "number", validation: { min: 5 } }), + field({ name: "n0", type: "number" }), + field({ name: "b", type: "boolean" }), + field({ name: "arr", type: "array" }), + field({ name: "obj", type: "object" }), + field({ name: "f", type: "file" }), + field({ name: "d", type: "string", default: "preset" }), + ]) + ); + + const onComplete = jasmine.createSpy("onComplete"); + service.initializeDefaultRow(onComplete); + + const rows = service.inputRows(); + expect(rows.length).toBe(1); + expect(rows[0].values).toEqual({ + s: "", + n: 5, + n0: 0, + b: false, + arr: [], + obj: {}, + f: "", + d: "preset", + }); + expect(service.nextRowId()).toBe(2); + expect(onComplete).toHaveBeenCalled(); + }); + + it("does nothing when a row already exists", () => { + service.inputSchemaData.set(schemaWithFields([field({ name: "s" })])); + service.initializeDefaultRow(); + service.initializeDefaultRow(); + expect(service.inputRows().length).toBe(1); + }); + + it("warns and adds no row when the schema is not loaded", () => { + const warn = spyOn(console, "warn"); + service.initializeDefaultRow(); + expect(service.inputRows().length).toBe(0); + expect(warn).toHaveBeenCalled(); + }); + }); + + describe("generateDefaultValues", () => { + it("returns an empty object when no schema is loaded", () => { + expect(service.generateDefaultValues()).toEqual({}); + expect(inputSchemaService.generateDefaultValues).not.toHaveBeenCalled(); + }); + + it("delegates to the input schema service when a schema is loaded", () => { + const parsed = schemaWithFields([field({ name: "s" })]); + service.inputSchemaData.set(parsed); + inputSchemaService.generateDefaultValues.and.returnValue({ s: "x" }); + + expect(service.generateDefaultValues()).toEqual({ s: "x" }); + expect(inputSchemaService.generateDefaultValues).toHaveBeenCalledWith( + parsed + ); + }); + }); + + describe("row value accessors", () => { + beforeEach(() => { + service.inputRows.set([{ id: "row1", values: { field1: "value1" } }]); + }); + + it("updates a row value", () => { + service.updateRowValue("row1", "field1", "updated"); + expect(service.getRowValue("row1", "field1")).toBe("updated"); + }); + + it("leaves other rows untouched when updating", () => { + service.inputRows.set([ + { id: "row1", values: { a: "1" } }, + { id: "row2", values: { a: "2" } }, + ]); + service.updateRowValue("row1", "a", "changed"); + expect(service.getRowValue("row2", "a")).toBe("2"); + }); + + it("returns an empty string for an unknown row or field", () => { + expect(service.getRowValue("missing", "field1")).toBe(""); + expect(service.getRowValue("row1", "missing")).toBe(""); + }); + }); + + describe("getFirstRowValues", () => { + it("returns an empty object when there are no rows", () => { + expect(service.getFirstRowValues()).toEqual({}); + }); + + it("adds defaults for optional fields not already present", () => { + service.inputRows.set([{ id: "row1", values: { present: "kept" } }]); + service.optionalInputFields.set([ + field({ name: "present", type: "string" }), + field({ name: "extra", type: "number", validation: { min: 3 } }), + ]); + + const values = service.getFirstRowValues(); + expect(values["present"]).toBe("kept"); + expect(values["extra"]).toBe(3); + }); + }); + + it("reset clears all state", () => { + service.inputSchemaData.set(schemaWithFields([field({ name: "s" })])); + service.inputSchemaFields.set([field({ name: "s" })]); + service.requiredInputFields.set([field({ name: "s" })]); + service.optionalInputFields.set([field({ name: "s" })]); + service.inputRows.set([{ id: "row1", values: {} }]); + service.nextRowId.set(9); + + service.reset(); + + expect(service.inputSchemaData()).toBeNull(); + expect(service.inputSchemaFields()).toEqual([]); + expect(service.requiredInputFields()).toEqual([]); + expect(service.optionalInputFields()).toEqual([]); + expect(service.inputRows()).toEqual([]); + expect(service.nextRowId()).toBe(1); + }); +}); From 3f4dd518491659f793f0b4bb0e87ad7ce42b48d2 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 8 Jul 2026 13:29:49 +1000 Subject: [PATCH 6/9] fix: update default title in WorkflowPreviewModalComponent to 'Review & Submit' --- .../workflow-preview-modal.component.spec.ts | 4 ++-- .../workflow-preview-modal.component.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/features/workflows/components/workflow-preview-modal/workflow-preview-modal.component.spec.ts b/src/app/features/workflows/components/workflow-preview-modal/workflow-preview-modal.component.spec.ts index 947f35cf..98f92592 100644 --- a/src/app/features/workflows/components/workflow-preview-modal/workflow-preview-modal.component.spec.ts +++ b/src/app/features/workflows/components/workflow-preview-modal/workflow-preview-modal.component.spec.ts @@ -25,13 +25,13 @@ describe("WorkflowPreviewModalComponent", () => { expect(component).toBeTruthy(); }); - it("should default the title to 'Workflow Preview'", () => { + it("should default the title to 'Review & Submit'", () => { fixture.componentRef.setInput("isOpen", true); fixture.detectChanges(); const heading = fixture.nativeElement.querySelector( "#workflow-preview-title" ); - expect(heading?.textContent?.trim()).toBe("Workflow Preview"); + expect(heading?.textContent?.trim()).toBe("Review & Submit"); }); it("should label the submit button with the credit cost", () => { diff --git a/src/app/features/workflows/components/workflow-preview-modal/workflow-preview-modal.component.ts b/src/app/features/workflows/components/workflow-preview-modal/workflow-preview-modal.component.ts index f170195c..ef413038 100644 --- a/src/app/features/workflows/components/workflow-preview-modal/workflow-preview-modal.component.ts +++ b/src/app/features/workflows/components/workflow-preview-modal/workflow-preview-modal.component.ts @@ -23,7 +23,7 @@ import { }) export class WorkflowPreviewModalComponent { readonly isOpen = input(false); - readonly title = input("Workflow Preview"); + readonly title = input("Review & Submit"); readonly credits = input(null); readonly isSubmitting = input(false); From ac20201b53f46af2ad721a16fe0bc1ec78693c3c Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 8 Jul 2026 15:01:36 +1000 Subject: [PATCH 7/9] style: update stikcy styling --- .../features/jobs/jobs-list/jobs-list.html | 178 +++++++++--------- 1 file changed, 91 insertions(+), 87 deletions(-) diff --git a/src/app/features/jobs/jobs-list/jobs-list.html b/src/app/features/jobs/jobs-list/jobs-list.html index de9770ec..735ed56a 100644 --- a/src/app/features/jobs/jobs-list/jobs-list.html +++ b/src/app/features/jobs/jobs-list/jobs-list.html @@ -22,7 +22,7 @@

My Jobs

-

View and manage my submitted workflow runs

+

View and manage submitted workflow runs

@if (!authLoading()) { @if (!canExecuteWorkflows()) { @@ -65,97 +65,101 @@

My Jobs

} @else { -
-
-
- -
- -
- - + + +
-
- -
-
-

Filter

- - - - - - @for (status of statusOptions; track status) { - - } - - -
+ + + + + @for (status of statusOptions; track status) { + + } + + +
- - + + +
From 41351bd1558fa3a62e64218689786e6778ba0a71 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 8 Jul 2026 15:48:33 +1000 Subject: [PATCH 8/9] fix: remove trailing periods from input error messages in validation functions --- .../de-novo-design/de-novo-design.ts | 18 +++---- .../interaction-screening.spec.ts | 2 +- .../interaction-screening.ts | 2 +- .../workflows/shared/fasta.utils.spec.ts | 32 ++++++------ .../features/workflows/shared/fasta.utils.ts | 52 +++++++++---------- .../shared/job-name.validators.spec.ts | 6 +-- .../workflows/shared/job-name.validators.ts | 6 +-- .../single-prediction.spec.ts | 2 +- .../single-prediction/single-prediction.ts | 12 ++--- 9 files changed, 66 insertions(+), 66 deletions(-) diff --git a/src/app/features/workflows/de-novo-design/de-novo-design.ts b/src/app/features/workflows/de-novo-design/de-novo-design.ts index 48d360cd..86e6b04f 100644 --- a/src/app/features/workflows/de-novo-design/de-novo-design.ts +++ b/src/app/features/workflows/de-novo-design/de-novo-design.ts @@ -354,7 +354,7 @@ export default class DeNovoDesignComponent for (const token of tokens) { if (!/^[A-Za-z]+$/.test(token)) { - return `Invalid chain "${token}". Use letter identifiers only, e.g. "A" or "A,B".`; + return `Invalid chain "${token}". Use letter identifiers only, e.g. "A" or "A,B"`; } } @@ -366,7 +366,7 @@ export default class DeNovoDesignComponent ...residueMap.keys(), ] .sort() - .join(", ")}.`; + .join(", ")}`; } } } @@ -379,7 +379,7 @@ export default class DeNovoDesignComponent .split(",") .filter(Boolean)) { if (!chainsSet.has(hChain)) { - return `Chain "${hChain}" is used in Target Hotspot Residues but not listed in Chains.`; + return `Chain "${hChain}" is used in Target Hotspot Residues but not listed in Chains`; } } } @@ -396,7 +396,7 @@ export default class DeNovoDesignComponent .filter(Boolean)) { const parsed = MolstarViewerComponent.parseResidueToken(token); if (!parsed) { - return `Invalid format "${token}". Use chain+residue notation, e.g. "A56" or "A12-A14".`; + return `Invalid format "${token}". Use chain+residue notation, e.g. "A56" or "A12-A14"`; } if (!residueMap) continue; @@ -406,16 +406,16 @@ export default class DeNovoDesignComponent ...residueMap.keys(), ] .sort() - .join(", ")}.`; + .join(", ")}`; } if (!chainResidues.has(parsed.resStart)) { - return `Residue ${parsed.resStart} not found in chain "${parsed.chain}".`; + return `Residue ${parsed.resStart} not found in chain "${parsed.chain}"`; } if ( parsed.resStart !== parsed.resEnd && !chainResidues.has(parsed.resEnd) ) { - return `End residue ${parsed.resEnd} not found in chain "${parsed.chain}".`; + return `End residue ${parsed.resEnd} not found in chain "${parsed.chain}"`; } } return null; @@ -453,12 +453,12 @@ export default class DeNovoDesignComponent if (count < 50) { this.formErrors.set({ ...currentErrors, - [errorKey]: `Structure has only ${count} residue(s). Minimum 50 residues required.`, + [errorKey]: `Structure has only ${count} residue(s). Minimum 50 residues required`, }); } else if (count > 300) { this.formErrors.set({ ...currentErrors, - [errorKey]: `Structure has ${count} residues. Maximum 300 residues allowed.`, + [errorKey]: `Structure has ${count} residues. Maximum 300 residues allowed`, }); } else { const updated = { ...currentErrors }; diff --git a/src/app/features/workflows/interaction-screening/interaction-screening.spec.ts b/src/app/features/workflows/interaction-screening/interaction-screening.spec.ts index bfc1bf00..2fe7907e 100644 --- a/src/app/features/workflows/interaction-screening/interaction-screening.spec.ts +++ b/src/app/features/workflows/interaction-screening/interaction-screening.spec.ts @@ -279,7 +279,7 @@ describe("InteractionScreeningComponent", () => { component.form.controls.jobName.setValue(""); expect(component.hasJobNameError()).toBe(true); - expect(component.getJobNameError()).toBe("Job Name is required."); + expect(component.getJobNameError()).toBe("Job Name is required"); }); it("should show job name maxlength error when touched and too long", () => { diff --git a/src/app/features/workflows/interaction-screening/interaction-screening.ts b/src/app/features/workflows/interaction-screening/interaction-screening.ts index 31fab4ba..22ad4c30 100644 --- a/src/app/features/workflows/interaction-screening/interaction-screening.ts +++ b/src/app/features/workflows/interaction-screening/interaction-screening.ts @@ -268,7 +268,7 @@ export default class InteractionScreeningComponent extends WorkflowPageBase { if (!err) return ""; return `Too many sequence combinations: ${ err.actual - } pairs (query × target). The maximum is ${err.max - 1}.`; + } pairs (query × target). The maximum is ${err.max - 1}`; } hasDuplicateSequencesError(): boolean { diff --git a/src/app/features/workflows/shared/fasta.utils.spec.ts b/src/app/features/workflows/shared/fasta.utils.spec.ts index ddeb42d3..22f34707 100644 --- a/src/app/features/workflows/shared/fasta.utils.spec.ts +++ b/src/app/features/workflows/shared/fasta.utils.spec.ts @@ -20,28 +20,28 @@ describe("fasta.utils", () => { it("rejects an empty name", () => { expect(validateFastaHeader(" ")).toEqual({ valid: false, - errorMessage: "Sequence name is required.", + errorMessage: "Sequence name is required", }); }); it("rejects names containing spaces", () => { expect(validateFastaHeader("seq 1")).toEqual({ valid: false, - errorMessage: "Sequence name must not contain spaces.", + errorMessage: "Sequence name must not contain spaces", }); }); it("rejects names containing underscores", () => { expect(validateFastaHeader("seq_1")).toEqual({ valid: false, - errorMessage: "Sequence name must not contain underscores.", + errorMessage: "Sequence name must not contain underscores", }); }); it("rejects a name that duplicates an existing name", () => { expect(validateFastaHeader("seq1", ["seq1", "seq2"])).toEqual({ valid: false, - errorMessage: "Sequence name must be unique.", + errorMessage: "Sequence name must be unique", }); }); @@ -58,7 +58,7 @@ describe("fasta.utils", () => { it("rejects empty protein input", () => { expect(validateProteinSequence(" \n\t ")).toEqual({ valid: false, - errorMessage: "Protein sequence is required.", + errorMessage: "Protein sequence is required", }); }); @@ -66,7 +66,7 @@ describe("fasta.utils", () => { expect(validateProteinSequence("ATG123")).toEqual({ valid: false, errorMessage: - "Protein sequence must contain only the 20 canonical amino acid letters.", + "Protein sequence must contain only the 20 canonical amino acid letters", }); }); }); @@ -79,7 +79,7 @@ describe("fasta.utils", () => { it("rejects empty DNA input", () => { expect(validateDnaSequence("")).toEqual({ valid: false, - errorMessage: "DNA sequence is required.", + errorMessage: "DNA sequence is required", }); }); @@ -87,7 +87,7 @@ describe("fasta.utils", () => { expect(validateDnaSequence("AUGC")).toEqual({ valid: false, errorMessage: - "DNA sequence must use valid DNA characters only (A, T, G, C).", + "DNA sequence must use valid DNA characters only (A, T, G, C)", }); }); }); @@ -100,7 +100,7 @@ describe("fasta.utils", () => { it("rejects empty RNA input", () => { expect(validateRnaSequence("")).toEqual({ valid: false, - errorMessage: "RNA sequence is required.", + errorMessage: "RNA sequence is required", }); }); @@ -108,7 +108,7 @@ describe("fasta.utils", () => { expect(validateRnaSequence("ATGC")).toEqual({ valid: false, errorMessage: - "RNA sequence must use valid RNA characters only (A, U, G, C).", + "RNA sequence must use valid RNA characters only (A, U, G, C)", }); }); }); @@ -131,7 +131,7 @@ describe("fasta.utils", () => { it("returns an error for an unsupported code", () => { expect(lookupCcdCompound("XYZ")).toEqual({ valid: false, - errorMessage: '"XYZ" is not in the supported CCD list.', + errorMessage: '"XYZ" is not in the supported CCD list', }); }); @@ -148,7 +148,7 @@ describe("fasta.utils", () => { expect(lookupCcdCompound("AT!")).toEqual({ valid: false, errorMessage: - "Ligand CCD code must be 1–5 alphanumeric characters (e.g. ATP, HEM).", + "Ligand CCD code must be 1–5 alphanumeric characters (e.g. ATP, HEM)", }); }); @@ -222,7 +222,7 @@ describe("fasta.utils", () => { it("rejects empty input", () => { expect(validateMultiFastaProtein("")).toEqual({ valid: false, - errorMessage: "At least one FASTA sequence is required.", + errorMessage: "At least one FASTA sequence is required", sequenceCount: 0, }); }); @@ -230,7 +230,7 @@ describe("fasta.utils", () => { it("rejects whitespace-only input", () => { expect(validateMultiFastaProtein(" \n ")).toEqual({ valid: false, - errorMessage: "At least one FASTA sequence is required.", + errorMessage: "At least one FASTA sequence is required", sequenceCount: 0, }); }); @@ -256,7 +256,7 @@ describe("fasta.utils", () => { it("rejects an entry with no sequence after the header", () => { expect(validateMultiFastaProtein(">seq1\n")).toEqual({ valid: false, - errorMessage: 'No sequence found for header "seq1".', + errorMessage: 'No sequence found for header "seq1"', sequenceCount: 0, }); }); @@ -306,7 +306,7 @@ describe("fasta.utils", () => { it("rejects spaces within sequence content", () => { expect(validateMultiFastaProtein(">seq1\nACD EFG HIK LMN")).toEqual({ valid: false, - errorMessage: 'Sequence for "seq1" must not contain spaces.', + errorMessage: 'Sequence for "seq1" must not contain spaces', sequenceCount: 0, }); }); diff --git a/src/app/features/workflows/shared/fasta.utils.ts b/src/app/features/workflows/shared/fasta.utils.ts index bc770fa4..13e869d5 100644 --- a/src/app/features/workflows/shared/fasta.utils.ts +++ b/src/app/features/workflows/shared/fasta.utils.ts @@ -21,22 +21,22 @@ export function validateFastaHeader( ): SequenceValidationResult { const trimmed = name.trim(); if (!trimmed) { - return { valid: false, errorMessage: "Sequence name is required." }; + return { valid: false, errorMessage: "Sequence name is required" }; } if (/\s/.test(trimmed)) { return { valid: false, - errorMessage: "Sequence name must not contain spaces.", + errorMessage: "Sequence name must not contain spaces", }; } if (trimmed.includes("_")) { return { valid: false, - errorMessage: "Sequence name must not contain underscores.", + errorMessage: "Sequence name must not contain underscores", }; } if (existingNames?.some((n) => n.trim() === trimmed)) { - return { valid: false, errorMessage: "Sequence name must be unique." }; + return { valid: false, errorMessage: "Sequence name must be unique" }; } return { valid: true }; } @@ -106,8 +106,8 @@ const CANONICAL_AA_REGEX = /^[ARNDCQEGHILKMFPSTWYV]+$/; */ export const validateProteinSequence = createSequenceValidator( CANONICAL_AA_REGEX, - "Protein sequence is required.", - "Protein sequence must contain only the 20 canonical amino acid letters." + "Protein sequence is required", + "Protein sequence must contain only the 20 canonical amino acid letters" ); /** @@ -125,7 +125,7 @@ export function validateMultiFastaProtein( if (!trimmed) { return { valid: false, - errorMessage: "At least one FASTA sequence is required.", + errorMessage: "At least one FASTA sequence is required", sequenceCount: 0, }; } @@ -168,7 +168,7 @@ export function validateMultiFastaProtein( if (headers.has(header)) { return { valid: false, - errorMessage: `Duplicate FASTA header: "${header}". All headers must be unique.`, + errorMessage: `Duplicate FASTA header: "${header}". All headers must be unique`, sequenceCount: 0, }; } @@ -184,7 +184,7 @@ export function validateMultiFastaProtein( if (!sequence) { return { valid: false, - errorMessage: `No sequence found for header "${header}".`, + errorMessage: `No sequence found for header "${header}"`, sequenceCount: 0, }; } @@ -192,7 +192,7 @@ export function validateMultiFastaProtein( if (sequenceLines.some((l) => /\s/.test(l))) { return { valid: false, - errorMessage: `Sequence for "${header}" must not contain spaces.`, + errorMessage: `Sequence for "${header}" must not contain spaces`, sequenceCount: 0, }; } @@ -200,7 +200,7 @@ export function validateMultiFastaProtein( if (!CANONICAL_AA_REGEX.test(sequence)) { return { valid: false, - errorMessage: `Sequence for "${header}" contains invalid characters. Only canonical amino acids (ARNDCQEGHILKMFPSTWYV) are allowed.`, + errorMessage: `Sequence for "${header}" contains invalid characters. Only canonical amino acids (ARNDCQEGHILKMFPSTWYV) are allowed`, sequenceCount: 0, }; } @@ -247,20 +247,20 @@ export function validateUniqueHeadersAcrossInputs( duplicates.length > 1 ? "s" : "" } must be unique across query and target. Duplicate${ duplicates.length > 1 ? "s" : "" - }: ${duplicates.map((h) => `"${h}"`).join(", ")}.`, + }: ${duplicates.map((h) => `"${h}"`).join(", ")}`, }; } export const validateDnaSequence = createSequenceValidator( /^[ATGC]+$/, - "DNA sequence is required.", - "DNA sequence must use valid DNA characters only (A, T, G, C)." + "DNA sequence is required", + "DNA sequence must use valid DNA characters only (A, T, G, C)" ); export const validateRnaSequence = createSequenceValidator( /^[AUGC]+$/, - "RNA sequence is required.", - "RNA sequence must use valid RNA characters only (A, U, G, C)." + "RNA sequence is required", + "RNA sequence must use valid RNA characters only (A, U, G, C)" ); export function lookupCcdCompound(code: string): CcdLookupResult { @@ -270,7 +270,7 @@ export function lookupCcdCompound(code: string): CcdLookupResult { return { valid: false, errorMessage: - "Ligand CCD code must be 1–5 alphanumeric characters (e.g. ATP, HEM).", + "Ligand CCD code must be 1–5 alphanumeric characters (e.g. ATP, HEM)", }; } @@ -281,7 +281,7 @@ export function lookupCcdCompound(code: string): CcdLookupResult { return { valid: false, - errorMessage: `"${normalized}" is not in the supported CCD list.`, + errorMessage: `"${normalized}" is not in the supported CCD list`, }; } @@ -310,7 +310,7 @@ export function validateBulkFastaProtein( if (!trimmed) { return { valid: false, - errorMessage: "At least one FASTA sequence is required.", + errorMessage: "At least one FASTA sequence is required", sequenceCount: 0, }; } @@ -329,7 +329,7 @@ export function validateBulkFastaProtein( if (blocks.length > MAX_BULK_ENTRIES) { return { valid: false, - errorMessage: `Too many FASTA entries: ${blocks.length}. The maximum allowed is ${MAX_BULK_ENTRIES}.`, + errorMessage: `Too many FASTA entries: ${blocks.length}. The maximum allowed is ${MAX_BULK_ENTRIES}`, sequenceCount: blocks.length, }; } @@ -362,7 +362,7 @@ export function validateBulkFastaProtein( if (headers.has(header)) { return { valid: false, - errorMessage: `Duplicate FASTA header: "${header}". All headers must be unique.`, + errorMessage: `Duplicate FASTA header: "${header}". All headers must be unique`, sequenceCount: 0, }; } @@ -378,7 +378,7 @@ export function validateBulkFastaProtein( if (!rawSequence) { return { valid: false, - errorMessage: `No sequence found for header "${header}".`, + errorMessage: `No sequence found for header "${header}"`, sequenceCount: 0, }; } @@ -386,7 +386,7 @@ export function validateBulkFastaProtein( if (sequenceLines.some((l) => /\s/.test(l))) { return { valid: false, - errorMessage: `Sequence for "${header}" must not contain spaces.`, + errorMessage: `Sequence for "${header}" must not contain spaces`, sequenceCount: 0, }; } @@ -394,7 +394,7 @@ export function validateBulkFastaProtein( if (!BULK_FASTA_REGEX.test(rawSequence)) { return { valid: false, - errorMessage: `Sequence for "${header}" contains invalid characters. Only canonical amino acids (ARNDCQEGHILKMFPSTWYV) and the ":" multimer delimiter are allowed.`, + errorMessage: `Sequence for "${header}" contains invalid characters. Only canonical amino acids (ARNDCQEGHILKMFPSTWYV) and the ":" multimer delimiter are allowed`, sequenceCount: 0, }; } @@ -402,7 +402,7 @@ export function validateBulkFastaProtein( if (/^:|:$|::/.test(rawSequence)) { return { valid: false, - errorMessage: `Sequence for "${header}" has invalid ":" placement. The colon must separate chains and cannot appear at the start, end, or consecutively (e.g. SEQUENCE1:SEQUENCE2).`, + errorMessage: `Sequence for "${header}" has invalid ":" placement. The colon must separate chains and cannot appear at the start, end, or consecutively (e.g. SEQUENCE1:SEQUENCE2)`, sequenceCount: 0, }; } @@ -411,7 +411,7 @@ export function validateBulkFastaProtein( if (aaLength > MAX_AA_LENGTH) { return { valid: false, - errorMessage: `Sequence for "${header}" has ${aaLength} amino-acid characters, exceeding the maximum of ${MAX_AA_LENGTH}.`, + errorMessage: `Sequence for "${header}" has ${aaLength} amino-acid characters, exceeding the maximum of ${MAX_AA_LENGTH}`, sequenceCount: 0, }; } diff --git a/src/app/features/workflows/shared/job-name.validators.spec.ts b/src/app/features/workflows/shared/job-name.validators.spec.ts index 4e88e565..ccd7a548 100644 --- a/src/app/features/workflows/shared/job-name.validators.spec.ts +++ b/src/app/features/workflows/shared/job-name.validators.spec.ts @@ -59,7 +59,7 @@ describe("jobNameErrorMessage", () => { it("returns required message", () => { expect(jobNameErrorMessage({ required: true })).toBe( - "Job Name is required." + "Job Name is required" ); }); @@ -68,14 +68,14 @@ describe("jobNameErrorMessage", () => { jobNameErrorMessage({ maxlength: { requiredLength: 60, actualLength: 61 }, }) - ).toBe("Job Name must be 60 characters or fewer."); + ).toBe("Job Name must be 60 characters or fewer"); }); it("returns pattern message", () => { expect( jobNameErrorMessage({ pattern: { requiredPattern: "", actualValue: "" } }) ).toBe( - "Job Name may only contain letters, numbers, hyphens, and underscores, and must not start with a number." + "Job Name may only contain letters, numbers, hyphens, and underscores, and must not start with a number" ); }); diff --git a/src/app/features/workflows/shared/job-name.validators.ts b/src/app/features/workflows/shared/job-name.validators.ts index c1702131..dcb5e63f 100644 --- a/src/app/features/workflows/shared/job-name.validators.ts +++ b/src/app/features/workflows/shared/job-name.validators.ts @@ -8,9 +8,9 @@ export const JOB_NAME_VALIDATORS = [ export function jobNameErrorMessage(errors: ValidationErrors | null): string { if (!errors) return ""; - if (errors["required"]) return "Job Name is required."; - if (errors["maxlength"]) return "Job Name must be 60 characters or fewer."; + if (errors["required"]) return "Job Name is required"; + if (errors["maxlength"]) return "Job Name must be 60 characters or fewer"; if (errors["pattern"]) - return "Job Name may only contain letters, numbers, hyphens, and underscores, and must not start with a number."; + return "Job Name may only contain letters, numbers, hyphens, and underscores, and must not start with a number"; return ""; } diff --git a/src/app/features/workflows/single-prediction/single-prediction.spec.ts b/src/app/features/workflows/single-prediction/single-prediction.spec.ts index 831138bc..46dce25e 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.spec.ts +++ b/src/app/features/workflows/single-prediction/single-prediction.spec.ts @@ -325,7 +325,7 @@ describe("SinglePredictionComponent", () => { component["validateSequenceByMoleculeType"]("ABC", "other" as never) ).toEqual({ valid: false, - errorMessage: "Sequence format is invalid.", + errorMessage: "Sequence format is invalid", }); }); diff --git a/src/app/features/workflows/single-prediction/single-prediction.ts b/src/app/features/workflows/single-prediction/single-prediction.ts index 4f5e712b..144fbcdf 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.ts +++ b/src/app/features/workflows/single-prediction/single-prediction.ts @@ -644,7 +644,7 @@ export default class SinglePredictionComponent extends WorkflowPageBase { const copyNumber = Number.parseInt(row.copyNumber, 10); if (!Number.isInteger(copyNumber) || copyNumber < 1) { errors.copyNumber = - "Copy number must be a whole number greater than or equal to 1."; + "Copy number must be a whole number greater than or equal to 1"; } if ( @@ -652,7 +652,7 @@ export default class SinglePredictionComponent extends WorkflowPageBase { this.selectedTool() === "alphafold2") && row.moleculeType !== "protein" ) { - errors.tool = `${this.selectedToolLabel()} accepts protein-only input.`; + errors.tool = `${this.selectedToolLabel()} accepts protein-only input`; } return errors; @@ -664,7 +664,7 @@ export default class SinglePredictionComponent extends WorkflowPageBase { if (!Number.isInteger(value) || value < 0) { return { alphafold2RandomSeed: - "alphafold2_random_seed must be a whole number greater than or equal to 0.", + "alphafold2_random_seed must be a whole number greater than or equal to 0", }; } } @@ -674,7 +674,7 @@ export default class SinglePredictionComponent extends WorkflowPageBase { if (!Number.isInteger(value) || value < 1) { return { colabfoldNumRecycles: - "colabfold_num_recycles must be a whole number greater than or equal to 1.", + "colabfold_num_recycles must be a whole number greater than or equal to 1", }; } } @@ -865,7 +865,7 @@ export default class SinglePredictionComponent extends WorkflowPageBase { ? { valid: true } : { valid: false, - errorMessage: "Ligand sequence must be a valid SMILES string.", + errorMessage: "Ligand sequence must be a valid SMILES string", }; case "ccd": return { valid: true }; @@ -873,7 +873,7 @@ export default class SinglePredictionComponent extends WorkflowPageBase { return { valid: false, - errorMessage: "Sequence format is invalid.", + errorMessage: "Sequence format is invalid", }; } } From ed6dd268173ad4471be5f35f62d0ce1ad9e8ee0d Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 8 Jul 2026 16:01:03 +1000 Subject: [PATCH 9/9] fix: adjust sequence name label and improve textarea styling in single prediction workflow --- .../single-prediction/single-prediction.html | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/app/features/workflows/single-prediction/single-prediction.html b/src/app/features/workflows/single-prediction/single-prediction.html index f223fe87..054e4233 100644 --- a/src/app/features/workflows/single-prediction/single-prediction.html +++ b/src/app/features/workflows/single-prediction/single-prediction.html @@ -163,9 +163,9 @@ [for]="'sequence-name-' + row.id" class="mb-1 block text-sm font-medium" > - Sequence Name * + Sequence Name + - - @if (shouldShowRowFieldError(row, 'name') && getRowErrors(i).name) {