Migrate UI to nortem (#16)

* migrate config editor ui to nortem repo
This commit is contained in:
Thomas Gilgan
2019-10-16 15:44:22 +01:00
committed by GitHub Enterprise
parent 9f68bea6f5
commit f5e2ea47fd
157 changed files with 22442 additions and 2 deletions

45
.gitignore vendored
View File

@@ -26,4 +26,47 @@ pom.xml.versionsBackup
*.pyc
#Storm
worker.yaml
worker.yaml
## Angular
# compiled output
/dist
/dist-test
/tmp
/out-tsc
# dependencies
/node_modules
# IDEs and editors
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# misc
/.sass-cache
/connect.lock
/coverage
/libpeerconnection.log
npm-debug.log
testem.log
/typings
# e2e
/e2e/*.js
/e2e/*.map
# System Files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,13 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
max_line_length = off
trim_trailing_whitespace = false

View File

@@ -1 +1,33 @@
"# config-editor-ui"
# Config Editor UI
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.0.6.
## Development server
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
## Code scaffolding
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|module`.
## Install
If Angular CLI is not installed, follow instructions from: https://confluence.uberit.net/display/DEVGLO/Web+Application+Development (`Installation` section)
Then run `npm install`
## Build
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build.
## Running unit tests
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
## Running end-to-end tests
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
Before running the tests make sure you are serving the app via `ng serve`.
## Further help
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).

View File

@@ -0,0 +1,131 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"ruleeditor": {
"root": "",
"sourceRoot": "src",
"projectType": "application",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "src/tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"assets": [
{ "glob": "**/*", "input": "node_modules/ngx-monaco-editor/assets/monaco", "output": "./assets/monaco/" },
"src/assets",
"src/favicon.ico",
"src/config.json"
],
"styles": [
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
}
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"browserTarget": "ruleeditor:build"
},
"configurations": {
"production": {
"browserTarget": "ruleeditor:build:production"
}
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "ruleeditor:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"karmaConfig": "./karma.conf.js",
"polyfills": "src/polyfills.ts",
"tsConfig": "src/tsconfig.spec.json",
"scripts": [],
"styles": [
"src/styles.scss"
],
"assets": [
"src/assets",
"src/favicon.ico",
"src/config.json"
]
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"src/tsconfig.app.json",
"src/tsconfig.spec.json"
],
"exclude": []
}
}
}
},
"ruleeditor-e2e": {
"root": "",
"sourceRoot": "",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@angular-devkit/build-angular:protractor",
"options": {
"protractorConfig": "./protractor.conf.js",
"devServerTarget": "ruleeditor:serve"
}
},
"lint": {
"builder": "@angular-devkit/build-angular:tslint",
"options": {
"tsConfig": [
"e2e/tsconfig.e2e.json"
],
"exclude": []
}
}
}
}
},
"defaultProject": "ruleeditor",
"schematics": {
"@schematics/angular:component": {
"prefix": "re",
"styleext": "scss"
},
"@schematics/angular:directive": {
"prefix": "re"
}
}
}

View File

@@ -0,0 +1,3 @@
Firefox ESR # the latest [Firefox ESR] version.
not dead # Don't support outdated browsers
not IE 9-11 # Don't support IE 9 - 11

View File

