Merge branch 'next/minor' of github.com:Start9Labs/start-os into feat/boot-param

This commit is contained in:
Aiden McClelland
2024-07-10 12:18:48 -06:00
343 changed files with 13365 additions and 11747 deletions

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