diff --git a/frontend/angular.json b/frontend/angular.json index 43d138d9c..dca324ac9 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -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", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e0171a7af..a3d54da15 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" diff --git a/frontend/package.json b/frontend/package.json index 2fa7c6d27..144fa63cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/projects/shared/assets/fonts/text-security-disc.woff2 b/frontend/projects/shared/assets/fonts/text-security-disc.woff2 new file mode 100644 index 000000000..ddaf38b11 Binary files /dev/null and b/frontend/projects/shared/assets/fonts/text-security-disc.woff2 differ diff --git a/frontend/projects/ui/src/app/app.module.ts b/frontend/projects/ui/src/app/app.module.ts index 31dd44da1..2e4bd2d2f 100644 --- a/frontend/projects/ui/src/app/app.module.ts +++ b/frontend/projects/ui/src/app/app.module.ts @@ -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], diff --git a/frontend/projects/ui/src/app/app.providers.ts b/frontend/projects/ui/src/app/app.providers.ts index 5d0ccc4f2..dea3e6292 100644 --- a/frontend/projects/ui/src/app/app.providers.ts +++ b/frontend/projects/ui/src/app/app.providers.ts @@ -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, diff --git a/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.ts b/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.ts index cb3a1116e..a63b81520 100644 --- a/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.ts +++ b/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.ts @@ -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' diff --git a/frontend/projects/ui/src/app/components/form-object/form-object.pipes.ts b/frontend/projects/ui/src/app/components/form-object/form-object.pipes.ts index 6faae4ce3..43a6fd98b 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object.pipes.ts +++ b/frontend/projects/ui/src/app/components/form-object/form-object.pipes.ts @@ -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(`${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) } } diff --git a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-file/form-file.component.html b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-file/form-file.component.html index 6cd9884bb..9a06542f2 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-file/form-file.component.html +++ b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-file/form-file.component.html @@ -2,7 +2,7 @@ diff --git a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-file/form-file.component.ts b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-file/form-file.component.ts index b95fbc7a8..d83c46838 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-file/form-file.component.ts +++ b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-file/form-file.component.ts @@ -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', diff --git a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-input/form-input.component.html b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-input/form-input.component.html index b0599b891..bb8914c62 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-input/form-input.component.html +++ b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-input/form-input.component.html @@ -2,7 +2,7 @@ class="label" [data]="{ name: spec.name, - description: spec.description, + description: spec.description || null, edited: control.dirty, required: spec.required }" diff --git a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-input/form-input.component.ts b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-input/form-input.component.ts index c7ebe504e..76f34b0b4 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-input/form-input.component.ts +++ b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-input/form-input.component.ts @@ -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({ diff --git a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-select/form-select.component.html b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-select/form-select.component.html index d9fe4cb0d..3c8febdef 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-select/form-select.component.html +++ b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-select/form-select.component.html @@ -2,7 +2,7 @@ diff --git a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-select/form-select.component.ts b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-select/form-select.component.ts index 7688b0a9d..abc4a8d91 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-select/form-select.component.ts +++ b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-select/form-select.component.ts @@ -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', diff --git a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-subform/form-subform.component.html b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-subform/form-subform.component.html index a125e0235..6afcea097 100644 --- a/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-subform/form-subform.component.html +++ b/frontend/projects/ui/src/app/components/form-object/form-object/controls/form-subform/form-subform.component.html @@ -5,7 +5,7 @@ = 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) + } +} diff --git a/frontend/projects/ui/src/app/components/form/control.ts b/frontend/projects/ui/src/app/components/form/control.ts new file mode 100644 index 000000000..784ab9ada --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/control.ts @@ -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 { + private readonly control: FormControlComponent = + 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) + } +} diff --git a/frontend/projects/ui/src/app/components/form/form-array/form-array.component.html b/frontend/projects/ui/src/app/components/form/form-array/form-array.component.html new file mode 100644 index 000000000..59ae693c2 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-array/form-array.component.html @@ -0,0 +1,50 @@ +
+ {{ spec.name }} + + +
+ + + + + {{ item.value | mustache : $any(spec.spec).displayAs }} + + + + + + + + + diff --git a/frontend/projects/ui/src/app/components/form/form-array/form-array.component.scss b/frontend/projects/ui/src/app/components/form/form-array/form-array.component.scss new file mode 100644 index 000000000..9b6415ff7 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-array/form-array.component.scss @@ -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; +} diff --git a/frontend/projects/ui/src/app/components/form/form-array/form-array.component.ts b/frontend/projects/ui/src/app/components/form/form-array/form-array.component.ts new file mode 100644 index 000000000..dd6c3bb0e --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-array/form-array.component.ts @@ -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() + + 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(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(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) + } +} diff --git a/frontend/projects/ui/src/app/components/form/form-boolean/form-boolean.component.html b/frontend/projects/ui/src/app/components/form/form-boolean/form-boolean.component.html new file mode 100644 index 000000000..74b262e9a --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-boolean/form-boolean.component.html @@ -0,0 +1,10 @@ +{{ spec.name }} + + diff --git a/frontend/projects/ui/src/app/components/form/form-boolean/form-boolean.component.scss b/frontend/projects/ui/src/app/components/form/form-boolean/form-boolean.component.scss new file mode 100644 index 000000000..6acffed43 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-boolean/form-boolean.component.scss @@ -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; +} diff --git a/frontend/projects/ui/src/app/components/form/form-boolean/form-boolean.component.ts b/frontend/projects/ui/src/app/components/form/form-boolean/form-boolean.component.ts new file mode 100644 index 000000000..332aa7b48 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-boolean/form-boolean.component.ts @@ -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 {} diff --git a/frontend/projects/ui/src/app/components/form/form-control/form-control.component.html b/frontend/projects/ui/src/app/components/form/form-control/form-control.component.html new file mode 100644 index 000000000..90ecf7252 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-control/form-control.component.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + {{ spec.warning }} +
+ + +
+
diff --git a/frontend/projects/ui/src/app/components/form/form-control/form-control.component.scss b/frontend/projects/ui/src/app/components/form/form-control/form-control.component.scss new file mode 100644 index 000000000..844651118 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-control/form-control.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; +} + +.buttons { + margin-top: 0.5rem; + + :first-child { + margin-right: 0.5rem; + } +} diff --git a/frontend/projects/ui/src/app/components/form/form-control/form-control.component.ts b/frontend/projects/ui/src/app/components/form/form-control/form-control.component.ts new file mode 100644 index 000000000..ffc477184 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-control/form-control.component.ts @@ -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) => ({ + required: 'Required', + pattern: () => control.spec.patternDescription, + }), + }, + ], +}) +export class FormControlComponent< + T extends ValueSpec, + V, +> extends AbstractTuiNullableControl { + @Input() + spec!: T + + @ViewChild('warning') + warning?: TemplateRef> + + 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(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 + } +} diff --git a/frontend/projects/ui/src/app/components/form/form-file/form-file.component.html b/frontend/projects/ui/src/app/components/form/form-file/form-file.component.html new file mode 100644 index 000000000..f90b55fe2 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-file/form-file.component.html @@ -0,0 +1,31 @@ + + + +
+
+ {{ spec.name }} + * + +
+ + + Click or drop file here + +
+
Drop file here
+
+
diff --git a/frontend/projects/ui/src/app/components/form/form-file/form-file.component.scss b/frontend/projects/ui/src/app/components/form/form-file/form-file.component.scss new file mode 100644 index 000000000..f4abf6232 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-file/form-file.component.scss @@ -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; +} diff --git a/frontend/projects/ui/src/app/components/form/form-file/form-file.component.ts b/frontend/projects/ui/src/app/components/form/form-file/form-file.component.ts new file mode 100644 index 000000000..fbc9239c1 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-file/form-file.component.ts @@ -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 {} diff --git a/frontend/projects/ui/src/app/components/form/form-group/form-group.component.html b/frontend/projects/ui/src/app/components/form/form-group/form-group.component.html new file mode 100644 index 000000000..1c4f8301a --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-group/form-group.component.html @@ -0,0 +1,30 @@ + + + + + + diff --git a/frontend/projects/ui/src/app/components/form/form-group/form-group.component.scss b/frontend/projects/ui/src/app/components/form/form-group/form-group.component.scss new file mode 100644 index 000000000..ce5665fc0 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-group/form-group.component.scss @@ -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; +} diff --git a/frontend/projects/ui/src/app/components/form/form-group/form-group.component.ts b/frontend/projects/ui/src/app/components/form/form-group/form-group.component.ts new file mode 100644 index 000000000..11c194278 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-group/form-group.component.ts @@ -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 + } +} diff --git a/frontend/projects/ui/src/app/components/form/form-group/form-group.providers.ts b/frontend/projects/ui/src/app/components/form/form-group/form-group.providers.ts new file mode 100644 index 000000000..d0f9a7a2f --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-group/form-group.providers.ts @@ -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, + }, + }, +] diff --git a/frontend/projects/ui/src/app/components/form/form-multiselect/form-multiselect.component.html b/frontend/projects/ui/src/app/components/form/form-multiselect/form-multiselect.component.html new file mode 100644 index 000000000..409a9ba1c --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-multiselect/form-multiselect.component.html @@ -0,0 +1,9 @@ + + {{ spec.name }} + + diff --git a/frontend/projects/ui/src/app/components/form/form-multiselect/form-multiselect.component.ts b/frontend/projects/ui/src/app/components/form/form-multiselect/form-multiselect.component.ts new file mode 100644 index 000000000..4a79834c4 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-multiselect/form-multiselect.component.ts @@ -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]) || [] + } +} diff --git a/frontend/projects/ui/src/app/components/form/form-number/form-number.component.html b/frontend/projects/ui/src/app/components/form/form-number/form-number.component.html new file mode 100644 index 000000000..a681530c3 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-number/form-number.component.html @@ -0,0 +1,14 @@ + + {{ spec.name }} + * + + diff --git a/frontend/projects/ui/src/app/components/form/form-number/form-number.component.ts b/frontend/projects/ui/src/app/components/form/form-number/form-number.component.ts new file mode 100644 index 000000000..2d2963f48 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-number/form-number.component.ts @@ -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 { + 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 + } +} diff --git a/frontend/projects/ui/src/app/components/form/form-object/form-object.component.html b/frontend/projects/ui/src/app/components/form/form-object/form-object.component.html new file mode 100644 index 000000000..763e4386f --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-object/form-object.component.html @@ -0,0 +1,25 @@ +