@@ -0,0 +1,44 @@
// Karma configuration file, see link for more information
// https://karma-runner.github.io/0.13/config/configuration-file.html
module.exports = function (config) {
config.set({
basePath: '',
frameworks: ['jasmine', '@angular-devkit/build-angular'],
plugins: [
require('karma-jasmine'),
require('karma-chrome-launcher'),
require('karma-jasmine-html-reporter'),
require('karma-coverage-istanbul-reporter'),
require('@angular-devkit/build-angular/plugins/karma')
],
client:{
clearContext: false // leave Jasmine Spec Runner output visible in browser
},
files: [
],
preprocessors: {
},
mime: {
'text/x-typescript': ['ts','tsx']
},
coverageIstanbulReporter: {
dir: require('path').join(__dirname, 'coverage'), reports: [ 'html', 'lcovonly' ],
fixWebpackSourcePaths: true
},
angularCli: {
environment: 'dev'
},
reporters: config.angularCli && config.angularCli.codeCoverage
? ['progress', 'coverage-istanbul']
: ['progress', 'kjhtml'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
{
"name": "rule-editor.ui",
"version": "1.1.0",
"license": "MIT",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"lint": "ng lint --type-check",
"e2e": "ng e2e",
"build-prod": "ng build --prod --progress=false",
"test-once": "ng test --single-run --code-coverage",
"test-with-coverage": "ng test --code-coverage",
"build-test": "webpack --config webpack.test.config.js",
"prebuild-test": "rimraf dist-test",
"component": "ng generate component"
},
"private": true,
"dependencies": {
"@angular-devkit/build-angular": "^0.803.1",
"@angular/animations": "^8.2.4",
"@angular/cdk": "^8.1.4",
"@angular/common": "^8.2.4",
"@angular/compiler": "^8.2.4",
"@angular/core": "^8.2.4",
"@angular/flex-layout": "^8.0.0-beta.26",
"@angular/forms": "^8.2.4",
"@angular/material": "^8.1.4",
"@angular/platform-browser": "^8.2.4",
"@angular/platform-browser-dynamic": "^8.2.4",
"@angular/router": "^8.2.4",
"@juggle/resize-observer": "^2.3.0",
"@ngrx/effects": "^8.3.0",
"@ngrx/router-store": "^8.3.0",
"@ngrx/store": "^8.3.0",
"@ngrx/store-devtools": "^8.3.0",
"@ngx-formly/core": "^5.4.1",
"@ngx-formly/material": "^5.4.1",
"@types/dragula": "^2.1.34",
"ajv": "^6.8.1",
"core-js": "~2.5.6",
"diff-match-patch": "^1.0.4",
"hammerjs": "^2.0.8",
"json-ptr": "^1.2.0",
"ngx-mat-select-search": "^1.2.3",
"ngx-scrollbar": "^5.0.1",
"omit-empty": "^1.0.0",
"rxjs": "^6.5.2",
"rxjs-compat": "^6.5.2",
"zone.js": "^0.9.1"
},
"devDependencies": {
"@angular/cli": "^8.3.1",
"@angular/compiler-cli": "^8.2.4",
"@types/jasmine": "2.8.7",
"@types/node": "~10.1.2",
"codelyzer": "~4.3.0",
"jasmine": "^3.1.0",
"jasmine-core": "~3.1.0",
"jasmine-spec-reporter": "~4.2.1",
"karma": "~2.0.2",
"karma-chrome-launcher": "~2.2.0",
"karma-cli": "~1.0.1",
"karma-coverage-istanbul-reporter": "^2.0.1",
"karma-jasmine": "~1.1.2",
"karma-jasmine-html-reporter": "^1.1.0",
"karma-nunit2-reporter": "^0.3.0",
"material-design-icons": "^3.0.1",
"ngrx-store-freeze": "^0.2.3",
"protractor": "~5.3.2",
"rimraf": "^2.6.1",
"ts-node": "^6.0.5",
"tslint": "~5.10.0",
"typescript": "^3.5.3"
}
}

View File

@@ -0,0 +1,28 @@
// Protractor configuration file, see link for more information
// https://github.com/angular/protractor/blob/master/lib/config.ts
const { SpecReporter } = require('jasmine-spec-reporter');
exports.config = {
allScriptsTimeout: 11000,
specs: [
'./e2e/**/*.e2e-spec.ts'
],
capabilities: {
'browserName': 'chrome'
},
directConnect: true,
baseUrl: 'http://localhost:4200/',
framework: 'jasmine',
jasmineNodeOpts: {
showColors: true,
defaultTimeoutInterval: 30000,
print: function() {}
},
onPrepare() {
require('ts-node').register({
project: 'e2e/tsconfig.e2e.json'
});
jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
}
};

View File

@@ -0,0 +1,23 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { InitComponent } from '@app/components/init-component/init.component';
import { ViewResolver } from '@app/guards';
const appRoutes: Routes = [
{
component: InitComponent,
path: '**',
resolve: {
message: ViewResolver,
},
},
];
@NgModule({
exports: [RouterModule],
imports: [RouterModule.forRoot(appRoutes, { useHash: true })],
providers: [ViewResolver],
})
export class AppRoutingModule {
constructor() {}
}

View File

@@ -0,0 +1,28 @@
import { ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { RouterStateUrl } from '@app/app-routing';
import * as fromRouter from '@ngrx/router-store';
/**
* The RouterStateSerializer takes the current RouterStateSnapshot
* and returns any pertinent information needed. The snapshot contains
* all information about the state of the router at the given point in time.
* The entire snapshot is complex and not always needed. In this case, you only
* need the URL and query parameters from the snapshot in the store. Other items could be
* returned such as route parameters and static route data.
*
* source: https://github.com/ngrx/platform/blob/master/docs/router-store/api.md#custom-router-state-serializer
*/
export class CustomRouterStateSerializer implements fromRouter.RouterStateSerializer<RouterStateUrl> {
serialize(routerState: RouterStateSnapshot): RouterStateUrl {
const { url } = routerState;
const { queryParams } = routerState.root;
let state: ActivatedRouteSnapshot = routerState.root;
while (state.firstChild) {
state = state.firstChild;
}
const { params } = state;
return { url, queryParams, params };
}
}

View File

@@ -0,0 +1,3 @@
export { RouterStateUrl } from './router-state-url';
export { CustomRouterStateSerializer } from './custom-router-state-serializer';
export { AppRoutingModule } from './app-routing.module';

View File

@@ -0,0 +1,7 @@
import { Params } from '@angular/router';
export interface RouterStateUrl {
url: string;
queryParams: Params;
params: Params;
}

View File

@@ -0,0 +1,7 @@
import { Component } from '@angular/core';
@Component({
selector: 're-root',
template: '<router-outlet></router-outlet>',
})
export class AppComponent { }

View File

@@ -0,0 +1,212 @@
import { DragDropModule } from '@angular/cdk/drag-drop';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { HashLocationStrategy, LocationStrategy } from '@angular/common';
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
import { APP_INITIALIZER, NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { GestureConfig } from '@angular/material';
import { BrowserModule, HAMMER_GESTURE_CONFIG } from '@angular/platform-browser';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { AppRoutingModule, CustomRouterStateSerializer } from '@app/app-routing';
import {
ConfigManagerComponent, DeployDialogComponent, EditorViewComponent,
ErrorDialogComponent, JsonViewerComponent, LandingPageComponent, NavBarComponent, SearchComponent, SideBarComponent,
SubmitDialogComponent
} from '@app/components';
import { TestingDialogComponent } from '@app/components/testing-dialog/testing-dialog.component';
import { ConfigTileComponent } from '@app/components/tile/config-tile.component';
import { DeploymentTileComponent } from '@app/components/tile/deployment-tile.component';
import { AppConfigService, ConfigModule } from '@app/config';
import { HomeComponent, PageNotFoundComponent } from '@app/containers';
import { CoreModule } from '@app/core';
import { CredentialsInterceptor } from '@app/credentials-interceptor';
import { RepoResolver, ViewResolver } from '@app/guards';
import { StripSuffixPipe } from '@app/pipes';
import { SharedModule } from '@app/shared';
import { metaReducers, reducers } from '@app/store';
import { EditorEffects } from '@app/store/editor.effects';
import { RouterEffects } from '@app/store/router-effects';
import { EffectsModule } from '@ngrx/effects';
import { RouterStateSerializer, StoreRouterConnectingModule } from '@ngrx/router-store';
import { StoreModule } from '@ngrx/store';
import { FormlyModule } from '@ngx-formly/core';
import { environment } from 'environments/environment';
import 'hammerjs';
import { NgxMatSelectSearchModule } from 'ngx-mat-select-search'
import { NgScrollbarModule } from 'ngx-scrollbar';
import { AppComponent } from './app.component';
import { ChangeHistoryComponent } from './components/change-history/change-history.component';
import { EditorComponent } from './components/editor/editor.component';
import { InitComponent } from './components/init-component/init.component';
import { ConfigStoreGuard } from './guards/config-store.guard';
import { ArrayTypeComponent } from './ngx-formly/components/array.type';
import { ExpansionPanelWrapperComponent } from './ngx-formly/components/expansion-panel-wrapper.component';
import { FormFieldWrapperComponent } from './ngx-formly/components/form-field-wrapper.component';
import { InputTypeComponent } from './ngx-formly/components/input.type.component';
import { NullTypeComponent } from './ngx-formly/components/null.type';
import { ObjectTypeComponent } from './ngx-formly/components/object.type.component';
import { PanelWrapperComponent } from './ngx-formly/components/panel-wrapper.component';
import { TabsWrapperComponent } from './ngx-formly/components/tabs-wrapper.component';
import { TabsetTypeComponent } from './ngx-formly/components/tabset.type.component';
import { TextAreaTypeComponent } from './ngx-formly/components/textarea.type.component';
import { HighlightVariablesPipe } from './pipes';
import { HoverPopoverDirective } from './popover/hover-popover.directive';
import { PopoverRendererComponent } from './popover/popover-renderer.component';
import { PopoverService } from './popover/popover-service';
import { PopupService } from './popup.service';
import { NgxTextDiffModule } from './text-diff/ngx-text-diff.module';
import {FormlyMaterialModule} from '@ngx-formly/material';
export function configServiceFactory(config: AppConfigService) {
return () => config.loadConfig();
}
export function uiMetadataServiceFactory(config: AppConfigService) {
return () => config.loadUiMetadata();
}
const PROD_PROVIDERS = [
{ provide: APP_INITIALIZER, useFactory: configServiceFactory, deps: [AppConfigService], multi: true },
{ provide: APP_INITIALIZER, useFactory: uiMetadataServiceFactory, deps: [AppConfigService], multi: true },
{ provide: HTTP_INTERCEPTORS, useClass: CredentialsInterceptor, multi: true },
];
const DEV_PROVIDERS = [...PROD_PROVIDERS];
@NgModule({
bootstrap: [AppComponent],
declarations: [
AppComponent,
HomeComponent,
PageNotFoundComponent,
ErrorDialogComponent,
SideBarComponent,
EditorViewComponent,
NavBarComponent,
JsonViewerComponent,
ConfigManagerComponent,
StripSuffixPipe,
DeployDialogComponent,
SubmitDialogComponent,
LandingPageComponent,
SearchComponent,
TestingDialogComponent,
ConfigTileComponent,
DeploymentTileComponent,
InitComponent,
EditorComponent,
ChangeHistoryComponent,
PopoverRendererComponent,
HoverPopoverDirective,
ObjectTypeComponent,
ArrayTypeComponent,
NullTypeComponent,
PanelWrapperComponent,
TabsWrapperComponent,
TabsetTypeComponent,
ExpansionPanelWrapperComponent,
TextAreaTypeComponent,
HighlightVariablesPipe,
InputTypeComponent,
FormFieldWrapperComponent,
],
entryComponents: [
ErrorDialogComponent,
JsonViewerComponent,
DeployDialogComponent,
SubmitDialogComponent,
TestingDialogComponent,
LandingPageComponent,
HomeComponent,
ConfigManagerComponent,
EditorViewComponent,
PageNotFoundComponent,
EditorComponent,
PopoverRendererComponent,
ChangeHistoryComponent,
],
imports: [
BrowserModule,
BrowserAnimationsModule,
HttpClientModule,
SharedModule,
ConfigModule,
CoreModule,
AppRoutingModule,
NgxMatSelectSearchModule,
DragDropModule,
NgScrollbarModule,
NgxTextDiffModule,
ScrollingModule,
FormlyModule.forRoot({
validationMessages: [
{ name: 'required', message: 'This field is required' },
{ name: 'null', message: 'should be null' },
{ name: 'minlength', message: 'Min length is' },
{ name: 'maxlength', message: 'Max length is' },
{ name: 'min', message: 'Min is' },
{ name: 'max', message: 'Max is' },
{ name: 'minItems', message: 'Min items required' },
{ name: 'maxItems', message: 'Max items' },
],
types: [
{ name: 'string', component: TextAreaTypeComponent },
{
name: 'number',
component: InputTypeComponent,
wrappers: ['form-field'],
defaultOptions: {
templateOptions: {
type: 'number',
},
},
},
{
name: 'integer',
component: InputTypeComponent,
wrappers: ['form-field'],
defaultOptions: {
templateOptions: {
type: 'number',
},
},
},
{ name: 'boolean', extends: 'checkbox' },
{ name: 'enum', extends: 'select' },
{ name: 'null', component: NullTypeComponent, wrappers: ['form-field'] },
{ name: 'array', component: ArrayTypeComponent },
{ name: 'object', component: ObjectTypeComponent },
{ name: 'tabs', component: TabsetTypeComponent},
],
wrappers: [
{ name: 'panel', component: PanelWrapperComponent },
{ name: 'expansion-panel', component: ExpansionPanelWrapperComponent },
{ name: 'form-field', component: FormFieldWrapperComponent },
],
extras: { checkExpressionOn: 'modelChange' },
}),
ReactiveFormsModule,
FormlyMaterialModule,
// ngrx
StoreModule.forRoot(reducers, { metaReducers }),
EffectsModule.forRoot([EditorEffects, RouterEffects]),
StoreRouterConnectingModule.forRoot(),
],
providers: [
environment.production ? PROD_PROVIDERS : DEV_PROVIDERS,
PopupService,
{ provide: RouterStateSerializer, useClass: CustomRouterStateSerializer },
{ provide: LocationStrategy, useClass: HashLocationStrategy },
{ provide: HAMMER_GESTURE_CONFIG, useClass: GestureConfig },
ViewResolver,
RepoResolver,
ConfigStoreGuard,
HighlightVariablesPipe,
StripSuffixPipe,
PopoverService,
],
})
export class AppModule { }

View File

@@ -0,0 +1 @@
export { StatusCode } from './status-code';

View File

@@ -0,0 +1,6 @@
export enum StatusCode {
OK = 'OK',
CREATED = 'CREATED',
BAD_REQUEST = 'BAD_REQUEST',
ERROR = 'INTERNAL_SERVER_ERROR',
}

View File

@@ -0,0 +1,11 @@
<div class="container" >
<h2 class="title">Change History</h2>
<table class="history-table">
<tr *ngFor="let item of history">
<td>{{item.date | date:'yyyy MMM dd'}}</td>
<td>{{item.author}}</td>
<td><span class="added"><strong>+ {{item.added}}</strong></span></td>
<td><span class="removed"><strong> - {{item.removed}}</strong></span></td>
</tr>
</table>
</div>

View File

@@ -0,0 +1,39 @@
.container {
margin: 0 20px;
}
.title {
text-align: center;
padding-bottom: 10px;
}
.added {
color: rgb(41, 145, 41);
}
.removed {
color: #bd2020;
}
.history-table {
width: 100%;
th {
font-weight: 400;
text-align: left !important;
font-size: 1.1em;
padding: 2px 7px;
}
tr:nth-child(even){
background: #f5f5f5;
border-radius: 2px;
}
td {
padding: 5px 7px;
}
td:first-child(), th:first-child(){
padding-left: 0;
}
td:last-child(), th:last-child(){
padding-right: 0;
}
}

View File

@@ -0,0 +1,26 @@
import { FileHistory } from '../../model/config-model';
import { PopoverContent, PopoverRef } from '../../popover/popover-ref';
import { Component, OnInit } from '@angular/core';
@Component({
selector: 're-change-history',
templateUrl: './change-history.component.html',
styleUrls: ['./change-history.component.scss'],
})
export class ChangeHistoryComponent implements OnInit {
history: FileHistory[];
content: PopoverContent;
constructor(private popoverRef: PopoverRef) {
this.history = this.popoverRef.data;
}
ngOnInit() {
this.content = this.popoverRef.content;
}
close() {
this.popoverRef.close({id: 1});
}
}

View File

@@ -0,0 +1,65 @@
<ng-scrollbar class="scrollbar">
<div class="container">
<div class="rules-container">
<div class="container-title">
<div class="title-info">
<h2>Store</h2>
<span class="item-count">{{(allConfigs$ | async).length}} {{(allConfigs$ | async).length === 1 ? 'item': 'items'}}</span>
</div>
<re-search [searchTerm]="searchTerm$ | async" [filterMyConfigs]="filterMyConfigs$ | async" [filterUndeployed]="filterUndeployed$ | async"
[filterUpgradable]="filterUpgradable$ | async" (searchTermChange)="onSearch($event)" (myConfigsChange)="onFilterMine($event)"
(undeployedConfigsChange)="onFilterUndeployed($event)" (updatedConfigsChange)="onFilterUpgradable($event)">
</re-search>
<div class="add-button">
<a mat-button color="primary" title="Create Config" (click)="onClickCreate()">
<mat-icon>add</mat-icon>
</a>
</div>
</div>
<mat-divider></mat-divider>
<div @list cdkDropList id="store-list" [cdkDropListData]="allConfigs$ | async" [cdkDropListConnectedTo]="['deployment-list']" [cdkDropListEnterPredicate]="noReturnPredicate"
(cdkDropListDropped)="drop($event)" class="rule-list">
<re-config-tile *ngFor="let config of (filteredConfigs$ | async); index as i" [class.selected-rule-box]="i == (selectedConfig$ |async)"
cdkDrag [cdkDragData]="config" [config]="config" (onEdit)="onEdit(i)" (onView)="onView(i)" (onAddToDeployment)="addToDeployment(i)">
</re-config-tile>
</div>
</div>
<mat-divider [vertical]="true"></mat-divider>
<div class="deployment-container">
<div class="deployment-container-title">
<div class="title-info">
<h2>Deployment</h2>
<span class="item-count">{{filteredDeployment.configs.length}} {{filteredDeployment.configs.length === 1 ? 'item': 'items'}}</span>
</div>
<a class="history-button" [hoverPopover]="deploymentHistory">
<mat-icon>history</mat-icon>
</a>
<span *ngIf="!(pullRequestPending$ | async).pull_request_pending && !(releaseStatus$ | async).submitInFlight; else prMessage">
<button class="button" mat-button color="accent" title="Deploy Configs" (click)="onDeploy()">DEPLOY</button>
</span>
<ng-template #prMessage>
<span>
<a mat-button color="accent" *ngIf="(pullRequestPending$ | async).pull_request_url !== undefined" href="{{(pullRequestPending$ | async).pull_request_url}}"
target="_blank">PR for release pending</a>
<button class="button" mat-button color="accent" title="refresh PR status" (click)="onRefreshPrStatus()">
<mat-icon>refresh</mat-icon>
</button>
</span>
</ng-template>
</div>
<mat-divider></mat-divider>
<div @list cdkDropList id="deployment-list" [cdkDropListData]="deployment.configs" [cdkDropListEnterPredicate]="duplicateItemCheck"
class="deployment-list" (cdkDropListDropped)="drop($event)">
<ng-container *ngIf="!deployment || !deployment.configs || deployment.configs.length === 0; else configTile">
<div class="placeholder-box">
<h4>drag item here to add to deployment</h4>
</div>
</ng-container>
<ng-template #configTile>
<re-deployment-tile *ngFor="let cfg of filteredDeployment.configs; index as i" [cdkDragData]="[cfg, deployment]" cdkDrag
[config]="cfg" (onDelete)="onRemove(i)" (onUpgrade)="upgrade(i)" (onViewDiff)="onView(i, i)"></re-deployment-tile>
</ng-template>
</div>
</div>
</div>
</ng-scrollbar>

View File

@@ -0,0 +1,205 @@
.container {
display: flex;
justify-content: space-between;
align-items: stretch;
padding: 10px 30px;
min-height: 100%;
&>mat-divider {
margin: 0 40px;
}
max-width: 1800px;
margin: auto;
}
.rules-container,
.deployment-container {
&>mat-divider {
margin: 10px 0;
}
}
.title-info {
margin-top: 18px;
}
.item-count {
margin-left: 10px;
color: #868686;
font-size: 1em;
}
.add-button {
a {
min-width: 36px;
padding: 0;
margin-right: 10px;
}
}
.rules-container {
width: 60%;
min-width: 800px;
margin-left: auto;
}
.deployment-container {
min-width: 300px;
width: 30%;
margin-right: auto;
}
.rule-list {
display: flex;
flex-direction: column;
}
.deployment-list {
display: flex;
flex-direction: column;
}
.chip-bag{
align-content: center;
}
.container-title {
display: flex;
justify-content: space-between;
align-items: center;
color: white;
margin-bottom: -5px;
h2 {
width: 90px;
max-width: 90px;
margin: 0 10px;
font-weight: 400;
font-size: 1.6em;
}
}
.deployment-container-title {
@extend .container-title;
}
mat-card {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
mat-card-title {
color: #83d3f1;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 20px;
h4 {
overflow: hidden;
text-overflow: ellipsis;
font-weight: 400;
}
.unsaved-warning {
color: rgb(180, 180, 180);
font-style: italic;
font-size: 0.7em;
}
}
mat-card-subtitle {
margin-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
.chip {
display: inline-block;
margin-left: 5px;
padding: 2px 5px;
min-width: 40px;
text-align: center;
border-radius: 9999px;
color: #111;
background: rgb(180, 180, 180);
font-family: monospace;
}
}
mat-card-content {
flex: 1;
margin-bottom: 0;
font-size: 1em;
}
&:hover mat-card-footer {
opacity: 1;
}
mat-card-footer {
opacity: 0;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 10px;
transition: all 0.2s ease-in-out;
a {
margin: 0 5px;
color: rgb(180, 180, 180);
cursor: pointer;
transition: all 0.2s ease-in-out;
&:hover {
color: white;
}
}
.drag-hint {
cursor: move;
}
}
}
.pr-message {
color: orange;
display: inline-block;
margin-right: 10px;
}
.placeholder-box {
background-color: #222;
border: 2px dotted white;
border-radius: 5px;
text-align: center;
padding: 20px;
h4 {
color: white;
font-weight: 400;
}
}
.cdk-drag-preview {
box-sizing: border-box;
box-shadow: 0 5px 5px -3px rgba(0,0,0,0.2),
0 8px 10px 1px rgba(0,0,0,0.14),
0 3px 14px 2px rgba(0,0,0,0.12);
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating,
.rule-list.cdk-drop-list-dragging re-rule-tile:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.cdk-drag-animating,
.deployment-list.cdk-drop-list-dragging re-deployment-tile:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
re-search {
width: 100%;
}
.scrollbar {
--scrollbar-thumb-color: #868686;
--scrollbar-thumb-hover-color: #a1a1a1;
}
.history-button {
margin: 0 0 0 auto;
color: #868686;
margin-top: 3px;
}

View File

@@ -0,0 +1,254 @@
import { animate, query, stagger, style, transition, trigger } from '@angular/animations';
import {
CdkDrag,
CdkDragDrop,
CdkDropList,
copyArrayItem,
moveItemInArray,
transferArrayItem,
} from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material';
import { ConfigData, ConfigWrapper, Deployment, PullRequestInfo, SubmitStatus } from '@app/model';
import { PopupService } from '@app/popup.service';
import { Store } from '@ngrx/store';
import * as fromStore from 'app/store';
import { cloneDeep } from 'lodash';
import { Observable, Subject } from 'rxjs';
import { skip, take, takeUntil } from 'rxjs/operators';
import { DeployDialogComponent } from '../deploy-dialog/deploy-dialog.component';
import { JsonViewerComponent } from '../json-viewer/json-viewer.component';
import { EditorService } from '@app/editor.service';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-config-manager',
styleUrls: ['./config-manager.component.scss'],
templateUrl: './config-manager.component.html',
animations: [
trigger('list', [
transition(':enter', [
transition('* => *', []),
query(':enter', [
style({ opacity: 0 }),
stagger(10, [
style({ transform: 'scale(0.8)', opacity: 0 }),
animate('.6s cubic-bezier(.8,-0.6,0.7,1.5)',
style({transform: 'scale(1)', opacity: 1 })),
]),
], {optional: true}),
]),
]),
],
})
export class ConfigManagerComponent implements OnInit, OnDestroy {
private ngUnsubscribe = new Subject();
public allConfigs$: Observable<ConfigWrapper<ConfigData>[]>;
public filteredConfigs$: Observable<ConfigWrapper<ConfigData>[]>;
public deployment$: Observable<Deployment<ConfigWrapper<ConfigData>>>;
public deployment: Deployment<ConfigWrapper<ConfigData>>;
public configs: ConfigWrapper<ConfigData>[];
public selectedConfig$: Observable<number>;
public selectedConfig: number;
public pullRequestPending$: Observable<PullRequestInfo>;
public releaseStatus$: Observable<SubmitStatus>;
public searchTerm$: Observable<string>;
public filteredDeployment: Deployment<ConfigWrapper<ConfigData>>;
public filteredDeployment$: Observable<Deployment<ConfigWrapper<ConfigData>>>;
private filteredConfigs: ConfigWrapper<ConfigData>[];
private serviceName: string;
public filterMyConfigs$: Observable<boolean>;
public filterUndeployed$: Observable<boolean>;
public filterUpgradable$: Observable<boolean>;
public deploymentHistory;
private readonly UNSAVED_ITEM_ALERT = 'Unsaved item! Item must be submitted to the store before it can be deployed!';
private readonly PR_OPEN_MESSAGE = 'A pull request is already open';
constructor(private store: Store<fromStore.State>, public dialog: MatDialog, private snackbar: PopupService,
private editorService: EditorService) {
this.allConfigs$ = this.store.select(fromStore.getConfigs);
this.filteredConfigs$ = this.store.select(fromStore.getConfigsFilteredBySearchTerm);
this.selectedConfig$ = this.store.select(fromStore.getSelectedConfig);
this.pullRequestPending$ = this.store.select(fromStore.getPullRequestPending);
this.releaseStatus$ = this.store.select(fromStore.getSubmitReleaseStatus);
this.deployment$ = this.store.select(fromStore.getStoredDeployment);
this.searchTerm$ = this.store.select(fromStore.getSearchTerm);
this.filteredDeployment$ = this.store.select(fromStore.getDeploymentFilteredBySearchTerm);
this.filterMyConfigs$ = this.store.select(fromStore.getFilterMyConfigs);
this.filterUndeployed$ = this.store.select(fromStore.getFilterUndeployed);
this.filterUpgradable$ = this.store.select(fromStore.getFilterUpgradable);
this.store.select(fromStore.getDeploymentHistory).pipe(take(1))
.subscribe(h => this.deploymentHistory = {fileHistory: h});
}
ngOnInit() {
this.selectedConfig$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(s => this.selectedConfig = s);
this.deployment$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(s => {
this.deployment = cloneDeep(s);
});
this.allConfigs$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(r => {
this.configs = cloneDeep(r);
});
this.store.select(fromStore.getServiceName).pipe(takeUntil(this.ngUnsubscribe))
.subscribe(name => this.serviceName = name);
this.filteredConfigs$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(s => this.filteredConfigs = s);
this.filteredDeployment$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(s => {
this.filteredDeployment = cloneDeep(s);
});
}
ngOnDestroy() {
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
}
public onSearch(searchTerm: string) {
this.store.dispatch(new fromStore.Go({
extras: {
queryParamsHandling: 'merge',
},
path: [],
query: { search: searchTerm },
}));
}
public upgrade(index: number) {
const originalIndex = this.deployment.configs
.findIndex(d => d.name === this.filteredDeployment.configs[index].name);
const items = Object.assign(this.deployment.configs.slice(), {
[originalIndex]: this.configs.find(c => c.name === this.deployment.configs[originalIndex].name),
});
this.deployment.configs = items;
this.store.dispatch(new fromStore.UpdateDeployment(this.deployment));
}
public drop(event: CdkDragDrop<ConfigWrapper<ConfigData>[]>) {
if (event.container.id === 'deployment-list' && event.previousContainer.id === 'store-list' && !event.item.data.savedInBackend) {
alert(this.UNSAVED_ITEM_ALERT);
return;
}
if (event.container.id === 'deployment-list') {
if (event.previousContainer.id === 'store-list') {
// In the case that the list has been filtered select the appropriate item to copy over
const index = this.configs.findIndex(e => e.name === this.filteredConfigs[event.previousIndex].name);
copyArrayItem(event.previousContainer.data,
event.container.data,
index,
event.currentIndex
);
} else if (event.previousContainer.id === 'deployment-list') {
const prevIndex = this.deployment.configs.findIndex(e =>
e.name === this.filteredDeployment.configs[event.previousIndex].name);
const currIndex = this.deployment.configs.findIndex(e =>
e.name === this.filteredDeployment.configs[event.currentIndex].name);
moveItemInArray(event.container.data, prevIndex, currIndex);
}
this.store.dispatch(new fromStore.UpdateDeployment(cloneDeep(this.deployment)));
}
}
public onView(id: number, releaseId: number = undefined) {
this.dialog.open(JsonViewerComponent, {
data: {
config1: releaseId === undefined ? undefined
: this.editorService.getLoader(this.serviceName)
.unwrapOptionalsFromArrays(cloneDeep(this.filteredDeployment.configs[releaseId])),
config2: this.editorService.getLoader(this.serviceName).unwrapOptionalsFromArrays(cloneDeep(this.filteredConfigs[id])),
},
});
}
public onEdit(id: number) {
const index = this.configs.findIndex(r => r.name === this.filteredConfigs[id].name);
if (index > -1) {
this.store.dispatch(new fromStore.Go({
path: [this.serviceName, 'edit', index],
}));
}
}
public addToDeployment(id: number) {
const item: ConfigWrapper<ConfigData> = this.configs.find(r => r.name === this.filteredConfigs[id].name);
if (item !== undefined) {
if (!item.savedInBackend) {
alert(this.UNSAVED_ITEM_ALERT);
return;
}
if (this.deployment.configs.find(r => r.name === item.name) === undefined) {
const updatedDeployment: Deployment<ConfigWrapper<ConfigData>> = cloneDeep(this.deployment);
updatedDeployment.configs.push(item);
this.store.dispatch(new fromStore.UpdateDeployment(updatedDeployment));
}
}
}
public onRemove(id: number) {
const index = this.deployment.configs
.findIndex(r => r.name === this.filteredDeployment.configs[id].name);
transferArrayItem(this.deployment.configs, [], index, 0);
this.store.dispatch(new fromStore.UpdateDeployment(cloneDeep(this.deployment)));
}
public onClickCreate() {
this.store.dispatch(new fromStore.AddConfig({
isNew: true,
configData: undefined,
savedInBackend: false,
name: `new_entry_${this.configs.length}`,
version: 0,
description: 'no description',
author: 'no author',
}));
}
public onDeploy() {
this.store.dispatch(new fromStore.LoadPullRequestStatus());
this.pullRequestPending$.pipe(skip(1), take(1)).subscribe(a => {
if (!a.pull_request_pending) {
const dialogRef = this.dialog.open(DeployDialogComponent, {
data: cloneDeep(this.deployment),
});
dialogRef.afterClosed().subscribe((results: Deployment<ConfigWrapper<ConfigData>>) => {
if (results && results.configs.length > 0) {
if (results.deploymentVersion >= 0) {
this.store.dispatch(new fromStore.SubmitRelease(results));
}
}
});
} else {
this.snackbar.openNotification(this.PR_OPEN_MESSAGE);
}
});
}
public onFilterMine($event: boolean) {
this.store.dispatch(new fromStore.FilterMyConfigs($event));
}
public onRefreshPrStatus() {
this.store.dispatch(new fromStore.LoadPullRequestStatus());
}
public duplicateItemCheck(item: CdkDrag<ConfigWrapper<ConfigData>>, deployment: CdkDropList<ConfigWrapper<ConfigData>[]>) {
return deployment.data.find(d => d.name === item.data.name) === undefined
? true : false;
}
public noReturnPredicate() {
return false;
}
public onFilterUpgradable($event: boolean) {
this.store.dispatch(new fromStore.FilterUpgradable($event));
}
public onFilterUndeployed($event: boolean) {
this.store.dispatch(new fromStore.FilterUndeployed($event));
}
}

View File

@@ -0,0 +1,66 @@
<div mat-dialog-title class="title-row">
<mat-icon *ngIf="!validating && isValid === true">check</mat-icon>
<mat-icon *ngIf="!validating && isValid === false">cancel</mat-icon>
<h1>Submit Deployment</h1>
</div>
<div mat-dialog-content>
<div class="validation" *ngIf="validating; else validationDone">
<p> Validating </p>
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div>
<ng-template #validationDone>
<div *ngIf="isValid === true">
<p>Validation successful</p>
<p>Deploying to environment: {{ environment }}</p>
<mat-divider></mat-divider>
<p>Items in deployment:</p>
<div class="rules-list">
<ol>
<li *ngFor="let config of deployment.configs">
{{ config.name }} <span class="extra-info">(v{{ config.version }} - {{ config.author }})</span>
</li>
</ol>
</div>
<mat-divider></mat-divider>
</div>
<div *ngIf="isValid === false">
<p>Validation was unsuccessful</p>
<p>Status code: {{ statusCode }}</p>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Message
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<p class="error">{{ message }}</p>
</ng-template>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Exception
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<p class="error">{{ exception || 'No exception provided - it may be wrapped in the message'}}</p>
</ng-template>
</mat-expansion-panel>
<mat-divider></mat-divider>
</div>
</ng-template>
</div>
<ng-container *ngIf="uiMetadata.deployment.extras !== undefined">
<form [formGroup]="form">
<formly-form [model]="extrasData" [fields]="fields" [options]="options" [form]="form"></formly-form>
</form>
<button mat-raised-button color="primary" (click)="onValidate()">VALIDATE</button>
</ng-container>
<div mat-dialog-actions class="button-row">
<button mat-raised-button color="primary" *ngFor="let name of [serviceName$ |async]"
[disabled]="!testEnabled || !isValid" (click)="onClickTest()">TEST</button>
<button mat-raised-button color="primary" [disabled]="!isValid" (click)="onClickDeploy()">DEPLOY</button>
<button mat-raised-button color="accent" (click)="onClickClose()">CANCEL</button>
</div>

View File

@@ -0,0 +1,41 @@
.title-row {
display: flex;
justify-content: flex-start;
align-items: center;
h1 {
margin-left: 10px;
font-weight: 400;
font-size: 1.3em;
}
}
.rules-list {
margin-left: 20px;
max-height: 500px;
li {
margin: 10px 0;
.extra-info {
color: #bbb;
font-size: 0.9em;
}
}
}
.error {
font-family: monospace;
}
p {
margin: 10px 0;
line-height: 1.6em;
}
.button-row {
display: flex;
justify-content: flex-end;
align-items: center;
}
mat-divider {
margin: 20px 0;
}

View File

@@ -0,0 +1,131 @@
import { UiMetadataMap } from '../../model/ui-metadata-map';
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material';
import { StatusCode } from '@app/commons';
import { FormGroup } from '@angular/forms';
import { AppConfigService } from '@app/config';
import { EditorService } from '@app/editor.service';
import { ConfigData, ConfigWrapper, Deployment } from '@app/model';
import { FormlyJsonschema } from '@app/ngx-formly/formly-json-schema.service';
import { Store } from '@ngrx/store';
import { FormlyFieldConfig, FormlyFormOptions } from '@ngx-formly/core';
import * as fromStore from 'app/store';
import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { TestingDialogComponent } from '../testing-dialog/testing-dialog.component';
@Component({
selector: 're-deploy-dialog',
styleUrls: ['deploy-dialog.component.scss'],
templateUrl: 'deploy-dialog.component.html',
})
export class DeployDialogComponent {
deployment: Deployment<ConfigWrapper<ConfigData>>;
environment: string;
isValid = undefined;
validating = true;
message: string;
exception: string;
statusCode: string;
serviceName$: Observable<string>;
deploymentSchema = {};
serviceName: string;
uiMetadata: UiMetadataMap;
extrasData = {};
testEnabled = false;
public options: FormlyFormOptions = {formState: {}};
fields: FormlyFieldConfig[];
public form: FormGroup = new FormGroup({});
constructor(public dialogref: MatDialogRef<DeployDialogComponent>,
private config: AppConfigService,
public dialog: MatDialog,
private store: Store<fromStore.State>,
private service: EditorService,
private formlyJsonSchema: FormlyJsonschema,
@Inject(MAT_DIALOG_DATA) public data: Deployment<ConfigWrapper<ConfigData>>) {
this.store.select(fromStore.getServiceName).pipe(take(1)).subscribe(r => {
this.validating = false;
this.serviceName = r;
this.uiMetadata = this.config.getUiMetadata(r);
if (this.uiMetadata.deployment.extras !== undefined) {
this.fields = [this.formlyJsonSchema.toFieldConfig(this.createDeploymentSchema(r))];
} else {
this.service.getLoader(this.serviceName).validateRelease(data).pipe(take(1))
.subscribe(s => {
if (s !== undefined) {
this.statusCode = s.status_code;
if (s.status_code !== StatusCode.OK) {
this.message = s.attributes.message;
this.exception = s.attributes.exception;
}
this.validating = false;
this.isValid = s.status_code === StatusCode.OK ? true : false;
}
});
}
});
this.testEnabled = this.uiMetadata.testing.deploymentTestEnabled;
this.deployment = data;
this.environment = this.config.environment;
}
private createDeploymentSchema(serviceName: string): string {
const depSchema = this.service.getLoader(serviceName).originalSchema;
depSchema.properties[this.uiMetadata.deployment.config_array] = {};
delete depSchema.properties[this.uiMetadata.deployment.config_array];
delete depSchema.properties[this.uiMetadata.deployment.version];
depSchema.required = depSchema.required.filter(element => {
if (element !== this.uiMetadata.deployment.version && element !== this.uiMetadata.deployment.config_array) {
return true;
}
return false;
});
return depSchema;
}
onValidate() {
this.deployment = {...this.deployment, ...this.extrasData};
this.service.getLoader(this.serviceName)
.validateRelease(this.deployment).pipe(take(1)).subscribe(s => {
if (s !== undefined) {
this.statusCode = s.status_code;
if (s.status_code !== StatusCode.OK) {
this.message = s.attributes.message;
this.exception = s.attributes.exception;
}
this.validating = false;
this.isValid = s.status_code === StatusCode.OK ? true : false;
}
});
}
onClickDeploy() {
const deployment = this.extrasData !== undefined
? Object.assign(cloneDeep(this.deployment), this.extrasData)
: this.deployment;
this.dialogref.close(deployment);
}
onClickTest() {
this.dialog.open(TestingDialogComponent, {
data: {
configDto: this.deployment,
singleConfig: false,
},
})
}
onClickClose() {
this.dialogref.close();
}
}

View File

@@ -0,0 +1,9 @@
<div class="container">
<re-side-bar></re-side-bar>
<mat-divider [vertical]="true"></mat-divider>
<div class="viewport">
<re-generic-editor
[testEnabled]="testEnabled" [sensorFieldsEnabled]="sensorFieldsEnabled" [editorType]="serviceName">
</re-generic-editor>
</div>
</div>

View File

@@ -0,0 +1,25 @@
.container {
display: flex;
justify-content: space-between;
align-items: stretch;
height: 100%;
}
re-side-bar {
min-width: 400px;
max-width: 800px;
flex: 1;
overflow-y: auto;
padding: 0 10px;
}
mat-card-content {
max-height: 50px !important;
overflow-y: hidden !important;
text-overflow: ellipsis !important;
}
.viewport {
flex: 2;
overflow-y: auto;
}

View File

@@ -0,0 +1,31 @@
import { AppConfigService } from '../../config/app-config.service';
import { ChangeDetectionStrategy } from '@angular/core';
import { Component } from '@angular/core';
import * as fromStore from '@app/store';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-editor-view',
styleUrls: ['./editor-view.component.scss'],
templateUrl: './editor-view.component.html',
})
export class EditorViewComponent {
bootstrapped$: Observable<string>;
testEnabled = false;
sensorFieldsEnabled = false;
serviceName: string;
constructor(private store: Store<fromStore.State>, private config: AppConfigService) {
this.store.select(fromStore.getServiceName).pipe(take(1)).subscribe(r => {
this.serviceName = r
this.testEnabled = this.config.getUiMetadata(r).testing.perConfigTestEnabled;
this.sensorFieldsEnabled = this.config.getUiMetadata(r).enableSensorFields;
});
this.bootstrapped$ = this.store.select(fromStore.getBootstrapped);
}
}

View File

@@ -0,0 +1,37 @@
<ng-scrollbar class="scrollbar">
<mat-card>
<mat-card-title>
<div class="rule-title" *ngIf="config.isNew; else showTitle">
<mat-form-field>
<input matInput placeholder="Config name" [(ngModel)]="configName" name="Config name" />
</mat-form-field>
</div>
<ng-template #showTitle>
<h2>{{ (configs$ | async)[selectedIndex].name }}</h2>
</ng-template>
<div class="button-group">
<button *ngIf="!config.isNew" mat-raised-button color="accent" title="Clone this config" (click)="onClone()">Clone</button>
<button mat-raised-button color="accent" title="Test this config" (click)="onTest()" [disabled]="!testEnabled">Test config</button>
</div>
</mat-card-title>
<mat-card-subtitle>
<p>v{{ (configs$ | async)[selectedIndex].version }} by {{ (configs$ | async)[selectedIndex].author }}</p>
</mat-card-subtitle>
<mat-form-field *ngIf="sensorFieldsEnabled">
<mat-label>Data Source</mat-label>
<mat-select (selectionChange)="onSelectDataSource($event.value)" [value]="selectedSensor$ | async">
<mat-option *ngFor="let sensor of (sensors$ | async)" [value]="sensor.sensor_name">
{{sensor.sensor_name}}
</mat-option>
</mat-select>
</mat-form-field>
<mat-card-content>
<form [formGroup]="form" *ngIf="fields">
<formly-form [model]="configData" [fields]="fields" [options]="options" [form]="form"></formly-form>
</form>
</mat-card-content>
<mat-card-actions align="end">
<button class="submit-button" mat-raised-button color="accent" type="submit" [disabled]="!form.valid" (click)="onSubmit()">Submit</button>
</mat-card-actions>
</mat-card>
</ng-scrollbar>

View File

@@ -0,0 +1,79 @@
mat-card {
width: 100%;
max-width: 1000px;
min-width: 800px;
margin: 20px auto;
mat-card-title {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
max-width: 80%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 20pt;
}
}
}
textarea{
width: 100%;
height: 100%;
background-color: white;
color: inherit;
}
.mat-raised-button {
margin: 0 5px 0 0;
}
.example {
color: red;
}
::ng-deep .show-text .mat-input-element {
-webkit-text-fill-color: unset !important;
}
mat-form-field {
width: 100%;
}
fieldset {
background: rgba(255,255,255,0.1);
}
.rule-title {
display: flex;
width: 85%;
}
h2 {
padding: 0 20px 0 0;
}
pre {
font-size: 0.5em;
display: inline;
}
::ng-deep mat-expansion-panel {
margin: 20px 0 !important;
}
::ng-deep .mat-card-header-text {
margin: 0 !important;
}
::ng-deep material-tabs-widget>.ng-star-inserted {
margin-top: 20px !important;
}
.scrollbar {
--scrollbar-thumb-color: #868686;
--scrollbar-thumb-hover-color: #a1a1a1;
}
.submit-button {
margin-right: 1em;
}

View File

@@ -0,0 +1,292 @@
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormGroup } from '@angular/forms';
import { MatDialog } from '@angular/material';
import { AppConfigService } from '@app/config';
import { ConfigData, ConfigWrapper, SensorFields } from '@app/model';
import { PopupService } from '@app/popup.service';
import { Store } from '@ngrx/store';
import { FormlyFieldConfig, FormlyFormOptions } from '@ngx-formly/core';
import * as fromStore from 'app/store';
import { cloneDeep } from 'lodash';
import * as omitEmpty from 'omit-empty';
import { Observable, of, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { SubmitDialogComponent } from '..';
import { EditorService } from '../../editor.service';
import { UiMetadataMap } from '../../model/ui-metadata-map';
import { FormlyJsonschema } from '../../ngx-formly/formly-json-schema.service';
import * as JsonPointer from '../../ngx-formly/util/jsonpointer.functions';
import { TestingDialogComponent } from '../testing-dialog/testing-dialog.component';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-generic-editor',
styleUrls: ['./editor.component.scss'],
templateUrl: './editor.component.html',
})
export class EditorComponent implements OnInit, OnDestroy {
public ngUnsubscribe = new Subject();
public configs$: Observable<ConfigWrapper<ConfigData>[]>;
public selectedIndex$: Observable<number>;
public schema$: Observable<any>;
public user$: Observable<string>;
public configName: string;
public selectedIndex: number;
public configs: ConfigWrapper<ConfigData>[];
public config: ConfigWrapper<ConfigData> = undefined;
public configData: ConfigData = {};
public schema: any = {};
public enabled = 0;
public user: string;
public sensors$: Observable<SensorFields[]> = of([]);
public selectedSensor$: Observable<string>;
private metaDataMap: UiMetadataMap;
public fields: FormlyFieldConfig[];
public options: FormlyFormOptions = {};
public sensors: SensorFields[] = [];
private serviceName: string;
private dynamicFieldsMap: Map<string, string>;
public form: FormGroup = new FormGroup({});
private readonly NO_INPUT_MESSAGE = 'No data inputted to form';
private readonly NO_NAME_MESSAGE = 'A name must be provided';
private readonly UNIQUE_NAME_MESSAGE = 'Config name must be unique';
private readonly SPACE_IN_NAME_MESSAGE = 'Config names cannot contain spaces';
@Input() testEnabled: boolean;
@Input() sensorFieldsEnabled: boolean;
@Input() editorType: string;
constructor(public store: Store<fromStore.State>, public dialog: MatDialog, public snackbar: PopupService,
private appConfigService: AppConfigService, private formlyJsonschema: FormlyJsonschema, private editorService: EditorService) {
this.configs$ = this.store.select(fromStore.getConfigs);
this.selectedIndex$ = this.store.select(fromStore.getSelectedConfig);
this.schema$ = this.store.select(fromStore.getSchema);
this.user$ = this.store.select(fromStore.getCurrentUser);
this.store.select(fromStore.getBootstrapped).pipe(take(1)).subscribe(s => this.serviceName = s);
this.store.select(fromStore.getDynamicFieldsMap).pipe(takeUntil(this.ngUnsubscribe)).subscribe(s => this.dynamicFieldsMap = s);
}
ngOnInit() {
this.schema$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(
s => {
this.fields = [this.formlyJsonschema.toFieldConfig(s.schema)];
});
this.metaDataMap = this.appConfigService.getUiMetadata(this.editorType);
this.configs$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(r => {
this.configs = <ConfigWrapper<ConfigData>[]>r;
if (this.config && this.selectedIndex) {
this.config = this.configs[this.selectedIndex];
}
});
this.store.select(fromStore.getSensorListFromDataSource).pipe(takeUntil(this.ngUnsubscribe)).subscribe(
s => this.sensors = s
);
this.selectedIndex$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(r => {
this.store.dispatch(new fromStore.SelectDataSource(undefined));
if (this.config && this.configData) {
this.pushRuleUpdateToState();
}
this.selectedIndex = r;
this.config = this.configs[r];
this.configData = cloneDeep(this.config.configData) || {};
this.configName = this.config.name;
this.options.formState = {
mainModel: this.configData,
sensorFields: this.sensors,
};
});
this.user$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(u => this.user = u);
if (this.sensorFieldsEnabled) {
this.sensors$ = this.store.select(fromStore.getSensorFields);
this.store.dispatch(new fromStore.LoadCentrifugeFields());
this.selectedSensor$ = this.store.select(fromStore.getDataSource);
this.selectedSensor$.subscribe(s => this.options.formState.sensorFields = this.sensors)
}
}
ngOnDestroy() {
if (this.config) {
this.store.select(fromStore.getServiceName).pipe(take(1))
.subscribe(serviceName => {
if (serviceName === this.editorType) {
this.pushRuleUpdateToState();
}
})
}
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
}
private cleanConfigData(configData: ConfigData): ConfigData {
let cfg = this.removeFieldsWhichShouldBeHidden(this.dynamicFieldsMap, configData);
cfg = this.editorService.getLoader(this.serviceName).produceOrderedJson(cfg, '/');
// recursively removes null, undefined, empty objects, empty arrays from the object
cfg = omitEmpty(cfg);
return cfg;
}
pushRuleUpdateToState(): ConfigWrapper<ConfigData> {
const cleanedConfigData = this.cleanConfigData(this.configData);
const configToUpdate = cloneDeep(this.config);
configToUpdate.configData = cloneDeep(cleanedConfigData);
if (this.config.isNew) {
configToUpdate.configData[this.metaDataMap.name] = configToUpdate.name = this.configName;
configToUpdate.configData[this.metaDataMap.version] = configToUpdate.version = 0;
configToUpdate.configData[this.metaDataMap.author] = configToUpdate.author = this.user;
} else {
configToUpdate.configData[this.metaDataMap.name] = configToUpdate.name = this.config.name;
configToUpdate.configData[this.metaDataMap.version] = configToUpdate.version = this.config.version;
configToUpdate.configData[this.metaDataMap.author] = configToUpdate.author = this.config.author;
}
configToUpdate.description = configToUpdate.configData[this.metaDataMap.description];
// check if rule has been changed, mark unsaved
if (JSON.stringify(this.config.configData) !== JSON.stringify(configToUpdate.configData)) {
configToUpdate.savedInBackend = false;
}
const newConfigs = Object.assign(this.configs.slice(), {
[this.selectedIndex]: configToUpdate,
});
this.store.dispatch(new fromStore.UpdateConfigs(newConfigs));
return configToUpdate;
}
private removeFieldsWhichShouldBeHidden(functionsMap: Map<string, string>, cfg): ConfigData {
let data = cloneDeep(cfg);
const functionsMapKeys = [];
const functionsMapItr = functionsMap.keys();
for (let i = 0; i < functionsMap.size; ++i) {
functionsMapKeys.push(functionsMapItr.next().value);
}
for (const stringPath of functionsMapKeys) {
const path = JsonPointer.JsonPointer.parse(stringPath);
// find the lengths of arrays of objects so they can be iterated through with conditional function
const arrayIndices = [];
for (let j = 0; j < path.length; j++) {
// in generic path '-' corresponds to an array index
if (path[j] === '-') {
const obj = JsonPointer.JsonPointer.get(data, path.slice(0, j));
if (obj !== undefined) {
arrayIndices.push(obj.length - 1);
}
}
}
if (arrayIndices.length === 0) {
this.removeFromDataIfFuncFalse(data, {}, functionsMap, stringPath);
}
// TODO replace this with generic implementation where arrays of arrays is possible in JSON structure
for (let j = 0; j <= arrayIndices[0]; j++) {
this.removeFromDataIfFuncFalse(data, {parent: { parent: { key: j}, key: j } }, functionsMap, stringPath);
}
}
return data;
}
private removeFromDataIfFuncFalse(data, field, functionsMap: Map<string, string>, stringPath: string) {
try {
const func = functionsMap.get(stringPath);
const dynFunc = new Function('model', 'localfield', 'field', func);
if (dynFunc(data, null, field)) {
let indexedPointer;
if (stringPath.includes('-')) {
indexedPointer = JsonPointer.JsonPointer.toIndexedPointer(stringPath, [field.parent.parent.key]);
} else {
indexedPointer = stringPath;
}
JsonPointer.JsonPointer.remove(data, indexedPointer);
}
} catch {
console.warn('Something went wrong with condition evaluation when cleaning form', stringPath);
}
}
onSubmit() {
const config = this.pushRuleUpdateToState();
if (!config.configData) {
this.snackbar.openNotification(this.NO_INPUT_MESSAGE);
return;
}
if (config.isNew) {
if (this.configName === undefined || this.configName === '') {
this.snackbar.openNotification(this.NO_NAME_MESSAGE);
return;
}
if (this.configs.find(f => config !== f && f.configData && f.name === this.configName) !== undefined) {
this.snackbar.openNotification(this.UNIQUE_NAME_MESSAGE);
return;
}
if (this.configName.includes(' ')) {
this.snackbar.openNotification(this.SPACE_IN_NAME_MESSAGE);
}
}
this.form = new FormGroup({});
this.dialog.open(SubmitDialogComponent, {
data: {
...config,
name: this.configName,
},
}).afterClosed().subscribe((submittedData: ConfigData) => {
if (submittedData) {
this.config = config;
if (config.isNew) {
this.store.dispatch(new fromStore.SubmitNewConfig(config));
} else {
this.store.dispatch(new fromStore.SubmitConfigEdit(config));
}
}
});
}
public onClone() {
const newConfig: ConfigWrapper<ConfigData> = {
isNew: true,
configData: Object.assign({}, cloneDeep(this.config.configData), {
[this.metaDataMap.name]: `${this.config.name}_clone`,
[this.metaDataMap.version]: 0,
}),
savedInBackend: false,
name: `${this.config.name}_clone`,
author: this.user,
version: 0,
description: this.config.description,
};
this.store.dispatch(new fromStore.AddConfig(newConfig));
}
public onTest() {
const currentConfig = this.pushRuleUpdateToState().configData;
this.dialog.open(TestingDialogComponent, {
data: {
configDto: currentConfig,
singleConfig: true,
},
});
}
public onSelectDataSource(dataSource: string) {
this.store.dispatch(new fromStore.SelectDataSource(dataSource));
}
}

