One of cleanup (#71)

* raw json obj validation, unreliable submit

* fix defaults not appearing in deployment extras

* version++

* disable testing when new rule or rule not valid

* update version

* support raw json object

* cleanup

* change from 'event' to 'test_specification'

* version++

* test suite fix

* new centrifuge prototype

* fix git ignore

* save changes

* working version

* working changes

* resolve conflicts

* move services, cleanup, oneOf working

* clean up effects, diff bug

* pr fixes

* remove package lock

* update gitignore
This commit is contained in:
Thomas Gilgan
2020-04-08 16:26:21 +01:00
committed by GitHub Enterprise
parent 6baf157993
commit e4cc7c8fd0
30 changed files with 1257 additions and 15490 deletions

1
.gitignore vendored
View File

@@ -35,6 +35,7 @@ worker.yaml
/dist-test
/tmp
/out-tsc
package-lock.json
# dependencies
/node_modules

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@
"@angular/core": "^9.0.0",
"@angular/flex-layout": "^9.0.0-beta.29",
"@angular/forms": "^9.0.0",
"@angular/material": "^9.0.0",
"@angular/material": "^9.2.0",
"@angular/platform-browser": "^9.0.0",
"@angular/platform-browser-dynamic": "^9.0.0",
"@angular/platform-server": "^9.0.0",
@@ -36,10 +36,10 @@
"@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",
"@ngx-formly/core": "^5.5.15",
"@ngx-formly/material": "^5.5.13",
"@types/dragula": "^2.1.34",
"ajv": "^6.8.1",
"ajv": "^6.12.0",
"core-js": "^3.6.4",
"diff-match-patch": "^1.0.4",
"json-ptr": "^1.2.0",

View File

@@ -48,10 +48,12 @@ import { ExpansionPanelWrapperComponent } from './ngx-formly/components/expansio
import { FormFieldWrapperComponent } from './ngx-formly/components/form-field-wrapper.component';
import { InputTypeComponent } from './ngx-formly/components/input.type.component';
import { JsonObjectTypeComponent } from './ngx-formly/components/json-object.type.component';
import { UnionTypeComponent } from './ngx-formly/components/union.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 { SelectTypeComponent } from './ngx-formly/components/select.type.component';
import { TabArrayTypeComponent } from './ngx-formly/components/tab-array.type.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';
@@ -135,6 +137,8 @@ const DEV_PROVIDERS = [...PROD_PROVIDERS];
HoverPopoverDirective,
TestCaseHelpComponent,
JsonTreeComponent,
UnionTypeComponent,
TabArrayTypeComponent,
BuildInfoDialogComponent,
],
imports: [
@@ -186,18 +190,20 @@ const DEV_PROVIDERS = [...PROD_PROVIDERS];
},
},
{ name: 'boolean', extends: 'checkbox' },
{ name: 'enum', component: SelectTypeComponent, wrappers: ['form-field'] },
{ name: 'enum', extends: 'select' },
{ name: 'null', component: NullTypeComponent, wrappers: ['form-field'] },
{ name: 'array', component: ArrayTypeComponent },
{ name: 'object', component: ObjectTypeComponent },
{ name: 'tabs', component: TabsetTypeComponent},
{ name: 'union', component: UnionTypeComponent },
{ name: 'tab-array', component: TabArrayTypeComponent },
],
wrappers: [
{ name: 'panel', component: PanelWrapperComponent },
{ name: 'expansion-panel', component: ExpansionPanelWrapperComponent },
{ name: 'form-field', component: FormFieldWrapperComponent },
],
extras: { checkExpressionOn: 'modelChange', immutable: false },
extras: { checkExpressionOn: 'changeDetectionCheck', immutable: false },
}),
ReactiveFormsModule,
FormlyMaterialModule,

View File

@@ -9,7 +9,7 @@ import {
} from '@angular/cdk/drag-drop';
import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { EditorService } from '@app/editor.service';
import { EditorService } from '@services/editor.service';
import { ConfigData, ConfigWrapper, Deployment, PullRequestInfo, SubmitStatus } from '@app/model';
import { PopupService } from '@app/popup.service';
import { Store } from '@ngrx/store';
@@ -155,9 +155,9 @@ export class ConfigManagerComponent implements OnInit, OnDestroy {
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])),
: this.editorService.configWrapper
.unwrapOptionalsFromArrays(cloneDeep(this.filteredDeployment.configs[releaseId].configData)),
config2: this.editorService.configWrapper.unwrapConfig(cloneDeep(this.filteredConfigs[id].configData)),
},
});
}
@@ -181,8 +181,8 @@ export class ConfigManagerComponent implements OnInit, OnDestroy {
}
if (this.deployment.configs.find(r => r.name === item.name) === undefined) {
const updatedDeployment: Deployment<ConfigWrapper<ConfigData>> = cloneDeep(this.deployment);
const orderedItem: ConfigData = this.editorService.getLoader(this.serviceName).produceOrderedJson(item.configData, '/');
item.configData = orderedItem;
const orderedItem: ConfigData = this.editorService.configWrapper.produceOrderedJson(item.configData, '/');
item.configData = this.editorService.configWrapper.unwrapConfig(orderedItem);
updatedDeployment.configs.push(item);
this.store.dispatch(new fromStore.UpdateDeployment(updatedDeployment));
}

View File

