mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
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:
committed by
Aiden McClelland
parent
ded16549f7
commit
09b91cc663
@@ -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",
|
||||
|
||||
89
frontend/package-lock.json
generated
89
frontend/package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
BIN
frontend/projects/shared/assets/fonts/text-security-disc.woff2
Normal file
BIN
frontend/projects/shared/assets/fonts/text-security-disc.woff2
Normal file
Binary file not shown.
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
description: spec.description || null,
|
||||
edited: control.dirty
|
||||
}"
|
||||
></form-label>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
class="label"
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
description: spec.description || null,
|
||||
edited: control.dirty,
|
||||
required: spec.required
|
||||
}"
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
description: spec.description || null,
|
||||
edited: control.dirty
|
||||
}"
|
||||
></form-label>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
description: spec.description || null,
|
||||
edited: control.dirty,
|
||||
newOptions: hasNewOptions
|
||||
}"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
description: spec.description || null,
|
||||
newOptions: hasNewOptions,
|
||||
edited: formGroup.dirty
|
||||
}"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
24
frontend/projects/ui/src/app/components/form/control.ts
Normal file
24
frontend/projects/ui/src/app/components/form/control.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
:first-child {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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]) || []
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
.toggle {
|
||||
pointer-events: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.masked {
|
||||
font-family: text-security-disc;
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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> {}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
88
frontend/projects/ui/src/app/components/form/form.module.ts
Normal file
88
frontend/projects/ui/src/app/components/form/form.module.ts
Normal 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 {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(' → ')
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(' → ')
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
21
frontend/projects/ui/src/app/modals/form/form.module.ts
Normal file
21
frontend/projects/ui/src/app/modals/form/form.module.ts
Normal 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 {}
|
||||
20
frontend/projects/ui/src/app/modals/form/form.page.html
Normal file
20
frontend/projects/ui/src/app/modals/form/form.page.html
Normal 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>
|
||||
12
frontend/projects/ui/src/app/modals/form/form.page.scss
Normal file
12
frontend/projects/ui/src/app/modals/form/form.page.scss
Normal 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);
|
||||
}
|
||||
96
frontend/projects/ui/src/app/modals/form/form.page.ts
Normal file
96
frontend/projects/ui/src/app/modals/form/form.page.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 = {}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
41
frontend/projects/ui/src/app/services/form-dialog.service.ts
Normal file
41
frontend/projects/ui/src/app/services/form-dialog.service.ts
Normal 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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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(']')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user