+ + + {{ spec.name }} + +

+ + +
+ +
+
diff --git a/frontend/projects/ui/src/app/components/form/form-object/form-object.component.scss b/frontend/projects/ui/src/app/components/form/form-object/form-object.component.scss new file mode 100644 index 000000000..c167c89fa --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-object/form-object.component.scss @@ -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); + } +} diff --git a/frontend/projects/ui/src/app/components/form/form-object/form-object.component.ts b/frontend/projects/ui/src/app/components/form/form-object/form-object.component.ts new file mode 100644 index 000000000..1b72f128d --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-object/form-object.component.ts @@ -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() + + private readonly container = inject(ControlContainer) + + get invalid() { + return !this.container.valid && this.container.touched + } + + toggle() { + this.open = !this.open + this.openChange.emit(this.open) + } +} diff --git a/frontend/projects/ui/src/app/components/form/form-select/form-select.component.html b/frontend/projects/ui/src/app/components/form/form-select/form-select.component.html new file mode 100644 index 000000000..2cceb17ba --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-select/form-select.component.html @@ -0,0 +1,14 @@ + + {{ spec.name }} + * + + diff --git a/frontend/projects/ui/src/app/components/form/form-select/form-select.component.ts b/frontend/projects/ui/src/app/components/form/form-select/form-select.component.ts new file mode 100644 index 000000000..500f23deb --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-select/form-select.component.ts @@ -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 { + 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 + } +} diff --git a/frontend/projects/ui/src/app/components/form/form-string/form-string.component.html b/frontend/projects/ui/src/app/components/form/form-string/form-string.component.html new file mode 100644 index 000000000..1561861a1 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-string/form-string.component.html @@ -0,0 +1,27 @@ + + {{ spec.name }} + * + + + + + diff --git a/frontend/projects/ui/src/app/components/form/form-string/form-string.component.scss b/frontend/projects/ui/src/app/components/form/form-string/form-string.component.scss new file mode 100644 index 000000000..a191523f7 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-string/form-string.component.scss @@ -0,0 +1,9 @@ +.toggle { + pointer-events: auto; + margin-left: auto; +} + +.masked { + font-family: text-security-disc; + -webkit-text-security: disc; +} diff --git a/frontend/projects/ui/src/app/components/form/form-string/form-string.component.ts b/frontend/projects/ui/src/app/components/form/form-string/form-string.component.ts new file mode 100644 index 000000000..b59065701 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-string/form-string.component.ts @@ -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 { + masked = true +} diff --git a/frontend/projects/ui/src/app/components/form/form-text/form-text.component.html b/frontend/projects/ui/src/app/components/form/form-text/form-text.component.html new file mode 100644 index 000000000..ddaca759e --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-text/form-text.component.html @@ -0,0 +1,11 @@ + + {{ spec.name }} + * + + diff --git a/frontend/projects/ui/src/app/components/form/form-text/form-text.component.ts b/frontend/projects/ui/src/app/components/form/form-text/form-text.component.ts new file mode 100644 index 000000000..3b42764b6 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-text/form-text.component.ts @@ -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 {} diff --git a/frontend/projects/ui/src/app/components/form/form-union/form-union.component.html b/frontend/projects/ui/src/app/components/form/form-union/form-union.component.html new file mode 100644 index 000000000..ffa07c942 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-union/form-union.component.html @@ -0,0 +1,11 @@ + + + + diff --git a/frontend/projects/ui/src/app/components/form/form-union/form-union.component.scss b/frontend/projects/ui/src/app/components/form/form-union/form-union.component.scss new file mode 100644 index 000000000..cfb2f95e8 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-union/form-union.component.scss @@ -0,0 +1,8 @@ +:host { + display: block; +} + +.group { + display: block; + margin-top: 1rem; +} diff --git a/frontend/projects/ui/src/app/components/form/form-union/form-union.component.ts b/frontend/projects/ui/src/app/components/form/form-union/form-union.component.ts new file mode 100644 index 000000000..f440d5e06 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form-union/form-union.component.ts @@ -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) + } +} diff --git a/frontend/projects/ui/src/app/components/form/form.module.ts b/frontend/projects/ui/src/app/components/form/form.module.ts new file mode 100644 index 000000000..92c1ebbdf --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/form.module.ts @@ -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 {} diff --git a/frontend/projects/ui/src/app/components/form/invalid.service.ts b/frontend/projects/ui/src/app/components/form/invalid.service.ts new file mode 100644 index 000000000..9f474e853 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/invalid.service.ts @@ -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) + } +} diff --git a/frontend/projects/ui/src/app/components/form/mustache.pipe.ts b/frontend/projects/ui/src/app/components/form/mustache.pipe.ts new file mode 100644 index 000000000..ec04b0104 --- /dev/null +++ b/frontend/projects/ui/src/app/components/form/mustache.pipe.ts @@ -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) + } +} diff --git a/frontend/projects/ui/src/app/modals/app-config/app-config-dep.component.ts b/frontend/projects/ui/src/app/modals/app-config/app-config-dep.component.ts new file mode 100644 index 000000000..a895ac222 --- /dev/null +++ b/frontend/projects/ui/src/app/modals/app-config/app-config-dep.component.ts @@ -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: ` + +

+ {{ package }} +

+ The following modifications have been made to {{ package }} to satisfy + {{ dep }}: +
    +
  • +
+ To accept these modifications, click "Save". +
+ `, + 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' + } + } +} diff --git a/frontend/projects/ui/src/app/modals/app-config/app-config.module.ts b/frontend/projects/ui/src/app/modals/app-config/app-config.module.ts index db9933f54..0b7f94131 100644 --- a/frontend/projects/ui/src/app/modals/app-config/app-config.module.ts +++ b/frontend/projects/ui/src/app/modals/app-config/app-config.module.ts @@ -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 {} diff --git a/frontend/projects/ui/src/app/modals/app-config/app-config.page.html b/frontend/projects/ui/src/app/modals/app-config/app-config.page.html index 3b70f65cc..bf400b392 100644 --- a/frontend/projects/ui/src/app/modals/app-config/app-config.page.html +++ b/frontend/projects/ui/src/app/modals/app-config/app-config.page.html @@ -1,149 +1,58 @@ - - - Config - - - - - - - + + - - - + + + + + {{ pkg.manifest.title }} has been automatically configured with + recommended defaults. Make whatever changes you want, then click "Save". + - - - - - {{ loadingError }} - - + + - - - -

