mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
port 040 config (#2657)
* port 040 config, WIP * update fixtures * use taiga modal for backups too * fix: update Taiga UI and refactor everything to work * chore: package-lock * fix interfaces and mocks for interfaces * better mocks * function to transform old spec to new * delete unused fns * delete unused FE config utils * fix exports from sdk * reorganize exports * functions to translate config * rename unionSelectKey and unionValueKey * Adding in the transformation of the getConfig to the new types. * chore: add Taiga UI to preloader --------- Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: J H <dragondef@gmail.com>
This commit is contained in:
9004
web/package-lock.json
generated
9004
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -43,20 +43,22 @@
|
||||
"@angular/service-worker": "^14.2.2",
|
||||
"@ionic/angular": "^6.1.15",
|
||||
"@materia-ui/ngx-monaco-editor": "^6.0.0",
|
||||
"@ng-web-apis/common": "^2.0.0",
|
||||
"@ng-web-apis/mutation-observer": "^2.0.0",
|
||||
"@ng-web-apis/resize-observer": "^2.0.0",
|
||||
"@ng-web-apis/common": "^3.0.6",
|
||||
"@ng-web-apis/mutation-observer": "^3.2.1",
|
||||
"@ng-web-apis/resize-observer": "^3.2.1",
|
||||
"@noble/curves": "^1.4.0",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@start9labs/argon2": "^0.2.2",
|
||||
"@start9labs/emver": "^0.1.5",
|
||||
"@start9labs/start-sdk": "file:../sdk/dist",
|
||||
"@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.84.0",
|
||||
"@taiga-ui/cdk": "3.84.0",
|
||||
"@taiga-ui/core": "3.84.0",
|
||||
"@taiga-ui/experimental": "3.84.0",
|
||||
"@taiga-ui/icons": "3.84.0",
|
||||
"@taiga-ui/kit": "3.84.0",
|
||||
"@tinkoff/ng-dompurify": "4.0.0",
|
||||
"@tinkoff/ng-event-plugins": "3.2.0",
|
||||
"angular-svg-round-progressbar": "^9.0.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"base64-js": "^1.5.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -11,7 +11,7 @@ import { ErrorToastService } from '@start9labs/shared'
|
||||
export class AppComponent {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly navCtrl: NavController,
|
||||
) {}
|
||||
|
||||
@@ -26,7 +26,7 @@ export class AppComponent {
|
||||
|
||||
await this.navCtrl.navigateForward(route)
|
||||
} catch (e: any) {
|
||||
this.errorToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import { RouteReuseStrategy } from '@angular/router'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { TuiRootModule } from '@taiga-ui/core'
|
||||
import { TuiAlertModule, TuiRootModule } from '@taiga-ui/core'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { MockApiService } from './services/api/mock-api.service'
|
||||
import { LiveApiService } from './services/api/live-api.service'
|
||||
@@ -41,6 +41,7 @@ const {
|
||||
RecoverPageModule,
|
||||
TransferPageModule,
|
||||
TuiRootModule,
|
||||
TuiAlertModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
ModalController,
|
||||
NavController,
|
||||
} from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { DiskInfo, ErrorService } from '@start9labs/shared'
|
||||
import { PasswordPage } from 'src/app/modals/password/password.page'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-attach',
|
||||
@@ -21,7 +21,7 @@ export class AttachPage {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
@@ -41,7 +41,7 @@ export class AttachPage {
|
||||
try {
|
||||
this.drives = await this.apiService.getDrives()
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export class AttachPage {
|
||||
await this.stateService.importDrive(guid, password)
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
ModalController,
|
||||
NavController,
|
||||
} from '@ionic/angular'
|
||||
import { DiskInfo, ErrorService, GuidPipe } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, ErrorToastService, GuidPipe } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
|
||||
@@ -27,7 +27,7 @@ export class EmbassyPage {
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly stateService: StateService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly guidPipe: GuidPipe,
|
||||
) {}
|
||||
|
||||
@@ -71,7 +71,7 @@ export class EmbassyPage {
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
@@ -148,7 +148,7 @@ export class EmbassyPage {
|
||||
await this.stateService.setupEmbassy(logicalname, password)
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} catch (e: any) {
|
||||
this.errorToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { IonicSlides } from '@ionic/angular'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import SwiperCore, { Swiper } from 'swiper'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import SwiperCore, { Swiper } from 'swiper'
|
||||
|
||||
SwiperCore.use([IonicSlides])
|
||||
|
||||
@@ -19,7 +19,7 @@ export class HomePage {
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class HomePage {
|
||||
await this.api.getPubKey()
|
||||
} catch (e: any) {
|
||||
this.error = true
|
||||
this.errToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
EMPTY,
|
||||
Observable,
|
||||
catchError,
|
||||
EMPTY,
|
||||
filter,
|
||||
from,
|
||||
interval,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
@@ -16,8 +17,6 @@ import {
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@Component({
|
||||
selector: 'app-loading',
|
||||
@@ -69,7 +68,7 @@ export class LoadingPage {
|
||||
constructor(
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly api: ApiService,
|
||||
private readonly errorToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
) {}
|
||||
|
||||
private async getStatus(): Promise<{
|
||||
@@ -96,7 +95,7 @@ export class LoadingPage {
|
||||
return from(this.getStatus()).pipe(
|
||||
filter(Boolean),
|
||||
catchError(e => {
|
||||
this.errorToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
return of(e)
|
||||
}),
|
||||
take(1),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, NavController } from '@ionic/angular'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page'
|
||||
import { ApiService, DiskBackupTarget } from 'src/app/services/api/api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PasswordPage } from '../../modals/password/password.page'
|
||||
|
||||
@@ -20,7 +20,7 @@ export class RecoverPage {
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly modalController: ModalController,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
@@ -62,7 +62,7 @@ export class RecoverPage {
|
||||
})
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { Component, ElementRef, Inject, NgZone, ViewChild } from '@angular/core'
|
||||
import { DownloadHTMLService, ErrorToastService } from '@start9labs/shared'
|
||||
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@@ -12,7 +12,8 @@ import { StateService } from 'src/app/services/state.service'
|
||||
})
|
||||
export class SuccessPage {
|
||||
@ViewChild('canvas', { static: true })
|
||||
private canvas: ElementRef<HTMLCanvasElement> = {} as ElementRef<HTMLCanvasElement>
|
||||
private canvas: ElementRef<HTMLCanvasElement> =
|
||||
{} as ElementRef<HTMLCanvasElement>
|
||||
private ctx: CanvasRenderingContext2D = {} as CanvasRenderingContext2D
|
||||
|
||||
torAddress?: string
|
||||
@@ -28,7 +29,7 @@ export class SuccessPage {
|
||||
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
private readonly errCtrl: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
private readonly api: ApiService,
|
||||
private readonly downloadHtml: DownloadHTMLService,
|
||||
@@ -83,7 +84,7 @@ export class SuccessPage {
|
||||
await this.api.exit()
|
||||
}
|
||||
} catch (e: any) {
|
||||
await this.errCtrl.present(e)
|
||||
await this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AlertController, NavController } from '@ionic/angular'
|
||||
import { DiskInfo, ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, ErrorToastService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
@@ -17,7 +17,7 @@ export class TransferPage {
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly errToastService: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
@@ -35,7 +35,7 @@ export class TransferPage {
|
||||
try {
|
||||
this.drives = await this.apiService.getDrives()
|
||||
} catch (e: any) {
|
||||
this.errToastService.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
BIN
web/projects/shared/assets/img/icon_transparent.png
Normal file
BIN
web/projects/shared/assets/img/icon_transparent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
BIN
web/projects/shared/assets/img/storefront-outline.png
Normal file
BIN
web/projects/shared/assets/img/storefront-outline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="1.5em" viewBox="0 0 24 24" width="1.5em">
|
||||
<path
|
||||
vector-effect="non-scaling-stroke"
|
||||
clip-rule="evenodd"
|
||||
d="M7.2395 3.45152L4.01497 3.49988C3.46275 3.50816 3.00837 3.06721 3.00008 2.51499C2.9918 1.96277 3.43275 1.50839 3.98497 1.50011L9.98497 1.41012C10.0214 1.40957 10.0575 1.41098 10.093 1.41425C10.6089 1.4111 11.1258 1.60635 11.5194 2L16 6.48053C16.781 7.26158 16.781 8.52791 16 9.30895L12.5492 12.7597L10.3089 15C9.52787 15.7811 8.26154 15.781 7.48049 15L5.24023 12.7597L2.99997 10.5195C2.21892 9.73843 2.21892 8.4721 2.99997 7.69105L7.2395 3.45152ZM10.1052 3.41422L4.41418 9.10527L5.80892 10.5H11.9805L14.5858 7.89474L10.1052 3.41422ZM20.4206 13.5445L20.4223 13.5453C20.6348 10.9945 18.7416 9.37791 17.9715 8.83085C17.9293 8.80059 17.8726 8.79856 17.8281 8.82545C17.7836 8.85234 17.7676 8.8632 17.7761 8.91357C17.9704 10.0886 16.2673 11.5428 16.2673 11.5428C15.8623 11.9726 15.5886 12.3895 15.4811 12.9331C15.2176 14.2662 16.0981 15.5842 17.4524 15.8519C18.8068 16.1196 20.1167 15.2659 20.3802 13.9329C20.4067 13.7992 20.4166 13.6763 20.4206 13.5445ZM3 18C2.44772 18 2 18.4477 2 19V21C2 21.5523 2.44772 22 3 22H21C21.5523 22 22 21.5523 22 21V19C22 18.4477 21.5523 18 21 18H3Z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,20 @@
|
||||
@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;
|
||||
min-width: 2rem;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
PolymorpheusContent,
|
||||
} from '@tinkoff/ng-polymorpheus'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<tui-loader [textContent]="content"></tui-loader>
|
||||
`,
|
||||
styleUrls: ['./loading.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LoadingComponent {
|
||||
readonly content: PolymorpheusContent =
|
||||
inject(POLYMORPHEUS_CONTEXT)['content']
|
||||
}
|
||||
13
web/projects/shared/src/components/loading/loading.module.ts
Normal file
13
web/projects/shared/src/components/loading/loading.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { TuiLoaderModule } from '@taiga-ui/core'
|
||||
import { tuiAsDialog } from '@taiga-ui/cdk'
|
||||
import { LoadingComponent } from './loading.component'
|
||||
import { LoadingService } from './loading.service'
|
||||
|
||||
@NgModule({
|
||||
imports: [TuiLoaderModule],
|
||||
declarations: [LoadingComponent],
|
||||
exports: [LoadingComponent],
|
||||
providers: [tuiAsDialog(LoadingService)],
|
||||
})
|
||||
export class LoadingModule {}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { AbstractTuiDialogService } from '@taiga-ui/cdk'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { LoadingComponent } from './loading.component'
|
||||
|
||||
@Injectable({ providedIn: `root` })
|
||||
export class LoadingService extends AbstractTuiDialogService<unknown> {
|
||||
protected readonly component = new PolymorpheusComponent(LoadingComponent)
|
||||
protected readonly defaultOptions = {}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ModalController } from '@ionic/angular'
|
||||
import { defer, isObservable, Observable, of } from 'rxjs'
|
||||
import { catchError, ignoreElements, share } from 'rxjs/operators'
|
||||
|
||||
import { getErrorMessage } from '../../services/error-toast.service'
|
||||
import { getErrorMessage } from '../../services/error.service'
|
||||
|
||||
@Component({
|
||||
selector: 'markdown',
|
||||
|
||||
@@ -9,6 +9,9 @@ export * from './components/alert/alert.component'
|
||||
export * from './components/alert/alert.module'
|
||||
export * from './components/alert/alert-button.directive'
|
||||
export * from './components/alert/alert-input.directive'
|
||||
export * from './components/loading/loading.component'
|
||||
export * from './components/loading/loading.module'
|
||||
export * from './components/loading/loading.service'
|
||||
export * from './components/markdown/markdown.component'
|
||||
export * from './components/markdown/markdown.component.module'
|
||||
export * from './components/text-spinner/text-spinner.component'
|
||||
@@ -42,7 +45,7 @@ export * from './pipes/unit-conversion/unit-conversion.pipe'
|
||||
|
||||
export * from './services/download-html.service'
|
||||
export * from './services/emver.service'
|
||||
export * from './services/error-toast.service'
|
||||
export * from './services/error.service'
|
||||
export * from './services/http.service'
|
||||
|
||||
export * from './themes/dark-theme/dark-theme.component'
|
||||
@@ -63,6 +66,7 @@ export * from './util/base-64'
|
||||
export * from './util/copy-to-clipboard'
|
||||
export * from './util/get-new-entries'
|
||||
export * from './util/get-pkg-id'
|
||||
export * from './util/invert'
|
||||
export * from './util/misc.util'
|
||||
export * from './util/rpc.util'
|
||||
export * from './util/to-local-iso-string'
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { IonicSafeString, ToastController } from '@ionic/angular'
|
||||
import { HttpError } from '../classes/http-error'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ErrorToastService {
|
||||
private toast?: HTMLIonToastElement
|
||||
|
||||
constructor(private readonly toastCtrl: ToastController) {}
|
||||
|
||||
async present(e: HttpError | string, link?: string): Promise<void> {
|
||||
console.error(e)
|
||||
|
||||
if (this.toast) return
|
||||
|
||||
this.toast = await this.toastCtrl.create({
|
||||
header: 'Error',
|
||||
message: getErrorMessage(e, link),
|
||||
duration: 0,
|
||||
position: 'top',
|
||||
cssClass: 'error-toast',
|
||||
buttons: [
|
||||
{
|
||||
side: 'end',
|
||||
icon: 'close',
|
||||
handler: () => {
|
||||
this.dismiss()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.toast.present()
|
||||
}
|
||||
|
||||
async dismiss(): Promise<void> {
|
||||
if (this.toast) {
|
||||
await this.toast.dismiss()
|
||||
this.toast = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorMessage(
|
||||
e: HttpError | string,
|
||||
link?: string,
|
||||
): string | IonicSafeString {
|
||||
let message = ''
|
||||
|
||||
if (typeof e === 'string') {
|
||||
message = e
|
||||
} else if (e.code === 0) {
|
||||
message =
|
||||
'Request Error. Your browser blocked the request. This is usually caused by a corrupt browser cache or an overly aggressive ad blocker. Please clear your browser cache and/or adjust your ad blocker and try again'
|
||||
link = 'https://docs.start9.com/0.3.5.x/support/common-issues#request-error'
|
||||
} else if (!e.message) {
|
||||
message = 'Unknown Error'
|
||||
} else {
|
||||
message = e.message
|
||||
}
|
||||
|
||||
if (link) {
|
||||
return new IonicSafeString(
|
||||
`${message}<br /><br /><a href=${link} target="_blank" rel="noreferrer" style="color: white;">Get Help</a>`,
|
||||
)
|
||||
}
|
||||
|
||||
return message
|
||||
}
|
||||
43
web/projects/shared/src/services/error.service.ts
Normal file
43
web/projects/shared/src/services/error.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ErrorHandler, inject, Injectable } from '@angular/core'
|
||||
import { TuiAlertService, TuiNotification } from '@taiga-ui/core'
|
||||
import { HttpError } from '../classes/http-error'
|
||||
|
||||
// TODO: Enable this as ErrorHandler
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ErrorService extends ErrorHandler {
|
||||
private readonly alerts = inject(TuiAlertService)
|
||||
|
||||
override handleError(error: HttpError | string, link?: string) {
|
||||
console.error(error)
|
||||
|
||||
this.alerts
|
||||
.open(getErrorMessage(error, link), {
|
||||
label: 'Error',
|
||||
autoClose: false,
|
||||
status: TuiNotification.Error,
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorMessage(e: HttpError | string, link?: string): string {
|
||||
let message = ''
|
||||
|
||||
if (typeof e === 'string') {
|
||||
message = e
|
||||
} else if (e.code === 0) {
|
||||
message =
|
||||
'Request Error. Your browser blocked the request. This is usually caused by a corrupt browser cache or an overly aggressive ad blocker. Please clear your browser cache and/or adjust your ad blocker and try again'
|
||||
} else if (!e.message) {
|
||||
message = 'Unknown Error'
|
||||
link = 'https://docs.start9.com/latest/support/faq'
|
||||
} else {
|
||||
message = e.message
|
||||
}
|
||||
|
||||
return link
|
||||
? `${message}<br /><br /><a href=${link} target="_blank" rel="noreferrer">Get Help</a>`
|
||||
: message
|
||||
}
|
||||
12
web/projects/shared/src/util/invert.ts
Normal file
12
web/projects/shared/src/util/invert.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function invert<
|
||||
T extends string | number | symbol,
|
||||
D extends string | number | symbol,
|
||||
>(obj: Record<T, D>): Record<D, T> {
|
||||
const result = {} as Record<D, T>
|
||||
|
||||
for (const key in obj) {
|
||||
result[obj[key]] = key
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DarkThemeModule,
|
||||
EnterModule,
|
||||
LightThemeModule,
|
||||
LoadingModule,
|
||||
MarkdownModule,
|
||||
ResponsiveColModule,
|
||||
SharedPipesModule,
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
import { AppComponent } from './app.component'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
|
||||
import { GenericInputComponentModule } from './modals/generic-input/generic-input.component.module'
|
||||
import { MarketplaceModule } from './marketplace.module'
|
||||
import { PreloaderModule } from './app/preloader/preloader.module'
|
||||
import { FooterModule } from './app/footer/footer.module'
|
||||
@@ -49,7 +49,7 @@ import { environment } from '../environments/environment'
|
||||
EnterModule,
|
||||
OSWelcomePageModule,
|
||||
MarkdownModule,
|
||||
GenericInputComponentModule,
|
||||
LoadingModule,
|
||||
MonacoEditorModule,
|
||||
SharedPipesModule,
|
||||
MarketplaceModule,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { UntypedFormBuilder } from '@angular/forms'
|
||||
import { Router, RouteReuseStrategy } from '@angular/router'
|
||||
import { IonicRouteStrategy, IonNav } from '@ionic/angular'
|
||||
import { RELATIVE_URL, THEME, WorkspaceConfig } from '@start9labs/shared'
|
||||
import { TUI_ICONS_PATH } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
PATCH_CACHE,
|
||||
@@ -53,6 +54,10 @@ export const APP_PROVIDERS: Provider[] = [
|
||||
provide: THEME,
|
||||
useExisting: ThemeSwitcherService,
|
||||
},
|
||||
{
|
||||
provide: TUI_ICONS_PATH,
|
||||
useValue: (name: string) => `/assets/taiga-ui/icons/${name}.svg#${name}`,
|
||||
},
|
||||
]
|
||||
|
||||
export function appInitializer(
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
<!-- Ionic components -->
|
||||
<ion-accordion></ion-accordion>
|
||||
<ion-accordion-group></ion-accordion-group>
|
||||
<ion-action-sheet></ion-action-sheet>
|
||||
<ion-alert></ion-alert>
|
||||
<ion-avatar></ion-avatar>
|
||||
<ion-back-button></ion-back-button>
|
||||
<ion-badge></ion-badge>
|
||||
@@ -19,14 +17,11 @@
|
||||
<ion-card-content></ion-card-content>
|
||||
<ion-card-header></ion-card-header>
|
||||
<ion-checkbox></ion-checkbox>
|
||||
<ion-content></ion-content>
|
||||
<ion-footer></ion-footer>
|
||||
<ion-grid></ion-grid>
|
||||
<ion-header></ion-header>
|
||||
<ion-popover></ion-popover>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed"></ion-refresher>
|
||||
<ion-refresher-content pullingContent="lines"></ion-refresher-content>
|
||||
<ion-infinite-scroll></ion-infinite-scroll>
|
||||
<ion-infinite-scroll-content
|
||||
loadingSpinner="lines"
|
||||
@@ -39,24 +34,16 @@
|
||||
<ion-label></ion-label>
|
||||
<ion-label style="font-weight: bold"></ion-label>
|
||||
<ion-list></ion-list>
|
||||
<ion-loading></ion-loading>
|
||||
<ion-modal></ion-modal>
|
||||
<ion-menu-button></ion-menu-button>
|
||||
<ion-note></ion-note>
|
||||
<ion-progress-bar></ion-progress-bar>
|
||||
<ion-radio></ion-radio>
|
||||
<ion-row></ion-row>
|
||||
<ion-searchbar></ion-searchbar>
|
||||
<ion-segment></ion-segment>
|
||||
<ion-segment-button></ion-segment-button>
|
||||
<ion-select></ion-select>
|
||||
<ion-select-option></ion-select-option>
|
||||
<ion-spinner name="lines"></ion-spinner>
|
||||
<ion-text></ion-text>
|
||||
<ion-text><strong>load bold font</strong></ion-text>
|
||||
<ion-title></ion-title>
|
||||
<ion-toast></ion-toast>
|
||||
<ion-toggle></ion-toggle>
|
||||
<ion-toolbar></ion-toolbar>
|
||||
|
||||
<!-- images -->
|
||||
@@ -66,10 +53,37 @@
|
||||
<img src="assets/img/icons/wifi-1.png" />
|
||||
<img src="assets/img/icons/wifi-2.png" />
|
||||
<img src="assets/img/icons/wifi-3.png" />
|
||||
|
||||
<!-- Taiga UI icons -->
|
||||
<img *ngFor="let icon of taiga" src="assets/taiga-ui/icons/{{ icon }}.svg" />
|
||||
|
||||
<!-- Taiga UI components -->
|
||||
<tui-input></tui-input>
|
||||
<tui-input-time></tui-input-time>
|
||||
<tui-input-date></tui-input-date>
|
||||
<tui-input-date-time></tui-input-date-time>
|
||||
<tui-input-files></tui-input-files>
|
||||
<tui-input-number></tui-input-number>
|
||||
<tui-text-area></tui-text-area>
|
||||
<tui-select></tui-select>
|
||||
<tui-multi-select></tui-multi-select>
|
||||
<tui-tooltip></tui-tooltip>
|
||||
<tui-toggle></tui-toggle>
|
||||
<tui-radio-list></tui-radio-list>
|
||||
<tui-error></tui-error>
|
||||
<tui-svg></tui-svg>
|
||||
<tui-icon></tui-icon>
|
||||
<tui-expand></tui-expand>
|
||||
<tui-elastic-container></tui-elastic-container>
|
||||
<tui-scrollbar></tui-scrollbar>
|
||||
<button tuiButton></button>
|
||||
<button tuiLink></button>
|
||||
<button tuiCell></button>
|
||||
<progress tuiProgressBar></progress>
|
||||
</div>
|
||||
|
||||
<!-- fonts -->
|
||||
<div style="visibility: hidden; height: 0">
|
||||
<!-- fonts -->
|
||||
<p style="font-family: Montserrat">a</p>
|
||||
<p style="font-family: Montserrat; font-weight: bold">a</p>
|
||||
<p style="font-family: Montserrat; font-weight: 100">a</p>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import {
|
||||
ActionSheetController,
|
||||
AlertController,
|
||||
ModalController,
|
||||
ToastController,
|
||||
} from '@ionic/angular'
|
||||
|
||||
// TODO: Turn into DI token if this is needed someplace else too
|
||||
const ICONS = [
|
||||
@@ -88,6 +94,26 @@ const ICONS = [
|
||||
'wifi',
|
||||
]
|
||||
|
||||
const TAIGA = [
|
||||
'tuiIconPaintOutline',
|
||||
'tuiIconTrash',
|
||||
'tuiIconTrashOutline',
|
||||
'tuiIconChevronDown',
|
||||
'tuiIconChevronDownOutline',
|
||||
'tuiIconRefreshCcw',
|
||||
'tuiIconRefreshCcwOutline',
|
||||
'tuiIconEye',
|
||||
'tuiIconEyeOutline',
|
||||
'tuiIconEyeOff',
|
||||
'tuiIconEyeOffOutline',
|
||||
'tuiIconPlus',
|
||||
'tuiIconMinus',
|
||||
'tuiIconCheck',
|
||||
'tuiIconClose',
|
||||
'tuiIconCalendarLarge',
|
||||
'tuiIconHelpCircle',
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'section[appPreloader]',
|
||||
templateUrl: 'preloader.component.html',
|
||||
@@ -95,4 +121,12 @@ const ICONS = [
|
||||
})
|
||||
export class PreloaderComponent {
|
||||
readonly icons = ICONS
|
||||
readonly taiga = TAIGA
|
||||
|
||||
constructor(
|
||||
_modals: ModalController,
|
||||
_alerts: AlertController,
|
||||
_toasts: ToastController,
|
||||
_actions: ActionSheetController,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,65 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
TuiErrorModule,
|
||||
TuiExpandModule,
|
||||
TuiLinkModule,
|
||||
TuiScrollbarModule,
|
||||
TuiSvgModule,
|
||||
TuiTooltipModule,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiElasticContainerModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiInputFilesModule,
|
||||
TuiInputModule,
|
||||
TuiInputNumberModule,
|
||||
TuiInputTimeModule,
|
||||
TuiMultiSelectModule,
|
||||
TuiProgressModule,
|
||||
TuiRadioListModule,
|
||||
TuiSelectModule,
|
||||
TuiTextAreaModule,
|
||||
TuiToggleModule,
|
||||
} from '@taiga-ui/kit'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
import { PreloaderComponent } from './preloader.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule, QrCodeModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
QrCodeModule,
|
||||
TuiTooltipModule,
|
||||
TuiErrorModule,
|
||||
TuiInputModule,
|
||||
TuiSvgModule,
|
||||
TuiIconModule,
|
||||
TuiButtonModule,
|
||||
TuiLinkModule,
|
||||
TuiInputTimeModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiInputFilesModule,
|
||||
TuiMultiSelectModule,
|
||||
TuiInputNumberModule,
|
||||
TuiExpandModule,
|
||||
TuiSelectModule,
|
||||
TuiTextAreaModule,
|
||||
TuiToggleModule,
|
||||
TuiElasticContainerModule,
|
||||
TuiCellModule,
|
||||
TuiProgressModule,
|
||||
TuiScrollbarModule,
|
||||
TuiRadioListModule,
|
||||
],
|
||||
declarations: [PreloaderComponent],
|
||||
exports: [PreloaderComponent],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Directive, HostListener, Input } from '@angular/core'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { SnakePage } from '../../modals/snake/snake.page'
|
||||
import { ApiService } from '../../services/api/embassy-api.service'
|
||||
|
||||
@@ -13,8 +13,8 @@ export class SnekDirective {
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly embassyApi: ApiService,
|
||||
) {}
|
||||
|
||||
@@ -30,12 +30,7 @@ export class SnekDirective {
|
||||
modal.onDidDismiss().then(async ({ data }) => {
|
||||
if (data?.highScore <= (this.appSnekHighScore || 0)) return
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Saving high score...',
|
||||
backdropDismiss: true,
|
||||
})
|
||||
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Saving high score...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.setDbValue<number>(
|
||||
@@ -43,9 +38,9 @@ export class SnekDirective {
|
||||
data.highScore,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loadingCtrl.dismiss()
|
||||
this.loader.subscribe()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
UnitConversionPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
} from '@start9labs/shared'
|
||||
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
@@ -23,7 +22,6 @@ import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.
|
||||
IonicModule,
|
||||
UnitConversionPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
GenericFormPageModule,
|
||||
],
|
||||
exports: [
|
||||
BackupDrivesComponent,
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { BackupService } from './backup.service'
|
||||
import { ActionSheetController, AlertController } from '@ionic/angular'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { CB } from '@start9labs/start-sdk'
|
||||
import {
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
RR,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import {
|
||||
ActionSheetController,
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
} from '@ionic/angular'
|
||||
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
|
||||
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
|
||||
import { FormComponent } from '../form.component'
|
||||
import { BackupService } from './backup.service'
|
||||
|
||||
type BackupType = 'create' | 'restore'
|
||||
|
||||
@@ -32,13 +29,13 @@ export class BackupDrivesComponent {
|
||||
loadingText = ''
|
||||
|
||||
constructor(
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly actionCtrl: ActionSheetController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly backupService: BackupService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
) {}
|
||||
|
||||
get loading() {
|
||||
@@ -87,23 +84,19 @@ export class BackupDrivesComponent {
|
||||
}
|
||||
|
||||
async presentModalAddCifs(): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: 'New Network Folder',
|
||||
spec: CifsSpec,
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'New Network Folder',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(cifsSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Connect',
|
||||
handler: (value: RR.AddBackupTargetReq) => {
|
||||
return this.addCifs(value)
|
||||
},
|
||||
isSubmit: true,
|
||||
text: 'Execute',
|
||||
handler: async (value: RR.AddBackupTargetReq) =>
|
||||
this.addCifs(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentActionCifs(
|
||||
@@ -151,10 +144,9 @@ export class BackupDrivesComponent {
|
||||
}
|
||||
|
||||
private async addCifs(value: RR.AddBackupTargetReq): Promise<boolean> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Testing connectivity to shared folder...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader
|
||||
.open('Testing connectivity to shared folder...')
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.addBackupTarget(value)
|
||||
@@ -166,10 +158,10 @@ export class BackupDrivesComponent {
|
||||
})
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,62 +172,57 @@ export class BackupDrivesComponent {
|
||||
): Promise<void> {
|
||||
const { hostname, path, username } = entry
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: 'Update Shared Folder',
|
||||
spec: CifsSpec,
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Update Network Folder',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(cifsSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (value: RR.AddBackupTargetReq) => {
|
||||
return this.editCifs({ id, ...value }, index)
|
||||
},
|
||||
isSubmit: true,
|
||||
text: 'Execute',
|
||||
handler: async (value: RR.AddBackupTargetReq) =>
|
||||
this.editCifs({ id, ...value }, index),
|
||||
},
|
||||
],
|
||||
initialValue: {
|
||||
value: {
|
||||
hostname,
|
||||
path,
|
||||
username,
|
||||
},
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async editCifs(
|
||||
value: RR.UpdateBackupTargetReq,
|
||||
index: number,
|
||||
): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Testing connectivity to shared folder...',
|
||||
})
|
||||
await loader.present()
|
||||
): Promise<boolean> {
|
||||
const loader = this.loader
|
||||
.open('Testing connectivity to shared folder...')
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.updateBackupTarget(value)
|
||||
this.backupService.cifs[index].entry = Object.values(res)[0]
|
||||
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteCifs(id: string, index: number): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Removing...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Removing...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.removeBackupTarget({ id })
|
||||
this.backupService.cifs.splice(index, 1)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,40 +261,33 @@ export class BackupDrivesStatusComponent {
|
||||
@Input() hasValidBackup!: boolean
|
||||
}
|
||||
|
||||
const CifsSpec: ConfigSpec = {
|
||||
hostname: {
|
||||
type: 'string',
|
||||
name: 'Hostname/IP',
|
||||
const cifsSpec = CB.Config.of({
|
||||
hostname: CB.Value.text({
|
||||
name: 'Hostname',
|
||||
description:
|
||||
'The hostname or IP address of the target device on your Local Area Network.',
|
||||
placeholder: `e.g. 'MyComputer.local' OR '192.168.1.4'`,
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
'The hostname of your target device on the Local Area Network.',
|
||||
warning: null,
|
||||
placeholder: `e.g. 'My Computer' OR 'my-computer.local'`,
|
||||
required: { default: null },
|
||||
patterns: [],
|
||||
}),
|
||||
path: CB.Value.text({
|
||||
name: 'Path',
|
||||
description: `On Windows, this is the fully qualified path to the shared folder, (e.g. /Desktop/my-folder).\n\n On Linux and Mac, this is the literal name of the shared folder (e.g. my-shared-folder).`,
|
||||
placeholder: 'e.g. my-shared-folder or /Desktop/my-folder',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
required: { default: null },
|
||||
}),
|
||||
username: CB.Value.text({
|
||||
name: 'Username',
|
||||
description: `On Linux, this is the samba username you created when sharing the folder.\n\n On Mac and Windows, this is the username of the user who is sharing the folder.`,
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: { default: null },
|
||||
placeholder: 'My Network Folder',
|
||||
}),
|
||||
password: CB.Value.text({
|
||||
name: 'Password',
|
||||
description: `On Linux, this is the samba password you created when sharing the folder.\n\n On Mac and Windows, this is the password of the user who is sharing the folder.`,
|
||||
nullable: true,
|
||||
required: false,
|
||||
masked: true,
|
||||
copyable: false,
|
||||
},
|
||||
}
|
||||
placeholder: 'My Network Folder',
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<ion-button
|
||||
*ngIf="data.description"
|
||||
class="slot-start"
|
||||
fill="clear"
|
||||
(click)="presentAlertDescription($event)"
|
||||
>
|
||||
<ion-icon name="help-circle-outline" slot="icon-only" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<span>{{ data.name }}</span>
|
||||
|
||||
<ion-text color="success" *ngIf="data.new"> (New)</ion-text>
|
||||
<ion-text color="success" *ngIf="data.newOptions"> (New Options)</ion-text>
|
||||
<ion-text color="warning" *ngIf="data.edited"> (Edited)</ion-text>
|
||||
|
||||
<span *ngIf="data.required"> *</span>
|
||||
@@ -1,372 +0,0 @@
|
||||
<ion-item-group [formGroup]="formGroup">
|
||||
<div *ngFor="let entry of formGroup.controls | keyvalue: asIsOrder">
|
||||
<div *ngIf="objectSpec[entry.key] as spec">
|
||||
<!-- string or number -->
|
||||
<ng-container *ngIf="spec.type === 'string' || spec.type === 'number'">
|
||||
<!-- label -->
|
||||
<h4 class="input-label">
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
new: original?.[entry.key] === undefined,
|
||||
edited: entry.value.dirty,
|
||||
required: !spec.nullable
|
||||
}"
|
||||
></form-label>
|
||||
</h4>
|
||||
<ion-item [color]="(theme$ | async) === 'Light' ? 'light' : 'dark'">
|
||||
<ion-textarea
|
||||
*ngIf="spec.type === 'string' && spec.textarea; else notTextArea"
|
||||
[placeholder]="spec.placeholder || 'Enter ' + spec.name"
|
||||
[formControlName]="entry.key"
|
||||
(ionFocus)="presentAlertChangeWarning(entry.key, spec)"
|
||||
(ionChange)="handleInputChange()"
|
||||
></ion-textarea>
|
||||
<ng-template #notTextArea>
|
||||
<ion-input
|
||||
type="text"
|
||||
[inputmode]="spec.type === 'number' ? 'tel' : 'text'"
|
||||
[class.redacted]="
|
||||
spec.type === 'string' &&
|
||||
entry.value.value &&
|
||||
spec.masked &&
|
||||
!unmasked[entry.key]
|
||||
"
|
||||
[placeholder]="spec.placeholder || 'Enter ' + spec.name"
|
||||
[formControlName]="entry.key"
|
||||
(ionFocus)="presentAlertChangeWarning(entry.key, spec)"
|
||||
(ionChange)="handleInputChange()"
|
||||
></ion-input>
|
||||
</ng-template>
|
||||
<ion-button
|
||||
*ngIf="spec.type === 'string' && spec.masked"
|
||||
slot="end"
|
||||
fill="clear"
|
||||
color="light"
|
||||
(click)="unmasked[entry.key] = !unmasked[entry.key]"
|
||||
>
|
||||
<ion-icon
|
||||
slot="icon-only"
|
||||
[name]="unmasked[entry.key] ? 'eye-off-outline' : 'eye-outline'"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
<ion-note
|
||||
*ngIf="spec.type === 'number' && spec.units"
|
||||
slot="end"
|
||||
color="light"
|
||||
style="font-size: medium"
|
||||
>
|
||||
{{ spec.units }}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
<p class="error-message">
|
||||
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
|
||||
{{ errors | getError: $any(spec)['pattern-description'] }}
|
||||
</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
<!-- boolean or enum -->
|
||||
<ion-item
|
||||
*ngIf="spec.type === 'boolean' || spec.type === 'enum'"
|
||||
style="--padding-start: 0"
|
||||
>
|
||||
<ion-button
|
||||
*ngIf="spec.description"
|
||||
fill="clear"
|
||||
(click)="presentAlertBoolEnumDescription($event, spec)"
|
||||
style="--padding-start: 0"
|
||||
>
|
||||
<ion-icon
|
||||
name="help-circle-outline"
|
||||
slot="icon-only"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
<ion-label>
|
||||
<b>
|
||||
{{ spec.name }}
|
||||
<ion-text
|
||||
*ngIf="original?.[entry.key] === undefined"
|
||||
color="success"
|
||||
>
|
||||
(New)
|
||||
</ion-text>
|
||||
<ion-text *ngIf="entry.value.dirty" color="warning">
|
||||
(Edited)
|
||||
</ion-text>
|
||||
</b>
|
||||
</ion-label>
|
||||
<!-- boolean -->
|
||||
<ion-toggle
|
||||
*ngIf="spec.type === 'boolean'"
|
||||
slot="end"
|
||||
[formControlName]="entry.key"
|
||||
(ionChange)="handleBooleanChange(entry.key, spec)"
|
||||
></ion-toggle>
|
||||
<!-- enum -->
|
||||
<!-- class enter-click disables the enter click on the modal behind the select -->
|
||||
<ion-select
|
||||
*ngIf="spec.type === 'enum' && formGroup.get(entry.key) as control"
|
||||
[interfaceOptions]="{
|
||||
message: spec.warning | toWarningText,
|
||||
cssClass: 'enter-click'
|
||||
}"
|
||||
slot="end"
|
||||
placeholder="Select"
|
||||
[formControlName]="entry.key"
|
||||
[selectedText]="spec['value-names'][control.value]"
|
||||
>
|
||||
<ion-select-option
|
||||
*ngFor="let option of spec.values"
|
||||
[value]="option"
|
||||
>
|
||||
{{ spec['value-names'][option] }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
<!-- object -->
|
||||
<ng-container *ngIf="spec.type === 'object'">
|
||||
<!-- label -->
|
||||
<ion-item-divider
|
||||
(click)="toggleExpandObject(entry.key)"
|
||||
style="cursor: pointer"
|
||||
[class.error-border]="entry.value.invalid"
|
||||
>
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
new: original?.[entry.key] === undefined,
|
||||
edited: entry.value.dirty,
|
||||
newOptions: objectDisplay[entry.key].hasNewOptions
|
||||
}"
|
||||
></form-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
name="chevron-up"
|
||||
[color]="entry.value.invalid ? 'danger' : undefined"
|
||||
[ngStyle]="{
|
||||
transform: objectDisplay[entry.key].expanded
|
||||
? 'rotate(0deg)'
|
||||
: 'rotate(180deg)',
|
||||
transition: 'transform 0.42s ease-out'
|
||||
}"
|
||||
></ion-icon>
|
||||
</ion-item-divider>
|
||||
<!-- body -->
|
||||
<tui-expand
|
||||
[expanded]="objectDisplay[entry.key].expanded"
|
||||
[id]="objectId | toElementId: entry.key"
|
||||
>
|
||||
<div class="nested-wrapper">
|
||||
<form-object
|
||||
[objectSpec]="spec.spec"
|
||||
[formGroup]="$any(entry.value)"
|
||||
[current]="current?.[entry.key]"
|
||||
[original]="original?.[entry.key]"
|
||||
(hasNewOptions)="setHasNew(entry.key)"
|
||||
></form-object>
|
||||
</div>
|
||||
</tui-expand>
|
||||
</ng-container>
|
||||
<!-- union -->
|
||||
<form-union
|
||||
*ngIf="spec.type === 'union'"
|
||||
[spec]="spec"
|
||||
[formGroup]="$any(entry.value)"
|
||||
[current]="current?.[entry.key]"
|
||||
[original]="original?.[entry.key]"
|
||||
></form-union>
|
||||
<!-- list (not enum) -->
|
||||
<ng-container *ngIf="spec.type === 'list' && spec.subtype !== 'enum'">
|
||||
<ng-container
|
||||
*ngIf="formGroup.get(entry.key) as formArr"
|
||||
[formArrayName]="entry.key"
|
||||
>
|
||||
<!-- label -->
|
||||
<ion-item-divider [class.error-border]="entry.value.invalid">
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
new: original?.[entry.key] === undefined,
|
||||
edited: entry.value.dirty,
|
||||
required: !!(spec.range | toRange).min
|
||||
}"
|
||||
></form-label>
|
||||
<ion-button
|
||||
strong
|
||||
fill="clear"
|
||||
color="dark"
|
||||
slot="end"
|
||||
(click)="addListItemWrapper(entry.key, spec)"
|
||||
>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
Add
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<p class="error-message" style="margin-bottom: 8px">
|
||||
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
|
||||
{{ errors | getError }}
|
||||
</span>
|
||||
</p>
|
||||
<!-- body -->
|
||||
<div class="nested-wrapper">
|
||||
<div
|
||||
*ngFor="
|
||||
let abstractControl of $any(formArr).controls;
|
||||
let i = index
|
||||
"
|
||||
>
|
||||
<!-- object or union -->
|
||||
<ng-container
|
||||
*ngIf="spec.subtype === 'object' || spec.subtype === 'union'"
|
||||
>
|
||||
<!-- object/union label -->
|
||||
<ion-item
|
||||
button
|
||||
(click)="toggleExpandListObject(entry.key, i)"
|
||||
[class.error-border]="abstractControl.invalid"
|
||||
>
|
||||
<form-label
|
||||
[data]="{
|
||||
name:
|
||||
objectListDisplay[entry.key][i].displayAs ||
|
||||
'Entry ' + (i + 1),
|
||||
new: false,
|
||||
edited: abstractControl.dirty
|
||||
}"
|
||||
></form-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
name="chevron-up"
|
||||
[color]="abstractControl.invalid ? 'danger' : undefined"
|
||||
[ngStyle]="{
|
||||
transform: objectListDisplay[entry.key][i].expanded
|
||||
? 'rotate(0deg)'
|
||||
: 'rotate(180deg)',
|
||||
transition: 'transform 0.42s ease-out'
|
||||
}"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
<!-- object/union body -->
|
||||
<tui-expand
|
||||
style="padding-left: 24px"
|
||||
[expanded]="objectListDisplay[entry.key][i].expanded"
|
||||
[id]="objectId | toElementId: entry.key:i"
|
||||
>
|
||||
<form-object
|
||||
*ngIf="spec.subtype === 'object'"
|
||||
[objectSpec]="$any(spec.spec).spec"
|
||||
[formGroup]="abstractControl"
|
||||
[current]="current?.[entry.key]?.[i]"
|
||||
[original]="original?.[entry.key]?.[i]"
|
||||
(onInputChange)="
|
||||
updateLabel(entry.key, i, $any(spec.spec)['display-as'])
|
||||
"
|
||||
></form-object>
|
||||
<form-union
|
||||
*ngIf="spec.subtype === 'union'"
|
||||
[spec]="$any(spec.spec)"
|
||||
[formGroup]="abstractControl"
|
||||
[current]="current?.[entry.key]?.[i]"
|
||||
[original]="original?.[entry.key]?.[i]"
|
||||
(onInputChange)="
|
||||
updateLabel(entry.key, i, $any(spec.spec)['display-as'])
|
||||
"
|
||||
></form-union>
|
||||
<div style="text-align: right; padding-top: 12px">
|
||||
<ion-button
|
||||
fill="clear"
|
||||
(click)="presentAlertDelete(entry.key, i)"
|
||||
color="danger"
|
||||
>
|
||||
<ion-icon slot="start" name="close"></ion-icon>
|
||||
Delete
|
||||
</ion-button>
|
||||
</div>
|
||||
</tui-expand>
|
||||
</ng-container>
|
||||
<!-- string or number -->
|
||||
<div
|
||||
*ngIf="spec.subtype === 'string' || spec.subtype === 'number'"
|
||||
[id]="objectId | toElementId: entry.key:i"
|
||||
>
|
||||
<ion-item
|
||||
[color]="(theme$ | async) === 'Light' ? 'light' : 'dark'"
|
||||
>
|
||||
<ion-input
|
||||
type="text"
|
||||
[inputmode]="spec.subtype === 'number' ? 'tel' : 'text'"
|
||||
[placeholder]="
|
||||
$any(spec.spec).placeholder || 'Enter ' + spec.name
|
||||
"
|
||||
[formControlName]="i"
|
||||
></ion-input>
|
||||
<ion-button
|
||||
strong
|
||||
fill="clear"
|
||||
slot="end"
|
||||
color="danger"
|
||||
(click)="presentAlertDelete(entry.key, i)"
|
||||
>
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<p class="error-message">
|
||||
<span
|
||||
*ngIf="
|
||||
(formGroup | getControl: entry.key:i).errors as errors
|
||||
"
|
||||
>
|
||||
{{ errors | getError: $any(spec)['pattern-description'] }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<!-- list (enum) -->
|
||||
<ng-container *ngIf="spec.type === 'list' && spec.subtype === 'enum'">
|
||||
<ng-container
|
||||
*ngIf="formGroup.get(entry.key) as formArr"
|
||||
[formArrayName]="entry.key"
|
||||
>
|
||||
<!-- label -->
|
||||
<p class="input-label">
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.name,
|
||||
description: spec.description,
|
||||
new: original?.[entry.key] === undefined,
|
||||
edited: entry.value.dirty
|
||||
}"
|
||||
></form-label>
|
||||
</p>
|
||||
<!-- list -->
|
||||
<ion-item
|
||||
button
|
||||
detail="false"
|
||||
color="dark"
|
||||
(click)="presentModalEnumList(entry.key, $any(spec), formArr.value)"
|
||||
>
|
||||
<ion-label style="white-space: nowrap !important">
|
||||
<h2>{{ formArr.value | toEnumListDisplay: $any(spec.spec) }}</h2>
|
||||
</ion-label>
|
||||
<ion-button slot="end" fill="clear" color="light">
|
||||
<ion-icon slot="icon-only" name="chevron-down"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<p class="error-message">
|
||||
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
|
||||
{{ errors | getError }}
|
||||
</span>
|
||||
</p>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
@@ -1,47 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
FormObjectComponent,
|
||||
FormUnionComponent,
|
||||
FormLabelComponent,
|
||||
} from './form-object.component'
|
||||
import {
|
||||
GetErrorPipe,
|
||||
ToWarningTextPipe,
|
||||
ToElementIdPipe,
|
||||
GetControlPipe,
|
||||
ToEnumListDisplayPipe,
|
||||
ToRangePipe,
|
||||
} from './form-object.pipes'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiElasticContainerModule } from '@taiga-ui/kit'
|
||||
import { EnumListPageModule } from 'src/app/modals/enum-list/enum-list.module'
|
||||
import { TuiExpandModule } from '@taiga-ui/core'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FormObjectComponent,
|
||||
FormUnionComponent,
|
||||
FormLabelComponent,
|
||||
ToWarningTextPipe,
|
||||
GetErrorPipe,
|
||||
ToEnumListDisplayPipe,
|
||||
ToElementIdPipe,
|
||||
GetControlPipe,
|
||||
ToRangePipe,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
SharedPipesModule,
|
||||
EnumListPageModule,
|
||||
TuiElasticContainerModule,
|
||||
TuiExpandModule,
|
||||
],
|
||||
exports: [FormObjectComponent, FormLabelComponent],
|
||||
})
|
||||
export class FormObjectComponentModule {}
|
||||
@@ -1,42 +0,0 @@
|
||||
.slot-start {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
--padding-start: 0;
|
||||
--padding-end: 7px;
|
||||
}
|
||||
|
||||
.error-border {
|
||||
border-color: var(--ion-color-danger-shade);
|
||||
--border-color: var(--ion-color-danger-shade);
|
||||
}
|
||||
|
||||
.redacted {
|
||||
font-family: 'Redacted'
|
||||
}
|
||||
|
||||
ion-input {
|
||||
font-family: 'Courier New';
|
||||
font-weight: bold;
|
||||
--placeholder-font-weight: 400;
|
||||
}
|
||||
|
||||
ion-item-divider {
|
||||
text-transform: unset;
|
||||
--padding-top: 18px;
|
||||
--padding-start: 0;
|
||||
border-bottom: 1px solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13))))
|
||||
}
|
||||
|
||||
.nested-wrapper {
|
||||
padding: 0 0 16px 24px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 2px;
|
||||
font-size: small;
|
||||
color: var(--ion-color-danger);
|
||||
}
|
||||
|
||||
.indent {
|
||||
margin-left: 24px;
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
Input,
|
||||
Output,
|
||||
EventEmitter,
|
||||
ChangeDetectionStrategy,
|
||||
Inject,
|
||||
inject,
|
||||
SimpleChanges,
|
||||
} from '@angular/core'
|
||||
import { FormArray, UntypedFormArray, UntypedFormGroup } from '@angular/forms'
|
||||
import { AlertButton, AlertController, ModalController } from '@ionic/angular'
|
||||
import {
|
||||
ConfigSpec,
|
||||
ListValueSpecOf,
|
||||
ValueSpec,
|
||||
ValueSpecBoolean,
|
||||
ValueSpecEnum,
|
||||
ValueSpecList,
|
||||
ValueSpecListOf,
|
||||
ValueSpecUnion,
|
||||
} from 'src/app/pkg-config/config-types'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
import { EnumListPage } from 'src/app/modals/enum-list/enum-list.page'
|
||||
import { THEME, pauseFor } from '@start9labs/shared'
|
||||
import { v4 } from 'uuid'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
const Mustache = require('mustache')
|
||||
|
||||
interface Config {
|
||||
[key: string]: any
|
||||
}
|
||||
@Component({
|
||||
selector: 'form-object',
|
||||
templateUrl: './form-object.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
})
|
||||
export class FormObjectComponent {
|
||||
@Input() objectSpec!: ConfigSpec
|
||||
@Input() formGroup!: UntypedFormGroup
|
||||
@Input() current?: Config
|
||||
@Input() original?: Config
|
||||
@Output() onInputChange = new EventEmitter<void>()
|
||||
@Output() hasNewOptions = new EventEmitter<void>()
|
||||
warningAck: { [key: string]: boolean } = {}
|
||||
unmasked: { [key: string]: boolean } = {}
|
||||
objectDisplay: {
|
||||
[key: string]: { expanded: boolean; hasNewOptions: boolean }
|
||||
} = {}
|
||||
objectListDisplay: {
|
||||
[key: string]: { expanded: boolean; displayAs: string }[]
|
||||
} = {}
|
||||
objectId = v4()
|
||||
|
||||
readonly theme$ = inject(THEME)
|
||||
|
||||
constructor(
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly formService: FormService,
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.setDisplays()
|
||||
|
||||
// setTimeout hack to avoid ExpressionChangedAfterItHasBeenCheckedError
|
||||
setTimeout(() => {
|
||||
if (
|
||||
this.original &&
|
||||
Object.keys(this.current || {}).some(
|
||||
key => this.original![key] === undefined,
|
||||
)
|
||||
)
|
||||
this.hasNewOptions.emit()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges) {
|
||||
const specChanges = changes['objectSpec']
|
||||
|
||||
if (!specChanges) return
|
||||
|
||||
if (
|
||||
!specChanges.firstChange &&
|
||||
Object.keys({
|
||||
...specChanges.previousValue,
|
||||
...specChanges.currentValue,
|
||||
}).length !== Object.keys(specChanges.previousValue).length
|
||||
) {
|
||||
this.setDisplays()
|
||||
}
|
||||
}
|
||||
|
||||
private setDisplays() {
|
||||
Object.keys(this.objectSpec).forEach(key => {
|
||||
const spec = this.objectSpec[key]
|
||||
|
||||
if (spec.type === 'list' && ['object', 'union'].includes(spec.subtype)) {
|
||||
this.objectListDisplay[key] = []
|
||||
this.formGroup.get(key)?.value.forEach((obj: any, index: number) => {
|
||||
const displayAs = (spec.spec as ListValueSpecOf<'object'>)[
|
||||
'display-as'
|
||||
]
|
||||
this.objectListDisplay[key][index] = {
|
||||
expanded: false,
|
||||
displayAs: displayAs
|
||||
? (Mustache as any).render(displayAs, obj)
|
||||
: '',
|
||||
}
|
||||
})
|
||||
} else if (spec.type === 'object') {
|
||||
this.objectDisplay[key] = {
|
||||
expanded: false,
|
||||
hasNewOptions: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addListItemWrapper<T extends ValueSpec>(
|
||||
key: string,
|
||||
spec: T extends ValueSpecUnion ? never : T,
|
||||
) {
|
||||
this.presentAlertChangeWarning(key, spec, () => this.addListItem(key))
|
||||
}
|
||||
|
||||
toggleExpandObject(key: string) {
|
||||
this.objectDisplay[key].expanded = !this.objectDisplay[key].expanded
|
||||
}
|
||||
|
||||
toggleExpandListObject(key: string, i: number) {
|
||||
this.objectListDisplay[key][i].expanded =
|
||||
!this.objectListDisplay[key][i].expanded
|
||||
}
|
||||
|
||||
updateLabel(key: string, i: number, displayAs: string) {
|
||||
this.objectListDisplay[key][i].displayAs = displayAs
|
||||
? Mustache.render(displayAs, this.formGroup.get(key)?.value[i])
|
||||
: ''
|
||||
}
|
||||
|
||||
handleInputChange() {
|
||||
this.onInputChange.emit()
|
||||
}
|
||||
|
||||
setHasNew(key: string) {
|
||||
this.hasNewOptions.emit()
|
||||
setTimeout(() => {
|
||||
this.objectDisplay[key].hasNewOptions = true
|
||||
}, 100)
|
||||
}
|
||||
|
||||
handleBooleanChange(key: string, spec: ValueSpecBoolean) {
|
||||
if (spec.warning) {
|
||||
const current = this.formGroup.get(key)?.value
|
||||
const cancelFn = () => this.formGroup.get(key)?.setValue(!current)
|
||||
this.presentAlertChangeWarning(key, spec, undefined, cancelFn)
|
||||
}
|
||||
}
|
||||
|
||||
async presentModalEnumList(
|
||||
key: string,
|
||||
spec: ValueSpecListOf<'enum'>,
|
||||
current: string[],
|
||||
) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
key,
|
||||
spec,
|
||||
current,
|
||||
},
|
||||
component: EnumListPage,
|
||||
})
|
||||
|
||||
modal.onWillDismiss<string[]>().then(({ data }) => {
|
||||
if (!data) return
|
||||
this.updateEnumList(key, current, data)
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentAlertChangeWarning<T extends ValueSpec>(
|
||||
key: string,
|
||||
spec: T extends ValueSpecUnion ? never : T,
|
||||
okFn?: Function,
|
||||
cancelFn?: Function,
|
||||
) {
|
||||
if (!spec.warning || this.warningAck[key]) return okFn ? okFn() : null
|
||||
this.warningAck[key] = true
|
||||
|
||||
const buttons: AlertButton[] = [
|
||||
{
|
||||
text: 'Ok',
|
||||
handler: () => {
|
||||
if (okFn) okFn()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
]
|
||||
|
||||
if (okFn || cancelFn) {
|
||||
buttons.unshift({
|
||||
text: 'Cancel',
|
||||
handler: () => {
|
||||
if (cancelFn) cancelFn()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
subHeader: `Editing ${spec.name} has consequences:`,
|
||||
message: spec.warning,
|
||||
buttons,
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async presentAlertDelete(key: string, index: number) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Confirm',
|
||||
message: 'Are you sure you want to delete this entry?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
handler: () => {
|
||||
this.deleteListItem(key, index)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async presentAlertBoolEnumDescription(
|
||||
event: Event,
|
||||
spec: ValueSpecBoolean | ValueSpecEnum,
|
||||
) {
|
||||
event.stopPropagation()
|
||||
const { name, description } = spec
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: name,
|
||||
message: description,
|
||||
buttons: [
|
||||
{
|
||||
text: 'OK',
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private addListItem(key: string): void {
|
||||
const arr = this.formGroup.get(key) as UntypedFormArray
|
||||
const listSpec = this.objectSpec[key] as ValueSpecList
|
||||
const newItem = this.formService.getListItem(listSpec, undefined)!
|
||||
|
||||
const index = arr.length
|
||||
arr.insert(index, newItem)
|
||||
|
||||
if (['object', 'union'].includes(listSpec.subtype)) {
|
||||
const displayAs = (listSpec.spec as ListValueSpecOf<'object'>)[
|
||||
'display-as'
|
||||
]
|
||||
this.objectListDisplay[key].push({
|
||||
expanded: false,
|
||||
displayAs: displayAs ? Mustache.render(displayAs, newItem.value) : '',
|
||||
})
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const element = this.document.getElementById(
|
||||
getElementId(this.objectId, key, index),
|
||||
)
|
||||
element?.parentElement?.scrollIntoView({ behavior: 'smooth' })
|
||||
|
||||
if (['object', 'union'].includes(listSpec.subtype)) {
|
||||
pauseFor(250).then(() => this.toggleExpandListObject(key, index))
|
||||
}
|
||||
}, 100)
|
||||
|
||||
arr.markAsDirty()
|
||||
}
|
||||
|
||||
private deleteListItem(key: string, index: number, markDirty = true): void {
|
||||
// if (this.objectListDisplay[key])
|
||||
// this.objectListDisplay[key][index].height = '0px'
|
||||
const arr = this.formGroup.get(key) as UntypedFormArray
|
||||
if (markDirty) arr.markAsDirty()
|
||||
pauseFor(250).then(() => {
|
||||
if (this.objectListDisplay[key])
|
||||
this.objectListDisplay[key].splice(index, 1)
|
||||
arr.removeAt(index)
|
||||
})
|
||||
}
|
||||
|
||||
private updateEnumList(key: string, current: string[], updated: string[]) {
|
||||
const arr = this.formGroup.get(key) as FormArray
|
||||
|
||||
for (let i = current.length - 1; i >= 0; i--) {
|
||||
if (!updated.includes(current[i])) {
|
||||
arr.removeAt(i)
|
||||
}
|
||||
}
|
||||
|
||||
const listSpec = this.objectSpec[key] as ValueSpecList
|
||||
|
||||
updated.forEach(val => {
|
||||
if (!current.includes(val)) {
|
||||
const newItem = this.formService.getListItem(listSpec, val)!
|
||||
arr.insert(arr.length, newItem)
|
||||
}
|
||||
})
|
||||
|
||||
arr.markAsDirty()
|
||||
}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'form-union',
|
||||
templateUrl: './form-union.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormUnionComponent {
|
||||
@Input() formGroup!: UntypedFormGroup
|
||||
@Input() spec!: ValueSpecUnion
|
||||
@Input() current?: Config
|
||||
@Input() original?: Config
|
||||
|
||||
get unionValue() {
|
||||
return this.formGroup.get(this.spec.tag.id)?.value
|
||||
}
|
||||
|
||||
get isNew() {
|
||||
return !this.original
|
||||
}
|
||||
|
||||
get hasNewOptions() {
|
||||
const tagId = this.spec.tag.id
|
||||
return (
|
||||
this.original?.[tagId] === this.current?.[tagId] &&
|
||||
!!Object.keys(this.current || {}).find(
|
||||
key => this.original![key] === undefined,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
objectId = v4()
|
||||
|
||||
constructor(private readonly formService: FormService) {}
|
||||
|
||||
updateUnion(e: any): void {
|
||||
const tagId = this.spec.tag.id
|
||||
|
||||
Object.keys(this.formGroup.controls).forEach(control => {
|
||||
if (control === tagId) return
|
||||
this.formGroup.removeControl(control)
|
||||
})
|
||||
|
||||
const unionGroup = this.formService.getUnionObject(
|
||||
this.spec as ValueSpecUnion,
|
||||
e.detail.value,
|
||||
)
|
||||
|
||||
Object.keys(unionGroup.controls).forEach(control => {
|
||||
if (control === tagId) return
|
||||
this.formGroup.addControl(control, unionGroup.controls[control])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'form-label',
|
||||
templateUrl: './form-label.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormLabelComponent {
|
||||
@Input() data!: {
|
||||
name: string
|
||||
new: boolean
|
||||
edited: boolean
|
||||
description?: string
|
||||
required?: boolean
|
||||
newOptions?: boolean
|
||||
}
|
||||
|
||||
constructor(private readonly alertCtrl: AlertController) {}
|
||||
|
||||
async presentAlertDescription(event: Event) {
|
||||
event.stopPropagation()
|
||||
const { name, description } = this.data
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: name,
|
||||
message: description,
|
||||
buttons: [
|
||||
{
|
||||
text: 'OK',
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
|
||||
export function getElementId(objectId: string, key: string, index = 0): string {
|
||||
return `${key}-${index}-${objectId}`
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import {
|
||||
AbstractControl,
|
||||
FormGroup,
|
||||
UntypedFormArray,
|
||||
ValidationErrors,
|
||||
} from '@angular/forms'
|
||||
import { IonicSafeString } from '@ionic/angular'
|
||||
import { ListValueSpecOf } from 'src/app/pkg-config/config-types'
|
||||
import { Range } from 'src/app/pkg-config/config-utilities'
|
||||
import { getElementId } from './form-object.component'
|
||||
|
||||
@Pipe({
|
||||
name: 'getError',
|
||||
})
|
||||
export class GetErrorPipe implements PipeTransform {
|
||||
transform(errors: ValidationErrors, patternDesc?: string): string {
|
||||
if (errors['required']) {
|
||||
return 'Required'
|
||||
} else if (errors['pattern']) {
|
||||
return patternDesc || 'Invalid pattern'
|
||||
} else if (errors['notNumber']) {
|
||||
return 'Must be a number'
|
||||
} else if (errors['numberNotInteger']) {
|
||||
return 'Must be an integer'
|
||||
} else if (errors['numberNotInRange']) {
|
||||
return errors['numberNotInRange'].value
|
||||
} else if (errors['listNotUnique']) {
|
||||
return errors['listNotUnique'].value
|
||||
} else if (errors['listNotInRange']) {
|
||||
return errors['listNotInRange'].value
|
||||
} else if (errors['listItemIssue']) {
|
||||
return errors['listItemIssue'].value
|
||||
} else {
|
||||
return 'Unknown error'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toEnumListDisplay',
|
||||
})
|
||||
export class ToEnumListDisplayPipe implements PipeTransform {
|
||||
transform(arr: string[], spec: ListValueSpecOf<'enum'>): string {
|
||||
return arr.map((v: string) => spec['value-names'][v]).join(', ')
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toWarningText',
|
||||
})
|
||||
export class ToWarningTextPipe implements PipeTransform {
|
||||
transform(text?: string): IonicSafeString | string {
|
||||
return text
|
||||
? new IonicSafeString(`<ion-text color="warning">${text}</ion-text>`)
|
||||
: ''
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toRange',
|
||||
})
|
||||
export class ToRangePipe implements PipeTransform {
|
||||
transform(range: string): Range {
|
||||
return Range.from(range)
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toElementId',
|
||||
})
|
||||
export class ToElementIdPipe implements PipeTransform {
|
||||
transform(objectId: string, key: string, index = 0): string {
|
||||
return getElementId(objectId, key, index)
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'getControl',
|
||||
})
|
||||
export class GetControlPipe implements PipeTransform {
|
||||
transform(
|
||||
formGroup: FormGroup,
|
||||
key: string,
|
||||
index?: number,
|
||||
): AbstractControl {
|
||||
const abstractControl = formGroup.get(key)!
|
||||
if (index !== undefined)
|
||||
return (abstractControl as UntypedFormArray).at(index)
|
||||
return abstractControl
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<div [formGroup]="formGroup">
|
||||
<!-- union enum -->
|
||||
<ion-item-divider [class.error-border]="formGroup.invalid">
|
||||
<form-label
|
||||
[data]="{
|
||||
name: spec.tag.name,
|
||||
description: spec.tag.description,
|
||||
new: isNew,
|
||||
newOptions: hasNewOptions,
|
||||
edited: formGroup.dirty
|
||||
}"
|
||||
></form-label>
|
||||
<!-- class enter-click disables the enter click on the modal behind the select -->
|
||||
<ion-select
|
||||
[interfaceOptions]="{
|
||||
message: spec.tag.warning | toWarningText,
|
||||
cssClass: 'enter-click'
|
||||
}"
|
||||
slot="end"
|
||||
placeholder="Select"
|
||||
[formControlName]="spec.tag.id"
|
||||
[selectedText]="spec.tag['variant-names'][unionValue]"
|
||||
(ionChange)="updateUnion($event)"
|
||||
>
|
||||
<ion-select-option
|
||||
*ngFor="let option of spec.variants | keyvalue"
|
||||
[value]="option.key"
|
||||
>
|
||||
{{ spec.tag['variant-names'][option.key] }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item-divider>
|
||||
|
||||
<tui-elastic-container [id]="objectId | toElementId: 'union'" class="indent">
|
||||
<form-object
|
||||
[objectSpec]="spec.variants[unionValue]"
|
||||
[formGroup]="formGroup"
|
||||
[current]="current"
|
||||
[original]="original"
|
||||
></form-object>
|
||||
</tui-elastic-container>
|
||||
</div>
|
||||
167
web/projects/ui/src/app/components/form.component.ts
Normal file
167
web/projects/ui/src/app/components/form.component.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
import {
|
||||
tuiMarkControlAsTouchedAndValidate,
|
||||
TuiValueChangesModule,
|
||||
} from '@taiga-ui/cdk'
|
||||
import { TuiDialogContext, TuiModeModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TuiDialogFormService } from '@taiga-ui/kit'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { compare, Operation } from 'fast-json-patch'
|
||||
import { FormModule } from 'src/app/components/form/form.module'
|
||||
import { InvalidService } from 'src/app/components/form/invalid.service'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
|
||||
export interface ActionButton<T> {
|
||||
text: string
|
||||
handler?: (value: T) => Promise<boolean | void> | void
|
||||
link?: string
|
||||
}
|
||||
|
||||
export interface FormContext<T> {
|
||||
spec: CT.InputSpec
|
||||
buttons: ActionButton<T>[]
|
||||
value?: T
|
||||
patch?: Operation[]
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-form',
|
||||
template: `
|
||||
<form
|
||||
[formGroup]="form"
|
||||
(submit.capture.prevent)="(0)"
|
||||
(reset.capture.prevent.stop)="onReset()"
|
||||
(tuiValueChanges)="markAsDirty()"
|
||||
>
|
||||
<form-group [spec]="spec"></form-group>
|
||||
<footer tuiMode="onDark">
|
||||
<ng-content></ng-content>
|
||||
<ng-container *ngFor="let button of buttons; let last = last">
|
||||
<button
|
||||
*ngIf="button.handler; else link"
|
||||
tuiButton
|
||||
[appearance]="last ? 'primary' : 'flat'"
|
||||
[type]="last ? 'submit' : 'button'"
|
||||
(click)="onClick(button.handler)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</button>
|
||||
<ng-template #link>
|
||||
<a
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
[routerLink]="button.link"
|
||||
(click)="close()"
|
||||
>
|
||||
{{ button.text }}
|
||||
</a>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
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);
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
TuiValueChangesModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
FormModule,
|
||||
],
|
||||
providers: [InvalidService],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormComponent<T extends Record<string, any>> implements OnInit {
|
||||
private readonly dialogFormService = inject(TuiDialogFormService)
|
||||
private readonly formService = inject(FormService)
|
||||
private readonly invalidService = inject(InvalidService)
|
||||
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
{ optional: true },
|
||||
)
|
||||
|
||||
@Input() spec = this.context?.data.spec || {}
|
||||
@Input() buttons = this.context?.data.buttons || []
|
||||
@Input() patch = this.context?.data.patch || []
|
||||
@Input() value?: T = this.context?.data.value
|
||||
|
||||
form = new FormGroup({})
|
||||
|
||||
ngOnInit() {
|
||||
this.dialogFormService.markAsPristine()
|
||||
this.form = this.formService.createForm(this.spec, this.value)
|
||||
this.process(this.patch)
|
||||
}
|
||||
|
||||
onReset() {
|
||||
const { value } = this.form
|
||||
|
||||
this.form = this.formService.createForm(this.spec)
|
||||
this.process(compare(this.form.value, value))
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
this.markAsDirty()
|
||||
}
|
||||
|
||||
async onClick(handler: Required<ActionButton<T>>['handler']) {
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
this.invalidService.scrollIntoView()
|
||||
|
||||
if (this.form.valid && (await handler(this.form.value as T))) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
markAsDirty() {
|
||||
this.dialogFormService.markAsDirty()
|
||||
}
|
||||
|
||||
close() {
|
||||
this.context?.$implicit.complete()
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
30
web/projects/ui/src/app/components/form/control.directive.ts
Normal file
30
web/projects/ui/src/app/components/form/control.directive.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Directive, ElementRef, inject, OnDestroy, OnInit } from '@angular/core'
|
||||
import { ControlContainer, NgControl } from '@angular/forms'
|
||||
import { InvalidService } from './invalid.service'
|
||||
|
||||
@Directive({
|
||||
selector: 'form-control, form-array, form-object',
|
||||
})
|
||||
export class ControlDirective implements OnInit, OnDestroy {
|
||||
private readonly invalidService = inject(InvalidService, { optional: true })
|
||||
private readonly element: ElementRef<HTMLElement> = inject(ElementRef)
|
||||
private readonly control =
|
||||
inject(NgControl, { optional: true }) ||
|
||||
inject(ControlContainer, { optional: true })
|
||||
|
||||
get invalid(): boolean {
|
||||
return !!this.control?.invalid
|
||||
}
|
||||
|
||||
scrollIntoView() {
|
||||
this.element.nativeElement.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.invalidService?.add(this)
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.invalidService?.remove(this)
|
||||
}
|
||||
}
|
||||
35
web/projects/ui/src/app/components/form/control.ts
Normal file
35
web/projects/ui/src/app/components/form/control.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { inject } from '@angular/core'
|
||||
import { FormControlComponent } from './form-control/form-control.component'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
export abstract class Control<Spec extends CT.ValueSpec, Value> {
|
||||
private readonly control: FormControlComponent<Spec, Value> =
|
||||
inject(FormControlComponent)
|
||||
|
||||
get invalid(): boolean {
|
||||
return this.control.touched && this.control.invalid
|
||||
}
|
||||
|
||||
get spec(): Spec {
|
||||
return this.control.spec
|
||||
}
|
||||
|
||||
// TODO: Properly handle already set immutable value
|
||||
get readOnly(): boolean {
|
||||
return (
|
||||
!!this.value && !!this.control.control?.pristine && this.control.immutable
|
||||
)
|
||||
}
|
||||
|
||||
get value(): Value | null {
|
||||
return this.control.value
|
||||
}
|
||||
|
||||
set value(value: Value | null) {
|
||||
this.control.onInput(value)
|
||||
}
|
||||
|
||||
onFocus(focused: boolean) {
|
||||
this.control.onFocus(focused)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="label">
|
||||
{{ spec.name }}
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description || spec.disabled"
|
||||
[content]="spec | hint"
|
||||
></tui-tooltip>
|
||||
<button
|
||||
tuiLink
|
||||
type="button"
|
||||
class="add"
|
||||
[disabled]="!canAdd"
|
||||
(click)="add()"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<tui-error [error]="order | tuiFieldError | async"></tui-error>
|
||||
|
||||
<ng-container *ngFor="let item of array.control.controls; let index = index">
|
||||
<form-object
|
||||
*ngIf="spec.spec.type === 'object'; else control"
|
||||
class="object"
|
||||
[class.object_open]="!!open.get(item)"
|
||||
[formGroup]="$any(item)"
|
||||
[spec]="$any(spec.spec)"
|
||||
[@tuiHeightCollapse]="animation"
|
||||
[@tuiFadeIn]="animation"
|
||||
[open]="!!open.get(item)"
|
||||
(openChange)="open.set(item, $event)"
|
||||
>
|
||||
{{ item.value | mustache: $any(spec.spec).displayAs }}
|
||||
<ng-container *ngTemplateOutlet="remove"></ng-container>
|
||||
</form-object>
|
||||
<ng-template #control>
|
||||
<form-control
|
||||
class="control"
|
||||
tuiTextfieldSize="m"
|
||||
[tuiTextfieldLabelOutside]="true"
|
||||
[tuiTextfieldIcon]="remove"
|
||||
[formControl]="$any(item)"
|
||||
[spec]="$any(spec.spec)"
|
||||
[@tuiHeightCollapse]="animation"
|
||||
[@tuiFadeIn]="animation"
|
||||
></form-control>
|
||||
</ng-template>
|
||||
<ng-template #remove>
|
||||
<button
|
||||
tuiIconButton
|
||||
type="button"
|
||||
class="remove"
|
||||
iconLeft="tuiIconTrash"
|
||||
appearance="icon"
|
||||
size="m"
|
||||
title="Remove"
|
||||
(click.stop)="removeAt(index)"
|
||||
></button>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,50 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add {
|
||||
font-size: 1rem;
|
||||
padding: 0 1rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.object {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
&_open::after,
|
||||
&:last-child::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
@include transition(opacity);
|
||||
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -0.5rem;
|
||||
height: 1px;
|
||||
left: 3rem;
|
||||
right: 1rem;
|
||||
background: var(--tui-clear);
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
margin-left: auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, HostBinding, inject, Input } from '@angular/core'
|
||||
import { AbstractControl, FormArrayName } from '@angular/forms'
|
||||
import { TUI_PARENT_ANIMATION, TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TUI_ANIMATION_OPTIONS,
|
||||
TuiDialogService,
|
||||
tuiFadeIn,
|
||||
tuiHeightCollapse,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { filter, takeUntil } from 'rxjs'
|
||||
import { FormService } from 'src/app/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_ANIMATION],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class FormArrayComponent {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecList
|
||||
|
||||
@HostBinding('@tuiParentAnimation')
|
||||
readonly animation = { value: '', ...inject(TUI_ANIMATION_OPTIONS) }
|
||||
readonly order = ERRORS
|
||||
readonly array = inject(FormArrayName)
|
||||
readonly open = new Map<AbstractControl, boolean>()
|
||||
|
||||
private warned = false
|
||||
private readonly formService = inject(FormService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly destroy$ = inject(TuiDestroyService)
|
||||
|
||||
get canAdd(): boolean {
|
||||
return (
|
||||
!this.spec.disabled &&
|
||||
(!this.spec.maxLength ||
|
||||
this.spec.maxLength >= this.array.control.controls.length)
|
||||
)
|
||||
}
|
||||
|
||||
add() {
|
||||
if (!this.warned && this.spec.warning) {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: { content: this.spec.warning, yes: 'Ok', no: 'Cancel' },
|
||||
})
|
||||
.pipe(filter(Boolean), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.addItem()
|
||||
})
|
||||
} else {
|
||||
this.addItem()
|
||||
}
|
||||
|
||||
this.warned = true
|
||||
}
|
||||
|
||||
removeAt(index: number) {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Are you sure you want to delete this entry?',
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.removeItem(index)
|
||||
})
|
||||
}
|
||||
|
||||
private removeItem(index: number) {
|
||||
this.open.delete(this.array.control.at(index))
|
||||
this.array.control.removeAt(index)
|
||||
}
|
||||
|
||||
private addItem() {
|
||||
this.array.control.insert(0, this.formService.getListItem(this.spec))
|
||||
this.open.set(this.array.control.at(0), true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<tui-input
|
||||
[maskito]="mask"
|
||||
[tuiTextfieldCustomContent]="color"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[readOnly]="readOnly"
|
||||
[disabled]="!!spec.disabled"
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input>
|
||||
<ng-template #color>
|
||||
<div class="wrapper" [style.color]="value">
|
||||
<input
|
||||
*ngIf="!readOnly && !spec.disabled"
|
||||
type="color"
|
||||
class="color"
|
||||
tabindex="-1"
|
||||
[(ngModel)]="value"
|
||||
(click.stop)="(0)"
|
||||
/>
|
||||
<tui-icon
|
||||
icon="tuiIconPaintLarge"
|
||||
tuiAppearance="icon"
|
||||
class="icon"
|
||||
></tui-icon>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,33 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
pointer-events: auto;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 0.3rem;
|
||||
width: 1.4rem;
|
||||
bottom: 0.125rem;
|
||||
background: currentColor;
|
||||
border-radius: 0.125rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.color {
|
||||
@include fullsize();
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@include fullsize();
|
||||
pointer-events: none;
|
||||
|
||||
input:hover + & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
import { MaskitoOptions } from '@maskito/core'
|
||||
|
||||
@Component({
|
||||
selector: 'form-color',
|
||||
templateUrl: './form-color.component.html',
|
||||
styleUrls: ['./form-color.component.scss'],
|
||||
})
|
||||
export class FormColorComponent extends Control<CT.ValueSpecColor, string> {
|
||||
readonly mask: MaskitoOptions = {
|
||||
mask: ['#', ...Array(6).fill(/[0-9a-f]/i)],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<ng-container [ngSwitch]="spec.type">
|
||||
<form-color *ngSwitchCase="'color'"></form-color>
|
||||
<form-datetime *ngSwitchCase="'datetime'"></form-datetime>
|
||||
<form-file *ngSwitchCase="'file'"></form-file>
|
||||
<form-multiselect *ngSwitchCase="'multiselect'"></form-multiselect>
|
||||
<form-number *ngSwitchCase="'number'"></form-number>
|
||||
<form-select *ngSwitchCase="'select'"></form-select>
|
||||
<form-text *ngSwitchCase="'text'"></form-text>
|
||||
<form-textarea *ngSwitchCase="'textarea'"></form-textarea>
|
||||
<form-toggle *ngSwitchCase="'toggle'"></form-toggle>
|
||||
</ng-container>
|
||||
<tui-error [error]="order | tuiFieldError | async"></tui-error>
|
||||
<ng-template
|
||||
*ngIf="spec.warning || immutable"
|
||||
#warning
|
||||
let-completeWith="completeWith"
|
||||
>
|
||||
{{ spec.warning }}
|
||||
<p *ngIf="immutable">This value cannot be changed once set!</p>
|
||||
<div class="buttons">
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="secondary"
|
||||
size="s"
|
||||
(click)="completeWith(true)"
|
||||
>
|
||||
Rollback
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="flat"
|
||||
size="s"
|
||||
(click)="completeWith(false)"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
:first-child {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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 { filter, takeUntil } from 'rxjs'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { ERRORS } from '../form-group/form-group.component'
|
||||
import { FORM_CONTROL_PROVIDERS } from './form-control.providers'
|
||||
|
||||
@Component({
|
||||
selector: 'form-control',
|
||||
templateUrl: './form-control.component.html',
|
||||
styleUrls: ['./form-control.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: FORM_CONTROL_PROVIDERS,
|
||||
})
|
||||
export class FormControlComponent<
|
||||
T extends CT.ValueSpec,
|
||||
V,
|
||||
> extends AbstractTuiNullableControl<V> {
|
||||
@Input()
|
||||
spec!: T
|
||||
|
||||
@ViewChild('warning')
|
||||
warning?: TemplateRef<TuiDialogContext<boolean>>
|
||||
|
||||
warned = false
|
||||
focused = false
|
||||
readonly order = ERRORS
|
||||
private readonly alerts = inject(TuiAlertService)
|
||||
|
||||
get immutable(): boolean {
|
||||
return 'immutable' in this.spec && this.spec.immutable
|
||||
}
|
||||
|
||||
onFocus(focused: boolean) {
|
||||
this.focused = focused
|
||||
this.updateFocused(focused)
|
||||
}
|
||||
|
||||
onInput(value: V | null) {
|
||||
const previous = this.value
|
||||
|
||||
if (!this.warned && this.warning) {
|
||||
this.alerts
|
||||
.open<boolean>(this.warning, {
|
||||
label: 'Warning',
|
||||
status: TuiNotification.Warning,
|
||||
hasCloseButton: false,
|
||||
autoClose: false,
|
||||
})
|
||||
.pipe(filter(Boolean), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.value = previous
|
||||
})
|
||||
}
|
||||
|
||||
this.warned = true
|
||||
this.value = value === '' ? null : value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { forwardRef, Provider } from '@angular/core'
|
||||
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { FormControlComponent } from './form-control.component'
|
||||
|
||||
interface ValidatorsPatternError {
|
||||
actualValue: string
|
||||
requiredPattern: string | RegExp
|
||||
}
|
||||
|
||||
export const FORM_CONTROL_PROVIDERS: Provider[] = [
|
||||
{
|
||||
provide: TUI_VALIDATION_ERRORS,
|
||||
deps: [forwardRef(() => FormControlComponent)],
|
||||
useFactory: (control: FormControlComponent<CT.ValueSpec, string>) => ({
|
||||
required: 'Required',
|
||||
pattern: ({ requiredPattern }: ValidatorsPatternError) =>
|
||||
('patterns' in control.spec &&
|
||||
control.spec.patterns.find(
|
||||
({ regex }) => String(regex) === String(requiredPattern),
|
||||
)?.description) ||
|
||||
'Invalid format',
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
<ng-container [ngSwitch]="spec.inputmode" [tuiHintContent]="spec.description">
|
||||
<tui-input-time
|
||||
*ngSwitchCase="'time'"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[ngModel]="getTime(value)"
|
||||
(ngModelChange)="value = $event?.toString() || null"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input-time>
|
||||
<tui-input-date
|
||||
*ngSwitchCase="'date'"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[min]="spec.min ? (spec.min | tuiMapper: getLimit)[0] : min"
|
||||
[max]="spec.max ? (spec.max | tuiMapper: getLimit)[0] : max"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input-date>
|
||||
<tui-input-date-time
|
||||
*ngSwitchCase="'datetime-local'"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[min]="spec.min ? (spec.min | tuiMapper: getLimit) : min"
|
||||
[max]="spec.max ? (spec.max | tuiMapper: getLimit) : max"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input-date-time>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
TUI_FIRST_DAY,
|
||||
TUI_LAST_DAY,
|
||||
TuiDay,
|
||||
tuiPure,
|
||||
TuiTime,
|
||||
} from '@taiga-ui/cdk'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-datetime',
|
||||
templateUrl: './form-datetime.component.html',
|
||||
})
|
||||
export class FormDatetimeComponent extends Control<
|
||||
CT.ValueSpecDatetime,
|
||||
string
|
||||
> {
|
||||
readonly min = TUI_FIRST_DAY
|
||||
readonly max = TUI_LAST_DAY
|
||||
|
||||
@tuiPure
|
||||
getTime(value: string | null) {
|
||||
return value ? TuiTime.fromString(value) : null
|
||||
}
|
||||
|
||||
getLimit(limit: string): [TuiDay, TuiTime] {
|
||||
return [
|
||||
TuiDay.jsonParse(limit.slice(0, 10)),
|
||||
limit.length === 10
|
||||
? new TuiTime(0, 0)
|
||||
: TuiTime.fromString(limit.slice(-5)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<tui-input-files
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
<input tuiInputFiles [accept]="spec.extensions.join(',')" />
|
||||
<ng-template let-drop>
|
||||
<div class="template" [class.template_hidden]="drop">
|
||||
<div class="label">
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description"
|
||||
[content]="spec.description"
|
||||
></tui-tooltip>
|
||||
</div>
|
||||
<tui-tag
|
||||
*ngIf="value; else label"
|
||||
class="file"
|
||||
size="l"
|
||||
[value]="value.name"
|
||||
[removable]="true"
|
||||
(edited)="value = null"
|
||||
></tui-tag>
|
||||
<ng-template #label>
|
||||
<small>Click or drop file here</small>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="drop" [class.drop_hidden]="!drop">Drop file here</div>
|
||||
</ng-template>
|
||||
</tui-input-files>
|
||||
@@ -0,0 +1,46 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
.template {
|
||||
@include transition(opacity);
|
||||
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0.5rem;
|
||||
font: var(--tui-font-text-m);
|
||||
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.25rem -0.25rem -0.25rem auto;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { TuiFileLike } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-file',
|
||||
templateUrl: './form-file.component.html',
|
||||
styleUrls: ['./form-file.component.scss'],
|
||||
})
|
||||
export class FormFileComponent extends Control<CT.ValueSpecFile, TuiFileLike> {}
|
||||
@@ -0,0 +1,30 @@
|
||||
<ng-container
|
||||
*ngFor="let entry of spec | keyvalue: asIsOrder"
|
||||
tuiMode="onDark"
|
||||
[ngSwitch]="entry.value.type"
|
||||
[tuiTextfieldCleaner]="true"
|
||||
>
|
||||
<form-object
|
||||
*ngSwitchCase="'object'"
|
||||
class="g-form-control"
|
||||
[formGroupName]="entry.key"
|
||||
[spec]="$any(entry.value)"
|
||||
></form-object>
|
||||
<form-union
|
||||
*ngSwitchCase="'union'"
|
||||
class="g-form-control"
|
||||
[formGroupName]="entry.key"
|
||||
[spec]="$any(entry.value)"
|
||||
></form-union>
|
||||
<form-array
|
||||
*ngSwitchCase="'list'"
|
||||
[formArrayName]="entry.key"
|
||||
[spec]="$any(entry.value)"
|
||||
></form-array>
|
||||
<form-control
|
||||
*ngSwitchDefault
|
||||
class="g-form-control"
|
||||
[formControlName]="entry.key"
|
||||
[spec]="entry.value"
|
||||
></form-control>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,35 @@
|
||||
form-group .g-form-control:not(:first-child) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
form-group .g-form-group {
|
||||
position: relative;
|
||||
padding-left: var(--tui-height-m);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--tui-clear);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
left: calc(1rem - 1px);
|
||||
bottom: 0.5rem;
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
left: 0.75rem;
|
||||
bottom: 0;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
form-group tui-tooltip {
|
||||
z-index: 1;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
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: CT.InputSpec = {}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Provider, SkipSelf } from '@angular/core'
|
||||
import {
|
||||
TUI_ARROW_MODE,
|
||||
tuiInputDateOptionsProvider,
|
||||
tuiInputTimeOptionsProvider,
|
||||
} 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,
|
||||
},
|
||||
},
|
||||
tuiInputDateOptionsProvider({
|
||||
nativePicker: true,
|
||||
}),
|
||||
tuiInputTimeOptionsProvider({
|
||||
nativePicker: true,
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
<tui-multi-select
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[editable]="false"
|
||||
[disabledItemHandler]="disabledItemHandler"
|
||||
[(ngModel)]="selected"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<select
|
||||
tuiSelect
|
||||
multiple
|
||||
[items]="items"
|
||||
[disabledItemHandler]="disabledItemHandler"
|
||||
></select>
|
||||
</tui-multi-select>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
import { invert } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'form-multiselect',
|
||||
templateUrl: './form-multiselect.component.html',
|
||||
})
|
||||
export class FormMultiselectComponent extends Control<
|
||||
CT.ValueSpecMultiselect,
|
||||
readonly string[]
|
||||
> {
|
||||
private readonly inverted = invert(this.spec.values)
|
||||
|
||||
private readonly isDisabled = (item: string) =>
|
||||
Array.isArray(this.spec.disabled) &&
|
||||
this.spec.disabled.includes(this.inverted[item])
|
||||
|
||||
private readonly isExceedingLimit = (item: string) =>
|
||||
!!this.spec.maxLength &&
|
||||
this.selected.length >= this.spec.maxLength &&
|
||||
!this.selected.includes(item)
|
||||
|
||||
readonly disabledItemHandler = (item: string): boolean =>
|
||||
this.isDisabled(item) || this.isExceedingLimit(item)
|
||||
|
||||
readonly items = Object.values(this.spec.values)
|
||||
|
||||
get disabled(): boolean {
|
||||
return typeof this.spec.disabled === 'string'
|
||||
}
|
||||
|
||||
get selected(): string[] {
|
||||
return this.memoize(this.value)
|
||||
}
|
||||
|
||||
set selected(value: string[]) {
|
||||
this.value = Object.entries(this.spec.values)
|
||||
.filter(([_, v]) => value.includes(v))
|
||||
.map(([k]) => k)
|
||||
}
|
||||
|
||||
@tuiPure
|
||||
private memoize(value: null | readonly string[]): string[] {
|
||||
return value?.map(key => this.spec.values[key]) || []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<tui-input-number
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[tuiTextfieldPostfix]="spec.units || ''"
|
||||
[pseudoInvalid]="invalid"
|
||||
[precision]="Infinity"
|
||||
[decimal]="spec.integer ? 'never' : 'not-zero'"
|
||||
[min]="spec.min ?? -Infinity"
|
||||
[max]="spec.max ?? Infinity"
|
||||
[step]="spec.step || 0"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<input tuiTextfield [placeholder]="spec.placeholder || ''" />
|
||||
</tui-input-number>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-number',
|
||||
templateUrl: './form-number.component.html',
|
||||
})
|
||||
export class FormNumberComponent extends Control<CT.ValueSpecNumber, number> {
|
||||
protected readonly Infinity = Infinity
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<h3 class="title" (click)="toggle()">
|
||||
<button
|
||||
tuiIconButton
|
||||
size="s"
|
||||
iconLeft="tuiIconChevronDown"
|
||||
type="button"
|
||||
class="button"
|
||||
[class.button_open]="open"
|
||||
[style.border-radius.%]="100"
|
||||
[appearance]="invalid ? 'destructive' : 'secondary'"
|
||||
></button>
|
||||
<ng-content></ng-content>
|
||||
{{ spec.name }}
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description"
|
||||
[content]="spec.description"
|
||||
(click.stop)="(0)"
|
||||
></tui-tooltip>
|
||||
</h3>
|
||||
|
||||
<tui-expand class="expand" [expanded]="open">
|
||||
<div class="g-form-group" [class.g-form-group_invalid]="invalid">
|
||||
<form-group [spec]="spec.spec"></form-group>
|
||||
</div>
|
||||
</tui-expand>
|
||||
@@ -0,0 +1,41 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
height: var(--tui-height-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
margin: 0 0 -0.75rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
@include transition(transform);
|
||||
|
||||
margin-right: 1rem;
|
||||
|
||||
&_open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.expand {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.g-form-group {
|
||||
padding-top: 0.75rem;
|
||||
|
||||
&_invalid::before,
|
||||
&_invalid::after {
|
||||
background: var(--tui-error-bg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { ControlContainer } from '@angular/forms'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
@Component({
|
||||
selector: 'form-object',
|
||||
templateUrl: './form-object.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormObjectComponent {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecObject
|
||||
|
||||
@Input()
|
||||
open = false
|
||||
|
||||
@Output()
|
||||
readonly openChange = new EventEmitter<boolean>()
|
||||
|
||||
private readonly container = inject(ControlContainer)
|
||||
|
||||
get invalid() {
|
||||
return !this.container.valid && this.container.touched
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open
|
||||
this.openChange.emit(this.open)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<tui-select
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="disabled"
|
||||
[readOnly]="readOnly"
|
||||
[tuiTextfieldCleaner]="!spec.required"
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="selected"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<select
|
||||
tuiSelect
|
||||
[items]="items"
|
||||
[disabledItemHandler]="disabledItemHandler"
|
||||
></select>
|
||||
</tui-select>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { invert } from '@start9labs/shared'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-select',
|
||||
templateUrl: './form-select.component.html',
|
||||
})
|
||||
export class FormSelectComponent extends Control<CT.ValueSpecSelect, string> {
|
||||
private readonly inverted = invert(this.spec.values)
|
||||
|
||||
readonly items = Object.values(this.spec.values)
|
||||
|
||||
readonly disabledItemHandler = (item: string) =>
|
||||
Array.isArray(this.spec.disabled) &&
|
||||
this.spec.disabled.includes(this.inverted[item])
|
||||
|
||||
get disabled(): boolean {
|
||||
return typeof this.spec.disabled === 'string'
|
||||
}
|
||||
|
||||
get selected(): string | null {
|
||||
return (this.value && this.spec.values[this.value]) || null
|
||||
}
|
||||
|
||||
set selected(value: string | null) {
|
||||
this.value = (value && this.inverted[value]) || null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<tui-input
|
||||
[tuiTextfieldCustomContent]="spec.masked || spec.generate ? toggle : ''"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<input
|
||||
tuiTextfield
|
||||
[class.masked]="spec.masked && masked"
|
||||
[placeholder]="spec.placeholder || ''"
|
||||
[attr.minLength]="spec.minLength"
|
||||
[attr.maxLength]="spec.maxLength"
|
||||
[attr.inputmode]="spec.inputmode"
|
||||
/>
|
||||
</tui-input>
|
||||
<ng-template #toggle>
|
||||
<button
|
||||
*ngIf="spec.generate"
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
title="Generate"
|
||||
size="xs"
|
||||
class="button"
|
||||
iconLeft="tuiIconRefreshCcw"
|
||||
(click)="generate()"
|
||||
></button>
|
||||
<button
|
||||
*ngIf="spec.masked"
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
title="Toggle masking"
|
||||
size="xs"
|
||||
class="button"
|
||||
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
|
||||
(click)="masked = !masked"
|
||||
></button>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,8 @@
|
||||
.button {
|
||||
pointer-events: auto;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT, utils } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-text',
|
||||
templateUrl: './form-text.component.html',
|
||||
styleUrls: ['./form-text.component.scss'],
|
||||
})
|
||||
export class FormTextComponent extends Control<CT.ValueSpecText, string> {
|
||||
masked = true
|
||||
|
||||
generate() {
|
||||
this.value = utils.getDefaultString(this.spec.generate || '')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<tui-text-area
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[expandable]="true"
|
||||
[rows]="6"
|
||||
[maxLength]="spec.maxLength"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<textarea tuiTextfield [placeholder]="spec.placeholder || ''"></textarea>
|
||||
</tui-text-area>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-textarea',
|
||||
templateUrl: './form-textarea.component.html',
|
||||
})
|
||||
export class FormTextareaComponent extends Control<
|
||||
CT.ValueSpecTextarea,
|
||||
string
|
||||
> {}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{ spec.name }}
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description || spec.disabled"
|
||||
[tuiHintContent]="spec | hint"
|
||||
></tui-tooltip>
|
||||
<tui-toggle
|
||||
size="l"
|
||||
[disabled]="!!spec.disabled || readOnly"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
></tui-toggle>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-toggle',
|
||||
templateUrl: './form-toggle.component.html',
|
||||
host: { class: 'g-toggle' },
|
||||
})
|
||||
export class FormToggleComponent extends Control<CT.ValueSpecToggle, boolean> {}
|
||||
@@ -0,0 +1,11 @@
|
||||
<form-control
|
||||
[spec]="selectSpec"
|
||||
formControlName="selection"
|
||||
(tuiValueChanges)="onUnion($event)"
|
||||
></form-control>
|
||||
<tui-elastic-container class="g-form-group" formGroupName="value">
|
||||
<form-group
|
||||
class="group"
|
||||
[spec]="(union && spec.variants[union].spec) || {}"
|
||||
></form-group>
|
||||
</tui-elastic-container>
|
||||
@@ -0,0 +1,8 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
} from '@angular/core'
|
||||
import { ControlContainer, FormGroupName } from '@angular/forms'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { FormService } from 'src/app/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!: CT.ValueSpecUnion
|
||||
|
||||
selectSpec!: CT.ValueSpecSelect
|
||||
|
||||
private readonly form = inject(FormGroupName)
|
||||
private readonly formService = inject(FormService)
|
||||
|
||||
get union(): string {
|
||||
return this.form.value.selection
|
||||
}
|
||||
|
||||
@tuiPure
|
||||
onUnion(union: string) {
|
||||
this.form.control.setControl(
|
||||
'value',
|
||||
this.formService.getFormGroup(
|
||||
union ? this.spec.variants[union].spec : {},
|
||||
),
|
||||
{
|
||||
emitEvent: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
this.selectSpec = this.formService.getUnionSelectSpec(this.spec, this.union)
|
||||
if (this.union) this.onUnion(this.union)
|
||||
}
|
||||
}
|
||||
109
web/projects/ui/src/app/components/form/form.module.ts
Normal file
109
web/projects/ui/src/app/components/form/form.module.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { MaskitoModule } from '@maskito/angular'
|
||||
import { TuiMapperPipeModule, TuiValueChangesModule } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiErrorModule,
|
||||
TuiExpandModule,
|
||||
TuiHintModule,
|
||||
TuiLinkModule,
|
||||
TuiModeModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiTooltipModule,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiAppearanceModule,
|
||||
TuiButtonModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiElasticContainerModule,
|
||||
TuiFieldErrorPipeModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiInputFilesModule,
|
||||
TuiInputModule,
|
||||
TuiInputNumberModule,
|
||||
TuiInputTimeModule,
|
||||
TuiMultiSelectModule,
|
||||
TuiPromptModule,
|
||||
TuiSelectModule,
|
||||
TuiTagModule,
|
||||
TuiTextAreaModule,
|
||||
TuiToggleModule,
|
||||
} from '@taiga-ui/kit'
|
||||
|
||||
import { FormGroupComponent } from './form-group/form-group.component'
|
||||
import { FormTextComponent } from './form-text/form-text.component'
|
||||
import { FormToggleComponent } from './form-toggle/form-toggle.component'
|
||||
import { FormTextareaComponent } from './form-textarea/form-textarea.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'
|
||||
import { FormColorComponent } from './form-color/form-color.component'
|
||||
import { FormDatetimeComponent } from './form-datetime/form-datetime.component'
|
||||
import { HintPipe } from './hint.pipe'
|
||||
|
||||
@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,
|
||||
MaskitoModule,
|
||||
TuiIconModule,
|
||||
TuiAppearanceModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputTimeModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiMapperPipeModule,
|
||||
],
|
||||
declarations: [
|
||||
FormGroupComponent,
|
||||
FormControlComponent,
|
||||
FormColorComponent,
|
||||
FormDatetimeComponent,
|
||||
FormTextComponent,
|
||||
FormToggleComponent,
|
||||
FormTextareaComponent,
|
||||
FormNumberComponent,
|
||||
FormSelectComponent,
|
||||
FormMultiselectComponent,
|
||||
FormFileComponent,
|
||||
FormUnionComponent,
|
||||
FormObjectComponent,
|
||||
FormArrayComponent,
|
||||
MustachePipe,
|
||||
HintPipe,
|
||||
ControlDirective,
|
||||
],
|
||||
exports: [FormGroupComponent],
|
||||
})
|
||||
export class FormModule {}
|
||||
21
web/projects/ui/src/app/components/form/hint.pipe.ts
Normal file
21
web/projects/ui/src/app/components/form/hint.pipe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
@Pipe({
|
||||
name: 'hint',
|
||||
})
|
||||
export class HintPipe implements PipeTransform {
|
||||
transform(spec: CT.ValueSpec): string {
|
||||
const hint = []
|
||||
|
||||
if (spec.description) {
|
||||
hint.push(spec.description)
|
||||
}
|
||||
|
||||
if ('disabled' in spec && typeof spec.disabled === 'string') {
|
||||
hint.push(`Disabled: ${spec.disabled}`)
|
||||
}
|
||||
|
||||
return hint.join('\n\n')
|
||||
}
|
||||
}
|
||||
19
web/projects/ui/src/app/components/form/invalid.service.ts
Normal file
19
web/projects/ui/src/app/components/form/invalid.service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ControlDirective } from './control.directive'
|
||||
|
||||
@Injectable()
|
||||
export class InvalidService {
|
||||
private readonly controls: ControlDirective[] = []
|
||||
|
||||
scrollIntoView() {
|
||||
this.controls.find(({ invalid }) => invalid)?.scrollIntoView()
|
||||
}
|
||||
|
||||
add(control: ControlDirective) {
|
||||
this.controls.push(control)
|
||||
}
|
||||
|
||||
remove(control: ControlDirective) {
|
||||
this.controls.splice(this.controls.indexOf(control), 1)
|
||||
}
|
||||
}
|
||||
12
web/projects/ui/src/app/components/form/mustache.pipe.ts
Normal file
12
web/projects/ui/src/app/components/form/mustache.pipe.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
|
||||
const Mustache = require('mustache')
|
||||
|
||||
@Pipe({
|
||||
name: 'mustache',
|
||||
})
|
||||
export class MustachePipe implements PipeTransform {
|
||||
transform(value: any, displayAs: string): string {
|
||||
return displayAs && Mustache.render(displayAs, value)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { IonContent, LoadingController } from '@ionic/angular'
|
||||
import { IonContent } from '@ionic/angular'
|
||||
import {
|
||||
DownloadHTMLService,
|
||||
ErrorService,
|
||||
LoadingService,
|
||||
Log,
|
||||
LogsRes,
|
||||
ServerLogsReq,
|
||||
toLocalIsoString,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import {
|
||||
bufferTime,
|
||||
catchError,
|
||||
@@ -11,15 +21,6 @@ import {
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import {
|
||||
LogsRes,
|
||||
ServerLogsReq,
|
||||
ErrorToastService,
|
||||
toLocalIsoString,
|
||||
Log,
|
||||
DownloadHTMLService,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
@@ -66,10 +67,10 @@ export class LogsComponent {
|
||||
count = 0
|
||||
|
||||
constructor(
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly destroy$: TuiDestroyService,
|
||||
private readonly api: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly downloadHtml: DownloadHTMLService,
|
||||
private readonly connection$: ConnectionService,
|
||||
) {}
|
||||
@@ -97,7 +98,7 @@ export class LogsComponent {
|
||||
|
||||
this.processRes(res)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
e.target.complete()
|
||||
}
|
||||
@@ -119,10 +120,7 @@ export class LogsComponent {
|
||||
}
|
||||
|
||||
async download() {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Processing 10,000 logs...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Processing 10,000 logs...').subscribe()
|
||||
|
||||
try {
|
||||
const { entries } = await this.fetchLogs({
|
||||
@@ -139,9 +137,9 @@ export class LogsComponent {
|
||||
|
||||
this.downloadHtml.download(`${this.context}-logs.html`, html, styles)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import { SwUpdate } from '@angular/service-worker'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { merge, Observable, Subject } from 'rxjs'
|
||||
|
||||
import { RefreshAlertService } from './refresh-alert.service'
|
||||
import { SwUpdate } from '@angular/service-worker'
|
||||
import { LoadingController } from '@ionic/angular'
|
||||
|
||||
@Component({
|
||||
selector: 'refresh-alert',
|
||||
@@ -18,7 +18,7 @@ export class RefreshAlertComponent {
|
||||
constructor(
|
||||
@Inject(RefreshAlertService) private readonly refresh$: Observable<boolean>,
|
||||
private readonly updates: SwUpdate,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly loader: LoadingService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
@@ -26,17 +26,14 @@ export class RefreshAlertComponent {
|
||||
}
|
||||
|
||||
async pwaReload() {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Reloading PWA...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Reloading PWA...').subscribe()
|
||||
try {
|
||||
// attempt to update to the latest client version available
|
||||
await this.updates.activateUpdate()
|
||||
} catch (e) {
|
||||
console.error('Error activating update from service worker: ', e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
// always reload, as this resolves most out of sync cases
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import { LoadingController } from '@ionic/angular'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { Observable, Subject, merge } from 'rxjs'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { merge, Observable, Subject } from 'rxjs'
|
||||
import { ApiService } from '../../../services/api/embassy-api.service'
|
||||
|
||||
import { UpdateToastService } from './update-toast.service'
|
||||
import { ApiService } from '../../../services/api/embassy-api.service'
|
||||
|
||||
@Component({
|
||||
selector: 'update-toast',
|
||||
@@ -19,8 +18,8 @@ export class UpdateToastComponent {
|
||||
constructor(
|
||||
@Inject(UpdateToastService) private readonly update$: Observable<boolean>,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
) {}
|
||||
|
||||
onDismiss() {
|
||||
@@ -30,18 +29,14 @@ export class UpdateToastComponent {
|
||||
async restart(): Promise<void> {
|
||||
this.onDismiss()
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Restarting...',
|
||||
})
|
||||
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Restarting...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restartServer({})
|
||||
} catch (e: any) {
|
||||
await this.errToast.present(e)
|
||||
await this.errorService.handleError(e)
|
||||
} finally {
|
||||
await loader.dismiss()
|
||||
await loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppConfigPage } from './app-config.page'
|
||||
import { TextSpinnerComponentModule } from '@start9labs/shared'
|
||||
import { FormObjectComponentModule } from 'src/app/components/form-object/form-object.component.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppConfigPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
TextSpinnerComponentModule,
|
||||
FormObjectComponentModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
exports: [AppConfigPage],
|
||||
})
|
||||
export class AppConfigPageModule {}
|
||||
@@ -1,144 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Config</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding" *ngIf="pkg.stateInfo.manifest as manifest">
|
||||
<!-- loading -->
|
||||
<text-spinner
|
||||
*ngIf="loading; else notLoading"
|
||||
[text]="loadingText"
|
||||
></text-spinner>
|
||||
|
||||
<!-- not loading -->
|
||||
<ng-template #notLoading>
|
||||
<ion-item *ngIf="loadingError; else noError">
|
||||
<ion-label>
|
||||
<ion-text color="danger">{{ loadingError }}</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ng-template #noError>
|
||||
<ng-container *ngIf="configForm && !pkg.status.configured">
|
||||
<ng-container *ngIf="!original; else hasOriginal">
|
||||
<h2
|
||||
*ngIf="!configForm.dirty"
|
||||
class="ion-padding-bottom header-details"
|
||||
>
|
||||
<ion-text color="success">
|
||||
{{ manifest.title }} has been automatically configured with
|
||||
recommended defaults. Make whatever changes you want, then click
|
||||
"Save".
|
||||
</ion-text>
|
||||
</h2>
|
||||
</ng-container>
|
||||
<ng-template #hasOriginal>
|
||||
<h2 *ngIf="hasNewOptions" class="ion-padding-bottom header-details">
|
||||
<ion-text color="success">
|
||||
New config options! To accept the default values, click "Save".
|
||||
You may also customize these new options below.
|
||||
</ion-text>
|
||||
</h2>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<!-- auto-config -->
|
||||
<ion-item
|
||||
lines="none"
|
||||
*ngIf="dependentInfo"
|
||||
class="rec-item"
|
||||
style="margin-bottom: 48px"
|
||||
>
|
||||
<ion-label>
|
||||
<h2 style="display: flex; align-items: center">
|
||||
<img
|
||||
style="width: 18px; margin: 4px"
|
||||
[src]="pkg.icon"
|
||||
[alt]="manifest.title"
|
||||
/>
|
||||
<ion-text
|
||||
style="margin: 5px; font-family: 'Montserrat'; font-size: 18px"
|
||||
>
|
||||
{{ manifest.title }}
|
||||
</ion-text>
|
||||
</h2>
|
||||
<p>
|
||||
<ion-text color="dark">
|
||||
The following modifications have been made to {{ manifest.title }}
|
||||
to satisfy {{ dependentInfo.title }}:
|
||||
<ul>
|
||||
<li *ngFor="let d of diff" [innerHtml]="d"></li>
|
||||
</ul>
|
||||
To accept these modifications, click "Save".
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- no options -->
|
||||
<ion-item *ngIf="!hasConfig">
|
||||
<ion-label>
|
||||
<p>
|
||||
No config options for {{ manifest.title }} {{ manifest.version }}.
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- has config -->
|
||||
<form
|
||||
*ngIf="configForm && configSpec"
|
||||
[formGroup]="configForm"
|
||||
novalidate
|
||||
>
|
||||
<form-object
|
||||
[objectSpec]="configSpec"
|
||||
[formGroup]="configForm"
|
||||
[current]="configForm.value"
|
||||
[original]="original"
|
||||
(hasNewOptions)="hasNewOptions = true"
|
||||
></form-object>
|
||||
</form>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ng-container *ngIf="!loading && !loadingError">
|
||||
<ion-buttons *ngIf="hasConfig" slot="start" class="ion-padding-start">
|
||||
<ion-button fill="clear" (click)="resetDefaults()">
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Reset Defaults
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
*ngIf="hasConfig"
|
||||
fill="solid"
|
||||
color="primary"
|
||||
[disabled]="saving"
|
||||
(click)="tryConfigure()"
|
||||
class="enter-click btn-128"
|
||||
[class.no-click]="saving"
|
||||
>
|
||||
Save
|
||||
</ion-button>
|
||||
<ion-button
|
||||
*ngIf="!hasConfig"
|
||||
fill="solid"
|
||||
color="dark"
|
||||
(click)="dismiss()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Close
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ng-container>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -1,12 +0,0 @@
|
||||
.notifier-item {
|
||||
margin: 12px;
|
||||
margin-top: 0px;
|
||||
border-radius: 12px;
|
||||
// kills the lines
|
||||
--border-width: 0;
|
||||
--inner-border-width: 0;
|
||||
}
|
||||
|
||||
.header-details {
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import {
|
||||
AlertController,
|
||||
ModalController,
|
||||
LoadingController,
|
||||
IonicSafeString,
|
||||
} from '@ionic/angular'
|
||||
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 { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import {
|
||||
DataModel,
|
||||
InstalledState,
|
||||
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 { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import {
|
||||
getAllPackages,
|
||||
getManifest,
|
||||
getPackage,
|
||||
isInstalled,
|
||||
} from 'src/app/util/get-package-data'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config',
|
||||
templateUrl: './app-config.page.html',
|
||||
styleUrls: ['./app-config.page.scss'],
|
||||
})
|
||||
export class AppConfigPage {
|
||||
@Input() pkgId!: string
|
||||
|
||||
@Input() dependentInfo?: DependentInfo
|
||||
|
||||
pkg!: PackageDataEntry<InstalledState>
|
||||
loadingText = ''
|
||||
|
||||
configSpec?: ConfigSpec
|
||||
configForm?: UntypedFormGroup
|
||||
|
||||
original?: object // only if existing config
|
||||
diff?: string[] // only if dependent info
|
||||
|
||||
loading = true
|
||||
hasNewOptions = false
|
||||
saving = false
|
||||
loadingError: string | IonicSafeString = ''
|
||||
|
||||
constructor(
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly formService: FormService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
get hasConfig() {
|
||||
return this.pkg.stateInfo.manifest.hasConfig
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const pkg = await getPackage(this.patch, this.pkgId)
|
||||
if (!pkg || !isInstalled(pkg)) return
|
||||
|
||||
this.pkg = pkg
|
||||
|
||||
if (!this.hasConfig) return
|
||||
|
||||
let newConfig: object | undefined
|
||||
let patch: Operation[] | undefined
|
||||
|
||||
if (this.dependentInfo) {
|
||||
this.loadingText = `Setting properties to accommodate ${this.dependentInfo.title}`
|
||||
const {
|
||||
oldConfig: oc,
|
||||
newConfig: nc,
|
||||
spec: s,
|
||||
} = await this.embassyApi.dryConfigureDependency({
|
||||
dependencyId: this.pkgId,
|
||||
dependentId: this.dependentInfo.id,
|
||||
})
|
||||
this.original = oc
|
||||
newConfig = nc
|
||||
this.configSpec = s
|
||||
patch = compare(this.original, newConfig)
|
||||
} else {
|
||||
this.loadingText = 'Loading Config'
|
||||
const { config: c, spec: s } = 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)
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.loadingError = getErrorMessage(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patch))) {
|
||||
this.dryConfigure()
|
||||
} else {
|
||||
this.configure()
|
||||
}
|
||||
}
|
||||
|
||||
private async dryConfigure() {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Checking dependent services...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const breakages = await this.embassyApi.drySetPackageConfig({
|
||||
id: this.pkgId,
|
||||
config: this.configForm!.value,
|
||||
})
|
||||
|
||||
if (isEmptyObject(breakages)) {
|
||||
this.configure(loader)
|
||||
} else {
|
||||
await loader.dismiss()
|
||||
const proceed = await this.presentAlertBreakages(breakages)
|
||||
if (proceed) {
|
||||
this.configure()
|
||||
} else {
|
||||
this.saving = false
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.saving = false
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async configure(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.configForm!.value,
|
||||
})
|
||||
this.modalCtrl.dismiss()
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.saving = false
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertBreakages(breakages: Breakages): Promise<boolean> {
|
||||
let message: string =
|
||||
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
|
||||
const localPkgs = await getAllPackages(this.patch)
|
||||
const bullets = Object.keys(breakages).map(id => {
|
||||
const title = getManifest(localPkgs[id]).title
|
||||
return `<li><b>${title}</b></li>`
|
||||
})
|
||||
message = `${message}${bullets}</ul>`
|
||||
|
||||
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',
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import {
|
||||
LoadingController,
|
||||
ModalController,
|
||||
IonicSafeString,
|
||||
} from '@ionic/angular'
|
||||
import { getErrorMessage } from '@start9labs/shared'
|
||||
import { IonicSafeString, ModalController } from '@ionic/angular'
|
||||
import { getErrorMessage, LoadingService } from '@start9labs/shared'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { take } from 'rxjs'
|
||||
import { BackupInfo } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { AppRecoverOption } from './to-options.pipe'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { take } from 'rxjs'
|
||||
import { AppRecoverOption } from './to-options.pipe'
|
||||
|
||||
@Component({
|
||||
selector: 'app-recover-select',
|
||||
@@ -30,7 +26,7 @@ export class AppRecoverSelectPage {
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
@@ -45,10 +41,7 @@ export class AppRecoverSelectPage {
|
||||
|
||||
async restore(options: AppRecoverOption[]): Promise<void> {
|
||||
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Initializing...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Initializing...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restorePackages({
|
||||
@@ -61,7 +54,7 @@ export class AppRecoverSelectPage {
|
||||
} catch (e: any) {
|
||||
this.error = getErrorMessage(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
web/projects/ui/src/app/modals/config-dep.component.ts
Normal file
104
web/projects/ui/src/app/modals/config-dep.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
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'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { TuiNotificationModule } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'config-dep',
|
||||
template: `
|
||||
<tui-notification>
|
||||
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
|
||||
{{ package }}
|
||||
</h3>
|
||||
The following modifications have been made to {{ package }} to satisfy
|
||||
{{ dep }}:
|
||||
<ul>
|
||||
<li *ngFor="let d of diff" [innerHTML]="d"></li>
|
||||
</ul>
|
||||
To accept these modifications, click "Save".
|
||||
</tui-notification>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiNotificationModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConfigDepComponent 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
281
web/projects/ui/src/app/modals/config.component.ts
Normal file
281
web/projects/ui/src/app/modals/config.component.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, Inject, ViewChild } from '@angular/core'
|
||||
import {
|
||||
ErrorService,
|
||||
getErrorMessage,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiLoaderModule,
|
||||
TuiModeModule,
|
||||
TuiNotificationModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { compare, Operation } from 'fast-json-patch'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { endWith, firstValueFrom, Subscription } from 'rxjs'
|
||||
import { ActionButton, FormComponent } from 'src/app/components/form.component'
|
||||
import { InvalidService } from 'src/app/components/form/invalid.service'
|
||||
import { ConfigDepComponent } from 'src/app/modals/config-dep.component'
|
||||
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
getAllPackages,
|
||||
getManifest,
|
||||
getPackage,
|
||||
} from 'src/app/util/get-package-data'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
|
||||
export interface PackageConfigData {
|
||||
readonly pkgId: string
|
||||
readonly dependentInfo?: DependentInfo
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<tui-loader
|
||||
*ngIf="loadingText"
|
||||
size="l"
|
||||
[textContent]="loadingText"
|
||||
></tui-loader>
|
||||
|
||||
<tui-notification
|
||||
*ngIf="!loadingText && (loadingError || !pkg)"
|
||||
status="error"
|
||||
>
|
||||
<div [innerHTML]="loadingError"></div>
|
||||
</tui-notification>
|
||||
|
||||
<ng-container
|
||||
*ngIf="
|
||||
!loadingText && !loadingError && pkg && (pkg | toManifest) as manifest
|
||||
"
|
||||
>
|
||||
<tui-notification *ngIf="success" status="success">
|
||||
{{ manifest.title }} has been automatically configured with recommended
|
||||
defaults. Make whatever changes you want, then click "Save".
|
||||
</tui-notification>
|
||||
|
||||
<config-dep
|
||||
*ngIf="dependentInfo && value && original"
|
||||
[package]="manifest.title"
|
||||
[dep]="dependentInfo.title"
|
||||
[original]="original"
|
||||
[value]="value"
|
||||
></config-dep>
|
||||
|
||||
<tui-notification *ngIf="!manifest.hasConfig" status="warning">
|
||||
No config options for {{ manifest.title }} {{ manifest.version }}.
|
||||
</tui-notification>
|
||||
|
||||
<app-form
|
||||
tuiMode="onDark"
|
||||
[spec]="spec"
|
||||
[value]="value || {}"
|
||||
[buttons]="buttons"
|
||||
[patch]="patch"
|
||||
>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
type="reset"
|
||||
[style.margin-right]="'auto'"
|
||||
>
|
||||
Reset Defaults
|
||||
</button>
|
||||
</app-form>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
tui-notification {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormComponent,
|
||||
TuiLoaderModule,
|
||||
TuiNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
ConfigDepComponent,
|
||||
UiPipeModule,
|
||||
],
|
||||
providers: [InvalidService],
|
||||
})
|
||||
export class ConfigModal {
|
||||
@ViewChild(FormComponent)
|
||||
private readonly form?: FormComponent<Record<string, any>>
|
||||
|
||||
readonly pkgId = this.context.data.pkgId
|
||||
readonly dependentInfo = this.context.data.dependentInfo
|
||||
|
||||
loadingError = ''
|
||||
loadingText = this.dependentInfo
|
||||
? `Setting properties to accommodate ${this.dependentInfo.title}`
|
||||
: 'Loading Config'
|
||||
|
||||
pkg?: PackageDataEntry
|
||||
spec: CT.InputSpec = {}
|
||||
patch: Operation[] = []
|
||||
buttons: ActionButton<any>[] = [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: value => this.save(value),
|
||||
},
|
||||
]
|
||||
|
||||
original: object | null = null
|
||||
value: object | null = null
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<void, PackageConfigData>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patchDb: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
get success(): boolean {
|
||||
return (
|
||||
!!this.form &&
|
||||
!this.form.form.dirty &&
|
||||
!this.original &&
|
||||
!this.pkg?.status?.configured
|
||||
)
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.pkg = await getPackage(this.patchDb, this.pkgId)
|
||||
|
||||
if (!this.pkg) {
|
||||
this.loadingError = 'This service does not exist'
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.dependentInfo) {
|
||||
const depConfig = await this.embassyApi.dryConfigureDependency({
|
||||
dependencyId: this.pkgId,
|
||||
dependentId: this.dependentInfo.id,
|
||||
})
|
||||
|
||||
this.original = depConfig.oldConfig
|
||||
this.value = depConfig.newConfig || this.original
|
||||
this.spec = depConfig.spec
|
||||
this.patch = compare(this.original, this.value)
|
||||
} else {
|
||||
const { config, spec } = await this.embassyApi.getPackageConfig({
|
||||
id: this.pkgId,
|
||||
})
|
||||
|
||||
this.original = config
|
||||
this.value = config
|
||||
this.spec = spec
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.loadingError = String(getErrorMessage(e))
|
||||
} finally {
|
||||
this.loadingText = ''
|
||||
}
|
||||
}
|
||||
|
||||
private async save(config: any) {
|
||||
const loader = new Subscription()
|
||||
|
||||
try {
|
||||
await this.uploadFiles(config, loader)
|
||||
|
||||
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patchDb))) {
|
||||
await this.configureDeps(config, loader)
|
||||
} else {
|
||||
await this.configure(config, loader)
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFiles(config: Record<string, any>, loader: Subscription) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
|
||||
// TODO: Could be nested files
|
||||
const keys = Object.keys(config).filter(key => config[key] instanceof File)
|
||||
const message = `Uploading File${keys.length > 1 ? 's' : ''}...`
|
||||
|
||||
if (!keys.length) return
|
||||
|
||||
loader.add(this.loader.open(message).subscribe())
|
||||
|
||||
const hashes = await Promise.all(
|
||||
keys.map(key => this.embassyApi.uploadFile(config[key])),
|
||||
)
|
||||
keys.forEach((key, i) => (config[key] = hashes[i]))
|
||||
}
|
||||
|
||||
private async configureDeps(
|
||||
config: Record<string, any>,
|
||||
loader: Subscription,
|
||||
) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Checking dependent services...').subscribe())
|
||||
|
||||
const breakages = await this.embassyApi.drySetPackageConfig({
|
||||
id: this.pkgId,
|
||||
config,
|
||||
})
|
||||
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
|
||||
if (isEmptyObject(breakages) || (await this.approveBreakages(breakages))) {
|
||||
await this.configure(config, loader)
|
||||
}
|
||||
}
|
||||
|
||||
private async configure(config: Record<string, any>, loader: Subscription) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Saving...').subscribe())
|
||||
|
||||
await this.embassyApi.setPackageConfig({ id: this.pkgId, config })
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
|
||||
private async approveBreakages(breakages: Breakages): Promise<boolean> {
|
||||
const packages = await getAllPackages(this.patchDb)
|
||||
const message =
|
||||
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
|
||||
const content = `${message}${Object.keys(breakages).map(
|
||||
id => `<li><b>${getManifest(packages[id]).title}</b></li>`,
|
||||
)}</ul>`
|
||||
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
|
||||
|
||||
return firstValueFrom(
|
||||
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { EnumListPage } from './enum-list.page'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [EnumListPage],
|
||||
imports: [CommonModule, IonicModule, FormsModule],
|
||||
exports: [EnumListPage],
|
||||
})
|
||||
export class EnumListPageModule {}
|
||||
@@ -1,45 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ spec.name }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ion-item-divider style="padding-bottom: 6px">
|
||||
<ion-buttons slot="end">
|
||||
<ion-button fill="clear" (click)="toggleSelectAll()">
|
||||
<b>{{ selectAll ? 'Select All' : 'Deselect All' }}</b>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let option of options | keyvalue : asIsOrder">
|
||||
<ion-label>{{ spec.spec['value-names'][option.key] }}</ion-label>
|
||||
<ion-checkbox
|
||||
slot="end"
|
||||
[(ngModel)]="option.value"
|
||||
(click)="toggleSelected(option.key)"
|
||||
></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="save()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Done
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ValueSpecListOf } from 'src/app/pkg-config/config-types'
|
||||
|
||||
@Component({
|
||||
selector: 'enum-list',
|
||||
templateUrl: './enum-list.page.html',
|
||||
styleUrls: ['./enum-list.page.scss'],
|
||||
})
|
||||
export class EnumListPage {
|
||||
@Input() key!: string
|
||||
@Input() spec!: ValueSpecListOf<'enum'>
|
||||
@Input() current: string[] = []
|
||||
|
||||
options: { [option: string]: boolean } = {}
|
||||
selectAll = false
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
ngOnInit() {
|
||||
for (let val of this.spec.spec.values || []) {
|
||||
this.options[val] = this.current.includes(val)
|
||||
}
|
||||
// if none are selected, set selectAll to true
|
||||
this.selectAll = Object.values(this.options).some(k => !k)
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
save() {
|
||||
this.modalCtrl.dismiss(
|
||||
Object.keys(this.options).filter(key => this.options[key]),
|
||||
)
|
||||
}
|
||||
|
||||
toggleSelectAll() {
|
||||
Object.keys(this.options).forEach(k => (this.options[k] = this.selectAll))
|
||||
this.selectAll = !this.selectAll
|
||||
}
|
||||
|
||||
toggleSelected(key: string) {
|
||||
this.options[key] = !this.options[key]
|
||||
}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { GenericFormPage } from './generic-form.page'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { FormObjectComponentModule } from 'src/app/components/form-object/form-object.component.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [GenericFormPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FormObjectComponentModule,
|
||||
],
|
||||
exports: [GenericFormPage],
|
||||
})
|
||||
export class GenericFormPageModule {}
|
||||
@@ -1,35 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ title }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<form
|
||||
[formGroup]="formGroup"
|
||||
(ngSubmit)="handleClick(submitBtn.handler)"
|
||||
novalidate
|
||||
>
|
||||
<form-object [objectSpec]="spec" [formGroup]="formGroup"></form-object>
|
||||
<button hidden type="submit"></button>
|
||||
</form>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar class="footer">
|
||||
<ion-buttons slot="end">
|
||||
<ion-button
|
||||
class="ion-padding-end"
|
||||
*ngFor="let button of buttons"
|
||||
(click)="handleClick(button.handler)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -1,9 +0,0 @@
|
||||
button:disabled,
|
||||
button[disabled]{
|
||||
border: 1px solid #999999;
|
||||
background-color: #cccccc;
|
||||
color: #666666;
|
||||
}
|
||||
button {
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user