@@ -6,11 +6,11 @@ import { StatusCode } from '@app/commons';
import { FormGroup } from '@angular/forms';
import { AppConfigService } from '@app/config';
import { EditorService } from '@app/editor.service';
import { EditorService } from '@services/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 { FormlyJsonschema } from '@ngx-formly/core/json-schema';
import * as fromStore from 'app/store';
import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs';
@@ -57,7 +57,7 @@ export class DeployDialogComponent {
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))
this.service.configLoader.validateRelease(data).pipe(take(1))
.subscribe(s => {
if (s !== undefined) {
this.statusCode = s.status_code;
@@ -78,7 +78,7 @@ export class DeployDialogComponent {
}
private createDeploymentSchema(serviceName: string): string {
const depSchema = this.service.getLoader(serviceName).originalSchema;
const depSchema = this.service.configLoader.originalSchema;
depSchema.properties[this.uiMetadata.deployment.config_array] = {};
delete depSchema.properties[this.uiMetadata.deployment.config_array];
delete depSchema.properties[this.uiMetadata.deployment.version];
@@ -95,7 +95,7 @@ export class DeployDialogComponent {
onValidate() {
this.deployment = {...this.deployment, ...this.extrasData};
this.service.getLoader(this.serviceName)
this.service.configLoader
.validateRelease(this.deployment).pipe(take(1)).subscribe(s => {
if (s !== undefined) {
this.statusCode = s.status_code;

View File

@@ -1,7 +1,6 @@
import { AppConfigService } from '@app/config/app-config.service';
import { TestCase, TestCaseMap } from '@app/model/test-case';
import { FormlyJsonschema } from '@app/ngx-formly/formly-json-schema.service';
import { ChangeDetectionStrategy, OnInit } from '@angular/core';
import { Component } from '@angular/core';
import { MatTabChangeEvent } from '@angular/material/tabs';
@@ -34,6 +33,7 @@ export class EditorViewComponent implements OnInit {
schema$: Observable<any> = new Observable();
selectedConfigIndex: number = undefined;
fields: FormlyFieldConfig[] = [];
formlyOptions: any = {autoClear: true}
onClickTestCase$: Subject<MatTabChangeEvent> = new Subject();
selectedConfigName: string;
@@ -46,6 +46,7 @@ export class EditorViewComponent implements OnInit {
dynamicFieldsMap$: Observable<Map<string, string>>;
configs: ConfigWrapper<ConfigData>[];
testCaseMap$: Observable<TestCaseMap>;
schema: any;
constructor(private store: Store<fromStore.State>, private config: AppConfigService, private formlyJsonschema: FormlyJsonschema) {
this.store.select(fromStore.getServiceName).pipe(takeUntil(this.ngUnsubscribe)).subscribe(r => {
@@ -67,7 +68,8 @@ export class EditorViewComponent implements OnInit {
ngOnInit() {
this.schema$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(
s => {
this.fields = [this.formlyJsonschema.toFieldConfig(s.schema)];
this.schema = s.schema;
this.fields = [this.formlyJsonschema.toFieldConfig(cloneDeep(s.schema), this.formlyOptions)];
this.store.dispatch(new fromStore.UpdateDynamicFieldsMap(this.formlyJsonschema.dynamicFieldsMap));
});
@@ -77,6 +79,10 @@ export class EditorViewComponent implements OnInit {
filter(f => f !== undefined),
takeUntil(this.ngUnsubscribe)
).subscribe(s => {
// due to how oneOf changes the displayed fields we need to provide a freshly generated form when a rule is selected
if (this.schema) {
this.fields = [this.formlyJsonschema.toFieldConfig(cloneDeep(this.schema))];
}
this.selectedConfigName = this.configs[s].name;
this.testCases = cloneDeep(this.testCaseMap[this.selectedConfigName]) || [];
this.selectedConfigIndex = s;

View File

@@ -3,10 +3,10 @@ import { FormGroup } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { MatTabChangeEvent } from '@angular/material/tabs';
import { AppConfigService } from '@app/config';
import { EditorService } from '@app/editor.service';
import { EditorService } from '@services/editor.service';
import { ConfigData, ConfigWrapper, SensorFields } from '@app/model';
import { TEST_CASE_TAB_NAME } from '@app/model/test-case';
import { UiMetadataMap } from '@app/model/ui-metadata-map';
import { TEST_CASE_TAB_NAME } from '@model/test-case';
import { UiMetadataMap } from '@model/ui-metadata-map';
import * as JsonPointer from '@app/ngx-formly/util/jsonpointer.functions';
import { PopupService } from '@app/popup.service';
import { Store } from '@ngrx/store';
@@ -125,11 +125,10 @@ export class EditorComponent implements OnInit, OnDestroy {
}
private cleanConfigData(configData: ConfigData): ConfigData {
let cfg = this.removeFieldsWhichShouldBeHidden(this.dynamicFieldsMap, configData);
cfg = this.editorService.getLoader(this.serviceName).produceOrderedJson(cfg, '/');
//let cfg = this.removeFieldsWhichShouldBeHidden(this.dynamicFieldsMap, configData);
let cfg = this.editorService.configWrapper.produceOrderedJson(configData, '/');
// recursively removes null, undefined, empty objects, empty arrays from the object
cfg = omitEmpty(cfg);
return cfg;
}
@@ -148,7 +147,8 @@ export class EditorComponent implements OnInit, OnDestroy {
configToUpdate.description = configToUpdate.configData[this.metaDataMap.description];
configToUpdate.configData = this.cleanConfigData(configToUpdate.configData);
// check if rule has been changed, mark unsaved
if (JSON.stringify(this.config.configData) !== JSON.stringify(configToUpdate.configData)) {
if (JSON.stringify(cloneDeep(this.config.configData)) !==
JSON.stringify(configToUpdate.configData)) {
configToUpdate.savedInBackend = false;
}
const newConfigs = Object.assign(this.configs.slice(), {

View File

@@ -5,7 +5,7 @@ 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 { EditorService } from '@services/editor.service';
import * as fromGuards from '../../guards';
import { RepoResolver, ViewResolver } from '../../guards';
import * as fromStore from '../../store';
@@ -74,7 +74,6 @@ export class InitComponent implements OnDestroy {
});
this.router.resetConfig(routes);
this.store.dispatch(new SetServiceNames(serviceList));
this.service.createLoaders();
})
this.store.select(fromStore.getServiceNames).pipe(

View File

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

View File

@@ -21,8 +21,8 @@ export interface DiffContent {
templateUrl: './json-viewer.component.html',
})
export class JsonViewerComponent {
public leftContent: ConfigWrapper<ConfigData>;
public rightContent: ConfigWrapper<ConfigData>;
public leftContent: ConfigData;
public rightContent: ConfigData;
constructor(public dialogRef: MatDialogRef<JsonViewerComponent>,
@Inject(MAT_DIALOG_DATA) public data: any) {

View File

@@ -3,7 +3,7 @@ import { OnInit } from '@angular/core';
import { Component, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { StatusCode } from '@app/commons/status-code';
import { EditorService } from '@app/editor.service';
import { EditorService } from '@services/editor.service';
import { EditorResult, ExceptionInfo } from '@app/model';
import { TestCase } from '@app/model/test-case';
import { ValidationState } from '@app/model/validation-status';

View File

@@ -4,24 +4,24 @@ import { MatDialog } from '@angular/material/dialog';
import { ErrorDialogComponent } from '@app/components';
import { AppConfigService } from '@app/config';
import { TestCase, TestCaseMap, TestCaseResultDefault, TestState } from '@app/model/test-case';
import { FormlyJsonschema } from '@app/ngx-formly/formly-json-schema.service';
import { PopupService } from '@app/popup.service';
import { Store } from '@ngrx/store';
import { FormlyJsonschema } from '@app/ngx-formly/formly-json-schema.service';
import { FormlyFieldConfig } from '@ngx-formly/core/lib/core';
import * as fromStore from 'app/store';
import { cloneDeep } from 'lodash';
import * as omitEmpty from 'omit-empty';
import { from, Observable, of, Subject, throwError } from 'rxjs';
import { catchError, delay, map, switchMap, take, takeUntil, tap } from 'rxjs/operators';
import { EditorService } from '../../../editor.service';
import { EditorService } from '@services/editor.service';
import {
ConfigData,
ConfigTestDto,
ConfigTestResult,
ConfigWrapper,
EditorResult,
} from '../../../model/config-model';
import { TestCaseWrapper, TestCaseWrapperDefault } from '../../../model/test-case';
} from '@model/config-model';
import { TestCaseWrapper, TestCaseWrapperDefault } from '@model/test-case';
import { SubmitTestcaseDialogComponent } from '../submit-testcase-dialog/submit-testcase-dialog.component';
interface OutputDict {
@@ -39,7 +39,7 @@ export class TestCentreComponent implements OnInit, OnDestroy {
EVENT_HELP: string;
private ngUnsubscribe = new Subject();
public fields: FormlyFieldConfig[] = [];
public options: any = {};
public options: any = {autoClear: false};
public testCase: TestCaseWrapper = new TestCaseWrapperDefault();
public alert: string;
public form: FormGroup = new FormGroup({});
@@ -70,20 +70,23 @@ export class TestCentreComponent implements OnInit, OnDestroy {
private dialog: MatDialog) {}
ngOnInit() {
this.service = this.editorService.getLoader(this.serviceName);
this.store.select(fromStore.getConfigTestingEvent).pipe(take(1)).subscribe(e => this.alert = e);
if (this.appConfig.getUiMetadata(this.serviceName).testing.testCaseEnabled) {
this.store.select(fromStore.getTestCaseSchema).pipe(takeUntil(this.ngUnsubscribe)).subscribe(s => {
this.service.getTestSpecificationSchema().pipe(takeUntil(this.ngUnsubscribe)).subscribe(l => {
this.editorService.configLoader.getTestSpecificationSchema().pipe(takeUntil(this.ngUnsubscribe)).subscribe(l => {
this.options.formState = {
mainModel: this.testCase,
rawObjects: {},
};
const subschema = new FormlyJsonschema().toFieldConfig(l);
const schemaConverter = new FormlyJsonschema();
schemaConverter.testSpec = subschema;
this.fields = [schemaConverter.toFieldConfig(s, this.options)];
this.fields = [schemaConverter.toFieldConfig(cloneDeep(s), this.options)];
})
})
}
this.selectedConfigIndex$.pipe(takeUntil(this.ngUnsubscribe)).subscribe(s => {
this.addNewTest = false;
this.changeDetector.markForCheck();
@@ -241,7 +244,7 @@ export class TestCentreComponent implements OnInit, OnDestroy {
test_specification: JSON.parse(JSON.stringify(testcase.test_specification, this.replacer)),
}
return this.editorService.getLoader(this.serviceName).testSingleConfig(testDto).pipe(
return this.editorService.configLoader.testSingleConfig(testDto).pipe(
map((r: EditorResult<ConfigTestResult>) => {
this.output[testcase.test_case_name] = r.attributes;
if (r.status_code === 'OK') {

View File

@@ -2,7 +2,7 @@ import { Inject } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { AppConfigService } from '@app/config';
import { EditorService } from '@app/editor.service';
import { EditorService } from '@services/editor.service';
import { ConfigData, Deployment, EditorResult } from '@app/model';
import { PopupService } from '@app/popup.service';
import { Store } from '@ngrx/store';
@@ -23,7 +23,6 @@ export class TestingDialogComponent implements OnDestroy, OnInit {
EVENT_HELP: string;
testSuccess = 'none';
private ngUnsubscribe = new Subject();
private service: any;
private env: string;
isSingleConfig: boolean;
@@ -35,7 +34,6 @@ export class TestingDialogComponent implements OnDestroy, OnInit {
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;
@@ -84,9 +82,9 @@ export class TestingDialogComponent implements OnDestroy, OnInit {
[this.appConfig.getUiMetadata(this.env).testing.eventName]: event,
};
if (this.service) {
if (this.editorService.configLoader) {
if (this.isSingleConfig) {
this.service.testSingleConfig(testDto).pipe(takeUntil(this.ngUnsubscribe))
this.editorService.configLoader.testSingleConfig(testDto).pipe(takeUntil(this.ngUnsubscribe))
.map(r => r)
.catch(err => {
this.ngUnsubscribe.next();
@@ -112,7 +110,7 @@ export class TestingDialogComponent implements OnDestroy, OnInit {
}
)
} else {
this.service.testDeploymentConfig(testDto).pipe(takeUntil(this.ngUnsubscribe))
this.editorService.configLoader.testDeploymentConfig(testDto).pipe(takeUntil(this.ngUnsubscribe))
.map(r => r)
.catch(err => {
this.ngUnsubscribe.next();

View File

@@ -1,419 +0,0 @@
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,
Content,
Deployment,
DeploymentWrapper,
EditorResult,
ExceptionInfo,
GitFiles,
PullRequestInfo,
RepositoryLinks,
RepositoryLinksWrapper,
SchemaInfo,
TestSchemaInfo,
} from './model/config-model';
import { Field } from './model/sensor-fields';
import { TestCase, TestCaseMap, TestCaseResultDefault, TestCaseWrapper, TestState } from './model/test-case';
import { UiMetadataMap } from './model/ui-metadata-map';
import { cloneDeep } from 'lodash';
import { map } from 'rxjs/operators';
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 serviceName: string) {
this.uiMetadata = this.config.getUiMetadata(this.serviceName);
try {
this.labelsFunc = new Function('model', this.uiMetadata.labelsFunc);
} catch {
console.error('unable to parse labels function');
this.labelsFunc = () => [];
}
}
public getConfigs(): Observable<ConfigWrapper<ConfigData>[]> {
return this.http.get<EditorResult<GitFiles<any>>>(
`${this.config.serviceRoot}api/v1/${this.serviceName}/configstore/configs`).pipe(
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: Content<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 getTestSpecificationSchema(): Observable<any> {
return this.http.get<EditorResult<TestSchemaInfo>>(`${this.config.serviceRoot}api/v1/${this.serviceName}/configs/testschema`).pipe(
map(x =>
x.attributes.test_schema
)
);
}
public getSchema(): Observable<SchemaDto> {
return this.http.get<EditorResult<SchemaInfo>>(`${this.config.serviceRoot}api/v1/${this.serviceName}/configs/schema`).pipe(
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);
if (thingy.default) {
delete thingy.default;
}
const sub = {...thingy};
thingy.type = 'array';
delete thingy.required;
delete thingy.properties;
delete thingy.title;
delete thingy.description;
// tabs is not compatible with the array type so delete it if it is at the parent level but keep it on the sub level
if (sub['x-schema-form'] !== undefined && sub['x-schema-form']['type'] !== 'tabs') {
delete sub['x-schema-form'];
}
if (thingy['x-schema-form'] !== undefined && thingy['x-schema-form']['type'] === 'tabs' && thingy['type'] === 'array') {
delete thingy['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.serviceName}/configstore/release/status`)
.pipe(
map(result => result.attributes)
)
}
public getRelease(): Observable<DeploymentWrapper> {
return this.http.get<EditorResult<GitFiles<any>>>
(`${this.config.serviceRoot}api/v1/${this.serviceName}/configstore/release`).pipe(
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.serviceName}/configstore/repositories`).pipe(
map(result => ({
...result.attributes.rules_repositories,
rulesetName: this.serviceName,
}))
)
}
public getTestCases(): Observable<TestCaseMap> {
return this.http.get<EditorResult<GitFiles<TestCase>>>(`${this.config.serviceRoot}api/v1/${this.serviceName}/configstore/testcases`)
.pipe(
map(result => this.testCaseFilesToMap(result))
);
}
private testCaseFilesToMap(result: EditorResult<GitFiles<TestCase>>): TestCaseMap {
const testCaseMap: TestCaseMap = {};
if (result.attributes && result.attributes.files && result.attributes.files.length > 0) {
result.attributes.files.forEach(file => {
if (!testCaseMap.hasOwnProperty(file.content.config_name)) {
testCaseMap[file.content.config_name] = [];
}
const testCase: TestCaseWrapper = {
testCase: file.content,
testState: TestState.NOT_RUN,
testResult: new TestCaseResultDefault(),
fileHistory: file.file_history,
}
testCaseMap[file.content.config_name].push(testCase);
});
}
return testCaseMap;
}
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.serviceName}/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.serviceName}/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.serviceName}/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.serviceName}/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.serviceName}/configstore/configs`, json);
}
public getFields(): Observable<Field[]> {
return this.http.get<EditorResult<any>>(
`${this.config.serviceRoot}api/v1/${this.serviceName}/configs/fields`).pipe(
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.serviceName}/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.serviceName}/configs/test?singleConfig=true`, testDto)
}
public submitTestCaseEdit(testCase: TestCaseWrapper): Observable<TestCaseMap> {
const json = JSON.stringify(cloneDeep(testCase.testCase), null, 2);
return this.http.put<EditorResult<GitFiles<TestCase>>>(
`${this.config.serviceRoot}api/v1/${this.serviceName}/configstore/testcases`, json).pipe(
map(result => this.testCaseFilesToMap(result))
);
}
public submitNewTestCase(testCase: TestCaseWrapper): Observable<TestCaseMap> {
const json = JSON.stringify(cloneDeep(testCase.testCase), null, 2);
return this.http.post<EditorResult<GitFiles<TestCase>>>(
`${this.config.serviceRoot}api/v1/${this.serviceName}/configstore/testcases`, json).pipe(
map(result => this.testCaseFilesToMap(result))
);
}
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

@@ -1,5 +1,5 @@
import { NgModule, Optional, SkipSelf } from '@angular/core';
import { EditorService } from 'app/editor.service';
import { EditorService } from '@services/editor.service';
@NgModule({
providers: [

View File

@@ -8,6 +8,7 @@ export interface UiMetadataMap {
enableSensorFields: boolean,
perConfigSchemaPath: string,
deployment: DeploymentConfig,
unionType?: UnionType,
}
export interface TestConfig {
@@ -24,3 +25,8 @@ export interface DeploymentConfig {
config_array: string,
extras?: string[],
}
export interface UnionType {
unionPath: string;
unionSelectorName: string;
}

View File

@@ -1,6 +1,8 @@
import { Component } from '@angular/core';
import { FieldArrayType } from '@ngx-formly/core';
import { clone, isNullOrUndefined, assignModelValue, getKeyPath } from '../util/utility.functions';
import { cloneDeep } from 'lodash';
@Component({
// tslint:disable-next-line:component-selector
@@ -120,9 +122,26 @@ export class ArrayTypeComponent extends FieldArrayType {
}
}
reorder (oldIndex, newIndex) {
reorder (oldIndex: number, newIndex: number) {
const temp = this.model[oldIndex];
this.remove(oldIndex);
this.add(newIndex, temp);
}
add(i?: number, initialModel?: any, { markAsDirty } = { markAsDirty: true }) {
i = isNullOrUndefined(i) ? this.field.fieldGroup.length : i;
if (!this.model) {
assignModelValue(this.field.parent.model, getKeyPath(this.field), []);
}
let originalModel = cloneDeep(this.model);
this.model.splice(i, 0, initialModel ? clone(initialModel) : undefined);
(<any> this.options)._buildForm(true);
originalModel.push(this.model[this.model.length - 1]);
for (let i=0; i < this.model.length; i++) {
this.model[i] = originalModel[i];
}
markAsDirty && this.formControl.markAsDirty();
}
}

View File

@@ -0,0 +1,86 @@
import { Component } from '@angular/core';
import { FieldArrayType } from '@ngx-formly/core';
import { cloneDeep } from 'lodash';
import { clone, isNullOrUndefined, assignModelValue, getKeyPath } from '../util/utility.functions';
@Component({
selector: 'formly-tab-array',
template: `
<div>
<mat-tab-group animationDuration="0ms" [(selectedIndex)]="selectedIndex">
<mat-tab *ngFor="let tab of field.fieldGroup; index as i" [label]="getEvaluatorType(tab?.model)">
<span class="align-right">
<svg
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>
</span>
<formly-field [field]="tab"></formly-field>
</mat-tab>
</mat-tab-group>
<div class="align-right">
<button mat-raised-button color="primary" (click)="add(selectedIndex + 1)">add evaluator</button>
</div>
</div>
`,
styles: [`
.close-button {
cursor: pointer;
top: 6px;
right: 6px;
fill: orange;
z-index: 500;
}
.move-arrow {
width: 14px;
height: 14px;
font-size: 18px;
cursor: pointer;
}
.greyed-out {
width: 14px;
height: 14px;
font-size: 18px;
color: #707070;
cursor: default;
}
.align-right {
margin-left: auto;
margin-right: 0;
display: table;
padding-top: 5px;
}
`],
})
export class TabArrayTypeComponent extends FieldArrayType {
public selectedIndex =0;
getEvaluatorType(model) {
return Object.keys(model)[0];
}
add(i?: number, initialModel?: any, { markAsDirty } = { markAsDirty: true }) {
i = isNullOrUndefined(i) ? this.field.fieldGroup.length : i;
if (!this.model) {
assignModelValue(this.field.parent.model, getKeyPath(this.field), []);
}
let originalModel = cloneDeep(this.model);
this.model.splice(i, 0, initialModel ? clone(initialModel) : undefined);
(<any> this.options)._buildForm(true);
originalModel.splice(i, 0, this.model[this.model.length - 1]);
for (let i=0; i < this.model.length; i++) {
this.model[i] = originalModel[i];
}
markAsDirty && this.formControl.markAsDirty();
this.selectedIndex = i;
}
}

View File

@@ -0,0 +1,19 @@
import { Component } from '@angular/core';
import { FieldType } from '@ngx-formly/core';
@Component({
selector: 'formly-union-type',
template: `
<div class="card mb-3">
<div class="card-body">
<legend *ngIf="to.label">{{ to.label }}</legend>
<p *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>
<formly-field *ngFor="let f of field.fieldGroup" [field]="f"></formly-field>
</div>
</div>
`,
})
export class UnionTypeComponent extends FieldType {}

View File

@@ -1,11 +1,13 @@
import { TitleCasePipe } from '@angular/common';
import { Injectable } from '@angular/core';
import { AbstractControl } from '@angular/forms';
import { AbstractControl, FormControl } from '@angular/forms';
import { FormlyFieldConfig } from '@ngx-formly/core';
import { ɵreverseDeepMerge as reverseDeepMerge } from '@ngx-formly/core';
import { FormlyFieldConfigCache } from '@ngx-formly/core/lib/models';
import { JSONSchema7, JSONSchema7TypeName } from 'json-schema';
import { cloneDeep } from 'lodash';
export interface FormlyJsonschemaOptions {
/**
* allows to intercept the mapping, taking the already mapped
@@ -15,12 +17,72 @@ export interface FormlyJsonschemaOptions {
map?: (mappedField: FormlyFieldConfig, mapSource: JSONSchema7) => FormlyFieldConfig;
}
function isEmpty(v) {
export function getFieldInitialValue(field: FormlyFieldConfig) {
let value = field.options['_initialModel'];
let paths = getKeyPath(field);
while (field.parent) {
field = field.parent;
paths = [...getKeyPath(field), ...paths];
}
for (const path of paths) {
if (!value) {
return undefined;
}
value = value[path];
}
return value;
}
export function getKeyPath(field: FormlyFieldConfigCache): string[] {
if (!field.key) {
return [];
}
/* We store the keyPath in the field for performance reasons. This function will be called frequently. */
if (!field._keyPath || field._keyPath.key !== field.key) {
const key =
field.key.indexOf('[') === -1
? field.key
: field.key.replace(/\[(\w+)\]/g, '.$1');
field._keyPath = {
key: field.key,
path: key.indexOf('.') !== -1 ? key.split('.') : [key]
};
}
}
function isEmpty(v: any) {
return v === '' || v === undefined || v === null;
}
function isConst(schema: JSONSchema7) {
return schema.hasOwnProperty('const') || (schema.enum && schema.enum.length === 1);
}
function isEmptyFieldModel(field: FormlyFieldConfig): boolean {
if (field.key && !field.fieldGroup) {
return getFieldInitialValue(field) === undefined;
}
return field.fieldGroup.every(f => isEmptyFieldModel(f));
}
function isFieldValid(field: FormlyFieldConfig): boolean {
if (field.key) {
return field.formControl.valid;
}
return field.fieldGroup.every(f => isFieldValid(f));
}
interface IOptions extends FormlyJsonschemaOptions {
schema: JSONSchema7;
autoClear?: boolean;
}
@Injectable({ providedIn: 'root' })
@@ -30,22 +92,15 @@ export class FormlyJsonschema {
titleCasePipe: TitleCasePipe = new TitleCasePipe();
constructor() {}
toFieldConfig(schema: JSONSchema7, options?: FormlyJsonschemaOptions): FormlyFieldConfig {
this.dynamicFieldsMap = new Map<string, string>();
const fieldConfig = this._toFieldConfig(schema, { schema, ...(options || {}) }, []);
const fieldConfig = this._toFieldConfig(schema, { schema, ...options }, []);
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);
}
schema = this.resolveSchema(schema, options);
let field: FormlyFieldConfig = {
type: this.guessType(schema),
@@ -57,9 +112,14 @@ export class FormlyJsonschema {
},
};
field['autoClear'] = true;
if (options.autoClear === false) {
field['autoClear'] = false;
}
switch (field.type) {
case 'null': {
this.addValidator(field, 'null', c => c.value === null);
this.addValidator(field, 'null', ({ value }) => value === null);
break;
}
case 'boolean': {
@@ -81,21 +141,26 @@ export class FormlyJsonschema {
if (schema.hasOwnProperty('exclusiveMinimum')) {
field.templateOptions.exclusiveMinimum = schema.exclusiveMinimum;
this.addValidator(field, 'exclusiveMinimum', c => isEmpty(c.value) || (c.value > schema.exclusiveMinimum));
this.addValidator(field, 'exclusiveMinimum', ({ value }) => isEmpty(value) || (value > schema.exclusiveMinimum));
}
if (schema.hasOwnProperty('exclusiveMaximum')) {
field.templateOptions.exclusiveMaximum = schema.exclusiveMaximum;
this.addValidator(field, 'exclusiveMaximum', c => isEmpty(c.value) || (c.value < schema.exclusiveMaximum));
this.addValidator(field, 'exclusiveMaximum', ({ value }) => isEmpty(value) || (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));
this.addValidator(field, 'multipleOf', ({ value }) => isEmpty(value) || (value % schema.multipleOf === 0));
}
break;
}
case 'string': {
const schemaType = schema.type as JSONSchema7TypeName;
if (Array.isArray(schemaType) && (schemaType.indexOf('null') !== -1)) {
field.parsers = [v => isEmpty(v) ? null : v];
}
['minLength', 'maxLength', 'pattern'].forEach(prop => {
if (schema.hasOwnProperty(prop)) {
field.templateOptions[prop] = schema[prop];
@@ -112,7 +177,7 @@ export class FormlyJsonschema {
const [propDeps, schemaDeps] = this.resolveDependencies(schema);
// TODO remove hard coded logic for generating the subschema
if (schema.properties === undefined) {
if (schema.properties === undefined && !schema.hasOwnProperty('oneOf')) {
if (this.testSpec !== undefined) {
field = this.testSpec;
break;
@@ -132,62 +197,129 @@ export class FormlyJsonschema {
if (Array.isArray(schema.required) && schema.required.indexOf(key) !== -1) {
f.templateOptions.required = true;
}
if (!f.templateOptions.required && propDeps[key]) {
if (f.templateOptions && !f.templateOptions.required && propDeps[key]) {
f.expressionProperties = {
'templateOptions.required': m => m && propDeps[key].some(k => !isEmpty(m[k])),
};
}
if (schemaDeps[key]) {
const getConstValue = (s: JSONSchema7) => {
return s.hasOwnProperty('const') ? s.const : s.enum[0];
};
const oneOfSchema = schemaDeps[key].oneOf;
if (
oneOfSchema
&& oneOfSchema.every(o => o.properties && o.properties[key] && isConst(o.properties[key]))
) {
oneOfSchema.forEach(oneOfSchemaItem => {
const { [key]: constSchema, ...properties } = oneOfSchemaItem.properties;
field.fieldGroup.push({
...this._toFieldConfig(schemaDeps[key], options),
...this._toFieldConfig({ ...oneOfSchemaItem, properties }, { ...options, autoClear: true }, newPropKey),
hideExpression: m => !m || getConstValue(constSchema) !== m[key],
});
});
} else {
field.fieldGroup.push({
...this._toFieldConfig(schemaDeps[key], options, newPropKey),
hideExpression: m => !m || isEmpty(m[key]),
});
}
}
});
if (schema.oneOf) {
field.fieldGroup.push(this.resolveMultiSchema(
'oneOf',
<JSONSchema7[]> schema.oneOf,
options,
cloneDeep(propKey)
));
}
if (schema.anyOf) {
field.fieldGroup.push(this.resolveMultiSchema(
'anyOf',
<JSONSchema7[]> schema.anyOf,
options,
cloneDeep(propKey)
));
}
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));
this.addValidator(field, 'minItems', ({ value }) => isEmpty(value) || (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));
this.addValidator(field, 'maxItems', ({ value }) => isEmpty(value) || (value.length <= schema.maxItems));
}
if (schema.hasOwnProperty('uniqueItems')) {
field.templateOptions.uniqueItems = schema.uniqueItems;
this.addValidator(field, 'uniqueItems', ({ value }) => {
if (isEmpty(value) || !schema.uniqueItems) {
return true;
}
const uniqueItems = Array.from(
new Set(value.map((v: any) => JSON.stringify(v))),
);
return uniqueItems.length === value.length;
});
}
// resolve items schema needed for isEnum check
if (schema.items && !Array.isArray(schema.items)) {
schema.items = this.resolveSchema(<JSONSchema7> schema.items, options);
}
// TODO: remove isEnum check once adding an option to skip extension
if (!this.isEnum(schema)) {
const _this = this;
Object.defineProperty(field, 'fieldArray', {
get: () => {
get: function() {
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);
return _this._toFieldConfig(<JSONSchema7> schema.items, options, newPropKey2);
}
const itemSchema = schema.items[field.fieldGroup.length]
? schema.items[field.fieldGroup.length]
const length = this.fieldGroup ? this.fieldGroup.length : 0;
const itemSchema = schema.items[length]
? schema.items[length]
: schema.additionalItems;
return itemSchema
? this._toFieldConfig(<JSONSchema7> itemSchema, options)
: null;
? _this._toFieldConfig(<JSONSchema7> itemSchema, options, newPropKey2)
: {};
},
enumerable: true,
configurable: true,
});
}
break;
}
}
if (schema.hasOwnProperty('const')) {
field.templateOptions.const = schema.const;
this.addValidator(field, 'const', ({ value }) => value === schema.const);
if (!field.type) {
field.defaultValue = schema.const;
}
}
if (schema.hasOwnProperty('x-schema-form')) {
if (schema['x-schema-form'].hasOwnProperty('type')) {
field.type = schema['x-schema-form'].type;
@@ -206,14 +338,20 @@ export class FormlyJsonschema {
} catch {
console.warn('Something went wrong with applying condition evaluation to form');
}
if (!schema['x-schema-form'].condition.hasOwnProperty('isContant')) {
this.dynamicFieldsMap.set(('/' + propKey.join('/')), schema['x-schema-form'].condition.hideExpression);
}
}
if (schema['x-schema-form'].condition.hasOwnProperty('disableAutoClear')) {
field['autoClear'] = false;
}
}
}
if (schema.enum) {
if (this.isEnum(schema)) {
field.templateOptions.multiple = field.type === 'array';
field.type = 'enum';
field.templateOptions.options = schema.enum.map(value => ({ value, label: value }));
field.templateOptions.options = this.toEnumOptions(schema);
}
// map in possible formlyConfig options from the widget property
@@ -226,12 +364,7 @@ export class FormlyJsonschema {
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) => {
private resolveSchema(schema: JSONSchema7, options: IOptions) {
if (schema.$ref) {
schema = this.resolveDefinition(schema, options);
}
@@ -239,6 +372,17 @@ export class FormlyJsonschema {
if (schema.allOf) {
schema = this.resolveAllOf(schema, options);
}
return schema;
}
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) => {
schema = this.resolveSchema(schema, options);
if (base.required && schema.required) {
base.required = [...base.required, ...schema.required];
}
@@ -267,6 +411,60 @@ export class FormlyJsonschema {
}, baseSchema);
}
private resolveMultiSchema(
mode: 'oneOf' | 'anyOf',
schemas: JSONSchema7[],
options: IOptions,
propKey: string[]
): FormlyFieldConfig {
return {
type: 'union',
fieldGroup: [
{
type: 'enum',
templateOptions: {
multiple: mode === 'anyOf',
options: schemas
.map((s, i) => ({ label: s.title, value: i })),
},
},
{
fieldGroup: schemas.map((s, i) => ({
...this._toFieldConfig(s, { ...options, autoClear: true }, propKey),
hideExpression: (m, fs, f) => {
const selectField = f.parent.parent.fieldGroup[0];
if (!selectField.formControl) {
const value = f.parent.fieldGroup
.map((f, i) => [f, i] as [FormlyFieldConfig, number])
.filter(([f]) => isFieldValid(f))
.sort(([f1], [f2]) => {
const isDefaultModel = isEmptyFieldModel(f1);
if (isDefaultModel === isEmptyFieldModel(f2)) {
return 0;
}
return isDefaultModel ? 1 : -1;
})
.map(([, i]) => i)
;
const normalizedValue = [value.length === 0 ? 0 : value[0]];
const formattedValue = mode === 'anyOf' ? normalizedValue : normalizedValue[0];
selectField.formControl = new FormControl(formattedValue);
}
const control = selectField.formControl;
return Array.isArray(control.value)
? control.value.indexOf(i) === -1
: control.value !== i;
},
})),
},
],
};
}
private resolveDefinition(schema: JSONSchema7, options: IOptions): JSONSchema7 {
const [uri, pointer] = schema.$ref.split('#/');
if (uri) {
@@ -275,7 +473,7 @@ export class FormlyJsonschema {
const definition = !pointer ? null : pointer.split('/').reduce(
(def, path) => def && def.hasOwnProperty(path) ? def[path] : null,
options.schema
options.schema,
);
if (!definition) {
@@ -328,6 +526,16 @@ export class FormlyJsonschema {
return 'object';
}
if (Array.isArray(type)) {
if (type.length === 1) {
return type[0];
}
if (type.length === 2 && type.indexOf('null') !== -1) {
return type[type[0] === 'null' ? 1 : 0];
}
}
return type;
}
@@ -335,4 +543,33 @@ export class FormlyJsonschema {
field.validators = field.validators || {};
field.validators[name] = validator;
}
private isEnum(schema: JSONSchema7) {
return schema.enum
|| (schema.anyOf && schema.anyOf.every(isConst))
|| (schema.oneOf && schema.oneOf.every(isConst))
|| schema.uniqueItems && schema.items && !Array.isArray(schema.items) && this.isEnum(<JSONSchema7> schema.items);
}
private toEnumOptions(schema: JSONSchema7) {
if (schema.enum) {
return schema.enum.map(value => ({ value, label: value }));
}
const toEnum = (s: JSONSchema7) => {
const value = s.hasOwnProperty('const') ? s.const : s.enum[0];
return { value: value, label: s.title || value };
};
if (schema.anyOf) {
return schema.anyOf.map(toEnum);
}
if (schema.oneOf) {
return schema.oneOf.map(toEnum);
}
return this.toEnumOptions(<JSONSchema7> schema.items);
}
}

View File

@@ -1,3 +1,5 @@
import { isObservable } from 'rxjs';
import {
hasValue,
inArray,
@@ -10,6 +12,8 @@ import {
isString,
PlainObject
} from './validator.functions';
import { AbstractControl } from '@angular/forms';
import { FormlyFieldConfigCache } from '@ngx-formly/core/lib/components/formly.field.config';
/**
* Utility function library:
@@ -318,3 +322,88 @@ export function toTitleCase(input: string, forceWords?: string | string[]): stri
}
});
}
export function getKeyPath(field: FormlyFieldConfigCache): string[] {
if (!field.key) {
return [];
}
/* We store the keyPath in the field for performance reasons. This function will be called frequently. */
if (!field._keyPath || field._keyPath.key !== field.key) {
const key = field.key.indexOf('[') === -1
? field.key
: field.key.replace(/\[(\w+)\]/g, '.$1');
field._keyPath = { key: field.key, path: key.indexOf('.') !== -1 ? key.split('.') : [key] };
}
return field._keyPath.path.slice(0);
}
export function assignModelValue(model: any, paths: string[], value: any) {
for (let i = 0; i < (paths.length - 1); i++) {
const path = paths[i];
if (!model[path] || !isObject(model[path])) {
model[path] = /^\d+$/.test(paths[i + 1]) ? [] : {};
}
model = model[path];
}
model[paths[paths.length - 1]] = clone(value);
}
export function clone(value: any): any {
if (
!isObject(value)
|| isObservable(value)
|| /* instanceof SafeHtmlImpl */ value.changingThisBreaksApplicationSecurity
|| ['RegExp', 'FileList', 'File', 'Blob'].indexOf(value.constructor.name) !== -1
) {
return value;
}
// https://github.com/moment/moment/blob/master/moment.js#L252
if (value._isAMomentObject && isFunction(value.clone)) {
return value.clone();
}
if (value instanceof AbstractControl) {
return null;
}
if (value instanceof Date) {
return new Date(value.getTime());
}
if (Array.isArray(value)) {
return value.slice(0).map(v => clone(v));
}
// best way to clone a js object maybe
// https://stackoverflow.com/questions/41474986/how-to-clone-a-javascript-es6-class-instance
const proto = Object.getPrototypeOf(value);
let c = Object.create(proto);
c = Object.setPrototypeOf(c, proto);
// need to make a deep copy so we dont use Object.assign
// also Object.assign wont copy property descriptor exactly
return Object.keys(value).reduce((newVal, prop) => {
const propDesc = Object.getOwnPropertyDescriptor(value, prop);
if (propDesc.get) {
Object.defineProperty(newVal, prop, propDesc);
} else {
newVal[prop] = clone(value[prop]);
}
return newVal;
}, c);
}
export function isNullOrUndefined(value: any) {
return value === undefined || value === null;
}
export function isFunction(value: any) {
return typeof(value) === 'function';
}

View File

@@ -0,0 +1,389 @@
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,
Content,
Deployment,
DeploymentWrapper,
EditorResult,
ExceptionInfo,
GitFiles,
PullRequestInfo,
SchemaInfo,
TestSchemaInfo
} from '@model/config-model';
import { Field } from '@model/sensor-fields';
import {
TestCase,
TestCaseMap,
TestCaseResultDefault,
TestCaseWrapper,
TestState
} from '@model/test-case';
import { UiMetadataMap } from '@model/ui-metadata-map';
import { cloneDeep } from 'lodash';
import { map } from 'rxjs/operators';
import { ConfigWrapperService } from './config-wrapper-service';
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 serviceName: string,
private configWrapperService: ConfigWrapperService
) {
this.uiMetadata = this.config.getUiMetadata(this.serviceName);
try {
this.labelsFunc = new Function('model', this.uiMetadata.labelsFunc);
} catch {
console.error('unable to parse labels function');
this.labelsFunc = () => [];
}
}
public getConfigs(): Observable<ConfigWrapper<ConfigData>[]> {
return this.http
.get<EditorResult<GitFiles<any>>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/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.configWrapperService.wrapConfig(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: Content<any>[]
): ConfigWrapper<ConfigData>[] {
const ret: ConfigWrapper<ConfigData>[] = [];
for (const file of files) {
ret.push({
isNew: false,
configData: this.configWrapperService.wrapConfig(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 getTestSpecificationSchema(): Observable<any> {
return this.http
.get<EditorResult<TestSchemaInfo>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configs/testschema`
)
.pipe(map(x => x.attributes.test_schema));
}
public getSchema(): Observable<SchemaDto> {
return this.http
.get<EditorResult<SchemaInfo>>(
`${this.config.serviceRoot}api/v1/${this.serviceName}/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.configWrapperService.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 };
});
}
public getPullRequestStatus(): Observable<PullRequestInfo> {
return this.http
.get<EditorResult<PullRequestInfo>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configstore/release/status`
)
.pipe(map(result => result.attributes));
}
public getRelease(): Observable<DeploymentWrapper> {
return this.http
.get<EditorResult<GitFiles<any>>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configstore/release`
)
.pipe(
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.configWrapperService.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 getTestCases(): Observable<TestCaseMap> {
return this.http
.get<EditorResult<GitFiles<TestCase>>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configstore/testcases`
)
.pipe(map(result => this.testCaseFilesToMap(result)));
}
private testCaseFilesToMap(
result: EditorResult<GitFiles<TestCase>>
): TestCaseMap {
const testCaseMap: TestCaseMap = {};
if (
result.attributes &&
result.attributes.files &&
result.attributes.files.length > 0
) {
result.attributes.files.forEach(file => {
if (!testCaseMap.hasOwnProperty(file.content.config_name)) {
testCaseMap[file.content.config_name] = [];
}
const testCase: TestCaseWrapper = {
testCase: file.content,
testState: TestState.NOT_RUN,
testResult: new TestCaseResultDefault(),
fileHistory: file.file_history
};
testCaseMap[file.content.config_name].push(testCase);
});
}
return testCaseMap;
}
public validateConfig(
config: ConfigWrapper<ConfigData>
): Observable<EditorResult<ExceptionInfo>> {
const json = JSON.stringify(
this.configWrapperService.unwrapConfig(cloneDeep(config.configData)),
null,
2
);
return this.http.post<EditorResult<ExceptionInfo>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configs/validate?singleConfig=true`,
json
);
}
public validateRelease(
deployment: Deployment<ConfigWrapper<ConfigData>>
): Observable<EditorResult<ExceptionInfo>> {
const validationFormat = this.configWrapperService.marshalDeploymentFormat(
deployment
);
const json = JSON.stringify(validationFormat, null, 2);
return this.http.post<EditorResult<ExceptionInfo>>(
`${this.config.serviceRoot}api/v1/${this.serviceName}/configs/validate`,
json
);
}
public submitRelease(
deployment: Deployment<ConfigWrapper<ConfigData>>
): Observable<EditorResult<ExceptionInfo>> {
const releaseFormat = this.configWrapperService.marshalDeploymentFormat(
deployment
);
const json = JSON.stringify(releaseFormat, null, 2);
return this.http.post<EditorResult<ExceptionInfo>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configstore/release`,
json
);
}
public submitConfigEdit(
config: ConfigWrapper<ConfigData>
): Observable<EditorResult<GitFiles<ConfigData>>> {
const json = JSON.stringify(
this.configWrapperService.unwrapConfig(cloneDeep(config.configData)),
null,
2
);
return this.http.put<EditorResult<GitFiles<ConfigData>>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configstore/configs`,
json
);
}
public submitNewConfig(
config: ConfigWrapper<ConfigData>
): Observable<EditorResult<GitFiles<ConfigData>>> {
const json = JSON.stringify(
this.configWrapperService.unwrapConfig(cloneDeep(config.configData)),
null,
2
);
return this.http.post<EditorResult<GitFiles<ConfigData>>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configstore/configs`,
json
);
}
public getFields(): Observable<Field[]> {
return this.http
.get<EditorResult<any>>(
`${this.config.serviceRoot}api/v1/${this.serviceName}/configs/fields`
)
.pipe(map(f => f.attributes.fields));
}
public testDeploymentConfig(
testDto: ConfigTestDto
): Observable<EditorResult<ConfigTestResult>> {
testDto.files[0].content = this.configWrapperService.marshalDeploymentFormat(
testDto.files[0].content
);
return this.http.post<EditorResult<any>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configs/test?singleConfig=false`,
testDto
);
}
public testSingleConfig(
testDto: ConfigTestDto
): Observable<EditorResult<ConfigTestResult>> {
testDto.files[0].content = this.configWrapperService.unwrapConfig(
cloneDeep(testDto.files[0].content)
);
return this.http.post<EditorResult<any>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configs/test?singleConfig=true`,
testDto
);
}
public submitTestCaseEdit(
testCase: TestCaseWrapper
): Observable<TestCaseMap> {
const json = JSON.stringify(cloneDeep(testCase.testCase), null, 2);
return this.http
.put<EditorResult<GitFiles<TestCase>>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configstore/testcases`,
json
)
.pipe(map(result => this.testCaseFilesToMap(result)));
}
public submitNewTestCase(testCase: TestCaseWrapper): Observable<TestCaseMap> {
const json = JSON.stringify(cloneDeep(testCase.testCase), null, 2);
return this.http
.post<EditorResult<GitFiles<TestCase>>>(
`${this.config.serviceRoot}api/v1/${
this.serviceName
}/configstore/testcases`,
json
)
.pipe(map(result => this.testCaseFilesToMap(result)));
}
}

View File

@@ -0,0 +1,212 @@
import { UiMetadataMap } from '@model/ui-metadata-map';
import { cloneDeep } from 'lodash';
import { ConfigData, Deployment, ConfigWrapper } from '@app/model';
export class ConfigWrapperService {
modelOrder = {};
unionPath: string;
optionalObjects: string[] = [];
selectorName: string;
constructor(private uiMetadata: UiMetadataMap) {
this.unionPath = uiMetadata.unionType.unionPath;
this.selectorName = uiMetadata.unionType.unionSelectorName;
}
// 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;
}
public 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);
if (thingy.default) {
delete thingy.default;
}
const sub = {...thingy};
thingy.type = 'array';
delete thingy.required;
delete thingy.properties;
delete thingy.title;
delete thingy.description;
// tabs is not compatible with the array type so delete it if it is at the parent level but keep it on the sub level
if (sub['x-schema-form'] !== undefined && sub['x-schema-form']['type'] !== 'tabs') {
delete sub['x-schema-form'];
}
if (thingy['x-schema-form'] !== undefined && thingy['x-schema-form']['type'] === 'tabs' && thingy['type'] === 'array') {
delete thingy['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.hasOwnProperty('oneOf')) {
this.wrapSchemaUnion(obj.items.oneOf);
this.unionPath = path + propKey;
}
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);
}
}
}
private wrapUnionConfig(obj, oneOfPath: string) {
const path = oneOfPath.split("/").filter(f => f !== '');
let sub = obj;
for (const part of path) {
sub = sub[part];
}
for (let i=0; i<sub.length; i++) {
let temp = sub[i];
sub[i] = {[sub[i][this.selectorName]]: temp}
}
}
public wrapOptionalsInArray(obj: object) {
for (const optional of this.optionalObjects) {
this.findAndWrap(obj, optional);
}
return obj;
}
public wrapConfig(obj: object): object {
let config = this.wrapOptionalsInArray(obj);
if (this.unionPath) {
this.wrapUnionConfig(config, this.unionPath);
}
return config;
}
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 unwrapConfigFromUnion(obj, oneOfPath: string): any {
const path = oneOfPath.split("/").filter(f => f !== '');
let sub = obj;
for (const part of path) {
sub = sub[part];
}
for (let i = 0; i < sub.length; i++) {
let keys = Object.keys(sub[i]);
let temp = sub[i][keys[0]];
sub[i][keys[0]] = undefined;
sub[i] = {...temp};
}
return obj;
}
private wrapSchemaUnion(obj: any, propKey?: string, path?: string): any {
for (let i=0; i<obj.length; i++) {
let temp = obj[i].properties;
let required = obj[i].required;
obj[i].properties = {[obj[i].title]: {type: 'object', properties: temp, required: required}}
obj[i].required = [obj[i].title];
}
}
public unwrapConfig(obj: object): object {
if (this.unionPath) {
obj = this.unwrapConfigFromUnion(obj, this.unionPath);
}
return this.unwrapOptionalsFromArrays(obj);
}
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 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

@@ -2,14 +2,15 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AppConfigService } from './config';
import { AppConfigService } from '../config';
import { StripSuffixPipe } from '../pipes';
import { ConfigLoaderService } from './config-loader.service';
import { ConfigWrapperService } from './config-wrapper-service';
import { ConfigData, ConfigWrapper, Deployment, GitFiles, PullRequestInfo, RepositoryLinks, SchemaDto, SensorFields,
SensorFieldTemplate, UserName } from './model';
import { ConfigTestDto, DeploymentWrapper, EditorResult, ExceptionInfo, SchemaInfo, TestCaseEvaluation } from './model/config-model';
import { Field } from './model/sensor-fields';
import { TestCase, TestCaseMap, TestCaseResult, TestCaseWrapper } from './model/test-case';
import { StripSuffixPipe } from './pipes';
SensorFieldTemplate, UserName, RepositoryLinksWrapper } from '@model';
import { ConfigTestDto, DeploymentWrapper, EditorResult, ExceptionInfo, SchemaInfo, TestCaseEvaluation } from '@model/config-model';
import { TestCase, TestCaseMap, TestCaseResult, TestCaseWrapper } from '@model/test-case';
import { Field } from '@model/sensor-fields';
export interface IConfigLoaderService {
originalSchema;
@@ -18,7 +19,6 @@ export interface IConfigLoaderService {
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>>>;
@@ -31,8 +31,6 @@ export interface IConfigLoaderService {
getTestCases(): Observable<TestCaseMap>;
submitTestCaseEdit(testCase: TestCaseWrapper): Observable<TestCaseMap>;
submitNewTestCase(testCase: TestCaseWrapper): Observable<TestCaseMap>;
produceOrderedJson(configData: ConfigData, path: string);
unwrapOptionalsFromArrays(obj: any);
};
export function replacer(key, value) {
@@ -45,6 +43,8 @@ export function replacer(key, value) {
export class EditorService {
loaderServices: Map<string, IConfigLoaderService> = new Map();
public configLoader: ConfigLoaderService;
configWrapper: ConfigWrapperService;
constructor(
private http: HttpClient,
@@ -79,10 +79,19 @@ export class EditorService {
)
}
public createLoaders() {
this.config.getServiceList().forEach(element => {
this.loaderServices.set(element, new ConfigLoaderService(this.http, this.config, element));
});
public getRepositoryLinks(serviceName): Observable<RepositoryLinks> {
return this.http.get<EditorResult<RepositoryLinksWrapper>>(
`${this.config.serviceRoot}api/v1/${serviceName}/configstore/repositories`).pipe(
map(result => ({
...result.attributes.rules_repositories,
rulesetName: serviceName,
}))
)
}
public createLoader(serviceName: string) {
this.configWrapper = new ConfigWrapperService(this.config.getUiMetadata(serviceName));
this.configLoader = new ConfigLoaderService(this.http, this.config, serviceName, this.configWrapper);
}
public getTestCaseSchema(): Observable<any> {
@@ -119,12 +128,4 @@ export class EditorService {
map(x => x.attributes)
)
}
public getLoader(serviceName: string): IConfigLoaderService {
try {
return this.loaderServices.get(serviceName);
} catch {
throw new DOMException('Invalid service name - can\'t do nothing');
}
}
}

View File

@@ -61,6 +61,14 @@ export const SUBMIT_NEW_TESTCASE_FAILURE = '[Testcase] Submit New Testcase Failu
export const UPDATE_TEST_CASE_STATE = '[Testcase] Update Testcase State';
export const UPDATE_ALL_TEST_CASE_STATE = '[Testcase] Update All Testcase State';
export const SET_MODEL_ORDER = '[Schema] Set Model Order';
export class SetModelOrder implements Action {
readonly type = SET_MODEL_ORDER;
constructor(public payload: object) { }
}
export class SetServiceNames implements Action {
readonly type = SET_SERVICE_NAMES;
constructor(public payload: string[]) { }
@@ -180,7 +188,7 @@ export class SubmitNewConfig implements Action {
export class SubmitNewConfigSuccess implements Action {
readonly type = SUBMIT_NEW_CONFIG_SUCCESS;
constructor(public payload: ConfigWrapper<ConfigData>) { }
constructor(public payload: ConfigWrapper<ConfigData>[]) { }
}
export class SubmitNewConfigFailure implements Action {
@@ -195,7 +203,7 @@ export class SubmitConfigEdit implements Action {
export class SubmitConfigEditSuccess implements Action {
readonly type = SUBMIT_CONFIG_EDIT_SUCCESS;
constructor(public payload: EditorResult<ConfigData>) { }
constructor(public payload: ConfigWrapper<ConfigData>[]) { }
}
export class SubmitConfigEditFailure implements Action {
@@ -364,4 +372,5 @@ export type Actions
| LoadTestCasesSuccess
| LoadTestCasesFailure
| UpdateAllTestCaseState
| UpdateTestCaseState;
| UpdateTestCaseState
| SetModelOrder;

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@angular/core';
import { EditorService } from '@app/editor.service';
import { EditorService } from '@services/editor.service';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Action, Store } from '@ngrx/store';
import { PopupService } from 'app/popup.service';
@@ -26,21 +26,23 @@ export class EditorEffects {
ofType<actions.Bootstrap>(actions.BOOTSTRAP),
withLatestFrom(this.store.select(fromStore.getBootstrapped)),
filter(([action, bootStrapped]) => bootStrapped !== action.payload),
exhaustMap(([action, isBootStrapped]) => forkJoin([
this.editorService.getLoader(action.payload).getSchema().pipe(
tap(([action, _]) => this.editorService.createLoader(action.payload)),
exhaustMap((_) => forkJoin([
this.editorService.configLoader.getSchema().pipe(
switchMap(schema => forkJoin([
of(schema),
this.editorService.getLoader(action.payload).getConfigs(),
this.editorService.getLoader(action.payload).getRelease().pipe(
this.editorService.configLoader.getConfigs(),
this.editorService.configLoader.getRelease().pipe(
map(result => [result.deploymentHistory, result.storedDeployment])
),
]))
),
this.editorService.getUser(),
this.editorService.getLoader(action.payload).getPullRequestStatus(),
this.editorService.configLoader.getPullRequestStatus(),
this.editorService.getSensorFields(),
this.editorService.getTestCaseSchema(),
this.editorService.getLoader(action.payload).getTestSpecificationSchema(),
this.editorService.configLoader.getTestSpecificationSchema(),
]).pipe(
map(([[configSchema, configs, [deploymentHistory, storedDeployment]],
currentUser, pullRequestPending, sensorFields, testCaseSchema, testSpecificationSchema]: any) => {
@@ -61,9 +63,9 @@ export class EditorEffects {
loadRepositories$: Observable<Action> = this.actions$.pipe(
ofType<actions.LoadRepositories>(actions.LOAD_REPOSITORIES),
withLatestFrom(this.store.select(fromStore.getServiceNames)),
exhaustMap(([action, serviceNames]) => {
exhaustMap(([_, serviceNames]) => {
return forkJoin(serviceNames.
map(serviceName => this.editorService.getLoader(serviceName).getRepositoryLinks())
map(serviceName => this.editorService.getRepositoryLinks(serviceName))
).pipe(
map((result: RepositoryLinks[]) => new actions.LoadRepositoriesSuccess(result)),
catchError(error => this.errorHandler(
@@ -76,9 +78,8 @@ export class EditorEffects {
@Effect()
loadPullRequestStatus$ = this.actions$.pipe(
ofType<actions.LoadPullRequestStatus>(actions.LOAD_PULL_REQUEST_STATUS),
withLatestFrom(this.store.select(fromStore.getServiceName)),
switchMap(([action, serviceName]) =>
this.editorService.getLoader(serviceName).getPullRequestStatus().pipe(
switchMap(() =>
this.editorService.configLoader.getPullRequestStatus().pipe(
map((result) => new actions.LoadPullRequestStatusSuccess(result)),
catchError(error => this.errorHandler(
error, this.RELEASE_STATUS_FAILED_MESSAGE, of(new actions.LoadPullRequestStatusFailure(error)))
@@ -90,9 +91,8 @@ export class EditorEffects {
@Effect()
loadTestCases$ = this.actions$.pipe(
ofType<actions.LoadTestCases>(actions.LOAD_TEST_CASES),
withLatestFrom(this.store.select(fromStore.getServiceName)),
switchMap(([action, serviceName]) =>
this.editorService.getLoader(serviceName).getTestCases().pipe(
switchMap(() =>
this.editorService.configLoader.getTestCases().pipe(
map((result) => new actions.LoadTestCasesSuccess(result)),
catchError(error => this.errorHandler(
error, this.RELEASE_STATUS_FAILED_MESSAGE, of(new actions.LoadTestCasesFailure(error))))
@@ -113,9 +113,8 @@ export class EditorEffects {
@Effect()
submitRelease$ = this.actions$.pipe(
ofType<actions.SubmitRelease>(actions.SUBMIT_RELEASE),
withLatestFrom(this.store.select(fromStore.getServiceName)),
switchMap(([action, serviceName]) =>
this.editorService.getLoader(serviceName).submitRelease(action.payload).pipe(
switchMap(action =>
this.editorService.configLoader.submitRelease(action.payload).pipe(
map(result => {
this.displayNotification(this.newSuccessMessage('release'));
this.store.dispatch(new actions.LoadPullRequestStatus());
@@ -131,14 +130,13 @@ export class EditorEffects {
@Effect()
submitNewConfig$ = this.actions$.pipe(
ofType<actions.SubmitNewConfig>(actions.SUBMIT_NEW_CONFIG),
withLatestFrom(this.store.select(fromStore.getServiceName)),
switchMap(([action, serviceName]) =>
this.editorService.getLoader(serviceName).submitNewConfig(action.payload).pipe(
switchMap(action =>
this.editorService.configLoader.submitNewConfig(action.payload).pipe(
map(result => {
this.displayNotification(this.newSuccessMessage('config'));
return new actions.SubmitNewConfigSuccess(
this.editorService.getLoader(serviceName).getConfigsFromFiles(result.attributes.files)
this.editorService.configLoader.getConfigsFromFiles(result.attributes.files)
);
}),
catchError(error => this.errorHandler(
@@ -149,9 +147,8 @@ export class EditorEffects {
@Effect()
validateConfig$ = this.actions$.pipe(
ofType<actions.ValidateConfig>(actions.VALIDATE_CONFIG),
withLatestFrom(this.store.select(fromStore.getServiceName)),
switchMap(([action, serviceName]) =>
this.editorService.getLoader(serviceName).validateConfig(action.payload).pipe(
switchMap(action =>
this.editorService.configLoader.validateConfig(action.payload).pipe(
map(result => new actions.ValidateConfigsSuccess(result)),
catchError(error => this.errorHandler(
error, this.VALIDATION_FAILED_MESSAGE, of(new actions.ValidateConfigsFailure(error))))
@@ -162,9 +159,8 @@ export class EditorEffects {
@Effect()
validateConfigs$ = this.actions$.pipe(
ofType<actions.ValidateConfigs>(actions.VALIDATE_CONFIGS),
withLatestFrom(this.store.select(fromStore.getStoredDeployment), this.store.select(fromStore.getServiceName)),
switchMap(([action, deployment, serviceName]) =>
this.editorService.getLoader(serviceName).validateRelease(action.payload).pipe(
switchMap(action =>
this.editorService.configLoader.validateRelease(action.payload).pipe(
map(result => new actions.ValidateConfigsSuccess(result)),
catchError(error => this.errorHandler(
error, this.validationFaliedMessage('config'), of(new actions.ValidateConfigsFailure(error)))))
@@ -185,14 +181,13 @@ export class EditorEffects {
@Effect()
submitRuleEdit$ = this.actions$.pipe(
ofType<actions.SubmitConfigEdit>(actions.SUBMIT_CONFIG_EDIT),
withLatestFrom(this.store.select(fromStore.getServiceName)),
switchMap(([action, serviceName]) =>
this.editorService.getLoader(serviceName).submitConfigEdit(action.payload).pipe(
tap(action =>
this.editorService.configLoader.submitConfigEdit(action.payload).pipe(
map(result => {
this.displayNotification(this.editSuccessMessage('config'));
return new actions.SubmitConfigEditSuccess(
this.editorService.getLoader(serviceName).getConfigsFromFiles(result.attributes.files)
this.editorService.configLoader.getConfigsFromFiles(result.attributes.files)
)
}),
catchError(error => this.errorHandler(
@@ -203,9 +198,8 @@ export class EditorEffects {
@Effect()
submitNewTestCase$ = this.actions$.pipe(
ofType<actions.SubmitNewTestCase>(actions.SUBMIT_NEW_TESTCASE),
withLatestFrom(this.store.select(fromStore.getServiceName)),
switchMap(([action, serviceName]) =>
this.editorService.getLoader(serviceName).submitNewTestCase(action.payload).pipe(
tap(action =>
this.editorService.configLoader.submitNewTestCase(action.payload).pipe(
map(m => new actions.SubmitNewTestCaseSuccess(m)),
tap(_ => this.displayNotification(this.newSuccessMessage('testcase'))),
catchError(error =>
@@ -220,9 +214,8 @@ export class EditorEffects {
@Effect()
submitTestCaseEdit$ = this.actions$.pipe(
ofType<actions.SubmitTestCaseEdit>(actions.SUBMIT_TESTCASE_EDIT),
withLatestFrom(this.store.select(fromStore.getServiceName)),
switchMap(([action, serviceName]) =>
this.editorService.getLoader(serviceName).submitTestCaseEdit(action.payload).pipe(
tap(action =>
this.editorService.configLoader.submitTestCaseEdit(action.payload).pipe(
map(m => new actions.SubmitTestCaseEditSuccess(m)),
tap(_ => this.displayNotification(this.editSuccessMessage('testcase'))),
catchError(error => this.errorHandler(

View File

@@ -2,9 +2,9 @@ import { StatusCode } from '@app/commons';
import {
ConfigData, ConfigWrapper, Deployment, EditorResult, ExceptionInfo,
FileHistory, PullRequestInfo, RepositoryLinks, SchemaDto, SensorFields, SubmitStatus,
} from '@app/model';
} from '@model';
import { cloneDeep } from 'lodash';
import { TestCaseMap } from '../model/test-case';
import { TestCaseMap } from '@model/test-case';
import * as editor from './editor.actions';
export interface State {
@@ -39,6 +39,7 @@ export interface State {
testCaseSchema: any;
testCaseMap: TestCaseMap;
testSpecificationSchema: any;
modelOrder: object;
}
export const initialState: State = {
@@ -73,6 +74,7 @@ export const initialState: State = {
testCaseSchema: {},
testCaseMap: undefined,
testSpecificationSchema: undefined,
modelOrder: {},
}
export function reducer(state = initialState, action: editor.Actions): State {
@@ -313,6 +315,11 @@ export function reducer(state = initialState, action: editor.Actions): State {
testCaseMap: testCaseMap2,
});
case editor.SET_MODEL_ORDER:
return Object.assign({}, state, {
modelOrder: action.payload,
});
default:
return state;
}

View File

@@ -8,6 +8,7 @@
},
"exclude": [
"test.ts",
"**/*.spec.ts"
"**/*.spec.ts",
"src/testing/*",
]
}

View File

@@ -23,7 +23,10 @@
],
"paths": {
"@app/*": ["app/*"],
"@env/*": ["environments/*"]
"@env/*": ["environments/*"],
"@model/*": ["app/model/*"],
"@model": ["app/model/index"],
"@services/*": ["app/services/*"],
}
}
}