mirror of
https://github.com/jwetzell/showbridge-webui.git
synced 2026-07-26 10:08:40 +00:00
real rough conversion of old webui working add module and a little bit of routes no processor stuff yet
This commit is contained in:
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"plugins": {
|
||||||
|
"@tailwindcss/postcss": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+9408
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -0,0 +1,6 @@
|
|||||||
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [provideBrowserGlobalErrorListeners()],
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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));
|
||||||
@@ -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);
|
||||||
@@ -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>;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('/', '.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user