View File

@@ -0,0 +1,10 @@
<h1 mat-dialog-title>Error Details</h1>
<div mat-dialog-content>
<p class="details">An error occurred when making a request. Please contact SecDev support for help.</p>
<mat-divider></mat-divider>
<p class="error">{{data.name}}, {{data.message}}</p>
<p class="error">{{data.error.attributes.message}}</p>
</div>
<div mat-dialog-actions>
<button mat-button class="button-layout" (click)="onClickClose()" tabindex="-1">CLOSE</button>
</div>

View File

@@ -0,0 +1,17 @@
.details {
font-family: Roboto, "Helvetica Neue", sans-serif;
}
.error {
font-family: monospace;
margin: 20px 0;
}
.button-layout {
margin-right: 0px;
margin-left: auto;
}
mat-divider {
margin: 10px 0;
}

View File

@@ -0,0 +1,18 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
@Component({
selector: 're-error-dialog',
styleUrls: ['error-dialog.component.scss'],
templateUrl: 'error-dialog.component.html',
})
export class ErrorDialogComponent {
constructor(public dialogref: MatDialogRef<ErrorDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any) {
console.log(data);
}
onClickClose() {
this.dialogref.close();
}
}

View File

@@ -0,0 +1,10 @@
export { EditorViewComponent } from './editor-view/editor-view.component';
export { SideBarComponent } from './side-bar/side-bar.component';
export { NavBarComponent } from './nav-bar/nav-bar.component';
export { JsonViewerComponent } from './json-viewer/json-viewer.component';
export { ConfigManagerComponent } from './config-manager/config-manager.component';
export { SubmitDialogComponent } from './submit-dialog/submit-dialog.component';
export { DeployDialogComponent } from './deploy-dialog/deploy-dialog.component';
export { ErrorDialogComponent } from './error-dialog/error-dialog.component';
export { LandingPageComponent } from './landing-page/landing-page.component';
export { SearchComponent } from './search/search.component';

