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 <alexander@inkin.ru>
This commit is contained in:
Matt Hill
2023-01-24 12:16:50 -07:00
committed by Aiden McClelland
parent c16404bb2d
commit d223ac4675
24 changed files with 545 additions and 351 deletions

View File

@@ -1,35 +0,0 @@
<div [hidden]="!control.dirty && !control.touched" class="error-message">
<!-- primitive -->
<p *ngIf="control.hasError('required')">{{ spec.name }} is required</p>
<!-- string -->
<p *ngIf="control.hasError('pattern')">
{{ $any(spec)['pattern-description'] }}
</p>
<!-- number -->
<ng-container *ngIf="spec.type === 'number'">
<p *ngIf="control.hasError('numberNotInteger')">
{{ spec.name }} must be an integer
</p>
<p *ngIf="control.hasError('numberNotInRange')">
{{ control.errors?.['numberNotInRange']?.value }}
</p>
<p *ngIf="control.hasError('notNumber')">
{{ spec.name }} must be a number
</p>
</ng-container>
<!-- list -->
<ng-container *ngIf="spec.type === 'list'">
<p *ngIf="control.hasError('listNotInRange')">
{{ control.errors?.['listNotInRange']?.value }}
</p>
<p *ngIf="control.hasError('listNotUnique')">
{{ control.errors?.['listNotUnique']?.value }}
</p>
<p *ngIf="control.hasError('listItemIssue')">
{{ control.errors?.['listItemIssue']?.value }}
</p>
</ng-container>
</div>

View File

@@ -1,5 +1,5 @@
<ion-button
*ngIf="data.spec.description"
*ngIf="data.description"
class="slot-start"
fill="clear"
(click)="presentAlertDescription($event)"
@@ -7,21 +7,10 @@
<ion-icon name="help-circle-outline" slot="icon-only" size="small"></ion-icon>
</ion-button>
<span>{{ data.spec.name }}</span>
<span>{{ data.name }}</span>
<ion-text color="success" *ngIf="data.new">&nbsp;(New)</ion-text>
<ion-text color="success" *ngIf="data.newOptions">&nbsp;(New Options)</ion-text>
<ion-text color="warning" *ngIf="data.edited">&nbsp;(Edited)</ion-text>
<span
*ngIf="
(['string', 'number'] | includes: data.spec.type) &&
!$any(data.spec).nullable
"
>
&nbsp;*
</span>
<span *ngIf="data.spec.type === 'list' && Range.from(data.spec.range).min"
>&nbsp;*</span
>
<span *ngIf="data.required"> &nbsp;* </span>

View File