- - {{ pkg.manifest.title }} has been automatically configured with - recommended defaults. Make whatever changes you want, then click - "Save". - -

-
- -

- - New config options! To accept the default values, click "Save". - You may also customize these new options below. - -

-
-
+ + + No config options for {{ pkg.manifest.title }} {{ pkg.manifest.version }}. + - - - -

- - - {{ pkg.manifest.title }} - -

-

- - The following modifications have been made to {{ - pkg.manifest.title }} to satisfy {{ dependentInfo.title }}: -

    -
  • -
- To accept these modifications, click "Save". - -

-
-
+ + + + +
- - - -

- No config options for {{ pkg.manifest.title }} {{ - pkg.manifest.version }}. -

-
-
- - -
- -
-
+ + +
+
-
- - - - - - - - Reset Defaults - - - - - Save - - - Close - - - - - + diff --git a/frontend/projects/ui/src/app/modals/app-config/app-config.page.scss b/frontend/projects/ui/src/app/modals/app-config/app-config.page.scss index e568528a8..d29fc1ffa 100644 --- a/frontend/projects/ui/src/app/modals/app-config/app-config.page.scss +++ b/frontend/projects/ui/src/app/modals/app-config/app-config.page.scss @@ -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; -} \ No newline at end of file +.reset { + margin-right: auto; +} diff --git a/frontend/projects/ui/src/app/modals/app-config/app-config.page.ts b/frontend/projects/ui/src/app/modals/app-config/app-config.page.ts index 867efceb6..efc0db2c2 100644 --- a/frontend/projects/ui/src/app/modals/app-config/app-config.page.ts +++ b/frontend/projects/ui/src/app/modals/app-config/app-config.page.ts @@ -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[] = [ + { + 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, + 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, + private readonly patchDb: PatchDB, ) {} 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, - 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, - 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 { - let message: string = + private async uploadFiles(config: Record, 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, + 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, 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 { + 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:
    ' - const localPkgs = await getAllPackages(this.patch) - const bullets = Object.keys(breakages).map(id => { - const title = localPkgs[id].manifest.title - return `
  • ${title}
  • ` - }) - message = `${message}${bullets}
` + const content = `${message}${Object.keys(breakages).map( + id => `
  • ${packages[id].manifest.title}
  • `, + )}` + 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(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() } } diff --git a/frontend/projects/ui/src/app/modals/form/form.module.ts b/frontend/projects/ui/src/app/modals/form/form.module.ts new file mode 100644 index 000000000..b2980608d --- /dev/null +++ b/frontend/projects/ui/src/app/modals/form/form.module.ts @@ -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 {} diff --git a/frontend/projects/ui/src/app/modals/form/form.page.html b/frontend/projects/ui/src/app/modals/form/form.page.html new file mode 100644 index 000000000..f845ad627 --- /dev/null +++ b/frontend/projects/ui/src/app/modals/form/form.page.html @@ -0,0 +1,20 @@ +
    + +
    + + +
    +
    diff --git a/frontend/projects/ui/src/app/modals/form/form.page.scss b/frontend/projects/ui/src/app/modals/form/form.page.scss new file mode 100644 index 000000000..fc2a2b19d --- /dev/null +++ b/frontend/projects/ui/src/app/modals/form/form.page.scss @@ -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); +} diff --git a/frontend/projects/ui/src/app/modals/form/form.page.ts b/frontend/projects/ui/src/app/modals/form/form.page.ts new file mode 100644 index 000000000..17fdc9c15 --- /dev/null +++ b/frontend/projects/ui/src/app/modals/form/form.page.ts @@ -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 { + text: string + handler: (value: T) => Promise | void +} + +export interface FormContext { + spec: InputSpec + buttons: ActionButton[] + value?: T + patch?: Operation[] +} + +@Component({ + selector: 'form-page', + templateUrl: './form.page.html', + styleUrls: ['./form.page.scss'], + providers: [InvalidService], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FormPage> implements OnInit { + private readonly dialogFormService = inject(TuiDialogFormService) + private readonly formService = inject(FormService) + private readonly invalidService = inject(InvalidService) + private readonly context = inject>>( + 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['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() + }) + } +} diff --git a/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts b/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts index 4893f7da1..d9544d08c 100644 --- a/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts +++ b/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts @@ -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 diff --git a/frontend/projects/ui/src/app/modals/loading/loading.component.scss b/frontend/projects/ui/src/app/modals/loading/loading.component.scss new file mode 100644 index 000000000..6c6e1c514 --- /dev/null +++ b/frontend/projects/ui/src/app/modals/loading/loading.component.scss @@ -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; +} diff --git a/frontend/projects/ui/src/app/modals/loading/loading.component.ts b/frontend/projects/ui/src/app/modals/loading/loading.component.ts new file mode 100644 index 000000000..fff032a1a --- /dev/null +++ b/frontend/projects/ui/src/app/modals/loading/loading.component.ts @@ -0,0 +1,20 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core' +import { + POLYMORPHEUS_CONTEXT, + PolymorpheusContent, +} from '@tinkoff/ng-polymorpheus' + +@Component({ + template: ` + + + {{ text }} + + `, + styleUrls: ['./loading.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LoadingComponent { + readonly content: PolymorpheusContent = + inject(POLYMORPHEUS_CONTEXT)['content'] +} diff --git a/frontend/projects/ui/src/app/modals/loading/loading.module.ts b/frontend/projects/ui/src/app/modals/loading/loading.module.ts new file mode 100644 index 000000000..e8494a066 --- /dev/null +++ b/frontend/projects/ui/src/app/modals/loading/loading.module.ts @@ -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 {} diff --git a/frontend/projects/ui/src/app/modals/loading/loading.service.ts b/frontend/projects/ui/src/app/modals/loading/loading.service.ts new file mode 100644 index 000000000..96ab4301f --- /dev/null +++ b/frontend/projects/ui/src/app/modals/loading/loading.service.ts @@ -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 { + protected readonly component = new PolymorpheusComponent(LoadingComponent) + protected readonly defaultOptions = {} +} diff --git a/frontend/projects/ui/src/app/modals/marketplace-settings/marketplace-settings.page.ts b/frontend/projects/ui/src/app/modals/marketplace-settings/marketplace-settings.page.ts index 5010978d3..7e73db06e 100644 --- a/frontend/projects/ui/src/app/modals/marketplace-settings/marketplace-settings.page.ts +++ b/frontend/projects/ui/src/app/modals/marketplace-settings/marketplace-settings.page.ts @@ -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, diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts index 742303fcc..8cbb69cbe 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts @@ -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, ) {} @@ -80,9 +84,10 @@ export class AppShowStatusComponent { this.launcherService.launch(addressInfo) } - async presentModalConfig(): Promise { - return this.modalService.presentModalConfig({ - pkgId: this.id, + presentModalConfig(): void { + this.formDialog.open(AppConfigPage, { + label: `${this.pkg.manifest.title} configuration`, + data: { pkgId: this.id }, }) } diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-buttons.pipe.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-buttons.pipe.ts index 955840564..bc73edc16 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-buttons.pipe.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-buttons.pipe.ts @@ -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, ) {} @@ -49,8 +53,11 @@ export class ToButtonsPipe implements PipeTransform { }, // config { - action: async () => - this.modalService.presentModalConfig({ pkgId: pkg.manifest.id }), + action: () => + this.formDialog.open(AppConfigPage, { + label: `${pkg.manifest.title} configuration`, + data: { pkgId: pkg.manifest.id }, + }), title: 'Config', description: `Customize ${pkgTitle}`, icon: 'options-outline', diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-dependencies.pipe.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-dependencies.pipe.ts index 6cde7d929..c0f547e13 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-dependencies.pipe.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/pipes/to-dependencies.pipe.ts @@ -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(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(AppConfigPage, { + label: 'Config', + data: { + pkgId: dependencyId, + dependentInfo, + }, }) } } diff --git a/frontend/projects/ui/src/app/pages/developer-routes/developer-list/developer-list.page.ts b/frontend/projects/ui/src/app/pages/developer-routes/developer-list/developer-list.page.ts index 0fcf04393..daecd00a7 100644 --- a/frontend/projects/ui/src/app/pages/developer-routes/developer-list/developer-list.page.ts +++ b/frontend/projects/ui/src/app/pages/developer-routes/developer-list/developer-list.page.ts @@ -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' diff --git a/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/form-info.ts b/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/form-info.ts index 5c3e1719f..4efadf47d 100644 --- a/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/form-info.ts +++ b/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/form-info.ts @@ -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 = { diff --git a/frontend/projects/ui/src/app/pages/server-routes/wifi/wifi.page.ts b/frontend/projects/ui/src/app/pages/server-routes/wifi/wifi.page.ts index ea66067a3..41038a5e7 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/wifi/wifi.page.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/wifi/wifi.page.ts @@ -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>> = { + 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 { - 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 { - 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 { - 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 { - 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 { - 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() } } } diff --git a/frontend/projects/ui/src/app/services/api/api.fixures.ts b/frontend/projects/ui/src/app/services/api/api.fixures.ts index 88f9b6e4a..e3fc0a120 100644 --- a/frontend/projects/ui/src/app/services/api/api.fixures.ts +++ b/frontend/projects/ui/src/app/services/api/api.fixures.ts @@ -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, diff --git a/frontend/projects/ui/src/app/services/api/api.types.ts b/frontend/projects/ui/src/app/services/api/api.types.ts index d1cd719c8..87418d4e0 100644 --- a/frontend/projects/ui/src/app/services/api/api.types.ts +++ b/frontend/projects/ui/src/app/services/api/api.types.ts @@ -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, diff --git a/frontend/projects/ui/src/app/services/form-dialog.service.ts b/frontend/projects/ui/src/app/services/form-dialog.service.ts new file mode 100644 index 000000000..b44218f75 --- /dev/null +++ b/frontend/projects/ui/src/app/services/form-dialog.service.ts @@ -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> = { + 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(component: Type, options: Partial> = {}) { + this.dialogs + .open(new PolymorpheusComponent(component, this.injector), { + closeable: this.prompt, + dismissible: this.prompt, + ...options, + }) + .subscribe({ + complete: () => this.formService.markAsPristine(), + }) + } +} diff --git a/frontend/projects/ui/src/app/services/form.service.ts b/frontend/projects/ui/src/app/services/form.service.ts index a49839898..ac58f431c 100644 --- a/frontend/projects/ui/src/app/services/form.service.ts +++ b/frontend/projects/ui/src/app/services/form.service.ts @@ -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 | 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}`, } } } diff --git a/frontend/projects/ui/src/app/services/modal.service.ts b/frontend/projects/ui/src/app/services/modal.service.ts deleted file mode 100644 index c34fce9a2..000000000 --- a/frontend/projects/ui/src/app/services/modal.service.ts +++ /dev/null @@ -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 { - const modal = await this.modalCtrl.create({ - component: AppConfigPage, - componentProps, - }) - await modal.present() - } -} - -interface ComponentProps { - pkgId: string - dependentInfo?: DependentInfo -} diff --git a/frontend/projects/ui/src/app/services/patch-db/data-model.ts b/frontend/projects/ui/src/app/services/patch-db/data-model.ts index 8ea218dc8..d6309eac5 100644 --- a/frontend/projects/ui/src/app/services/patch-db/data-model.ts +++ b/frontend/projects/ui/src/app/services/patch-db/data-model.ts @@ -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' diff --git a/frontend/projects/ui/src/app/util/config-utilities.ts b/frontend/projects/ui/src/app/util/config-utilities.ts index 8a9e4afd6..9c1130d18 100644 --- a/frontend/projects/ui/src/app/util/config-utilities.ts +++ b/frontend/projects/ui/src/app/util/config-utilities.ts @@ -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(']') diff --git a/frontend/projects/ui/src/styles.scss b/frontend/projects/ui/src/styles.scss index dbca8cae3..664b25611 100644 --- a/frontend/projects/ui/src/styles.scss +++ b/frontend/projects/ui/src/styles.scss @@ -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; + } +}