View File

@@ -0,0 +1,90 @@
import { Component, OnDestroy } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { delay, filter, map, switchMap, takeUntil, withLatestFrom } from 'rxjs/operators';
import { ConfigManagerComponent, EditorViewComponent, LandingPageComponent } from '..';
import { HomeComponent, PageNotFoundComponent } from '../../containers';
import { EditorService } from '../../editor.service';
import * as fromGuards from '../../guards';
import { RepoResolver, ViewResolver } from '../../guards';
import * as fromStore from '../../store';
import { SetServiceNames } from '../../store';
@Component({
template: '',
})
export class InitComponent implements OnDestroy {
private readonly specificEditorRoutes: Routes = [
{
path: '',
component: ConfigManagerComponent,
resolve: {
message: ViewResolver,
},
},
{
path: 'edit',
redirectTo: 'edit/0',
},
{
component: EditorViewComponent,
path: 'edit/:id',
canActivate: [fromGuards.ConfigStoreGuard],
},
]
private appRoutes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'home',
},
{
path: 'home',
component: LandingPageComponent,
resolve: {
message: RepoResolver,
},
},
];
private ngUnsubscribe = new Subject();
constructor(private router: Router, private store: Store<fromStore.State>,
private service: EditorService) {
this.service.getServiceNames().pipe(takeUntil(this.ngUnsubscribe)).subscribe((serviceList: string[]) => {
const routes = this.appRoutes;
serviceList.forEach(r => {
routes.push({path: r, component: HomeComponent, children: this.specificEditorRoutes})
});
routes.push({
component: PageNotFoundComponent,
path: '**',
});
this.router.resetConfig(routes);
this.store.dispatch(new SetServiceNames(serviceList));
this.service.createLoaders();
})
this.store.select(fromStore.getServiceNames).pipe(
takeUntil(this.ngUnsubscribe),
switchMap(_ => {
this.router.navigate([this.router.url]);
return this.store.select(fromStore.getBootstrapped).pipe(
withLatestFrom(this.store.select(fromStore.getServiceName)),
filter(([bootstrapped, serviceName]) => bootstrapped !== undefined && bootstrapped === serviceName),
delay(100),
map(() => {
this.router.navigate([this.router.url]);
})
)
})
).subscribe();
}
ngOnDestroy() {
this.ngUnsubscribe.next();
}
}

View File

@@ -0,0 +1,11 @@
<div class="viewer">
<td-ngx-text-diff
[left]="(leftContent === undefined ? rightContent?.configData : leftContent?.configData) | json"
[right]="rightContent?.configData | json"
[loading]="false"
[format]="leftContent === undefined ? 'LineByLine' : 'SideBySide'"
[compareRowsStyle]="{'max-height': '1000px', 'overflow': 'auto'}"
(compareResults)="onCompareResults($event)"
>
</td-ngx-text-diff>
</div>

View File

@@ -0,0 +1,15 @@
.viewer{
width: 100%;
font-size: 1.1em;
line-height: 1.2em;
}
h4 {
margin-bottom: 20px;
}
pre {
overflow-y: auto;
overflow-x: auto;
line-height: 1.6em;
}

View File

@@ -0,0 +1,37 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy } from '@angular/core';
import { Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { ConfigData, ConfigWrapper } from '@app/model';
export interface DiffContent {
leftContent: string;
rightContent: string;
}
export interface DiffContent {
leftContent: string;
rightContent: string;
}
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-json-viewer',
styleUrls: ['./json-viewer.component.scss'],
templateUrl: './json-viewer.component.html',
})
export class JsonViewerComponent {
public leftContent: ConfigWrapper<ConfigData>;
public rightContent: ConfigWrapper<ConfigData>;
constructor(public dialogRef: MatDialogRef<JsonViewerComponent>,
@Inject(MAT_DIALOG_DATA) public data: any) {
this.leftContent = data.config1;
this.rightContent = data.config2;
}
onCompareResults($event) {
// noop
}
}

View File

@@ -0,0 +1,15 @@
<div class="centre-box">
<div>
<h1>Config Editor UI</h1>
<div class="rulesets">
<mat-card *ngFor="let ruleset of serviceNames$ | async" class="ruleset">
<h2>{{ ruleset | titlecase}} </h2>
<button mat-raised-button color="primary" [routerLink]="['/' + ruleset]">{{ ruleset | titlecase }} Manager</button>
<ng-container *ngIf="repositoryLinks[ruleset]">
<a mat-raised-button color="accent" [href]="repositoryLinks[ruleset].rule_store_url">Working Repo</a>
<a mat-raised-button color="accent" [href]="repositoryLinks[ruleset].rules_release_url">Release Repo</a>
</ng-container>
</mat-card>
</div>
</div>
</div>

View File

@@ -0,0 +1,46 @@
.centre-box {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
min-height: 100vh;
font-size: 2em;
}
h1 {
font-weight: 400;
margin-bottom: 50px;
color: #eee;
font-size: 1.5em;
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
}
button {
margin: 20px auto;
display: block;
}
.rulesets {
display: flex;
justify-content: center;
align-items: stretch;
flex-wrap: wrap;
}
.ruleset {
margin: 25px;
padding: 40px 50px;
h2 {
font-size: 1em;
margin-bottom: 20px;
}
a {
display: inline-block;
margin: 5px;
}
}
::ng-deep .rulesets .mat-raised-button {
font-size: 18px !important;
line-height: 46px !important;
}

View File

@@ -0,0 +1,35 @@
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { RepositoryLinks } from '@app/model';
import * as fromStore from '@app/store';
import { Store } from '@ngrx/store';
import { Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.Default,
selector: 're-landing-page',
styleUrls: ['./landing-page.component.scss'],
templateUrl: './landing-page.component.html',
})
export class LandingPageComponent implements OnInit, OnDestroy {
private ngUnsubscribe = new Subject();
serviceNames$: Observable<string[]>;
repositoryLinks: { [name: string]: RepositoryLinks } = {};
constructor(private store: Store<fromStore.State>) { }
ngOnInit(): void {
this.serviceNames$ = this.store.select(fromStore.getServiceNames);
this.store.select(fromStore.getRepositoryLinks).pipe(takeUntil(this.ngUnsubscribe)).subscribe(links => {
if (links) {
this.repositoryLinks = links.reduce((pre, cur) => ({ ...pre, [cur.rulesetName]: cur }), {});
}
});
}
ngOnDestroy(): void {
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
}
}

View File

@@ -0,0 +1,20 @@
<mat-toolbar>
<mat-toolbar-row class="nav-content">
<div>
<img class="logo" src="/assets/grbig.png" [class.done-spin]="!(loading$ | async)">
<h1> {{ serviceName$ | async | titlecase }} Editor</h1>
<h1 class="env"> {{ environment | titlecase }} </h1>
</div>
<div>
<button mat-button color="accent" [routerLink]="['/']">Home</button>
<mat-button-toggle-group>
<mat-button-toggle *ngFor="let serviceName of serviceNames$ | async" [routerLink]="['/' + serviceName]" [checked]="serviceName === (serviceName$ | async)">
{{ serviceName | titlecase }}
</mat-button-toggle>
</mat-button-toggle-group>
<mat-icon>account_box</mat-icon>
<h3>{{ user$ | async }}</h3>
</div>
</mat-toolbar-row>
</mat-toolbar>
<mat-progress-bar *ngIf="loading$ | async" mode="indeterminate" color="accent"></mat-progress-bar>

View File

@@ -0,0 +1,69 @@
.nav-content {
display: flex;
align-items: center;
justify-content: space-between;
color: #eee;
background: #212121;
font-size: 0.8em;
padding: 0 30px;
&>div {
display: flex;
justify-content: center;
align-items: center;
}
img {
display: block;
height: 50px;
animation: spin 1.2s cubic-bezier(.55,.31,.45,.69) infinite;
margin-left: 20px;
}
h1 {
font-size: 1.8em;
display: inline-block;
margin: 0 10px 0 25px;
}
button {
margin: 0 5px;
font-size: 1em;
}
mat-icon {
margin-left: 40px;
margin-right: 2px;
display: inline-block;
}
h3 {
font-weight: 400;
}
}
.env {
margin-left: 0 !important;
color: #ff9800 !important;
}
::ng-deep .mat-button-toggle-appearance-standard .mat-button-toggle-label-content {
line-height: 36px !important;
padding: 0 30px !important;
}
::ng-deep .mat-button-toggle-checked {
background: rgba(6, 167, 226, 0.9);
}
.done-spin {
animation-iteration-count: 1 !important;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(720deg);
}
}
mat-progress-bar {
position: absolute;
z-index: 1;
}

View File

@@ -0,0 +1,38 @@
import { Component } from '@angular/core';
import { ChangeDetectionStrategy } from '@angular/core';
import { AppConfigService } from '@app/config/app-config.service';
import { Store } from '@ngrx/store';
import * as fromStore from 'app/store';
import { Observable } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-nav-bar',
styleUrls: ['./nav-bar.component.scss'],
templateUrl: './nav-bar.component.html',
})
export class NavBarComponent {
user$: Observable<String>;
loading$: Observable<boolean>;
serviceName$: Observable<string>;
serviceNames$: Observable<string[]>;
environment: string;
constructor(private store: Store<fromStore.State>, private config: AppConfigService) {
this.user$ = this.store.select(fromStore.getCurrentUser);
this.loading$ = this.store.select(fromStore.getLoading);
this.serviceName$ = this.store.select(fromStore.getServiceName);
this.serviceNames$ = this.store.select(fromStore.getServiceNames);
this.environment = this.config.environment;
}
public onSelectView(view: string) {
this.store.dispatch(new fromStore.Go({
extras: {
queryParamsHandling: 'merge',
},
path: ['id', view],
query: {},
}));
}
}

View File

@@ -0,0 +1,14 @@
<div class="search-box">
<mat-icon>search</mat-icon>
<mat-form-field class="search-container" hintLabel="Search by name, author or labels">
<input matInput #searchBox [(ngModel)]="searchTerm" (keyup)="onSearch(searchTerm)" (keyup.escape)="onClearSearch()"/>
<button mat-button *ngIf="searchTerm" matSuffix mat-icon-button aria-label="Clear" (click)="onClearSearch()">
<mat-icon>close</mat-icon>
</button>
</mat-form-field>
<span class="checkboxes">
<mat-checkbox (change)="clickMyConfigs($event.checked)" [ngModel]="filterMyConfigs">My Edits</mat-checkbox>
<mat-checkbox (change)="clickNotDeployed($event.checked)" [ngModel]="filterUndeployed">Undeployed</mat-checkbox>
<mat-checkbox (change)="clickUpdatedConfigs($event.checked)" [ngModel]="filterUpgradable">Upgradable</mat-checkbox>
</span>
</div>

View File

@@ -0,0 +1,32 @@
.search-box {
display: flex;
justify-content: stretch;
align-items: stretch;
mat-icon {
margin: auto;
}
//margin: 10px;
}
mat-checkbox {
margin: auto;
margin-right: 20px;
color: #b5b5b5;
}
mat-form-field {
padding-right: 20px;
}
.mat-checkbox-layout {
color: #b5b5b5;
}
.checkboxes {
display: flex;
flex: 1;
}
.search-container {
flex: 3;
}

View File

@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-search',
styleUrls: ['./search.component.scss'],
templateUrl: './search.component.html',
})
export class SearchComponent implements OnInit {
@ViewChild('searchBox', { static: true }) searchBox;
@Input() searchTerm: string;
@Input() filterMyConfigs: boolean;
@Input() filterUndeployed: boolean;
@Input() filterUpgradable: boolean;
@Output() searchTermChange: EventEmitter<string> = new EventEmitter<string>();
@Output() myConfigsChange: EventEmitter<boolean> = new EventEmitter<boolean>();
@Output() undeployedConfigsChange: EventEmitter<boolean> = new EventEmitter<boolean>();
@Output() updatedConfigsChange: EventEmitter<boolean> = new EventEmitter<boolean>();
myConfigs = true;
ngOnInit(): void {
this.searchBox.nativeElement.focus();
}
public onSearch(searchTerm: string) {
this.searchTermChange.emit(searchTerm);
}
public onClearSearch() {
this.onSearch('');
this.searchTerm = '';
}
public clickMyConfigs($event: boolean) {
this.myConfigsChange.emit($event);
}
public clickNotDeployed($event: boolean) {
this.undeployedConfigsChange.emit($event);
}
public clickUpdatedConfigs($event: boolean) {
this.updatedConfigsChange.emit($event);
}
}

View File

@@ -0,0 +1,11 @@
<ng-scrollbar class="scrollbar">
<div class="back-button">
<button mat-raised-button color="primary" (click)="onReturn()" class="back-button-text">
<mat-icon>keyboard_arrow_left</mat-icon>Config Manager
</button>
</div>
<mat-divider></mat-divider>
<re-config-tile *ngFor="let config of (configs$ | async); index as i" [selected]="i == (selectedConfig$ |async)"
(click)="onSelect(i)" [config]="config" [disableButtons]="'True'">
</re-config-tile>
</ng-scrollbar>

View File

@@ -0,0 +1,24 @@
.back-button {
margin: 15px;
}
::ng-deep .back-button .mat-raised-button {
font-size: 1.2em;
}
mat-divider {
margin: 10px;
}
mat-card {
margin: 10px;
}
.scrollbar {
--scrollbar-thumb-color: #868686;
--scrollbar-thumb-hover-color: #a1a1a1;
}
re-rule-tile {
margin: inherit 5px;
}

View File

@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ConfigData, ConfigWrapper } from '@app/model';
import { Store } from '@ngrx/store';
import * as fromStore from 'app/store';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { AppConfigService } from '../../config';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-side-bar',
styleUrls: ['./side-bar.component.scss', '../config-manager/config-manager.component.scss'],
templateUrl: './side-bar.component.html',
})
export class SideBarComponent {
public configs$: Observable<ConfigWrapper<ConfigData>[]>;
public selectedConfig$: Observable<number>;
private serviceName: string;
constructor(public config: AppConfigService, private store: Store<fromStore.State>) {
this.configs$ = this.store.select(fromStore.getConfigs);
this.selectedConfig$ = this.store.select(fromStore.getSelectedConfig);
this.store.select(fromStore.getServiceName).pipe(take(1)).subscribe(name => this.serviceName = name);
}
public onReturn() {
this.store.dispatch(new fromStore.Go({
path: [this.serviceName],
}));
}
public onSelect(index: number) {
this.store.dispatch(new fromStore.Go({
path: [this.serviceName, 'edit', index],
}));
}
}

View File

@@ -0,0 +1,48 @@
<div mat-dialog-title class="title-row">
<mat-icon *ngIf="!validating && isValid">check</mat-icon>
<mat-icon *ngIf="!validating && !isValid">cancel</mat-icon>
<h1>Submit Config</h1>
</div>
<div mat-dialog-content>
<div class="validation" *ngIf="validating; else validationDone">
<p> Validating </p>
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
</div>
<ng-template #validationDone>
<div *ngIf="isValid; else invalidMessage">
<p>Config has been validated by the backend</p>
<p> Do you wish to submit {{ config.name }} to the store? </p>
</div>
<ng-template #invalidMessage>
<p>Config did not pass backend validation.</p>
<p>Status code: {{ statusCode }}</p>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Message
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<p class="error">{{ message }}</p>
</ng-template>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Exception
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<p class="error">{{ exception || 'No exception provided - it may be wrapped in the message'}}</p>
</ng-template>
</mat-expansion-panel>
</ng-template>
</ng-template>
<mat-divider></mat-divider>
</div>
<div mat-dialog-actions class="button-row">
<button mat-raised-button color="primary" [disabled]="!isValid" (click)="onClickSubmit()">SUBMIT</button>
<button mat-raised-button color="accent" (click)="onClickClose()">CANCEL</button>
</div>

View File

@@ -0,0 +1,25 @@
.title-row {
display: flex;
justify-content: flex-start;
align-items: center;
h1 {
margin-left: 10px;
font-weight: 400;
font-size: 1.3em;
}
}
.error {
font-family: monospace;
}
p {
margin: 10px 0;
line-height: 1.6em;
}
.button-row {
display: flex;
justify-content: flex-end;
align-items: center;
}

View File

