web/src/app/dialogs/new-inspection/new-inspection.component.ts (278 lines of code) (raw):
/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Component,
inject,
Inject,
OnDestroy,
signal,
ViewChild,
} from '@angular/core';
import { MatStepper, MatStepperModule } from '@angular/material/stepper';
import {
BehaviorSubject,
Subject,
filter,
map,
shareReplay,
switchMap,
take,
takeUntil,
withLatestFrom,
} from 'rxjs';
import {
InspectionDryRunRequest,
InspectionType,
} from 'src/app/common/schema/api-types';
import { ReactiveFormsModule } from '@angular/forms';
import {
MatDialog,
MatDialogModule,
MatDialogRef,
} from '@angular/material/dialog';
import {
BACKEND_API,
BackendAPI,
} from 'src/app/services/api/backend-api-interface';
import { BACKEND_CONNECTION } from 'src/app/services/api/backend-connection.service';
import { BackendConnectionService } from 'src/app/services/api/backend-connection-interface';
import { MatCardModule } from '@angular/material/card';
import { CommonModule } from '@angular/common';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { KHICommonModule } from 'src/app/common/common.module';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import {
DefaultParameterStore,
PARAMETER_STORE,
} from './components/service/parameter-store';
import {
GroupParameterFormField,
ParameterFormField,
ParameterHintType,
ParameterInputType,
} from 'src/app/common/schema/form-types';
import { GroupParameterComponent } from './components/group-parameter.component';
import {
InspectionMetadataPlan,
InspectionMetadataQuery,
} from 'src/app/common/schema/metadata-types';
import {
EXTENSION_STORE,
ExtensionStore,
} from 'src/app/extensions/extension-common/extension-store';
export interface NewInspectionDialogResult {
inspectionTaskStarted: boolean;
}
export interface ParameterPageViewModel {
rootGroupForm: GroupParameterFormField;
queries: InspectionMetadataQuery[];
plan: InspectionMetadataPlan;
errorFieldCount: number;
fieldCount: number;
}
export function openNewInspectionDialog(dialog: MatDialog) {
return dialog.open(NewInspectionDialogComponent, {
width: '80%',
maxWidth: '1200px',
height: '90%',
});
}
@Component({
templateUrl: './new-inspection.component.html',
styleUrls: ['./new-inspection.component.sass'],
imports: [
CommonModule,
KHICommonModule,
MatButtonModule,
MatInputModule,
MatDialogModule,
MatStepperModule,
MatCardModule,
MatProgressBarModule,
MatIconModule,
ReactiveFormsModule,
MatFormFieldModule,
MatAutocompleteModule,
GroupParameterComponent,
],
providers: [
{
provide: PARAMETER_STORE,
useClass: DefaultParameterStore,
},
],
})
export class NewInspectionDialogComponent implements OnDestroy {
static readonly STEP_INDEX_CLUSTER_TYPE = 0;
static readonly STEP_INDEX_FEATURE_SELECTION = 1;
static readonly STEP_INDEX_PARAMETER_INPUT = 2;
private destroyed = new Subject<void>();
private readonly store = inject(PARAMETER_STORE);
/**
* It's true only when the run button has already pressed.
*/
public hadRun = signal(false);
constructor(
private readonly dialogRef: MatDialogRef<object, NewInspectionDialogResult>,
@Inject(BACKEND_CONNECTION)
private readonly backendConnection: BackendConnectionService,
@Inject(BACKEND_API) private readonly apiClient: BackendAPI,
@Inject(EXTENSION_STORE) private readonly extension: ExtensionStore,
) {
this.featureToggleRequest
.pipe(
takeUntil(this.destroyed),
withLatestFrom(this.featureStatusMap),
map(([featureId, currentFeatures]) => {
return Object.fromEntries([[featureId, !currentFeatures[featureId]]]);
}),
withLatestFrom(this.currentTaskClient),
)
.subscribe(([featureIds, client]) => {
client.setFeatures(featureIds);
});
this.dryrunRequest
.pipe(takeUntil(this.destroyed), withLatestFrom(this.currentTaskClient))
.subscribe(([req, client]) => {
client.dryrun(req);
});
// Send dryrun request to server when any of the parameters changed to validate parameters.
this.store
.watchAll()
.pipe(takeUntil(this.destroyed))
.subscribe((values) => {
this.dryrunRequest.next(values);
});
// Receive the form field parameters and extract default values, then set it to the store.
this.currentDryrunMetadata
.pipe(takeUntil(this.destroyed))
.subscribe((metadata) => {
const defaultValues = this.flattenDefaultValues(metadata.form);
this.store.setDefaultValues(defaultValues);
});
// Event handler reacting to the `Run` button click.
this.startInspectionSubject
.pipe(
takeUntil(this.destroyed),
take(1),
withLatestFrom(this.currentTaskClient, this.store.watchAll()),
switchMap(([, client, parameters]) => client.run(parameters)),
)
.subscribe(() => {
this.extension.notifyLifecycleOnInspectionStart();
this.dialogRef.close({
inspectionTaskStarted: true,
});
});
}
@ViewChild('stepper') private stepper!: MatStepper;
public inspectionTypes = this.backendConnection.inspectionTypes();
public currentInspectionType = new BehaviorSubject<InspectionType | null>(
null,
);
public currentTaskClient = this.currentInspectionType.pipe(
takeUntil(this.destroyed),
filter((type) => !!type),
switchMap((taskType) => this.apiClient.createInspection(taskType!.id)),
shareReplay(1),
);
public currentTaskFeatures = this.currentTaskClient.pipe(
switchMap((tc) => tc.features),
);
/**
* A map of feature id and its status - true if enabled
*/
public featureStatusMap = this.currentTaskFeatures.pipe(
map((features) =>
Object.fromEntries(
features.map((feature) => [feature.id, feature.enabled]),
),
),
);
public featuresEnabled = this.currentTaskFeatures.pipe(
map((features) => features.some((f) => f.enabled)),
);
private featureToggleRequest = new Subject<string>();
private dryrunRequest = new Subject<InspectionDryRunRequest>();
private startInspectionSubject = new Subject<void>();
private currentDryrunMetadata = this.currentTaskClient.pipe(
switchMap((client) => client.dryRunResult),
map((result) => result.metadata),
);
parameterViewModel = this.currentDryrunMetadata.pipe(
map((metadata) => {
const errorFieldCount = this.countErrorFields(metadata.form);
const fieldCount = this.countAllFields(metadata.form);
return {
rootGroupForm: {
type: ParameterInputType.Group,
children: metadata.form,
},
queries: metadata.query,
plan: metadata.plan,
errorFieldCount: errorFieldCount,
fieldCount: fieldCount,
} as ParameterPageViewModel;
}),
);
public setInspectionType(inspectionType: InspectionType) {
this.currentInspectionType.next(inspectionType);
setTimeout(() => {
this.stepper.next();
}, 10);
}
public selectedStepChange(stepIndex: number) {
if (stepIndex === NewInspectionDialogComponent.STEP_INDEX_PARAMETER_INPUT) {
this.dryrunRequest.next({});
}
}
public toggleFeature(featureId: string) {
this.featureToggleRequest.next(featureId);
}
public onRunButtonClick() {
this.hadRun.set(true);
this.startInspectionSubject.next();
}
/**
* Convert the array of form fields to the flatten map of default values.
*/
private flattenDefaultValues(parameters: ParameterFormField[]): {
[key: string]: unknown;
} {
let result: { [key: string]: unknown } = {};
for (const parameter of parameters) {
switch (parameter.type) {
case ParameterInputType.Text:
result[parameter.id] = parameter.default;
break;
case ParameterInputType.Group:
result = {
...result,
...this.flattenDefaultValues(parameter.children),
};
break;
default:
break;
}
}
return result;
}
/**
* Count error fields.
* This ignores Group type form because the group itself isn't a field.
*/
private countErrorFields(parameters: ParameterFormField[]): number {
let result = 0;
for (const parameter of parameters) {
if (parameter.type === ParameterInputType.Group) {
result += this.countErrorFields(parameter.children);
} else if (parameter.hintType === ParameterHintType.Error) {
result++;
}
}
return result;
}
/**
* Count fields.
* This ignores Group type form because the group itself isn't a field.
*/
private countAllFields(parameters: ParameterFormField[]): number {
let result = 0;
for (const parameter of parameters) {
if (parameter.type === ParameterInputType.Group) {
result += this.countAllFields(parameter.children);
} else {
result++;
}
}
return result;
}
ngOnDestroy(): void {
if (this.store instanceof DefaultParameterStore) {
this.store.destroy();
}
this.destroyed.next();
}
}