real rough conversion of old webui working add module and a little bit of routes no processor stuff yet

This commit is contained in:
Joel Wetzell
2026-02-12 17:06:37 -06:00
commit 3435e340a8
46 changed files with 11788 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners()],
};
View File
+30
View File
@@ -0,0 +1,30 @@
<div class="h-screen max-h-screen flex flex-col">
<mat-toolbar color="primary" class="w-full">
<span>showbridge</span>
<span class="flex-auto"></span>
<button mat-icon-button matTooltip="Download Config" (click)="downloadConfig()">
<mat-icon>download</mat-icon>
</button>
@if (configService.pendingConfigIsValid()) {
<button mat-icon-button matTooltip="Apply Config" (click)="applyConfig()">
<mat-icon>save</mat-icon>
</button>
} @else {
<div
class="flex items-center justify-center text-red-500 hover:cursor-pointer"
matTooltip="Config Errors"
>
<mat-icon>error</mat-icon>
</div>
}
</mat-toolbar>
<div class="flex-grow h-full overflow-hidden">
@if (configService.currentlyShownConfig() && schemaService.schemasLoaded()) {
<app-config [(config)]="configService.currentlyShownConfig"></app-config>
} @else {
<div class="flex items-center justify-center mt-28">
<mat-spinner></mat-spinner>
</div>
}
</div>
</div>
+23
View File
@@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', async () => {
const fixture = TestBed.createComponent(App);
await fixture.whenStable();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, webui');
});
});
+80
View File
@@ -0,0 +1,80 @@
import { Component, effect, inject, signal } from '@angular/core';
import { SchemaService } from '../services/schema.service';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatProgressSpinnerModule, MatSpinner } from '@angular/material/progress-spinner';
import { ConfigService } from '../services/config.service';
import { MatIconModule } from '@angular/material/icon';
import { TimeagoModule } from 'ngx-timeago';
import { Config } from '../models/config.models';
import { MatMenuModule } from '@angular/material/menu';
import { SettingsService } from '../services/settings.service';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ConfigComponent } from './config/config.component';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { filter } from 'rxjs';
@Component({
selector: 'app-root',
imports: [
ConfigComponent,
MatToolbarModule,
MatProgressSpinnerModule,
MatIconModule,
TimeagoModule,
MatMenuModule,
MatButtonModule,
],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {
public schemaService = inject(SchemaService);
public configService = inject(ConfigService);
private settingsService = inject(SettingsService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
constructor() {
effect(() => {
if (this.schemaService.schemasLoaded()) {
this.configService.loadConfig();
}
});
}
applyConfig() {
console.log('apply config');
}
loadConfig(config: Config) {
this.configService.updateCurrentlyShownConfig(config);
}
downloadConfig() {
const config = this.configService.currentlyShownConfig();
if (config) {
this.downloadJSON(config, 'config.json');
} else {
this.snackBar.open('No config to download.', 'Dismiss', {
duration: 3000,
});
}
}
downloadJSON(data: object, filename: string) {
const content = JSON.stringify(data, null, 2);
const dataUri = URL.createObjectURL(
new Blob([content], {
type: 'text/json;charset=utf-8',
}),
);
const dummyLink = document.createElement('a');
dummyLink.href = dataUri;
dummyLink.download = filename;
document.body.appendChild(dummyLink);
dummyLink.click();
document.body.removeChild(dummyLink);
}
}
@@ -0,0 +1,3 @@
.cdk-drag-placeholder {
opacity: 0;
}
@@ -0,0 +1,48 @@
@if (paramFormControl && paramInfo?.schema) {
@if (arrayValue) {
<div cdkDropList (cdkDropListDropped)="dropItem($event)" [cdkDropListData]="arrayValue">
<!-- <div *ngIf="arrayValue.length === 0" class="w-full h-6"></div> -->
@for (item of arrayValue; track trackByIndex(i, item); let i = $index) {
<div cdkDrag class="flex my-2 mr-2 items-center">
<label for="item-{{ i }}" class="text-gray-300 mr-1 hover:cursor-move" cdkDragHandle
>{{ i }}:</label
>
@if (paramInfo?.schema.items.type === 'object') {
<app-params-form
[paramsSchema]="paramInfo?.schema.items"
[data]="item"
(updated)="updateItem(i, $event)"
></app-params-form>
} @else {
<!-- TODO(jwetzell): add input validation using info from schema -->
<input
id="item-{{ i }}"
class="pl-1 border-2 border-gray-200 border-solid rounded-sm text-gray-200"
[(ngModel)]="arrayValue[i]"
[placeholder]="paramInfo?.placeholder"
[ngModelOptions]="{ standalone: true }"
(input)="valueUpdated()"
/>
}
@if (i >= minItems) {
<div
class="flex items-center justify-center hover:cursor-pointer bg-red-400 rounded-sm ml-1"
matTooltip="Remove"
>
<mat-icon (click)="deleteItem(i)">remove</mat-icon>
</div>
}
</div>
}
</div>
}
@if (!arrayIsMaxed()) {
<div
class="w-full bg-gray-400 flex items-center justify-center rounded-sm hover:cursor-pointer hover:bg-gray-300"
(click)="addItem()"
matTooltip="Add Item"
>
<mat-icon class="scale-100">add</mat-icon>
</div>
}
}
+116
View File
@@ -0,0 +1,116 @@
import { CdkDrag, CdkDragDrop, CdkDropList, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, inject, Input, OnInit } from '@angular/core';
import { ParamInfo } from '../../models/form.model';
import { ListsService } from '../../services/lists.service';
import { SchemaService } from '../../services/schema.service';
import { MatIconModule } from '@angular/material/icon';
import { FormsModule } from '@angular/forms';
import { ParamsFormComponent } from '../params-form/params-form.component';
@Component({
selector: 'app-array-form',
templateUrl: './array-form.component.html',
styleUrl: './array-form.component.css',
imports: [MatIconModule, FormsModule, ParamsFormComponent, CdkDrag, CdkDropList],
standalone: true,
})
export class ArrayFormComponent implements OnInit {
@Input() paramFormControl?: any;
@Input() paramInfo?: ParamInfo;
minItems: number = 0;
maxItems: number = Number.MAX_SAFE_INTEGER;
arrayValue: any[] | undefined;
private schemaService = inject(SchemaService);
public listsService = inject(ListsService);
constructor() {}
ngOnInit(): void {
if (this.paramFormControl && this.paramInfo?.schema) {
if (!Array.isArray(this.paramFormControl.value)) {
this.arrayValue = this.schemaService.parseStringToArray(
this.paramFormControl.value,
this.paramInfo.schema,
);
} else {
this.arrayValue = this.paramFormControl.value;
}
if (this.paramInfo.schema.minItems) {
this.minItems = parseInt(this.paramInfo?.schema.minItems);
}
if (this.paramInfo.schema.maxItems) {
this.maxItems = parseInt(this.paramInfo?.schema.maxItems);
}
}
this.ensureArrayMin();
}
dropItem(event: CdkDragDrop<any | undefined>) {
if (this.arrayValue !== undefined) {
moveItemInArray(this.arrayValue, event.previousIndex, event.currentIndex);
this.valueUpdated();
}
}
deleteItem(index: number) {
this.arrayValue?.splice(index, 1);
this.valueUpdated();
}
ensureArrayMin() {
if (this.arrayValue === undefined) {
this.arrayValue = [];
}
if (this.arrayValue.length < this.minItems) {
for (let i = 0; i <= this.minItems - this.arrayValue.length; i += 1) {
this.arrayValue.push(null);
}
this.valueUpdated();
}
}
arrayIsMaxed() {
if (this.arrayValue) {
return this.arrayValue?.length >= this.maxItems;
}
return false;
}
addItem() {
if (this.arrayValue === undefined) {
this.arrayValue = [];
}
if (!this.arrayIsMaxed()) {
this.arrayValue.push(null);
}
this.valueUpdated();
// this.updated.emit(true);
}
valueUpdated() {
if (this.arrayValue) {
this.paramFormControl.setValue(
this.schemaService.cleanArray(this.arrayValue, this.paramInfo?.schema?.items),
);
}
}
trackByIndex(index: number, obj: any): any {
return index;
}
// NOTE(jwetzel): this is only needed for object item types
updateItem(index: number, value: any) {
if (this.arrayValue) {
this.arrayValue[index] = value;
this.valueUpdated();
}
}
}
View File
+75
View File
@@ -0,0 +1,75 @@
@if (config()) {
<div class="flex h-full flex-row m-2 gap-1.5">
<div class="flex flex-col">
<div class="text-2xl text-white text-center">Modules</div>
<div class="flex-grow h-full overflow-scroll gap-1.5 border-2 border-gray-500">
@for (module of modules(); track module; let i = $index) {
<div>
<app-module
[path]="`modules/${i}`"
(moduleChange)="moduleUpdated(i, $event)"
[module]="module"
(delete)="deleteModule(i)"
></app-module>
</div>
}
</div>
<div class="flex mb-2">
@if (schemaService.moduleTypes.length > 0) {
<div
matTooltip="Add Module"
class="flex items-center justify-center w-full h-10 rounded-lg bg-gray-200 hover:cursor-pointer hover:bg-gray-300 m-2"
[matMenuTriggerFor]="addModuleMenu"
>
<mat-icon>add</mat-icon>
</div>
<mat-menu #addModuleMenu="matMenu">
@for (moduleType of schemaService.moduleTypes; track moduleType) {
<button mat-menu-item (click)="addModule(moduleType.type)">
<span>{{ moduleType.name }}</span>
</button>
}
</mat-menu>
}
</div>
</div>
<div class="flex flex-col flex-grow">
<div class="text-2xl text-white text-center w-full">Routes</div>
<div
class="flex-grow h-full overflow-scroll flex flex-row flex-wrap gap-1.5 content-baseline border-2 border-gray-500"
>
@for (route of routes(); track route; let i = $index) {
<div>
<app-route
[path]="`routes/${i}`"
(routeChange)="routeUpdated(i, $event)"
[route]="route"
(delete)="deleteRoute(i)"
></app-route>
</div>
}
</div>
<div class="flex mb-2">
<div
matTooltip="Add Route"
class="flex items-center justify-center w-full h-10 rounded-lg m-2 bg-gray-200 hover:cursor-pointer hover:bg-gray-300"
(click)="addRoute()"
>
<mat-icon>add</mat-icon>
</div>
</div>
</div>
</div>
}
<ng-template #addModuleMenu>
@if (schemaService.moduleTypes.length > 0) {
<div cdkMenu class="context-menu">
@for (moduleType of schemaService.moduleTypes; track moduleType) {
<button cdkMenuItem class="context-menu-item" (click)="addModule(moduleType.type)">
<span>{{ moduleType.name }}</span>
</button>
}
</div>
}
</ng-template>
+22
View File
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ConfigComponent } from './config.component';
describe('ConfigComponent', () => {
let component: ConfigComponent;
let fixture: ComponentFixture<ConfigComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ConfigComponent],
}).compileComponents();
fixture = TestBed.createComponent(ConfigComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+137
View File
@@ -0,0 +1,137 @@
import { Component, computed, inject, model } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Config, ModuleConfiguration, RouteConfiguration } from '../../models/config.models';
import { SchemaService } from '../../services/schema.service';
import { ModuleComponent } from '../module/module.component';
import { RouteComponent } from '../route/route.component';
import { MatTooltipModule } from '@angular/material/tooltip';
@Component({
selector: 'app-config',
imports: [
MatMenuModule,
MatIconModule,
ModuleComponent,
RouteComponent,
MatButtonModule,
MatTooltipModule,
],
templateUrl: './config.component.html',
styleUrl: './config.component.css',
})
export class ConfigComponent {
config = model<Config>();
modules = computed(() => this.config()?.modules ?? []);
routes = computed(() => this.config()?.routes ?? []);
public schemaService = inject(SchemaService);
private snackBar = inject(MatSnackBar);
deleteModule(index: number) {
this.config.update((config) => {
if (config && config.modules) {
config?.modules?.splice(index, 1);
return {
...config,
modules: config.modules,
};
}
return config;
});
this.snackBar.open('Module Removed', 'Dismiss', {
duration: 3000,
});
}
deleteRoute(index: number) {
this.config.update((config) => {
if (config && config.routes) {
config?.routes?.splice(index, 1);
return {
...config,
routes: config.routes,
};
}
return config;
});
this.snackBar.open('Route Removed', 'Dismiss', {
duration: 3000,
});
}
moduleUpdated(index: number, module: ModuleConfiguration | undefined) {
if (module === undefined) {
console.error('module is undefined, not updating');
return;
}
this.config.update((config) => {
if (config && config.modules) {
config.modules[index].id = module.id;
config.modules[index].type = module.type;
config.modules[index].params = module.params;
return {
...config,
modules: config.modules,
};
}
return config;
});
}
routeUpdated(index: number, route: RouteConfiguration | undefined) {
if (route === undefined) {
console.error('route is undefined, not updating');
return;
}
this.config.update((config) => {
if (config && config.routes) {
config.routes[index].input = route.input;
config.routes[index].output = route.output;
return {
...config,
routes: config.routes,
};
}
return config;
});
}
addModule(moduleType: string) {
const moduleTemplate = this.schemaService.getSkeletonForModule(moduleType);
this.config.update((config) => {
if (config) {
if (!config.modules) {
config.modules = [];
}
config.modules?.push(moduleTemplate);
return {
...config,
modules: config.modules,
};
}
return config;
});
}
addRoute() {
const routeTemplate = this.schemaService.getSkeletonForRoute();
this.config.update((config) => {
if (config) {
if (!config.routes) {
config.routes = [];
}
config.routes?.push(routeTemplate);
return {
...config,
routes: config.routes,
};
}
return config;
});
}
}
View File
+38
View File
@@ -0,0 +1,38 @@
@if (module() && schema()) {
<div class="flex items-start m-2 w-fit">
<div
class="flex flex-col bg-gray-800 border-2 {{
isInError() ? 'border-red-500' : 'border-gray-200'
}} border-solid w-fit h-full"
>
<div class="flex items-center justify-center bg-gray-600">
<div class="flex-grow ml-1 mr-3 text-gray-200">
{{ schema().title }}
</div>
<div class="flex items-center justify-center" (click)="delete.emit()">
<mat-icon class="!text-red-500">close</mat-icon>
</div>
</div>
<div>
<form [formGroup]="formGroup" class="h-full">
<div class="m-2">
<div class="flex items-center m-1">
<label class="mr-2 text-gray-300"> id: </label>
<input
type="text"
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[placeholder]="'module-1'"
[formControlName]="'id'"
/>
</div>
<app-params-form
[paramsSchema]="schema()?.properties?.params"
[data]="params()"
(updated)="paramsUpdated($event)"
></app-params-form>
</div>
</form>
</div>
</div>
</div>
}
+22
View File
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ModuleComponent } from './module.component';
describe('ModuleComponent', () => {
let component: ModuleComponent;
let fixture: ComponentFixture<ModuleComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ModuleComponent],
}).compileComponents();
fixture = TestBed.createComponent(ModuleComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+89
View File
@@ -0,0 +1,89 @@
import { Component, computed, inject, input, model, output } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { ModuleConfiguration } from '../../models/config.models';
import { SchemaService } from '../../services/schema.service';
import { ParamsFormComponent } from '../params-form/params-form.component';
@Component({
selector: 'app-module',
imports: [
MatFormFieldModule,
MatIconModule,
ParamsFormComponent,
ReactiveFormsModule,
MatMenuModule,
],
templateUrl: './module.component.html',
styleUrl: './module.component.css',
})
export class ModuleComponent {
path = input<string>('');
module = model<ModuleConfiguration>();
delete = output<void>();
params = computed(() => this.module()!.params);
id = computed(() => this.module()!.id);
schema = computed(() => {
return this.module()?.type
? this.schemaService.getSchemaForModuleType(this.module()!.type)
: undefined;
});
formGroup: FormGroup = new FormGroup({
id: new FormControl(''),
type: new FormControl(''),
});
private schemaService = inject(SchemaService);
ngOnInit(): void {
this.formGroup.patchValue({
id: this.module()?.id,
type: this.module()?.type,
});
this.formGroup.valueChanges.subscribe((value) => {
this.module.update((module) => {
if (module) {
module.id = value.id;
module.type = value.type;
return {
...module,
id: module.id,
type: module.type,
};
}
return undefined;
});
});
}
paramsUpdated(params: any) {
this.module.update((module) => {
if (module !== undefined) {
module.params = params;
return {
...module,
params: module.params,
};
}
return undefined;
});
}
deleteMe() {
this.delete.emit();
}
isInError(): boolean {
const path = this.path();
if (path) {
return this.schemaService.errorPaths.includes(path);
}
return false;
}
}
@@ -0,0 +1,8 @@
input::placeholder {
color: #d2d2d2;
opacity: 0.5;
}
select {
background-color: #424242;
}
@@ -0,0 +1,127 @@
@if (paramsSchema && paramsFormInfo) {
@if (paramsOptions.length > 1) {
<mat-tab-group
[selectedIndex]="paramsOptionsSelectedIndex"
(selectedTabChange)="paramsOptionsTabSelected($event)"
>
@for (paramsOption of paramsOptions; track paramsOption) {
<mat-tab [label]="paramsOption.display"></mat-tab>
}
</mat-tab-group>
}
<form [formGroup]="paramsFormInfo.formGroup">
<div class="flex flex-col">
@for (key of paramKeys(); track key) {
<div class="flex items-center m-1">
@if (showParam(key) && getParamInfo(key); as paramInfo) {
<label class="mr-2 text-gray-300"> {{ paramInfo.display }}: </label>
@switch (paramInfo.type) {
@case ('boolean') {
<input type="checkbox" [formControlName]="paramInfo.key" />
}
@case ('number') {
@if (!paramInfo.options) {
<input
type="number"
[placeholder]="paramInfo.placeholder"
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[formControlName]="paramInfo.key"
/>
} @else {
<select
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[formControlName]="paramInfo.key"
>
@for (option of paramInfo.options; track option) {
<option [value]="option">
{{ option }}
</option>
}
</select>
}
<mat-icon class="!text-gray-200">123</mat-icon>
}
@case ('integer') {
@if (!paramInfo.options) {
<input
type="number"
[placeholder]="paramInfo.placeholder"
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[formControlName]="paramInfo.key"
/>
} @else {
<select
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[formControlName]="paramInfo.key"
>
@for (option of paramInfo.options; track option) {
<option [value]="option">
{{ option }}
</option>
}
</select>
}
<mat-icon class="!text-gray-200" matTooltip="Number">123</mat-icon>
}
@case ('string') {
@if (!paramInfo.options) {
<input
type="text"
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[placeholder]="paramInfo.placeholder"
[formControlName]="paramInfo.key"
/>
} @else {
<select
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[formControlName]="paramInfo.key"
>
@for (option of paramInfo.options; track option) {
<option [value]="option">
{{ option }}
</option>
}
</select>
}
<mat-icon class="!text-gray-200" matTooltip="String">abc</mat-icon>
}
@case ('array') {
<app-array-form
[paramFormControl]="paramsFormInfo.formGroup.get(paramInfo.key)"
[paramInfo]="paramInfo"
></app-array-form>
<mat-icon class="!text-gray-200" matTooltip="Item list">data_array</mat-icon>
}
@case ('object') {
<!-- TODO(jwetzell): figure out how to display objects-->
<input
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[formControlName]="paramInfo.key"
[placeholder]="paramInfo.placeholder"
/>
<mat-icon class="!text-gray-200" matTooltip="JSON">data_object</mat-icon>
}
@default {
<input
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[formControlName]="paramInfo.key"
/>
}
}
@if (!paramsFormInfo.formGroup.controls[key].valid) {
<!-- TODO(jwetzell): better error displaying -->
<div class="ml-2 text-red-500">
{{ paramsFormInfo.formGroup.controls[key].errors | json }}
</div>
}
<div class="flex items-center justify-center">
<mat-icon [matTooltip]="paramInfo.hint" class="ml-2 !text-gray-200">
help_outline
</mat-icon>
</div>
}
</div>
}
</div>
</form>
}
@@ -0,0 +1,249 @@
import { Component, EventEmitter, inject, Input, OnInit, Output } from '@angular/core';
import { MatTabChangeEvent, MatTabsModule } from '@angular/material/tabs';
import { SomeJSONSchema } from 'ajv/dist/types/json-schema';
import { cloneDeep, has } from 'lodash-es';
import { Subscription } from 'rxjs';
import { ParamInfo, ParamsFormInfo } from '../../models/form.model';
import { SchemaService } from '../../services/schema.service';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { JsonPipe } from '@angular/common';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ArrayFormComponent } from '../array-form/array-form.component';
@Component({
selector: 'app-params-form',
templateUrl: './params-form.component.html',
styleUrls: ['./params-form.component.css'],
imports: [
MatIconModule,
MatTooltipModule,
JsonPipe,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
ArrayFormComponent,
MatTabsModule,
],
standalone: true,
})
export class ParamsFormComponent implements OnInit {
@Input() paramsSchema?: SomeJSONSchema;
@Input() data?: any;
@Output() updated: EventEmitter<any> = new EventEmitter<any>();
patchable: boolean = false;
paramsFormInfo?: ParamsFormInfo;
formGroupSubscription?: Subscription;
paramsOptions: {
display: string;
paramsFormInfo: ParamsFormInfo;
keys: string[];
schema: SomeJSONSchema;
}[] = [];
paramsOptionsSelectedIndex: number = 0;
keysToTemplate: Set<string> = new Set<string>();
patchType: 'midi' | 'network' | undefined;
patchIndex: number = -1;
private schemaService = inject(SchemaService);
constructor() {}
ngOnInit(): void {
if (this.paramsSchema) {
if (this.paramsSchema.properties) {
this.paramsFormInfo = this.schemaService.getFormInfoFromParamsSchema(this.paramsSchema);
} else if (this.paramsSchema.oneOf) {
this.paramsOptions = this.paramsSchema.oneOf.map((oneOf: any) => {
const paramsOption = {
display: oneOf.title,
schema: oneOf,
paramsFormInfo: this.schemaService.getFormInfoFromParamsSchema(oneOf),
};
return {
...paramsOption,
keys: Object.keys(paramsOption.paramsFormInfo.formGroup.controls),
};
});
const matchingSchemaIndex = this.schemaService.matchParamsDataToSchema(
this.data,
this.paramsOptions.map((paramsOption) => paramsOption.schema),
);
this.paramsOptionsSelectedIndex = matchingSchemaIndex;
this.paramsFormInfo = this.paramsOptions[matchingSchemaIndex].paramsFormInfo;
this.paramsSchema = this.paramsOptions[matchingSchemaIndex].schema;
} else {
console.error('params is not a singular or oneOf');
console.error(this.paramsSchema);
}
}
if (this.data && this.paramsFormInfo?.formGroup) {
// NOTE(jwetzell): prepare data for form patching
const dataToPatch = cloneDeep(this.data);
Object.entries(this.paramsFormInfo.paramsInfo).forEach(([paramKey, paramInfo]) => {
if (has(dataToPatch, paramKey)) {
switch (paramInfo.type) {
case 'object':
dataToPatch[paramKey] = JSON.stringify(dataToPatch[paramKey]);
break;
case 'array':
dataToPatch[paramKey] = dataToPatch[paramKey]
.map((item: any) => {
switch (typeof item) {
case 'object':
return JSON.stringify(item);
default:
return item;
}
})
.join(',');
break;
default:
break;
}
if (paramKey === '_port' || paramKey === '_host') {
//NOTE(jwetzell): determine what patch to load for dropdown
const valueIsPatch = dataToPatch[paramKey].match(
/^\${vars.patches.(midi|network)\[(\d+)\].(port|host)}$/,
);
if (valueIsPatch) {
this.patchType = valueIsPatch[1];
try {
this.patchIndex = parseInt(valueIsPatch[2]);
} catch (error) {
console.error('params-form: error decoding patch info');
}
}
}
}
});
//NOTE(jwetzell): initialize keysToTemplate
Object.entries(dataToPatch).forEach(([key, value]) => {
if (key.startsWith('_') && value !== undefined) {
this.keysToTemplate.add(key.substring(1));
}
});
if (has(this.paramsFormInfo.paramsInfo, 'port')) {
this.patchable = true;
if (has(this.paramsFormInfo.paramsInfo, 'host')) {
this.patchType = 'network';
} else {
this.patchType = 'midi';
}
}
this.paramsFormInfo.formGroup.patchValue(dataToPatch);
}
this.formGroupSubscription = this.paramsFormInfo?.formGroup.valueChanges.subscribe((value) => {
this.formUpdated();
});
}
paramsOptionsTabSelected(event: MatTabChangeEvent) {
// NOTE(jwetzell): no longer interested in the old formGroup valueChanges
if (this.formGroupSubscription) {
this.formGroupSubscription.unsubscribe();
}
const paramsOption = this.paramsOptions[event.index];
this.paramsSchema = paramsOption.schema;
this.paramsFormInfo = paramsOption.paramsFormInfo;
// NOTE(jwetzell): prune params that MUST change from the data when switch paramOptions
Object.entries(this.paramsFormInfo.paramsInfo).forEach(([paramKey, paramInfo]) => {
if (paramInfo.isConst && this.data) {
if (this.data[paramKey]) {
delete this.data[paramKey];
}
}
});
const allowedParamKeys = Object.keys(this.paramsSchema?.properties);
if (this.data) {
// NOTE(jwetzell): remove keys that aren't allowed in the new params variation
Object.keys(this.data).forEach((paramKey) => {
if (allowedParamKeys && !allowedParamKeys.includes(paramKey)) {
delete this.data[paramKey];
}
});
}
if (this.paramsFormInfo.formGroup) {
this.formGroupSubscription = this.paramsFormInfo.formGroup.valueChanges.subscribe((value) => {
this.formUpdated();
});
}
if (this.data && this.paramsFormInfo.formGroup) {
this.paramsFormInfo.formGroup.patchValue(this.data);
}
}
formUpdated() {
if (this.paramsSchema) {
const params = this.schemaService.cleanParams(
this.paramsSchema,
this.paramsFormInfo?.formGroup.value,
this.keysToTemplate,
);
this.updated.emit(params);
} else {
console.error('params-form: no paramsSchema loaded');
}
}
paramKeys() {
if (this.paramsFormInfo) {
return Object.keys(this.paramsFormInfo?.formGroup.controls).filter((key) => {
if (this.keysToTemplate.has(key)) {
return false;
}
// NOTE(jwezell): exclude template keys that aren't supposed to be
if (key.startsWith('_') && !this.keysToTemplate.has(key.substring(1))) {
return false;
}
return true;
});
}
return [];
}
getParamInfo(key: string): ParamInfo | undefined {
return this.paramsFormInfo?.paramsInfo[key];
}
showParam(key: string): boolean {
const paramInfo = this.paramsFormInfo?.paramsInfo[key];
if (paramInfo) {
if (paramInfo?.schema?.$ref === '#/definitions/ActionList') {
return false;
}
}
return true;
}
getParamValue(key: string) {
if (this.paramsSchema) {
const params = this.schemaService.cleanParams(
this.paramsSchema,
this.paramsFormInfo?.formGroup.value,
this.keysToTemplate,
);
return params[key];
}
}
}
View File
+49
View File
@@ -0,0 +1,49 @@
@if (route()) {
<div class="flex items-start m-2 w-fit">
<div
class="flex flex-col bg-gray-800 border-2 {{
isInError() ? 'border-red-500' : 'border-gray-200'
}} border-solid w-fit h-full mr-2"
>
<div class="flex items-center justify-end bg-gray-600">
<div class="flex items-center justify-center" (click)="delete.emit()">
<mat-icon class="!text-red-500">close</mat-icon>
</div>
</div>
<div>
<form [formGroup]="formGroup" class="h-full">
<div class="m-2">
<div class="flex items-center m-1">
<label class="mr-2 text-gray-300"> input: </label>
<input
type="text"
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[placeholder]="'module-1'"
[formControlName]="'input'"
/>
@if (!formGroup.controls['input'].valid) {
<!-- TODO(jwetzell): better error displaying -->
<div class="ml-2 text-red-500">{{ formGroup.controls['input'].errors | json }}</div>
}
</div>
<div class="flex items-center m-1">
<label class="mr-2 text-gray-300"> output: </label>
<input
type="text"
class="pl-1 border-2 border-gray-200 text-gray-200 border-solid rounded-sm"
[placeholder]="'module-1'"
[formControlName]="'output'"
/>
@if (!formGroup.controls['output'].valid) {
<!-- TODO(jwetzell): better error displaying -->
<div class="ml-2 text-red-500">
{{ formGroup.controls['output'].errors | json }}
</div>
}
</div>
</div>
</form>
</div>
</div>
</div>
}
+22
View File
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouteComponent } from './route.component';
describe('RouteComponent', () => {
let component: RouteComponent;
let fixture: ComponentFixture<RouteComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RouteComponent],
}).compileComponents();
fixture = TestBed.createComponent(RouteComponent);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});
+57
View File
@@ -0,0 +1,57 @@
import { Component, inject, input, model, output } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { RouteConfiguration } from '../../models/config.models';
import { SchemaService } from '../../services/schema.service';
import { JsonPipe } from '@angular/common';
@Component({
selector: 'app-route',
imports: [MatFormFieldModule, MatIconModule, ReactiveFormsModule, MatMenuModule, JsonPipe],
templateUrl: './route.component.html',
styleUrl: './route.component.css',
})
export class RouteComponent {
path = input<string>('');
route = model<RouteConfiguration>();
delete = output<void>();
formGroup: FormGroup = new FormGroup({
input: new FormControl('', [Validators.required]),
output: new FormControl('', [Validators.required]),
});
private schemaService = inject(SchemaService);
ngOnInit(): void {
this.formGroup.patchValue({
input: this.route()?.input,
output: this.route()?.output,
});
this.formGroup.valueChanges.subscribe((value) => {
this.route.update((route) => {
if (route) {
route.input = value.input;
route.output = value.output;
return {
...route,
input: route.input,
output: route.output,
};
}
return undefined;
});
});
}
isInError(): boolean {
const path = this.path();
if (path) {
return this.schemaService.errorPaths.includes(path);
}
return false;
}
}