@@ -0,0 +1,53 @@
import { Component, Inject } from '@angular/core';
import { OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { StatusCode } from '@app/commons/status-code';
import { ConfigData, ConfigWrapper, EditorResult, ExceptionInfo } from '@app/model';
import { Store } from '@ngrx/store';
import * as fromStore from 'app/store';
import { Observable } from 'rxjs';
@Component({
selector: 're-submit-dialog',
styleUrls: ['submit-dialog.component.scss'],
templateUrl: 'submit-dialog.component.html',
})
export class SubmitDialogComponent implements OnInit {
config: ConfigWrapper<ConfigData>;
configValidity$: Observable<EditorResult<ExceptionInfo>>;
message: string;
exception: string;
statusCode: string;
validating = true;
isValid = false;
constructor(public dialogref: MatDialogRef<SubmitDialogComponent>,
private store: Store<fromStore.State>,
@Inject(MAT_DIALOG_DATA) public data: ConfigWrapper<ConfigData>) {
this.configValidity$ = this.store.select(fromStore.getConfigValidity);
this.config = data;
this.store.dispatch(new fromStore.ValidateConfig(this.config));
}
ngOnInit() {
this.configValidity$.subscribe(v => {
if (v !== undefined) {
this.statusCode = v.status_code;
if (v.status_code !== StatusCode.OK) {
this.message = v.attributes.message;
this.exception = v.attributes.exception;
}
this.validating = false;
this.isValid = this.statusCode === StatusCode.OK;
}
});
}
onClickSubmit() {
this.dialogref.close(this.config);
}
onClickClose() {
this.dialogref.close();
}
}

View File

@@ -0,0 +1,42 @@
<div mat-dialog-title>
<div *ngIf="!isSingleConfig">
<h1>Test Deployment Configuration:</h1>
<div class="title">
<div *ngFor="let r of deploymentConfig.configs"><h3 class="list-item"> {{r?.name}} </h3></div>
</div>
</div>
<div *ngIf="isSingleConfig">
<h1>Test Config</h1>
<div class="title">
<h3 class="list-item"> {{deploymentConfig?.name}} </h3>
</div>
</div>
</div>
<div mat-dialog-content>
<div class="columns">
<div class="input">
<span class="title">
<h4><mat-icon class="left-icon">note</mat-icon>Event</h4>
<h4 class="help"><mat-icon [matTooltip]="EVENT_HELP">help</mat-icon></h4>
</span>
<textarea class="text-file" [(ngModel)]="alert" (keydown.Tab)="onTab($event)"></textarea>
</div>
<div class="console">
<span class="title">
<h4><mat-icon class="left-icon">computer</mat-icon>Test Output</h4>
<div *ngIf="testSuccess === 'fail'">
<h4 class="help"><mat-icon>cancel</mat-icon></h4>
</div>
<div *ngIf="testSuccess === 'success'">
<h4 class="help"><mat-icon>check</mat-icon></h4>
</div>
</span>
<pre>{{ output }}</pre>
</div>
</div>
</div>
<div mat-dialog-actions class="button-row">
<button mat-raised-button color="primary" (click)="beginTest()">Test Rule</button>
<button mat-raised-button (click)="onClickClose()">Close</button>
</div>

View File

@@ -0,0 +1,91 @@
pre {
max-width: 800px;
min-width: 800px;
min-height: 600px;
max-height: 600px;
overflow-x: auto;
overflow-y: auto;
background-color: black;
padding: 20px;
}
textarea {
min-width: 300px;
max-width: 800px;
min-height: 600px;
max-height: 600px;
overflow-x: auto;
overflow-y: auto;
background-color: black;
color: inherit;
border-radius: 5px;
overflow-wrap: inherit;
width: 800px;
}
.input {
display: block;
margin: 20px;
border: 1px solid rgba(255,255,255,0.3);
border-radius: 5px;
}
.console {
display: block;
margin: 20px;
border: 1px solid rgba(255,255,255,0.3);
border-radius: 5px;
}
h4 {
font-weight: 400;
font-family: monospace;
font-size: 1.2em;
margin: 5px;
display: flex;
justify-content: flex-start;
align-items: center;
}
.columns {
display: flex;
padding: 10px;
}
.config-options {
margin: 10px;
}
.button-row {
display: flex;
justify-content: flex-end;
align-items: center;
}
mat-form-field {
padding-bottom: 20px;
}
.title {
display: flex;
overflow: auto;
}
.help {
margin-left: auto;
}
.left-icon {
margin: 0 10px 0 5px;
}
::ng-deep .mat-tooltip {
white-space: pre-line;
font-size: 0.7em;
line-height: 1.4em;
}
.list-item {
padding: 10px;
}

View File

@@ -0,0 +1,154 @@
import { Inject } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material';
import { AppConfigService } from '@app/config';
import { EditorService } from '@app/editor.service';
import { ConfigData, Deployment, EditorResult } from '@app/model';
import { PopupService } from '@app/popup.service';
import { Store } from '@ngrx/store';
import * as fromStore from 'app/store';
import { of, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { ConfigLoaderService } from '../../config-loader.service';
import { ConfigTestResult } from '../../model/config-model';
@Component({
selector: 're-testing-dialog',
styleUrls: ['testing-dialog.component.scss'],
templateUrl: 'testing-dialog.component.html',
})
export class TestingDialogComponent implements OnDestroy, OnInit {
deploymentConfig: Deployment<ConfigData>;
alert: string;
output: string;
EVENT_HELP: string;
testSuccess = 'none';
private ngUnsubscribe = new Subject();
private service: ConfigLoaderService;
private env: string;
isSingleConfig: boolean;
constructor(public dialogref: MatDialogRef<TestingDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private editorService: EditorService,
public snackbar: PopupService,
private store: Store<fromStore.State>,
private appConfig: AppConfigService) {
this.store.select(fromStore.getServiceName).pipe(take(1)).subscribe(r => {
this.env = r;
this.service = this.editorService.getLoader(r);
this.EVENT_HELP = this.appConfig.getUiMetadata(this.env).testing.helpMessage;
})
this.deploymentConfig = data.configDto;
this.isSingleConfig = data.singleConfig;
}
ngOnInit() {
this.store.select(fromStore.getConfigTestingEvent).pipe(take(1)).subscribe(e => this.alert = e);
}
public beginTest() {
this.testSuccess = 'none';
if (this.alert === null || this.alert === undefined || this.alert === '') {
this.output = 'Error: please provide an alert to test the rule against';
this.testSuccess = 'fail';
return;
}
let event: any;
try {
// TODO remove this once test specification is done properly
if (this.env === 'parserconfig') {
event = {log: this.alert};
} else {
event = JSON.parse(this.alert);
}
} catch (e) {
this.output = 'Error in alert JSON:\n\n' + e;
this.testSuccess = 'fail';
return;
}
const testDto: any = {
files: [{
content:
this.deploymentConfig,
}],
event: event,
};
if (this.service) {
if (this.isSingleConfig) {
this.service.testSingleConfig(testDto).pipe(takeUntil(this.ngUnsubscribe))
.map(r => r)
.catch(err => {
this.ngUnsubscribe.next();
this.snackbar.openSnackBar(err, `Error could not contact ${this.env} backend`);
return of(null);
}).subscribe((b: EditorResult<ConfigTestResult>) => {
if (b === null) {
return;
}
if (b.status_code === 'OK') {
this.output = b.attributes.test_result_output;
} else {
if (b.attributes.message && b.attributes.exception) {
this.output = b.attributes.message + '\n' + b.attributes.exception;
} else if (b.attributes.message !== undefined) {
this.output = b.attributes.message;
} else if (b.attributes.exception !== undefined) {
this.output = b.attributes.exception;
}
}
this.testSuccess = b.attributes.test_result_complete ? 'success' : 'fail';
}
)
} else {
this.service.testDeploymentConfig(testDto).pipe(takeUntil(this.ngUnsubscribe))
.map(r => r)
.catch(err => {
this.ngUnsubscribe.next();
this.snackbar.openSnackBar(err, `Error could not contact ${this.env} backend`);
return of(null);
}).subscribe((b: EditorResult<ConfigTestResult>) => {
if (b === null) {
return;
}
if (b.status_code === 'OK') {
this.output = b.attributes.test_result_output;
} else {
if (b.attributes.message && b.attributes.exception) {
this.output = b.attributes.message + '\n' + b.attributes.exception;
} else if (b.attributes.message !== undefined) {
this.output = b.attributes.message;
} else if (b.attributes.exception !== undefined) {
this.output = b.attributes.exception;
}
}
this.testSuccess = b.attributes.test_result_complete ? 'success' : 'fail';
}
)
}
} else {
this.snackbar.openSnackBar(`Error, could not load ${this.env} service correctly,
this is likely due to not being able to fetch the service name`, 'Error loading testing service');
}
}
public onTab($event) {
return false;
}
onClickClose() {
this.dialogref.close();
}
ngOnDestroy() {
this.store.dispatch(new fromStore.StoreConfigTestingEvent(this.alert));
this.ngUnsubscribe.next();
}
}

View File

@@ -0,0 +1,35 @@
<div class="box" [class.selected-rule-box]="selected">
<div class="inline">
<div class="column-fixed" [hoverPopover]="config">
<span class="chip-bag">
<span #versionChip class="chip" [ngClass]="{'warn': !config.savedInBackend}">v{{config.version || '0' }}</span>
</span>
<h4 class="author">{{config.author}}</h4>
</div>
<mat-divider [vertical]="true"></mat-divider>
<span class="column">
<div class="inline-title">
<h3 class="rule-title">{{config.name}}</h3>
<span class="tags-column">
<div class="tag-chip" [matTooltip]="tag" *ngFor="let tag of config?.tags">
<div class="tag-text" *ngIf="tag !== null || tag !== ''">
{{tag}}
</div>
</div>
</span>
</div>
<h4 class="subtitle">{{config.description}}</h4>
</span>
<span class="buttons" *ngIf="!disableButtons">
<a (click)="editConfig()" [title]="'Edit Config'">
<mat-icon>edit</mat-icon>
</a>
<a (click)="viewConfig()" [title]="'View Json'">
<mat-icon>pageview</mat-icon>
</a>
<a (click)="addToDeployment()" [title]="'Add to Deployment'">
<mat-icon>arrow_forward</mat-icon>
</a>
</span>
</div>
</div>

View File

@@ -0,0 +1,167 @@
.chip {
display: inline-block;
margin: 0 auto;
padding: 2px 5px;
min-width: 40px;
text-align: center;
border-radius: 9999px;
color: #111;
background: rgb(180, 180, 180);
font-family: monospace;
pointer-events: none;
}
.tag-chip {
display: inline-block;
margin: 0 auto;
padding: 2px 5px;
min-width: 40px;
max-width: 200px;
text-align: center;
border-radius: 9999px;
color: #111;
background: #868686;
font-family: monospace;
margin-left: 10px;
}
.tag-text {
padding-top: 2px;
font-size: 8pt;
text-overflow: ellipsis;
overflow: hidden;
}
.inline {
display: flex;
}
.inline-title {
display: flex;
margin-top: 5px;
}
.column-fixed {
margin: 0 10px;
width: 60px;
min-width: 60px;
}
mat-divider {
margin-right: 10px;
}
.column {
overflow: hidden;
}
.tags-column {
overflow: hidden;
margin-left: 20px;
display: inline-flex;
white-space: nowrap;
}
.warn {
background: orange !important;
}
.title {
color: #83d3f1;
font-size: 18px;
font-weight: 200;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 400;
white-space: nowrap;
padding-top: 5px;
}
.rule-title {
color: #83d3f1;
font-size: 18px;
font-weight: 200;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 400;
white-space: nowrap;
padding-top: 5px;
display: contents;
}
.space {
flex: 5;
}
.author {
color: white;
font-size: 14px;
font-weight: 100;
flex: 1;
padding-top: 5px;
text-align: center;
}
.subtitle {
font-weight: 100;
padding-top: 3px;
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
flex: 5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.box {
background-color: rgb(68, 68, 68);
color: inherit;
padding: 5px;
box-shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 3px 0px rgba(0, 0, 0, 0.12);
border-radius: 5px;
margin-bottom: 5px
}
.buttons {
margin-right: 0px;
margin-left: auto;
opacity: 0;
display: flex;
justify-content: flex-end;
align-items: center;
padding: 10px;
transition: all 0.2s ease-in-out;
a {
margin: 0 5px;
color: rgb(180, 180, 180);
cursor: pointer;
transition: all 0.2s ease-in-out;
&:hover {
color: white;
}
}
.drag-hint {
cursor: move;
}
}
:hover .buttons {
opacity: 1;
}
.chip-bag {
margin-top: 5px;
display: flex;
pointer-events: none;
}
button {
line-height: 18px;
margin-top: 2px;
}
.selected-rule-box {
@extend .box;
box-shadow: 0 0 0 1px rgba(255,255,255,0.9) inset !important;
margin: 20px 0;
}

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { ConfigData, ConfigWrapper } from '../../model/config-model';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-config-tile',
styleUrls: ['./config-tile.component.scss'],
templateUrl: './config-tile.component.html',
})
export class ConfigTileComponent {
@Input() config: ConfigWrapper<ConfigData>;
@Input() disableButtons: boolean;
@Input() selected: boolean;
@Output() onEdit = new EventEmitter<number>();
@Output() onView = new EventEmitter<number>();
@Output() onAddToDeployment = new EventEmitter<number>();
constructor() {}
editConfig() {
this.onEdit.emit();
}
viewConfig() {
this.onView.emit();
}
addToDeployment() {
this.onAddToDeployment.emit();
}
}

View File

@@ -0,0 +1,24 @@
<div class="box">
<div class="inline">
<div class="column-fixed">
<span class="chip-bag">
<span class="chip">v{{config.version || '0' }}</span>
</span>
<h4 class="author">{{config.author}}</h4>
</div>
<mat-divider [vertical]="true"></mat-divider>
<span class="column">
<h3 class="title">{{config.name}}</h3>
<button *ngIf="config.versionFlag > 0" mat-button color="accent" (click)="viewDiff()">View Diff</button>
<button *ngIf="config.versionFlag > 0" mat-button color="accent" (click)="upgradeConfig()">Upgrade to v{{config.versionFlag}}</button>
</span>
<span class="buttons">
<a (click)="deleteConfig()" [title]="'Remove from Deployment'">
<mat-icon>delete</mat-icon>
</a>
<a [title]="'Reorder'" class="drag-hint">
<mat-icon>swap_vert</mat-icon>
</a>
</span>
</div>
</div>

View File

@@ -0,0 +1,32 @@
import { ConfigData, ConfigWrapper } from '../../model/config-model';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-deployment-tile',
styleUrls: ['./config-tile.component.scss'],
templateUrl: './deployment-tile.component.html',
})
export class DeploymentTileComponent {
@Input() config: ConfigWrapper<ConfigData>;
@Output() onDelete = new EventEmitter<number>();
@Output() onUpgrade = new EventEmitter<number>();
@Output() onViewDiff = new EventEmitter<any>();
constructor() {}
deleteConfig() {
this.onDelete.emit();
}
upgradeConfig() {
this.onUpgrade.emit();
}
viewDiff() {
this.onViewDiff.emit();
}
}

View File