@@ -1,66 +1,21 @@
<ion-item-group [formGroup]="formGroup">
<div *ngFor="let entry of formGroup.controls | keyvalue: asIsOrder">
<!-- union enum -->
<ng-container *ngIf="unionSpec && entry.key === unionSpec.tag.id">
<ion-item>
<ion-button
*ngIf="unionSpec.tag.description"
class="slot-start"
fill="clear"
size="small"
(click)="
presentUnionTagDescription(
$event,
unionSpec.tag.name,
unionSpec.tag.description
)
"
>
<ion-icon name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label>
<ion-text color="dark">
<b>{{ unionSpec.tag.name }}</b>
</ion-text>
</ion-label>
<!-- class enter-click disables the enter click on the modal behind the select -->
<ion-select
[interfaceOptions]="{
message: getWarningText(unionSpec.warning),
cssClass: 'enter-click'
}"
slot="end"
placeholder="Select"
[formControlName]="unionSpec.tag.id"
[selectedText]="unionSpec.tag['variant-names'][entry.value.value]"
(ionChange)="updateUnion($event)"
>
<ion-select-option
*ngFor="let option of Object.keys(unionSpec.variants)"
[value]="option"
>
{{ unionSpec.tag['variant-names'][option] }}
</ion-select-option>
</ion-select>
</ion-item>
</ng-container>
<div *ngIf="objectSpec[entry.key] as spec" [class.indent]="unionSpec">
<div *ngIf="objectSpec[entry.key] as spec">
<!-- string or number -->
<ng-container *ngIf="spec.type === 'string' || spec.type === 'number'">
<!-- label -->
<h4
class="input-label"
[class.validation-error]="formGroup.get(entry.key)?.errors"
>
<h4 class="input-label">
<form-label
[data]="{
spec: spec,
name: spec.name,
description: spec.description,
new: original?.[entry.key] === undefined,
edited: entry.value.dirty
edited: entry.value.dirty,
required: !spec.nullable
}"
></form-label>
</h4>
<ion-item color="dark" class="ion-margin-bottom">
<ion-item color="dark">
<ion-textarea
*ngIf="spec.type === 'string' && spec.textarea; else notTextArea"
[placeholder]="spec.placeholder || 'Enter ' + spec.name"
@@ -107,22 +62,21 @@
>{{ spec.units }}</ion-note
>
</ion-item>
<form-error
*ngIf="formGroup.get(entry.key)?.errors"
[control]="$any(formGroup.get(entry.key))"
[spec]="spec"
>
</form-error>
<p class="error-message">
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
{{ errors | getError: $any(spec)['pattern-description'] }}
</span>
</p>
</ng-container>
<!-- boolean or enum -->
<ion-item
*ngIf="['boolean', 'enum'] | includes: spec.type"
*ngIf="spec.type === 'boolean' || spec.type === 'enum'"
style="--padding-start: 0"
>
<ion-button
*ngIf="spec.description"
fill="clear"
(click)="presentAlertDescription($event, spec)"
(click)="presentAlertBoolEnumDescription($event, spec)"
style="--padding-start: 0"
>
<ion-icon
@@ -157,7 +111,7 @@
<ion-select
*ngIf="spec.type === 'enum' && formGroup.get(entry.key) as control"
[interfaceOptions]="{
message: getWarningText(spec.warning),
message: spec.warning | toWarningText,
cssClass: 'enter-click'
}"
slot="end"
@@ -173,21 +127,21 @@
</ion-select-option>
</ion-select>
</ion-item>
<!-- object or union -->
<ng-container *ngIf="spec.type === 'object' || spec.type === 'union'">
<!-- object -->
<ng-container *ngIf="spec.type === 'object'">
<!-- label -->
<ion-item-divider
(click)="toggleExpandObject(entry.key)"
style="cursor: pointer"
[class.error-border]="entry.value.invalid"
[class.validation-error]="entry.value.invalid"
>
<form-label
[data]="{
spec: spec,
name: spec.name,
description: spec.description,
new: original?.[entry.key] === undefined,
newOptions: objectDisplay[entry.key].hasNewOptions,
edited: entry.value.dirty
edited: entry.value.dirty,
newOptions: objectDisplay[entry.key].hasNewOptions
}"
></form-label>
<ion-icon
@@ -198,45 +152,40 @@
transform: objectDisplay[entry.key].expanded
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.25s ease-out'
transition: 'transform 0.42s ease-out'
}"
></ion-icon>
</ion-item-divider>
<!-- body -->
<div
[id]="getElementId(entry.key)"
[id]="objectId | toElementId: entry.key"
[ngStyle]="{
'max-height': objectDisplay[entry.key].height,
overflow: 'hidden',
'transition-property': 'max-height',
'transition-duration': '.25s'
'transition-duration': '.42s'
}"
>
<div class="nested-wrapper">
<form-object
*ngIf="spec.type === 'object'"
[objectSpec]="spec.spec"
[formGroup]="$any(entry.value)"
[current]="current?.[entry.key]"
[original]="original?.[entry.key]"
(onExpand)="resize(entry.key)"
(hasNewOptions)="setHasNew(entry.key)"
></form-object>
<form-object
*ngIf="spec.type === 'union'"
[objectSpec]="
spec.variants[$any(entry.value).controls[spec.tag.id].value]
"
[formGroup]="$any(entry.value)"
[current]="current?.[entry.key]"
[original]="original?.[entry.key][spec.tag.id] === current?.[entry.key][spec.tag.id] ? original?.[entry.key] : undefined"
[unionSpec]="spec"
(onExpand)="resize(entry.key)"
(onResize)="resize(entry.key)"
(hasNewOptions)="setHasNew(entry.key)"
></form-object>
</div>
</div>
</ng-container>
<!-- union -->
<form-union
*ngIf="spec.type === 'union'"
[spec]="spec"
[formGroup]="$any(entry.value)"
[current]="current?.[entry.key]"
[original]="original?.[entry.key]"
></form-union>
<!-- list (not enum) -->
<ng-container *ngIf="spec.type === 'list' && spec.subtype !== 'enum'">
<ng-container
@@ -244,15 +193,14 @@
[formArrayName]="entry.key"
>
<!-- label -->
<ion-item-divider
[class.error-border]="entry.value.invalid"
[class.validation-error]="entry.value.invalid"
>
<ion-item-divider [class.error-border]="entry.value.invalid">
<form-label
[data]="{
spec: spec,
name: spec.name,
description: spec.description,
new: original?.[entry.key] === undefined,
edited: entry.value.dirty
edited: entry.value.dirty,
required: !!(spec.range | toRange).min
}"
></form-label>
<ion-button
@@ -266,12 +214,11 @@
Add
</ion-button>
</ion-item-divider>
<form-error
*ngIf="formGroup.get(entry.key)?.errors"
[control]="$any(formGroup.get(entry.key))"
[spec]="spec"
>
</form-error>
<p class="error-message" style="margin-bottom: 8px">
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
{{ errors | getError }}
</span>
</p>
<!-- body -->
<div class="nested-wrapper">
<div
@@ -279,13 +226,12 @@
let abstractControl of $any(formArr).controls;
let i = index
"
class="ion-padding-top"
>
<!-- nested -->
<!-- object or union -->
<ng-container
*ngIf="spec.subtype === 'object' || spec.subtype === 'union'"
>
<!-- nested label -->
<!-- object/union label -->
<ion-item
button
(click)="toggleExpandListObject(entry.key, i)"
@@ -293,11 +239,9 @@
>
<form-label
[data]="{
spec: $any({
name:
objectListDisplay[entry.key][i].displayAs ||
'Entry ' + (i + 1)
}),
name:
objectListDisplay[entry.key][i].displayAs ||
'Entry ' + (i + 1),
new: false,
edited: abstractControl.dirty
}"
@@ -310,41 +254,43 @@
transform: objectListDisplay[entry.key][i].expanded
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.25s ease-out'
transition: 'transform 0.42s ease-out'
}"
></ion-icon>
</ion-item>
<!-- nested body -->
<!-- object/union body -->
<div
style="padding-left: 24px"
[id]="getElementId(entry.key, i)"
[id]="objectId | toElementId: entry.key:i"
[ngStyle]="{
'max-height': objectListDisplay[entry.key][i].height,
overflow: 'hidden',
'transition-property': 'max-height',
'transition-duration': '.25s'
'transition-duration': '.42s'
}"
>
<form-object
[objectSpec]="
spec.subtype === 'union'
? $any(spec.spec).variants[
abstractControl.controls[$any(spec.spec).tag.id]
.value
]
: $any(spec.spec).spec
"
*ngIf="spec.subtype === 'object'"
[objectSpec]="$any(spec.spec).spec"
[formGroup]="abstractControl"
[current]="current?.[entry.key]?.[i]"
[original]="original?.[entry.key]?.[i]"
[unionSpec]="
spec.subtype === 'union' ? $any(spec.spec) : undefined
"
(onInputChange)="
updateLabel(entry.key, i, $any(spec.spec)['display-as'])
"
(onExpand)="resize(entry.key, i)"
(onResize)="resize(entry.key, i)"
></form-object>
<form-union
*ngIf="spec.subtype === 'union'"
[spec]="$any(spec.spec)"
[formGroup]="abstractControl"
[current]="current?.[entry.key]?.[i]"
[original]="original?.[entry.key]?.[i]"
(onInputChange)="
updateLabel(entry.key, i, $any(spec.spec)['display-as'])
"
(onResize)="resize(entry.key, i)"
></form-union>
<div style="text-align: right; padding-top: 12px">
<ion-button
fill="clear"
@@ -358,9 +304,9 @@
</div>
</ng-container>
<!-- string or number -->
<ion-item-group
[id]="getElementId(entry.key, i)"
<div
*ngIf="spec.subtype === 'string' || spec.subtype === 'number'"
[id]="objectId | toElementId: entry.key:i"
>
<ion-item color="dark">
<ion-input
@@ -382,13 +328,16 @@
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-item>
<form-error
*ngIf="abstractControl.errors"
[control]="abstractControl"
[spec]="$any(spec.spec)"
>
</form-error>
</ion-item-group>
<p class="error-message">
<span
*ngIf="
(formGroup | getControl: entry.key:i).errors as errors
"
>
{{ errors | getError: $any(spec)['pattern-description'] }}
</span>
</p>
</div>
</div>
</div>
</ng-container>
@@ -402,9 +351,9 @@
<!-- label -->
<p class="input-label">
<form-label
[class.validation-error]="entry.value.invalid"
[data]="{
spec: spec,
name: spec.name,
description: spec.description,
new: original?.[entry.key] === undefined,
edited: entry.value.dirty
}"
@@ -418,18 +367,17 @@
(click)="presentModalEnumList(entry.key, $any(spec), formArr.value)"
>
<ion-label style="white-space: nowrap !important">
<h2>{{ getEnumListDisplay(formArr.value, $any(spec.spec)) }}</h2>
<h2>{{ formArr.value | toEnumListDisplay: $any(spec.spec) }}</h2>
</ion-label>
<ion-button slot="end" fill="clear" color="light">
<ion-icon slot="icon-only" name="chevron-down"></ion-icon>
</ion-button>
</ion-item>
<form-error
*ngIf="formGroup.get(entry.key)?.errors"
[control]="$any(formGroup.get(entry.key))"
[spec]="spec"
>
</form-error>
<p class="error-message">
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
{{ errors | getError }}
</span>
</p>
</ng-container>
</ng-container>
</div>

