From 52234c7c49bd232cd2db339ff84d6d7edb998a8a Mon Sep 17 00:00:00 2001 From: Guilhermo Pazuch <1490938+gpazuch@users.noreply.github.com> Date: Tue, 9 May 2023 10:56:14 -0300 Subject: [PATCH] (feat/fix): Enable Partial Sink Update (#2230) * wip: sink view init * wip: sink view + edit * wip: config editor * wip: save * fix edit config & save * update sink config view css * fix json format --- ui/src/app/pages/pages-routing.module.ts | 6 + ui/src/app/pages/pages.module.ts | 2 + .../pages/sinks/list/sink.list.component.html | 2 +- .../pages/sinks/list/sink.list.component.ts | 7 + .../pages/sinks/view/sink.view.component.html | 42 ++++++ .../pages/sinks/view/sink.view.component.scss | 72 ++++++++++ .../sinks/view/sink.view.component.spec.ts | 25 ++++ .../pages/sinks/view/sink.view.component.ts | 128 ++++++++++++++++++ .../sink-config/sink-config.component.html | 45 ++++++ .../sink-config/sink-config.component.scss | 3 + .../sink-config/sink-config.component.spec.ts | 25 ++++ .../sink/sink-config/sink-config.component.ts | 85 ++++++++++++ .../sink-details/sink-details.component.html | 94 +++++++++++++ .../sink-details/sink-details.component.scss | 5 + .../sink-details.component.spec.ts | 25 ++++ .../sink-details/sink-details.component.ts | 73 ++++++++++ ui/src/app/shared/shared.module.ts | 6 + ui/src/assets/text/strings.ts | 3 + 18 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 ui/src/app/pages/sinks/view/sink.view.component.html create mode 100644 ui/src/app/pages/sinks/view/sink.view.component.scss create mode 100644 ui/src/app/pages/sinks/view/sink.view.component.spec.ts create mode 100644 ui/src/app/pages/sinks/view/sink.view.component.ts create mode 100644 ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.html create mode 100644 ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.scss create mode 100644 ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.spec.ts create mode 100644 ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.ts create mode 100644 ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.html create mode 100644 ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.scss create mode 100644 ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.spec.ts create mode 100644 ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.ts diff --git a/ui/src/app/pages/pages-routing.module.ts b/ui/src/app/pages/pages-routing.module.ts index e0d67ac19..96ba9881b 100644 --- a/ui/src/app/pages/pages-routing.module.ts +++ b/ui/src/app/pages/pages-routing.module.ts @@ -25,6 +25,7 @@ import { DashboardComponent } from 'app/pages/dashboard/dashboard.component'; import { DatasetAddComponent } from 'app/pages/datasets/add/dataset.add.component'; import { ProfileComponent } from './profile/profile.component'; import { AgentPolicyViewComponent } from 'app/pages/datasets/policies.agent/view/agent.policy.view.component'; +import { SinkViewComponent } from './sinks/view/sink.view.component'; const children = [ { @@ -116,6 +117,11 @@ const children = [ component: SinkAddComponent, data: {breadcrumb: 'Edit Sink'}, }, + { + path: 'view/:id', + component: SinkViewComponent, + data: {breadcrumb: 'View Sink'}, + }, ], }, { diff --git a/ui/src/app/pages/pages.module.ts b/ui/src/app/pages/pages.module.ts index c041eb78d..62904cb14 100644 --- a/ui/src/app/pages/pages.module.ts +++ b/ui/src/app/pages/pages.module.ts @@ -68,6 +68,7 @@ import { DashboardModule } from './dashboard/dashboard.module'; import { AgentViewComponent } from './fleet/agents/view/agent.view.component'; import { PagesRoutingModule } from './pages-routing.module'; import { PagesComponent } from './pages.component'; +import { SinkViewComponent } from './sinks/view/sink.view.component'; @NgModule({ imports: [ @@ -150,6 +151,7 @@ import { PagesComponent } from './pages.component'; SinkAddComponent, SinkDetailsComponent, SinkDeleteComponent, + SinkViewComponent, // DEV SHOWCASE ShowcaseComponent, ], diff --git a/ui/src/app/pages/sinks/list/sink.list.component.html b/ui/src/app/pages/sinks/list/sink.list.component.html index 6e7f8852c..3bdddacdf 100644 --- a/ui/src/app/pages/sinks/list/sink.list.component.html +++ b/ui/src/app/pages/sinks/list/sink.list.component.html @@ -86,7 +86,7 @@

{{ strings.list.header }}

>
+ +
+ + +
+ + + + +
diff --git a/ui/src/app/pages/sinks/view/sink.view.component.scss b/ui/src/app/pages/sinks/view/sink.view.component.scss new file mode 100644 index 000000000..a02108a74 --- /dev/null +++ b/ui/src/app/pages/sinks/view/sink.view.component.scss @@ -0,0 +1,72 @@ +button { + margin: 0 3px; + + &.policy-duplicate { + float: right; + color: #fff !important; + font-family: "Montserrat", sans-serif; + font-weight: 700; + text-transform: none !important; + + &.btn-disabled { + background: #2b3148; + } + + &:not(.btn-disabled) { + background-color: #3089fc !important; + } + } + + &.policy-save { + float: right; + color: #fff !important; + font-family: "Montserrat", sans-serif; + font-weight: 500; + text-transform: none !important; + + &.btn-disabled { + background: #2b3148; + } + + &:not(.btn-disabled) { + background-color: #3089fc !important; + } + } + + &.policy-discard { + float: right; + color: #fff !important; + font-family: "Montserrat", sans-serif; + font-weight: 500; + text-transform: none !important; + + &.btn-disabled { + background: #2b3148; + } + + &:not(.btn-disabled) { + background-color: #df316f !important; + } + } +} + +ngx-sink-details { + flex: 0 1 22rem; +} + +ngx-sink-config { + flex: 2 1 auto; + min-height: 30rem !important; + + nb-card { + height: 30rem !important; + } +} + +.row { + gap: 1rem; +} + +header { + justify-content: space-between; +} diff --git a/ui/src/app/pages/sinks/view/sink.view.component.spec.ts b/ui/src/app/pages/sinks/view/sink.view.component.spec.ts new file mode 100644 index 000000000..b1b7437e0 --- /dev/null +++ b/ui/src/app/pages/sinks/view/sink.view.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SinkViewComponent } from './sink.view.component'; + +describe('SinkViewComponent', () => { + let component: SinkViewComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SinkViewComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SinkViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/pages/sinks/view/sink.view.component.ts b/ui/src/app/pages/sinks/view/sink.view.component.ts new file mode 100644 index 000000000..b5201a5ca --- /dev/null +++ b/ui/src/app/pages/sinks/view/sink.view.component.ts @@ -0,0 +1,128 @@ +import { ChangeDetectorRef, Component, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Sink } from 'app/common/interfaces/orb/sink.interface'; +import { NotificationsService } from 'app/common/services/notifications/notifications.service'; +import { SinksService } from 'app/common/services/sinks/sinks.service'; +import { SinkConfigComponent } from 'app/shared/components/orb/sink/sink-config/sink-config.component'; +import { SinkDetailsComponent } from 'app/shared/components/orb/sink/sink-details/sink-details.component'; +import { STRINGS } from 'assets/text/strings'; +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'ngx-sink-view', + templateUrl: './sink.view.component.html', + styleUrls: ['./sink.view.component.scss'] +}) +export class SinkViewComponent implements OnInit, OnChanges, OnDestroy { + strings = STRINGS; + + isLoading = false; + + sink: Sink; + + sinkId = ''; + + sinkSubscription: Subscription; + + editMode = { + details: false, + config: false, + } + + @ViewChild(SinkDetailsComponent) detailsComponent: SinkDetailsComponent; + + @ViewChild(SinkConfigComponent) + configComponent: SinkConfigComponent; + + constructor(private cdr: ChangeDetectorRef, + private notifications: NotificationsService, + private sinks: SinksService, + private route: ActivatedRoute, + ) { } + + ngOnInit(): void { + this.fetchData(); + } + + ngOnChanges(): void { + this.fetchData(); + } + + fetchData() { + this.isLoading = true; + this.sinkId = this.route.snapshot.paramMap.get('id'); + this.retrieveSink(); + } + + isEditMode() { + return Object.values(this.editMode).reduce( + (prev, cur) => prev || cur, + false, + ); + } + + canSave() { + const detailsValid = this.editMode.details + ? this.detailsComponent?.formGroup?.status === 'VALID' + : true; + + const configValid = this.editMode.config + ? this.configComponent?.formControl?.status === 'VALID' + : true; + + return detailsValid && configValid; + } + + discard() { + this.editMode.details = false; + this.editMode.config = false; + } + + save() { + const { name, description, id, backend } = this.sink; + + const sinkDetails = this.detailsComponent.formGroup?.value; + const tags = this.detailsComponent.selectedTags; + const config = this.configComponent.code; + + const detailsPartial = (!!this.editMode.details && { ...sinkDetails, id, backend}) + || { name, description, id, backend }; + + let configPartial = (!!this.editMode.config && JSON.parse(config)) || {}; + + const payload = { + ...configPartial, + ...detailsPartial, + tags, + + } as Sink; + + try { + this.sinks.editSink(payload).subscribe((resp) => { + this.discard(); + this.sink = resp; + this.fetchData(); + this.notifications.success('Sink updated successfully', ''); + }); + } catch (err) { + this.notifications.error( + 'Failed to edit Sink', + 'Error: Invalid configuration', + ) + } + } + + retrieveSink() { + this.sinkSubscription = this.sinks + .getSinkById(this.sinkId) + .subscribe(sink => { + this.sink = sink; + this.isLoading = false; + this.cdr.markForCheck(); + }); + } + + ngOnDestroy(): void { + this.sinkSubscription.unsubscribe(); + } +} diff --git a/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.html b/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.html new file mode 100644 index 000000000..a81842200 --- /dev/null +++ b/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.html @@ -0,0 +1,45 @@ + + Sink Backend Configuration + + + + + +
+ + +
+
+
+ + +
+

+    
+
+ + +

+  
+ \ No newline at end of file diff --git a/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.scss b/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.scss new file mode 100644 index 000000000..0639c95f1 --- /dev/null +++ b/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.scss @@ -0,0 +1,3 @@ +ngx-monaco-editor { + height: 25rem; +} \ No newline at end of file diff --git a/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.spec.ts b/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.spec.ts new file mode 100644 index 000000000..9ffa7cd11 --- /dev/null +++ b/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SinkConfigComponent } from './sink-config.component'; + +describe('SinkConfigComponent', () => { + let component: SinkConfigComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SinkConfigComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SinkConfigComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.ts b/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.ts new file mode 100644 index 000000000..ddf13678c --- /dev/null +++ b/ui/src/app/shared/components/orb/sink/sink-config/sink-config.component.ts @@ -0,0 +1,85 @@ +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'; +import { FormBuilder, FormControl, Validators } from '@angular/forms'; +import { Sink } from 'app/common/interfaces/orb/sink.interface'; +import IStandaloneEditorConstructionOptions = monaco.editor.IStandaloneEditorConstructionOptions; +@Component({ + selector: 'ngx-sink-config', + templateUrl: './sink-config.component.html', + styleUrls: ['./sink-config.component.scss'] +}) +export class SinkConfigComponent implements OnInit, OnChanges { + + @Input() + sink: Sink; + + @Input() + editMode: boolean; + + @Output() + editModeChange: EventEmitter; + + @ViewChild('editorComponent') + editor; + + editorOptions: IStandaloneEditorConstructionOptions = { + theme: 'vs-dark', + dragAndDrop: true, + wordWrap: 'on', + detectIndentation: true, + tabSize: 2, + autoIndent: 'full', + formatOnPaste: true, + trimAutoWhitespace: true, + formatOnType: true, + matchBrackets: 'always', + language: 'json', + automaticLayout: true, + glyphMargin: false, + folding: true, + readOnly: true, + scrollBeyondLastLine: false, + // Undocumented see https://github.com/Microsoft/vscode/issues/30795#issuecomment-410998882 + lineDecorationsWidth: 0, + lineNumbersMinChars: 0, + }; + + code = ''; + + formControl: FormControl; + + constructor(private fb: FormBuilder) { + this.sink = {}; + this.editMode = false; + this.editModeChange = new EventEmitter(); + this.updateForm(); + } + + ngOnInit(): void { + this.code = JSON.stringify(this.sink.config, null, 2); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes?.editMode && !changes?.editMode.firstChange) { + this.toggleEdit(changes.editMode.currentValue, false); + } + } + + updateForm() { + const { config } = this.sink; + if (this.editMode) { + this.code = JSON.stringify(config, null, 2); + this.formControl = this.fb.control(this.code, [Validators.required]); + } else { + this.formControl = this.fb.control(null, [Validators.required]); + this.code = JSON.stringify(config, null, 2); + } + } + + toggleEdit(edit, notify = true) { + this.editMode = edit; + this.editorOptions = { ...this.editorOptions, readOnly: !edit }; + this.updateForm(); + !!notify && this.editModeChange.emit(this.editMode); + } + +} diff --git a/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.html b/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.html new file mode 100644 index 000000000..7487106ca --- /dev/null +++ b/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.html @@ -0,0 +1,94 @@ + + Sink Details + + + + +
+
+
+ + +
+
+ +

{{ sink?.name }}

+
+
+ +

{{ sink?.description }}

+
+ +
+ + +
+
+
+ + +
+ +
+
+ + * +
+ +
+
+ Name is required. +
+
+ Name does not match the pattern. +
+
+
+
+ +
+ +
+ +
+
+ + +
+ \ No newline at end of file diff --git a/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.scss b/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.scss new file mode 100644 index 000000000..524faceb8 --- /dev/null +++ b/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.scss @@ -0,0 +1,5 @@ +.required { + color: #df316f; + padding-left: 2px; + } + \ No newline at end of file diff --git a/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.spec.ts b/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.spec.ts new file mode 100644 index 000000000..a9ede211d --- /dev/null +++ b/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SinkDetailsComponent } from './sink-details.component'; + +describe('SinkDetailsComponent', () => { + let component: SinkDetailsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SinkDetailsComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SinkDetailsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.ts b/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.ts new file mode 100644 index 000000000..36a68aa77 --- /dev/null +++ b/ui/src/app/shared/components/orb/sink/sink-details/sink-details.component.ts @@ -0,0 +1,73 @@ +import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Sink } from 'app/common/interfaces/orb/sink.interface'; +import { Tags } from 'app/common/interfaces/orb/tag'; + + +@Component({ + selector: 'ngx-sink-details', + templateUrl: './sink-details.component.html', + styleUrls: ['./sink-details.component.scss'] +}) +export class SinkDetailsComponent implements OnInit, OnChanges { + @Input() + sink: Sink; + + @Input() + editMode: boolean; + + @Output() + editModeChange: EventEmitter; + + formGroup: FormGroup; + + selectedTags: Tags; + + constructor(private fb: FormBuilder) { + this.sink = {}; + this.editMode = false; + this.editModeChange = new EventEmitter(); + this.updateForm(); + } + + ngOnInit(): void { + this.selectedTags = this.sink?.tags || {}; + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes?.editMode) { + this.toggleEdit(changes.editMode.currentValue, false); + } + if (changes?.sink) { + this.selectedTags = this.sink?.tags || {}; + } + } + + updateForm() { + if (this.editMode) { + const { name, description, tags } = this.sink; + this.formGroup = this.fb.group({ + name: [ + name, + [ + Validators.required, + Validators.pattern('^[a-zA-Z_][a-zA-Z0-9_-]*$'), + ], + ], + description: [description], + }); + this.selectedTags = {...tags} || {}; + } else { + this.formGroup = this.fb.group({ + name: null, + description: null, + }); + } + } + + toggleEdit(value, notify = true) { + this.editMode = value; + this.updateForm(); + !!notify && this.editModeChange.emit(this.editMode); + } +} diff --git a/ui/src/app/shared/shared.module.ts b/ui/src/app/shared/shared.module.ts index b19f016c6..28621b2c4 100644 --- a/ui/src/app/shared/shared.module.ts +++ b/ui/src/app/shared/shared.module.ts @@ -64,6 +64,8 @@ import { ToMillisecsPipe } from './pipes/time.pipe'; import { PollControlComponent } from './components/poll-control/poll-control.component'; import {EmptyInputDirective} from 'app/shared/directives/empty-input.directive'; import { AgentBackendsComponent } from './components/orb/agent/agent-backends/agent-backends.component'; +import { SinkDetailsComponent } from './components/orb/sink/sink-details/sink-details.component'; +import { SinkConfigComponent } from './components/orb/sink/sink-config/sink-config.component'; @NgModule({ imports: [ @@ -132,6 +134,8 @@ import { AgentBackendsComponent } from './components/orb/agent/agent-backends/ag FilterComponent, PollControlComponent, EmptyInputDirective, + SinkDetailsComponent, + SinkConfigComponent, ], exports: [ ThemeModule, @@ -168,6 +172,8 @@ import { AgentBackendsComponent } from './components/orb/agent/agent-backends/ag FilterComponent, PollControlComponent, EmptyInputDirective, + SinkDetailsComponent, + SinkConfigComponent, ], providers: [ MessageValuePipe, diff --git a/ui/src/assets/text/strings.ts b/ui/src/assets/text/strings.ts index fb084791e..b5b0ab54d 100644 --- a/ui/src/assets/text/strings.ts +++ b/ui/src/assets/text/strings.ts @@ -66,6 +66,9 @@ export const STRINGS = { edit: { header: 'Edit Sink', }, + view: { + header: 'View Sink', + }, // delete modal delete: { header: 'Delete Sink Confirmation',