@@ -0,0 +1,345 @@
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { AppConfigService } from './config/app-config.service';
import { IConfigLoaderService } from './editor.service';
import { SchemaDto } from './model';
import {
ConfigData,
ConfigTestDto,
ConfigTestResult,
ConfigWrapper,
ContentRuleFile,
Deployment,
DeploymentWrapper,
EditorResult,
ExceptionInfo,
GitFiles,
PullRequestInfo,
RepositoryLinks,
RepositoryLinksWrapper,
SchemaInfo,
} from './model/config-model';
import { Field } from './model/sensor-fields';
import { UiMetadataMap } from './model/ui-metadata-map';
import { cloneDeep } from 'lodash';
export class ConfigLoaderService implements IConfigLoaderService {
private optionalObjects: string[] = [];
private readonly uiMetadata: UiMetadataMap;
private labelsFunc: Function;
public originalSchema;
public modelOrder = {};
constructor(private http: HttpClient, private config: AppConfigService, private env: string) {
this.uiMetadata = this.config.getUiMetadata(this.env);
try {
this.labelsFunc = new Function('model', this.uiMetadata.labelsFunc);
} catch {
console.error('unable to parse labels funciton');
this.labelsFunc = () => [];
}
}
public getConfigs(): Observable<ConfigWrapper<ConfigData>[]> {
return this.http.get<EditorResult<GitFiles<any>>>(
`${this.config.serviceRoot}api/v1/${this.env}/configstore/configs`)
.map(result => {
if (result.attributes && result.attributes.files && result.attributes.files.length > 0) {
return result.attributes.files.map(file => ({
isNew: false,
configData: this.wrapOptionalsInArray(file.content),
savedInBackend: true,
name: file.content[this.uiMetadata.name],
description: file.content[this.uiMetadata.description],
author: file.content[this.uiMetadata.author],
version: file.content[this.uiMetadata.version],
versionFlag: -1,
isDeployed: false,
tags: this.labelsFunc(file.content),
fileHistory: file.file_history,
}));
}
throw new DOMException('bad format response when loading configs');
});
}
public getConfigsFromFiles(files: ContentRuleFile<any>[]): ConfigWrapper<ConfigData>[] {
const ret: ConfigWrapper<ConfigData>[] = [];
for (const file of files) {
ret.push({
isNew: false,
configData: this.wrapOptionalsInArray(file.content),
savedInBackend: true,
name: file.content[this.uiMetadata.name],
description: file.content[this.uiMetadata.description],
author: file.content[this.uiMetadata.author],
version: file.content[this.uiMetadata.version],
versionFlag: -1,
isDeployed: false,
tags: this.labelsFunc(file.content),
});
}
return ret;
}
private returnSubTree(tree, path: string): any {
let subtree = cloneDeep(tree);
path.split('.').forEach(node => {
subtree = subtree[node];
});
return subtree;
}
public getSchema(): Observable<SchemaDto> {
return this.http.get<EditorResult<SchemaInfo>>(`${this.config.serviceRoot}api/v1/${this.env}/configs/schema`)
.map(x => {
this.originalSchema = x.attributes.rules_schema;
try {
return this.returnSubTree(x, this.uiMetadata.perConfigSchemaPath);
} catch {
throw new Error('Call to schema endpoint didn\'t return the expected schema');
}
})
.map(schema => {
this.optionalObjects = []; // clear optional objects in case they have been set previously;
this.modelOrder = {}
this.wrapOptionalsInSchema(schema, '', '');
delete schema.properties[this.uiMetadata.name];
delete schema.properties[this.uiMetadata.author];
delete schema.properties[this.uiMetadata.version];
schema.required = schema.required.filter(
f => (f !== this.uiMetadata.name) && (f !== this.uiMetadata.author) && (f !== this.uiMetadata.version));
return { schema };
});
}
// function to go through the output json and reorder the properties such that it is consistent with the schema
public produceOrderedJson(configData: ConfigData, path: string) {
if (this.modelOrder[path]) {
const currentCfg = cloneDeep(configData)
configData = {};
for (const key of this.modelOrder[path]) {
configData[key] = currentCfg[key];
const searchPath = path === '/' ? path + key : path + '/' + key;
// ensure it has children
if (typeof(configData[key]) === typeof({}) && this.modelOrder[searchPath] !== undefined) {
if (configData[key].length === undefined) {
// is an object
const tempCopy = cloneDeep(configData[key])
configData[key] = {};
const tmpObj = {}
for (const orderedKey of this.modelOrder[searchPath]) {
if (tempCopy[orderedKey] !== undefined) {
tmpObj[orderedKey] = tempCopy[orderedKey];
}
}
configData[key] = tmpObj;
configData[key] = this.produceOrderedJson(configData[key], searchPath)
} else {
// is an array
const tmp = cloneDeep(configData[key]);
configData[key] = [];
for (let i = 0; i < tmp.length; ++i) {
configData[key].push(this.produceOrderedJson(tmp[i], searchPath));
}
}
}
}
}
return configData;
}
private wrapOptionalsInArray(obj: object) {
for (const optional of this.optionalObjects) {
this.findAndWrap(obj, optional);
}
return obj;
}
private findAndWrap(obj: any, optionalKey: string) {
if (typeof(obj) === typeof ({})) {
for (const key of Object.keys(obj)) {
if (key === optionalKey) {
obj[key] = [obj[key]];
return;
}
this.findAndWrap(obj[key], optionalKey);
}
}
}
private wrapOptionalsInSchema(obj: any, propKey?: string, path?: string): any {
if (obj === undefined || obj === null || typeof (obj) !== typeof ({})) {
return;
}
if (obj.type === 'object' && typeof(obj.properties) === typeof ({})) {
path = path.endsWith('/') ? path + propKey : path + '/' + propKey;
const requiredProperties = obj.required || [];
const props = Object.keys(obj.properties);
this.modelOrder[path] = props;
for (const property of props) {
const thingy = obj.properties[property];
const isRequired = requiredProperties.includes(property);
const isObject = thingy.type === 'object';
if (!isRequired && isObject) {
this.optionalObjects.push(property);
const sub = {...thingy};
thingy.type = 'array';
delete thingy.required;
delete thingy.properties;
delete thingy.title;
delete thingy.description;
if (sub['x-schema-form'] !== undefined) {
delete sub['x-schema-form'];
}
thingy.items = sub;
thingy.maxItems = 1;
this.wrapOptionalsInSchema(thingy.items, property, path);
} else {
this.wrapOptionalsInSchema(thingy, property, path);
}
}
} else if (obj.type === 'array') {
path = path === '/' ? path : path + '/';
if (obj.items.type === 'object') {
this.wrapOptionalsInSchema(obj.items, propKey, path);
}
} else if (obj.type === undefined && !obj.hasOwnProperty('properties')) {
path = path === '/' ? path + propKey : path + '/' + propKey;
for (const key of Object.keys(obj)) {
this.wrapOptionalsInSchema(obj[key], key, path);
}
}
}
public unwrapOptionalsFromArrays(obj: any) {
if (obj === undefined || obj === null || typeof (obj) !== typeof ({})) {
return obj;
}
for (const key of Object.keys(obj)) {
if (this.optionalObjects.includes(key)) {
obj[key] = obj[key] === [] || obj[key] === undefined || obj[key] === null ? undefined : obj[key][0];
}
}
for (const key of Object.keys(obj)) {
this.unwrapOptionalsFromArrays(obj[key]);
}
return obj;
}
public getPullRequestStatus(): Observable<PullRequestInfo> {
return this.http.get<EditorResult<PullRequestInfo>>(`${this.config.serviceRoot}api/v1/${this.env}/configstore/release/status`)
.map(result => result.attributes);
}
public getRelease(): Observable<DeploymentWrapper> {
return this.http.get<EditorResult<GitFiles<any>>>
(`${this.config.serviceRoot}api/v1/${this.env}/configstore/release`)
.map(result => result.attributes.files[0])
.map(result => (
{
deploymentHistory: result.file_history,
storedDeployment: {
deploymentVersion: result.content[this.uiMetadata.deployment.version],
configs: result.content[this.uiMetadata.deployment.config_array].map(configData => ({
isNew: false,
configData: this.wrapOptionalsInArray(configData),
savedInBackend: true,
name: configData[this.uiMetadata.name],
description: configData[this.uiMetadata.description],
author: configData[this.uiMetadata.author],
version: configData[this.uiMetadata.version],
versionFlag: -1,
tags: this.labelsFunc(configData),
})),
},
}
))
}
public getRepositoryLinks(): Observable<RepositoryLinks> {
return this.http.get<EditorResult<RepositoryLinksWrapper>>(
`${this.config.serviceRoot}api/v1/${this.env}/configstore/repositories`)
.map(result => ({
...result.attributes.rules_repositories,
rulesetName: this.env,
}))
}
public validateConfig(config: ConfigWrapper<ConfigData>): Observable<EditorResult<ExceptionInfo>> {
const json = JSON.stringify(this.unwrapOptionalsFromArrays(cloneDeep(config.configData)), null, 2);
return this.http.post<EditorResult<ExceptionInfo>>(
`${this.config.serviceRoot}api/v1/${this.env}/configs/validate?singleConfig=true`, json);
}
public validateRelease(deployment: Deployment<ConfigWrapper<ConfigData>>): Observable<EditorResult<ExceptionInfo>> {
const validationFormat = this.marshalDeploymentFormat(deployment);
const json = JSON.stringify(validationFormat, null, 2);
return this.http.post<EditorResult<ExceptionInfo>>(`${this.config.serviceRoot}api/v1/${this.env}/configs/validate`, json);
}
public submitRelease(deployment: Deployment<ConfigWrapper<ConfigData>>): Observable<EditorResult<ExceptionInfo>> {
const releaseFormat = this.marshalDeploymentFormat(deployment);
const json = JSON.stringify(releaseFormat, null, 2);
return this.http.post<EditorResult<ExceptionInfo>>(`${this.config.serviceRoot}api/v1/${this.env}/configstore/release`, json);
}
public submitConfigEdit(config: ConfigWrapper<ConfigData>): Observable<EditorResult<GitFiles<ConfigData>>> {
const json = JSON.stringify(this.unwrapOptionalsFromArrays(cloneDeep(config.configData)), null, 2);
return this.http.put<EditorResult<GitFiles<ConfigData>>>(
`${this.config.serviceRoot}api/v1/${this.env}/configstore/configs`, json);
}
public submitNewConfig(config: ConfigWrapper<ConfigData>): Observable<EditorResult<GitFiles<ConfigData>>> {
const json = JSON.stringify(this.unwrapOptionalsFromArrays(cloneDeep(config.configData)), null, 2);
return this.http.post<EditorResult<GitFiles<ConfigData>>>(
`${this.config.serviceRoot}api/v1/${this.env}/configstore/configs`, json);
}
public getFields(): Observable<Field[]> {
return this.http.get<EditorResult<any>>(
`${this.config.serviceRoot}api/v1/${this.env}/configs/fields`)
.map(f => f.attributes.fields);
}
public testDeploymentConfig(testDto: ConfigTestDto): Observable<EditorResult<ConfigTestResult>> {
testDto.files[0].content = this.marshalDeploymentFormat(testDto.files[0].content);
return this.http.post<EditorResult<any>>(
`${this.config.serviceRoot}api/v1/${this.env}/configs/test?singleConfig=false`, testDto)
}
public testSingleConfig(testDto: ConfigTestDto): Observable<EditorResult<ConfigTestResult>> {
testDto.files[0].content = this.unwrapOptionalsFromArrays(cloneDeep(testDto.files[0].content));
return this.http.post<EditorResult<any>>(
`${this.config.serviceRoot}api/v1/${this.env}/configs/test?singleConfig=true`, testDto)
}
public marshalDeploymentFormat(deployment: Deployment<ConfigWrapper<ConfigData>>): any {
const d = cloneDeep(deployment);
delete d.deploymentVersion;
delete d.configs;
return Object.assign(d, {
[this.uiMetadata.deployment.version]: deployment.deploymentVersion,
[this.uiMetadata.deployment.config_array]:
deployment.configs.map(config => this.unwrapOptionalsFromArrays(cloneDeep(config.configData))),
});
}
}

View File

@@ -0,0 +1,59 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/toPromise';
import { ConfigData } from '../model/config-model';
import { UiMetadataMap } from '../model/ui-metadata-map';
import { EditorConfig } from './editor-config';
@Injectable({
providedIn: 'root',
})
export class AppConfigService {
private config: EditorConfig;
private uiMetadata: UiMetadataMap;
constructor(private http: HttpClient) { }
// This gets called on startup and APP_INITIALIZER will wait for the promise to resolve
public loadConfig(): Promise<any> {
return this.http.get('config.json')
.toPromise()
.then((r: ConfigData) => {
// tslint:disable-next-line:no-console
console.info(`Loaded ${r.environment} config`, r);
this.config = r;
});
}
public loadUiMetadata(): Promise<any> {
return this.http.get('assets/uiSetupConfig.json')
.toPromise()
.then((r: UiMetadataMap) => {
// tslint:disable-next-line:no-console
console.info('loaded UI setup', r);
this.uiMetadata = r;
})
}
public getServiceList(): string[] {
return Object.keys(this.uiMetadata);
}
public getUiMetadata(serviceName: string): UiMetadataMap {
return this.uiMetadata[serviceName];
}
public getConfig(): EditorConfig {
return this.config;
}
public get environment(): string {
return this.config.environment;
}
public get serviceRoot(): string {
return this.config.serviceRoot;
}
}

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
// import { HttpModule } from '@angular/http';
import { HttpClientModule } from '@angular/common/http';
import { AppConfigService } from './app-config.service';
export function configFactory(config: AppConfigService) {
return config.getConfig();
}
@NgModule({
imports: [HttpClientModule],
providers: [AppConfigService],
})
export class ConfigModule {
constructor() {}
}

View File

@@ -0,0 +1,4 @@
export class EditorConfig {
environment: string;
serviceRoot: string;
}

View File

@@ -0,0 +1,3 @@
export { EditorConfig } from './editor-config';
export { AppConfigService } from './app-config.service';
export { ConfigModule } from './config.module';

View File

@@ -0,0 +1,6 @@
<div class="container">
<re-nav-bar></re-nav-bar>
<div class="router-holder">
<router-outlet></router-outlet>
</div>
</div>

View File

@@ -0,0 +1,12 @@
.container {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: stretch;
height: 100vh;
}
.router-holder {
flex: 1;
overflow-y: auto;
}

View File

@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-home',
styleUrls: ['./home.component.scss'],
templateUrl: './home.component.html',
})
export class HomeComponent {
constructor() {}
}

View File

@@ -0,0 +1,2 @@
export { HomeComponent } from './home/home.component';
export { PageNotFoundComponent } from './page-not-found/page-not-found.component';

View File

@@ -0,0 +1,7 @@
div {
font-family: Roboto, "Helvetica Neue", sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 're-page-not-found',
styleUrls: ['./page-not-found.component.scss'],
template: '<div><mat-card><mat-card-content>Page not found!</mat-card-content></mat-card></div>',
})
export class PageNotFoundComponent { }

View File

@@ -0,0 +1,16 @@
import { NgModule, Optional, SkipSelf } from '@angular/core';
import { EditorService } from 'app/editor.service';
@NgModule({
providers: [
EditorService,
],
})
export class CoreModule {
constructor( @Optional() @SkipSelf() parentModule: CoreModule) {
if (parentModule) {
throw new Error(
'CoreModule is already loaded. Import it in the AppModule only ');
}
}
}

View File

@@ -0,0 +1 @@
export { CoreModule } from './core.module';

View File

@@ -0,0 +1,14 @@
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs/Observable';
@Injectable({
providedIn: 'root',
})
export class CredentialsInterceptor implements HttpInterceptor {
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const clone = req.clone({ withCredentials: true });
return next.handle(clone);
}
}

View File

