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
+17
View File
@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false
+46
View File
@@ -0,0 +1,46 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/mcp.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
__screenshots__/
# System files
.DS_Store
Thumbs.db
*.schema.json
+1
View File
@@ -0,0 +1 @@
v24.11.1
+5
View File
@@ -0,0 +1,5 @@
{
"plugins": {
"@tailwindcss/postcss": {}
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Joel Wetzell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
# Showbridge WebUI
+71
View File
@@ -0,0 +1,71 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
},
"newProjectRoot": "projects",
"projects": {
"webui": {
"projectType": "application",
"schematics": {},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": ["src/material-theme.scss", "src/styles.css"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "webui:build:production"
},
"development": {
"buildTarget": "webui:build:development"
}
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
}
}
}
}
}
+9408
View File
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@showbridge/webui",
"version": "0.11.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"format:write": "prettier ./ --write"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"files": [
"dist"
],
"license": "MIT",
"packageManager": "npm@11.6.2",
"dependencies": {
"@angular/cdk": "~21.1.4",
"@angular/common": "21.1.0",
"@angular/compiler": "21.1.0",
"@angular/core": "21.1.0",
"@angular/forms": "21.1.0",
"@angular/material": "~21.1.4",
"@angular/platform-browser": "21.1.0",
"@angular/router": "21.1.0",
"ajv": "8.17.1",
"lodash-es": "4.17.23",
"ngx-timeago": "4.0.0",
"rxjs": "7.8.0",
"tslib": "2.3.0"
},
"devDependencies": {
"@angular/build": "21.1.4",
"@angular/cli": "21.1.4",
"@angular/compiler-cli": "21.1.0",
"@angular/material": "^21.1.4",
"@tailwindcss/postcss": "4.1.12",
"@types/lodash-es": "4.17.12",
"jsdom": "27.1.0",
"postcss": "8.5.3",
"prettier": "3.8.1",
"tailwindcss": "4.1.12",
"typescript": "5.9.2",
"vitest": "4.0.8"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+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;
}
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Showbridge</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
rel="stylesheet"
/>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
</head>
<body class="overscroll-none">
<app-root></app-root>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
bootstrapApplication(App, appConfig).catch((err) => console.error(err));
+98
View File
@@ -0,0 +1,98 @@
@use '@angular/material' as mat;
@include mat.elevation-classes();
@include mat.app-background();
// NOTE(jwetzell): Color Palettes
$showbridge-primary-palette: (
50: #e4f2ff,
100: #bdddff,
200: #93c8ff,
300: #68b2ff,
400: #4ba1ff,
500: #3991ff,
600: #3b83f6,
700: #3a70e2,
800: #395ecf,
900: #353eaf,
contrast: (
50: rgba(black, 0.87),
100: rgba(black, 0.87),
200: rgba(black, 0.87),
300: rgba(black, 0.87),
400: rgba(black, 0.87),
500: white,
600: white,
700: white,
800: white,
900: white,
),
);
$showbridge-warn-palette: (
50: #fcedef,
100: #f9d3d4,
200: #e6a5a0,
300: #d9857c,
400: #e26e5c,
500: #e46546,
600: #d75d44,
700: #c5543e,
800: #b84e39,
900: #a9442e,
contrast: (
50: rgba(black, 0.87),
100: rgba(black, 0.87),
200: rgba(black, 0.87),
300: rgba(black, 0.87),
400: rgba(black, 0.87),
500: white,
600: white,
700: white,
800: white,
900: white,
),
);
$showbridge-accent-palette: (
50: #eceff1,
100: #cfd8dc,
200: #b0bec5,
300: #90a4ae,
400: #78909c,
500: #607d8b,
600: #546e7a,
700: #455a64,
800: #37474f,
900: #263238,
contrast: (
50: rgba(black, 0.87),
100: rgba(black, 0.87),
200: rgba(black, 0.87),
300: rgba(black, 0.87),
400: rgba(black, 0.87),
500: white,
600: white,
700: white,
800: white,
900: white,
),
);
$my-primary: mat.m2-define-palette($showbridge-primary-palette, 600);
$my-accent: mat.m2-define-palette($showbridge-accent-palette, 200);
$my-warn: mat.m2-define-palette($showbridge-warn-palette, 400);
$my-theme: mat.m2-define-dark-theme(
(
color: (
primary: $my-primary,
accent: $my-accent,
warn: $my-warn,
),
typography: mat.m2-define-typography-config(),
density: 0,
)
);
@include mat.all-component-themes($my-theme);
+27
View File
@@ -0,0 +1,27 @@
export type Config = {
modules: ModuleConfiguration[];
routes: RouteConfiguration[];
};
export type ConfigState = {
config: Config;
timestamp: number;
isCurrent: boolean;
};
export type ModuleConfiguration = {
id?: string;
type: string;
params?: Record<string, any>;
};
export type RouteConfiguration = {
input?: string;
processors?: ProcessorConfiguration[];
output?: string;
};
export type ProcessorConfiguration = {
type: string;
params?: Record<string, any>;
};
+27
View File
@@ -0,0 +1,27 @@
import { FormGroup } from '@angular/forms';
export type ObjectInfo = {
name: string;
type: string;
schema: any;
};
export type ParamsFormInfo = {
formGroup: FormGroup;
paramsInfo: ParamsInfo;
};
export type ParamsInfo = {
[key: string]: ParamInfo;
};
export type ParamInfo = {
key: string;
display: string;
hint: string;
type: string;
placeholder?: string;
options?: string[];
isConst: boolean;
schema: any;
};
+94
View File
@@ -0,0 +1,94 @@
import { computed, effect, Injectable, signal } from '@angular/core';
import { cloneDeep } from 'lodash-es';
import { Config } from '../models/config.models';
import { SchemaService } from './schema.service';
import { SettingsService } from './settings.service';
@Injectable({
providedIn: 'root',
})
export class ConfigService {
pendingConfigIsValid = computed(() => {
const config = this.currentlyShownConfig();
if (config === undefined) {
return false;
}
return this.schemaService.validate(config);
});
currentlyShownConfig = signal<Config | undefined>(undefined);
constructor(private schemaService: SchemaService) {
this.loadConfig();
effect(() => {
console.log('config state changed', this.currentlyShownConfig());
});
}
loadConfig() {
// TODO(jwetzell): load from local storage
this.updateCurrentlyShownConfig({
modules: [
{
id: 'http',
type: 'http.server',
params: {
port: 8080,
},
},
{
id: 'http',
type: 'http.server',
params: {
port: 8080,
},
},
{
id: 'http',
type: 'http.server',
params: {
port: 8080,
},
},
{
id: 'http',
type: 'http.server',
params: {
port: 8080,
},
},
{
id: 'http',
type: 'http.server',
params: {
port: 8080,
},
},
{
id: 'http',
type: 'http.server',
params: {
port: 8080,
},
},
{
id: 'http',
type: 'http.server',
params: {
port: 8080,
},
},
],
routes: [],
});
}
saveConfig(config: Config) {
// TODO(jwetzell): save to local storage
console.log('saveConfig');
console.log(config);
}
updateCurrentlyShownConfig(config: Config) {
// NOTE(jwetzell): mark the configStates that match the currently shown as such
this.currentlyShownConfig.set(cloneDeep(config));
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class ListsService {
public actionListIds: string[] = [];
public transformListIds: string[] = [];
public moduleListsId: string[] = [];
registerActionList(path: string | undefined) {
if (path === undefined) {
return '';
}
const id = this.pathToId(path);
if (!this.actionListIds.includes(id)) {
this.actionListIds.push(id);
}
return id;
}
registerTransformList(path: string | undefined) {
if (path === undefined) {
return '';
}
const id = this.pathToId(path);
if (!this.transformListIds.includes(id)) {
this.transformListIds.push(id);
}
return id;
}
pathToId(path: string) {
return path.replaceAll('/', '.');
}
}
+529
View File
@@ -0,0 +1,529 @@
import { HttpClient } from '@angular/common/http';
import { computed, effect, Injectable, signal } from '@angular/core';
import {
AbstractControl,
FormControl,
FormGroup,
ValidationErrors,
ValidatorFn,
Validators,
} from '@angular/forms';
import { Ajv2020, JSONSchemaType } from 'ajv/dist/2020';
import { SomeJSONSchema } from 'ajv/dist/types/json-schema';
import { sortBy } from 'lodash-es';
import { Config, ModuleConfiguration, ProcessorConfiguration } from '../models/config.models';
import { ObjectInfo, ParamsFormInfo } from '../models/form.model';
import { SettingsService } from './settings.service';
import { RouteConfiguration } from '../models/config.models';
@Injectable({
providedIn: 'root',
})
export class SchemaService {
configSchema = signal<JSONSchemaType<Config> | undefined>(undefined);
modulesSchema = signal<JSONSchemaType<ModuleConfiguration[]> | undefined>(undefined);
routesSchema = signal<JSONSchemaType<RouteConfiguration[]> | undefined>(undefined);
processorsSchema = signal<JSONSchemaType<ProcessorConfiguration[]> | undefined>(undefined);
schemasLoaded = computed(() => {
return (
this.configSchema() !== undefined &&
this.modulesSchema() !== undefined &&
this.routesSchema() !== undefined &&
this.processorsSchema() !== undefined
);
});
ajv: Ajv2020 = new Ajv2020({ allErrors: true });
processorTypes: ObjectInfo[] = [];
moduleTypes: ObjectInfo[] = [];
errorPaths: string[] = [];
constructor(
private http: HttpClient,
private settingsService: SettingsService,
) {
effect(() => {
if (this.settingsService.configSchemaUrl()) {
this.loadConfigSchema();
}
});
effect(() => {
if (this.settingsService.modulesSchemaUrl()) {
this.loadModulesSchema();
}
});
effect(() => {
if (this.settingsService.routesSchemaUrl()) {
this.loadRoutesSchema();
}
});
effect(() => {
if (this.settingsService.processorsSchemaUrl()) {
this.loadProcessorsSchema();
}
});
effect(() => {
if (this.schemasLoaded()) {
this.ajv.addSchema(this.modulesSchema()!);
this.ajv.addSchema(this.routesSchema()!);
this.ajv.addSchema(this.processorsSchema()!);
this.ajv.compile(this.configSchema()!);
}
});
}
loadConfigSchema() {
this.http
.get<JSONSchemaType<Config>>(this.settingsService.configSchemaUrl().toString())
.subscribe((schema) => {
this.setConfigSchema(schema);
});
}
loadModulesSchema() {
this.http
.get<
JSONSchemaType<ModuleConfiguration[]>
>(this.settingsService.modulesSchemaUrl().toString())
.subscribe((schema) => {
this.setModulesSchema(schema);
});
}
loadRoutesSchema() {
this.http
.get<JSONSchemaType<RouteConfiguration[]>>(this.settingsService.routesSchemaUrl().toString())
.subscribe((schema) => {
this.setRoutesSchema(schema);
});
}
loadProcessorsSchema() {
this.http
.get<
JSONSchemaType<ProcessorConfiguration[]>
>(this.settingsService.processorsSchemaUrl().toString())
.subscribe((schema) => {
this.setProcessorsSchema(schema);
});
}
validate(data: any): boolean {
if (this.schemasLoaded() && this.ajv) {
this.ajv.validate('https://showbridge.io/config.schema.json', data);
if (this.ajv.errors) {
console.error('validation errors', this.ajv.errors);
const errorPaths = new Set(
this.ajv.errors.map((error) => {
const errorPathMatch = error.instancePath.match(/(\/.*)\d+/);
if (errorPathMatch) {
return errorPathMatch[0]
.substring(1)
.replaceAll('handlers/', '')
.replaceAll('/params', '');
}
return '';
}),
);
this.errorPaths = Array.from(errorPaths);
console.log(this.errorPaths);
return false;
} else {
this.errorPaths = [];
}
}
return true;
}
getSkeletonForRoute(): RouteConfiguration {
const template: RouteConfiguration = {};
return template;
}
getSkeletonForProcessor(processorType: string): ProcessorConfiguration {
const template: ProcessorConfiguration = {
type: processorType,
};
const itemInfo = this.processorTypes.find((itemInfo) => itemInfo.type === processorType);
if (itemInfo?.schema?.required && itemInfo.schema.required.includes('params')) {
template.params = this.getSkeletonForParamsSchema(
itemInfo.schema.properties.params,
) as Record<string, any>;
}
return template;
}
getSkeletonForModule(moduleType: string): ModuleConfiguration {
const template: ModuleConfiguration = {
type: moduleType,
};
const itemInfo = this.moduleTypes.find((itemInfo) => itemInfo.type === moduleType);
if (itemInfo?.schema?.required && itemInfo.schema.required.includes('params')) {
template.params = this.getSkeletonForParamsSchema(
itemInfo.schema.properties.params,
) as Record<string, any>;
}
return template;
}
getSkeletonForParamsSchema(paramsSchema: SomeJSONSchema): Record<string, any> {
const paramsTemplate: any = {};
if (paramsSchema.properties) {
Object.entries(paramsSchema.properties).forEach(([paramKey, paramSchema]) => {
const schema = paramSchema as any;
switch (schema.type) {
case 'array':
if (paramsSchema.required?.includes(paramKey)) {
paramsTemplate[paramKey] = [];
}
break;
default:
console.error(`schema-service: unhandled param skeleton type = ${schema.type}`);
break;
}
});
}
return paramsTemplate;
}
setConfigSchema(schema: JSONSchemaType<Config>) {
this.configSchema.set(schema);
}
setModulesSchema(schema: JSONSchemaType<ModuleConfiguration[]>) {
this.modulesSchema.set(schema);
this.moduleTypes = [];
this.populateModuleTypes();
}
setRoutesSchema(schema: JSONSchemaType<RouteConfiguration[]>) {
this.routesSchema.set(schema);
}
setProcessorsSchema(schema: JSONSchemaType<ProcessorConfiguration[]>) {
this.processorsSchema.set(schema);
this.processorTypes = [];
this.populateProcessorTypes();
}
populateModuleTypes() {
const modulesSchema = this.modulesSchema();
if (modulesSchema !== undefined) {
const definitions = modulesSchema.items?.oneOf;
if (definitions) {
this.moduleTypes = sortBy(
Object.keys(definitions)
.map((definitionKey) => definitions[definitionKey])
.filter((definition) => definition.properties?.type?.const !== undefined)
.map((definition) => {
return {
name: definition['title'],
type: definition.properties?.type?.const,
schema: definition,
};
}),
['name'],
);
}
}
}
populateProcessorTypes() {
const processorsSchema = this.processorsSchema();
if (processorsSchema !== undefined) {
const definitions = processorsSchema.items?.oneOf;
if (definitions) {
this.processorTypes = sortBy(
Object.keys(definitions)
.map((definitionKey) => definitions[definitionKey])
.filter((definition) => definition.properties?.type?.const !== undefined)
.map((definition) => {
return {
name: definition['title'],
type: definition.properties?.type?.const,
schema: definition,
};
}),
['name'],
);
}
}
}
getSchemaForModuleType(protocolType: string) {
const moduleType = this.moduleTypes.find((item) => item.type === protocolType);
if (moduleType !== undefined) {
return moduleType.schema;
}
return undefined;
}
getSchemaForProcessorType(protocolType: string) {
const processorType = this.processorTypes.find((item) => item.type === protocolType);
if (processorType !== undefined) {
return processorType.schema;
}
return undefined;
}
matchParamsDataToSchema(data: any, schemas: SomeJSONSchema[]) {
const matchingSchemaIndex = schemas.findIndex((schema) => {
return this.ajv.validate(schema, data);
});
if (matchingSchemaIndex >= 0) {
return matchingSchemaIndex;
}
return 0;
}
getFormInfoFromParamsSchema(schema: SomeJSONSchema): ParamsFormInfo {
const paramsFormInfo: ParamsFormInfo = {
formGroup: new FormGroup({}),
paramsInfo: {},
};
if (schema?.properties) {
const paramKeys = Object.keys(schema.properties);
Object.entries(schema.properties).forEach(([paramKey, paramSchema]: [string, any]) => {
if (paramSchema.type) {
switch (paramSchema.type) {
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'array': // TODO(jwetzell): actually handle arrays
case 'object': // TODO(jwetzell): actually handle objects
let formDefault = '';
const validators: ValidatorFn[] = [];
// NOTE(jwetzell): check for a default value to set
if (paramSchema.const) {
formDefault = paramSchema.const;
} else if (paramSchema.default) {
formDefault = paramSchema.default;
}
// NOTE(jwetzell): add as many validators as we can
if (paramSchema.minimum) {
validators.push(Validators.min(paramSchema.minimum));
}
if (paramSchema.maximum) {
validators.push(Validators.max(paramSchema.maximum));
}
if (paramSchema.pattern) {
validators.push(Validators.pattern(new RegExp(paramSchema.pattern)));
}
if (schema.required) {
if (schema.required.includes(paramKey)) {
validators.push(Validators.required);
}
}
paramsFormInfo.paramsInfo[paramKey] = {
key: paramKey,
display: paramSchema.title ? paramSchema.title : paramKey,
type: paramSchema.type,
hint: paramSchema.description,
isConst: !!paramSchema.const,
schema: paramSchema,
placeholder: '',
};
if (paramSchema.examples && paramSchema.examples.length > 0) {
paramsFormInfo.paramsInfo[paramKey].placeholder = paramSchema.examples[0];
}
if (paramSchema.type === 'object') {
validators.push(this.objectValidator);
}
//TODO(jwetzell): figure out how to disable a control but not have to deal with undefined values on disabled controls
paramsFormInfo.formGroup.addControl(
paramKey,
new FormControl(formDefault, validators),
);
if (paramSchema.enum) {
paramsFormInfo.paramsInfo[paramKey].options = paramSchema.enum;
}
break;
default:
console.error(
`schema-service: unhandled param schema type for form group = ${paramSchema.type}`,
);
break;
}
} else {
console.error('schema-service: param property without type');
}
});
} else {
console.error('trigger-form: params schema without properties');
console.error(schema);
}
return paramsFormInfo;
}
cleanArray(values: any[], itemSchema: SomeJSONSchema) {
if (Array.isArray(values)) {
switch (itemSchema.type) {
case 'number':
return values.map(Number.parseFloat);
case 'integer':
return values.map(Number.parseInt);
case 'string':
return values;
case 'object':
return values;
default:
console.error(`schema-service: unsupported array type ${itemSchema.type}`);
return values;
}
}
return [];
}
cleanParams(paramsSchema: SomeJSONSchema, params: any, keysToTemplate: Set<string>): any {
Object.keys(params).forEach((paramKey) => {
if (paramsSchema.properties[paramKey]) {
const paramSchema = paramsSchema.properties[paramKey];
// delete null/undefined/empty params that aren't required
if (
params[paramKey] === undefined ||
params[paramKey] === null ||
params[paramKey] === ''
) {
if (paramSchema.required) {
if (!paramSchema.includes(paramKey)) {
delete params[paramKey];
return;
}
} else {
delete params[paramKey];
return;
}
}
if (paramSchema.type) {
switch (paramSchema.type) {
case 'integer':
var paramValue = parseInt(params[paramKey]);
// NOTE(jwetzell): delete non-numbers
if (Number.isNaN(paramValue)) {
delete params[paramKey];
} else {
params[paramKey] = paramValue;
}
break;
case 'number':
var paramValue = parseFloat(params[paramKey]);
// NOTE(jwetzell): delete non-numbers
if (Number.isNaN(paramValue)) {
delete params[paramKey];
} else {
params[paramKey] = paramValue;
}
break;
case 'array':
if (!Array.isArray(params[paramKey])) {
const paramValue = params[paramKey];
params[paramKey] = this.parseStringToArray(paramValue, paramSchema);
}
break;
case 'object':
try {
params[paramKey] = JSON.parse(params[paramKey]);
} catch (error) {
console.error('object param is not JSON');
}
break;
case 'string':
// string is default so nothing needs to happen to clean it's value
break;
default:
console.log(`schema-service: unhandled param schema type: ${paramSchema.type}`);
break;
}
}
}
});
return params;
}
parseStringToArray(value: any, schema: SomeJSONSchema): any[] | undefined {
if (!Array.isArray(value)) {
const paramValue = value;
if (paramValue === undefined || paramValue.trim().length === 0) {
value = [];
} else {
if (schema.items?.type) {
if (schema.items?.type === 'integer') {
return paramValue
.split(',')
.map((part: string) => part.trim())
.map((item: any) => parseInt(item));
} else if (schema.items?.type === 'number') {
return paramValue
.split(',')
.map((part: string) => part.trim())
.map((item: any) => parseFloat(item));
} else if (schema.items?.type === 'string') {
return paramValue.split(',').map((part: string) => part.trim());
} else if (schema.items?.type === 'object') {
// TODO(jwetzell): this seems gross, not sure if this covers everything
return JSON.parse(`[${paramValue}]`);
} else {
console.error(`schema-service: unhandled array schema type: ${schema.items?.type}`);
}
} else if (schema['$ref'] === '#/definitions/ActionList') {
try {
return JSON.parse(`[${paramValue}]`);
} catch (error) {
console.error('sub action list is not JSON');
}
} else {
// NOTE(jwetzell): default to comma-separated strings
return paramValue.split(',').map((part: string) => part.trim());
}
}
}
return undefined;
}
objectValidator(control: AbstractControl): ValidationErrors | null {
try {
JSON.parse(control.value);
return null;
} catch (error) {
return { json: true };
}
}
jsonValidator(validateSchema: SomeJSONSchema) {
return (control: AbstractControl): ValidationErrors | null => {
try {
const jsonObj = JSON.parse(control.value);
if (this.ajv.validate(validateSchema, jsonObj)) {
return null;
} else {
return { json: this.ajv.errors };
}
} catch (error) {
console.error(error);
return { json: true };
}
};
}
}
+35
View File
@@ -0,0 +1,35 @@
import { computed, Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class SettingsService {
public isDummySite: boolean = false;
public configSchemaPath = signal('/config.schema.json');
public modulesSchemaPath = signal('/modules.schema.json');
public routesSchemaPath = signal('/routes.schema.json');
public processorsSchemaPath = signal('/processors.schema.json');
public baseUrl = signal(window.location.href);
public configSchemaUrl = computed(() => {
return new URL(this.configSchemaPath(), this.baseUrl());
});
public modulesSchemaUrl = computed(() => {
return new URL(this.modulesSchemaPath(), this.baseUrl());
});
public routesSchemaUrl = computed(() => {
return new URL(this.routesSchemaPath(), this.baseUrl());
});
public processorsSchemaUrl = computed(() => {
return new URL(this.processorsSchemaPath(), this.baseUrl());
});
constructor() {}
updateBaseUrl(baseUrl: string) {
this.baseUrl.set(baseUrl);
}
}
+32
View File
@@ -0,0 +1,32 @@
/* You can add global styles to this file, and also import other style files */
@import 'tailwindcss';
@layer components {
.context-menu-item {
@apply p-2 py-0.5 text-left border border-gray-700 hover:bg-gray-500;
}
.context-menu {
@apply flex flex-col bg-gray-600 border border-black border-solid;
}
}
html,
body {
height: 100%;
background-color: rgb(30, 30, 30);
overscroll-behavior: none !important;
}
body {
margin: 0;
font-family: Roboto, 'Helvetica Neue', sans-serif;
}
.cdk-drop-list-dragging .cdk-drag {
transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
}
.cdk-drag-animating {
transition: transform 200ms cubic-bezier(0, 0, 0.2, 1);
}
+11
View File
@@ -0,0 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}
+33
View File
@@ -0,0 +1,33 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
+10
View File
@@ -0,0 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["vitest/globals"]
},
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
}