diff --git a/package-lock.json b/package-lock.json index 9a2cdc386d..e410612e86 100644 --- a/package-lock.json +++ b/package-lock.json @@ -318,6 +318,14 @@ "tslib": "1.9.2" } }, + "@angular/flex-layout": { + "version": "6.0.0-beta.18", + "resolved": "https://registry.npmjs.org/@angular/flex-layout/-/flex-layout-6.0.0-beta.18.tgz", + "integrity": "sha512-1Alv3YSIZYp0CTUIESIaSQLoSVyLzuNKPa5bGM/RzOmeSrndm5plVgI9wopGfJUDiwM18R97rq/4XjDvNT/+ig==", + "requires": { + "tslib": "1.9.2" + } + }, "@angular/forms": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-6.1.1.tgz", @@ -11148,6 +11156,14 @@ "integrity": "sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=", "dev": true }, + "stratos-angular6-json-schema-form": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stratos-angular6-json-schema-form/-/stratos-angular6-json-schema-form-1.0.3.tgz", + "integrity": "sha512-4CZm6hWqIlJQ7T4Bg3SM4YB4F+6KsswmC5Bm6yNMF6OfNPRo7sQuWw5wuAQMw6X3BRUJnxTkJGnG9vH2BpbPPw==", + "requires": { + "tslib": "1.9.2" + } + }, "stratos-merge-dirs": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/stratos-merge-dirs/-/stratos-merge-dirs-0.2.3.tgz", diff --git a/package.json b/package.json index be72c00665..c710e57c4d 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@angular/compiler": "^6.1.1", "@angular/core": "^6.1.1", "@angular/forms": "^6.1.1", + "@angular/flex-layout": "^6.0.0-beta.16", "@angular/http": "^6.1.1", "@angular/material": "^6.1.0", "@angular/material-moment-adapter": "^6.4.7", @@ -59,6 +60,7 @@ "@ngrx/store-devtools": "^6.0.1", "@swimlane/ngx-charts": "^9.0.0", "angular2-virtual-scroll": "^0.3.1", + "stratos-angular6-json-schema-form": "1.0.3", "core-js": "^2.5.7", "hammerjs": "^2.0.8", "js-yaml": "^3.11.0", diff --git a/src/frontend/app/core/cf-api-svc.types.ts b/src/frontend/app/core/cf-api-svc.types.ts index 4873567204..d069919477 100644 --- a/src/frontend/app/core/cf-api-svc.types.ts +++ b/src/frontend/app/core/cf-api-svc.types.ts @@ -67,6 +67,21 @@ export interface IServicePlan { service?: APIResource; guid?: string; cfGuid?: string; + schemas?: ServicePlanSchemas; +} + +export interface ServicePlanSchemas { + service_instance: ServicePlanSchema; + service_binding: ServicePlanSchema; +} + +export interface ServicePlanSchema { + create?: { + parameters: object + }; + update?: { + parameters: object + }; } export interface IServicePlanExtra { @@ -81,6 +96,7 @@ export interface IServicePlanCost { }; unit: string; } + export interface IService { label: string; description: string; diff --git a/src/frontend/app/core/utils.service.ts b/src/frontend/app/core/utils.service.ts index ef9cad501c..7cec338b02 100644 --- a/src/frontend/app/core/utils.service.ts +++ b/src/frontend/app/core/utils.service.ts @@ -246,6 +246,22 @@ export function parseHttpPipeError(res): {} { return {}; } +export function safeStringToObj(value: string): object { + try { + if (value) { + const jsonObj = JSON.parse(value); + // Check if jsonObj is actually an obj + if (jsonObj.constructor !== {}.constructor) { + throw new Error('not an object'); + } + return jsonObj; + } + } catch (e) { + return null; + } + return null; +} + export const safeUnsubscribe = (...subs: Subscription[]) => { subs.forEach(sub => { if (sub) { diff --git a/src/frontend/app/features/service-catalog/services-helper.ts b/src/frontend/app/features/service-catalog/services-helper.ts index e2361ee8a6..39ac33fe00 100644 --- a/src/frontend/app/features/service-catalog/services-helper.ts +++ b/src/frontend/app/features/service-catalog/services-helper.ts @@ -20,7 +20,6 @@ import { getPaginationObservables } from '../../store/reducers/pagination-reduce import { APIResource } from '../../store/types/api.types'; import { getIdFromRoute } from '../cloud-foundry/cf.helpers'; - export const getSvcAvailability = (servicePlan: APIResource, serviceBroker: APIResource, allServicePlanVisibilities: APIResource[]) => { @@ -51,7 +50,6 @@ export const getServiceJsonParams = (params: any): {} => { return prms; }; - export const isMarketplaceMode = (activatedRoute: ActivatedRoute) => { const serviceId = getIdFromRoute(activatedRoute, 'serviceId'); const cfId = getIdFromRoute(activatedRoute, 'endpointId'); @@ -108,3 +106,5 @@ export const getServicePlans = ( } })); }; + + diff --git a/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.html b/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.html index 1ef88c153b..dbefdec04a 100644 --- a/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.html +++ b/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.html @@ -12,10 +12,10 @@ - + - + diff --git a/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.spec.ts b/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.spec.ts index 1d811bc835..b17ff3a80e 100644 --- a/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.spec.ts +++ b/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.spec.ts @@ -1,4 +1,5 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MaterialDesignFrameworkModule } from 'stratos-angular6-json-schema-form'; import { ServicesService } from '../../../../features/service-catalog/services.service'; import { ServicesServiceMock } from '../../../../features/service-catalog/services.service.mock'; @@ -28,6 +29,7 @@ import { CfServiceCardComponent } from '../../list/list-types/cf-services/cf-ser import { MetadataItemComponent } from '../../metadata-item/metadata-item.component'; import { MultilineTitleComponent } from '../../multiline-title/multiline-title.component'; import { PageHeaderModule } from '../../page-header/page-header.module'; +import { SchemaFormComponent } from '../../schema-form/schema-form.component'; import { ServiceIconComponent } from '../../service-icon/service-icon.component'; import { SteppersModule } from '../../stepper/steppers.module'; import { BindAppsStepComponent } from '../bind-apps-step/bind-apps-step.component'; @@ -63,12 +65,14 @@ describe('AddServiceInstanceComponent', () => { AppChipsComponent, ApplicationStateIconComponent, ApplicationStateIconPipe, + SchemaFormComponent, MultilineTitleComponent, FocusDirective ], imports: [ PageHeaderModule, SteppersModule, + MaterialDesignFrameworkModule, // CoreModule, BaseTestModulesNoShared ], diff --git a/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.ts b/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.ts index b63b91a6fd..afc2559866 100644 --- a/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.ts +++ b/src/frontend/app/shared/components/add-service-instance/add-service-instance/add-service-instance.component.ts @@ -69,6 +69,7 @@ export class AddServiceInstanceComponent implements OnDestroy, AfterContentInit bindAppStepperText = 'Bind App (Optional)'; appId: string; public inMarketplaceMode: boolean; + constructor( private cSIHelperServiceFactory: CreateServiceInstanceHelperServiceFactory, private activatedRoute: ActivatedRoute, @@ -130,13 +131,6 @@ export class AddServiceInstanceComponent implements OnDestroy, AfterContentInit } } - private getIdsFromRoute() { - const serviceId = getIdFromRoute(this.activatedRoute, 'serviceId'); - const cfId = getIdFromRoute(this.activatedRoute, 'endpointId'); - const appId = getIdFromRoute(this.activatedRoute, 'id'); - return { serviceId, cfId, appId }; - } - private setupForAppServiceMode() { const appId = getIdFromRoute(this.activatedRoute, 'id'); diff --git a/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.html b/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.html index 57e6e33efa..9048e5a7d3 100644 --- a/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.html +++ b/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.html @@ -7,11 +7,9 @@ {{ app.entity.name }} - - - - Not valid JSON. Please specify a valid JSON Object - - +
+
Binding Parameters
+ +
diff --git a/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.scss b/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.scss index 5f81e6c60e..b040dce3a3 100644 --- a/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.scss +++ b/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.scss @@ -1,3 +1,7 @@ :host { flex: 1; } + +.stepper-form__params { + padding-top: 20px; +} diff --git a/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.spec.ts b/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.spec.ts index c4cc82eedc..eb0cfffcfe 100644 --- a/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.spec.ts +++ b/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.spec.ts @@ -1,11 +1,13 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MaterialDesignFrameworkModule } from 'stratos-angular6-json-schema-form'; -import { BindAppsStepComponent } from './bind-apps-step.component'; -import { BaseTestModules, BaseTestModulesNoShared } from '../../../../test-framework/cloud-foundry-endpoint-service.helper'; import { ServicesService } from '../../../../features/service-catalog/services.service'; import { ServicesServiceMock } from '../../../../features/service-catalog/services.service.mock'; -import { CsiGuidsService } from '../csi-guids.service'; +import { BaseTestModulesNoShared } from '../../../../test-framework/cloud-foundry-endpoint-service.helper'; import { PaginationMonitorFactory } from '../../../monitors/pagination-monitor.factory'; +import { SchemaFormComponent } from '../../schema-form/schema-form.component'; +import { CsiGuidsService } from '../csi-guids.service'; +import { BindAppsStepComponent } from './bind-apps-step.component'; describe('BindAppsStepComponent', () => { let component: BindAppsStepComponent; @@ -13,8 +15,14 @@ describe('BindAppsStepComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [BindAppsStepComponent], - imports: [BaseTestModulesNoShared], + declarations: [ + BindAppsStepComponent, + SchemaFormComponent + ], + imports: [ + BaseTestModulesNoShared, + MaterialDesignFrameworkModule + ], providers: [ { provide: ServicesService, useClass: ServicesServiceMock }, CsiGuidsService, diff --git a/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.ts b/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.ts index ff23e3e1b6..8aa49e24d5 100644 --- a/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.ts +++ b/src/frontend/app/shared/components/add-service-instance/bind-apps-step/bind-apps-step.component.ts @@ -2,10 +2,11 @@ import { AfterContentInit, Component, Input, OnDestroy } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { Store } from '@ngrx/store'; import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; -import { filter, first, map, switchMap, tap } from 'rxjs/operators'; +import { filter, switchMap } from 'rxjs/operators'; +import { IServicePlan } from '../../../../core/cf-api-svc.types'; import { IApp } from '../../../../core/cf-api.types'; -import { appDataSort } from '../../../../features/cloud-foundry/services/cloud-foundry-endpoint.service'; +import { pathGet, safeUnsubscribe } from '../../../../core/utils.service'; import { SetCreateServiceInstanceApp } from '../../../../store/actions/create-service-instance.actions'; import { GetAllAppsInSpace } from '../../../../store/actions/space.actions'; import { AppState } from '../../../../store/app-state'; @@ -15,9 +16,8 @@ import { getPaginationObservables } from '../../../../store/reducers/pagination- import { selectCreateServiceInstance } from '../../../../store/selectors/create-service-instance.selectors'; import { APIResource } from '../../../../store/types/api.types'; import { PaginationMonitorFactory } from '../../../monitors/pagination-monitor.factory'; +import { SchemaFormConfig } from '../../schema-form/schema-form.component'; import { StepOnNextResult } from '../../stepper/step/step.component'; -import { CsiGuidsService } from '../csi-guids.service'; -import { SpecifyDetailsStepComponent } from '../specify-details-step/specify-details-step.component'; @Component({ selector: 'app-bind-apps-step', @@ -30,18 +30,21 @@ export class BindAppsStepComponent implements OnDestroy, AfterContentInit { boundAppId: string; validateSubscription: Subscription; - validate = new BehaviorSubject(true); + validate = new BehaviorSubject(true); serviceInstanceGuid: string; stepperForm: FormGroup; apps$: Observable[]>; guideText = 'Specify the application to bind (Optional)'; + selectedServicePlan: APIResource; + bindingParams: object = {}; + schemaFormConfig: SchemaFormConfig; + constructor( private store: Store, private paginationMonitorFactory: PaginationMonitorFactory ) { this.stepperForm = new FormGroup({ apps: new FormControl(''), - params: new FormControl('', SpecifyDetailsStepComponent.isValidJsonValidatorFn()), }); } @@ -54,16 +57,6 @@ export class BindAppsStepComponent implements OnDestroy, AfterContentInit { } ngAfterContentInit() { - this.validateSubscription = this.stepperForm.statusChanges.pipe( - map(() => { - if (this.stepperForm.pristine) { - setTimeout(() => this.validate.next(true)); - } - setTimeout(() => this.validate.next(this.stepperForm.valid)); - }) - ).subscribe(); - - this.apps$ = this.store.select(selectCreateServiceInstance).pipe( filter(p => !!p && !!p.spaceGuid && !!p.cfGuid), switchMap(createServiceInstance => { @@ -80,17 +73,53 @@ export class BindAppsStepComponent implements OnDestroy, AfterContentInit { this.setBoundApp(); } - submit = (): Observable => { - this.setApp(); - return observableOf({ success: true }); + onEnter = (selectedServicePlan: APIResource) => { + if (selectedServicePlan) { + // Don't overwrite if it's null (we've returned to this step from the next) + this.selectedServicePlan = selectedServicePlan; + } + + // Start + this.validateSubscription = this.stepperForm.controls['apps'].valueChanges.subscribe(app => { + if (!app) { + // If there's no app selected the step will always be valid + this.validate.next(true); + } + }); + + if (!this.schemaFormConfig) { + // Create new config + this.schemaFormConfig = { + schema: pathGet('entity.schemas.service_binding.create.parameters', this.selectedServicePlan), + }; + } else { + // Update existing config (retaining any existing config) + this.schemaFormConfig = { + ...this.schemaFormConfig, + schema: pathGet('entity.schemas.service_binding.create.parameters', this.selectedServicePlan) + }; + } + + } + + setBindingParams(data) { + this.bindingParams = data; + } + + setParamValid(valid: boolean) { + this.validate.next(valid); } - setApp = () => this.store.dispatch( - new SetCreateServiceInstanceApp(this.stepperForm.controls.apps.value, this.stepperForm.controls.params.value) - ) + submit = (): Observable => { + this.store.dispatch(new SetCreateServiceInstanceApp(this.stepperForm.controls.apps.value, this.bindingParams)); + return observableOf({ + success: true, + data: this.selectedServicePlan + }); + } ngOnDestroy(): void { - this.validateSubscription.unsubscribe(); + safeUnsubscribe(this.validateSubscription); } } diff --git a/src/frontend/app/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts b/src/frontend/app/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts index 96e22bff92..415f10f243 100644 --- a/src/frontend/app/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts +++ b/src/frontend/app/shared/components/add-service-instance/select-plan-step/select-plan-step.component.ts @@ -11,13 +11,7 @@ import { import { FormControl, FormGroup, Validators } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { Store } from '@ngrx/store'; -import { - BehaviorSubject, - combineLatest as observableCombineLatest, - Observable, - of as observableOf, - Subscription, -} from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, Subscription } from 'rxjs'; import { distinctUntilChanged, filter, @@ -167,7 +161,12 @@ export class SelectPlanStepComponent implements OnDestroy { onNext = (): Observable => { this.store.dispatch(new SetCreateServiceInstanceServicePlan(this.stepperForm.controls.servicePlans.value)); - return observableOf({ success: true }); + return this.selectedPlan$.pipe( + map(selectedServicePlan => ({ + success: true, + data: selectedServicePlan.entity + })) + ); } ngOnDestroy(): void { diff --git a/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.html b/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.html index 88256b47e9..bb36d177ac 100644 --- a/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.html +++ b/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.html @@ -21,15 +21,13 @@ {{tag.label}} cancel - + - - - - Not valid JSON. Please specify a valid JSON Object - - +
+
Service Parameters
+ +
@@ -37,6 +35,6 @@ {{ sI.entity.name }} -
+ diff --git a/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.scss b/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.scss index 356c6033ab..9a3c28fd32 100644 --- a/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.scss +++ b/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.scss @@ -6,6 +6,7 @@ &__tags { &__chip-list { mat-chip { + &.stepper-form__tags__chip-list__chip, &.stepper-form__tags__chip-list__chip + &.stepper-form__tags__chip-list__chip { $chip-padding: 5px; @@ -14,6 +15,10 @@ } } } + + &__params { + padding-top: 20px; + } } .specify-details { diff --git a/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.spec.ts b/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.spec.ts index a6731a2d45..442f4f8da9 100644 --- a/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.spec.ts +++ b/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.spec.ts @@ -1,12 +1,14 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MaterialDesignFrameworkModule } from 'stratos-angular6-json-schema-form'; -import { BaseTestModules, BaseTestModulesNoShared } from '../../../../test-framework/cloud-foundry-endpoint-service.helper'; +import { BaseTestModulesNoShared } from '../../../../test-framework/cloud-foundry-endpoint-service.helper'; +import { EntityMonitorFactory } from '../../../monitors/entity-monitor.factory.service'; +import { PaginationMonitorFactory } from '../../../monitors/pagination-monitor.factory'; +import { SchemaFormComponent } from '../../schema-form/schema-form.component'; import { CreateServiceInstanceHelperServiceFactory } from '../create-service-instance-helper-service-factory.service'; -import { SpecifyDetailsStepComponent } from './specify-details-step.component'; import { CsiGuidsService } from '../csi-guids.service'; -import { PaginationMonitorFactory } from '../../../monitors/pagination-monitor.factory'; -import { EntityMonitorFactory } from '../../../monitors/entity-monitor.factory.service'; import { CsiModeService } from '../csi-mode.service'; +import { SpecifyDetailsStepComponent } from './specify-details-step.component'; describe('SpecifyDetailsStepComponent', () => { let component: SpecifyDetailsStepComponent; @@ -14,8 +16,14 @@ describe('SpecifyDetailsStepComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [SpecifyDetailsStepComponent], - imports: [BaseTestModulesNoShared], + declarations: [ + SpecifyDetailsStepComponent, + SchemaFormComponent + ], + imports: [ + BaseTestModulesNoShared, + MaterialDesignFrameworkModule + ], providers: [ CreateServiceInstanceHelperServiceFactory, CsiGuidsService, diff --git a/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.ts b/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.ts index 41da4b558e..7e5fb423df 100644 --- a/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.ts +++ b/src/frontend/app/shared/components/add-service-instance/specify-details-step/specify-details-step.component.ts @@ -3,7 +3,13 @@ import { AfterContentInit, Component, Input, OnDestroy } from '@angular/core'; import { AbstractControl, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { MatChipInputEvent } from '@angular/material'; import { Store } from '@ngrx/store'; -import { BehaviorSubject, Observable, of as observableOf, Subscription } from 'rxjs'; +import { + BehaviorSubject, + combineLatest as observableCombineLatest, + Observable, + of as observableOf, + Subscription, +} from 'rxjs'; import { combineLatest, distinctUntilChanged, @@ -16,22 +22,30 @@ import { startWith, switchMap, take, - tap + tap, } from 'rxjs/operators'; -import { IServiceInstance } from '../../../../core/cf-api-svc.types'; -import { getServiceJsonParams } from '../../../../features/service-catalog/services-helper'; + +import { IServiceInstance, IServicePlan } from '../../../../core/cf-api-svc.types'; +import { pathGet, safeStringToObj } from '../../../../core/utils.service'; import { GetAppEnvVarsAction } from '../../../../store/actions/app-metadata.actions'; -import { SetCreateServiceInstanceOrg, SetServiceInstanceGuid } from '../../../../store/actions/create-service-instance.actions'; +import { + SetCreateServiceInstanceOrg, + SetServiceInstanceGuid, +} from '../../../../store/actions/create-service-instance.actions'; import { RouterNav } from '../../../../store/actions/router.actions'; import { CreateServiceBinding } from '../../../../store/actions/service-bindings.actions'; -import { CreateServiceInstance, GetServiceInstance, UpdateServiceInstance } from '../../../../store/actions/service-instances.actions'; +import { + CreateServiceInstance, + GetServiceInstance, + UpdateServiceInstance, +} from '../../../../store/actions/service-instances.actions'; import { AppState } from '../../../../store/app-state'; import { serviceBindingSchemaKey, serviceInstancesSchemaKey } from '../../../../store/helpers/entity-factory'; import { RequestInfoState } from '../../../../store/reducers/api-request-reducer/types'; import { selectRequestInfo, selectUpdateInfo } from '../../../../store/selectors/api.selectors'; import { selectCreateServiceInstance, - selectCreateServiceInstanceSpaceGuid + selectCreateServiceInstanceSpaceGuid, } from '../../../../store/selectors/create-service-instance.selectors'; import { APIResource, NormalizedResponse } from '../../../../store/types/api.types'; import { CreateServiceInstanceState } from '../../../../store/types/create-service-instance.types'; @@ -40,7 +54,7 @@ import { CreateServiceInstanceHelperServiceFactory } from '../create-service-ins import { CreateServiceInstanceHelper } from '../create-service-instance-helper.service'; import { CsiGuidsService } from '../csi-guids.service'; import { CsiModeService } from '../csi-mode.service'; - +import { SchemaFormConfig } from '../../schema-form/schema-form.component'; const enum FormMode { CreateServiceInstance = 'create-service-instance', @@ -92,24 +106,11 @@ export class SpecifyDetailsStepComponent implements OnDestroy, AfterContentInit spaceScopeSub: Subscription; bindExistingInstance = false; subscriptions: Subscription[] = []; + serviceParamsValid = new BehaviorSubject(false); + serviceParams: object = null; + schemaFormConfig: SchemaFormConfig; - static isValidJsonValidatorFn = (): ValidatorFn => { - return (formField: AbstractControl): { [key: string]: any } => { - try { - if (formField.value) { - const jsonObj = JSON.parse(formField.value); - // Check if jsonObj is actually an obj - if (jsonObj.constructor !== {}.constructor) { - throw new Error('not an object'); - } - } - } catch (e) { - return { 'notValidJson': { value: formField.value } }; - } - return null; - }; - } nameTakenValidator = (): ValidatorFn => { return (formField: AbstractControl): { [key: string]: any } => !this.checkName(formField.value) ? { 'nameTaken': { value: formField.value } } : null; @@ -179,7 +180,24 @@ export class SpecifyDetailsStepComponent implements OnDestroy, AfterContentInit ); } - onEnter = () => { + onEnter = (selectedServicePlan: APIResource) => { + const schema = this.modeService.isEditServiceInstanceMode() ? + pathGet('entity.schemas.service_instance.update.parameters', selectedServicePlan) : + pathGet('entity.schemas.service_instance.create.parameters', selectedServicePlan); + + if (!this.schemaFormConfig) { + // Create new config + this.schemaFormConfig = { + schema + }; + } else { + // Update existing config (retaining any existing config) + this.schemaFormConfig = { + ...this.schemaFormConfig, + schema + }; + } + this.formMode = FormMode.CreateServiceInstance; this.allServiceInstances$ = this.cSIHelperService.getServiceInstancesForService(null, null, this.csiGuidsService.cfGuid); if (this.modeService.isEditServiceInstanceMode()) { @@ -187,7 +205,9 @@ export class SpecifyDetailsStepComponent implements OnDestroy, AfterContentInit take(1), tap(state => { this.createNewInstanceForm.controls.name.setValue(state.name); - this.createNewInstanceForm.controls.params.setValue(state.parameters); + + this.schemaFormConfig.initialData = safeStringToObj(state.parameters); + this.serviceInstanceGuid = state.serviceInstanceGuid; this.serviceInstanceName = state.name; this.createNewInstanceForm.updateValueAndValidity(); @@ -200,6 +220,14 @@ export class SpecifyDetailsStepComponent implements OnDestroy, AfterContentInit this.subscriptions.push(this.setupFormValidatorData()); } + setServiceParams(data) { + this.serviceParams = data; + } + + setParamsValid(valid: boolean) { + this.serviceParamsValid.next(valid); + } + resetForms = (mode: FormMode) => { this.validate.next(false); this.createNewInstanceForm.reset(); @@ -235,7 +263,6 @@ export class SpecifyDetailsStepComponent implements OnDestroy, AfterContentInit private setupForms() { this.createNewInstanceForm = new FormGroup({ name: new FormControl('', [Validators.required, this.nameTakenValidator()]), - params: new FormControl('', SpecifyDetailsStepComponent.isValidJsonValidatorFn()), tags: new FormControl(''), }); this.selectExistingInstanceForm = new FormGroup({ @@ -245,7 +272,7 @@ export class SpecifyDetailsStepComponent implements OnDestroy, AfterContentInit setOrg = (guid) => this.store.dispatch(new SetCreateServiceInstanceOrg(guid)); - ngOnDestroy(): void { + ngOnDestroy() { this.subscriptions.forEach(s => s.unsubscribe()); } @@ -330,10 +357,19 @@ export class SpecifyDetailsStepComponent implements OnDestroy, AfterContentInit this.bindExistingInstance ? this.selectExistingInstanceForm.controls.serviceInstances.value : request.response.result[0] private setupValidate() { - this.subscriptions.push(this.createNewInstanceForm.statusChanges.pipe( - map(() => this.validate.next(this.createNewInstanceForm.valid))).subscribe()); + // For a new service instance the step is valid if the form and service params are both valid + this.subscriptions.push( + observableCombineLatest([ + this.serviceParamsValid.asObservable(), + this.createNewInstanceForm.statusChanges + ]).pipe( + map(([serviceParamsValid, b]) => this.validate.next(serviceParamsValid && this.createNewInstanceForm.valid)) + ).subscribe() + ); + // For existing service instance the step is valid if the form is (there's no service params) this.subscriptions.push(this.selectExistingInstanceForm.statusChanges.pipe( - map(() => this.validate.next(this.selectExistingInstanceForm.valid))).subscribe()); + map(() => this.validate.next(this.selectExistingInstanceForm.valid)) + ).subscribe()); } private getNewServiceGuid(name: string, spaceGuid: string, servicePlanGuid: string) { @@ -391,7 +427,7 @@ export class SpecifyDetailsStepComponent implements OnDestroy, AfterContentInit const name = this.createNewInstanceForm.controls.name.value; const { spaceGuid, cfGuid } = createServiceInstance; const servicePlanGuid = createServiceInstance.servicePlanGuid; - const params = getServiceJsonParams(this.createNewInstanceForm.controls.params.value); + const params = this.serviceParams; let tagsStr = null; tagsStr = this.tags.length > 0 ? this.tags.map(t => t.label) : []; @@ -432,7 +468,6 @@ export class SpecifyDetailsStepComponent implements OnDestroy, AfterContentInit createBinding = (serviceInstanceGuid: string, cfGuid: string, appGuid: string, params: {}) => { const guid = `${cfGuid}-${appGuid}-${serviceInstanceGuid}`; - params = getServiceJsonParams(params); this.store.dispatch(new CreateServiceBinding( cfGuid, diff --git a/src/frontend/app/shared/components/application-state/application-state-icon/application-state-icon.component.ts b/src/frontend/app/shared/components/application-state/application-state-icon/application-state-icon.component.ts index c35becf6a6..9b7376a1a4 100644 --- a/src/frontend/app/shared/components/application-state/application-state-icon/application-state-icon.component.ts +++ b/src/frontend/app/shared/components/application-state/application-state-icon/application-state-icon.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit, Input } from '@angular/core'; +import { Component, Input } from '@angular/core'; + import { CardStatus } from '../application-state.service'; @Component({ @@ -6,13 +7,8 @@ import { CardStatus } from '../application-state.service'; templateUrl: './application-state-icon.component.html', styleUrls: ['./application-state-icon.component.scss'] }) -export class ApplicationStateIconComponent implements OnInit { - - constructor() { } +export class ApplicationStateIconComponent { @Input() public status: CardStatus; - ngOnInit() { - } - } diff --git a/src/frontend/app/shared/components/schema-form/schema-form.component.html b/src/frontend/app/shared/components/schema-form/schema-form.component.html new file mode 100644 index 0000000000..f6b88429a1 --- /dev/null +++ b/src/frontend/app/shared/components/schema-form/schema-form.component.html @@ -0,0 +1,23 @@ + +
+ + + + Not valid JSON. Please specify a valid JSON Object + + +
+
+ +
+ + Form + JSON + + +
+ + +
+
+
diff --git a/src/frontend/app/shared/components/schema-form/schema-form.component.scss b/src/frontend/app/shared/components/schema-form/schema-form.component.scss new file mode 100644 index 0000000000..e0bc053bbf --- /dev/null +++ b/src/frontend/app/shared/components/schema-form/schema-form.component.scss @@ -0,0 +1,22 @@ +@import '../../../../sass/mixins'; + +.schema-form { + display: flex; + flex-direction: column; + max-width: 450px; + + &__radios { + padding: 10px 0; + + &-btn { + padding-right: 20px; + } + } + + &__form { + &--data-bad { + margin: 10px 0; + padding: 10px; + } + } +} diff --git a/src/frontend/app/shared/components/schema-form/schema-form.component.spec.ts b/src/frontend/app/shared/components/schema-form/schema-form.component.spec.ts new file mode 100644 index 0000000000..176bb65a7e --- /dev/null +++ b/src/frontend/app/shared/components/schema-form/schema-form.component.spec.ts @@ -0,0 +1,33 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MaterialDesignFrameworkModule } from 'stratos-angular6-json-schema-form'; + +import { BaseTestModulesNoShared } from '../../../test-framework/cloud-foundry-endpoint-service.helper'; +import { SchemaFormComponent } from './schema-form.component'; + +describe('BindServiceAppFormComponent', () => { + let component: SchemaFormComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + SchemaFormComponent + ], + imports: [ + BaseTestModulesNoShared, + MaterialDesignFrameworkModule + ], + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SchemaFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/frontend/app/shared/components/schema-form/schema-form.component.theme.scss b/src/frontend/app/shared/components/schema-form/schema-form.component.theme.scss new file mode 100644 index 0000000000..ec57b17d29 --- /dev/null +++ b/src/frontend/app/shared/components/schema-form/schema-form.component.theme.scss @@ -0,0 +1,8 @@ +@mixin app-schema-form-theme($theme, $app-theme) { + $status-colors: map-get($app-theme, status); + $error-color: map-get($status-colors, danger); + + .schema-form__form--data-bad { + background-color: transparentize($error-color, .9); + } +} diff --git a/src/frontend/app/shared/components/schema-form/schema-form.component.ts b/src/frontend/app/shared/components/schema-form/schema-form.component.ts new file mode 100644 index 0000000000..19bb3f0d6f --- /dev/null +++ b/src/frontend/app/shared/components/schema-form/schema-form.component.ts @@ -0,0 +1,170 @@ +import { AfterContentInit, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { AbstractControl, FormControl, FormGroup, ValidatorFn } from '@angular/forms'; +import { ErrorStateMatcher, ShowOnDirtyErrorStateMatcher } from '@angular/material'; +import { BehaviorSubject, Subscription } from 'rxjs'; +import { delay } from 'rxjs/operators'; +import { JsonPointer } from 'stratos-angular6-json-schema-form'; + +import { safeStringToObj } from '../../../core/utils.service'; + +export interface SchemaFormValidationError { + dataPath: {}; + message: string; +} + +export function isValidJsonValidator(): ValidatorFn { + return (formField: AbstractControl): { [key: string]: any } => { + try { + if (formField.value) { + const jsonObj = JSON.parse(formField.value); + // Check if jsonObj is actually an obj + if (jsonObj.constructor !== {}.constructor) { + throw new Error('not an object'); + } + } + } catch (e) { + return { 'notValidJson': { value: formField.value } }; + } + return null; + }; +} + +export class SchemaFormConfig { + schema: object; + initialData?: object; +} + +@Component({ + selector: 'app-schema-form', + templateUrl: './schema-form.component.html', + styleUrls: ['./schema-form.component.scss'], + providers: [ + { provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher } + ] +}) +export class SchemaFormComponent implements OnInit, OnDestroy, AfterContentInit { + + mode: 'JSON' | 'schema'; + schemaView: 'schemaForm' | 'schemaJson' = 'schemaForm'; + private schema; + + @Input() + set config(config: SchemaFormConfig) { + // Skip if no config... or schema is the same (avoids losing existing data in form) + if (!config || (config.schema && config.schema === this.schema)) { + return; + } + this.schema = config.schema; + this.cleanSchema = this.filterSchema(config.schema); + this.mode = this.cleanSchema ? 'schema' : 'JSON'; + if (this.mode === 'JSON') { + this.setJsonFormData(config.initialData); + if (!config.initialData) { + this._validChange.next(true); + } + } else if (this.mode === 'schema') { + this.formInitialData = config.initialData; + } + } + + @Output() + dataChange = new EventEmitter(); + _dataChange = new BehaviorSubject(null); + + @Input() + valid = false; + @Output() + validChange = new EventEmitter(); + _validChange = new BehaviorSubject(false); + + + cleanSchema: object; + + jsonData: object; + jsonForm: FormGroup; + + formData: object = {}; + formInitialData: object; + formValidationErrors: SchemaFormValidationError[]; + formValidationErrorsStr: string; + + subs: Subscription[] = []; + + ngOnInit() { + this.jsonForm = new FormGroup({ + json: new FormControl('', isValidJsonValidator()), + }); + } + + ngAfterContentInit() { + this.subs.push(this.jsonForm.controls['json'].valueChanges.subscribe(jsonStr => { + this.jsonData = safeStringToObj(jsonStr); + this._dataChange.next(this.jsonData); + this._validChange.next(this.isJsonFormValid()); + })); + + this.subs.push(this._dataChange.asObservable().pipe(delay(0)).subscribe(data => this.dataChange.emit(data))); + this.subs.push(this._validChange.asObservable().pipe(delay(0)).subscribe(valid => this.validChange.emit(valid))); + } + + ngOnDestroy() { + this.subs.forEach(sub => sub.unsubscribe()); + } + + onSchemaViewChanged() { + if (this.schemaView === 'schemaForm') { + // Copy json into form + this.formInitialData = this.jsonData; + } else { + // Copy form into json + this.setJsonFormData(this.formData); + } + } + + setJsonFormData(data: object) { + if (this.jsonForm) { + const jsonString = data ? JSON.stringify(data) : ''; + this.jsonForm.controls['json'].setValue(jsonString); + } + } + + private isJsonFormValid(): boolean { + return !this.jsonForm.controls['json'].value || this.jsonForm.controls['json'].valid; + } + + private filterSchema = (schema?: object): any => { + if (!schema) { + return; + } + const filterSchema = Object.keys(schema).reduce((obj, key) => { + if (key !== '$schema') { obj[key] = schema[key]; } + return obj; + }, {}); + return Object.keys(filterSchema).length > 0 ? filterSchema : null; + } + + onFormChange(formData) { + this.formData = formData; + this._dataChange.next(formData); + } + + onFormValidationErrors(data: SchemaFormValidationError[]): void { + this.formValidationErrors = data || []; + this.formValidationErrorsStr = this.prettyValidationErrorsFn(this.formValidationErrors); + this._validChange.next(!this.formValidationErrors.length); + } + + private prettyValidationErrorsFn = (formValidationErrors: SchemaFormValidationError[]): string => { + if (!formValidationErrors) { + return null; + } + return formValidationErrors.reduce((a, c) => { + const arrMessage = JsonPointer.parse(c.dataPath).reduce((aa, cc) => { + const dd = /^\d+$/.test(cc) ? `[${cc}]` : `.${cc}`; + return aa + dd; + }, ''); + return `${a} ${arrMessage} ${c.message}
`; + }, ''); + } + +} diff --git a/src/frontend/app/shared/components/stepper/steppers/steppers.component.ts b/src/frontend/app/shared/components/stepper/steppers/steppers.component.ts index d269709220..94e846476d 100644 --- a/src/frontend/app/shared/components/stepper/steppers/steppers.component.ts +++ b/src/frontend/app/shared/components/stepper/steppers/steppers.component.ts @@ -220,7 +220,7 @@ export class SteppersComponent implements OnInit, AfterContentInit, OnDestroy { if (index === this.currentIndex) { return true; } - if (index < 0 || index > this.steps.length) { + if (index < 0 || index >= this.steps.length) { return false; } if (index < this.currentIndex) { diff --git a/src/frontend/app/shared/shared.module.ts b/src/frontend/app/shared/shared.module.ts index c05f28b52b..9f6a721113 100644 --- a/src/frontend/app/shared/shared.module.ts +++ b/src/frontend/app/shared/shared.module.ts @@ -5,6 +5,8 @@ import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { NgxChartsModule } from '@swimlane/ngx-charts'; +import { MomentModule } from 'ngx-moment'; +import { MaterialDesignFrameworkModule } from 'stratos-angular6-json-schema-form'; import { CoreModule } from '../core/core.module'; import { @@ -54,6 +56,7 @@ import { } from './components/cards/service-recent-instances-card/service-recent-instances-card.component'; import { ServiceSummaryCardComponent } from './components/cards/service-summary-card/service-summary-card.component'; import { CfAuthModule } from './components/cf-auth/cf-auth.module'; +import { CfEndpointsMissingComponent } from './components/cf-endpoints-missing/cf-endpoints-missing.component'; import { CfRoleCheckboxComponent } from './components/cf-role-checkbox/cf-role-checkbox.component'; import { AppChipsComponent } from './components/chips/chips.component'; import { CliCommandComponent } from './components/cli-info/cli-command/cli-command.component'; @@ -63,6 +66,7 @@ import { ConfirmationDialogService } from './components/confirmation-dialog.serv import { CreateApplicationStep1Component, } from './components/create-application/create-application-step1/create-application-step1.component'; +import { DateTimeComponent } from './components/date-time/date-time.component'; import { DetailsCardComponent } from './components/details-card/details-card.component'; import { DialogConfirmComponent } from './components/dialog-confirm/dialog-confirm.component'; import { DialogErrorComponent } from './components/dialog-error/dialog-error.component'; @@ -95,13 +99,21 @@ import { LoadingPageComponent } from './components/loading-page/loading-page.com import { LogViewerComponent } from './components/log-viewer/log-viewer.component'; import { MetadataItemComponent } from './components/metadata-item/metadata-item.component'; import { MetricsChartComponent } from './components/metrics-chart/metrics-chart.component'; +import { + MetricsParentRangeSelectorComponent, +} from './components/metrics-parent-range-selector/metrics-parent-range-selector.component'; +import { MetricsRangeSelectorComponent } from './components/metrics-range-selector/metrics-range-selector.component'; +import { MultilineTitleComponent } from './components/multiline-title/multiline-title.component'; import { NestedTabsComponent } from './components/nested-tabs/nested-tabs.component'; import { NoContentMessageComponent } from './components/no-content-message/no-content-message.component'; import { PageHeaderModule } from './components/page-header/page-header.module'; import { RingChartComponent } from './components/ring-chart/ring-chart.component'; +import { RoutingIndicatorComponent } from './components/routing-indicator/routing-indicator.component'; import { RunningInstancesComponent } from './components/running-instances/running-instances.component'; +import { SchemaFormComponent } from './components/schema-form/schema-form.component'; import { ServiceIconComponent } from './components/service-icon/service-icon.component'; import { SshViewerComponent } from './components/ssh-viewer/ssh-viewer.component'; +import { StartEndDateComponent } from './components/start-end-date/start-end-date.component'; import { StatefulIconComponent } from './components/stateful-icon/stateful-icon.component'; import { SteppersModule } from './components/stepper/steppers.module'; import { StratosTitleComponent } from './components/stratos-title/stratos-title.component'; @@ -117,27 +129,19 @@ import { UserProfileBannerComponent } from './components/user-profile-banner/use import { CfOrgSpaceDataService } from './data-services/cf-org-space-service.service'; import { CfUserService } from './data-services/cf-user.service'; import { CloudFoundryService } from './data-services/cloud-foundry.service'; +import { GitSCMService } from './data-services/scm/scm.service'; import { ServiceActionHelperService } from './data-services/service-action-helper.service'; import { EntityMonitorFactory } from './monitors/entity-monitor.factory.service'; import { InternalEventMonitorFactory } from './monitors/internal-event-monitor.factory'; import { PaginationMonitorFactory } from './monitors/pagination-monitor.factory'; +import { CapitalizeFirstPipe } from './pipes/capitalizeFirstLetter.pipe'; import { MbToHumanSizePipe } from './pipes/mb-to-human-size.pipe'; import { PercentagePipe } from './pipes/percentage.pipe'; import { UptimePipe } from './pipes/uptime.pipe'; import { UsageBytesPipe } from './pipes/usage-bytes.pipe'; import { ValuesPipe } from './pipes/values.pipe'; -import { UserPermissionDirective } from './user-permission.directive'; -import { CfEndpointsMissingComponent } from './components/cf-endpoints-missing/cf-endpoints-missing.component'; -import { CapitalizeFirstPipe } from './pipes/capitalizeFirstLetter.pipe'; -import { RoutingIndicatorComponent } from './components/routing-indicator/routing-indicator.component'; -import { GitSCMService } from './data-services/scm/scm.service'; -import { DateTimeComponent } from './components/date-time/date-time.component'; -import { StartEndDateComponent } from './components/start-end-date/start-end-date.component'; -import { MomentModule } from 'ngx-moment'; -import { MetricsRangeSelectorComponent } from './components/metrics-range-selector/metrics-range-selector.component'; -import { MetricsParentRangeSelectorComponent } from './components/metrics-parent-range-selector/metrics-parent-range-selector.component'; import { MetricsRangeSelectorService } from './services/metrics-range-selector.service'; -import { MultilineTitleComponent } from './components/multiline-title/multiline-title.component'; +import { UserPermissionDirective } from './user-permission.directive'; @NgModule({ imports: [ @@ -149,6 +153,7 @@ import { MultilineTitleComponent } from './components/multiline-title/multiline- CfAuthModule, CdkTableModule, NgxChartsModule, + MaterialDesignFrameworkModule, MomentModule, ], declarations: [ @@ -241,6 +246,7 @@ import { MultilineTitleComponent } from './components/multiline-title/multiline- CfEndpointsMissingComponent, CapitalizeFirstPipe, RoutingIndicatorComponent, + SchemaFormComponent, DateTimeComponent, StartEndDateComponent, MetricsRangeSelectorComponent, diff --git a/src/frontend/sass/_all-theme.scss b/src/frontend/sass/_all-theme.scss index 6e8550e930..8d2fafef21 100644 --- a/src/frontend/sass/_all-theme.scss +++ b/src/frontend/sass/_all-theme.scss @@ -44,6 +44,7 @@ @import './mat-themes'; @import './fonts'; @import './ansi-colors'; +@import '../app/shared/components/schema-form/schema-form.component.theme'; // Defaults $side-nav-light-text: #fff; $side-nav-light-bg: #333; @@ -116,6 +117,7 @@ $side-nav-light-active: #484848; @include page-404($theme, $app-theme); @include about-page-theme($theme, $app-theme); @include meta-card-component($theme, $app-theme); + @include app-schema-form-theme($theme, $app-theme); @include start-end-theme($theme, $app-theme); @include metrics-chart-theme($theme, $app-theme); @include metrics-range-selector-theme($theme, $app-theme);