@@ -0,0 +1,91 @@
import { ConfigTestDto, DeploymentWrapper } from './model/config-model';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { AppConfigService } from '@app/config';
import { ConfigLoaderService } from '@app/config-loader.service';
import {
ConfigData,
ConfigWrapper,
Deployment,
EditorResult,
ExceptionInfo,
GitFiles,
PullRequestInfo,
RepositoryLinks,
SchemaDto,
SensorFieldTemplate,
UserName
} from '@app/model';
import { Field, SensorFields } from '@app/model/sensor-fields';
import { StripSuffixPipe } from '@app/pipes';
import { Observable } from 'rxjs';
export interface IConfigLoaderService {
getConfigs(): Observable<ConfigWrapper<ConfigData>[]>;
getConfigsFromFiles(files: any);
getSchema(): Observable<SchemaDto>;
getPullRequestStatus(): Observable<PullRequestInfo>;
getRelease(): Observable<DeploymentWrapper>;
getRepositoryLinks(): Observable<RepositoryLinks>;
validateConfig(config: ConfigWrapper<ConfigData>): Observable<EditorResult<ExceptionInfo>>;
validateRelease(deployment: Deployment<ConfigWrapper<ConfigData>>): Observable<EditorResult<ExceptionInfo>>;
submitNewConfig(config: ConfigWrapper<ConfigData>): Observable<EditorResult<GitFiles<ConfigData>>>;
submitConfigEdit(config: ConfigWrapper<ConfigData>): Observable<EditorResult<GitFiles<ConfigData>>>;
submitRelease(deployment: Deployment<ConfigWrapper<ConfigData>>): Observable<EditorResult<ExceptionInfo>>;
getFields(): Observable<Field[]>
testDeploymentConfig(config: any): Observable<EditorResult<any>>;
testSingleConfig(config: ConfigTestDto): Observable<EditorResult<any>>;
};
@Injectable({
providedIn: 'root',
})
export class EditorService {
loaderServices: Map<string, ConfigLoaderService> = new Map();
constructor(
private http: HttpClient,
private config: AppConfigService) {}
public getUser(): Observable<string> {
return this.http.get<EditorResult<UserName>>(`${this.config.serviceRoot}user`)
.map(result => new StripSuffixPipe().transform(result.attributes.user_name, '@UBERIT.NET'));
}
public getServiceNames(): Observable<string[]> {
return this.http.get<EditorResult<any>>(`${this.config.serviceRoot}user`)
.map(result => result.attributes.services)
.map(arr => arr.map(serviceName => serviceName.name));
}
public getSensorFields(): Observable<SensorFields[]> {
return this.http.get<EditorResult<SensorFieldTemplate>>(
`${this.config.serviceRoot}api/v1/sensorfields`)
.map(result => result.attributes.sensor_template_fields.sort((a, b) => {
if (a.sensor_name > b.sensor_name) {
return 1;
} else if (a.sensor_name < b.sensor_name) {
return -1;
} else {
return 0;
}
}
));
}
public createLoaders() {
this.config.getServiceList().forEach(element => {
this.loaderServices.set(element, new ConfigLoaderService(this.http, this.config, element));
});
}
public getLoader(serviceName: string): any {
try {
return this.loaderServices.get(serviceName);
} catch {
throw new DOMException('Invalid service name - can\'t do nothing');
}
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate } from '@angular/router';
import * as fromStore from '@app/store';
import { Store } from '@ngrx/store';
import { combineLatest, Observable } from 'rxjs';
import { filter, map, take, tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class ConfigStoreGuard implements CanActivate {
constructor(private store: Store<fromStore.State>) { }
canActivate(route: ActivatedRouteSnapshot): Observable<boolean> | Promise<boolean> | boolean {
const id = parseInt(route.params['id'], 10);
const serviceName = route.parent.url[0].path;
return isNaN(id)
? false
: combineLatest(this.store.select(fromStore.getBootstrapped), this.store.select(fromStore.getConfigs)).pipe(
tap(([bootstrapped, configs]) => {
if (bootstrapped !== serviceName) {
this.store.dispatch(new fromStore.Bootstrap(serviceName));
}
}),
filter(([bootstrapped, configs]) => bootstrapped === serviceName),
map(([isBootstrapped, configs]) => id < configs.length),
take(1));
}
}

View File

@@ -0,0 +1,3 @@
export { ConfigStoreGuard } from './config-store.guard';
export { ViewResolver } from './view.resolver';
export { RepoResolver } from './repo.resolver';

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import { Resolve } from '@angular/router';
import * as fromStore from '@app/store';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { map, take, tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class RepoResolver implements Resolve<boolean> {
constructor(private store: Store<fromStore.State>) { }
resolve(): Observable<boolean> {
return this.store.select(fromStore.getRepositoryLinks).pipe(
tap(repositoryLinks => {
if (!repositoryLinks) {
this.store.dispatch(new fromStore.LoadRepositories());
}
}),
map(repositoryLinks => true),
take(1));
}
}

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, Resolve } from '@angular/router';
import * as fromStore from '@app/store';
import { Store } from '@ngrx/store';
import { of } from 'rxjs';
import { Observable } from 'rxjs/Observable';
import { filter, map, take, tap } from 'rxjs/operators';
@Injectable({
providedIn: 'root',
})
export class ViewResolver implements Resolve<boolean> {
constructor(private store: Store<fromStore.State>) { }
resolve(route: ActivatedRouteSnapshot): Observable<boolean> {
let serviceName = '';
if (route.parent.url[0] === undefined) {
return of(false);
}
serviceName = route.parent.url[0].path;
return this.store.select(fromStore.getBootstrapped).pipe(
tap(bootstrapped => {
if (bootstrapped !== serviceName) {
this.store.dispatch(new fromStore.Bootstrap(serviceName));
}
}),
map(bootstrapped => bootstrapped === serviceName),
filter(isBootstrapped => isBootstrapped),
take(1));
}
}

View File

@@ -0,0 +1,102 @@
import { SensorFields } from '@app/model';
import { SchemaDto } from './schema';
export interface EditorResult<T> {
status_code: string;
attributes: T;
}
export interface ExceptionInfo {
exception?: string;
message?: string;
}
export interface GitFiles<T> extends ExceptionInfo {
files: T[];
}
export interface GeneralRule {
file_name: string;
}
export interface ContentRuleFile<T> extends GeneralRule {
content: T;
}
export interface UserName extends ExceptionInfo {
user_name: string;
}
export interface RepositoryLinksWrapper extends ExceptionInfo {
rules_repositories: RepositoryLinks;
}
export interface RepositoryLinks {
rule_store_url: string;
rules_release_url: string;
rulesetName: string;
}
export interface SchemaInfo extends ExceptionInfo {
rules_schema: any;
}
export interface PullRequestInfo extends ExceptionInfo {
pull_request_pending: boolean;
pull_request_url: string;
}
export interface ConfigWrapper<T> {
versionFlag?: number;
isDeployed?: boolean;
isNew: boolean;
configData: T;
savedInBackend: boolean;
name: string;
author: string;
version: number;
description: string;
tags?: string[];
fileHistory?: FileHistory[];
}
export interface FileHistory {
author: string;
date: string;
removed: number;
added: number;
}
export interface ConfigTestDto {
files: Deployment<ConfigData>,
event: string,
}
export type ConfigData = any;
export interface Deployment<T> {
configs: T[];
deploymentVersion: number;
}
export interface BootstrapData {
configs: ConfigWrapper<ConfigData>[],
configSchema: SchemaDto,
currentUser: string,
pullRequestPending: PullRequestInfo,
storedDeployment: Deployment<ConfigWrapper<ConfigData>>,
sensorFields: SensorFields[],
deploymentHistory?: FileHistory[],
};
export interface ConfigTestResult {
exception: string;
message: string;
test_result_output: string;
test_result_complete: boolean;
}
export interface DeploymentWrapper {
storedDeployment: Deployment<ConfigWrapper<ConfigData>>;
deploymentHistory: FileHistory[];
}

View File

@@ -0,0 +1,7 @@
export { SchemaDto } from './schema';
export { GitFiles, UserName, SchemaInfo, EditorResult, ContentRuleFile,
PullRequestInfo, ConfigWrapper, ConfigData, Deployment,
BootstrapData, ExceptionInfo, RepositoryLinks, RepositoryLinksWrapper, FileHistory,
} from './config-model';
export { SubmitStatus } from './submit-status';
export { SensorFieldTemplate, SensorFields } from './sensor-fields';

View File

@@ -0,0 +1,11 @@
export class SchemaDto {
schema: Schema;
}
export interface Schema {
description: string;
properties: any;
required: string[];
title: string;
type: string;
}

View File

@@ -0,0 +1,12 @@
export interface SensorFieldTemplate {
sensor_template_fields: SensorFields[];
}
export interface SensorFields {
fields: Field[];
sensor_name: string;
}
export interface Field {
name: string;
}

View File

@@ -0,0 +1,24 @@
export interface ISubmitStatus {
submitInFlight: boolean;
submitSuccess: boolean;
statusCode: string;
message: string;
}
export class SubmitStatus implements ISubmitStatus {
public submitInFlight;
public submitSuccess;
public statusCode;
public message;
constructor(
submitInFlight: boolean = false,
submitSuccess: boolean = false,
message: string = undefined,
statusCode: string = undefined) {
this.submitInFlight = submitInFlight;
this.submitSuccess = submitSuccess;
this.statusCode = statusCode;
this.message = message;
}
}

View File

@@ -0,0 +1,23 @@
export interface UiMetadataMap {
name: string,
version: string,
author: string,
description: string,
labelsFunc: string,
testing: TestConfig,
enableSensorFields: boolean,
perConfigSchemaPath: string,
deployment: DeploymentConfig,
}
export interface TestConfig {
perConfigTestEnabled: boolean,
deploymentTestEnabled: boolean,
helpMessage: string
}
export interface DeploymentConfig {
version: string,
config_array: string,
extras?: string[],
}

View File

@@ -0,0 +1,128 @@
import { Component } from '@angular/core';
import { FieldArrayType } from '@ngx-formly/core';
@Component({
// tslint:disable-next-line:component-selector
selector: 'formly-array-type',
template: `
<legend *ngIf="to.label">{{ to.label }}</legend>
<p class="description" *ngIf="to.description">{{ to.description }}</p>
<div class="alert alert-danger" role="alert" *ngIf="showError && formControl.errors">
<formly-validation-message [field]="field"></formly-validation-message>
</div>
<div *ngFor="let f of field.fieldGroup; let i = index;" class="row">
<formly-field class="array-item" [field]="f"></formly-field>
<div class="column">
<a (click)="moveUp(i)">
<mat-icon class="move-arrow" [class.greyed-out]="i <= 0">arrow_drop_up</mat-icon>
</a>
<a (click)="moveDown(i)">
<mat-icon class="move-arrow" [class.greyed-out]="i >= field.fieldGroup.length -1">arrow_drop_down</mat-icon>
</a>
</div>
<svg *ngIf="showRemoveButton"
xmlns="http://www.w3.org/2000/svg"
height="18" width="18" viewBox="0 0 24 24"
class="close-button"
(click)="remove(i)">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</div>
<div class="row">
<div class="add-button" *ngIf="to.maxItems ? field.fieldGroup.length < to.maxItems : true">
<button mat-raised-button color="primary" (click)="add()">Add to {{to.label}}</button>
</div>
<div>
`,
styles: [`
legend {
font-weight: 400;
margin: 5px;
font-size: 1.3em;
}
.description {
padding: 0 10px 10px 15px;
font-size: 0.9em;
color: rgba(255, 255, 255, 0.7);
}
.row {
display: flex;
padding-top: 5px;
}
.rhs {
margin: 5px auto 5px 0;
}
.close-button {
cursor: pointer;
top: 6px;
right: 6px;
fill: orange;
z-index: 500;
}
.add-button {
margin: 5px 0 5px 0;
}
.column {
display: block;
width: 16px;
}
.move-arrow {
width: 14px;
height: 14px;
font-size: 18px;
cursor: pointer;
}
.greyed-out {
width: 14px;
height: 14px;
font-size: 18px;
color: #707070;
cursor: default;
}
.blank-icon {
width: 14px;
height: 14px;
opacity: 0;
cursor: default;
}
.close-button:hover { background: rgba(255,255,255,0.2); }
.array-item:hover > .close-button { visibility: visible }
.array-item {
flex: 9;
position: relative;
transition: all 0.2s ease-in-out;
}
`],
})
export class ArrayTypeComponent extends FieldArrayType {
public showRemoveButton = true;
moveUp (index: number) {
if (index > 0) {
this.reorder(index, index - 1);
}
}
moveDown (index: number) {
if (index < this.field.fieldGroup.length - 1) {
this.reorder(index, index + 1);
}
}
reorder (oldIndex, newIndex) {
const temp = this.model[oldIndex];
this.remove(oldIndex);
this.add(newIndex, temp);
}
}

View File

@@ -0,0 +1,23 @@
import { Component } from '@angular/core';
import { FieldWrapper } from '@ngx-formly/core';
@Component({
// tslint:disable-next-line:component-selector
selector: 'formly-expansion-panel-wrapper',
template: `
<mat-expansion-panel [expanded]="true">
<mat-expansion-panel-header>
<mat-panel-title>
{{ to.label }}
</mat-panel-title>
<mat-panel-description>
{{ to.description }}
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent #fieldComponent></ng-template>
</mat-expansion-panel>
`,
})
export class ExpansionPanelWrapperComponent extends FieldWrapper {
}

View File

@@ -0,0 +1,138 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import { AfterContentChecked,
AfterViewInit, Component, ElementRef, OnDestroy, OnInit, Renderer2, TemplateRef, ViewChild } from '@angular/core';
import { MatFormField } from '@angular/material/form-field';
import { MatFormFieldControl } from '@angular/material/form-field';
import { FieldWrapper, FormlyFieldConfig, ɵdefineHiddenProp as defineHiddenProp } from '@ngx-formly/core';
import { Subject } from 'rxjs';
import { FieldType } from '@ngx-formly/material';
interface MatFormlyFieldConfig extends FormlyFieldConfig {
_matprefix: TemplateRef<any>;
_matsuffix: TemplateRef<any>;
__formField__: FormFieldWrapperComponent;
_componentFactory: any;
}
@Component({
// tslint:disable-next-line:component-selector
selector: 'formly-wrapper-mat-form-field',
template: `
<!-- fix https://github.com/angular/material2/pull/7083 by setting width to 100% -->
<mat-form-field
[hideRequiredMarker]="true"
[floatLabel]="to.floatLabel"
[appearance]="to.appearance"
[color]="to.color"
[style.width]="'100%'">
<ng-container #fieldComponent></ng-container>
<mat-label *ngIf="to.label && to.hideLabel !== true">
{{ to.label }}
<span *ngIf="to.required && to.hideRequiredMarker !== true" class="mat-form-field-required-marker">*</span>
</mat-label>
<ng-container matPrefix>
<ng-container *ngTemplateOutlet="to.prefix ? to.prefix : formlyField._matprefix"></ng-container>
</ng-container>
<ng-container matSuffix>
<ng-container *ngTemplateOutlet="to.suffix ? to.suffix : formlyField._matsuffix"></ng-container>
</ng-container>
<!-- fix https://github.com/angular/material2/issues/7737 by setting id to null -->
<mat-error [id]="null">
<formly-validation-message [field]="field"></formly-validation-message>
</mat-error>
<!-- fix https://github.com/angular/material2/issues/7737 by setting id to null -->
<mat-hint *ngIf="to.description" [id]="null" align="end">{{ to.description }}</mat-hint>
</mat-form-field>
`,
providers: [{ provide: MatFormFieldControl, useExisting: FormFieldWrapperComponent }],
})
export class FormFieldWrapperComponent extends FieldWrapper<MatFormlyFieldConfig>
implements OnInit, OnDestroy, MatFormFieldControl<any>, AfterViewInit, AfterContentChecked {
// TODO: remove `any`, once dropping angular `V7` support.
@ViewChild(MatFormField, <any> { static: true }) formField!: MatFormField;
stateChanges = new Subject<void>();
_errorState = false;
private initialGapCalculated = false;
constructor(
private renderer: Renderer2,
private elementRef: ElementRef,
private focusMonitor: FocusMonitor
) {
super();
focusMonitor.monitor(elementRef, true).subscribe(origin => {
this.field.focus = !!origin;
this.stateChanges.next();
});
}
ngOnInit() {
this.formField._control = this;
defineHiddenProp(this.field, '__formField__', this.formField);
const fieldComponent = this.formlyField['_componentFactory'];
if (fieldComponent && !(fieldComponent.componentRef.instance instanceof FieldType)) {
console.warn(
`Component '${fieldComponent.component.prototype.constructor.name}' must extend 'FieldType' from '@ngx-formly/material'.`);
}
// fix for https://github.com/angular/material2/issues/11437
if (this.formlyField.hide && this.formlyField.templateOptions!.appearance === 'outline') {
this.initialGapCalculated = true;
}
}
ngAfterContentChecked() {
if (!this.initialGapCalculated || this.formlyField.hide) {
return;
}
this.formField.updateOutlineGap();
this.initialGapCalculated = true;
}
ngAfterViewInit() {
// temporary fix for https://github.com/angular/material2/issues/7891
if (this.formField.underlineRef && this.to.hideFieldUnderline === true) {
this.renderer.removeClass(this.formField.underlineRef.nativeElement, 'mat-form-field-underline');
this.renderer.removeClass(this.formField.underlineRef.nativeElement.firstChild, 'mat-form-field-ripple');
}
}
ngOnDestroy() {
delete this.formlyField.__formField__;
this.stateChanges.complete();
this.focusMonitor.stopMonitoring(this.elementRef);
}
setDescribedByIds(ids: string[]): void { }
onContainerClick(event: MouseEvent): void {
this.formlyField.focus = true;
this.stateChanges.next();
}
get errorState() {
const showError = this.options!.showError!(this);
if (showError !== this._errorState) {
this._errorState = showError;
this.stateChanges.next();
}
return showError;
}
get controlType() { return this.to.type; }
get focused() { return !!this.formlyField.focus && !this.disabled; }
get disabled() { return !!this.to.disabled; }
get required() { return !!this.to.required; }
get placeholder() { return this.to.placeholder || ''; }
get shouldPlaceholderFloat() { return this.shouldLabelFloat; }
get value() { return this.formControl.value; }
get ngControl() { return this.formControl as any; }
get empty() { return !this.formControl.value; }
get shouldLabelFloat() { return this.focused || !this.empty; }
get formlyField() { return this.field as MatFormlyFieldConfig; }
}

View File

@@ -0,0 +1,38 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { MatInput } from '@angular/material/input';
import { FieldType } from '@ngx-formly/material/form-field';
@Component({
selector: 'formly-field-mat-input',
template: `
<input *ngIf="type !== 'number'; else numberTmp"
matInput
[name]="to.title"
[id]="id"
[readonly]="to.readonly"
[type]="type || 'text'"
[errorStateMatcher]="errorStateMatcher"
[formControl]="formControl"
[formlyAttributes]="field"
[tabindex]="to.tabindex || 0"
[placeholder]="to.placeholder">
<ng-template #numberTmp>
<input matInput
[id]="id"
type="number"
[readonly]="to.readonly"
[errorStateMatcher]="errorStateMatcher"
[formControl]="formControl"
[formlyAttributes]="field"
[tabindex]="to.tabindex || 0"
[placeholder]="to.placeholder">
</ng-template>
`,
})
export class InputTypeComponent extends FieldType implements OnInit {
@ViewChild(MatInput, <any> { static: true }) formFieldControl!: MatInput;
get type() {
return this.to.type || 'text';
}
}

View File

@@ -0,0 +1,9 @@
import { Component } from '@angular/core';
import { FieldType } from '@ngx-formly/core';
@Component({
selector: 'formly-null-type',
template: '',
})
export class NullTypeComponent extends FieldType {}

View File

@@ -0,0 +1,23 @@
import { Component } from '@angular/core';
import { FieldType } from '@ngx-formly/core';
@Component({
// tslint:disable-next-line:component-selector
selector: 'formly-object-type',
template: `
<div class="alert alert-danger" role="alert" *ngIf="showError && formControl.errors">
<formly-validation-message [field]="field"></formly-validation-message>
</div>
<formly-field *ngFor="let f of field.fieldGroup" [field]="f"></formly-field>
`,
styles: [`
mat-card:last-child {
display: none;
}
`],
})
export class ObjectTypeComponent extends FieldType {
defaultOptions = {
defaultValue: {},
};
}

View File

@@ -0,0 +1,34 @@
import { Component } from '@angular/core';
import { FieldWrapper } from '@ngx-formly/core';
@Component({
// tslint:disable-next-line:component-selector
selector: 'formly-wrapper-panel',
template: `
<mat-card>
<mat-card-header>
<mat-card-title>
{{ to.label }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<p class="description" *ngIf="to.description">{{ to.description }}</p>
<ng-container #fieldComponent></ng-container>
</mat-card-content>
<mat-card>
`,
styles: [`
.description {
padding: 0 10px 10px 15px;
font-size: 0.9em;
color: rgba(255, 255, 255, 0.7);
}
.mat-card:nth-child(3) {
display: none;
}
`],
})
export class PanelWrapperComponent extends FieldWrapper {
}

View File

@@ -0,0 +1,17 @@
import { Component, ViewChild, ViewContainerRef } from '@angular/core';
import { FieldWrapper } from '@ngx-formly/core';
@Component({
// tslint:disable-next-line:component-selector
selector: 'formly-wrapper-tabs',
template:
`<mat-tab-group>
<mat-tab *ngFor="let tab of field.fieldGroup" [label]="to.label">
<ng-template #fieldComponent></ng-template>
</mat-tab>
</mat-tab-group>
`,
})
export class TabsWrapperComponent extends FieldWrapper {
@ViewChild('fieldComponent', { read: ViewContainerRef, static: true }) fieldComponent: ViewContainerRef;
}

View File

@@ -0,0 +1,19 @@
import { Component } from '@angular/core';
import { FieldType } from '@ngx-formly/core';
@Component({
// tslint:disable-next-line:component-selector
selector: 'formly-tabset-type',
template:
`<mat-tab-group animationDuration="0ms">
<mat-tab *ngFor="let tab of field.fieldGroup" [label]="tab?.templateOptions?.label">
<formly-field [field]="tab"></formly-field>
</mat-tab>
</mat-tab-group>
`,
})
export class TabsetTypeComponent extends FieldType {
defaultOptions = {
defaultValue: {},
};
}

View File

@@ -0,0 +1,157 @@
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
import { FormControl } from '@angular/forms';
import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger } from '@angular/material';
import { MatInput } from '@angular/material/input';
import { SensorFields } from '@app/model';
import { FieldType } from '@ngx-formly/material/form-field';
import { cloneDeep } from 'lodash';
import { Observable, Subject } from 'rxjs';
import { debounceTime, takeUntil, tap } from 'rxjs/operators';
@Component({
// tslint:disable-next-line:component-selector
selector: 'formly-field-mat-textarea',
template: `
<div class="overlay-holder">
<mat-form-field #formfield>
<mat-label>{{ to.label }}</mat-label>
<textarea highlight matInput #textbox #autocompleteInput
[matAutocomplete]="auto"
[class.hide-text]="true"
[id]="id"
[name]="to.title"
[readonly]="to.readonly"
[formControl]="formControl"
[errorStateMatcher]="errorStateMatcher"
[formlyAttributes]="field"
[placeholder]="to.placeholder"
[tabindex]="to.tabindex || 0"
[readonly]="to.readonly"
(ngModelChange)="resizeTextArea($event)"
>
</textarea>
<mat-autocomplete #auto="matAutocomplete" [autoActiveFirstOption]='true' (optionSelected)="autoCompleteSelected($event)">
<mat-optgroup *ngFor="let group of filteredList" [label]="group.sensor_name">
<mat-option *ngFor="let field of group?.fields" [value]="field?.name">
{{field.name}}
</mat-option>
</mat-optgroup>
</mat-autocomplete>
<mat-hint *ngIf="to?.description && (!to?.errorMessage)"
align="end" [innerHTML]="to?.description"></mat-hint>
</mat-form-field>
<div class="highlighted-overlay" [class.show-overlay]="true" [innerHtml]="modelValue | highlightVariables"></div>
</div>
`,
styles: [`
textarea {
resize: none;
line-height: normal;
overflow: hidden;
}
mat-form-field {
width: 100%;
}
.hide-text {
-webkit-text-fill-color: transparent;
}
.show-overlay {
display: block;
width: 100%;
overflow-wrap: break-word;
white-space: pre-wrap;
}
.highlighted-overlay {
position: absolute;
top: 18px;
left: 0;
z-index: 10;
}
.overlay-holder {
position: relative;
line-height: normal;
overflow: hidden;
}
::ng-deep .overlay-holder .mat-input-element {
position: relative;
z-index: 20;
}
`],
})
export class TextAreaTypeComponent extends FieldType implements OnDestroy, AfterViewInit {
@ViewChild(MatInput, {static: false}) formFieldControl!: MatInput;
@ViewChild('textbox', {static: true}) textbox: ElementRef;
@ViewChild('autocompleteInput', { read: MatAutocompleteTrigger, static: false }) autocomplete: MatAutocompleteTrigger;
@ViewChild('formfield', {static: true}) formfield: FormControl;
public displayOverlay = true;
public modelValue;
sensorFields$: Observable<SensorFields[]>;
autoCompleteList: SensorFields[];
filteredList: SensorFields[] = [];
sensorDataSource$: any;
_value;
private _prevValue;
private ngUnsubscribe: Subject<any> = new Subject();
private readonly variableRegex = new RegExp(/\${([a-zA-Z_.:]*)(?![^ ]*})/);
constructor() {
super();
}
ngOnDestroy() {
this.ngUnsubscribe.next();
this.ngUnsubscribe.complete();
}
ngAfterViewInit() {
// populate the model value with the current form value
this.modelValue = this.value;
this.resizeTextArea(this.field.model[this.field.key]);
this.formControl.valueChanges.pipe(tap(v => this.modelValue = v), debounceTime(400), takeUntil(this.ngUnsubscribe)).subscribe(val => {
if (val === null || val === undefined || !this.options.formState.sensorFields) {
return;
}
this._prevValue = this._value;
this._value = val;
this.autoCompleteList = this.options.formState.sensorFields;
this.filteredList = cloneDeep(this.options.formState.sensorFields);
const matches = this.variableRegex.exec(val);
if (matches != null && matches.length > 0) {
for (let i = 0; i < this.options.formState.sensorFields.length; ++i) {
this.filteredList[i].fields = this.options.formState.sensorFields[i].fields
.filter(n => n.name.includes(matches[matches.length - 1]));
}
this.autocomplete.autocompleteDisabled = false;
this.autocomplete.openPanel();
} else {
this.autocomplete.closePanel();
this.autocomplete.autocompleteDisabled = true;
}
});
}
resizeTextArea(event) {
const textarea = this.textbox.nativeElement as HTMLTextAreaElement;
// scrollheight needs some persuading to tell us what the new height should be
textarea.style.height = '20px';
if (textarea.scrollHeight !== 0) {
textarea.style.height = `${textarea.scrollHeight}px`;
}
}
public autoCompleteSelected($event: MatAutocompleteSelectedEvent) {
const replacementStr = this._prevValue.replace(this.variableRegex, '${' + $event.option.value + '}');
this.field.formControl.setValue(replacementStr);
}
}

View File

@@ -0,0 +1,328 @@
import { TitleCasePipe } from '@angular/common';
import { Injectable } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { Store } from '@ngrx/store';
import { FormlyFieldConfig } from '@ngx-formly/core';
import { ɵreverseDeepMerge as reverseDeepMerge } from '@ngx-formly/core';
import * as fromStore from 'app/store';
import { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
import { cloneDeep } from 'lodash';
export interface FormlyJsonschemaOptions {
/**
* allows to intercept the mapping, taking the already mapped
* formly field and the original JSONSchema source from which it
* was mapped.
*/
map?: (mappedField: FormlyFieldConfig, mapSource: JSONSchema7) => FormlyFieldConfig;
}
function isEmpty(v) {
return v === '' || v === undefined || v === null;
}
interface IOptions extends FormlyJsonschemaOptions {
schema: JSONSchema7;
}
@Injectable({ providedIn: 'root' })
export class FormlyJsonschema {
private dynamicFieldsMap: Map<string, string>;
titleCasePipe: TitleCasePipe = new TitleCasePipe();
constructor(private store: Store<fromStore.State>) {}
toFieldConfig(schema: JSONSchema7, options?: FormlyJsonschemaOptions): FormlyFieldConfig {
this.dynamicFieldsMap = new Map<string, string>();
const fieldConfig = this._toFieldConfig(schema, { schema, ...(options || {}) }, []);
this.store.dispatch(new fromStore.UpdateDynamicFieldsMap(this.dynamicFieldsMap));
return fieldConfig;
}
private _toFieldConfig(schema: JSONSchema7, options: IOptions, propKey?: string[]): FormlyFieldConfig {
if (schema.$ref) {
schema = this.resolveDefinition(schema, options);
}
if (schema.allOf) {
schema = this.resolveAllOf(schema, options);
}
let field: FormlyFieldConfig = {
type: this.guessType(schema),
defaultValue: schema.default,
templateOptions: {
label: schema.title ? this.titleCasePipe.transform(<string> schema.title.replace(/_/g, ' ')) : '',
readonly: schema.readOnly,
description: schema.description,
},
};
switch (field.type) {
case 'null': {
this.addValidator(field, 'null', c => c.value === null);
break;
}
case 'boolean': {
field.templateOptions.label = this.titleCasePipe.transform(propKey[propKey.length - 1].replace(/_/g, ' '));
field.templateOptions.description = schema.description;
break;
}
case 'number':
case 'integer': {
field.templateOptions.label = this.titleCasePipe.transform(propKey[propKey.length - 1].replace(/_/g, ' '));
field.parsers = [v => isEmpty(v) ? null : Number(v)];
if (schema.hasOwnProperty('minimum')) {
field.templateOptions.min = schema.minimum;
}
if (schema.hasOwnProperty('maximum')) {
field.templateOptions.max = schema.maximum;
}
if (schema.hasOwnProperty('exclusiveMinimum')) {
field.templateOptions.exclusiveMinimum = schema.exclusiveMinimum;
this.addValidator(field, 'exclusiveMinimum', c => isEmpty(c.value) || (c.value > schema.exclusiveMinimum));
}
if (schema.hasOwnProperty('exclusiveMaximum')) {
field.templateOptions.exclusiveMaximum = schema.exclusiveMaximum;
this.addValidator(field, 'exclusiveMaximum', c => isEmpty(c.value) || (c.value < schema.exclusiveMaximum));
}
if (schema.hasOwnProperty('multipleOf')) {
field.templateOptions.step = schema.multipleOf;
this.addValidator(field, 'multipleOf', c => isEmpty(c.value) || (c.value % schema.multipleOf === 0));
}
break;
}
case 'string': {
['minLength', 'maxLength', 'pattern'].forEach(prop => {
if (schema.hasOwnProperty(prop)) {
field.templateOptions[prop] = schema[prop];
}
});
field.templateOptions.label = propKey[propKey.length - 1] !== '-'
? this.titleCasePipe.transform(propKey[propKey.length - 1].replace(/_/g, ' '))
: '';
break;
}
case 'object': {
field.fieldGroup = [];
const [propDeps, schemaDeps] = this.resolveDependencies(schema);
Object.keys(schema.properties || {}).forEach(key => {
const newPropKey = cloneDeep(propKey);
newPropKey.push(key);
const f = this._toFieldConfig(<JSONSchema7> schema.properties[key], options, newPropKey);
field.fieldGroup.push(f);
f.key = key;
if (Array.isArray(schema.required) && schema.required.indexOf(key) !== -1) {
f.templateOptions.required = true;
}
if (!f.templateOptions.required && propDeps[key]) {
f.expressionProperties = {
'templateOptions.required': m => m && propDeps[key].some(k => !isEmpty(m[k])),
};
}
if (schemaDeps[key]) {
field.fieldGroup.push({
...this._toFieldConfig(schemaDeps[key], options),
hideExpression: m => !m || isEmpty(m[key]),
});
}
});
break;
}
case 'array': {
field.fieldGroup = [];
field.templateOptions.label = this.titleCasePipe.transform(propKey[propKey.length - 1].replace(/_/g, ' '));
const newPropKey2 = cloneDeep(propKey);
if (schema.hasOwnProperty('minItems')) {
field.templateOptions.minItems = schema.minItems;
this.addValidator(field, 'minItems', c => isEmpty(c.value) || (c.value.length >= schema.minItems));
}
if (schema.hasOwnProperty('maxItems')) {
field.templateOptions.maxItems = schema.maxItems;
this.addValidator(field, 'maxItems', c => isEmpty(c.value) || (c.value.length <= schema.maxItems));
}
Object.defineProperty(field, 'fieldArray', {
get: () => {
if (!Array.isArray(schema.items)) {
// When items is a single schema, the additionalItems keyword is meaningless, and it should not be used.
if (newPropKey2[newPropKey2.length - 1] !== '-') {
newPropKey2.push('-');
}
return this._toFieldConfig(<JSONSchema7> schema.items, options, newPropKey2);
}
const itemSchema = schema.items[field.fieldGroup.length]
? schema.items[field.fieldGroup.length]
: schema.additionalItems;
return itemSchema
? this._toFieldConfig(<JSONSchema7> itemSchema, options)
: null;
},
enumerable: true,
configurable: true,
});
break;
}
}
if (schema.hasOwnProperty('x-schema-form')) {
if (schema['x-schema-form'].hasOwnProperty('type')) {
field.type = schema['x-schema-form'].type;
}
if (schema['x-schema-form'].hasOwnProperty('wrappers')) {
field.wrappers = schema['x-schema-form'].wrappers;
} else if (field.type === 'object') {
field.wrappers = ['panel']
}
if (schema['x-schema-form'].hasOwnProperty('condition')) {
if (schema['x-schema-form'].condition.hasOwnProperty('hideExpression')) {
try {
const dynFunc: Function =
new Function('model', 'localFields', 'field', schema['x-schema-form'].condition.hideExpression);
field.hideExpression = (model, formState, f) => dynFunc(formState.mainModel, model, f);
} catch {
console.warn('Something went wrong with applying condition evaluation to form');
}
this.dynamicFieldsMap.set(('/' + propKey.join('/')), schema['x-schema-form'].condition.hideExpression);
}
}
}
if (schema.enum) {
field.type = 'enum';
field.templateOptions.options = schema.enum.map(value => ({ value, label: value }));
}
// map in possible formlyConfig options from the widget property
if (schema['widget'] && schema['widget'].formlyConfig) {
field = reverseDeepMerge(schema['widget'].formlyConfig, field);
}
// if there is a map function passed in, use it to allow the user to
// further customize how fields are being mapped
return options.map ? options.map(field, schema) : field;
}
private resolveAllOf({ allOf, ...baseSchema }: JSONSchema7, options: IOptions) {
if (!allOf.length) {
throw Error(`allOf array can not be empty ${allOf}.`);
}
return allOf.reduce((base: JSONSchema7, schema: JSONSchema7) => {
if (schema.$ref) {
schema = this.resolveDefinition(schema, options);
}
if (schema.allOf) {
schema = this.resolveAllOf(schema, options);
}
if (base.required && schema.required) {
base.required = [...base.required, ...schema.required];
}
if (schema.uniqueItems) {
base.uniqueItems = schema.uniqueItems;
}
// resolve to min value
['maxLength', 'maximum', 'exclusiveMaximum', 'maxItems', 'maxProperties']
.forEach(prop => {
if (!isEmpty(base[prop]) && !isEmpty(schema[prop])) {
base[prop] = base[prop] < schema[prop] ? base[prop] : schema[prop];
}
});
// resolve to max value
['minLength', 'minimum', 'exclusiveMinimum', 'minItems', 'minProperties']
.forEach(prop => {
if (!isEmpty(base[prop]) && !isEmpty(schema[prop])) {
base[prop] = base[prop] > schema[prop] ? base[prop] : schema[prop];
}
});
return reverseDeepMerge(base, schema);
}, baseSchema);
}
private resolveDefinition(schema: JSONSchema7, options: IOptions): JSONSchema7 {
const [uri, pointer] = schema.$ref.split('#/');
if (uri) {
throw Error(`Remote schemas for ${schema.$ref} not supported yet.`);
}
const definition = !pointer ? null : pointer.split('/').reduce(
(def, path) => def && def.hasOwnProperty(path) ? def[path] : null,
options.schema
);
if (!definition) {
throw Error(`Cannot find a definition for ${schema.$ref}.`);
}
if (definition.$ref) {
return this.resolveDefinition(definition, options);
}
return {
...definition,
...['title', 'description', 'default'].reduce((annotation, p) => {
if (schema.hasOwnProperty(p)) {
annotation[p] = schema[p];
}
return annotation;
}, {}),
};
}
private resolveDependencies(schema: JSONSchema7) {
const deps = {};
const schemaDeps = {};
Object.keys(schema.dependencies || {}).forEach(prop => {
const dependency = schema.dependencies[prop] as JSONSchema7;
if (Array.isArray(dependency)) {
// Property dependencies
dependency.forEach(dep => {
if (!deps[dep]) {
deps[dep] = [prop];
} else {
deps[dep].push(prop);
}
});
} else {
// schema dependencies
schemaDeps[prop] = dependency;
}
});
return [deps, schemaDeps];
}
private guessType(schema: JSONSchema7) {
const type = schema.type as JSONSchema7TypeName;
if (!type && schema.properties) {
return 'object';
}
return type;
}
private addValidator(field: FormlyFieldConfig, name: string, validator: (control: AbstractControl) => boolean) {
field.validators = field.validators || {};
field.validators[name] = validator;
}
}

View File

@@ -0,0 +1,299 @@
import { Injectable } from '@angular/core';
import { hasOwn } from './utility.functions';
import {
isArray,
isDefined,
isMap,
isObject,
isString
} from './validator.functions';
/**
* 'JsonPointer' class
*
* Some utilities for using JSON Pointers with JSON objects
* https://tools.ietf.org/html/rfc6901
*
* get, getCopy, getFirst, set, setCopy, insert, insertCopy, remove, has, dict,
* forEachDeep, forEachDeepCopy, escape, unescape, parse, compile, toKey,
* isJsonPointer, isSubPointer, toIndexedPointer, toGenericPointer,
* toControlPointer, toSchemaPointer, toDataPointer, parseObjectPath
*
* Some functions based on manuelstofer's json-pointer utilities
* https://github.com/manuelstofer/json-pointer
*/
export type Pointer = string | string[];
@Injectable({
providedIn: 'root',
})
export class JsonPointer {
/**
* 'get' function
*
* Uses a JSON Pointer to retrieve a value from an object.
*
* // { object } object - Object to get value from
* // { Pointer } pointer - JSON Pointer (string or array)
* // { number = 0 } startSlice - Zero-based index of first Pointer key to use
* // { number } endSlice - Zero-based index of last Pointer key to use
* // { boolean = false } getBoolean - Return only true or false?
* // { boolean = false } errors - Show error if not found?
* // { object } - Located value (or true or false if getBoolean = true)
*/
static get(
object, pointer, startSlice = 0, endSlice: number = null,
getBoolean = false, errors = false
) {
if (object === null) { return getBoolean ? false : undefined; }
let keyArray: any[] = this.parse(pointer, errors);
if (typeof object === 'object' && keyArray !== null) {
let subObject = object;
if (startSlice >= keyArray.length || endSlice <= -keyArray.length) { return object; }
if (startSlice <= -keyArray.length) { startSlice = 0; }
if (!isDefined(endSlice) || endSlice >= keyArray.length) { endSlice = keyArray.length; }
keyArray = keyArray.slice(startSlice, endSlice);
for (let key of keyArray) {
if (key === '-' && isArray(subObject) && subObject.length) {
key = subObject.length - 1;
}
if (isMap(subObject) && subObject.has(key)) {
subObject = subObject.get(key);
} else if (typeof subObject === 'object' && subObject !== null &&
hasOwn(subObject, key)
) {
subObject = subObject[key];
} else {
if (errors) {
console.error(`get error: "${key}" key not found in object.`);
console.error(pointer);
console.error(object);
}
return getBoolean ? false : undefined;
}
}
return getBoolean ? true : subObject;
}
if (errors && keyArray === null) {
console.error(`get error: Invalid JSON Pointer: ${pointer}`);
}
if (errors && typeof object !== 'object') {
console.error('get error: Invalid object:');
console.error(object);
}
return getBoolean ? false : undefined;
}
/**
* 'remove' function
*
* Uses a JSON Pointer to remove a key and its attribute from an object
*
* // { object } object - object to delete attribute from
* // { Pointer } pointer - JSON Pointer (string or array)
* // { object }
*/
static remove(object, pointer) {
const keyArray = this.parse(pointer);
if (keyArray !== null && keyArray.length) {
let lastKey = keyArray.pop();
const parentObject = this.get(object, keyArray);
if (isArray(parentObject)) {
if (lastKey === '-') { lastKey = parentObject.length - 1; }
parentObject.splice(lastKey, 1);
} else if (isObject(parentObject)) {
delete parentObject[lastKey];
}
return object;
}
console.error(`remove error: Invalid JSON Pointer: ${pointer}`);
return object;
}
/**
* 'has' function
*
* Tests if an object has a value at the location specified by a JSON Pointer
*
* // { object } object - object to chek for value
* // { Pointer } pointer - JSON Pointer (string or array)
* // { boolean }
*/
static has(object, pointer) {
const hasValue = this.get(object, pointer, 0, null, true);
return hasValue;
}
/**
* 'dict' function
*
* Returns a (pointer -> value) dictionary for an object
*
* // { object } object - The object to create a dictionary from
* // { object } - The resulting dictionary object
*/
static dict(object) {
const results: any = {};
this.forEachDeep(object, (value, pointer) => {
if (typeof value !== 'object') { results[pointer] = value; }
});
return results;
}
/**
* 'forEachDeep' function
*
* Iterates over own enumerable properties of an object or items in an array
* and invokes an iteratee function for each key/value or index/value pair.
* By default, iterates over items within objects and arrays after calling
* the iteratee function on the containing object or array itself.
*
* The iteratee is invoked with three arguments: (value, pointer, rootObject),
* where pointer is a JSON pointer indicating the location of the current
* value within the root object, and rootObject is the root object initially
* submitted to th function.
*
* If a third optional parameter 'bottomUp' is set to TRUE, the iterator
* function will be called on sub-objects and arrays after being
* called on their contents, rather than before, which is the default.
*
* This function can also optionally be called directly on a sub-object by
* including optional 4th and 5th parameterss to specify the initial
* root object and pointer.
*
* // { object } object - the initial object or array
* // { (v: any, p?: string, o?: any) => any } function - iteratee function
* // { boolean = false } bottomUp - optional, set to TRUE to reverse direction
* // { object = object } rootObject - optional, root object or array
* // { string = '' } pointer - optional, JSON Pointer to object within rootObject
* // { object } - The modified object
*/
static forEachDeep(
object, fn: (v: any, p?: string, o?: any) => any = (v) => v,
bottomUp = false, pointer = '', rootObject = object
) {
if (typeof fn !== 'function') {
console.error(`forEachDeep error: Iterator is not a function:`, fn);
return;
}
if (!bottomUp) { fn(object, pointer, rootObject); }
if (isObject(object) || isArray(object)) {
for (const key of Object.keys(object)) {
const newPointer = pointer + '/' + this.escape(key);
this.forEachDeep(object[key], fn, bottomUp, newPointer, rootObject);
}
}
if (bottomUp) { fn(object, pointer, rootObject); }
}
/**
* 'forEachDeepCopy' function
*
* Similar to forEachDeep, but returns a copy of the original object, with
* the same keys and indexes, but with values replaced with the result of
* the iteratee function.
*
* // { object } object - the initial object or array
* // { (v: any, k?: string, o?: any, p?: any) => any } function - iteratee function
* // { boolean = false } bottomUp - optional, set to TRUE to reverse direction
* // { object = object } rootObject - optional, root object or array
* // { string = '' } pointer - optional, JSON Pointer to object within rootObject
* // { object } - The copied object
*/
static forEachDeepCopy(
object, fn: (v: any, p?: string, o?: any) => any = (v) => v,
bottomUp = false, pointer = '', rootObject = object
) {
if (typeof fn !== 'function') {
console.error(`forEachDeepCopy error: Iterator is not a function:`, fn);
return null;
}
if (isObject(object) || isArray(object)) {
let newObject = isArray(object) ? [ ...object ] : { ...object };
if (!bottomUp) { newObject = fn(newObject, pointer, rootObject); }
for (const key of Object.keys(newObject)) {
const newPointer = pointer + '/' + this.escape(key);
newObject[key] = this.forEachDeepCopy(
newObject[key], fn, bottomUp, newPointer, rootObject
);
}
if (bottomUp) { newObject = fn(newObject, pointer, rootObject); }
return newObject;
} else {
return fn(object, pointer, rootObject);
}
}
/**
* 'isJsonPointer' function
*
* Checks a string or array value to determine if it is a valid JSON Pointer.
* Returns true if a string is empty, or starts with '/' or '#/'.
* Returns true if an array contains only string values.
*
* // value - value to check
* // { boolean } - true if value is a valid JSON Pointer, otherwise false
*/
static isJsonPointer(value) {
if (isArray(value)) {
return value.every(key => typeof key === 'string');
} else if (isString(value)) {
if (value === '' || value === '#') { return true; }
if (value[0] === '/' || value.slice(0, 2) === '#/') {
return !/(~[^01]|~$)/g.test(value);
}
}
return false;
}
/**
* 'escape' function
*
* Escapes a string reference key
*
* // { string } key - string key to escape
* // { string } - escaped key
*/
static escape(key) {
const escaped = key.toString().replace(/~/g, '~0').replace(/\//g, '~1');
return escaped;
}
/**
* 'unescape' function
*
* Unescapes a string reference key
*
* // { string } key - string key to unescape
* // { string } - unescaped key
*/
static unescape(key) {
const unescaped = key.toString().replace(/~1/g, '/').replace(/~0/g, '~');
return unescaped;
}
/**
* 'parse' function
*
* Converts a string JSON Pointer into a array of keys
* (if input is already an an array of keys, it is returned unchanged)
*
* // { Pointer } pointer - JSON Pointer (string or array)
* // { boolean = false } errors - Show error if invalid pointer?
* // { string[] } - JSON Pointer array of keys
*/
static parse(pointer, errors = false) {
if (!this.isJsonPointer(pointer)) {
if (errors) { console.error(`parse error: Invalid JSON Pointer: ${pointer}`); }
return null;
}
if (isArray(pointer)) { return <string[]>pointer; }
if (typeof pointer === 'string') {
if ((<string>pointer)[0] === '#') { pointer = pointer.slice(1); }
if (<string>pointer === '' || <string>pointer === '/') { return []; }
return (<string>pointer).slice(1).split('/').map(this.unescape);
}
}
}

Some files were not shown because too many files have changed in this diff Show More