View File

@@ -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 {}

View File

@@ -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 {

View File

@@ -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<void>()
@Output() onExpand = new EventEmitter<void>()
@Output() onResize = new EventEmitter<void>()
@Output() hasNewOptions = new EventEmitter<void>()
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<T extends ValueSpec>(
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(`<ion-text color="warning">${text}</ion-text>`)
: ''
}
handleInputChange() {
this.onInputChange.emit()
}
@@ -224,9 +180,9 @@ export class FormObjectComponent {
await modal.present()
}
async presentAlertChangeWarning(
async presentAlertChangeWarning<T extends ValueSpec>(
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<void>()
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}`
}

View File

@@ -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(`<ion-text color="warning">${text}</ion-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
}
}

View File

@@ -0,0 +1,43 @@
<div [formGroup]="formGroup">
<!-- union enum -->
<ion-item-divider [class.error-border]="formGroup.invalid">
<form-label
[data]="{
name: spec.tag.name,
description: spec.tag.description,
new: isNew,
newOptions: hasNewOptions,
edited: formGroup.dirty
}"
></form-label>
<!-- class enter-click disables the enter click on the modal behind the select -->
<ion-select
[interfaceOptions]="{
message: spec.tag.warning | toWarningText,
cssClass: 'enter-click'
}"
slot="end"
placeholder="Select"
[formControlName]="spec.tag.id"
[selectedText]="spec.tag['variant-names'][unionValue]"
(ionChange)="updateUnion($event)"
>
<ion-select-option
*ngFor="let option of spec.variants | keyvalue"
[value]="option.key"
>
{{ spec.tag['variant-names'][option.key] }}
</ion-select-option>
</ion-select>
</ion-item-divider>
<elastic-container [id]="objectId | toElementId: 'union'" class="indent">
<form-object
[objectSpec]="spec.variants[unionValue]"
[formGroup]="formGroup"
[current]="current"
[original]="original"
(onResize)="resize()"
></form-object>
</elastic-container>
</div>

