From d223ac4675a5a36e7c63be9c602749b515a29f93 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Tue, 24 Jan 2023 12:16:50 -0700 Subject: [PATCH] Config refactor (#2128) * prevent excessive nesting for unions, closes #2107, and genrally refactor config * a littel cleaner * working but with inefficiencies * remove warning from union list * introduce messaging for config with only pointers * feat(shared): `ElasticContainer` add new component (#2134) * feat(shared): `ElasticContainer` add new component * chore: fix imports * revert to 250 for resize * remove logs Co-authored-by: Alex Inkin --- frontend/package-lock.json | 63 +++++ frontend/package.json | 3 + frontend/projects/shared/package.json | 2 + .../elastic-container.component.html | 3 + .../elastic-container.component.scss | 5 + .../elastic-container.component.ts | 12 + .../elastic-container.directive.ts | 35 +++ .../elastic-container.module.ts | 10 + frontend/projects/shared/src/public-api.ts | 3 + .../form-object/form-error.component.html | 35 --- .../form-object/form-label.component.html | 17 +- .../form-object/form-object.component.html | 224 +++++++---------- .../form-object.component.module.ts | 27 +- .../form-object/form-object.component.scss | 12 +- .../form-object/form-object.component.ts | 237 +++++++++--------- .../form-object/form-object.pipes.ts | 92 +++++++ .../form-object/form-union.component.html | 43 ++++ .../modals/app-config/app-config.page.html | 10 +- .../app/modals/app-config/app-config.page.ts | 7 +- .../modals/generic-form/generic-form.page.ts | 1 - .../ui/src/app/pkg-config/config-types.ts | 7 +- .../ui/src/app/services/api/api.fixures.ts | 16 +- .../ui/src/app/services/api/mock-patch.ts | 7 +- .../ui/src/app/services/form.service.ts | 25 +- 24 files changed, 545 insertions(+), 351 deletions(-) create mode 100644 frontend/projects/shared/src/components/elastic-container/elastic-container.component.html create mode 100644 frontend/projects/shared/src/components/elastic-container/elastic-container.component.scss create mode 100644 frontend/projects/shared/src/components/elastic-container/elastic-container.component.ts create mode 100644 frontend/projects/shared/src/components/elastic-container/elastic-container.directive.ts create mode 100644 frontend/projects/shared/src/components/elastic-container/elastic-container.module.ts delete mode 100644 frontend/projects/ui/src/app/components/form-object/form-error.component.html create mode 100644 frontend/projects/ui/src/app/components/form-object/form-object.pipes.ts create mode 100644 frontend/projects/ui/src/app/components/form-object/form-union.component.html diff --git a/frontend/package-lock.json b/frontend/package-lock.json index be1f3dda4..e7b2ea606 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,9 @@ "@angular/router": "^14.1.0", "@ionic/angular": "^6.1.15", "@materia-ui/ngx-monaco-editor": "^6.0.0", + "@ng-web-apis/common": "^2.0.0", + "@ng-web-apis/mutation-observer": "^2.0.0", + "@ng-web-apis/resize-observer": "^2.0.0", "@start9labs/argon2": "^0.1.0", "@start9labs/emver": "^0.1.5", "angular-svg-round-progressbar": "^9.0.0", @@ -3266,6 +3269,42 @@ "rxjs": ">=6.0.0" } }, + "node_modules/@ng-web-apis/common": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-2.0.0.tgz", + "integrity": "sha512-2Vnp4WTEqKZArhbKLgD1JIKjsDa3hWCa67OWaRWRH5sgX5xneVVaIAvC8gVpiCfl2p1Roen2kxfyYngx7G64SQ==", + "dependencies": { + "tslib": "^2.2.0" + }, + "peerDependencies": { + "@angular/common": ">=12.0.0", + "@angular/core": ">=12.0.0" + } + }, + "node_modules/@ng-web-apis/mutation-observer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-2.0.0.tgz", + "integrity": "sha512-f51Cu2DloNze1HaTWdUbtYFnt9VXhzpEnHDd9KFdiKOUNfEDx7wrSXIEQqv810hrq7F2jcIAERCdiqV6ItH7Pg==", + "dependencies": { + "tslib": "^2.2.0" + }, + "peerDependencies": { + "@angular/core": ">=12.0.0", + "@ng-web-apis/common": ">=2.0.0" + } + }, + "node_modules/@ng-web-apis/resize-observer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-2.0.0.tgz", + "integrity": "sha512-umuXJepTYBCI3ZcW9873fozO0qt1PeHLBNM+wXA+7Wphy35+RQcPNmkwfgkKqWceIjlYAvyuPTNWa5TM1OEeqg==", + "dependencies": { + "tslib": "^2.2.0" + }, + "peerDependencies": { + "@angular/core": ">=12.0.0", + "@ng-web-apis/common": ">=2.0.0" + } + }, "node_modules/@ngtools/webpack": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-14.2.3.tgz", @@ -16891,6 +16930,30 @@ "tslib": "^2.0.0" } }, + "@ng-web-apis/common": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-2.0.0.tgz", + "integrity": "sha512-2Vnp4WTEqKZArhbKLgD1JIKjsDa3hWCa67OWaRWRH5sgX5xneVVaIAvC8gVpiCfl2p1Roen2kxfyYngx7G64SQ==", + "requires": { + "tslib": "^2.2.0" + } + }, + "@ng-web-apis/mutation-observer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-2.0.0.tgz", + "integrity": "sha512-f51Cu2DloNze1HaTWdUbtYFnt9VXhzpEnHDd9KFdiKOUNfEDx7wrSXIEQqv810hrq7F2jcIAERCdiqV6ItH7Pg==", + "requires": { + "tslib": "^2.2.0" + } + }, + "@ng-web-apis/resize-observer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-2.0.0.tgz", + "integrity": "sha512-umuXJepTYBCI3ZcW9873fozO0qt1PeHLBNM+wXA+7Wphy35+RQcPNmkwfgkKqWceIjlYAvyuPTNWa5TM1OEeqg==", + "requires": { + "tslib": "^2.2.0" + } + }, "@ngtools/webpack": { "version": "14.2.3", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-14.2.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index b28da8090..9d13d7ac6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,9 @@ "@angular/platform-browser-dynamic": "^14.1.0", "@angular/router": "^14.1.0", "@ionic/angular": "^6.1.15", + "@ng-web-apis/common": "^2.0.0", + "@ng-web-apis/mutation-observer": "^2.0.0", + "@ng-web-apis/resize-observer": "^2.0.0", "@materia-ui/ngx-monaco-editor": "^6.0.0", "@start9labs/argon2": "^0.1.0", "@start9labs/emver": "^0.1.5", diff --git a/frontend/projects/shared/package.json b/frontend/projects/shared/package.json index 3ccefeee3..22ded4fb3 100644 --- a/frontend/projects/shared/package.json +++ b/frontend/projects/shared/package.json @@ -6,6 +6,8 @@ "@angular/core": ">=13.2.0", "@angular/router": ">=13.2.0", "@ionic/angular": ">=6.0.0", + "@ng-web-apis/mutation-observer": ">=2.0.0", + "@ng-web-apis/resize-observer": ">=2.0.0", "@start9labs/emver": "^0.1.5" }, "exports": { diff --git a/frontend/projects/shared/src/components/elastic-container/elastic-container.component.html b/frontend/projects/shared/src/components/elastic-container/elastic-container.component.html new file mode 100644 index 000000000..c587915cf --- /dev/null +++ b/frontend/projects/shared/src/components/elastic-container/elastic-container.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/frontend/projects/shared/src/components/elastic-container/elastic-container.component.scss b/frontend/projects/shared/src/components/elastic-container/elastic-container.component.scss new file mode 100644 index 000000000..d60e43bb0 --- /dev/null +++ b/frontend/projects/shared/src/components/elastic-container/elastic-container.component.scss @@ -0,0 +1,5 @@ +:host { + display: block; + overflow: hidden; + transition: height 0.25s; +} diff --git a/frontend/projects/shared/src/components/elastic-container/elastic-container.component.ts b/frontend/projects/shared/src/components/elastic-container/elastic-container.component.ts new file mode 100644 index 000000000..5e3e9f7bf --- /dev/null +++ b/frontend/projects/shared/src/components/elastic-container/elastic-container.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component, HostBinding } from '@angular/core' + +@Component({ + selector: 'elastic-container', + templateUrl: './elastic-container.component.html', + styleUrls: ['./elastic-container.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ElasticContainerComponent { + @HostBinding('style.height.px') + height = NaN +} diff --git a/frontend/projects/shared/src/components/elastic-container/elastic-container.directive.ts b/frontend/projects/shared/src/components/elastic-container/elastic-container.directive.ts new file mode 100644 index 000000000..e12770493 --- /dev/null +++ b/frontend/projects/shared/src/components/elastic-container/elastic-container.directive.ts @@ -0,0 +1,35 @@ +import { Directive, ElementRef, inject, Output } from '@angular/core' +import { ResizeObserverService } from '@ng-web-apis/resize-observer' +import { + MUTATION_OBSERVER_INIT, + MutationObserverService, +} from '@ng-web-apis/mutation-observer' +import { distinctUntilChanged, map, merge } from 'rxjs' + +@Directive({ + selector: '[elasticContainer]', + providers: [ + ResizeObserverService, + MutationObserverService, + { + provide: MUTATION_OBSERVER_INIT, + useValue: { + childList: true, + characterData: true, + subtree: true, + }, + }, + ], +}) +export class ElasticContainerDirective { + private readonly elementRef = inject(ElementRef) + + @Output() + readonly elasticContainer = merge( + inject(ResizeObserverService), + inject(MutationObserverService), + ).pipe( + map(() => this.elementRef.nativeElement.clientHeight), + distinctUntilChanged(), + ) +} diff --git a/frontend/projects/shared/src/components/elastic-container/elastic-container.module.ts b/frontend/projects/shared/src/components/elastic-container/elastic-container.module.ts new file mode 100644 index 000000000..a44145458 --- /dev/null +++ b/frontend/projects/shared/src/components/elastic-container/elastic-container.module.ts @@ -0,0 +1,10 @@ +import { NgModule } from '@angular/core' + +import { ElasticContainerComponent } from './elastic-container.component' +import { ElasticContainerDirective } from './elastic-container.directive' + +@NgModule({ + declarations: [ElasticContainerComponent, ElasticContainerDirective], + exports: [ElasticContainerComponent], +}) +export class ElasticContainerModule {} diff --git a/frontend/projects/shared/src/public-api.ts b/frontend/projects/shared/src/public-api.ts index 311cbc4bf..4f1ccc7bc 100644 --- a/frontend/projects/shared/src/public-api.ts +++ b/frontend/projects/shared/src/public-api.ts @@ -9,6 +9,9 @@ export * from './components/alert/alert.component' export * from './components/alert/alert.module' export * from './components/alert/alert-button.directive' export * from './components/alert/alert-input.directive' +export * from './components/elastic-container/elastic-container.component' +export * from './components/elastic-container/elastic-container.directive' +export * from './components/elastic-container/elastic-container.module' export * from './components/markdown/markdown.component' export * from './components/markdown/markdown.component.module' export * from './components/text-spinner/text-spinner.component' diff --git a/frontend/projects/ui/src/app/components/form-object/form-error.component.html b/frontend/projects/ui/src/app/components/form-object/form-error.component.html deleted file mode 100644 index 9321fed43..000000000 --- a/frontend/projects/ui/src/app/components/form-object/form-error.component.html +++ /dev/null @@ -1,35 +0,0 @@ -
- -

{{ spec.name }} is required

- - -

- {{ $any(spec)['pattern-description'] }} -

- - - -

- {{ spec.name }} must be an integer -

-

- {{ control.errors?.['numberNotInRange']?.value }} -

-

- {{ spec.name }} must be a number -

-
- - - -

- {{ control.errors?.['listNotInRange']?.value }} -

-

- {{ control.errors?.['listNotUnique']?.value }} -

-

- {{ control.errors?.['listItemIssue']?.value }} -

-
-
diff --git a/frontend/projects/ui/src/app/components/form-object/form-label.component.html b/frontend/projects/ui/src/app/components/form-object/form-label.component.html index e8d8375b6..0827348ad 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-label.component.html +++ b/frontend/projects/ui/src/app/components/form-object/form-label.component.html @@ -1,5 +1,5 @@ -{{ data.spec.name }} +{{ data.name }}  (New)  (New Options)  (Edited) - -  * - - - * +  * diff --git a/frontend/projects/ui/src/app/components/form-object/form-object.component.html b/frontend/projects/ui/src/app/components/form-object/form-object.component.html index 0c1763a9a..8cc2afadd 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object.component.html +++ b/frontend/projects/ui/src/app/components/form-object/form-object.component.html @@ -1,66 +1,21 @@
- - - - - - - - - {{ unionSpec.tag.name }} - - - - - - {{ unionSpec.tag['variant-names'][option] }} - - - - -
+
-

+

- + {{ spec.units }} - - +

+ + {{ errors | getError: $any(spec)['pattern-description'] }} + +

- - + +
-
+ + - + - - +

+ + {{ errors | getError }} + +

- + - + - +
+
- - - - +

+ + {{ errors | getError: $any(spec)['pattern-description'] }} + +

+
@@ -402,9 +351,9 @@

-

{{ getEnumListDisplay(formArr.value, $any(spec.spec)) }}

+

{{ formArr.value | toEnumListDisplay: $any(spec.spec) }}

- - +

+ + {{ errors | getError }} + +

diff --git a/frontend/projects/ui/src/app/components/form-object/form-object.component.module.ts b/frontend/projects/ui/src/app/components/form-object/form-object.component.module.ts index fde102b3d..715b03aca 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object.component.module.ts +++ b/frontend/projects/ui/src/app/components/form-object/form-object.component.module.ts @@ -2,16 +2,34 @@ import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' import { FormObjectComponent, + FormUnionComponent, FormLabelComponent, - FormErrorComponent, } from './form-object.component' +import { + GetErrorPipe, + ToWarningTextPipe, + ToElementIdPipe, + GetControlPipe, + ToEnumListDisplayPipe, + ToRangePipe, +} from './form-object.pipes' import { IonicModule } from '@ionic/angular' import { FormsModule, ReactiveFormsModule } from '@angular/forms' -import { SharedPipesModule } from '@start9labs/shared' +import { ElasticContainerModule, SharedPipesModule } from '@start9labs/shared' import { EnumListPageModule } from 'src/app/modals/enum-list/enum-list.module' @NgModule({ - declarations: [FormObjectComponent, FormLabelComponent, FormErrorComponent], + declarations: [ + FormObjectComponent, + FormUnionComponent, + FormLabelComponent, + ToWarningTextPipe, + GetErrorPipe, + ToEnumListDisplayPipe, + ToElementIdPipe, + GetControlPipe, + ToRangePipe, + ], imports: [ CommonModule, IonicModule, @@ -19,7 +37,8 @@ import { EnumListPageModule } from 'src/app/modals/enum-list/enum-list.module' ReactiveFormsModule, SharedPipesModule, EnumListPageModule, + ElasticContainerModule, ], - exports: [FormObjectComponent, FormLabelComponent, FormErrorComponent], + exports: [FormObjectComponent, FormLabelComponent], }) export class FormObjectComponentModule {} diff --git a/frontend/projects/ui/src/app/components/form-object/form-object.component.scss b/frontend/projects/ui/src/app/components/form-object/form-object.component.scss index 4808421a1..a98a0ff72 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object.component.scss +++ b/frontend/projects/ui/src/app/components/form-object/form-object.component.scss @@ -31,16 +31,10 @@ ion-item-divider { padding: 0 0 16px 24px; } -.validation-error { - opacity: 1; -} - .error-message { - p { - margin-bottom: 4px; - font-size: small; - color: var(--ion-color-danger); - } + margin-top: 2px; + font-size: small; + color: var(--ion-color-danger); } .indent { diff --git a/frontend/projects/ui/src/app/components/form-object/form-object.component.ts b/frontend/projects/ui/src/app/components/form-object/form-object.component.ts index 48559432c..de10c60fb 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object.component.ts +++ b/frontend/projects/ui/src/app/components/form-object/form-object.component.ts @@ -1,30 +1,28 @@ -import { Component, Input, Output, EventEmitter } from '@angular/core' import { - AbstractFormGroupDirective, - FormArray, - UntypedFormArray, - UntypedFormGroup, -} from '@angular/forms' -import { - AlertButton, - AlertController, - IonicSafeString, - ModalController, -} from '@ionic/angular' + Component, + Input, + Output, + EventEmitter, + ChangeDetectionStrategy, + Inject, +} from '@angular/core' +import { FormArray, UntypedFormArray, UntypedFormGroup } from '@angular/forms' +import { AlertButton, AlertController, ModalController } from '@ionic/angular' import { ConfigSpec, ListValueSpecOf, ValueSpec, ValueSpecBoolean, + ValueSpecEnum, ValueSpecList, ValueSpecListOf, ValueSpecUnion, } from 'src/app/pkg-config/config-types' import { FormService } from 'src/app/services/form.service' -import { Range } from 'src/app/pkg-config/config-utilities' import { EnumListPage } from 'src/app/modals/enum-list/enum-list.page' import { pauseFor } from '@start9labs/shared' import { v4 } from 'uuid' +import { DOCUMENT } from '@angular/common' const Mustache = require('mustache') interface Config { @@ -38,11 +36,10 @@ interface Config { export class FormObjectComponent { @Input() objectSpec!: ConfigSpec @Input() formGroup!: UntypedFormGroup - @Input() unionSpec?: ValueSpecUnion @Input() current?: Config @Input() original?: Config @Output() onInputChange = new EventEmitter() - @Output() onExpand = new EventEmitter() + @Output() onResize = new EventEmitter() @Output() hasNewOptions = new EventEmitter() warningAck: { [key: string]: boolean } = {} unmasked: { [key: string]: boolean } = {} @@ -52,14 +49,13 @@ export class FormObjectComponent { objectListDisplay: { [key: string]: { expanded: boolean; height: string; displayAs: string }[] } = {} - private objectId = v4() - - Object = Object + objectId = v4() constructor( private readonly alertCtrl: AlertController, private readonly modalCtrl: ModalController, private readonly formService: FormService, + @Inject(DOCUMENT) private readonly document: Document, ) {} ngOnInit() { @@ -80,7 +76,7 @@ export class FormObjectComponent { : '', } }) - } else if (['object', 'union'].includes(spec.type)) { + } else if (spec.type === 'object') { this.objectDisplay[key] = { expanded: false, height: '0px', @@ -101,64 +97,30 @@ export class FormObjectComponent { }, 10) } - getEnumListDisplay(arr: string[], spec: ListValueSpecOf<'enum'>): string { - return arr.map((v: string) => spec['value-names'][v]).join(', ') - } - - updateUnion(e: any): void { - const id = this.unionSpec?.tag.id - - Object.keys(this.formGroup.controls).forEach(control => { - if (control === id) return - this.formGroup.removeControl(control) - }) - - const unionGroup = this.formService.getUnionObject( - this.unionSpec as ValueSpecUnion, - e.detail.value, - ) - - Object.keys(unionGroup.controls).forEach(control => { - if (control === id) return - this.formGroup.addControl(control, unionGroup.controls[control]) - }) - - Object.entries(this.unionSpec?.variants[e.detail.value] || {}).forEach( - ([key, value]) => { - if (['object', 'union'].includes(value.type)) { - this.objectDisplay[key] = { - expanded: false, - height: '0px', - hasNewOptions: false, - } - } - }, - ) - - this.onExpand.emit() - } - resize(key: string, i?: number): void { setTimeout(() => { if (i !== undefined) { - this.objectListDisplay[key][i].height = this.getDocSize(key, i) + this.objectListDisplay[key][i].height = this.getScrollHeight(key, i) } else { - this.objectDisplay[key].height = this.getDocSize(key) + this.objectDisplay[key].height = this.getScrollHeight(key) } - this.onExpand.emit() - }, 250) // 250 to match transition-duration, defined in html + this.onResize.emit() + }, 250) // 250 to match transition-duration defined in html, for smooth recursive resize } - addListItemWrapper(key: string, spec: ValueSpec) { + addListItemWrapper( + key: string, + spec: T extends ValueSpecUnion ? never : T, + ) { this.presentAlertChangeWarning(key, spec, () => this.addListItem(key)) } toggleExpandObject(key: string) { this.objectDisplay[key].expanded = !this.objectDisplay[key].expanded this.objectDisplay[key].height = this.objectDisplay[key].expanded - ? this.getDocSize(key) + ? this.getScrollHeight(key) : '0px' - this.onExpand.emit() + this.onResize.emit() } toggleExpandListObject(key: string, i: number) { @@ -166,9 +128,9 @@ export class FormObjectComponent { !this.objectListDisplay[key][i].expanded this.objectListDisplay[key][i].height = this.objectListDisplay[key][i] .expanded - ? this.getDocSize(key, i) + ? this.getScrollHeight(key, i) : '0px' - this.onExpand.emit() + this.onResize.emit() } updateLabel(key: string, i: number, displayAs: string) { @@ -177,12 +139,6 @@ export class FormObjectComponent { : '' } - getWarningText(text: string = ''): IonicSafeString | string { - return text - ? new IonicSafeString(`${text}`) - : '' - } - handleInputChange() { this.onInputChange.emit() } @@ -224,9 +180,9 @@ export class FormObjectComponent { await modal.present() } - async presentAlertChangeWarning( + async presentAlertChangeWarning( key: string, - spec: ValueSpec, + spec: T extends ValueSpecUnion ? never : T, okFn?: Function, cancelFn?: Function, ) { @@ -282,7 +238,10 @@ export class FormObjectComponent { await alert.present() } - async presentAlertDescription(event: Event, spec: ValueSpec) { + async presentAlertBoolEnumDescription( + event: Event, + spec: ValueSpecBoolean | ValueSpecEnum, + ) { event.stopPropagation() const { name, description } = spec @@ -318,15 +277,18 @@ export class FormObjectComponent { }) } - this.onExpand.emit() - - pauseFor(400).then(() => { - const element = document.getElementById(this.getElementId(key, index)) + setTimeout(() => { + const element = this.document.getElementById( + getElementId(this.objectId, key, index), + ) element?.parentElement?.scrollIntoView({ behavior: 'smooth' }) - }) + + if (['object', 'union'].includes(listSpec.subtype)) { + pauseFor(250).then(() => this.toggleExpandListObject(key, index)) + } + }, 100) arr.markAsDirty() - newItem.markAllAsTouched() } private deleteListItem(key: string, index: number, markDirty = true): void { @@ -360,57 +322,106 @@ export class FormObjectComponent { }) arr.markAsDirty() - arr.markAllAsTouched() } - private getDocSize(key: string, index = 0): string { - const element = document.getElementById(this.getElementId(key, index)) + private getScrollHeight(key: string, index = 0): string { + const element = this.document.getElementById( + getElementId(this.objectId, key, index), + ) return `${element?.scrollHeight}px` } - getElementId(key: string, index = 0): string { - return `${key}-${index}-${this.objectId}` - } - - async presentUnionTagDescription( - event: Event, - name: string, - description: string, - ) { - event.stopPropagation() - const alert = await this.alertCtrl.create({ - header: name, - message: description, - }) - await alert.present() - } - asIsOrder() { return 0 } } -interface HeaderData { - spec: ValueSpec - edited: boolean - new: boolean - newOptions?: boolean +@Component({ + selector: 'form-union', + templateUrl: './form-union.component.html', + styleUrls: ['./form-object.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FormUnionComponent { + @Input() formGroup!: UntypedFormGroup + @Input() spec!: ValueSpecUnion + @Input() current?: Config + @Input() original?: Config + + @Output() onResize = new EventEmitter() + + get unionValue() { + return this.formGroup.get(this.spec.tag.id)?.value + } + + get isNew() { + return !this.original + } + + get hasNewOptions() { + const tagId = this.spec.tag.id + return ( + this.original?.[tagId] === this.current?.[tagId] && + !!Object.keys(this.current || {}).find( + key => this.original![key] === undefined, + ) + ) + } + + objectId = v4() + + constructor( + private readonly formService: FormService, + @Inject(DOCUMENT) private readonly document: Document, + ) {} + + updateUnion(e: any): void { + const tagId = this.spec.tag.id + + Object.keys(this.formGroup.controls).forEach(control => { + if (control === tagId) return + this.formGroup.removeControl(control) + }) + + const unionGroup = this.formService.getUnionObject( + this.spec as ValueSpecUnion, + e.detail.value, + ) + + Object.keys(unionGroup.controls).forEach(control => { + if (control === tagId) return + this.formGroup.addControl(control, unionGroup.controls[control]) + }) + } + + resize(): void { + setTimeout(() => { + this.onResize.emit() + }, 250) // 250 to match transition-duration, defined in html + } } @Component({ selector: 'form-label', templateUrl: './form-label.component.html', styleUrls: ['./form-object.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class FormLabelComponent { - Range = Range - @Input() data!: HeaderData + @Input() data!: { + name: string + new: boolean + edited: boolean + description?: string + required?: boolean + newOptions?: boolean + } constructor(private readonly alertCtrl: AlertController) {} async presentAlertDescription(event: Event) { event.stopPropagation() - const { name, description } = this.data.spec + const { name, description } = this.data const alert = await this.alertCtrl.create({ header: name, @@ -426,12 +437,6 @@ export class FormLabelComponent { } } -@Component({ - selector: 'form-error', - templateUrl: './form-error.component.html', - styleUrls: ['./form-object.component.scss'], -}) -export class FormErrorComponent { - @Input() control!: AbstractFormGroupDirective - @Input() spec!: ValueSpec +export function getElementId(objectId: string, key: string, index = 0): string { + return `${key}-${index}-${objectId}` } diff --git a/frontend/projects/ui/src/app/components/form-object/form-object.pipes.ts b/frontend/projects/ui/src/app/components/form-object/form-object.pipes.ts new file mode 100644 index 000000000..1dc5a18f2 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form-object/form-object.pipes.ts @@ -0,0 +1,92 @@ +import { Pipe, PipeTransform } from '@angular/core' +import { + AbstractControl, + FormGroup, + UntypedFormArray, + ValidationErrors, +} from '@angular/forms' +import { IonicSafeString } from '@ionic/angular' +import { ListValueSpecOf } from 'src/app/pkg-config/config-types' +import { Range } from 'src/app/pkg-config/config-utilities' +import { getElementId } from './form-object.component' + +@Pipe({ + name: 'getError', +}) +export class GetErrorPipe implements PipeTransform { + transform(errors: ValidationErrors, patternDesc?: string): string { + if (errors['required']) { + return 'Required' + } else if (errors['pattern']) { + return patternDesc || 'Invalid pattern' + } else if (errors['notNumber']) { + return 'Must be a number' + } else if (errors['numberNotInteger']) { + return 'Must be an integer' + } else if (errors['numberNotInRange']) { + return errors['numberNotInRange'].value + } else if (errors['listNotUnique']) { + return errors['listNotUnique'].value + } else if (errors['listNotInRange']) { + return errors['listNotInRange'].value + } else if (errors['listItemIssue']) { + return errors['listItemIssue'].value + } else { + return 'Unknown error' + } + } +} + +@Pipe({ + name: 'toEnumListDisplay', +}) +export class ToEnumListDisplayPipe implements PipeTransform { + transform(arr: string[], spec: ListValueSpecOf<'enum'>): string { + return arr.map((v: string) => spec['value-names'][v]).join(', ') + } +} + +@Pipe({ + name: 'toWarningText', +}) +export class ToWarningTextPipe implements PipeTransform { + transform(text?: string): IonicSafeString | string { + return text + ? new IonicSafeString(`${text}`) + : '' + } +} + +@Pipe({ + name: 'toRange', +}) +export class ToRangePipe implements PipeTransform { + transform(range: string): Range { + return Range.from(range) + } +} + +@Pipe({ + name: 'toElementId', +}) +export class ToElementIdPipe implements PipeTransform { + transform(objectId: string, key: string, index = 0): string { + return getElementId(objectId, key, index) + } +} + +@Pipe({ + name: 'getControl', +}) +export class GetControlPipe implements PipeTransform { + transform( + formGroup: FormGroup, + key: string, + index?: number, + ): AbstractControl { + const abstractControl = formGroup.get(key)! + if (index !== undefined) + return (abstractControl as UntypedFormArray).at(index) + return abstractControl + } +} diff --git a/frontend/projects/ui/src/app/components/form-object/form-union.component.html b/frontend/projects/ui/src/app/components/form-object/form-union.component.html new file mode 100644 index 000000000..e0224f735 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form-object/form-union.component.html @@ -0,0 +1,43 @@ +
+ + + + + + + {{ spec.tag['variant-names'][option.key] }} + + + + + + + +
diff --git a/frontend/projects/ui/src/app/modals/app-config/app-config.page.html b/frontend/projects/ui/src/app/modals/app-config/app-config.page.html index 03a9f3257..6772e9f6d 100644 --- a/frontend/projects/ui/src/app/modals/app-config/app-config.page.html +++ b/frontend/projects/ui/src/app/modals/app-config/app-config.page.html @@ -80,8 +80,8 @@ - - + +

No config options for {{ pkg.manifest.title }} {{ @@ -111,7 +111,11 @@ - + Reset Defaults diff --git a/frontend/projects/ui/src/app/modals/app-config/app-config.page.ts b/frontend/projects/ui/src/app/modals/app-config/app-config.page.ts index 3e750ce1d..35388b36c 100644 --- a/frontend/projects/ui/src/app/modals/app-config/app-config.page.ts +++ b/frontend/projects/ui/src/app/modals/app-config/app-config.page.ts @@ -53,6 +53,8 @@ export class AppConfigPage { saving = false loadingError: string | IonicSafeString = '' + hasOptions = false + constructor( private readonly embassyApi: ApiService, private readonly errToast: ErrorToastService, @@ -101,7 +103,10 @@ export class AppConfigPage { this.configSpec, newConfig || this.original, ) - this.configForm.markAllAsTouched() + + this.hasOptions = !!Object.values(this.configSpec).find( + valSpec => valSpec.type !== 'pointer', + ) if (patch) { this.diff = this.getDiff(patch) diff --git a/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts b/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts index 4fe4abdf5..eb690a787 100644 --- a/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts +++ b/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts @@ -48,7 +48,6 @@ export class GenericFormPage { convertValuesRecursive(this.spec, this.formGroup) if (this.formGroup.invalid) { - this.formGroup.markAllAsTouched() document .getElementsByClassName('validation-error')[0] ?.scrollIntoView({ behavior: 'smooth' }) diff --git a/frontend/projects/ui/src/app/pkg-config/config-types.ts b/frontend/projects/ui/src/app/pkg-config/config-types.ts index 22ebd7089..08f0b9d26 100644 --- a/frontend/projects/ui/src/app/pkg-config/config-types.ts +++ b/frontend/projects/ui/src/app/pkg-config/config-types.ts @@ -53,7 +53,7 @@ export interface ValueSpecBoolean extends WithStandalone { default: boolean } -export interface ValueSpecUnion extends WithStandalone { +export interface ValueSpecUnion { type: 'union' tag: UnionTagSpec variants: { [key: string]: ConfigSpec } @@ -159,12 +159,13 @@ export interface ListValueSpecUnion { export interface UnionTagSpec { id: string // The name of the field containing one of the union variants - name: string - description?: string 'variant-names': { // the name of each variant [variant: string]: string } + name: string + description?: string + warning?: string } export type DefaultString = string | { charset: string; len: number } diff --git a/frontend/projects/ui/src/app/services/api/api.fixures.ts b/frontend/projects/ui/src/app/services/api/api.fixures.ts index fca5a6d6e..c627943fd 100644 --- a/frontend/projects/ui/src/app/services/api/api.fixures.ts +++ b/frontend/projects/ui/src/app/services/api/api.fixures.ts @@ -291,18 +291,17 @@ export module Mock { }, }, bitcoinNode: { - name: 'Bitcoin Node Settings', type: 'union', - description: 'The node settings', default: 'internal', - warning: 'Careful changing this', tag: { id: 'type', - name: 'Type', 'variant-names': { internal: 'Internal', external: 'External', }, + name: 'Bitcoin Node Settings', + description: 'The node settings', + warning: 'Careful changing this', }, variants: { internal: { @@ -1249,12 +1248,12 @@ export module Mock { spec: { tag: { id: 'preference', - name: 'Preferences', 'variant-names': { summer: 'Summer', winter: 'Winter', other: 'Other', }, + name: 'Preference', }, // this default is used to make a union selection when a new list element is first created default: 'summer', @@ -1425,18 +1424,17 @@ export module Mock { }, }, 'bitcoin-node': { - name: 'Bitcoin Node Settings', type: 'union', - description: 'Options

  • Item 1
  • Item 2
', default: 'internal', - warning: 'Careful changing this', tag: { id: 'type', - name: 'Type', 'variant-names': { internal: 'Internal', external: 'External', }, + name: 'Bitcoin Node Settings', + description: 'Options
  • Item 1
  • Item 2
', + warning: 'Careful changing this', }, variants: { internal: { diff --git a/frontend/projects/ui/src/app/services/api/mock-patch.ts b/frontend/projects/ui/src/app/services/api/mock-patch.ts index 1cc63b60d..093b9e2bd 100644 --- a/frontend/projects/ui/src/app/services/api/mock-patch.ts +++ b/frontend/projects/ui/src/app/services/api/mock-patch.ts @@ -345,18 +345,17 @@ export const mockPatchData: DataModel = { }, }, bitcoinNode: { - name: 'Bitcoin Node Settings', type: 'union', - description: 'The node settings', default: 'internal', - warning: 'Careful changing this', tag: { id: 'type', - name: 'Type', 'variant-names': { internal: 'Internal', external: 'External', }, + name: 'Bitcoin Node Settings', + description: 'The node settings', + warning: 'Careful changing this', }, variants: { internal: { diff --git a/frontend/projects/ui/src/app/services/form.service.ts b/frontend/projects/ui/src/app/services/form.service.ts index dd861a348..66d368b50 100644 --- a/frontend/projects/ui/src/app/services/form.service.ts +++ b/frontend/projects/ui/src/app/services/form.service.ts @@ -46,9 +46,7 @@ export class FormService { current?: { [key: string]: any } | null, ): UntypedFormGroup { const { variants, tag } = spec - const { name, description, warning } = isFullUnion(spec) - ? spec - : { ...spec.tag, warning: undefined } + const { name, description, warning, 'variant-names': variantNames } = tag const enumSpec: ValueSpecEnum = { type: 'enum', @@ -57,7 +55,7 @@ export class FormService { warning, default: selection, values: Object.keys(variants), - 'value-names': tag['variant-names'], + 'value-names': variantNames, } return this.getFormGroup( { [spec.tag.id]: enumSpec, ...spec.variants[selection] }, @@ -207,12 +205,6 @@ function listValidators(spec: ValueSpecList): ValidatorFn[] { return validators } -function isFullUnion( - spec: ValueSpecUnion | ListValueSpecUnion, -): spec is ValueSpecUnion { - return !!(spec as ValueSpecUnion).name -} - export function numberInRange(stringRange: string): ValidatorFn { return control => { const value = control.value @@ -461,15 +453,18 @@ function uniqueByMessageWrapper( ) { let configSpec: ConfigSpec if (isUnion(spec)) { - const variantKey = obj[spec.tag.id] - configSpec = spec.variants[variantKey] + const tagId = spec.tag.id + configSpec = { + [tagId]: { name: spec.tag.name } as ValueSpec, + ...spec.variants[obj[tagId]], + } } else { configSpec = spec.spec } const message = uniqueByMessage(uniqueBy, configSpec) if (message) { - return ' Must be unique by: ' + message + '.' + return ' Must be unique by: ' + message } } @@ -483,7 +478,9 @@ function uniqueByMessage( if (uniqueBy === null) { return '' } else if (typeof uniqueBy === 'string') { - return configSpec[uniqueBy] ? configSpec[uniqueBy].name : uniqueBy + return configSpec[uniqueBy] + ? (configSpec[uniqueBy] as ValueSpecObject).name + : uniqueBy } else if ('any' in uniqueBy) { joinFunc = ' OR ' for (let subSpec of uniqueBy.any) {