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:
Matt Hill
2024-07-10 11:58:02 -06:00
committed by GitHub
parent 822dd5e100
commit f76e822381
173 changed files with 9761 additions and 9200 deletions

9004
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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)
}
}
}

View File

@@ -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 },

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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']
}

View 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 {}

View File

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

View File

@@ -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',

View File

@@ -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'

View File

@@ -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
}

View 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
}

View 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
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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>

View File

@@ -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,
) {}
}

View File

@@ -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],

View File

@@ -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()
}
})

View File

@@ -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,

View File

@@ -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',
}),
})

View File

@@ -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">&nbsp;(New)</ion-text>
<ion-text color="success" *ngIf="data.newOptions">&nbsp;(New Options)</ion-text>
<ion-text color="warning" *ngIf="data.edited">&nbsp;(Edited)</ion-text>
<span *ngIf="data.required">&nbsp;*</span>

View File

@@ -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>

View File

@@ -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 {}

View File

@@ -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;
}

View File

@@ -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}`
}

View File

@@ -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
}
}

View File

@@ -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>

View 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()
})
}
}

View File

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

View File

@@ -0,0 +1,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)
}
}

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,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)
}
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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)],
}
}

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,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
}
}

View File

@@ -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',
}),
},
]

View File

@@ -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>

View File

@@ -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)),
]
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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> {}

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import {
ChangeDetectionStrategy,
Component,
Input,
ViewEncapsulation,
} from '@angular/core'
import { 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
}
}

View File

@@ -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,
}),
]

View File

@@ -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>

View File

@@ -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]) || []
}
}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,38 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
Output,
} from '@angular/core'
import { ControlContainer } from '@angular/forms'
import { 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)
}
}

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -0,0 +1,8 @@
.button {
pointer-events: auto;
margin-left: 0.25rem;
}
.masked {
-webkit-text-security: disc;
}

View File

@@ -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 || '')
}
}

View File

@@ -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>

View File

@@ -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
> {}

View File

@@ -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>

View File

@@ -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> {}

View File

@@ -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>

View File

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

View File

@@ -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)
}
}

View 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 {}

View 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')
}
}

View File

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

View File

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

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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()
}
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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(' &rarr; ')
return `${displayPath}: ${message}`
})
}
private getOldValue(path: any): string {
const val = getValueByPointer(this.original, path)
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'entry'
} else {
return 'list'
}
}
private getNewValue(val: any): string {
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'new entry'
} else {
return 'new list'
}
}
private markDirty(patch: Operation[]) {
patch.forEach(op => {
const arrPath = op.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (op.op !== 'remove') this.configForm!.get(arrPath)?.markAsDirty()
if (typeof arrPath[arrPath.length - 1] === 'number') {
const prevPath = arrPath.slice(0, arrPath.length - 1)
this.configForm!.get(prevPath)?.markAsDirty()
}
})
}
private async presentAlertUnsaved() {
const alert = await this.alertCtrl.create({
header: 'Unsaved Changes',
message: 'You have unsaved changes. Are you sure you want to leave?',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: `Leave`,
handler: () => {
this.modalCtrl.dismiss()
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
}

View File

@@ -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()
}
}
}

View 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(' &rarr; ')
}
private getMessage(operation: Operation): string {
switch (operation.op) {
case 'add':
return `Added ${this.getNewValue(operation.value)}`
case 'remove':
return `Removed ${this.getOldValue(operation.path)}`
case 'replace':
return `Changed from ${this.getOldValue(
operation.path,
)} to ${this.getNewValue(operation.value)}`
default:
return `Unknown operation`
}
}
private getOldValue(path: any): string {
const val = getValueByPointer(this.original, path)
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'entry'
} else {
return 'list'
}
}
private getNewValue(val: any): string {
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'new entry'
} else {
return 'new list'
}
}
}

View File

@@ -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)),
)
}
}

View File

@@ -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 {}

View File

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

View File

@@ -1,50 +0,0 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { ValueSpecListOf } from '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
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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