feat: use Taiga UI for config modal (#2250)

* feat: use Taiga UI for config modal

* chore: finish remaining changes

* chore: address comments

* bump sdk version

---------

Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
This commit is contained in:
Alex Inkin
2023-04-14 21:52:21 +08:00
committed by Aiden McClelland
parent ded16549f7
commit 09b91cc663
88 changed files with 1916 additions and 691 deletions

View File

@@ -41,9 +41,15 @@
"glob": "ngsw.json",
"input": "dist/ui",
"output": "projects/ui/src"
},
{
"glob": "**/*",
"input": "node_modules/@taiga-ui/icons/src",
"output": "assets/taiga-ui/icons"
}
],
"styles": [
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less",
"projects/shared/styles/variables.scss",
"projects/shared/styles/global.scss",
"projects/shared/styles/shared.scss",

View File

@@ -25,11 +25,11 @@
"@ng-web-apis/resize-observer": "^2.0.0",
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"@taiga-ui/addon-charts": "3.20.0",
"@taiga-ui/cdk": "3.20.0",
"@taiga-ui/core": "3.20.0",
"@taiga-ui/icons": "3.20.0",
"@taiga-ui/kit": "3.20.0",
"@taiga-ui/addon-charts": "3.24.0",
"@taiga-ui/cdk": "3.24.0",
"@taiga-ui/core": "3.24.0",
"@taiga-ui/icons": "3.24.0",
"@taiga-ui/kit": "3.24.0",
"angular-svg-round-progressbar": "^9.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
@@ -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.charlie2",
"start-sdk": "^0.4.0-lib0.charlie19",
"swiper": "^8.2.4",
"ts-matches": "^5.2.1",
"tslib": "^2.3.0",
@@ -3887,9 +3887,9 @@
}
},
"node_modules/@taiga-ui/addon-charts": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.20.0.tgz",
"integrity": "sha512-EH/mhwCv7Dq/JaEmGgJnDFr2ZYCzlTJWMS9BHwEZXaLqKVQWiSeXOhB/5sGhuAXiitZb2XvSumlVslAECpe/Kg==",
"version": "3.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.24.0.tgz",
"integrity": "sha512-0pbRO8n6nNllgDwLJV0fY5+KOVzfVftGWeaV4AYRIFpoBU9voT8SqnIAu2Rotk/ze+sa8WqXPjnWM1fHvX4Z+g==",
"dependencies": {
"tslib": ">=2.0.0"
},
@@ -3897,25 +3897,25 @@
"@angular/common": ">=12.0.0",
"@angular/core": ">=12.0.0",
"@ng-web-apis/common": ">=2.0.0",
"@taiga-ui/cdk": ">=3.20.0",
"@taiga-ui/core": ">=3.20.0",
"@taiga-ui/cdk": ">=3.24.0",
"@taiga-ui/core": ">=3.24.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0"
}
},
"node_modules/@taiga-ui/cdk": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.20.0.tgz",
"integrity": "sha512-fXX8pV/MELxfoEjStcTe0Eh+1f3vp/jXrHU5JR7p1yA3KtYw+a8WBPLa2+BNAYYSxhpfLsplCb4K2XcVEdUiew==",
"version": "3.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.24.0.tgz",
"integrity": "sha512-FsTWG6KchhqenGT5YOGfDO5Wy6DNfhcqZzif40cxv0kgZO7d97EdE0F5J0Tda/wJwu5cdlM4u2avUcaHGaKsHA==",
"dependencies": {
"@ng-web-apis/common": "2.1.0",
"@ng-web-apis/mutation-observer": "2.0.0",
"@ng-web-apis/resize-observer": "2.0.0",
"@tinkoff/ng-event-plugins": "3.1.0",
"@tinkoff/ng-polymorpheus": "4.0.10",
"@tinkoff/ng-polymorpheus": "4.0.11",
"tslib": "2.5.0"
},
"optionalDependencies": {
"ng-morph": "2.1.0",
"ng-morph": "2.1.3",
"parse5": "6.0.1"
},
"peerDependencies": {
@@ -3927,11 +3927,11 @@
}
},
"node_modules/@taiga-ui/core": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.20.0.tgz",
"integrity": "sha512-TzhU2Nos80tpvcM9DiQ4xcO/RvDjUwOvbQyeu5RTk7mPLsEVoRHaFagO4KXOFhr7D5BFMQimqQaQLFI3Y5c8Xw==",
"version": "3.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.24.0.tgz",
"integrity": "sha512-PPNE2I7yTZlVdis+Zd09dV21OspOPhe/VXPrWNM+J2A3vmxMAMwAR5i8qAoS6rBfUWpI/5vXImQSmvztXQQJeg==",
"dependencies": {
"@taiga-ui/i18n": "^3.20.0",
"@taiga-ui/i18n": "^3.24.0",
"tslib": ">=2.0.0"
},
"peerDependencies": {
@@ -3943,17 +3943,17 @@
"@angular/router": ">=12.0.0",
"@ng-web-apis/common": ">=2.0.0",
"@ng-web-apis/mutation-observer": ">=2.0.0",
"@taiga-ui/cdk": ">=3.20.0",
"@taiga-ui/i18n": ">=3.20.0",
"@taiga-ui/cdk": ">=3.24.0",
"@taiga-ui/i18n": ">=3.24.0",
"@tinkoff/ng-event-plugins": ">=3.1.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0",
"rxjs": ">=6.0.0"
}
},
"node_modules/@taiga-ui/i18n": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.20.0.tgz",
"integrity": "sha512-R0cKNSnvcAXZgfF4j7MPqkja1pQD87XudXG5T3o9qore0ysKCqEW/8A036hBXjLzkHUaMbOutIjy26RZLcoDbQ==",
"version": "3.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.24.0.tgz",
"integrity": "sha512-fPzY18AWoQXdvt0+5E59dTFGV853A+g9j81QrzxF+YH+ToroxnkZHcL6bXj0RUi4r40j69cvs0H3FlfyvC3PNA==",
"dependencies": {
"tslib": ">=2.0.0"
},
@@ -3963,17 +3963,17 @@
}
},
"node_modules/@taiga-ui/icons": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.20.0.tgz",
"integrity": "sha512-N4lwb/XLUogHY7goTNwdfz03gsYczjP7Qnq+IbuqI1iwuH+e3rUWuOCZBjFZ5m1aAB3d/xnKos8bBf84wixJWQ==",
"version": "3.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.24.0.tgz",
"integrity": "sha512-3cwKEraJs0JmMrn266NYjHBGqMo1PXMHMU0U2mz9SVmenStAMn2FdXRlRB5TZWd6JksIKu2e+JPqCDW6Ge2K6w==",
"dependencies": {
"tslib": "^2.2.0"
}
},
"node_modules/@taiga-ui/kit": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.20.0.tgz",
"integrity": "sha512-fo2FDlTWaynaU9fbyLX4uQdKVsFmIRB+K4zHBwnyzrr2JgNqPQfRUXPZNyGA0cOf+r5Sz7Hs5K5i8TGhGKoFZQ==",
"version": "3.24.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.24.0.tgz",
"integrity": "sha512-8p1ithoSOhKJqJQ+dJpUiGTNz85UfukeG39lqmJjUZlnW37seiGTYQsYKxg6LD8HoVXNptUQWwAx/X1k0i6Q2g==",
"dependencies": {
"@ng-web-apis/intersection-observer": "3.0.0",
"text-mask-core": "5.1.2",
@@ -3986,9 +3986,10 @@
"@angular/router": ">=12.0.0",
"@ng-web-apis/common": ">=2.0.0",
"@ng-web-apis/mutation-observer": ">=2.0.0",
"@taiga-ui/cdk": ">=3.20.0",
"@taiga-ui/core": ">=3.20.0",
"@taiga-ui/i18n": ">=3.20.0",
"@ng-web-apis/resize-observer": ">=2.0.0",
"@taiga-ui/cdk": ">=3.24.0",
"@taiga-ui/core": ">=3.24.0",
"@taiga-ui/i18n": ">=3.24.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0",
"rxjs": ">=6.0.0"
}
@@ -4007,9 +4008,9 @@
}
},
"node_modules/@tinkoff/ng-polymorpheus": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/@tinkoff/ng-polymorpheus/-/ng-polymorpheus-4.0.10.tgz",
"integrity": "sha512-BxHSwj9CertJ3qiamZ52NTpsKn81EZHjDwiph8mXiEeKXpuPaDn6e5wmTWdW8mYexLPtBsxmCRvZ9vapw4F1kA==",
"version": "4.0.11",
"resolved": "https://registry.npmjs.org/@tinkoff/ng-polymorpheus/-/ng-polymorpheus-4.0.11.tgz",
"integrity": "sha512-pRU4crK5pW4RPnEuvPq+sE3fgy5xqcdMfmfqQzd+OBRNGNJx8pFrzY1yXFEkC00pNl7/fZEVelXqe8v5MltAdw==",
"dependencies": {
"tslib": "^2.0.0"
},
@@ -10332,13 +10333,13 @@
}
},
"node_modules/ng-morph": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ng-morph/-/ng-morph-2.1.0.tgz",
"integrity": "sha512-jn34Ter6HlY7E3yOoMhfk3cnUwjLlvcGTsAJ7jS0pZ3SAGi3hzqlf3oyUQO6fNfbFnydc33yNqQtUIrbHKCtNA==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ng-morph/-/ng-morph-2.1.3.tgz",
"integrity": "sha512-bFeSMSn2ORgtYw4ZmwISJ/RGdZxi03IwODrnXB6FbTEvmyfuTCB7x0FyQsm8euNX43fTp3FZclCZpRmO8t5w8w==",
"optional": true,
"dependencies": {
"jsonc-parser": "3.0.0",
"minimatch": "3.0.4",
"minimatch": "3.0.5",
"multimatch": "5.0.0",
"ts-morph": "10.0.2"
},
@@ -10364,9 +10365,9 @@
"optional": true
},
"node_modules/ng-morph/node_modules/minimatch": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.5.tgz",
"integrity": "sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==",
"optional": true,
"dependencies": {
"brace-expansion": "^1.1.7"

View File

@@ -50,11 +50,11 @@
"@ng-web-apis/resize-observer": "^2.0.0",
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"@taiga-ui/addon-charts": "3.20.0",
"@taiga-ui/cdk": "3.20.0",
"@taiga-ui/core": "3.20.0",
"@taiga-ui/icons": "3.20.0",
"@taiga-ui/kit": "3.20.0",
"@taiga-ui/addon-charts": "3.24.0",
"@taiga-ui/cdk": "3.24.0",
"@taiga-ui/core": "3.24.0",
"@taiga-ui/icons": "3.24.0",
"@taiga-ui/kit": "3.24.0",
"angular-svg-round-progressbar": "^9.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
@@ -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.charlie2",
"start-sdk": "^0.4.0-lib0.charlie19",
"swiper": "^8.2.4",
"ts-matches": "^5.2.1",
"tslib": "^2.3.0",

View File

@@ -1,4 +1,5 @@
import {
TuiAlertModule,
TuiDialogModule,
TuiModeModule,
TuiRootModule,
@@ -33,6 +34,8 @@ import { ConnectionBarComponentModule } from './components/connection-bar/connec
import { WidgetsPageModule } from './pages/widgets/widgets.module'
import { ServiceWorkerModule } from '@angular/service-worker'
import { environment } from '../environments/environment'
import { LoadingModule } from './modals/loading/loading.module'
import { FormPageModule } from './modals/form/form.module'
@NgModule({
declarations: [AppComponent],
@@ -58,6 +61,7 @@ import { environment } from '../environments/environment'
ConnectionBarComponentModule,
TuiRootModule,
TuiDialogModule,
TuiAlertModule,
TuiModeModule,
TuiThemeNightModule,
WidgetsPageModule,
@@ -70,6 +74,8 @@ import { environment } from '../environments/environment'
// or after 30 seconds (whichever comes first).
registrationStrategy: 'registerWhenStable:30000',
}),
LoadingModule,
FormPageModule,
],
providers: APP_PROVIDERS,
bootstrap: [AppComponent],

View File

@@ -2,6 +2,10 @@ import { APP_INITIALIZER, Provider } from '@angular/core'
import { UntypedFormBuilder } from '@angular/forms'
import { Router, RouteReuseStrategy } from '@angular/router'
import { IonicRouteStrategy, IonNav } from '@ionic/angular'
import {
tuiButtonOptionsProvider,
tuiNumberFormatProvider,
} from '@taiga-ui/core'
import { RELATIVE_URL, THEME, WorkspaceConfig } from '@start9labs/shared'
import { ApiService } from './services/api/embassy-api.service'
import { MockApiService } from './services/api/embassy-mock-api.service'
@@ -20,6 +24,8 @@ export const APP_PROVIDERS: Provider[] = [
FilterPackagesPipe,
UntypedFormBuilder,
IonNav,
tuiNumberFormatProvider({ decimalSeparator: '.', thousandSeparator: '' }),
tuiButtonOptionsProvider({ size: 'm' }),
{
provide: RouteReuseStrategy,
useClass: IonicRouteStrategy,

View File

@@ -12,7 +12,7 @@ import {
ModalController,
} from '@ionic/angular'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { InputSpec } from 'start-sdk/lib/config/config-types'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'

View File

@@ -38,7 +38,7 @@ export class GetErrorPipe implements PipeTransform {
name: 'toWarningText',
})
export class ToWarningTextPipe implements PipeTransform {
transform(text: string | null): IonicSafeString | string {
transform(text?: string | null): IonicSafeString | string {
return text
? new IonicSafeString(`<ion-text color="warning">${text}</ion-text>`)
: ''
@@ -49,7 +49,7 @@ export class ToWarningTextPipe implements PipeTransform {
name: 'toRange',
})
export class ToRangePipe implements PipeTransform {
transform(range: string): Range {
transform(range?: string): Range {
return Range.from(range)
}
}

View File

@@ -2,7 +2,7 @@
<form-label
[data]="{
name: spec.name,
description: spec.description,
description: spec.description || null,
edited: control.dirty
}"
></form-label>

View File

@@ -1,6 +1,6 @@
import { Component, Input } from '@angular/core'
import { AbstractControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/lib/config/config-types'
import { ValueSpecOf } from 'start-sdk/lib/config/configTypes'
@Component({
selector: 'form-file',

View File

@@ -2,7 +2,7 @@
class="label"
[data]="{
name: spec.name,
description: spec.description,
description: spec.description || null,
edited: control.dirty,
required: spec.required
}"

View File

@@ -1,6 +1,6 @@
import { Component, Input, inject, Output, EventEmitter } from '@angular/core'
import { FormControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/lib/config/config-types'
import { ValueSpecOf } from 'start-sdk/lib/config/configTypes'
import { THEME } from '@start9labs/shared'
@Component({

View File

@@ -2,7 +2,7 @@
<form-label
[data]="{
name: spec.name,
description: spec.description,
description: spec.description || null,
edited: control.dirty
}"
></form-label>

View File

@@ -1,6 +1,6 @@
import { Component, Input } from '@angular/core'
import { FormControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/lib/config/config-types'
import { ValueSpecOf } from 'start-sdk/lib/config/configTypes'
@Component({
selector: 'form-select',

View File

@@ -5,7 +5,7 @@
<form-label
[data]="{
name: spec.name,
description: spec.description,
description: spec.description || null,
edited: control.dirty,
newOptions: hasNewOptions
}"

View File

@@ -1,6 +1,6 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'
import { AbstractControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/lib/config/config-types'
import { ValueSpecOf } from 'start-sdk/lib/config/configTypes'
@Component({
selector: 'form-subform',

View File

@@ -65,7 +65,7 @@
<form-label
[data]="{
name: spec.name,
description: spec.description,
description: spec.description || null,
edited: entry.value.dirty,
required: !!(spec.range | toRange).min
}"

View File

@@ -16,7 +16,7 @@ import {
ValueSpecBoolean,
ValueSpecList,
ValueSpecUnion,
} from 'start-sdk/lib/config/config-types'
} from 'start-sdk/lib/config/configTypes'
import { FormService } from 'src/app/services/form.service'
import { THEME, pauseFor } from '@start9labs/shared'
import { v4 } from 'uuid'

View File

@@ -4,7 +4,7 @@
<form-label
[data]="{
name: spec.name,
description: spec.description,
description: spec.description || null,
newOptions: hasNewOptions,
edited: formGroup.dirty
}"

View File

@@ -6,7 +6,7 @@ import {
ValueSpecUnion,
InputSpec,
unionSelectKey,
} from 'start-sdk/lib/config/config-types'
} from 'start-sdk/lib/config/configTypes'
@Component({
selector: 'form-union',

View File

@@ -1,5 +1,5 @@
import { Directive } from '@angular/core'
import { ValueSpec, ValueSpecUnion } from 'start-sdk/lib/config/config-types'
import { ValueSpec, ValueSpecUnion } from 'start-sdk/lib/config/configTypes'
import { AlertButton, AlertController } from '@ionic/angular'
@Directive({

View File

@@ -0,0 +1,30 @@
import { Directive, ElementRef, inject, OnDestroy, OnInit } from '@angular/core'
import { ControlContainer, NgControl } from '@angular/forms'
import { InvalidService } from './invalid.service'
@Directive({
selector: 'form-control, form-array, form-object',
})
export class ControlDirective implements OnInit, OnDestroy {
private readonly invalidService = inject(InvalidService, { optional: true })
private readonly element: ElementRef<HTMLElement> = inject(ElementRef)
private readonly control =
inject(NgControl, { optional: true }) ||
inject(ControlContainer, { optional: true })
get invalid(): boolean {
return !!this.control?.invalid
}
scrollIntoView() {
this.element.nativeElement.scrollIntoView({ behavior: 'smooth' })
}
ngOnInit() {
this.invalidService?.add(this)
}
ngOnDestroy() {
this.invalidService?.remove(this)
}
}

View File

@@ -0,0 +1,24 @@
import { inject } from '@angular/core'
import { FormControlComponent } from './form-control/form-control.component'
import { ValueSpec } from 'start-sdk/lib/config/configTypes'
export abstract class Control<Spec extends ValueSpec, Value> {
private readonly control: FormControlComponent<Spec, Value> =
inject(FormControlComponent)
get spec(): Spec {
return this.control.spec
}
get value(): Value | null {
return this.control.value
}
set value(value: Value | null) {
this.control.onInput(value)
}
onFocus(focused: boolean) {
this.control.onFocus(focused)
}
}

View File

@@ -0,0 +1,50 @@
<div class="label">
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description"
[content]="spec.description"
></tui-tooltip>
<button tuiLink type="button" class="add" (click)="add()">+ Add</button>
</div>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<ng-container *ngFor="let item of array.control.controls; let index = index">
<form-object
*ngIf="spec.spec.type === 'object'; else control"
class="object"
[class.object_open]="!!open.get(item)"
[formGroup]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
[open]="!!open.get(item)"
(openChange)="open.set(item, $event)"
>
{{ item.value | mustache : $any(spec.spec).displayAs }}
<ng-container *ngTemplateOutlet="remove"></ng-container>
</form-object>
<ng-template #control>
<form-control
class="control"
tuiTextfieldSize="m"
[tuiTextfieldLabelOutside]="true"
[tuiTextfieldIcon]="remove"
[formControl]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
></form-control>
</ng-template>
<ng-template #remove>
<button
tuiIconButton
type="button"
class="remove"
icon="tuiIconTrash"
appearance="icon"
size="m"
title="Remove"
(click.stop)="removeAt(index)"
></button>
</ng-template>
</ng-container>

View File

@@ -0,0 +1,50 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: block;
margin: 2rem 0;
}
.label {
display: flex;
font-size: 1.25rem;
font-weight: bold;
}
.add {
font-size: 1rem;
padding: 0 1rem;
margin-left: auto;
}
.object {
display: block;
position: relative;
&_open::after,
&:last-child::after {
opacity: 0;
}
&:after {
@include transition(opacity);
content: '';
position: absolute;
bottom: -0.5rem;
height: 1px;
left: 3rem;
right: 1rem;
background: var(--tui-clear);
}
}
.remove {
margin-left: auto;
pointer-events: auto;
}
.control {
display: block;
margin: 0.5rem 0;
}

View File

@@ -0,0 +1,83 @@
import { Component, HostBinding, inject, Input } from '@angular/core'
import { AbstractControl, FormArrayName } from '@angular/forms'
import { TUI_PARENT_STOP, TuiDestroyService } from '@taiga-ui/cdk'
import {
TUI_ANIMATION_OPTIONS,
TuiDialogService,
tuiFadeIn,
tuiHeightCollapse,
} from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter, takeUntil } from 'rxjs'
import { ValueSpecList } from 'start-sdk/lib/config/configTypes'
import { FormService } from '../../../services/form.service'
import { ERRORS } from '../form-group/form-group.component'
@Component({
selector: 'form-array',
templateUrl: './form-array.component.html',
styleUrls: ['./form-array.component.scss'],
animations: [tuiFadeIn, tuiHeightCollapse, TUI_PARENT_STOP],
providers: [TuiDestroyService],
})
export class FormArrayComponent {
@Input()
spec!: ValueSpecList
@HostBinding('@tuiParentStop')
readonly animation = { value: '', ...inject(TUI_ANIMATION_OPTIONS) }
readonly order = ERRORS
readonly array = inject(FormArrayName)
readonly open = new Map<AbstractControl, boolean>()
private warned = false
private readonly formService = inject(FormService)
private readonly dialogs = inject(TuiDialogService)
private readonly destroy$ = inject(TuiDestroyService)
add() {
if (!this.warned && this.spec.warning) {
this.dialogs
.open<boolean>(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: { content: this.spec.warning, yes: 'Ok', no: 'Cancel' },
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.addItem()
})
} else {
this.addItem()
}
this.warned = true
}
removeAt(index: number) {
this.dialogs
.open<boolean>(TUI_PROMPT, {
label: 'Confirm',
size: 's',
data: {
content: 'Are you sure you want to delete this entry?',
yes: 'Delete',
no: 'Cancel',
},
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.removeItem(index)
})
}
private removeItem(index: number) {
this.open.delete(this.array.control.at(index))
this.array.control.removeAt(index)
}
private addItem() {
this.array.control.insert(0, this.formService.getListItem(this.spec))
this.open.set(this.array.control.at(0), true)
}
}

View File

@@ -0,0 +1,10 @@
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description"
[content]="spec.description"
></tui-tooltip>
<tui-toggle
size="l"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
></tui-toggle>

View File

@@ -0,0 +1,14 @@
:host {
height: var(--tui-height-l);
display: flex;
align-items: center;
padding: 0 1rem;
box-shadow: inset 0 0 0 1px var(--tui-base-03);
font: var(--tui-font-text-l);
font-weight: bold;
border-radius: var(--tui-radius-m);
}
tui-toggle {
margin-left: auto;
}

View File

@@ -0,0 +1,10 @@
import { Component } from '@angular/core'
import { ValueSpecBoolean } from 'start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({
selector: 'form-boolean',
templateUrl: './form-boolean.component.html',
styleUrls: ['./form-boolean.component.scss'],
})
export class FormBooleanComponent extends Control<ValueSpecBoolean, boolean> {}

View File

@@ -0,0 +1,33 @@
<ng-container [ngSwitch]="spec.type">
<form-string *ngSwitchCase="'string'"></form-string>
<form-number *ngSwitchCase="'number'"></form-number>
<form-text *ngSwitchCase="'textarea'"></form-text>
<form-boolean *ngSwitchCase="'boolean'"></form-boolean>
<form-select *ngSwitchCase="'select'"></form-select>
<form-multiselect *ngSwitchCase="'multiselect'"></form-multiselect>
<form-file *ngSwitchCase="'file'"></form-file>
</ng-container>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<ng-template *ngIf="spec.warning" #warning let-completeWith="completeWith">
{{ spec.warning }}
<div class="buttons">
<button
tuiButton
type="button"
appearance="secondary"
size="s"
(click)="completeWith(true)"
>
Rollback
</button>
<button
tuiButton
type="button"
appearance="flat"
size="s"
(click)="completeWith(false)"
>
Accept
</button>
</div>
</ng-template>

View File

@@ -0,0 +1,11 @@
:host {
display: block;
}
.buttons {
margin-top: 0.5rem;
:first-child {
margin-right: 0.5rem;
}
}

View File

@@ -0,0 +1,76 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
TemplateRef,
ViewChild,
} from '@angular/core'
import { AbstractTuiNullableControl } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiDialogContext,
TuiNotification,
} from '@taiga-ui/core'
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
import { filter, takeUntil } from 'rxjs'
import { ValueSpec, ValueSpecString } from 'start-sdk/lib/config/configTypes'
import { ERRORS } from '../form-group/form-group.component'
@Component({
selector: 'form-control',
templateUrl: './form-control.component.html',
styleUrls: ['./form-control.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: TUI_VALIDATION_ERRORS,
deps: [FormControlComponent],
useFactory: (control: FormControlComponent<ValueSpecString, string>) => ({
required: 'Required',
pattern: () => control.spec.patternDescription,
}),
},
],
})
export class FormControlComponent<
T extends ValueSpec,
V,
> extends AbstractTuiNullableControl<V> {
@Input()
spec!: T
@ViewChild('warning')
warning?: TemplateRef<TuiDialogContext<boolean>>
warned = false
focused = false
readonly order = ERRORS
private readonly alerts = inject(TuiAlertService)
onFocus(focused: boolean) {
this.focused = focused
this.updateFocused(focused)
}
onInput(value: V | null) {
const previous = this.value
if (!this.warned && this.warning) {
this.alerts
.open<boolean>(this.warning, {
label: 'Warning',
status: TuiNotification.Warning,
hasCloseButton: false,
autoClose: false,
})
.pipe(filter(Boolean), takeUntil(this.destroy$))
.subscribe(() => {
this.value = previous
})
}
this.warned = true
this.value = value === '' ? null : value
}
}

View File

@@ -0,0 +1,31 @@
<tui-input-files
[pseudoInvalid]="true"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
<input tuiInputFiles [accept]="spec.extensions.join(',')" />
<ng-template let-drop>
<div class="template" [class.template_hidden]="drop">
<div class="label">
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<tui-tooltip
*ngIf="spec.description"
[content]="spec.description"
></tui-tooltip>
</div>
<tui-tag
*ngIf="value; else label"
class="file"
size="l"
[value]="value.name"
[removable]="true"
(edited)="value = null"
></tui-tag>
<ng-template #label>
<small>Click or drop file here</small>
</ng-template>
</div>
<div class="drop" [class.drop_hidden]="!drop">Drop file here</div>
</ng-template>
</tui-input-files>

View File

@@ -0,0 +1,45 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
.template {
@include transition(opacity);
display: flex;
align-items: center;
padding: 0 0.5rem;
font: var(--tui-font-text-l);
font-weight: bold;
&_hidden {
opacity: 0;
}
}
.drop {
@include fullsize();
@include transition(opacity);
display: flex;
align-items: center;
justify-content: space-around;
&_hidden {
opacity: 0;
}
}
.label {
display: flex;
align-items: center;
max-width: 50%;
}
small {
max-width: 50%;
font-weight: normal;
color: var(--tui-text-02);
margin-left: auto;
}
tui-tag {
z-index: 1;
margin: 0 -0.25rem 0 auto;
}

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core'
import { TuiFileLike } from '@taiga-ui/kit'
import { ValueSpecFile } from 'start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({
selector: 'form-file',
templateUrl: './form-file.component.html',
styleUrls: ['./form-file.component.scss'],
})
export class FormFileComponent extends Control<ValueSpecFile, TuiFileLike> {}

View File

@@ -0,0 +1,30 @@
<ng-container
*ngFor="let entry of spec | keyvalue : asIsOrder"
tuiMode="onDark"
[ngSwitch]="entry.value.type"
[tuiTextfieldCleaner]="true"
>
<form-object
*ngSwitchCase="'object'"
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
></form-object>
<form-union
*ngSwitchCase="'union'"
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
></form-union>
<form-array
*ngSwitchCase="'list'"
[formArrayName]="entry.key"
[spec]="$any(entry.value)"
></form-array>
<form-control
*ngSwitchDefault
class="g-form-control"
[formControlName]="entry.key"
[spec]="entry.value"
></form-control>
</ng-container>

View File

@@ -0,0 +1,35 @@
form-group .g-form-control:not(:first-child) {
margin-top: 1rem;
}
form-group .g-form-group {
position: relative;
padding-left: var(--tui-height-m);
&::before,
&::after {
content: '';
position: absolute;
background: var(--tui-clear);
}
&::before {
top: 0;
left: calc(1rem - 1px);
bottom: 0.5rem;
width: 2px;
}
&::after {
left: 0.75rem;
bottom: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
}
}
form-group tui-tooltip {
z-index: 1;
margin-left: 0.25rem;
}

View File

@@ -0,0 +1,35 @@
import {
ChangeDetectionStrategy,
Component,
Input,
ViewEncapsulation,
} from '@angular/core'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { FORM_GROUP_PROVIDERS } from './form-group.providers'
export const ERRORS = [
'required',
'pattern',
'notNumber',
'numberNotInteger',
'numberNotInRange',
'listNotUnique',
'listNotInRange',
'listItemIssue',
]
@Component({
selector: 'form-group',
templateUrl: './form-group.component.html',
styleUrls: ['./form-group.component.scss'],
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [FORM_GROUP_PROVIDERS],
})
export class FormGroupComponent {
@Input() spec: InputSpec = {}
asIsOrder() {
return 0
}
}

View File

@@ -0,0 +1,24 @@
import { Provider, SkipSelf } from '@angular/core'
import { TUI_ARROW_MODE } from '@taiga-ui/kit'
import { TUI_DEFAULT_ERROR_MESSAGE } from '@taiga-ui/core'
import { ControlContainer } from '@angular/forms'
import { identity, of } from 'rxjs'
export const FORM_GROUP_PROVIDERS: Provider[] = [
{
provide: TUI_DEFAULT_ERROR_MESSAGE,
useValue: of('Unknown error'),
},
{
provide: ControlContainer,
deps: [[new SkipSelf(), ControlContainer]],
useFactory: identity,
},
{
provide: TUI_ARROW_MODE,
useValue: {
interactive: null,
disabled: null,
},
},
]

View File

@@ -0,0 +1,9 @@
<tui-multi-select
[tuiHintContent]="spec.description"
[editable]="false"
[(ngModel)]="selected"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<select tuiSelect multiple [items]="items"></select>
</tui-multi-select>

View File

@@ -0,0 +1,30 @@
import { Component } from '@angular/core'
import { ValueSpecMultiselect } from 'start-sdk/lib/config/configTypes'
import { Control } from '../control'
import { tuiPure } from '@taiga-ui/cdk'
@Component({
selector: 'form-multiselect',
templateUrl: './form-multiselect.component.html',
})
export class FormMultiselectComponent extends Control<
ValueSpecMultiselect,
readonly string[]
> {
readonly items = Object.values(this.spec.values)
get selected(): string[] {
return this.memoize(this.value)
}
set selected(value: string[]) {
this.value = Object.entries(this.spec.values)
.filter(([_, v]) => value.includes(v))
.map(([k]) => k)
}
@tuiPure
private memoize(value: null | readonly string[]): string[] {
return value?.map(key => this.spec.values[key]) || []
}
}

View File

@@ -0,0 +1,14 @@
<tui-input-number
[tuiHintContent]="spec.description"
[tuiTextfieldPostfix]="spec.units || ''"
[precision]="Infinity"
[decimal]="spec.integral ? 'never' : 'not-zero'"
[min]="min"
[max]="max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<input tuiTextfield [placeholder]="spec.placeholder || ''" />
</tui-input-number>

View File

@@ -0,0 +1,25 @@
import { Component } from '@angular/core'
import { ValueSpecNumber } from 'start-sdk/lib/config/configTypes'
import { Range } from 'src/app/util/config-utilities'
import { Control } from '../control'
@Component({
selector: 'form-number',
templateUrl: './form-number.component.html',
})
export class FormNumberComponent extends Control<ValueSpecNumber, number> {
protected readonly Infinity = Infinity
private range = Range.from(this.spec.range)
get min(): number {
const min = this.range.min || -Infinity
return this.range.minInclusive || !this.spec.integral ? min : min + 1
}
get max(): number {
const max = this.range.max || Infinity
return this.range.maxInclusive || !this.spec.integral ? max : max - 1
}
}

View File

@@ -0,0 +1,25 @@
<h3 class="title" (click)="toggle()">
<button
tuiIconButton
size="s"
icon="tuiIconChevronDown"
type="button"
shape="rounded"
class="button"
[class.button_open]="open"
[appearance]="invalid ? 'secondary-destructive' : 'secondary'"
></button>
<ng-content></ng-content>
{{ spec.name }}
<tui-tooltip
*ngIf="spec.description"
[content]="spec.description"
(click.stop)="(0)"
></tui-tooltip>
</h3>
<tui-expand class="expand" [expanded]="open">
<div class="g-form-group" [class.g-form-group_invalid]="invalid">
<form-group [spec]="spec.spec"></form-group>
</div>
</tui-expand>

View File

@@ -0,0 +1,41 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.title {
position: relative;
height: var(--tui-height-l);
display: flex;
align-items: center;
cursor: pointer;
font: var(--tui-font-text-l);
font-weight: bold;
margin: 0 0 -0.75rem;
}
.button {
@include transition(transform);
margin-right: 1rem;
&_open {
transform: rotate(180deg);
}
}
.expand {
align-self: stretch;
}
.g-form-group {
padding-top: 0.75rem;
&_invalid::before,
&_invalid::after {
background: var(--tui-error-bg);
}
}

View File

@@ -0,0 +1,38 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
Output,
} from '@angular/core'
import { ControlContainer } from '@angular/forms'
import { ValueSpecObject } from 'start-sdk/lib/config/configTypes'
@Component({
selector: 'form-object',
templateUrl: './form-object.component.html',
styleUrls: ['./form-object.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormObjectComponent {
@Input()
spec!: ValueSpecObject
@Input()
open = false
@Output()
readonly openChange = new EventEmitter<boolean>()
private readonly container = inject(ControlContainer)
get invalid() {
return !this.container.valid && this.container.touched
}
toggle() {
this.open = !this.open
this.openChange.emit(this.open)
}
}

View File

@@ -0,0 +1,14 @@
<tui-select
[tuiHintContent]="spec.description"
[tuiTextfieldCleaner]="!spec.required"
[(ngModel)]="selected"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<select
tuiSelect
[labels]="[spec.warning ? ' ' + spec.warning : '']"
[items]="[items]"
></select>
</tui-select>

View File

@@ -0,0 +1,21 @@
import { Component } from '@angular/core'
import { ValueSpecSelect } from 'start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({
selector: 'form-select',
templateUrl: './form-select.component.html',
})
export class FormSelectComponent extends Control<ValueSpecSelect, string> {
readonly items = Object.values(this.spec.values)
get selected(): string | null {
return this.value && this.spec.values[this.value]
}
set selected(value: string | null) {
this.value =
Object.entries(this.spec.values).find(([_, v]) => value === v)?.[0] ??
null
}
}

View File

@@ -0,0 +1,27 @@
<tui-input
[tuiTextfieldCustomContent]="spec.masked ? toggle : ''"
[tuiHintContent]="spec.description"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<input
tuiTextfield
[class.masked]="spec.masked && masked"
[placeholder]="spec.placeholder || ''"
[attr.inputmode]="spec.inputmode"
/>
</tui-input>
<ng-template #toggle>
<button
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="toggle"
[icon]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
(click)="masked = !masked"
></button>
</ng-template>

View File

@@ -0,0 +1,9 @@
.toggle {
pointer-events: auto;
margin-left: auto;
}
.masked {
font-family: text-security-disc;
-webkit-text-security: disc;
}

View File

@@ -0,0 +1,12 @@
import { Component } from '@angular/core'
import { ValueSpecString } from 'start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({
selector: 'form-string',
templateUrl: './form-string.component.html',
styleUrls: ['./form-string.component.scss'],
})
export class FormStringComponent extends Control<ValueSpecString, string> {
masked = true
}

