implement select and multiselect for config

This commit is contained in:
Matt Hill
2023-03-25 19:31:06 -06:00
committed by Aiden McClelland
parent a657c332b1
commit 4a6a3da36c
21 changed files with 105 additions and 396 deletions

View File

@@ -49,7 +49,7 @@
"patch-db-client": "file: ../../../patch-db/client",
"pbkdf2": "^3.1.2",
"rxjs": "^7.5.6",
"start-sdk": "^0.4.0-lib0.alpha8",
"start-sdk": "^0.4.0-lib0.alpha9",
"swiper": "^8.2.4",
"ts-matches": "^5.2.1",
"tslib": "^2.3.0",
@@ -13771,9 +13771,9 @@
}
},
"node_modules/start-sdk": {
"version": "0.4.0-lib0.alpha8",
"resolved": "https://registry.npmjs.org/start-sdk/-/start-sdk-0.4.0-lib0.alpha8.tgz",
"integrity": "sha512-qErlv8ikV8nYqyCxxSN856dUwddGK5OOwTXk62IiJPxY3si03P1NQ3MnFch6Vx3NXiOmKeNNqw4/bj26TdUWRA==",
"version": "0.4.0-lib0.alpha9",
"resolved": "https://registry.npmjs.org/start-sdk/-/start-sdk-0.4.0-lib0.alpha9.tgz",
"integrity": "sha512-F+ekxjVEKgNv7SU5XCe1rn7PqbKsbqCSS7pecMxcKQmbNlic4iyo3+lIS9JuPBSyTTjJlzJFzkdaxGwf4h83mg==",
"dependencies": {
"@iarna/toml": "^2.2.5",
"lodash": "^4.17.21",

View File

@@ -74,7 +74,7 @@
"patch-db-client": "file: ../../../patch-db/client",
"pbkdf2": "^3.1.2",
"rxjs": "^7.5.6",
"start-sdk": "^0.4.0-lib0.alpha8",
"start-sdk": "^0.4.0-lib0.alpha9",
"swiper": "^8.2.4",
"ts-matches": "^5.2.1",
"tslib": "^2.3.0",

View File

@@ -5,7 +5,6 @@ import { IonicModule } from '@ionic/angular'
import { SharedPipesModule } from '@start9labs/shared'
import { TuiElasticContainerModule } from '@taiga-ui/kit'
import { TuiExpandModule } from '@taiga-ui/core'
import { EnumListPageModule } from 'src/app/modals/enum-list/enum-list.module'
import { FormLabelComponent } from './form-label/form-label.component'
import { FormObjectComponent } from './form-object/form-object.component'
import { FormUnionComponent } from './form-union/form-union.component'
@@ -13,15 +12,13 @@ import {
GetErrorPipe,
ToWarningTextPipe,
ToElementIdPipe,
ToEnumListDisplayPipe,
ToRangePipe,
} from './form-object.pipes'
import { FormFileComponent } from './form-object/controls/form-file/form-file.component'
import { FormInputComponent } from './form-object/controls/form-input/form-input.component'
import { FormWarningDirective } from './form-warning.directive'
import { FormSubformComponent } from './form-object/controls/form-subform/form-subform.component'
import { FormEnumComponent } from './form-object/controls/form-enum/form-enum.component'
import { FormValueComponent } from './form-object/controls/form-value/form-value.component'
import { FormSelectComponent } from './form-object/controls/form-select/form-select.component'
@NgModule({
declarations: [
@@ -30,15 +27,13 @@ import { FormValueComponent } from './form-object/controls/form-value/form-value
FormLabelComponent,
ToWarningTextPipe,
GetErrorPipe,
ToEnumListDisplayPipe,
ToElementIdPipe,
ToRangePipe,
FormWarningDirective,
FormFileComponent,
FormInputComponent,
FormSubformComponent,
FormEnumComponent,
FormValueComponent,
FormSelectComponent,
],
imports: [
CommonModule,
@@ -46,7 +41,6 @@ import { FormValueComponent } from './form-object/controls/form-value/form-value
FormsModule,
ReactiveFormsModule,
SharedPipesModule,
EnumListPageModule,
TuiElasticContainerModule,
TuiExpandModule,
],

View File

@@ -1,12 +1,6 @@
import { Pipe, PipeTransform } from '@angular/core'
import {
AbstractControl,
FormGroup,
UntypedFormArray,
ValidationErrors,
} from '@angular/forms'
import { ValidationErrors } from '@angular/forms'
import { IonicSafeString } from '@ionic/angular'
import { ListValueSpecOf } from 'start-sdk/types/config-types'
import { Range } from 'src/app/util/config-utilities'
import { getElementId } from './form-object/form-object.component'
@@ -40,15 +34,6 @@ export class GetErrorPipe implements PipeTransform {
}
}
@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',
})

View File

@@ -1,21 +0,0 @@
<form-label
class="label"
[data]="{
name: spec.name,
description: spec.description,
edited: control.dirty
}"
></form-label>
<ion-item button detail="false" color="dark" (click)="edit.emit()">
<ion-label class="list">
<h2>{{ control.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>
<p class="error-message">
<span *ngIf="control.errors as errors">
{{ errors | getError }}
</span>
</p>

View File

@@ -1,15 +0,0 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'
import { AbstractControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/types/config-types'
@Component({
selector: 'form-enum',
templateUrl: './form-enum.component.html',
styleUrls: ['./form-enum.component.scss'],
})
export class FormEnumComponent {
@Input() spec!: ValueSpecOf<'list'>
@Input() control!: AbstractControl
@Output() edit = new EventEmitter<void>()
}

View File

@@ -13,12 +13,12 @@
#warning="formWarning"
slot="end"
[formControl]="control"
(ionChange)="warning.onChange(name, spec, undefined, cancel)"
(ionChange)="warning.onChange(name, spec, undefined, cancelBool)"
></ion-toggle>
<!-- enum -->
<!-- class enter-click disables the enter click on the modal behind the select -->
<!-- select -->
<!-- adding class enter-click disables the enter click on the modal behind the select -->
<ion-select
*ngIf="spec.type === 'enum'"
*ngIf="spec.type === 'select' || spec.type === 'multiselect'"
[interfaceOptions]="{
header: spec.name,
message: spec.warning | toWarningText,
@@ -26,11 +26,21 @@
}"
slot="end"
placeholder="Select"
[multiple]="spec.type === 'multiselect'"
[formControl]="control"
[selectedText]="spec['value-names'][control.value]"
[selectedText]="
spec.type === 'multiselect' && control.value?.length > 1
? '[' + control.value.length + ' selected]'
: spec['value-names'][control.value]
"
>
<ion-select-option *ngFor="let option of spec.values" [value]="option">
{{ spec['value-names'][option] }}
</ion-select-option>
</ion-select>
</ion-item>
<p class="error-message">
<span *ngIf="control.errors as errors">
{{ errors | getError }}
</span>
</p>

View File

@@ -0,0 +1,16 @@
import { Component, Input } from '@angular/core'
import { FormControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/types/config-types'
@Component({
selector: 'form-select',
templateUrl: './form-select.component.html',
styleUrls: ['./form-select.component.scss'],
})
export class FormSelectComponent {
@Input() spec!: ValueSpecOf<'boolean' | 'select' | 'multiselect'>
@Input() control!: FormControl
@Input() name!: string
cancelBool = () => this.control.setValue(!this.control.value)
}

View File

@@ -1,15 +0,0 @@
import { Component, Input } from '@angular/core'
import { FormControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/types/config-types'
@Component({
selector: 'form-value',
templateUrl: './form-value.component.html',
})
export class FormValueComponent {
@Input() spec!: ValueSpecOf<'boolean' | 'enum'>
@Input() control!: FormControl
@Input() name!: string
cancel = () => this.control.setValue(!this.control.value)
}

View File

@@ -15,13 +15,17 @@
[control]="$any(entry.value)"
(onInputChange)="handleInputChange()"
></form-input>
<!-- boolean or enum -->
<form-value
*ngIf="spec.type === 'boolean' || spec.type === 'enum'"
<!-- boolean, select or multiselect -->
<form-select
*ngIf="
spec.type === 'boolean' ||
spec.type === 'select' ||
spec.type === 'multiselect'
"
[spec]="spec"
[name]="entry.key"
[control]="$any(entry.value)"
></form-value>
></form-select>
<!-- object -->
<form-subform
*ngIf="spec.type === 'object'"
@@ -46,8 +50,8 @@
[current]="current?.[entry.key]"
[original]="original?.[entry.key]"
></form-union>
<!-- list (not enum) -->
<ng-container *ngIf="spec.type === 'list' && spec.subtype !== 'enum'">
<!-- list -->
<ng-container *ngIf="spec.type === 'list'">
<ng-container
*ngIf="formGroup.get(entry.key) as formArr"
[formArrayName]="entry.key"
@@ -195,13 +199,6 @@
</div>
</ng-container>
</ng-container>
<!-- list (enum) -->
<form-enum
*ngIf="spec.type === 'list' && spec.subtype === 'enum'"
[spec]="spec"
[control]="entry.value"
(edit)="presentModalEnumList(entry.key, $any(spec), entry.value.value)"
></form-enum>
</ng-container>
</ng-container>
</ion-item-group>

View File

@@ -7,7 +7,7 @@ import {
inject,
SimpleChanges,
} from '@angular/core'
import { FormArray, UntypedFormArray, UntypedFormGroup } from '@angular/forms'
import { UntypedFormArray, UntypedFormGroup } from '@angular/forms'
import { AlertButton, AlertController, ModalController } from '@ionic/angular'
import {
InputSpec,
@@ -15,11 +15,9 @@ import {
ValueSpec,
ValueSpecBoolean,
ValueSpecList,
ValueSpecListOf,
ValueSpecUnion,
} from 'start-sdk/types/config-types'
import { FormService } from 'src/app/services/form.service'
import { EnumListPage } from 'src/app/modals/enum-list/enum-list.page'
import { THEME, pauseFor } from '@start9labs/shared'
import { v4 } from 'uuid'
import { DOCUMENT } from '@angular/common'
@@ -148,28 +146,6 @@ export class FormObjectComponent {
}
}
async presentModalEnumList(
key: string,
spec: ValueSpecListOf<'enum'>,
current: string[],
) {
const modal = await this.modalCtrl.create({
componentProps: {
key,
spec,
current,
},
component: EnumListPage,
})
modal.onWillDismiss<string[]>().then(({ data }) => {
if (!data) return
this.updateEnumList(key, current, data)
})
await modal.present()
}
async presentAlertChangeWarning<T extends ValueSpec>(
key: string,
spec: T extends ValueSpecUnion ? never : T,
@@ -272,27 +248,6 @@ export class FormObjectComponent {
})
}
private updateEnumList(key: string, current: string[], updated: string[]) {
const arr = this.formGroup.get(key) as FormArray
for (let i = current.length - 1; i >= 0; i--) {
if (!updated.includes(current[i])) {
arr.removeAt(i)
}
}
const listSpec = this.objectSpec[key] as ValueSpecList
updated.forEach(val => {
if (!current.includes(val)) {
const newItem = this.formService.getListItem(listSpec, val)!
arr.insert(arr.length, newItem)
}
})
arr.markAsDirty()
}
asIsOrder() {
return 0
}

View File

@@ -1,16 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { EnumListPage } from './enum-list.page'
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [EnumListPage],
imports: [
CommonModule,
IonicModule,
FormsModule,
],
exports: [EnumListPage],
})
export class EnumListPageModule { }

View File

@@ -1,45 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title> {{ spec.name }} </ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item-group>
<ion-item-divider style="padding-bottom: 6px">
<ion-buttons slot="end">
<ion-button fill="clear" (click)="toggleSelectAll()">
<b>{{ selectAll ? 'Select All' : 'Deselect All' }}</b>
</ion-button>
</ion-buttons>
</ion-item-divider>
<ion-item *ngFor="let option of options | keyvalue : asIsOrder">
<ion-label>{{ spec.spec['value-names'][option.key] }}</ion-label>
<ion-checkbox
slot="end"
[(ngModel)]="option.value"
(click)="toggleSelected(option.key)"
></ion-checkbox>
</ion-item>
</ion-item-group>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button
fill="solid"
color="primary"
(click)="save()"
class="enter-click btn-128"
>
Done
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -1,50 +0,0 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { ValueSpecListOf } from 'start-sdk/types/config-types'
@Component({
selector: 'enum-list',
templateUrl: './enum-list.page.html',
styleUrls: ['./enum-list.page.scss'],
})
export class EnumListPage {
@Input() key!: string
@Input() spec!: ValueSpecListOf<'enum'>
@Input() current: string[] = []
options: { [option: string]: boolean } = {}
selectAll = false
constructor(private readonly modalCtrl: ModalController) {}
ngOnInit() {
for (let val of this.spec.spec.values || []) {
this.options[val] = this.current.includes(val)
}
// if none are selected, set selectAll to true
this.selectAll = Object.values(this.options).some(k => !k)
}
dismiss() {
this.modalCtrl.dismiss()
}
save() {
this.modalCtrl.dismiss(
Object.keys(this.options).filter(key => this.options[key]),
)
}
toggleSelectAll() {
Object.keys(this.options).forEach(k => (this.options[k] = this.selectAll))
this.selectAll = !this.selectAll
}
toggleSelected(key: string) {
this.options[key] = !this.options[key]
}
asIsOrder() {
return 0
}
}

View File

@@ -253,8 +253,8 @@ const SAMPLE_CONFIG: InputSpec = {
default: true,
warning: null,
},
'sample-enum': {
type: 'enum',
'sample-select': {
type: 'multiselect',
name: 'Example Enum Select',
values: ['red', 'blue', 'green'],
'value-names': {
@@ -263,8 +263,9 @@ const SAMPLE_CONFIG: InputSpec = {
green: 'Green',
},
// optional
warning: 'Example warning to display when changing this enum value.',
description: 'Example description for enum select',
default: 'red',
warning: 'Example warning to display when changing this select value.',
description: 'Example description for select select',
range: '[0, 2)',
default: ['red'],
},
}

View File

@@ -110,7 +110,7 @@ export function getBasicInfoSpec(devData: DevProjectData): InputSpec {
warning: null,
},
license: {
type: 'enum',
type: 'select',
name: 'License',
warning: null,
values: [
@@ -135,7 +135,7 @@ export function getBasicInfoSpec(devData: DevProjectData): InputSpec {
'the-unlicense': 'The Unlicense',
custom: 'Custom',
},
description: 'Example description for enum select',
description: 'Example description for select',
default: 'mit',
},
'wrapper-repo': {

View File

@@ -963,7 +963,7 @@ export module Mock {
},
'favorite-flower': {
name: 'Favorite Flower',
type: 'enum',
type: 'select',
description: 'Select your favorite flower',
warning: null,
'value-names': {
@@ -989,19 +989,34 @@ export module Mock {
'unique-by': 'preference',
},
},
'random-enum': {
name: 'Random Enum',
type: 'enum',
'random-select': {
name: 'Random Select',
type: 'select',
'value-names': {
null: 'Null',
option1: 'One 1',
option2: 'Two 2',
option3: 'Three 3',
hello: 'Hello',
goodbye: 'Goodbye',
sup: 'Sup',
},
default: 'null',
default: 'sup',
description: 'This is not even real.',
warning: 'Be careful changing this!',
values: ['null', 'option1', 'option2', 'option3'],
values: ['hello', 'goodbye', 'sup'],
},
notifications: {
name: 'Notification Preferences',
type: 'multiselect',
description: 'how you want to be notified',
warning: null,
range: '(1,3]',
'value-names': {
email: 'EEEEmail',
text: 'Texxxt',
call: 'Ccccall',
push: 'PuuuusH',
webhook: 'WebHooookkeee',
},
values: ['email', 'text', 'call', 'push', 'webhook'],
default: ['email'],
},
'favorite-number': {
name: 'Favorite Number',
@@ -1292,25 +1307,6 @@ export module Mock {
description: 'Advanced settings',
warning: null,
spec: {
notifications: {
name: 'Notification Preferences',
type: 'list',
subtype: 'enum',
description: 'how you want to be notified',
warning: null,
range: '[1,3]',
default: ['email'],
spec: {
'value-names': {
email: 'EEEEmail',
text: 'Texxxt',
call: 'Ccccall',
push: 'PuuuusH',
webhook: 'WebHooookkeee',
},
values: ['email', 'text', 'call', 'push', 'webhook'],
},
},
rpcsettings: {
name: 'RPC Settings',
type: 'object',
@@ -1525,7 +1521,7 @@ export module Mock {
},
],
'union-list': undefined,
'random-enum': 'option2',
'random-select': ['goodbye'],
'favorite-number': 0,
rpcsettings: {
laws: {

View File

@@ -17,7 +17,8 @@ import {
ListValueSpecUnion,
UniqueBy,
ValueSpec,
ValueSpecEnum,
ValueSpecSelect,
ValueSpecMultiselect,
ValueSpecFile,
ValueSpecList,
ValueSpecNumber,
@@ -49,8 +50,8 @@ export class FormService {
const { variants, tag } = spec
const { name, description, warning, 'variant-names': variantNames } = tag
const enumSpec: ValueSpecEnum = {
type: 'enum',
const enumSpec: ValueSpecSelect = {
type: 'select',
name,
description,
warning,
@@ -71,8 +72,6 @@ export class FormService {
return this.formBuilder.control(entry, listItemValidators)
} else if (isValueSpecListOf(spec, 'number')) {
return this.formBuilder.control(entry, listItemValidators)
} else if (isValueSpecListOf(spec, 'enum')) {
return this.formBuilder.control(entry)
} else if (isValueSpecListOf(spec, 'object')) {
return this.getFormGroup(spec.spec.spec, listItemValidators, entry)
} else if (isValueSpecListOf(spec, 'union')) {
@@ -99,7 +98,6 @@ export class FormService {
spec: ValueSpec,
currentValue?: any,
): UntypedFormGroup | UntypedFormArray | UntypedFormControl {
let validators: ValidatorFn[]
let value: any
switch (spec.type) {
case 'string':
@@ -140,9 +138,12 @@ export class FormService {
isValid ? currentValue : undefined,
)
case 'boolean':
case 'enum':
case 'select':
value = currentValue === undefined ? spec.default : currentValue
return this.formBuilder.control(value)
case 'multiselect':
value = currentValue === undefined ? spec.default : currentValue
return this.formBuilder.control(value, multiselectValidators(spec))
default:
return this.formBuilder.control(null)
}
@@ -193,17 +194,16 @@ function numberValidators(
return validators
}
function multiselectValidators(spec: ValueSpecMultiselect): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(listInRange(spec.range))
return validators
}
function listValidators(spec: ValueSpecList): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(listInRange(spec.range))
validators.push(listItemIssue())
if (!isValueSpecListOf(spec, 'enum')) {
validators.push(listUnique(spec))
}
return validators
}
@@ -314,7 +314,6 @@ function listItemEquals(spec: ValueSpecList, val1: any, val2: any): boolean {
switch (spec.subtype) {
case 'string':
case 'number':
case 'enum':
return val1 == val2
case 'object':
const obj: ListValueSpecObject = spec.spec as any
@@ -334,7 +333,7 @@ function itemEquals(spec: ValueSpec, val1: any, val2: any): boolean {
case 'string':
case 'number':
case 'boolean':
case 'enum':
case 'select':
return val1 == val2
case 'object':
// TODO: 'unique-by' does not exist on ValueSpecObject, fix types

View File

@@ -1,4 +1,4 @@
import { ValueSpec, DefaultString } from 'start-sdk/types/config-types'
import { DefaultString } from 'start-sdk/types/config-types'
export class Range {
min?: number
@@ -31,95 +31,21 @@ export class Range {
}
}
hasMin(): this is Range & { min: number } {
private hasMin(): this is Range & { min: number } {
return this.min !== undefined
}
hasMax(): this is Range & { max: number } {
private hasMax(): this is Range & { max: number } {
return this.max !== undefined
}
minMessage(): string {
private minMessage(): string {
return `greater than${this.minInclusive ? ' or equal to' : ''} ${this.min}`
}
maxMessage(): string {
private maxMessage(): string {
return `less than${this.maxInclusive ? ' or equal to' : ''} ${this.max}`
}
description(): string {
let message = 'Value can be any number.'
if (this.hasMin() || this.hasMax()) {
message = 'Value must be'
}
if (this.hasMin() && this.hasMax()) {
message = `${message} ${this.minMessage()} AND ${this.maxMessage()}.`
} else if (this.hasMin() && !this.hasMax()) {
message = `${message} ${this.minMessage()}.`
} else if (!this.hasMin() && this.hasMax()) {
message = `${message} ${this.maxMessage()}.`
}
return message
}
integralMin(): number | undefined {
if (this.min) {
const ceil = Math.ceil(this.min)
if (this.minInclusive) {
return ceil
} else {
if (ceil === this.min) {
return ceil + 1
} else {
return ceil
}
}
}
}
integralMax(): number | undefined {
if (this.max) {
const floor = Math.floor(this.max)
if (this.maxInclusive) {
return floor
} else {
if (floor === this.max) {
return floor - 1
} else {
return floor
}
}
}
}
}
export function getDefaultDescription(spec: ValueSpec): string {
let toReturn: string | undefined
switch (spec.type) {
case 'string':
if (typeof spec.default === 'string') {
toReturn = spec.default
} else if (typeof spec.default === 'object') {
toReturn = 'random'
}
break
case 'number':
if (typeof spec.default === 'number') {
toReturn = String(spec.default)
}
break
case 'boolean':
toReturn = spec.default === true ? 'True' : 'False'
break
case 'enum':
toReturn = spec['value-names'][spec.default]
break
}
return toReturn || ''
}
export function getDefaultString(defaultSpec: DefaultString): string {
@@ -136,7 +62,7 @@ export function getDefaultString(defaultSpec: DefaultString): string {
}
// a,g,h,A-Z,,,,-
export function getRandomCharInSet(charset: string): string {
function getRandomCharInSet(charset: string): string {
const set = stringToCharSet(charset)
let charIdx = Math.floor(Math.random() * set.len)
for (let range of set.ranges) {