View File

@@ -80,8 +80,8 @@
</ion-label>
</ion-item>
<!-- no config -->
<ion-item *ngIf="!configForm">
<!-- no options -->
<ion-item *ngIf="!hasOptions">
<ion-label>
<p>
No config options for {{ pkg.manifest.title }} {{
@@ -111,7 +111,11 @@
<ion-footer>
<ion-toolbar>
<ng-container *ngIf="!loading && !loadingError">
<ion-buttons *ngIf="configForm" slot="start" class="ion-padding-start">
<ion-buttons
*ngIf="configForm && hasOptions"
slot="start"
class="ion-padding-start"
>
<ion-button fill="clear" (click)="resetDefaults()">
<ion-icon slot="start" name="refresh"></ion-icon>
Reset Defaults

View File

@@ -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)

View File

@@ -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' })

View File

@@ -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 }

View File

@@ -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<ul><li>Item 1</li><li>Item 2</li></ul>',
default: 'internal',
warning: 'Careful changing this',
tag: {
id: 'type',
name: 'Type',
'variant-names': {
internal: 'Internal',
external: 'External',
},
name: 'Bitcoin Node Settings',
description: 'Options<ul><li>Item 1</li><li>Item 2</li></ul>',
warning: 'Careful changing this',
},
variants: {
internal: {

View File

@@ -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: {

View File

@@ -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) {