View File

@@ -0,0 +1,11 @@
<tui-text-area
[tuiHintContent]="spec.description"
[expandable]="true"
[rows]="6"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
<textarea tuiTextfield [placeholder]="spec.placeholder || ''"></textarea>
</tui-text-area>

View File

@@ -0,0 +1,9 @@
import { Component } from '@angular/core'
import { ValueSpecTextarea } from 'start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({
selector: 'form-text',
templateUrl: './form-text.component.html',
})
export class FormTextComponent extends Control<ValueSpecTextarea, string> {}

View File

@@ -0,0 +1,11 @@
<form-control
[spec]="selectSpec"
[formControlName]="select"
(tuiValueChanges)="onUnion($event)"
></form-control>
<tui-elastic-container class="g-form-group" [formGroupName]="value">
<form-group
class="group"
[spec]="(union && spec.variants[union].spec) || {}"
></form-group>
</tui-elastic-container>

View File

@@ -0,0 +1,8 @@
:host {
display: block;
}
.group {
display: block;
margin-top: 1rem;
}

View File

@@ -0,0 +1,59 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnChanges,
} from '@angular/core'
import { ControlContainer, FormGroupName } from '@angular/forms'
import {
unionSelectKey,
ValueSpecSelect,
ValueSpecUnion,
unionValueKey,
} from 'start-sdk/lib/config/configTypes'
import { FormService } from '../../../services/form.service'
import { tuiPure } from '@taiga-ui/cdk'
@Component({
selector: 'form-union',
templateUrl: './form-union.component.html',
styleUrls: ['./form-union.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
viewProviders: [
{
provide: ControlContainer,
useExisting: FormGroupName,
},
],
})
export class FormUnionComponent implements OnChanges {
@Input()
spec!: ValueSpecUnion
selectSpec!: ValueSpecSelect
readonly select = unionSelectKey
readonly value = unionValueKey
private readonly form = inject(FormGroupName)
private readonly formService = inject(FormService)
get union(): string {
return this.form.value[unionSelectKey]
}
@tuiPure
onUnion(union: string) {
this.form.control.setControl(
unionValueKey,
this.formService.getFormGroup(
union ? this.spec.variants[union].spec : {},
),
)
}
ngOnChanges() {
this.selectSpec = this.formService.getUnionSelectSpec(this.spec, this.union)
}
}

View File

@@ -0,0 +1,88 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TuiValueChangesModule } from '@taiga-ui/cdk'
import {
TuiButtonModule,
TuiErrorModule,
TuiExpandModule,
TuiHintModule,
TuiLinkModule,
TuiModeModule,
TuiTextfieldControllerModule,
TuiTooltipModule,
} from '@taiga-ui/core'
import {
TuiElasticContainerModule,
TuiFieldErrorPipeModule,
TuiInputFilesModule,
TuiInputModule,
TuiInputNumberModule,
TuiMultiSelectModule,
TuiPromptModule,
TuiSelectModule,
TuiTagModule,
TuiTextAreaModule,
TuiToggleModule,
} from '@taiga-ui/kit'
import { FormGroupComponent } from './form-group/form-group.component'
import { FormStringComponent } from './form-string/form-string.component'
import { FormBooleanComponent } from './form-boolean/form-boolean.component'
import { FormTextComponent } from './form-text/form-text.component'
import { FormNumberComponent } from './form-number/form-number.component'
import { FormSelectComponent } from './form-select/form-select.component'
import { FormFileComponent } from './form-file/form-file.component'
import { FormMultiselectComponent } from './form-multiselect/form-multiselect.component'
import { FormUnionComponent } from './form-union/form-union.component'
import { FormObjectComponent } from './form-object/form-object.component'
import { FormArrayComponent } from './form-array/form-array.component'
import { FormControlComponent } from './form-control/form-control.component'
import { MustachePipe } from './mustache.pipe'
import { ControlDirective } from './control.directive'
@NgModule({
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
TuiInputModule,
TuiInputNumberModule,
TuiInputFilesModule,
TuiTextAreaModule,
TuiSelectModule,
TuiMultiSelectModule,
TuiToggleModule,
TuiTooltipModule,
TuiHintModule,
TuiModeModule,
TuiTagModule,
TuiButtonModule,
TuiExpandModule,
TuiTextfieldControllerModule,
TuiLinkModule,
TuiPromptModule,
TuiErrorModule,
TuiFieldErrorPipeModule,
TuiValueChangesModule,
TuiElasticContainerModule,
],
declarations: [
FormGroupComponent,
FormControlComponent,
FormStringComponent,
FormBooleanComponent,
FormTextComponent,
FormNumberComponent,
FormSelectComponent,
FormMultiselectComponent,
FormFileComponent,
FormUnionComponent,
FormObjectComponent,
FormArrayComponent,
MustachePipe,
ControlDirective,
],
exports: [FormGroupComponent],
})
export class FormModule {}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@angular/core'
import { ControlDirective } from './control.directive'
@Injectable()
export class InvalidService {
private readonly controls: ControlDirective[] = []
scrollIntoView() {
this.controls.find(({ invalid }) => invalid)?.scrollIntoView()
}
add(control: ControlDirective) {
this.controls.push(control)
}
remove(control: ControlDirective) {
this.controls.splice(this.controls.indexOf(control), 1)
}
}

View File

@@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core'
const Mustache = require('mustache')
@Pipe({
name: 'mustache',
})
export class MustachePipe implements PipeTransform {
transform(value: any, displayAs: string): string {
return displayAs && Mustache.render(displayAs, value)
}
}

View File

@@ -0,0 +1,100 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
} from '@angular/core'
import { compare, getValueByPointer, Operation } from 'fast-json-patch'
import { isObject } from '@start9labs/shared'
import { tuiIsNumber } from '@taiga-ui/cdk'
@Component({
selector: 'app-config-dep',
template: `
<tui-notification>
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
{{ package }}
</h3>
The following modifications have been made to {{ package }} to satisfy
{{ dep }}:
<ul>
<li *ngFor="let d of diff" [innerHTML]="d"></li>
</ul>
To accept these modifications, click "Save".
</tui-notification>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppConfigDepComponent implements OnChanges {
@Input()
package = ''
@Input()
dep = ''
@Input()
original: object = {}
@Input()
value: object = {}
diff: string[] = []
ngOnChanges() {
this.diff = compare(this.original, this.value).map(
op => `${this.getPath(op)}: ${this.getMessage(op)}`,
)
}
private getPath(operation: Operation): string {
const path = operation.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (tuiIsNumber(path[path.length - 1])) {
path.pop()
}
return path.join(' &rarr; ')
}
private getMessage(operation: Operation): string {
switch (operation.op) {
case 'add':
return `Added ${this.getNewValue(operation.value)}`
case 'remove':
return `Removed ${this.getOldValue(operation.path)}`
case 'replace':
return `Changed from ${this.getOldValue(
operation.path,
)} to ${this.getNewValue(operation.value)}`
default:
return `Unknown operation`
}
}
private getOldValue(path: any): string {
const val = getValueByPointer(this.original, path)
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'entry'
} else {
return 'list'
}
}
private getNewValue(val: any): string {
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'new entry'
} else {
return 'new list'
}
}
}

View File

@@ -1,21 +1,27 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular'
import { ReactiveFormsModule } from '@angular/forms'
import {
TuiButtonModule,
TuiLoaderModule,
TuiModeModule,
TuiNotificationModule,
} from '@taiga-ui/core'
import { AppConfigPage } from './app-config.page'
import { TextSpinnerComponentModule } from '@start9labs/shared'
import { FormObjectModule } from 'src/app/components/form-object/form-object.module'
import { FormPageModule } from '../form/form.module'
import { AppConfigDepComponent } from './app-config-dep.component'
@NgModule({
declarations: [AppConfigPage],
imports: [
CommonModule,
FormsModule,
IonicModule,
TextSpinnerComponentModule,
FormObjectModule,
ReactiveFormsModule,
FormPageModule,
TuiLoaderModule,
TuiNotificationModule,
TuiButtonModule,
TuiModeModule,
],
declarations: [AppConfigPage, AppConfigDepComponent],
exports: [AppConfigPage],
})
export class AppConfigPageModule {}

View File

@@ -1,149 +1,58 @@
<ion-header>
<ion-toolbar>
<ion-title>Config</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>
<!-- loading -->
<tui-loader
*ngIf="loadingText; else content"
size="l"
[textContent]="loadingText"
></tui-loader>
<ion-content class="ion-padding">
<!-- loading -->
<text-spinner
*ngIf="loading; else notLoading"
[text]="loadingText"
></text-spinner>
<!-- not loading -->
<ng-template #content>
<ng-container *ngIf="!loadingError && pkg else error">
<tui-notification
*ngIf="form && !form.form.dirty && !original && !pkg.installed?.status?.configured"
status="success"
class="notification"
>
{{ pkg.manifest.title }} has been automatically configured with
recommended defaults. Make whatever changes you want, then click "Save".
</tui-notification>
<!-- not loading -->
<ng-template #notLoading>
<ion-item *ngIf="loadingError; else noError">
<ion-label>
<ion-text color="danger">{{ loadingError }}</ion-text>
</ion-label>
</ion-item>
<!-- auto-config -->
<app-config-dep
*ngIf="dependentInfo && value && original"
[package]="pkg.manifest.title"
[dep]="dependentInfo.title"
[original]="original"
[value]="value"
></app-config-dep>
<ng-template #noError>
<ng-container *ngIf="configForm && !pkg.installed?.status?.configured">
<ng-container *ngIf="!original; else hasOriginal">
<h2
*ngIf="!configForm.dirty"
class="ion-padding-bottom header-details"
>
<ion-text color="success">
{{ pkg.manifest.title }} has been automatically configured with
recommended defaults. Make whatever changes you want, then click
"Save".
</ion-text>
</h2>
</ng-container>
<ng-template #hasOriginal>
<h2 *ngIf="hasNewOptions" class="ion-padding-bottom header-details">
<ion-text color="success">
New config options! To accept the default values, click "Save".
You may also customize these new options below.
</ion-text>
</h2>
</ng-template>
</ng-container>
<!-- no options -->
<tui-notification
*ngIf="!pkg.installed?.['has-config']"
status="warning"
class="notification"
>
No config options for {{ pkg.manifest.title }} {{ pkg.manifest.version }}.
</tui-notification>
<!-- auto-config -->
<ion-item
lines="none"
*ngIf="dependentInfo"
class="rec-item"
style="margin-bottom: 48px"
>
<ion-label>
<h2 style="display: flex; align-items: center">
<img
style="width: 18px; margin: 4px"
[src]="pkg.icon"
[alt]="pkg.manifest.title"
/>
<ion-text
style="margin: 5px; font-family: 'Montserrat'; font-size: 18px"
>
{{ pkg.manifest.title }}
</ion-text>
</h2>
<p>
<ion-text color="dark">
The following modifications have been made to {{
pkg.manifest.title }} to satisfy {{ dependentInfo.title }}:
<ul>
<li *ngFor="let d of diff" [innerHtml]="d"></li>
</ul>
To accept these modifications, click "Save".
</ion-text>
</p>
</ion-label>
</ion-item>
<!-- has config -->
<form-page
#form
tuiMode="onDark"
[spec]="spec"
[value]="value || {}"
[buttons]="buttons"
[patch]="patch"
>
<button tuiButton appearance="flat" class="reset" type="reset">
Reset Defaults
</button>
</form-page>
</ng-container>
<!-- no options -->
<ion-item *ngIf="!pkg.installed?.['has-config']">
<ion-label>
<p>
No config options for {{ pkg.manifest.title }} {{
pkg.manifest.version }}.
</p>
</ion-label>
</ion-item>
<!-- has config -->
<form
*ngIf="configForm && configSpec"
[formGroup]="configForm"
novalidate
>
<form-object
[objectSpec]="configSpec"
[formGroup]="configForm"
[current]="configForm.value"
[original]="original"
(hasNewOptions)="hasNewOptions = true"
></form-object>
</form>
</ng-template>
<ng-template #error>
<tui-notification status="error">
<div [innerHTML]="loadingError"></div>
</tui-notification>
</ng-template>
</ion-content>
<ion-footer>
<ion-toolbar>
<ng-container *ngIf="!loading && !loadingError">
<ion-buttons
*ngIf="configForm && pkg.installed?.['has-config']"
slot="start"
class="ion-padding-start"
>
<ion-button fill="clear" (click)="resetDefaults()">
<ion-icon slot="start" name="refresh"></ion-icon>
Reset Defaults
</ion-button>
</ion-buttons>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button
*ngIf="configForm"
fill="solid"
color="primary"
[disabled]="saving"
(click)="tryConfigure()"
class="enter-click btn-128"
[class.no-click]="saving"
>
Save
</ion-button>
<ion-button
*ngIf="!configForm"
fill="solid"
color="dark"
(click)="dismiss()"
class="enter-click btn-128"
>
Close
</ion-button>
</ion-buttons>
</ng-container>
</ion-toolbar>
</ion-footer>
</ng-template>

View File

@@ -1,12 +1,8 @@
.notifier-item {
margin: 12px;
margin-top: 0px;
border-radius: 12px;
// kills the lines
--border-width: 0;
--inner-border-width: 0;
.notification {
font-size: 1rem;
margin-bottom: 1rem;
}
.header-details {
font-size: 20px;
}
.reset {
margin-right: auto;
}

View File

@@ -1,372 +1,197 @@
import { Component, Input } from '@angular/core'
import { Component, Inject } from '@angular/core'
import { endWith, firstValueFrom, Subscription } from 'rxjs'
import { tuiIsString } from '@taiga-ui/cdk'
import {
AlertController,
ModalController,
LoadingController,
IonicSafeString,
} from '@ionic/angular'
TuiAlertService,
TuiDialogContext,
TuiDialogService,
TuiNotification,
} from '@taiga-ui/core'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
ErrorToastService,
getErrorMessage,
isEmptyObject,
isObject,
} from '@start9labs/shared'
import { DependentInfo } from 'src/app/types/dependent-info'
import { InputSpec } from 'start-sdk/lib/config/config-types'
import { getErrorMessage, isEmptyObject } from '@start9labs/shared'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import { UntypedFormGroup } from '@angular/forms'
import {
convertValuesRecursive,
FormService,
} from 'src/app/services/form.service'
import { compare, Operation, getValueByPointer } from 'fast-json-patch'
import { compare, Operation } from 'fast-json-patch'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { getAllPackages, getPackage } from 'src/app/util/get-package-data'
import { Breakages } from 'src/app/services/api/api.types'
import { InvalidService } from '../../components/form/invalid.service'
import { LoadingService } from '../loading/loading.service'
import { DependentInfo } from '../../types/dependent-info'
import { ActionButton } from '../form/form.page'
export interface PackageConfigData {
readonly pkgId: string
readonly dependentInfo?: DependentInfo
}
@Component({
selector: 'app-config',
templateUrl: './app-config.page.html',
styleUrls: ['./app-config.page.scss'],
providers: [InvalidService],
})
export class AppConfigPage {
@Input() pkgId!: string
@Input() dependentInfo?: DependentInfo
readonly pkgId = this.context.data.pkgId
readonly dependentInfo = this.context.data.dependentInfo
pkg!: PackageDataEntry
loadingError = ''
loadingText = this.dependentInfo
? `Setting properties to accommodate ${this.dependentInfo.title}`
: 'Loading Config'
loadingText = ''
pkg?: PackageDataEntry
spec: InputSpec = {}
patch: Operation[] = []
buttons: ActionButton<any>[] = [
{
text: 'Save',
handler: value => this.save(value),
},
]
configSpec?: InputSpec
configForm?: UntypedFormGroup
original?: object // only if existing config
diff?: string[] // only if dependent info
loading = true
hasNewOptions = false
saving = false
loadingError: string | IonicSafeString = ''
original: object | null = null
value: object | null = null
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<void, PackageConfigData>,
private readonly dialogs: TuiDialogService,
private readonly alerts: TuiAlertService,
private readonly loader: LoadingService,
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly formService: FormService,
private readonly patch: PatchDB<DataModel>,
private readonly patchDb: PatchDB<DataModel>,
) {}
async ngOnInit() {
try {
const pkg = await getPackage(this.patch, this.pkgId)
if (!pkg?.installed?.['has-config']) return
this.pkg = await getPackage(this.patchDb, this.pkgId)
this.pkg = pkg
if (!this.pkg) {
this.loadingError = 'This service does not exist'
let newConfig: object | undefined
let patch: Operation[] | undefined
return
}
if (this.dependentInfo) {
this.loadingText = `Setting properties to accommodate ${this.dependentInfo.title}`
const {
'old-config': oc,
'new-config': nc,
spec: s,
} = await this.embassyApi.dryConfigureDependency({
const depConfig = await this.embassyApi.dryConfigureDependency({
'dependency-id': this.pkgId,
'dependent-id': this.dependentInfo.id,
})
this.original = oc
newConfig = nc
this.configSpec = s
patch = compare(this.original, newConfig)
this.original = depConfig['old-config']
this.value = depConfig['new-config'] || this.original
this.spec = depConfig.spec
this.patch = compare(this.original, this.value)
} else {
this.loadingText = 'Loading Config'
const { config: c, spec: s } = await this.embassyApi.getPackageConfig({
const { config, spec } = await this.embassyApi.getPackageConfig({
id: this.pkgId,
})
this.original = c
this.configSpec = s
}
this.configForm = this.formService.createForm(
this.configSpec!,
newConfig || this.original,
)
if (patch) {
this.diff = this.getDiff(patch)
this.markDirty(patch)
this.original = config
this.value = config
this.spec = spec
}
} catch (e: any) {
this.loadingError = getErrorMessage(e)
const message = getErrorMessage(e)
this.loadingError = tuiIsString(message) ? message : message.value
} finally {
this.loading = false
this.loadingText = ''
}
}
resetDefaults() {
this.configForm = this.formService.createForm(this.configSpec!)
const patch = compare(this.original || {}, this.configForm.value)
this.markDirty(patch)
}
async dismiss() {
if (this.configForm?.dirty) {
this.presentAlertUnsaved()
} else {
this.modalCtrl.dismiss()
}
}
async tryConfigure() {
convertValuesRecursive(this.configSpec!, this.configForm!)
if (this.configForm!.invalid) {
document
.getElementsByClassName('validation-error')[0]
?.scrollIntoView({ behavior: 'smooth' })
return
}
this.saving = true
const config = this.configForm!.value
const fileKeys = Object.keys(config).filter(
key => config[key] instanceof File,
)
let loader: HTMLIonLoadingElement | undefined
if (fileKeys.length) {
loader = await this.loadingCtrl.create({
message: `Uploading File${fileKeys.length > 1 ? 's' : ''}...`,
})
await loader.present()
try {
const hashes = await Promise.all(
fileKeys.map(key => this.embassyApi.uploadFile(config[key])),
)
fileKeys.forEach((key, i) => (config[key] = hashes[i]))
} catch (e: any) {
this.errToast.present(e)
} finally {
await loader.dismiss()
return
}
}
if (await hasCurrentDeps(this.patch, this.pkgId)) {
this.dryConfigure(config, loader)
} else {
this.configure(config, loader)
}
}
private async dryConfigure(
config: Record<string, any>,
loader?: HTMLIonLoadingElement,
) {
const message = 'Checking dependent services...'
if (loader) {
loader.message = message
} else {
loader = await this.loadingCtrl.create({ message })
await loader.present()
}
private async save(config: any) {
const loader = new Subscription()
try {
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config,
})
await this.uploadFiles(config, loader)
if (isEmptyObject(breakages)) {
this.configure(config, loader)
if (await hasCurrentDeps(this.patchDb, this.pkgId)) {
await this.configureDeps(config, loader)
} else {
await loader.dismiss()
const proceed = await this.presentAlertBreakages(breakages)
if (proceed) {
this.configure(config)
} else {
this.saving = false
}
await this.configure(config, loader)
}
} catch (e: any) {
this.errToast.present(e)
this.saving = false
loader.dismiss()
}
}
private async configure(
config: Record<string, any>,
loader?: HTMLIonLoadingElement,
) {
const message = 'Saving...'
if (loader) {
loader.message = message
} else {
loader = await this.loadingCtrl.create({ message })
await loader.present()
}
try {
await this.embassyApi.setPackageConfig({
id: this.pkgId,
config,
})
this.modalCtrl.dismiss()
} catch (e: any) {
this.errToast.present(e)
this.showError(e)
} finally {
this.saving = false
loader.dismiss()
loader.unsubscribe()
}
}
private async presentAlertBreakages(breakages: Breakages): Promise<boolean> {
let message: string =
private async uploadFiles(config: Record<string, any>, loader: Subscription) {
loader.unsubscribe()
// TODO: Could be nested files
const keys = Object.keys(config).filter(key => config[key] instanceof File)
const message = `Uploading File${keys.length > 1 ? 's' : ''}...`
if (!keys.length) return
loader.add(this.loader.open(message).subscribe())
const hashes = await Promise.all(
keys.map(key => this.embassyApi.uploadFile(config[key])),
)
keys.forEach((key, i) => (config[key] = hashes[i]))
}
private async configureDeps(
config: Record<string, any>,
loader: Subscription,
) {
loader.unsubscribe()
loader.add(this.loader.open('Checking dependent services...').subscribe())
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config,
})
loader.unsubscribe()
if (isEmptyObject(breakages) || (await this.approveBreakages(breakages))) {
await this.configure(config, loader)
}
}
private async configure(config: Record<string, any>, loader: Subscription) {
loader.unsubscribe()
loader.add(this.loader.open('Saving...').subscribe())
await this.embassyApi.setPackageConfig({ id: this.pkgId, config })
this.context.$implicit.complete()
}
private async approveBreakages(breakages: Breakages): Promise<boolean> {
const packages = await getAllPackages(this.patchDb)
const message =
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
const localPkgs = await getAllPackages(this.patch)
const bullets = Object.keys(breakages).map(id => {
const title = localPkgs[id].manifest.title
return `<li><b>${title}</b></li>`
})
message = `${message}${bullets}</ul>`
const content = `${message}${Object.keys(breakages).map(
id => `<li><b>${packages[id].manifest.title}</b></li>`,
)}</ul>`
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'Warning',
message,
buttons: [
{
text: 'Cancel',
role: 'cancel',
handler: () => {
resolve(false)
},
},
{
text: 'Continue',
handler: () => {
resolve(true)
},
cssClass: 'enter-click',
},
],
cssClass: 'alert-warning-message',
return firstValueFrom(
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
)
}
private showError(e: any) {
const message = getErrorMessage(e)
this.alerts
.open(tuiIsString(message) ? message : message.value, {
status: TuiNotification.Error,
autoClose: false,
label: 'Error',
})
await alert.present()
})
}
private getDiff(patch: Operation[]): string[] {
return patch.map(op => {
let message: string
switch (op.op) {
case 'add':
message = `Added ${this.getNewValue(op.value)}`
break
case 'remove':
message = `Removed ${this.getOldValue(op.path)}`
break
case 'replace':
message = `Changed from ${this.getOldValue(
op.path,
)} to ${this.getNewValue(op.value)}`
break
default:
message = `Unknown operation`
}
let displayPath: string
const arrPath = op.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (typeof arrPath[arrPath.length - 1] === 'number') {
arrPath.pop()
}
displayPath = arrPath.join(' &rarr; ')
return `${displayPath}: ${message}`
})
}
private getOldValue(path: any): string {
const val = getValueByPointer(this.original, path)
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'entry'
} else {
return 'list'
}
}
private getNewValue(val: any): string {
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'new entry'
} else {
return 'new list'
}
}
private markDirty(patch: Operation[]) {
patch.forEach(op => {
const arrPath = op.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (op.op !== 'remove') this.configForm!.get(arrPath)?.markAsDirty()
if (typeof arrPath[arrPath.length - 1] === 'number') {
const prevPath = arrPath.slice(0, arrPath.length - 1)
this.configForm!.get(prevPath)?.markAsDirty()
}
})
}
private async presentAlertUnsaved() {
const alert = await this.alertCtrl.create({
header: 'Unsaved Changes',
message: 'You have unsaved changes. Are you sure you want to leave?',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: `Leave`,
handler: () => {
this.modalCtrl.dismiss()
},
cssClass: 'enter-click',
},
],
})
await alert.present()
.subscribe()
}
}

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ReactiveFormsModule } from '@angular/forms'
import { TuiValueChangesModule } from '@taiga-ui/cdk'
import { TuiButtonModule, TuiModeModule } from '@taiga-ui/core'
import { FormModule } from 'src/app/components/form/form.module'
import { FormPage } from './form.page'
@NgModule({
imports: [
CommonModule,
ReactiveFormsModule,
TuiValueChangesModule,
TuiButtonModule,
TuiModeModule,
FormModule,
],
declarations: [FormPage],
exports: [FormPage],
})
export class FormPageModule {}

View File

@@ -0,0 +1,20 @@
<form
novalidate
(reset.capture.prevent.stop)="onReset()"
[formGroup]="form"
(tuiValueChanges)="markAsDirty()"
>
<form-group [spec]="spec"></form-group>
<footer tuiMode="onDark">
<ng-content></ng-content>
<button
*ngFor="let button of buttons; let last = last"
tuiButton
[appearance]="last ? 'primary' : 'flat'"
[type]="last ? 'submit' : 'button'"
(click)="onClick(button.handler)"
>
{{ button.text }}
</button>
</footer>
</form>

View File

@@ -0,0 +1,12 @@
footer {
position: sticky;
bottom: 0;
z-index: 10;
display: flex;
justify-content: flex-end;
padding: 1rem 0;
margin: 1rem 0 -1rem;
gap: 1rem;
background: var(--tui-elevation-01);
border-top: 1px solid var(--tui-base-02);
}

View File

@@ -0,0 +1,96 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
OnInit,
} from '@angular/core'
import { FormService } from 'src/app/services/form.service'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { TuiDialogContext } from '@taiga-ui/core'
import { tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'
import { InvalidService } from '../../components/form/invalid.service'
import { TuiDialogFormService } from '@taiga-ui/kit'
import { FormGroup } from '@angular/forms'
import { compare, Operation } from 'fast-json-patch'
export interface ActionButton<T> {
text: string
handler: (value: T) => Promise<boolean | void> | void
}
export interface FormContext<T> {
spec: InputSpec
buttons: ActionButton<T>[]
value?: T
patch?: Operation[]
}
@Component({
selector: 'form-page',
templateUrl: './form.page.html',
styleUrls: ['./form.page.scss'],
providers: [InvalidService],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormPage<T extends Record<string, any>> implements OnInit {
private readonly dialogFormService = inject(TuiDialogFormService)
private readonly formService = inject(FormService)
private readonly invalidService = inject(InvalidService)
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
POLYMORPHEUS_CONTEXT,
{ optional: true },
)
@Input() spec = this.context?.data.spec || {}
@Input() buttons = this.context?.data.buttons || []
@Input() patch = this.context?.data.patch || []
@Input() value?: T = this.context?.data.value
form = new FormGroup({})
ngOnInit() {
this.dialogFormService.markAsPristine()
this.form = this.formService.createForm(this.spec, this.value)
this.process(this.patch)
}
onReset() {
const { value } = this.form
this.form = this.formService.createForm(this.spec)
this.process(compare(this.form.value, value))
tuiMarkControlAsTouchedAndValidate(this.form)
this.markAsDirty()
}
async onClick(handler: ActionButton<T>['handler']) {
tuiMarkControlAsTouchedAndValidate(this.form)
this.invalidService.scrollIntoView()
if (this.form.valid && (await handler(this.form.value as T))) {
this.context?.$implicit.complete()
}
}
markAsDirty() {
this.dialogFormService.markAsDirty()
}
private process(patch: Operation[]) {
patch.forEach(({ op, path }) => {
const control = this.form.get(path.substring(1).split('/'))
if (!control || !control.parent) return
if (op !== 'remove') {
control.markAsDirty()
control.markAsTouched()
}
control.parent.markAsDirty()
control.parent.markAsTouched()
})
}
}

View File

@@ -5,7 +5,7 @@ import {
convertValuesRecursive,
FormService,
} from 'src/app/services/form.service'
import { InputSpec } from 'start-sdk/lib/config/config-types'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
export interface ActionButton {
text: string

View File

@@ -0,0 +1,21 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
@include shadow(3);
display: flex;
align-items: center;
max-width: 80%;
margin: auto;
padding: 1.5rem;
background: var(--tui-elevation-01);
border-radius: var(--tui-radius-m);
--tui-primary: var(--tui-warning-fill);
}
tui-loader {
flex-shrink: 0;
width: 2rem;
margin-right: 1rem;
}

View File

@@ -0,0 +1,20 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusContent,
} from '@tinkoff/ng-polymorpheus'
@Component({
template: `
<tui-loader></tui-loader>
<ng-container *polymorpheusOutlet="content as text">
{{ text }}
</ng-container>
`,
styleUrls: ['./loading.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LoadingComponent {
readonly content: PolymorpheusContent =
inject(POLYMORPHEUS_CONTEXT)['content']
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core'
import { TuiLoaderModule } from '@taiga-ui/core'
import { PolymorpheusModule } from '@tinkoff/ng-polymorpheus'
import { tuiAsDialog } from '@taiga-ui/cdk'
import { LoadingComponent } from './loading.component'
import { LoadingService } from './loading.service'
@NgModule({
imports: [PolymorpheusModule, TuiLoaderModule],
declarations: [LoadingComponent],
exports: [LoadingComponent],
providers: [tuiAsDialog(LoadingService)],
})
export class LoadingModule {}

View File

@@ -0,0 +1,10 @@
import { Injectable } from '@angular/core'
import { AbstractTuiDialogService } from '@taiga-ui/cdk'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { LoadingComponent } from './loading.component'
@Injectable({ providedIn: `root` })
export class LoadingService extends AbstractTuiDialogService<unknown> {
protected readonly component = new PolymorpheusComponent(LoadingComponent)
protected readonly defaultOptions = {}
}

View File

@@ -14,7 +14,7 @@ import { ActionSheetButton } from '@ionic/core'
import { ErrorToastService, sameUrl, toUrl } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ValueSpecObject } from 'start-sdk/lib/config/config-types'
import { ValueSpecObject } from 'start-sdk/lib/config/configTypes'
import {
GenericFormPage,
GenericFormOptions,

View File

@@ -15,7 +15,11 @@ import {
import { ErrorToastService } from '@start9labs/shared'
import { AlertController, LoadingController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ModalService } from 'src/app/services/modal.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
AppConfigPage,
PackageConfigData,
} from 'src/app/modals/app-config/app-config.page'
import { DependencyInfo } from '../../pipes/to-dependencies.pipe'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { ConnectionService } from 'src/app/services/connection.service'
@@ -47,7 +51,7 @@ export class AppShowStatusComponent {
private readonly loadingCtrl: LoadingController,
private readonly embassyApi: ApiService,
private readonly launcherService: UiLauncherService,
private readonly modalService: ModalService,
private readonly formDialog: FormDialogService,
private readonly connectionService: ConnectionService,
private readonly patch: PatchDB<DataModel>,
) {}
@@ -80,9 +84,10 @@ export class AppShowStatusComponent {
this.launcherService.launch(addressInfo)
}
async presentModalConfig(): Promise<void> {
return this.modalService.presentModalConfig({
pkgId: this.id,
presentModalConfig(): void {
this.formDialog.open<PackageConfigData>(AppConfigPage, {
label: `${this.pkg.manifest.title} configuration`,
data: { pkgId: this.id },
})
}

View File

@@ -6,7 +6,11 @@ import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ModalService } from 'src/app/services/modal.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
AppConfigPage,
PackageConfigData,
} from 'src/app/modals/app-config/app-config.page'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { from, map, Observable } from 'rxjs'
import { PatchDB } from 'patch-db-client'
@@ -28,7 +32,7 @@ export class ToButtonsPipe implements PipeTransform {
private readonly route: ActivatedRoute,
private readonly navCtrl: NavController,
private readonly modalCtrl: ModalController,
private readonly modalService: ModalService,
private readonly formDialog: FormDialogService,
private readonly apiService: ApiService,
private readonly patch: PatchDB<DataModel>,
) {}
@@ -49,8 +53,11 @@ export class ToButtonsPipe implements PipeTransform {
},
// config
{
action: async () =>
this.modalService.presentModalConfig({ pkgId: pkg.manifest.id }),
action: () =>
this.formDialog.open<PackageConfigData>(AppConfigPage, {
label: `${pkg.manifest.title} configuration`,
data: { pkgId: pkg.manifest.id },
}),
title: 'Config',
description: `Customize ${pkgTitle}`,
icon: 'options-outline',

View File

@@ -6,7 +6,11 @@ import {
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { DependentInfo } from 'src/app/types/dependent-info'
import { ModalService } from 'src/app/services/modal.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
AppConfigPage,
PackageConfigData,
} from 'src/app/modals/app-config/app-config.page'
import { Manifest } from '@start9labs/marketplace'
export interface DependencyInfo {
@@ -25,7 +29,7 @@ export interface DependencyInfo {
export class ToDependenciesPipe implements PipeTransform {
constructor(
private readonly navCtrl: NavController,
private readonly modalService: ModalService,
private readonly formDialog: FormDialogService,
) {}
transform(pkg: PackageDataEntry): DependencyInfo[] {
@@ -96,7 +100,15 @@ export class ToDependenciesPipe implements PipeTransform {
case 'update':
return this.installDep(pkg.manifest, depId)
case 'configure':
return this.configureDep(pkg.manifest, depId)
return this.formDialog.open<PackageConfigData>(AppConfigPage, {
label: `${
pkg.installed!['dependency-info'][depId].title
} configuration`,
data: {
pkgId: depId,
dependentInfo: pkg.manifest,
},
})
}
}
@@ -127,9 +139,12 @@ export class ToDependenciesPipe implements PipeTransform {
title: manifest.title,
}
await this.modalService.presentModalConfig({
pkgId: dependencyId,
dependentInfo,
return this.formDialog.open<PackageConfigData>(AppConfigPage, {
label: 'Config',
data: {
pkgId: dependencyId,
dependentInfo,
},
})
}
}

View File

@@ -12,7 +12,7 @@ import {
} from 'src/app/modals/generic-input/generic-input.component'
import { PatchDB } from 'patch-db-client'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { InputSpec } from 'start-sdk/lib/config/config-types'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import * as yaml from 'js-yaml'
import { v4 } from 'uuid'
import { DataModel, DevData } from 'src/app/services/patch-db/data-model'

View File

@@ -1,4 +1,4 @@
import { InputSpec } from 'start-sdk/lib/config/config-types'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { DevProjectData } from 'src/app/services/patch-db/data-model'
export type BasicInfo = {

View File

@@ -2,21 +2,24 @@ import { Component } from '@angular/core'
import {
ActionSheetController,
AlertController,
LoadingController,
ModalController,
ToastController,
} from '@ionic/angular'
import { AlertInput } from '@ionic/core'
import { TuiDialogOptions } from '@taiga-ui/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ActionSheetButton } from '@ionic/core'
import { ValueSpecObject } from 'start-sdk/lib/config/config-types'
import { ValueSpecObject } from 'start-sdk/lib/config/configTypes'
import { RR } from 'src/app/services/api/api.types'
import { pauseFor, ErrorToastService } from '@start9labs/shared'
import {
GenericFormPage,
GenericFormOptions,
} from 'src/app/modals/generic-form/generic-form.page'
import { ConfigService } from 'src/app/services/config.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormContext, FormPage } from 'src/app/modals/form/form.page'
import { LoadingService } from 'src/app/modals/loading/loading.service'
interface WiFiForm {
ssid: string
password: string
}
@Component({
selector: 'wifi',
@@ -34,8 +37,8 @@ export class WifiPage {
private readonly api: ApiService,
private readonly toastCtrl: ToastController,
private readonly alertCtrl: AlertController,
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly loader: LoadingService,
private readonly formDialog: FormDialogService,
private readonly errToast: ErrorToastService,
private readonly actionCtrl: ActionSheetController,
private readonly config: ConfigService,
@@ -109,33 +112,27 @@ export class WifiPage {
await alert.present()
}
async presentModalAdd(ssid?: string, needsPW: boolean = true) {
const wifiSpec = getWifiValueSpec(ssid, needsPW)
const options: GenericFormOptions = {
title: wifiSpec.name,
spec: wifiSpec.spec,
buttons: [
{
text: 'Save for Later',
handler: async (value: { ssid: string; password: string }) =>
this.save(value.ssid, value.password),
},
{
text: 'Save and Connect',
handler: async (value: { ssid: string; password: string }) =>
this.saveAndConnect(value.ssid, value.password),
isSubmit: true,
},
],
presentModalAdd(ssid?: string, needsPW: boolean = true) {
const { name, spec } = getWifiValueSpec(ssid, needsPW)
const options: Partial<TuiDialogOptions<FormContext<WiFiForm>>> = {
label: name,
data: {
spec,
buttons: [
{
text: 'Save for Later',
handler: async ({ ssid, password }) => this.save(ssid, password),
},
{
text: 'Save and Connect',
handler: async ({ ssid, password }) =>
this.saveAndConnect(ssid, password),
},
],
},
}
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: options,
})
await modal.present()
this.formDialog.open(FormPage, options)
}
async presentAction(ssid: string) {
@@ -171,10 +168,7 @@ export class WifiPage {
}
private async setCountry(country: string): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Setting country...',
})
await loader.present()
const loader = this.loader.open('Setting country...').subscribe()
try {
await this.api.setWifiCountry({ country })
@@ -183,7 +177,7 @@ export class WifiPage {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
@@ -262,10 +256,9 @@ export class WifiPage {
}
private async connect(ssid: string): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Connecting. This could take a while...',
})
await loader.present()
const loader = this.loader
.open('Connecting. This could take a while...')
.subscribe()
try {
await this.api.connectWifi({ ssid })
@@ -273,15 +266,12 @@ export class WifiPage {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
private async delete(ssid: string): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Deleting...',
})
await loader.present()
const loader = this.loader.open('Deleting...').subscribe()
try {
await this.api.deleteWifi({ ssid })
@@ -290,15 +280,12 @@ export class WifiPage {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
private async save(ssid: string, password: string): Promise<boolean> {
const loader = await this.loadingCtrl.create({
message: 'Saving...',
})
await loader.present()
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.addWifi({
@@ -313,7 +300,7 @@ export class WifiPage {
this.errToast.present(e)
return false
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
@@ -321,10 +308,9 @@ export class WifiPage {
ssid: string,
password: string,
): Promise<boolean> {
const loader = await this.loadingCtrl.create({
message: 'Connecting. This could take a while...',
})
await loader.present()
const loader = this.loader
.open('Connecting. This could take a while...')
.subscribe()
try {
await this.api.addWifi({
@@ -339,7 +325,7 @@ export class WifiPage {
this.errToast.present(e)
return false
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
}

View File

@@ -13,7 +13,7 @@ import {
Manifest,
} from '@start9labs/marketplace'
import { Log } from '@start9labs/shared'
import { unionSelectKey } from 'start-sdk/lib/config/config-types'
import { unionSelectKey } from 'start-sdk/lib/config/configTypes'
export module Mock {
export const ServerUpdated: ServerStatusInfo = {
@@ -1111,6 +1111,24 @@ export module Mock {
warning: 'Careful changing this',
required: true,
variants: {
dummy: {
name: 'Dummy',
spec: {
name: {
type: 'string',
inputmode: 'text',
name: 'Name',
description: null,
required: true,
masked: false,
pattern: '^[a-zA-Z]+$',
patternDescription: 'Must contain only letters.',
placeholder: null,
warning: null,
default: null,
},
},
},
internal: { name: 'Internal', spec: {} },
external: {
name: 'External',
@@ -1187,7 +1205,6 @@ export module Mock {
'the default port for your Bitcoin node. default: 8333, testnet: 18333, regtest: 18444',
warning: null,
required: true,
default: 8333,
range: '(0, 9998]',
units: null,
placeholder: null,

View File

@@ -1,7 +1,7 @@
import { Dump, Revision } from 'patch-db-client'
import { MarketplacePkg, StoreInfo, Manifest } from '@start9labs/marketplace'
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
import { InputSpec } from 'start-sdk/lib/config/config-types'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import {
DataModel,
DependencyError,

View File

@@ -0,0 +1,41 @@
import { inject, Injectable, Injector, Type } from '@angular/core'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { TuiDialogFormService, TuiPromptData } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
export const PROMPT: Partial<TuiDialogOptions<TuiPromptData>> = {
label: 'Unsaved Changes',
data: {
content: 'You have unsaved changes. Are you sure you want to leave?',
yes: 'Leave',
no: 'Cancel',
},
}
@Injectable({ providedIn: 'root' })
export class FormDialogService {
private readonly dialogs = inject(TuiDialogService)
private readonly formService = new TuiDialogFormService(this.dialogs)
private readonly prompt = this.formService.withPrompt(PROMPT)
private readonly injector = Injector.create({
parent: inject(Injector),
providers: [
{
provide: TuiDialogFormService,
useValue: this.formService,
},
],
})
open<T>(component: Type<any>, options: Partial<TuiDialogOptions<T>> = {}) {
this.dialogs
.open(new PolymorpheusComponent(component, this.injector), {
closeable: this.prompt,
dismissible: this.prompt,
...options,
})
.subscribe({
complete: () => this.formService.markAsPristine(),
})
}
}

View File

@@ -7,6 +7,7 @@ import {
ValidatorFn,
Validators,
} from '@angular/forms'
import { getDefaultString, Range } from '../util/config-utilities'
import {
InputSpec,
isValueSpecListOf,
@@ -26,8 +27,8 @@ import {
ValueSpecUnion,
unionSelectKey,
ValueSpecTextarea,
} from 'start-sdk/lib/config/config-types'
import { getDefaultString, Range } from '../util/config-utilities'
unionValueKey,
} from 'start-sdk/lib/config/configTypes'
const Mustache = require('mustache')
@Injectable({
@@ -43,37 +44,37 @@ export class FormService {
return this.getFormGroup(spec, [], current)
}
getUnionSelectSpec(
spec: ValueSpecUnion,
selection: string | null,
): ValueSpecSelect {
return {
...spec,
type: 'select',
default: selection,
values: Object.fromEntries(
Object.entries(spec.variants).map(([key, { name }]) => [key, name]),
),
}
}
getUnionObject(
spec: ValueSpecUnion,
selection: string | null,
): UntypedFormGroup {
const { name, description, warning, variants, required } = spec
const selectSpec: ValueSpecSelect = {
type: 'select',
name,
description,
warning,
default: selection,
required,
values: Object.keys(variants).reduce(
(prev, curr) => ({
...prev,
[curr]: variants[curr].name,
}),
{},
),
}
const selectedSpec = selection ? variants[selection].spec : {}
return this.getFormGroup({
[unionSelectKey]: selectSpec,
...selectedSpec,
const group = this.getFormGroup({
[unionSelectKey]: this.getUnionSelectSpec(spec, selection),
})
group.setControl(
unionValueKey,
this.getFormGroup(selection ? spec.variants[selection].spec : {}),
)
return group
}
getListItem(spec: ValueSpecList, entry: any) {
getListItem(spec: ValueSpecList, entry?: any) {
const listItemValidators = getListItemValidators(spec)
if (isValueSpecListOf(spec, 'string')) {
return this.formBuilder.control(entry, listItemValidators)
@@ -84,7 +85,7 @@ export class FormService {
}
}
private getFormGroup(
getFormGroup(
config: InputSpec,
validators: ValidatorFn[] = [],
current?: Record<string, any> | null,
@@ -246,7 +247,7 @@ function fileValidators(spec: ValueSpecFile): ValidatorFn[] {
return validators
}
export function numberInRange(stringRange: string): ValidatorFn {
export function numberInRange(stringRange: string = ''): ValidatorFn {
return control => {
const value = control.value
if (!value) return null
@@ -254,32 +255,30 @@ export function numberInRange(stringRange: string): ValidatorFn {
Range.from(stringRange).checkIncludes(value)
return null
} catch (e: any) {
return { numberNotInRange: { value: `Number must be ${e.message}` } }
return { numberNotInRange: `Number must be ${e.message}` }
}
}
}
export function isNumber(): ValidatorFn {
return control =>
!control.value || control.value == Number(control.value)
? null
: { notNumber: { value: control.value } }
return ({ value }) =>
!value || value == Number(value) ? null : { notNumber: 'Must be a number' }
}
export function isInteger(): ValidatorFn {
return control =>
!control.value || control.value == Math.trunc(control.value)
return ({ value }) =>
!value || value == Math.trunc(value)
? null
: { numberNotInteger: { value: control.value } }
: { numberNotInteger: 'Must be an integer' }
}
export function listInRange(stringRange: string): ValidatorFn {
export function listInRange(stringRange: string = ''): ValidatorFn {
return control => {
try {
Range.from(stringRange).checkIncludes(control.value.length)
return null
} catch (e: any) {
return { listNotInRange: { value: `List must be ${e.message}` } }
return { listNotInRange: `List must be ${e.message}` }
}
}
}
@@ -289,7 +288,7 @@ export function listItemIssue(): ValidatorFn {
const { controls } = parentControl as UntypedFormArray
const problemChild = controls.find(c => c.invalid)
if (problemChild) {
return { listItemIssue: { value: 'Invalid entries' } }
return { listItemIssue: 'Invalid entries' }
} else {
return null
}
@@ -324,9 +323,7 @@ export function listUnique(spec: ValueSpecList): ValidatorFn {
}
return {
listNotUnique: {
value: `${display1} and ${display2} are not unique.${uniqueMessage}`,
},
listNotUnique: `${display1} and ${display2} are not unique.${uniqueMessage}`,
}
}
}

View File

@@ -1,24 +0,0 @@
import { Injectable } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { DependentInfo } from 'src/app/types/dependent-info'
import { AppConfigPage } from 'src/app/modals/app-config/app-config.page'
@Injectable({
providedIn: 'root',
})
export class ModalService {
constructor(private readonly modalCtrl: ModalController) {}
async presentModalConfig(componentProps: ComponentProps): Promise<void> {
const modal = await this.modalCtrl.create({
component: AppConfigPage,
componentProps,
})
await modal.present()
}
}
interface ComponentProps {
pkgId: string
dependentInfo?: DependentInfo
}

View File

@@ -1,4 +1,4 @@
import { InputSpec } from 'start-sdk/lib/config/config-types'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { Url } from '@start9labs/shared'
import { Manifest } from '@start9labs/marketplace'
import { BasicInfo } from 'src/app/pages/developer-routes/developer-menu/form-info'

View File

@@ -1,4 +1,4 @@
import { DefaultString } from 'start-sdk/lib/config/config-types'
import { DefaultString } from 'start-sdk/lib/config/configTypes'
export class Range {
min?: number
@@ -6,7 +6,7 @@ export class Range {
minInclusive!: boolean
maxInclusive!: boolean
static from(s: string): Range {
static from(s: string = '(*,*)'): Range {
const r = new Range()
r.minInclusive = s.startsWith('[')
r.maxInclusive = s.endsWith(']')

View File

@@ -1,3 +1,8 @@
@font-face {
font-family: 'text-security-disc';
src: url('/assets/fonts/text-security-disc.woff2') format('woff2');
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
@@ -339,3 +344,26 @@ ul {
padding-left: 40px;
list-style-type: disc;
}
// Taiga UI overrides
tui-dialog {
transform: translate3d(0, 0, 0);
}
tui-opt-group[data-label^='⚠️']:before {
color: var(--tui-warning-fill);
}
tui-hint[data-appearance='onDark'] {
background: white !important;
color: #222 !important;
}
[tuiLink] {
color: var(--tui-link) !important;
&:hover {
color: var(--tui-link-hover) !important;
}
}