Feat/external-smtp (#1791)

* UI for EOS smtp, missing API layer

* implement api

* fix errors

* switch to external smtp creds

* fix things up

* fix types

* update types for new forms

* feat: add new form to emails and marketplace (#2268)

* import tuilet module

* feat: get rid of old form completely (#2270)

* move to builder spec and delete developer menu

* update sdk

* tiny

* getting better

* working

* done

* feat: add step to number config

* chore: small fixes

* update SDK and step for numbers

---------

Co-authored-by: Alex Inkin <alexander@inkin.ru>
This commit is contained in:
Matt Hill
2023-05-16 08:03:29 -06:00
committed by Aiden McClelland
parent 4c465850a2
commit 010be05920
105 changed files with 1237 additions and 4156 deletions

View File

@@ -27,11 +27,12 @@
"@ng-web-apis/resize-observer": "^2.0.0",
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"@taiga-ui/addon-charts": "3.26.0",
"@taiga-ui/cdk": "3.26.0",
"@taiga-ui/core": "3.26.0",
"@taiga-ui/icons": "3.26.0",
"@taiga-ui/kit": "3.26.0",
"@start9labs/start-sdk": "git+https://github.com/Start9Labs/start-sdk#9a23967a7a9c529b27868ca3d7628d271bfb38af",
"@taiga-ui/addon-charts": "3.27.0",
"@taiga-ui/cdk": "3.27.0",
"@taiga-ui/core": "3.27.0",
"@taiga-ui/icons": "3.27.0",
"@taiga-ui/kit": "3.27.0",
"angular-svg-round-progressbar": "^9.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
@@ -53,7 +54,6 @@
"patch-db-client": "file: ../../../patch-db/client",
"pbkdf2": "^3.1.2",
"rxjs": "^7.5.6",
"start-sdk": "^0.4.0-lib0.rc3",
"swiper": "^8.2.4",
"ts-matches": "^5.2.1",
"tslib": "^2.3.0",
@@ -3830,6 +3830,25 @@
"resolved": "https://registry.npmjs.org/@start9labs/emver/-/emver-0.1.5.tgz",
"integrity": "sha512-1dhiG03VkfEwSLx/JPKVms6srAbYFQgwfSGhwpUKMDliMXuAHGVaueStmqzVxn3JpH/HEVz0QW8w/PXHqjdiIg=="
},
"node_modules/@start9labs/start-sdk": {
"version": "0.4.0-rev0.lib0.rc2",
"resolved": "git+ssh://git@github.com/Start9Labs/start-sdk.git#9a23967a7a9c529b27868ca3d7628d271bfb38af",
"integrity": "sha512-P2EkO20hRszt2f/PdhsdRnNe3g0RG96RIV7n38htsVBouHOy/j4QZ1naBvWTuPOKOjdwb3Sbk5haq/FT10JPqw==",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",
"ts-matches": "^5.4.1",
"yaml": "^2.2.2"
}
},
"node_modules/@start9labs/start-sdk/node_modules/yaml": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.2.tgz",
"integrity": "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==",
"engines": {
"node": ">= 14"
}
},
"node_modules/@stencil/core": {
"version": "2.22.3",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-2.22.3.tgz",
@@ -3843,9 +3862,9 @@
}
},
"node_modules/@taiga-ui/addon-charts": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.26.0.tgz",
"integrity": "sha512-nkAzI+B4CcPogUrpEwANu3D8n3cJzuIakF//8MyOzxvg0S4olpL81t9/Mx4+zyXxqjVTaU8q2a/rJNaV+7SyRg==",
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.27.0.tgz",
"integrity": "sha512-PZMwRl8pcbF1UcRXzrnzF6rcdg6ZMHSdiF7Q2VUO8Q39GFguyYNYIFdkRHOLvh1wbsXQKoSxho72RN2yeEybCA==",
"dependencies": {
"tslib": ">=2.0.0"
},
@@ -3853,15 +3872,15 @@
"@angular/common": ">=12.0.0",
"@angular/core": ">=12.0.0",
"@ng-web-apis/common": ">=2.0.0",
"@taiga-ui/cdk": ">=3.26.0",
"@taiga-ui/core": ">=3.26.0",
"@taiga-ui/cdk": ">=3.27.0",
"@taiga-ui/core": ">=3.27.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0"
}
},
"node_modules/@taiga-ui/cdk": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.26.0.tgz",
"integrity": "sha512-vd2CMQ/Z6bhzCQSBSHjSoCIJEE2g4RKmjl3RBK/OdA/L46s9/nQS8oTRBG8I0zk8lNx7YHqqC6u9IY6BZgOeAg==",
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.27.0.tgz",
"integrity": "sha512-53XLDaQzStpjTV7a4X8658YVlaG7bp1JG4cgIamexylXwkWdsHa9o9KnFFOgsGO5I7heiQ2+kotKPWg7sgUwuQ==",
"dependencies": {
"@ng-web-apis/common": "2.1.0",
"@ng-web-apis/mutation-observer": "2.0.0",
@@ -3871,7 +3890,7 @@
"tslib": "2.5.0"
},
"optionalDependencies": {
"ng-morph": "2.2.0",
"ng-morph": "2.2.4",
"parse5": "6.0.1"
},
"peerDependencies": {
@@ -3883,11 +3902,11 @@
}
},
"node_modules/@taiga-ui/core": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.26.0.tgz",
"integrity": "sha512-+IYn0ssZ3dO8Cm1HYAtbL5t+dvhp0RVzljdS72HBcr7IsnEhr2UDWWvsLv4DqsG4tXigWq6sL9wjXqg6/ylH4g==",
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.27.0.tgz",
"integrity": "sha512-kXODpMjhxR+4YcdEFVpVaC++G7scMCSuSKPuXXoOCWtEZsQTp/pvSCCxcg951/lLRyh0MkzvEHyz7a8BKikgog==",
"dependencies": {
"@taiga-ui/i18n": "^3.26.0",
"@taiga-ui/i18n": "^3.27.0",
"tslib": ">=2.0.0"
},
"peerDependencies": {
@@ -3899,17 +3918,17 @@
"@angular/router": ">=12.0.0",
"@ng-web-apis/common": ">=2.0.0",
"@ng-web-apis/mutation-observer": ">=2.0.0",
"@taiga-ui/cdk": ">=3.26.0",
"@taiga-ui/i18n": ">=3.26.0",
"@taiga-ui/cdk": ">=3.27.0",
"@taiga-ui/i18n": ">=3.27.0",
"@tinkoff/ng-event-plugins": ">=3.1.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0",
"rxjs": ">=6.0.0"
}
},
"node_modules/@taiga-ui/i18n": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.26.0.tgz",
"integrity": "sha512-pI8IIQPYe3I7f/HQ4prCNpttEzwR1VA6ooJoaygVcSQDS8KVr03yyl9RBUzKpl57vnemuduVdfqM9LxX4bPeWQ==",
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.27.0.tgz",
"integrity": "sha512-orOoo4CeecBc4GVMFcMhwvYo83wsudgtbnEbmFecE2NZO3wdntjOGE/TNpVM28JinO3uL5yabgDTd3UaxK6NSw==",
"dependencies": {
"tslib": ">=2.0.0"
},
@@ -3919,18 +3938,21 @@
}
},
"node_modules/@taiga-ui/icons": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.26.0.tgz",
"integrity": "sha512-q42C7LYqmOEf1P6GZPl6we5YZe9dboke4kNmbSYxWMT1EWCsgPWK8QmK02BsDeltUwSp7cnCP7jGZG1lkbuzKg==",
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.27.0.tgz",
"integrity": "sha512-uXMe4B3cMgJ1qLfezsrOxvsHD9Bw6y39921GFMvlpeIwSEnXMc/rn1wEQpyd6Qo1Ib9AfFWHRDhBa7NPGnXllA==",
"dependencies": {
"tslib": "^2.2.0"
}
},
"node_modules/@taiga-ui/kit": {
"version": "3.26.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.26.0.tgz",
"integrity": "sha512-Sdp9FKSi/+C2PgirSLr03YQNyboewhFOaFRtT6cBXzscHJLfTWLSv6nNq1kMDLueVTtuPJjksAXsHj+fpnWIiQ==",
"version": "3.27.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.27.0.tgz",
"integrity": "sha512-2YYiku5wXCr1XeqZHnOgLTH4o3rW3EsCx5O8FRSy2LCtkGFLfLemV7E8x1WQqYzOlTW7cCa2goo+K1NMrUWfMQ==",
"dependencies": {
"@maskito/angular": "0.11.1",
"@maskito/core": "0.11.1",
"@maskito/kit": "0.11.1",
"@ng-web-apis/intersection-observer": "3.0.0",
"text-mask-core": "5.1.2",
"tslib": ">=2.0.0"
@@ -3943,13 +3965,41 @@
"@ng-web-apis/common": ">=2.0.0",
"@ng-web-apis/mutation-observer": ">=2.0.0",
"@ng-web-apis/resize-observer": ">=2.0.0",
"@taiga-ui/cdk": ">=3.26.0",
"@taiga-ui/core": ">=3.26.0",
"@taiga-ui/i18n": ">=3.26.0",
"@taiga-ui/cdk": ">=3.27.0",
"@taiga-ui/core": ">=3.27.0",
"@taiga-ui/i18n": ">=3.27.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0",
"rxjs": ">=6.0.0"
}
},
"node_modules/@taiga-ui/kit/node_modules/@maskito/angular": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@maskito/angular/-/angular-0.11.1.tgz",
"integrity": "sha512-80V4FT2jHv+VrJA2gRJpvWvbYVJvPHHoS0ZDqt8DZO/ejWe2SJP3+i/tFHar3i423tXk59dBLp0ahfwkaaNN1A==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=12.0.0",
"@angular/core": ">=12.0.0",
"@angular/forms": ">=12.0.0",
"@maskito/core": "^0.11.1",
"rxjs": ">=6.0.0"
}
},
"node_modules/@taiga-ui/kit/node_modules/@maskito/core": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@maskito/core/-/core-0.11.1.tgz",
"integrity": "sha512-8wPNVvlf+q1g4KF1By++eppIZxYs0XWCd/dzvtbfLQRwPXIPTnp9Cm8yWFPGbUVkfA5znkpk5OiiCLzkuYYg7A=="
},
"node_modules/@taiga-ui/kit/node_modules/@maskito/kit": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@maskito/kit/-/kit-0.11.1.tgz",
"integrity": "sha512-5P+WC/oP9Cwk2aEyxGLpy934jpOwagvm2wLGGfNLZ7D0WaXSuDtXJGizG0Yt6EOnx3/EdChwI3WcmdLhDKK+bQ==",
"peerDependencies": {
"@maskito/core": "^0.11.1"
}
},
"node_modules/@tinkoff/ng-event-plugins": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@tinkoff/ng-event-plugins/-/ng-event-plugins-3.1.0.tgz",
@@ -6277,6 +6327,7 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10313,9 +10364,9 @@
}
},
"node_modules/ng-morph": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ng-morph/-/ng-morph-2.2.0.tgz",
"integrity": "sha512-0CEswQ+QrxPBWv1dBBu/N6idk0wIXkdFmqk+GW55/Ta7DJTKMCPZLVGXpp+Lia9XF55vVyxnOBw9J3QNN2Dv5A==",
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/ng-morph/-/ng-morph-2.2.4.tgz",
"integrity": "sha512-4AIsjcvUAT6htnX56DsUPZDQuNhWxmi09exUS6TreD6hKghGuqT3QfRf+K9aFw1FJyCsLsh/0py3S/sMtarsIA==",
"optional": true,
"dependencies": {
"jsonc-parser": "3.0.0",
@@ -13796,26 +13847,6 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
}
},
"node_modules/start-sdk": {
"version": "0.4.0-lib0.rc3",
"resolved": "https://registry.npmjs.org/start-sdk/-/start-sdk-0.4.0-lib0.rc3.tgz",
"integrity": "sha512-PAExAKEw0AUhk0UYu25o/UfAwclLt8tvQIDqzv4MaiFg4stPSzWYyFFBBX2kIKlBDlIMlzC6Fj0/8qoxzqq8iQ==",
"dependencies": {
"@iarna/toml": "^2.2.5",
"deepmerge": "^4.3.1",
"lodash": "^4.17.21",
"ts-matches": "^5.4.1",
"yaml": "^2.2.1"
}
},
"node_modules/start-sdk/node_modules/yaml": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.2.1.tgz",
"integrity": "sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw==",
"engines": {
"node": ">= 14"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View File

@@ -52,11 +52,11 @@
"@ng-web-apis/resize-observer": "^2.0.0",
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"@taiga-ui/addon-charts": "3.26.0",
"@taiga-ui/cdk": "3.26.0",
"@taiga-ui/core": "3.26.0",
"@taiga-ui/icons": "3.26.0",
"@taiga-ui/kit": "3.26.0",
"@taiga-ui/addon-charts": "3.27.0",
"@taiga-ui/cdk": "3.27.0",
"@taiga-ui/core": "3.27.0",
"@taiga-ui/icons": "3.27.0",
"@taiga-ui/kit": "3.27.0",
"angular-svg-round-progressbar": "^9.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
@@ -78,7 +78,7 @@
"patch-db-client": "file: ../../../patch-db/client",
"pbkdf2": "^3.1.2",
"rxjs": "^7.5.6",
"start-sdk": "^0.4.0-lib0.rc3",
"@start9labs/start-sdk": "git+https://github.com/Start9Labs/start-sdk#9a23967a7a9c529b27868ca3d7628d271bfb38af",
"swiper": "^8.2.4",
"ts-matches": "^5.2.1",
"tslib": "^2.3.0",

View File

@@ -63,15 +63,6 @@ const routes: Routes = [
m => m.AppsRoutingModule,
),
},
{
path: 'developer',
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
loadChildren: () =>
import('./pages/developer-routes/developer-routing.module').then(
m => m.DeveloperRoutingModule,
),
},
{
path: 'backups',
canActivate: [AuthGuard],

View File

@@ -22,7 +22,6 @@ 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 { GenericFormPageModule } from './modals/generic-form/generic-form.module'
import { MarketplaceModule } from './marketplace.module'
import { PreloaderModule } from './app/preloader/preloader.module'
import { FooterModule } from './app/footer/footer.module'
@@ -54,7 +53,6 @@ import { FormPageModule } from './modals/form/form.module'
OSWelcomePageModule,
MarkdownModule,
GenericInputComponentModule,
GenericFormPageModule,
MonacoEditorModule,
SharedPipesModule,
MarketplaceModule,

View File

@@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'
// TODO: Turn into DI token if this is needed someplace else too
const ICONS = [
'add',
'alarm-outline',
'alert-outline',
'alert-circle-outline',
'aperture-outline',

View File

@@ -1,15 +0,0 @@
<ion-button
*ngIf="data.description"
class="icon"
fill="clear"
(click.stop)="presentAlertDescription()"
>
<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.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,11 +0,0 @@
:host {
display: flex;
align-items: center;
font-weight: bold;
}
.icon {
--padding-start: 0;
--padding-end: 4px;
margin-right: 4px;
}

View File

@@ -1,34 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { AlertController } from '@ionic/angular'
@Component({
selector: 'form-label',
templateUrl: './form-label.component.html',
styleUrls: ['./form-label.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormLabelComponent {
@Input() data!: {
name: string
description: string | null
edited?: boolean
required?: boolean
newOptions?: boolean
}
constructor(private readonly alertCtrl: AlertController) {}
async presentAlertDescription() {
const alert = await this.alertCtrl.create({
header: this.data.name,
message: this.data.description || '',
buttons: [
{
text: 'OK',
cssClass: 'enter-click',
},
],
})
await alert.present()
}
}

View File

@@ -1,49 +0,0 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular'
import { SharedPipesModule } from '@start9labs/shared'
import { TuiElasticContainerModule } from '@taiga-ui/kit'
import { TuiExpandModule } from '@taiga-ui/core'
import { FormLabelComponent } from './form-label/form-label.component'
import { FormObjectComponent } from './form-object/form-object.component'
import { FormUnionComponent } from './form-union/form-union.component'
import {
GetErrorPipe,
ToWarningTextPipe,
ToElementIdPipe,
ToRangePipe,
} from './form-object.pipes'
import { FormFileComponent } from './form-object/controls/form-file/form-file.component'
import { FormInputComponent } from './form-object/controls/form-input/form-input.component'
import { FormWarningDirective } from './form-warning.directive'
import { FormSubformComponent } from './form-object/controls/form-subform/form-subform.component'
import { FormSelectComponent } from './form-object/controls/form-select/form-select.component'
@NgModule({
declarations: [
FormObjectComponent,
FormUnionComponent,
FormLabelComponent,
ToWarningTextPipe,
GetErrorPipe,
ToElementIdPipe,
ToRangePipe,
FormWarningDirective,
FormFileComponent,
FormInputComponent,
FormSubformComponent,
FormSelectComponent,
],
imports: [
CommonModule,
IonicModule,
FormsModule,
ReactiveFormsModule,
SharedPipesModule,
TuiElasticContainerModule,
TuiExpandModule,
],
exports: [FormObjectComponent, FormLabelComponent],
})
export class FormObjectModule {}

View File

@@ -1,64 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { ValidationErrors } from '@angular/forms'
import { IonicSafeString } from '@ionic/angular'
import { Range } from 'src/app/util/config-utilities'
import { getElementId } from './form-object/form-object.component'
@Pipe({
name: 'getError',
})
export class GetErrorPipe implements PipeTransform {
transform(
errors: ValidationErrors,
patternDesc: string = 'Invalid pattern',
): string {
if (errors['required']) {
return 'Required'
} else if (errors['pattern']) {
return patternDesc
} 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: 'toWarningText',
})
export class ToWarningTextPipe implements PipeTransform {
transform(text?: string | null): 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)
}
}

View File

@@ -1,40 +0,0 @@
<ion-item style="--padding-start: 0">
<form-label
[data]="{
name: spec.name,
description: spec.description || null,
edited: control.dirty
}"
></form-label>
<div slot="end">
<ion-button
*ngIf="!control.value; else hasFile"
strong
color="dark"
size="small"
(click)="uploadFile.click()"
>
Browse...
</ion-button>
<input
type="file"
[accept]="spec.extensions.join(',')"
style="display: none"
#uploadFile
(change)="handleFileInput($event)"
/>
<ng-template #hasFile>
<div class="inline">
<p class="ion-padding-end">{{ control.value.name }}</p>
<div style="cursor: pointer" (click)="clearFile()">
<ion-icon name="close"></ion-icon>
</div>
</div>
</ng-template>
</div>
</ion-item>
<p class="error-message">
<span *ngIf="control.errors as errors">
{{ errors | getError }}
</span>
</p>

View File

@@ -1,26 +0,0 @@
:host {
display: block;
}
ion-item-divider {
text-transform: unset;
border-bottom: 1px solid
var(
--ion-item-border-color,
var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13)))
);
--padding-top: 18px;
--padding-start: 0;
&.error-border {
border-color: var(--ion-color-danger-shade);
--border-color: var(--ion-color-danger-shade);
}
}
.error-message {
margin-top: 2px;
font-size: small;
color: var(--ion-color-danger);
}

View File

@@ -1,21 +0,0 @@
import { Component, Input } from '@angular/core'
import { AbstractControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/lib/config/configTypes'
@Component({
selector: 'form-file',
templateUrl: './form-file.component.html',
styleUrls: ['./form-file.component.scss'],
})
export class FormFileComponent {
@Input() spec!: ValueSpecOf<'file'>
@Input() control!: AbstractControl
handleFileInput(e: any) {
this.control.patchValue(e.target.files[0])
}
clearFile() {
this.control.patchValue(null)
}
}

View File

@@ -1,65 +0,0 @@
<form-label
class="label"
[data]="{
name: spec.name,
description: spec.description || null,
edited: control.dirty,
required: spec.required
}"
></form-label>
<ion-item [color]="(theme$ | async) === 'Light' ? 'light' : 'dark'">
<ion-textarea
*ngIf="spec.type === 'textarea'; else notTextArea"
formWarning
#warning="formWarning"
[placeholder]="spec.placeholder"
[formControl]="control"
(ionFocus)="warning.onChange(name, spec)"
(ionChange)="onInputChange.emit()"
></ion-textarea>
<ng-template #notTextArea>
<ion-input
formWarning
#warning="formWarning"
type="text"
class="input"
[class.input_redacted]="
spec.type === 'text' && control.value && spec.masked && !unmasked
"
[inputmode]="spec.type === 'text' ? spec.inputmode : 'tel'"
[minlength]="spec.type === 'number' ? null : spec.minLength"
[maxlength]="spec.type === 'number' ? null : spec.maxLength"
[step]="spec.type === 'number' ? spec.step : null"
[placeholder]="spec.placeholder"
[formControl]="control"
(ionFocus)="warning.onChange(name, spec)"
(ionChange)="onInputChange.emit()"
></ion-input>
</ng-template>
<ion-button
*ngIf="spec.type === 'text' && spec.masked"
slot="end"
fill="clear"
color="light"
(click)="unmasked = !unmasked"
>
<ion-icon
slot="icon-only"
size="small"
[name]="unmasked ? 'eye-off-outline' : 'eye-outline'"
></ion-icon>
</ion-button>
<ion-note
*ngIf="spec.type === 'number' && spec.units"
slot="end"
color="light"
class="units"
>
{{ spec.units }}
</ion-note>
</ion-item>
<p class="error-message">
<span *ngIf="control.errors as errors">
{{ errors | getError : $any(spec).patternDescription }}
</span>
</p>

View File

@@ -1,24 +0,0 @@
.label {
margin: 16px 0 6px;
}
.input {
font-family: 'Courier New';
font-weight: bold;
--placeholder-font-weight: 400;
&_redacted {
font-family: 'Redacted';
}
}
.units {
font-size: medium;
}
.error-message {
margin-top: 2px;
font-size: small;
color: var(--ion-color-danger);
}

View File

@@ -1,21 +0,0 @@
import { Component, Input, inject, Output, EventEmitter } from '@angular/core'
import { FormControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/lib/config/configTypes'
import { THEME } from '@start9labs/shared'
@Component({
selector: 'form-input',
templateUrl: './form-input.component.html',
styleUrls: ['./form-input.component.scss'],
})
export class FormInputComponent {
@Input() name!: string
@Input() spec!: ValueSpecOf<'text' | 'textarea' | 'number'>
@Input() control!: FormControl
@Output() onInputChange = new EventEmitter<void>()
unmasked = false
readonly theme$ = inject(THEME)
}

View File

@@ -1,49 +0,0 @@
<ion-item style="--padding-start: 0">
<form-label
[data]="{
name: spec.name,
description: spec.description || null,
edited: control.dirty
}"
></form-label>
<!-- boolean -->
<ion-toggle
*ngIf="spec.type === 'toggle'"
formWarning
#warning="formWarning"
slot="end"
[formControl]="control"
(ionChange)="warning.onChange(name, spec, undefined, cancelBool)"
></ion-toggle>
<!-- select -->
<!-- adding class enter-click disables the enter click on the modal behind the select -->
<ion-select
*ngIf="spec.type === 'select' || spec.type === 'multiselect'"
[interfaceOptions]="{
header: spec.name,
message: spec.warning | toWarningText,
cssClass: 'enter-click'
}"
slot="end"
placeholder="Select"
[multiple]="spec.type === 'multiselect'"
[formControl]="control"
[selectedText]="
spec.type === 'multiselect' && control.value?.length > 1
? '[' + control.value.length + ' selected]'
: spec.values[control.value]
"
>
<ion-select-option
*ngFor="let option of spec.values | keyvalue"
[value]="option.key"
>
{{ option.value }}
</ion-select-option>
</ion-select>
</ion-item>
<p class="error-message">
<span *ngIf="control.errors as errors">
{{ errors | getError }}
</span>
</p>

View File

@@ -1,5 +0,0 @@
.error-message {
margin-top: 2px;
font-size: small;
color: var(--ion-color-danger);
}

View File

@@ -1,16 +0,0 @@
import { Component, Input } from '@angular/core'
import { FormControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/lib/config/configTypes'
@Component({
selector: 'form-select',
templateUrl: './form-select.component.html',
styleUrls: ['./form-select.component.scss'],
})
export class FormSelectComponent {
@Input() spec!: ValueSpecOf<'toggle' | 'select' | 'multiselect'>
@Input() control!: FormControl
@Input() name!: string
cancelBool = () => this.control.setValue(!this.control.value)
}

View File

@@ -1,23 +0,0 @@
<ion-item-divider
[class.error-border]="control.invalid"
(click)="expanded = !expanded"
>
<form-label
[data]="{
name: spec.name,
description: spec.description || null,
edited: control.dirty,
newOptions: hasNewOptions
}"
></form-label>
<ion-icon
slot="end"
name="chevron-down"
class="icon"
[class.icon_rotated]="expanded"
[color]="control.invalid ? 'danger' : undefined"
></ion-icon>
</ion-item-divider>
<tui-expand [expanded]="expanded">
<ng-content></ng-content>
</tui-expand>

View File

@@ -1,25 +0,0 @@
ion-item-divider {
cursor: pointer;
text-transform: unset;
border-bottom: 1px solid
var(
--ion-item-border-color,
var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13)))
);
--padding-top: 18px;
--padding-start: 0;
&.error-border {
border-color: var(--ion-color-danger-shade);
--border-color: var(--ion-color-danger-shade);
}
}
.icon {
transition: transform 0.42s ease-out;
&_rotated {
transform: rotate(180deg);
}
}

View File

@@ -1,16 +0,0 @@
import { Component, Input, Output, EventEmitter } from '@angular/core'
import { AbstractControl } from '@angular/forms'
import { ValueSpecOf } from 'start-sdk/lib/config/configTypes'
@Component({
selector: 'form-subform',
templateUrl: './form-subform.component.html',
styleUrls: ['./form-subform.component.scss'],
})
export class FormSubformComponent {
@Input() spec!: ValueSpecOf<'object'>
@Input() control!: AbstractControl
@Input() hasNewOptions = false
expanded = false
}

View File

@@ -1,198 +0,0 @@
<ion-item-group [formGroup]="formGroup">
<ng-container *ngFor="let entry of formGroup.controls | keyvalue : asIsOrder">
<ng-container *ngIf="objectSpec[entry.key] as spec">
<!-- file -->
<form-file
*ngIf="spec.type === 'file'"
[spec]="spec"
[control]="entry.value"
></form-file>
<!-- string or number -->
<form-input
*ngIf="
spec.type === 'text' ||
spec.type === 'textarea' ||
spec.type === 'number'
"
[spec]="spec"
[name]="entry.key"
[control]="$any(entry.value)"
(onInputChange)="handleInputChange()"
></form-input>
<!-- boolean, select or multiselect -->
<form-select
*ngIf="
spec.type === 'toggle' ||
spec.type === 'select' ||
spec.type === 'multiselect'
"
[spec]="spec"
[name]="entry.key"
[control]="$any(entry.value)"
></form-select>
<!-- object -->
<form-subform
*ngIf="spec.type === 'object'"
[spec]="spec"
[control]="entry.value"
[hasNewOptions]="objectDisplay[entry.key].hasNewOptions"
>
<form-object
class="nested-wrapper"
[objectSpec]="spec.spec"
[formGroup]="$any(entry.value)"
[current]="current?.[entry.key]"
[original]="original?.[entry.key]"
(hasNewOptions)="setHasNew(entry.key)"
></form-object>
</form-subform>
<!-- union -->
<form-union
*ngIf="spec.type === 'union'"
[spec]="spec"
[formGroup]="$any(entry.value)"
[current]="current?.[entry.key]"
[original]="original?.[entry.key]"
></form-union>
<!-- list -->
<ng-container *ngIf="spec.type === 'list'">
<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 || null,
edited: entry.value.dirty,
required: !!spec.minLength
}"
></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.get(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 -->
<ng-container *ngIf="spec.spec.type === 'object'">
<!-- object 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),
description: null,
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.spec.type === '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>
<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.spec.type === 'text' || spec.spec.type === 'number'"
[id]="objectId | toElementId : entry.key : i"
>
<ion-item
[color]="(theme$ | async) === 'Light' ? 'light' : 'dark'"
>
<ion-input
type="text"
[inputmode]="
spec.spec.type === 'text' ? spec.spec.inputmode : 'tel'
"
[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="
$any(formGroup.get(entry.key))?.at(i)?.errors as errors
"
>
{{ errors | getError : $any(spec).patternDescription }}
</span>
</p>
</div>
</div>
</div>
</ng-container>
</ng-container>
</ng-container>
</ng-container>
</ion-item-group>

View File

@@ -1,41 +0,0 @@
:host {
display: block;
}
.input {
font-family: 'Courier New';
font-weight: bold;
--placeholder-font-weight: 400;
&_redacted {
font-family: 'Redacted';
}
}
ion-item-divider {
text-transform: unset;
border-bottom: 1px solid
var(
--ion-item-border-color,
var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13)))
);
--padding-top: 18px;
--padding-start: 0;
&.error-border {
border-color: var(--ion-color-danger-shade);
--border-color: var(--ion-color-danger-shade);
}
}
.nested-wrapper {
padding: 0 0 16px 24px;
}
.error-message {
margin-top: 2px;
font-size: small;
color: var(--ion-color-danger);
}

View File

@@ -1,253 +0,0 @@
import {
Component,
Input,
Output,
EventEmitter,
Inject,
inject,
SimpleChanges,
} from '@angular/core'
import { UntypedFormArray, UntypedFormGroup } from '@angular/forms'
import { AlertButton, AlertController } from '@ionic/angular'
import {
InputSpec,
ListValueSpecOf,
ValueSpec,
ValueSpecToggle,
ValueSpecList,
ValueSpecUnion,
} from 'start-sdk/lib/config/configTypes'
import { FormService } from 'src/app/services/form.service'
import { THEME, pauseFor } from '@start9labs/shared'
import { v4 } from 'uuid'
import { DOCUMENT } from '@angular/common'
const Mustache = require('mustache')
@Component({
selector: 'form-object',
templateUrl: './form-object.component.html',
styleUrls: ['./form-object.component.scss'],
})
export class FormObjectComponent {
@Input() objectSpec!: InputSpec
@Input() formGroup!: UntypedFormGroup
@Input() current?: Record<string, any>
@Input() original?: Record<string, any>
@Output() onInputChange = new EventEmitter<void>()
@Output() hasNewOptions = new EventEmitter<void>()
warningAck: { [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 formService: FormService,
@Inject(DOCUMENT) private readonly document: Document,
) {}
ngOnInit() {
this.setDisplays()
// setTimeout hack to avoid ExpressionChangedAfterItHasBeenCheckedError
// setTimeout(() => {
// if (this.original && Object.values(this.objectSpec).some(spec => spec['is-new'])) 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' && spec.spec.type === 'object') {
this.objectListDisplay[key] = []
this.formGroup.get(key)?.value.forEach((obj: any, index: number) => {
const displayAs = (spec.spec as ListValueSpecOf<'object'>).displayAs
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
})
}
handleBooleanChange(key: string, spec: ValueSpecToggle) {
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 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()
}
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 (listSpec.spec.type === 'object') {
const displayAs = listSpec.spec.displayAs
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 (listSpec.spec.type === 'object') {
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)
})
}
asIsOrder() {
return 0
}
}
export function getElementId(objectId: string, key: string, index = 0): string {
return `${key}-${index}-${objectId}`
}

View File

@@ -1,47 +0,0 @@
<div [formGroup]="formGroup">
<!-- union enum -->
<ion-item-divider [class.error-border]="formGroup.invalid">
<form-label
[data]="{
name: spec.name,
description: spec.description || null,
newOptions: hasNewOptions,
edited: formGroup.dirty
}"
></form-label>
<!-- class enter-click disables the enter click on the modal behind the select -->
<ion-select
[interfaceOptions]="{
header: spec.name,
message: spec.warning | toWarningText,
cssClass: 'enter-click'
}"
slot="end"
placeholder="Select"
[formControlName]="unionSelectKey"
[selectedText]="variantName"
(ionChange)="updateUnion($event)"
>
<ion-select-option
*ngFor="let option of spec.variants | keyvalue"
[value]="option.key"
>
{{ spec.variants[option.key].name }}
</ion-select-option>
</ion-select>
</ion-item-divider>
<p class="error-message">
<span *ngIf="unionControl?.errors as errors">
{{ errors | getError }}
</span>
</p>
<tui-elastic-container [id]="objectId | toElementId : 'union'" class="indent">
<form-object
[objectSpec]="variantSpec"
[formGroup]="formGroup"
[current]="current"
[original]="original"
></form-object>
</tui-elastic-container>
</div>

View File

@@ -1,30 +0,0 @@
:host {
display: block;
}
ion-item-divider {
text-transform: unset;
border-bottom: 1px solid
var(
--ion-item-border-color,
var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13)))
);
--padding-top: 18px;
--padding-start: 0;
&.error-border {
border-color: var(--ion-color-danger-shade);
--border-color: var(--ion-color-danger-shade);
}
}
.indent {
margin-left: 24px;
}
.error-message {
margin-top: 2px;
font-size: small;
color: var(--ion-color-danger);
}

View File

@@ -1,66 +0,0 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { AbstractControl, UntypedFormGroup } from '@angular/forms'
import { v4 } from 'uuid'
import { FormService } from 'src/app/services/form.service'
import {
ValueSpecUnion,
InputSpec,
unionSelectKey,
} from 'start-sdk/lib/config/configTypes'
@Component({
selector: 'form-union',
templateUrl: './form-union.component.html',
styleUrls: ['./form-union.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormUnionComponent {
readonly unionSelectKey = unionSelectKey
@Input() formGroup!: UntypedFormGroup
@Input() spec!: ValueSpecUnion
@Input() current?: Record<string, any>
@Input() original?: Record<string, any>
get unionControl(): AbstractControl | null {
return this.formGroup.get(unionSelectKey)
}
get selectedVariant(): string {
return this.unionControl?.value || ''
}
get variantName(): string {
return this.spec.variants[this.selectedVariant]?.name || ''
}
get variantSpec(): InputSpec {
return this.spec.variants[this.selectedVariant]?.spec || {}
}
get hasNewOptions(): boolean {
// return Object.values(this.variantSpec).some(spec => spec['is-new'])
return false
}
objectId = v4()
constructor(private readonly formService: FormService) {}
updateUnion(e: any): void {
Object.keys(this.formGroup.controls).forEach(control => {
if (control === unionSelectKey) 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 === unionSelectKey) return
this.formGroup.addControl(control, unionGroup.controls[control])
})
}
}

View File

@@ -1,51 +0,0 @@
import { Directive } from '@angular/core'
import { ValueSpec, ValueSpecUnion } from 'start-sdk/lib/config/configTypes'
import { AlertButton, AlertController } from '@ionic/angular'
@Directive({
selector: '[formWarning]',
exportAs: 'formWarning',
})
export class FormWarningDirective {
private warned = false
constructor(private readonly alertCtrl: AlertController) {}
async onChange<T extends ValueSpec>(
key: string,
spec: T extends ValueSpecUnion ? never : T,
okFn?: Function,
cancelFn?: Function,
) {
if (!spec.warning || this.warned) return okFn ? okFn() : null
this.warned = 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()
}
}

View File

@@ -1,6 +1,6 @@
import { inject } from '@angular/core'
import { FormControlComponent } from './form-control/form-control.component'
import { ValueSpec } from 'start-sdk/lib/config/configTypes'
import { ValueSpec } from '@start9labs/start-sdk/lib/config/configTypes'
export abstract class Control<Spec extends ValueSpec, Value> {
private readonly control: FormControlComponent<Spec, Value> =

View File

@@ -9,7 +9,7 @@ import {
} from '@taiga-ui/core'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { filter, takeUntil } from 'rxjs'
import { ValueSpecList } from 'start-sdk/lib/config/configTypes'
import { ValueSpecList } from '@start9labs/start-sdk/lib/config/configTypes'
import { FormService } from '../../../services/form.service'
import { ERRORS } from '../form-group/form-group.component'

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core'
import { ValueSpecColor } from 'start-sdk/lib/config/configTypes'
import { ValueSpecColor } from '@start9labs/start-sdk/lib/config/configTypes'
import { Control } from '../control'
import { MaskitoOptions } from '@maskito/core'

View File

@@ -6,18 +6,14 @@ import {
TemplateRef,
ViewChild,
} from '@angular/core'
import {
AbstractTuiNullableControl,
TuiContextWithImplicit,
} from '@taiga-ui/cdk'
import { AbstractTuiNullableControl } from '@taiga-ui/cdk'
import {
TuiAlertService,
TuiDialogContext,
TuiNotification,
} from '@taiga-ui/core'
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
import { filter, takeUntil } from 'rxjs'
import { ValueSpec, ValueSpecText } from 'start-sdk/lib/config/configTypes'
import { ValueSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import { ERRORS } from '../form-group/form-group.component'
import { FORM_CONTROL_PROVIDERS } from './form-control.providers'

View File

@@ -1,6 +1,6 @@
import { forwardRef, Provider } from '@angular/core'
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
import { ValueSpec } from 'start-sdk/lib/config/configTypes'
import { ValueSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import { FormControlComponent } from './form-control.component'
interface ValidatorsPatternError {

View File

@@ -6,7 +6,7 @@ import {
tuiPure,
TuiTime,
} from '@taiga-ui/cdk'
import { ValueSpecDatetime } from 'start-sdk/lib/config/configTypes'
import { ValueSpecDatetime } from '@start9labs/start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({

View File

@@ -1,6 +1,6 @@
import { Component } from '@angular/core'
import { TuiFileLike } from '@taiga-ui/kit'
import { ValueSpecFile } from 'start-sdk/lib/config/configTypes'
import { ValueSpecFile } from '@start9labs/start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({

View File

@@ -4,7 +4,7 @@ import {
Input,
ViewEncapsulation,
} from '@angular/core'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import { FORM_GROUP_PROVIDERS } from './form-group.providers'
export const ERRORS = [

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core'
import { ValueSpecMultiselect } from 'start-sdk/lib/config/configTypes'
import { ValueSpecMultiselect } from '@start9labs/start-sdk/lib/config/configTypes'
import { Control } from '../control'
import { tuiPure } from '@taiga-ui/cdk'

View File

@@ -1,4 +1,3 @@
<!-- @TODO Implement InputNumber step once it is added in Taiga UI -->
<tui-input-number
[tuiHintContent]="spec.description"
[tuiTextfieldPostfix]="spec.units || ''"
@@ -7,6 +6,7 @@
[decimal]="spec.integer ? 'never' : 'not-zero'"
[min]="spec.min ?? -Infinity"
[max]="spec.max ?? Infinity"
[step]="spec.step ?? Infinity"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core'
import { ValueSpecNumber } from 'start-sdk/lib/config/configTypes'
import { ValueSpecNumber } from '@start9labs/start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({

View File

@@ -7,7 +7,7 @@ import {
Output,
} from '@angular/core'
import { ControlContainer } from '@angular/forms'
import { ValueSpecObject } from 'start-sdk/lib/config/configTypes'
import { ValueSpecObject } from '@start9labs/start-sdk/lib/config/configTypes'
@Component({
selector: 'form-object',

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core'
import { ValueSpecSelect } from 'start-sdk/lib/config/configTypes'
import { ValueSpecSelect } from '@start9labs/start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core'
import { ValueSpecText } from 'start-sdk/lib/config/configTypes'
import { ValueSpecText } from '@start9labs/start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core'
import { ValueSpecTextarea } from 'start-sdk/lib/config/configTypes'
import { ValueSpecTextarea } from '@start9labs/start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({

View File

@@ -1,5 +1,5 @@
import { Component } from '@angular/core'
import { ValueSpecToggle } from 'start-sdk/lib/config/configTypes'
import { ValueSpecToggle } from '@start9labs/start-sdk/lib/config/configTypes'
import { Control } from '../control'
@Component({

View File

@@ -11,7 +11,7 @@ import {
ValueSpecSelect,
ValueSpecUnion,
unionValueKey,
} from 'start-sdk/lib/config/configTypes'
} from '@start9labs/start-sdk/lib/config/configTypes'
import { FormService } from '../../../services/form.service'
import { tuiPure } from '@taiga-ui/cdk'
@@ -50,10 +50,14 @@ export class FormUnionComponent implements OnChanges {
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

@@ -11,7 +11,7 @@ import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { getErrorMessage, isEmptyObject } from '@start9labs/shared'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import {
DataModel,
PackageDataEntry,
@@ -127,6 +127,7 @@ export class AppConfigPage {
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)
@@ -147,6 +148,7 @@ export class AppConfigPage {
loader: Subscription,
) {
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Checking dependent services...').subscribe())
const breakages = await this.embassyApi.drySetPackageConfig({
@@ -155,6 +157,7 @@ export class AppConfigPage {
})
loader.unsubscribe()
loader.closed = false
if (isEmptyObject(breakages) || (await this.approveBreakages(breakages))) {
await this.configure(config, loader)
@@ -163,6 +166,7 @@ export class AppConfigPage {
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 })

View File

@@ -6,7 +6,7 @@ import {
OnInit,
} from '@angular/core'
import { FormService } from 'src/app/services/form.service'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { TuiDialogContext } from '@taiga-ui/core'
import { tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'

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 { FormObjectModule } from 'src/app/components/form-object/form-object.module'
@NgModule({
declarations: [GenericFormPage],
imports: [
CommonModule,
IonicModule,
FormsModule,
ReactiveFormsModule,
FormObjectModule,
],
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 enter-click"
*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);
}

View File

@@ -1,72 +0,0 @@
import { Component, Input } from '@angular/core'
import { UntypedFormGroup } from '@angular/forms'
import { ModalController } from '@ionic/angular'
import {
convertValuesRecursive,
FormService,
} from 'src/app/services/form.service'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { ErrorToastService } from '@start9labs/shared'
export interface ActionButton {
text: string
handler: (value: any) => Promise<boolean>
isSubmit?: boolean
}
@Component({
selector: 'generic-form',
templateUrl: './generic-form.page.html',
styleUrls: ['./generic-form.page.scss'],
})
export class GenericFormPage {
@Input() title!: string
@Input() spec!: InputSpec
@Input() buttons!: ActionButton[]
@Input() initialValue: Record<string, any> = {}
submitBtn!: ActionButton
formGroup!: UntypedFormGroup
constructor(
private readonly modalCtrl: ModalController,
private readonly formService: FormService,
private readonly errToast: ErrorToastService,
) {}
ngOnInit() {
this.formGroup = this.formService.createForm(this.spec, this.initialValue)
this.submitBtn = this.buttons.find(btn => btn.isSubmit)! // @TODO this really needs to be redesigned. No way to enforce this with types.
}
async dismiss(): Promise<void> {
this.modalCtrl.dismiss()
}
async handleClick(handler: ActionButton['handler']): Promise<void> {
convertValuesRecursive(this.spec, this.formGroup)
if (this.formGroup.invalid) {
document
.getElementsByClassName('validation-error')[0]
?.scrollIntoView({ behavior: 'smooth' })
return
}
try {
const response = await handler(this.formGroup.value)
this.modalCtrl.dismiss({ response }, 'success')
} catch (e: any) {
this.errToast.present(e)
}
}
}
export interface GenericFormOptions {
// required
title: string
spec: InputSpec
buttons: ActionButton[]
// optional
initialValue?: Record<string, any>
}

View File

@@ -4,6 +4,11 @@ import { IonicModule } from '@ionic/angular'
import { MarketplaceSettingsPage } from './marketplace-settings.page'
import { SharedPipesModule } from '@start9labs/shared'
import { StoreIconComponentModule } from 'src/app/components/store-icon/store-icon.component.module'
import {
TuiDataListModule,
TuiHostedDropdownModule,
TuiSvgModule,
} from '@taiga-ui/core'
@NgModule({
imports: [
@@ -11,6 +16,9 @@ import { StoreIconComponentModule } from 'src/app/components/store-icon/store-ic
IonicModule,
SharedPipesModule,
StoreIconComponentModule,
TuiHostedDropdownModule,
TuiDataListModule,
TuiSvgModule,
],
declarations: [MarketplaceSettingsPage],
})

View File

@@ -1,22 +1,11 @@
<ion-header>
<ion-toolbar>
<ion-title>Change Registry</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-top">
<div class="ion-padding-top">
<ion-item-group *ngIf="stores$ | async as stores">
<ion-item-divider>Default Registries</ion-item-divider>
<ion-item
*ngFor="let s of stores.standard"
detail="false"
[button]="!s.selected"
(click)="s.selected ? '' : presentAction(s)"
(click)="s.selected ? '' : connect(s.url)"
>
<ion-avatar slot="start">
<store-icon [url]="s.url"></store-icon>
@@ -44,24 +33,45 @@
</ion-label>
</ion-item>
<ion-item
<tui-hosted-dropdown
*ngFor="let a of stores.alt"
detail="false"
[button]="!a.selected"
(click)="a.selected ? '' : presentAction(a, true)"
class="host"
tuiDropdownLimitWidth="fixed"
[canOpen]="!a.selected"
[content]="content"
>
<store-icon slot="start" [url]="a.url" size="36px"></store-icon>
<ion-label>
<h2>{{ a.name }}</h2>
<p>{{ a.url }}</p>
</ion-label>
<ion-icon
*ngIf="a.selected"
slot="end"
size="large"
name="checkmark"
color="success"
></ion-icon>
</ion-item>
<ion-item detail="false" [button]="!a.selected">
<ion-avatar slot="start">
<store-icon [url]="a.url" size="36px"></store-icon>
</ion-avatar>
<ion-label>
<h2>{{ a.name }}</h2>
<p>{{ a.url }}</p>
</ion-label>
<ion-icon
*ngIf="a.selected"
slot="end"
size="large"
name="checkmark"
color="success"
></ion-icon>
</ion-item>
<ng-template #content>
<tui-data-list>
<button
tuiOption
class="delete"
(click)="presentAlertDelete(a.url, a.name)"
>
Delete
<tui-svg src="tuiIconTrash2Large"></tui-svg>
</button>
<button tuiOption (click)="connect(a.url)">
Connect
<tui-svg src="tuiIconLogInLarge"></tui-svg>
</button>
</tui-data-list>
</ng-template>
</tui-hosted-dropdown>
</ion-item-group>
</ion-content>
</div>

View File

@@ -0,0 +1,16 @@
ion-item {
--background: transparent;
}
.host {
display: flex;
}
.delete {
background: var(--tui-error-bg);
color: var(--tui-error-fill);
&:focus {
background: var(--tui-error-bg-hover);
}
}

View File

@@ -1,29 +1,18 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
ViewChild,
} from '@angular/core'
import {
ActionSheetController,
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import { ActionSheetButton } from '@ionic/core'
import { ErrorToastService, sameUrl, toUrl } from '@start9labs/shared'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { ErrorService, sameUrl, toUrl } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { TuiDialogService } from '@taiga-ui/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ValueSpecObject } from 'start-sdk/lib/config/configTypes'
import {
GenericFormPage,
GenericFormOptions,
} from 'src/app/modals/generic-form/generic-form.page'
import { ValueSpecObject } from '@start9labs/start-sdk/lib/config/configTypes'
import { PatchDB } from 'patch-db-client'
import { DataModel, UIStore } from 'src/app/services/patch-db/data-model'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { map } from 'rxjs/operators'
import { combineLatest, firstValueFrom } from 'rxjs'
import { combineLatest, filter, firstValueFrom, Subscription } from 'rxjs'
import { FormDialogService } from '../../services/form-dialog.service'
import { FormPage } from '../form/form.page'
import { LoadingService } from '../loading/loading.service'
import { TUI_PROMPT } from '@taiga-ui/kit'
@Component({
selector: 'marketplace-settings',
@@ -52,140 +41,87 @@ export class MarketplaceSettingsPage {
constructor(
private readonly api: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly errToast: ErrorToastService,
private readonly actionCtrl: ActionSheetController,
private readonly loader: LoadingService,
private readonly formDialog: FormDialogService,
private readonly errorService: ErrorService,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
private readonly patch: PatchDB<DataModel>,
private readonly alertCtrl: AlertController,
private readonly dialogs: TuiDialogService,
) {}
async dismiss() {
this.modalCtrl.dismiss()
}
async presentModalAdd() {
const { name, spec } = getMarketplaceValueSpec()
const options: GenericFormOptions = {
title: name,
spec,
buttons: [
{
text: 'Save for Later',
handler: async (value: { url: string }) => this.saveOnly(value.url),
},
{
text: 'Save and Connect',
handler: async (value: { url: string }) =>
this.saveAndConnect(value.url),
isSubmit: true,
},
],
}
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: options,
cssClass: 'alertlike-modal',
this.formDialog.open(FormPage, {
label: name,
data: {
spec,
buttons: [
{
text: 'Save for Later',
handler: async (value: { url: string }) => this.saveOnly(value.url),
},
{
text: 'Save and Connect',
handler: async (value: { url: string }) =>
this.saveAndConnect(value.url),
isSubmit: true,
},
],
},
})
await modal.present()
}
async presentAction(
{ url, name }: { url: string; name?: string },
canDelete = false,
) {
const buttons: ActionSheetButton[] = [
{
text: 'Connect',
handler: () => {
this.connect(url)
},
},
]
if (canDelete) {
buttons.unshift({
text: 'Delete',
role: 'destructive',
handler: () => {
this.presentAlertDelete(url, name!)
async presentAlertDelete(url: string, name: string = '') {
this.dialogs
.open(TUI_PROMPT, {
label: 'Confirm',
size: 's',
data: {
content: `Are you sure you want to delete ${name}?`,
yes: 'Delete',
no: 'Cancel',
},
})
}
const action = await this.actionCtrl.create({
header: name,
mode: 'ios',
buttons,
})
await action.present()
.pipe(filter(Boolean))
.subscribe(() => this.delete(url))
}
private async presentAlertDelete(url: string, name: string) {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: `Are you sure you want to delete ${name}?`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
handler: () => this.delete(url),
cssClass: 'enter-click',
},
],
})
await alert.present()
}
private async connect(
async connect(
url: string,
loader?: HTMLIonLoadingElement,
loader: Subscription = new Subscription(),
): Promise<void> {
const message = 'Changing Registry...'
if (!loader) {
loader = await this.loadingCtrl.create({ message })
await loader.present()
} else {
loader.message = message
}
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Changing Registry...').subscribe())
try {
await this.api.setDbValue<string>(['marketplace', 'selected-url'], url)
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
} finally {
loader.dismiss()
this.dismiss()
loader.unsubscribe()
}
}
private async saveOnly(rawUrl: string): Promise<boolean> {
const loader = await this.loadingCtrl.create()
const loader = this.loader.open('Loading').subscribe()
try {
const url = new URL(rawUrl).toString()
await this.validateAndSave(url, loader)
return true
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
return false
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
private async saveAndConnect(rawUrl: string): Promise<boolean> {
const loader = await this.loadingCtrl.create()
const loader = this.loader.open('Loading').subscribe()
try {
const url = new URL(rawUrl).toString()
@@ -193,17 +129,16 @@ export class MarketplaceSettingsPage {
await this.connect(url, loader)
return true
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
return false
} finally {
loader.dismiss()
this.dismiss()
loader.unsubscribe()
}
}
private async validateAndSave(
url: string,
loader: HTMLIonLoadingElement,
loader: Subscription,
): Promise<void> {
// Error on duplicates
const hosts = await firstValueFrom(
@@ -213,15 +148,18 @@ export class MarketplaceSettingsPage {
if (currentUrls.includes(url)) throw new Error('marketplace already added')
// Validate
loader.message = 'Validating marketplace...'
await loader.present()
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Validating marketplace...').subscribe())
const { name } = await firstValueFrom(
this.marketplaceService.fetchInfo$(url),
)
// Save
loader.message = 'Saving...'
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Saving...').subscribe())
await this.api.setDbValue<{ name: string }>(
['marketplace', 'known-hosts', url],
@@ -230,10 +168,7 @@ export class MarketplaceSettingsPage {
}
private async delete(url: string): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Deleting...',
})
await loader.present()
const loader = this.loader.open('Deleting...').subscribe()
const hosts = await firstValueFrom(
this.patch.watch$('ui', 'marketplace', 'known-hosts'),
@@ -255,9 +190,9 @@ export class MarketplaceSettingsPage {
filtered,
)
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
}
@@ -287,6 +222,9 @@ function getMarketplaceValueSpec(): ValueSpecObject {
placeholder: 'e.g. https://example.org',
default: null,
warning: null,
disabled: false,
immutable: false,
generate: null,
},
},
}

View File

@@ -7,12 +7,7 @@ import {
} from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
AlertController,
LoadingController,
ModalController,
NavController,
} from '@ionic/angular'
import { AlertController, ModalController, NavController } from '@ionic/angular'
import { PatchDB } from 'patch-db-client'
import {
Action,
@@ -20,19 +15,18 @@ import {
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
import {
GenericFormPage,
GenericFormOptions,
} from 'src/app/modals/generic-form/generic-form.page'
import {
isEmptyObject,
ErrorToastService,
getPkgId,
WithId,
ErrorService,
} from '@start9labs/shared'
import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { filter } from 'rxjs'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormPage } from 'src/app/modals/form/form.page'
import { LoadingService } from 'src/app/modals/loading/loading.service'
@Component({
selector: 'app-actions',
@@ -51,10 +45,11 @@ export class AppActionsPage {
private readonly embassyApi: ApiService,
private readonly modalCtrl: ModalController,
private readonly alertCtrl: AlertController,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
private readonly formDialog: FormDialogService,
) {}
async handleAction(action: WithId<Action>) {
@@ -68,23 +63,19 @@ export class AppActionsPage {
await alert.present()
} else {
if (action['input-spec'] && !isEmptyObject(action['input-spec'])) {
const options: GenericFormOptions = {
title: action.name,
spec: action['input-spec'],
buttons: [
{
text: 'Execute',
handler: async (value: any) =>
this.executeAction(action.id, value),
isSubmit: true,
},
],
}
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: options,
this.formDialog.open(FormPage, {
label: action.name,
data: {
spec: action['input-spec'],
buttons: [
{
text: 'Execute',
handler: async (value: any) =>
this.executeAction(action.id, value),
},
],
},
})
await modal.present()
} else {
const alert = await this.alertCtrl.create({
header: 'Confirm',
@@ -142,10 +133,7 @@ export class AppActionsPage {
}
private async uninstall() {
const loader = await this.loadingCtrl.create({
message: `Beginning uninstall...`,
})
await loader.present()
const loader = this.loader.open(`Beginning uninstall...`).subscribe()
try {
await this.embassyApi.uninstallPackage({ id: this.pkgId })
@@ -154,9 +142,9 @@ export class AppActionsPage {
.catch(e => console.error('Failed to mark instructions as unseen', e))
this.navCtrl.navigateRoot('/services')
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
@@ -164,10 +152,7 @@ export class AppActionsPage {
actionId: string,
input?: object,
): Promise<boolean> {
const loader = await this.loadingCtrl.create({
message: 'Executing action...',
})
await loader.present()
const loader = this.loader.open('Executing action...').subscribe()
try {
const res = await this.embassyApi.executePackageAction({
@@ -186,10 +171,10 @@ export class AppActionsPage {
setTimeout(() => successModal.present(), 500)
return true
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
return false
} finally {
loader.dismiss()
loader.unsubscribe()
}
}

View File

@@ -2,26 +2,44 @@ import { Component } from '@angular/core'
import {
BackupTarget,
BackupTargetType,
DiskBackupTarget,
RR,
UnknownDisk,
} from 'src/app/services/api/api.types'
import {
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import {
CifsSpec,
DropboxSpec,
GoogleDriveSpec,
DiskBackupTargetSpec,
RemoteBackupTargetSpec,
cifsSpec,
diskBackupTargetSpec,
dropboxSpec,
googleDriveSpec,
remoteBackupTargetSpec,
} from '../../types/target-types'
import { BehaviorSubject } from 'rxjs'
import { BehaviorSubject, filter } from 'rxjs'
import { TuiDialogService } from '@taiga-ui/core'
import { ErrorService } from '@start9labs/shared'
import { FormDialogService } from '../../../../services/form-dialog.service'
import { FormPage } from '../../../../modals/form/form.page'
import { LoadingService } from '../../../../modals/loading/loading.service'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import {
InputSpec,
unionSelectKey,
unionValueKey,
} from '@start9labs/start-sdk/lib/config/configTypes'
type BackupConfig =
| {
type: {
[unionSelectKey]: 'dropbox' | 'google-drive'
[unionValueKey]: RR.AddCloudBackupTargetReq
}
}
| {
type: {
[unionSelectKey]: 'cifs'
[unionValueKey]: RR.AddCifsBackupTargetReq
}
}
export type BackupType = 'create' | 'restore'
@@ -41,27 +59,23 @@ export class BackupTargetsPage {
loading$ = new BehaviorSubject(true)
constructor(
private readonly modalCtrl: ModalController,
private readonly alertCtrl: AlertController,
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly dialogs: TuiDialogService,
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
private readonly api: ApiService,
private readonly formDialog: FormDialogService,
) {}
ngOnInit() {
this.getTargets()
}
async presentModalAddPhysical(
disk: UnknownDisk,
index: number,
): Promise<void> {
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'New Physical Target',
spec: DiskBackupTargetSpec,
initialValue: {
async presentModalAddPhysical(disk: UnknownDisk, index: number) {
this.formDialog.open(FormPage, {
label: 'New Physical Target',
data: {
spec: await configBuilderToSpec(diskBackupTargetSpec),
value: {
name: disk.label || disk.logicalname,
},
buttons: [
@@ -74,60 +88,56 @@ export class BackupTargetsPage {
}).then(disk => {
this.targets['unknown-disks'].splice(index, 1)
this.targets.saved.push(disk)
return true
}),
isSubmit: true,
},
],
},
})
await modal.present()
}
async presentModalAddRemote(): Promise<void> {
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'New Remote Target',
spec: RemoteBackupTargetSpec,
async presentModalAddRemote() {
this.formDialog.open(FormPage, {
label: 'New Remote Target',
data: {
spec: await configBuilderToSpec(remoteBackupTargetSpec),
buttons: [
{
text: 'Save',
handler: (
value:
| (RR.AddCifsBackupTargetReq & { type: BackupTargetType })
| (RR.AddCloudBackupTargetReq & { type: BackupTargetType }),
) => this.add(value.type, value),
isSubmit: true,
handler: ({ type }: BackupConfig) =>
this.add(
type[unionSelectKey] === 'cifs' ? 'cifs' : 'cloud',
type[unionValueKey],
),
},
],
},
})
await modal.present()
}
async presentModalUpdate(target: BackupTarget): Promise<void> {
let spec: typeof RemoteBackupTargetSpec = {}
async presentModalUpdate(target: BackupTarget) {
let spec: InputSpec
switch (target.type) {
case 'cifs':
spec = CifsSpec
spec = await configBuilderToSpec(cifsSpec)
break
case 'cloud':
spec = target.provider === 'dropbox' ? DropboxSpec : GoogleDriveSpec
spec = await configBuilderToSpec(
target.provider === 'dropbox' ? dropboxSpec : googleDriveSpec,
)
break
case 'disk':
spec = DiskBackupTargetSpec
spec = await configBuilderToSpec(diskBackupTargetSpec)
break
}
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'Update Remote Target',
this.formDialog.open(FormPage, {
label: 'Update Target',
data: {
spec,
initialValue: target,
value: target,
buttons: [
{
text: 'Save',
@@ -136,49 +146,38 @@ export class BackupTargetsPage {
| RR.UpdateCifsBackupTargetReq
| RR.UpdateCloudBackupTargetReq
| RR.UpdateDiskBackupTargetReq,
) => this.update(target.type, value),
isSubmit: true,
) => this.update(target.type, { ...value, id: target.id }),
},
],
},
})
await modal.present()
}
async presentAlertDelete(id: string, index: number) {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: 'Forget backup target? This actions cannot be undone.',
buttons: [
{
text: 'Cancel',
role: 'cancel',
presentAlertDelete(id: string, index: number) {
this.dialogs
.open(TUI_PROMPT, {
label: 'Confirm',
size: 's',
data: {
content: 'Forget backup target? This actions cannot be undone.',
no: 'Cancel',
yes: 'Delete',
},
{
text: 'Delete',
handler: () => {
this.delete(id, index)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
})
.pipe(filter(Boolean))
.subscribe(() => this.delete(id, index))
}
async delete(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.api.removeBackupTarget({ id })
this.targets.saved.splice(index, 1)
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
@@ -202,7 +201,7 @@ export class BackupTargetsPage {
try {
this.targets = await this.api.getBackupTargets({})
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
} finally {
this.loading$.next(false)
}
@@ -215,16 +214,12 @@ export class BackupTargetsPage {
| RR.AddCloudBackupTargetReq
| RR.AddDiskBackupTargetReq,
): Promise<BackupTarget> {
const loader = await this.loadingCtrl.create({
message: 'Saving target...',
})
await loader.present()
const loader = this.loader.open('Saving target...').subscribe()
try {
const res = await this.api.addBackupTarget(type, value)
return res
return await this.api.addBackupTarget(type, value)
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
@@ -235,16 +230,12 @@ export class BackupTargetsPage {
| RR.UpdateCloudBackupTargetReq
| RR.UpdateDiskBackupTargetReq,
): Promise<BackupTarget> {
const loader = await this.loadingCtrl.create({
message: 'Saving target...',
})
await loader.present()
const loader = this.loader.open('Saving target...').subscribe()
try {
const res = await this.api.updateBackupTarget(type, value)
return res
return await this.api.updateBackupTarget(type, value)
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
}

View File

@@ -1,221 +1,121 @@
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
import { Variants } from '@start9labs/start-sdk/lib/config/builder/variants'
export const DropboxSpec: InputSpec = {
name: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
export const dropboxSpec = Config.of({
name: Value.text({
name: 'Name',
description: 'A friendly name for this Dropbox target',
placeholder: 'My Dropbox',
required: true,
masked: false,
warning: null,
default: null,
},
token: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
required: { default: null },
}),
token: Value.text({
name: 'Access Token',
description: 'The secret access token for your custom Dropbox app',
warning: null,
placeholder: null,
required: true,
required: { default: null },
masked: true,
default: null,
},
path: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
}),
path: Value.text({
name: 'Path',
description: 'The fully qualified path to the backup directory',
warning: null,
placeholder: 'e.g. /Desktop/my-folder',
required: true,
masked: false,
default: null,
},
}
required: { default: null },
}),
})
export const GoogleDriveSpec: InputSpec = {
name: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
export const googleDriveSpec = Config.of({
name: Value.text({
name: 'Name',
description: 'A friendly name for this Google Drive target',
warning: null,
placeholder: 'My Google Drive',
required: true,
masked: false,
default: null,
},
key: {
type: 'file',
required: { default: null },
}),
path: Value.text({
name: 'Path',
description: 'The fully qualified path to the backup directory',
placeholder: 'e.g. /Desktop/my-folder',
required: { default: null },
}),
key: Value.file({
name: 'Private Key File',
description:
'Your Google Drive service account private key file (.json file)',
warning: null,
required: true,
extensions: ['json'],
},
path: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
name: 'Path',
description: 'The fully qualified path to the backup directory',
placeholder: 'e.g. /Desktop/my-folder',
required: true,
masked: false,
warning: null,
default: null,
},
}
}),
})
export const CifsSpec: InputSpec = {
name: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
export const cifsSpec = Config.of({
name: Value.text({
name: 'Name',
description: 'A friendly name for this Network Folder',
warning: null,
placeholder: 'My Network Folder',
required: true,
masked: false,
default: null,
},
hostname: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [
{
regex: '^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$',
description: `Must be a valid hostname. e.g. 'My Computer' OR 'my-computer.local'`,
},
],
required: { default: null },
}),
hostname: Value.text({
name: 'Hostname',
description:
'The hostname of your target device on the Local Area Network.',
warning: null,
required: true,
masked: false,
placeholder: `e.g. 'My Computer' OR 'my-computer.local'`,
default: null,
},
path: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
required: { default: null },
patterns: [],
}),
path: 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',
required: true,
masked: false,
warning: null,
default: null,
},
username: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
required: { default: null },
}),
username: 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.`,
required: true,
masked: false,
warning: null,
required: { default: null },
placeholder: 'My Network Folder',
default: null,
},
password: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
}),
password: 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.`,
required: false,
masked: true,
warning: null,
placeholder: 'My Network Folder',
default: null,
},
}
}),
})
export const RemoteBackupTargetSpec: InputSpec = {
type: {
type: 'union',
name: 'Target Type',
description: null,
warning: null,
required: true,
variants: {
export const remoteBackupTargetSpec = Config.of({
type: Value.union(
{
name: 'Target Type',
required: { default: 'dropbox' },
},
Variants.of({
dropbox: {
name: 'Dropbox',
spec: DropboxSpec,
spec: dropboxSpec,
},
'google-drive': {
name: 'Google Drive',
spec: GoogleDriveSpec,
spec: googleDriveSpec,
},
cifs: {
name: 'Network Folder',
spec: CifsSpec,
spec: cifsSpec,
},
},
default: 'dropbox',
},
}
}),
),
})
export const DiskBackupTargetSpec: InputSpec = {
name: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
export const diskBackupTargetSpec = Config.of({
name: Value.text({
name: 'Name',
description: 'A friendly name for this physical target',
placeholder: 'My Physical Target',
required: true,
masked: false,
warning: null,
default: null,
},
path: {
type: 'text',
inputmode: 'text',
minLength: null,
maxLength: null,
patterns: [],
required: { default: null },
}),
path: Value.text({
name: 'Path',
description: 'The fully qualified path to the backup directory',
placeholder: 'e.g. /Backups/my-folder',
required: true,
masked: false,
warning: null,
default: null,
},
}
required: { default: null },
}),
})

View File

@@ -1,30 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { DevConfigPage } from './dev-config.page'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
import { FormsModule } from '@angular/forms'
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
const routes: Routes = [
{
path: '',
component: DevConfigPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
BadgeMenuComponentModule,
BackupReportPageModule,
FormsModule,
MonacoEditorModule,
],
declarations: [DevConfigPage],
})
export class DevConfigPageModule {}

View File

@@ -1,28 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button
[defaultHref]="'/developer/projects/' + projectId"
></ion-back-button>
</ion-buttons>
<ion-title
>Config
<ion-spinner
*ngIf="saving"
name="crescent"
style="transform: scale(0.55); position: absolute"
></ion-spinner
></ion-title>
<ion-buttons slot="end">
<ion-button (click)="preview()">Preview</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ngx-monaco-editor
(keyup)="save()"
[options]="editorOptions"
[(ngModel)]="code"
></ngx-monaco-editor>
</ion-content>

View File

@@ -1,79 +0,0 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular'
import { debounce, ErrorService } from '@start9labs/shared'
import * as yaml from 'js-yaml'
import { filter, take } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDB } from 'patch-db-client'
import { getProjectId } from 'src/app/util/get-project-id'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { FormDialogService } from '../../../services/form-dialog.service'
import { FormPage } from '../../../modals/form/form.page'
@Component({
selector: 'dev-config',
templateUrl: 'dev-config.page.html',
styleUrls: ['dev-config.page.scss'],
})
export class DevConfigPage {
readonly projectId = getProjectId(this.route)
editorOptions = { theme: 'vs-dark', language: 'yaml' }
code: string = ''
saving: boolean = false
constructor(
private readonly formDialog: FormDialogService,
private readonly errorHandler: ErrorService,
private readonly route: ActivatedRoute,
private readonly modalCtrl: ModalController,
private readonly patch: PatchDB<DataModel>,
private readonly api: ApiService,
) {}
ngOnInit() {
this.patch
.watch$('ui', 'dev', this.projectId, 'config')
.pipe(filter(Boolean), take(1))
.subscribe(config => {
this.code = config
})
}
async preview() {
let doc: any
try {
doc = yaml.load(this.code)
} catch (e: any) {
this.errorHandler.handleError(e)
}
this.formDialog.open(FormPage, {
label: 'Config Sample',
data: {
spec: JSON.parse(JSON.stringify(doc, null, 2)),
buttons: [
{
text: 'OK',
handler: async () => true,
},
],
},
})
}
@debounce(1000)
async save() {
this.saving = true
try {
await this.api.setDbValue<string>(
['dev', this.projectId, 'config'],
this.code,
)
} catch (e: any) {
this.errorHandler.handleError(e)
} finally {
this.saving = false
}
}
}

View File

@@ -1,30 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { DevInstructionsPage } from './dev-instructions.page'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
import { FormsModule } from '@angular/forms'
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
const routes: Routes = [
{
path: '',
component: DevInstructionsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
BadgeMenuComponentModule,
BackupReportPageModule,
FormsModule,
MonacoEditorModule,
],
declarations: [DevInstructionsPage],
})
export class DevInstructionsPageModule {}

View File

@@ -1,28 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button
[defaultHref]="'/developer/projects/' + projectId"
></ion-back-button>
</ion-buttons>
<ion-title
>Instructions
<ion-spinner
*ngIf="saving"
name="crescent"
style="transform: scale(0.55); position: absolute"
></ion-spinner
></ion-title>
<ion-buttons slot="end">
<ion-button (click)="preview()">Preview</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ngx-monaco-editor
(keyup)="save()"
[options]="editorOptions"
[(ngModel)]="code"
></ngx-monaco-editor>
</ion-content>

View File

@@ -1,69 +0,0 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular'
import { filter, take } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
debounce,
ErrorToastService,
MarkdownComponent,
} from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { getProjectId } from 'src/app/util/get-project-id'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'dev-instructions',
templateUrl: 'dev-instructions.page.html',
styleUrls: ['dev-instructions.page.scss'],
})
export class DevInstructionsPage {
readonly projectId = getProjectId(this.route)
editorOptions = { theme: 'vs-dark', language: 'markdown' }
code = ''
saving = false
constructor(
private readonly route: ActivatedRoute,
private readonly errToast: ErrorToastService,
private readonly modalCtrl: ModalController,
private readonly patch: PatchDB<DataModel>,
private readonly api: ApiService,
) {}
ngOnInit() {
this.patch
.watch$('ui', 'dev', this.projectId, 'instructions')
.pipe(filter(Boolean), take(1))
.subscribe(config => {
this.code = config
})
}
async preview() {
const modal = await this.modalCtrl.create({
componentProps: {
title: 'Instructions Sample',
content: this.code,
},
component: MarkdownComponent,
})
await modal.present()
}
@debounce(1000)
async save() {
this.saving = true
try {
await this.api.setDbValue<string>(
['dev', this.projectId, 'instructions'],
this.code,
)
} catch (e: any) {
this.errToast.present(e)
} finally {
this.saving = false
}
}
}

View File

@@ -1,30 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { DevManifestPage } from './dev-manifest.page'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
import { FormsModule } from '@angular/forms'
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
const routes: Routes = [
{
path: '',
component: DevManifestPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
BadgeMenuComponentModule,
BackupReportPageModule,
FormsModule,
MonacoEditorModule,
],
declarations: [DevManifestPage],
})
export class DevManifestPageModule {}

View File

@@ -1,17 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button
[defaultHref]="'/developer/projects/' + projectId"
></ion-back-button>
</ion-buttons>
<ion-title> Manifest </ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ngx-monaco-editor
[options]="editorOptions"
[(ngModel)]="manifest"
></ngx-monaco-editor>
</ion-content>

View File

@@ -1,32 +0,0 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import * as yaml from 'js-yaml'
import { take } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import { getProjectId } from 'src/app/util/get-project-id'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'dev-manifest',
templateUrl: 'dev-manifest.page.html',
styleUrls: ['dev-manifest.page.scss'],
})
export class DevManifestPage {
readonly projectId = getProjectId(this.route)
editorOptions = { theme: 'vs-dark', language: 'yaml', readOnly: true }
manifest: string = ''
constructor(
private readonly route: ActivatedRoute,
private readonly patch: PatchDB<DataModel>,
) {}
ngOnInit() {
this.patch
.watch$('ui', 'dev', this.projectId)
.pipe(take(1))
.subscribe(devData => {
this.manifest = yaml.dump(devData['basic-info'])
})
}
}

View File

@@ -1,26 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { DeveloperListPage } from './developer-list.page'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
const routes: Routes = [
{
path: '',
component: DeveloperListPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
BadgeMenuComponentModule,
BackupReportPageModule,
],
declarations: [DeveloperListPage],
})
export class DeveloperListPageModule {}

View File

@@ -1,37 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button></ion-back-button>
</ion-buttons>
<ion-title>Developer Tools</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item-divider>Projects</ion-item-divider>
<ion-item button detail="false" (click)="openCreateProjectModal()">
<ion-icon slot="start" name="add" color="dark"></ion-icon>
<ion-label>
<ion-text color="dark">Create project</ion-text>
</ion-label>
</ion-item>
<ion-item
button
*ngFor="let entry of devData | keyvalue"
[routerLink]="[entry.key]"
>
<p>{{ entry.value.name }}</p>
<ion-button
slot="end"
fill="clear"
(click)="presentAction(entry.key, $event)"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</ion-button>
</ion-item>
</ion-content>

View File

@@ -1,279 +0,0 @@
import { Component } from '@angular/core'
import {
ActionSheetButton,
ActionSheetController,
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import {
GenericInputComponent,
GenericInputOptions,
} from 'src/app/modals/generic-input/generic-input.component'
import { PatchDB } from 'patch-db-client'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import * as yaml from 'js-yaml'
import { v4 } from 'uuid'
import { DataModel, DevData } from 'src/app/services/patch-db/data-model'
import { ErrorToastService } from '@start9labs/shared'
import { TuiDestroyService } from '@taiga-ui/cdk'
import { takeUntil } from 'rxjs/operators'
@Component({
selector: 'developer-list',
templateUrl: 'developer-list.page.html',
styleUrls: ['developer-list.page.scss'],
providers: [TuiDestroyService],
})
export class DeveloperListPage {
devData: DevData = {}
constructor(
private readonly modalCtrl: ModalController,
private readonly api: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController,
private readonly destroy$: TuiDestroyService,
private readonly patch: PatchDB<DataModel>,
private readonly actionCtrl: ActionSheetController,
) {}
ngOnInit() {
this.patch
.watch$('ui', 'dev')
.pipe(takeUntil(this.destroy$))
.subscribe(dd => {
this.devData = dd
})
}
async openCreateProjectModal() {
const projNumber = Object.keys(this.devData).length + 1
const options: GenericInputOptions = {
title: 'Add new project',
message: 'Create a new dev project.',
label: 'New project',
useMask: false,
placeholder: `Project ${projNumber}`,
required: false,
initialValue: `Project ${projNumber}`,
buttonText: 'Save',
submitFn: (value: string) => this.createProject(value),
}
const modal = await this.modalCtrl.create({
componentProps: { options },
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
})
await modal.present()
}
async presentAction(id: string, event: Event) {
event.stopPropagation()
event.preventDefault()
const buttons: ActionSheetButton[] = [
{
text: 'Edit Name',
icon: 'pencil',
handler: () => {
this.openEditNameModal(id)
},
},
{
text: 'Delete',
icon: 'trash',
role: 'destructive',
handler: () => {
this.presentAlertDelete(id)
},
},
]
const action = await this.actionCtrl.create({
header: this.devData[id].name,
subHeader: 'Manage project',
mode: 'ios',
buttons,
})
await action.present()
}
async openEditNameModal(id: string) {
const curName = this.devData[id].name
const options: GenericInputOptions = {
title: 'Edit Name',
message: 'Edit the name of your project.',
label: 'Name',
useMask: false,
placeholder: curName,
required: false,
initialValue: curName,
buttonText: 'Save',
submitFn: (value: string) => this.editName(id, value),
}
const modal = await this.modalCtrl.create({
componentProps: { options },
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
})
await modal.present()
}
async createProject(name: string) {
// fail silently if duplicate project name
if (
Object.values(this.devData)
.map(v => v.name)
.includes(name)
)
return
const loader = await this.loadingCtrl.create({
message: 'Creating Project...',
})
await loader.present()
try {
const id = v4()
const config = yaml
.dump(SAMPLE_CONFIG)
.replace(/warning:/g, '# Optional\n warning:')
const def = { name, config, instructions: SAMPLE_INSTUCTIONS }
await this.api.setDbValue<{
name: string
config: string
instructions: string
}>(['dev', id], def)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
async presentAlertDelete(id: string) {
const alert = await this.alertCtrl.create({
header: 'Caution',
message: `Are you sure you want to delete this project?`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
handler: () => {
this.delete(id)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
async editName(id: string, newName: string) {
const loader = await this.loadingCtrl.create({
message: 'Saving...',
})
await loader.present()
try {
await this.api.setDbValue<string>(['dev', id, 'name'], newName)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
async delete(id: string) {
const loader = await this.loadingCtrl.create({
message: 'Removing Project...',
})
await loader.present()
try {
const devDataToSave: DevData = JSON.parse(JSON.stringify(this.devData))
delete devDataToSave[id]
await this.api.setDbValue<DevData>(['dev'], devDataToSave)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
}
const SAMPLE_INSTUCTIONS = `# Create Instructions using Markdown! :)`
const SAMPLE_CONFIG: InputSpec = {
'sample-string': {
type: 'text',
name: 'Example String Input',
inputmode: 'text',
required: true,
masked: false,
// optional
description: 'Example description for required string input.',
placeholder: 'Enter string value',
patterns: [
{
regex: '^[a-zA-Z0-9! _]+$',
description: 'Must be alphanumeric (may contain underscore).',
},
],
minLength: null,
maxLength: null,
default: null,
warning: null,
},
'sample-number': {
type: 'number',
name: 'Example Number Input',
required: true,
min: 5,
max: 1000000,
step: '5',
integer: true,
// optional
warning: 'Example warning to display when changing this number value.',
units: 'ms',
description: 'Example description for optional number input.',
placeholder: 'Enter number value',
default: null,
},
'sample-boolean': {
type: 'toggle',
name: 'Example Boolean Toggle',
// optional
description: 'Example description for boolean toggle',
default: true,
warning: null,
},
'sample-select': {
type: 'multiselect',
name: 'Example Enum Select',
values: {
red: 'Red',
blue: 'Blue',
green: 'Green',
},
// optional
warning: 'Example warning to display when changing this select value.',
description: 'Example description for select select',
minLength: null,
maxLength: 2,
default: ['red'],
},
}

View File

@@ -1,32 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { DeveloperMenuPage } from './developer-menu.page'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
import { FormsModule } from '@angular/forms'
import { SharedPipesModule } from '@start9labs/shared'
const routes: Routes = [
{
path: '',
component: DeveloperMenuPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
BadgeMenuComponentModule,
BackupReportPageModule,
FormsModule,
MonacoEditorModule,
SharedPipesModule,
],
declarations: [DeveloperMenuPage],
})
export class DeveloperMenuPageModule {}

View File

@@ -1,51 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/developer"></ion-back-button>
</ion-buttons>
<ion-title>{{ (projectData$ | async)?.name || '' }}</ion-title>
<ion-buttons slot="end">
<ion-button routerLink="manifest">View Manifest</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item
*ngIf="projectData$ | async as projectData"
button
(click)="openBasicInfoModal(projectData)"
>
<ion-icon slot="start" name="information-circle-outline"></ion-icon>
<ion-label>
<h2>Basic Info</h2>
<p>Complete basic info for your package</p>
</ion-label>
<ion-icon
slot="end"
color="success"
name="checkmark"
*ngIf="!(projectData['basic-info'] | empty)"
></ion-icon>
<ion-icon
slot="end"
color="warning"
name="remove-outline"
*ngIf="projectData['basic-info'] | empty"
></ion-icon>
</ion-item>
<ion-item button detail routerLink="instructions" routerDirection="forward">
<ion-icon slot="start" name="list-outline"></ion-icon>
<ion-label>
<h2>Instructions Generator</h2>
<p>Create instructions and see how they will appear to the end user</p>
</ion-label>
</ion-item>
<ion-item button detail routerLink="config">
<ion-icon slot="start" name="construct-outline"></ion-icon>
<ion-label>
<h2>Config Generator</h2>
<p>Edit the config with YAML and see it in real time</p>
</ion-label>
</ion-item>
</ion-content>

View File

@@ -1,64 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { BasicInfo, getBasicInfoSpec } from './form-info'
import { PatchDB } from 'patch-db-client'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorService } from '@start9labs/shared'
import { getProjectId } from 'src/app/util/get-project-id'
import { DataModel, DevProjectData } from 'src/app/services/patch-db/data-model'
import { FormDialogService } from '../../../services/form-dialog.service'
import { FormPage } from '../../../modals/form/form.page'
import { LoadingService } from '../../../modals/loading/loading.service'
@Component({
selector: 'developer-menu',
templateUrl: 'developer-menu.page.html',
styleUrls: ['developer-menu.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DeveloperMenuPage {
readonly projectId = getProjectId(this.route)
readonly projectData$ = this.patch.watch$('ui', 'dev', this.projectId)
constructor(
private readonly formDialog: FormDialogService,
private readonly loader: LoadingService,
private readonly errorHandler: ErrorService,
private readonly route: ActivatedRoute,
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
) {}
async openBasicInfoModal(data: DevProjectData) {
this.formDialog.open(FormPage, {
label: 'Basic Info',
data: {
spec: getBasicInfoSpec(data),
buttons: [
{
text: 'Save',
handler: async (basicInfo: BasicInfo) =>
this.saveBasicInfo(basicInfo),
},
],
},
})
}
async saveBasicInfo(basicInfo: BasicInfo): Promise<boolean> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.setDbValue<BasicInfo>(
['dev', this.projectId, 'basic-info'],
basicInfo,
)
return true
} catch (e: any) {
this.errorHandler.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,201 +0,0 @@
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { DevProjectData } from 'src/app/services/patch-db/data-model'
export type BasicInfo = {
id: string
title: string
'service-version-number': string
'release-notes': string
license: string
'wrapper-repo': string
'upstream-repo'?: string
'support-site'?: string
'marketing-site'?: string
description: {
short: string
long: string
}
}
export function getBasicInfoSpec(devData: DevProjectData): InputSpec {
const basicInfo = devData['basic-info']
return {
id: {
type: 'text',
inputmode: 'text',
name: 'ID',
description: 'The package identifier used by the OS',
placeholder: 'e.g. bitcoind',
required: true,
masked: false,
minLength: null,
maxLength: null,
patterns: [
{
regex: '^([a-z][a-z0-9]*)(-[a-z0-9]+)*$',
description: 'Must be kebab case',
},
],
default: basicInfo?.id || '',
warning: null,
},
title: {
type: 'text',
inputmode: 'text',
name: 'Service Name',
description: 'A human readable service title',
placeholder: 'e.g. Bitcoin Core',
required: true,
masked: false,
minLength: null,
maxLength: null,
patterns: [],
default: basicInfo ? basicInfo.title : devData.name,
warning: null,
},
'service-version-number': {
type: 'text',
inputmode: 'text',
name: 'Service Version',
description:
'Service version - accepts up to four digits, where the last confirms to revisions necessary for StartOS - see documentation: https://github.com/Start9Labs/emver-rs. This value will change with each release of the service',
placeholder: 'e.g. 0.1.2.3',
required: true,
masked: false,
minLength: null,
maxLength: null,
patterns: [
{
regex: '^([0-9]+).([0-9]+).([0-9]+).([0-9]+)$',
description: 'Must be valid Emver version',
},
],
default: basicInfo?.['service-version-number'] || '',
warning: null,
},
description: {
type: 'object',
name: 'Marketplace Descriptions',
description: null,
warning: null,
spec: {
short: {
type: 'text',
inputmode: 'text',
name: 'Short Description',
description:
'This is the first description visible to the user in the marketplace',
placeholder: null,
required: true,
masked: false,
default: basicInfo?.description?.short || '',
minLength: null,
maxLength: 320,
patterns: [],
warning: null,
},
long: {
type: 'textarea',
name: 'Long Description',
description: `This description will display with additional details in the service's individual marketplace page`,
placeholder: null,
minLength: 20,
maxLength: 1000,
required: true,
warning: null,
},
},
},
'release-notes': {
type: 'text',
inputmode: 'text',
name: 'Release Notes',
description:
'Markdown supported release notes for this version of this service.',
placeholder: 'e.g. Markdown _release notes_ for **Bitcoin Core**',
required: true,
minLength: null,
maxLength: null,
masked: false,
patterns: [],
default: basicInfo?.['release-notes'] || '',
warning: null,
},
license: {
type: 'select',
name: 'License',
warning: null,
values: {
'gnu-agpl-v3': 'GNU AGPLv3',
'gnu-gpl-v3': 'GNU GPLv3',
'gnu-lgpl-v3': 'GNU LGPLv3',
'mozilla-public-license-2.0': 'Mozilla Public License 2.0',
'apache-license-2.0': 'Apache License 2.0',
mit: 'mit',
'boost-software-license-1.0': 'Boost Software License 1.0',
'the-unlicense': 'The Unlicense',
custom: 'Custom',
},
description: 'Example description for select',
required: true,
default: 'mit',
},
'wrapper-repo': {
type: 'text',
inputmode: 'url',
name: 'Wrapper Repo',
description:
'The Start9 wrapper repository URL for the package. This repo contains the manifest file (this), any scripts necessary for configuration, backups, actions, or health checks',
placeholder: 'e.g. www.github.com/example',
patterns: [],
minLength: null,
maxLength: null,
required: true,
masked: false,
default: basicInfo?.['wrapper-repo'] || '',
warning: null,
},
'upstream-repo': {
type: 'text',
inputmode: 'url',
name: 'Upstream Repo',
description: 'The original project repository URL',
placeholder: 'e.g. www.github.com/example',
patterns: [],
minLength: null,
maxLength: null,
required: false,
masked: false,
default: basicInfo?.['upstream-repo'] || '',
warning: null,
},
'support-site': {
type: 'text',
inputmode: 'url',
name: 'Support Site',
description: 'URL to the support site / channel for the project',
placeholder: 'e.g. start9.com/support',
patterns: [],
minLength: null,
maxLength: null,
required: false,
masked: false,
default: basicInfo?.['support-site'] || '',
warning: null,
},
'marketing-site': {
type: 'text',
inputmode: 'url',
name: 'Website',
description: 'URL to the marketing site / channel for the project',
placeholder: 'e.g. start9.com',
patterns: [],
minLength: null,
maxLength: null,
required: false,
masked: false,
default: basicInfo?.['marketing-site'] || '',
warning: null,
},
}
}

View File

@@ -1,49 +0,0 @@
import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'
const routes: Routes = [
{
path: '',
redirectTo: 'projects',
pathMatch: 'full',
},
{
path: 'projects',
loadChildren: () =>
import('./developer-list/developer-list.module').then(
m => m.DeveloperListPageModule,
),
},
{
path: 'projects/:projectId',
loadChildren: () =>
import('./developer-menu/developer-menu.module').then(
m => m.DeveloperMenuPageModule,
),
},
{
path: 'projects/:projectId/config',
loadChildren: () =>
import('./dev-config/dev-config.module').then(m => m.DevConfigPageModule),
},
{
path: 'projects/:projectId/instructions',
loadChildren: () =>
import('./dev-instructions/dev-instructions.module').then(
m => m.DevInstructionsPageModule,
),
},
{
path: 'projects/:projectId/manifest',
loadChildren: () =>
import('./dev-manifest/dev-manifest.module').then(
m => m.DevManifestPageModule,
),
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DeveloperRoutingModule {}

View File

@@ -1,7 +1,8 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { TuiDialogService } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { MarketplaceSettingsPage } from 'src/app/modals/marketplace-settings/marketplace-settings.page'
@@ -73,7 +74,7 @@ export class MarketplaceListPage {
private readonly patch: PatchDB<DataModel>,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
private readonly modalCtrl: ModalController,
private readonly dialogs: TuiDialogService,
private readonly config: ConfigService,
private readonly route: ActivatedRoute,
) {}
@@ -81,11 +82,12 @@ export class MarketplaceListPage {
category = 'featured'
query = ''
async presentModalMarketplaceSettings() {
const modal = await this.modalCtrl.create({
component: MarketplaceSettingsPage,
})
await modal.present()
presentModalMarketplaceSettings() {
this.dialogs
.open(new PolymorpheusComponent(MarketplaceSettingsPage), {
label: 'Change Registry',
})
.subscribe()
}
onCategoryChange(category: string): void {

View File

@@ -87,7 +87,6 @@
<h2>
<b>
<span *ngIf="not['package-id'] as pkgId">
<!-- @TODO remove $any when Angular gets smart enough -->
{{ $any(packageData[pkgId])?.manifest.title || pkgId }} -
</span>
<ion-text [color]="getColor(not)">{{ not.title }}</ion-text>

View File

@@ -0,0 +1,32 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { TuiButtonModule, TuiNotificationModule } from '@taiga-ui/core'
import { EmailPage } from './email.page'
import { Routes, RouterModule } from '@angular/router'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { FormModule } from 'src/app/components/form/form.module'
import { TuiInputModule } from '@taiga-ui/kit'
const routes: Routes = [
{
path: '',
component: EmailPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
TuiButtonModule,
FormModule,
FormsModule,
ReactiveFormsModule,
TuiInputModule,
TuiNotificationModule,
RouterModule.forChild(routes),
],
declarations: [EmailPage],
})
export class EmailPageModule {}

View File

@@ -0,0 +1,63 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
<ion-title>Email</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="with-widgets">
<div *ngIf="form$ | async as form" class="cap-width">
<tui-notification>
Adding SMTP credentials to StartOS enables StartOS and some services to
send you emails.
<a
href="https://docs.start9.com/latest/user-manual/smtp"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
<!-- form -->
<form [formGroup]="form">
<ion-item-divider>SMTP Credentials</ion-item-divider>
<form-group
*ngIf="spec | async as resolved"
[spec]="resolved"
></form-group>
<div class="ion-text-right ion-padding-top">
<button
tuiButton
size="m"
[disabled]="form.invalid"
(click)="save(form.value)"
>
Save
</button>
</div>
</form>
<form>
<ion-item-divider>Test Email</ion-item-divider>
<tui-input
[(ngModel)]="testAddress"
[ngModelOptions]="{standalone: true}"
>
Firstname Lastname &lt;email@example.com&gt;
<input tuiTextfield inputmode="email" />
</tui-input>
<div class="ion-text-right ion-padding-top">
<button
tuiButton
appearance="secondary"
size="m"
[disabled]="!testAddress || form.invalid"
(click)="sendTestEmail(form)"
>
Send Test Email
</button>
</div>
</form>
</div>
</ion-content>

View File

@@ -0,0 +1,11 @@
ion-item-divider {
text-transform: unset;
padding-bottom: 12px;
padding-left: 0;
}
ion-item-group {
background-color: #1e2024;
border: 1px solid #717171;
border-radius: 6px;
}

View File

@@ -0,0 +1,76 @@
import { Component } from '@angular/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorService } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { FormService } from 'src/app/services/form.service'
import { LoadingService } from '../../../modals/loading/loading.service'
import { TuiDialogService } from '@taiga-ui/core'
import { switchMap } from 'rxjs/operators'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
import { UntypedFormGroup } from '@angular/forms'
@Component({
selector: 'email',
templateUrl: './email.page.html',
styleUrls: ['./email.page.scss'],
})
export class EmailPage {
spec: Promise<InputSpec> = configBuilderToSpec(customSmtp)
testAddress = ''
readonly form$ = this.patch
.watch$('server-info', 'smtp')
.pipe(
switchMap(async value =>
this.formService.createForm(await this.spec, value),
),
)
constructor(
private readonly dialogs: TuiDialogService,
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
private readonly patch: PatchDB<DataModel>,
private readonly api: ApiService,
private readonly formService: FormService,
) {}
async save(value: unknown): Promise<void> {
const loader = this.loader.open('Saving...').subscribe()
try {
await this.api.configureEmail(customSmtp.validator.unsafeCast(value))
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async sendTestEmail(form: UntypedFormGroup) {
const loader = this.loader.open('Sending...').subscribe()
try {
await this.api.testEmail({
to: this.testAddress,
...form.value,
})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
this.dialogs
.open(
`A test email has been sent to ${this.testAddress}.<br /><br /><b>Check your spam folder and mark as not spam</b>`,
{
label: 'Success',
size: 's',
},
)
.subscribe()
}
}

View File

@@ -73,6 +73,11 @@ const routes: Routes = [
m => m.ExperimentalFeaturesPageModule,
),
},
{
path: 'email',
loadChildren: () =>
import('./email/email.module').then(m => m.EmailPageModule),
},
]
@NgModule({

View File

@@ -387,6 +387,15 @@ export class ServerShowPage {
detail: true,
disabled$: of(false),
},
{
title: 'Email',
description: 'Provide an external SMTP server for sending emails',
icon: 'mail-outline',
action: () =>
this.navCtrl.navigateForward(['email'], { relativeTo: this.route }),
detail: true,
disabled$: of(false),
},
{
title: 'WiFi',
description: 'Add or remove WiFi networks',

View File

@@ -1,4 +1,4 @@
import { ValueSpecObject } from 'start-sdk/lib/config/configTypes'
import { ValueSpecObject } from '@start9labs/start-sdk/lib/config/configTypes'
export const wifiSpec: ValueSpecObject = {
type: 'object',
@@ -20,6 +20,9 @@ export const wifiSpec: ValueSpecObject = {
masked: false,
default: null,
warning: null,
disabled: false,
immutable: false,
generate: null,
},
password: {
type: 'text',
@@ -39,6 +42,9 @@ export const wifiSpec: ValueSpecObject = {
masked: true,
default: null,
warning: null,
disabled: false,
immutable: false,
generate: null,
},
},
}

View File

@@ -4,6 +4,7 @@ import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { WifiPage, ToWifiIconPipe } from './wifi.page'
import { SharedPipesModule } from '@start9labs/shared'
import { TuiLetModule } from '@taiga-ui/cdk'
const routes: Routes = [
{
@@ -18,6 +19,7 @@ const routes: Routes = [
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
TuiLetModule,
],
declarations: [WifiPage, ToWifiIconPipe],
})

View File

@@ -8,13 +8,13 @@
</ion-header>
<ion-content class="with-widgets">
<div class="smaller" *tuiLet="enabled$ | async as enabled">
<div class="cap-width" *tuiLet="enabled$ | async as enabled">
<ion-item-group>
<ion-item>
<ion-label>
<h2>
Adding WiFi credentials to your Embassy allows you to remove the
Ethernet cable and move the device anywhere you want. Embassy will
Adding WiFi credentials to StartOS allows you to remove the Ethernet
cable and move the device anywhere you want. StartOS will
automatically connect to available networks.
<a
href="https://docs.start9.com/latest/user-manual/wifi"

View File

@@ -1,8 +1,3 @@
.smaller {
padding: 32px;
max-width: 800px;
}
.no-padding {
padding-right: 0;
}

View File

@@ -24,7 +24,7 @@ import {
switchMap,
tap,
} from 'rxjs'
import { wifiSpec } from './wifiSpec'
import { wifiSpec } from './wifi.const'
interface WiFiForm {
ssid: string

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,13 @@
import { Dump, Revision } from 'patch-db-client'
import { MarketplacePkg, StoreInfo, Manifest } from '@start9labs/marketplace'
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
import { InputSpec } from 'start-sdk/lib/config/configTypes'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import {
DataModel,
DependencyError,
} from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
export module RR {
// DB
@@ -136,6 +137,14 @@ export module RR {
export type DeleteWifiReq = { ssid: string } // wifi.delete
export type DeleteWifiRes = null
// email
export type ConfigureEmailReq = typeof customSmtp.validator._TYPE // email.configure
export type ConfigureEmailRes = null
export type TestEmailReq = ConfigureEmailReq & { to: string } // email.test
export type TestEmailRes = null
// ssh
export type GetSSHKeysReq = {} // ssh.list

View File

@@ -160,6 +160,14 @@ export abstract class ApiService {
abstract deleteWifi(params: RR.DeleteWifiReq): Promise<RR.ConnectWifiRes>
// email
abstract testEmail(params: RR.TestEmailReq): Promise<RR.TestEmailRes>
abstract configureEmail(
params: RR.ConfigureEmailReq,
): Promise<RR.ConfigureEmailRes>
// ssh
abstract getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes>

View File

@@ -31,7 +31,7 @@ export class LiveApiService extends ApiService {
private readonly patch: PatchDB<DataModel>,
) {
super()
;(window as any).rpcClient = this
; (window as any).rpcClient = this
}
// for getting static files: ex icons, instructions, licenses
@@ -295,6 +295,18 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'wifi.delete', params })
}
// email
async testEmail(params: RR.TestEmailReq): Promise<RR.TestEmailRes> {
return this.rpcRequest({ method: 'email.test', params })
}
async configureEmail(
params: RR.ConfigureEmailReq,
): Promise<RR.ConfigureEmailRes> {
return this.rpcRequest({ method: 'email.configure', params })
}
// ssh
async getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {

View File

@@ -459,6 +459,28 @@ export class MockApiService extends ApiService {
return null
}
// email
async testEmail(params: RR.TestEmailReq): Promise<RR.TestEmailRes> {
await pauseFor(2000)
return null
}
async configureEmail(
params: RR.ConfigureEmailReq,
): Promise<RR.ConfigureEmailRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/email',
value: params,
},
]
return this.withRevision(patch)
}
// ssh
async getSshKeys(params: RR.GetSSHKeysReq): Promise<RR.GetSSHKeysRes> {
@@ -649,7 +671,8 @@ export class MockApiService extends ApiService {
params: RR.GetPackagePropertiesReq,
): Promise<RR.GetPackagePropertiesRes<2>['data']> {
await pauseFor(2000)
return parsePropertiesPermissive(Mock.PackageProperties)
return '' as any
// return parsePropertiesPermissive(Mock.PackageProperties)
}
async getPackageLogs(
@@ -730,7 +753,7 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
return {
config: Mock.MockConfig,
spec: Mock.InputSpec,
spec: await Mock.getInputSpec(),
}
}
@@ -965,7 +988,7 @@ export class MockApiService extends ApiService {
return {
'old-config': Mock.MockConfig,
'new-config': Mock.MockDependencyConfig,
spec: Mock.InputSpec,
spec: await Mock.getInputSpec(),
}
}

View File

@@ -1,11 +1,6 @@
import {
DataModel,
DependencyErrorType,
HealthResult,
PackageMainStatus,
PackageState,
} from 'src/app/services/patch-db/data-model'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { BUILT_IN_WIDGETS } from '../../pages/widgets/built-in/widgets'
import { Mock } from './api.fixures'
export const mockPatchData: DataModel = {
ui: {
@@ -31,7 +26,6 @@ export const mockPatchData: DataModel = {
},
},
},
dev: {},
gaming: {
snake: {
'high-score': 0,
@@ -71,213 +65,17 @@ export const mockPatchData: DataModel = {
'ca-fingerprint': 'SHA-256: 63 2B 11 99 44 40 17 DF 37 FC C3 DF 0F 3D 15',
'system-start-time': new Date(new Date().valueOf() - 360042).toUTCString(),
zram: false,
smtp: {
server: '',
port: 587,
from: '',
login: '',
password: '',
tls: true,
},
},
'package-data': {
bitcoind: {
state: PackageState.Installed,
icon: '/assets/img/service-icons/bitcoind.svg',
manifest: {
id: 'bitcoind',
title: 'Bitcoin Core',
version: '0.20.0',
'git-hash': 'abcdefgh',
description: {
short: 'A Bitcoin full node by Bitcoin Core.',
long: 'Bitcoin is a decentralized consensus protocol and settlement network.',
},
assets: {
icon: 'icon.png',
},
'release-notes': 'Taproot, Schnorr, and more.',
license: 'MIT',
'wrapper-repo': 'https://github.com/start9labs/bitcoind-wrapper',
'upstream-repo': 'https://github.com/bitcoin/bitcoin',
'support-site': 'https://bitcoin.org',
'marketing-site': 'https://bitcoin.org',
'donation-url': 'https://start9.com',
alerts: {
install: 'Bitcoin can take over a week to sync.',
uninstall:
'Chain state will be lost, as will any funds stored on your Bitcoin Core waller that have not been backed up.',
restore: null,
start: 'Starting Bitcoin is good for your health.',
stop: null,
},
dependencies: {},
'os-version': '0.4.0',
},
installed: {
'last-backup': null,
status: {
configured: true,
main: {
status: PackageMainStatus.Running,
started: '2021-06-14T20:49:17.774Z',
health: {
'ephemeral-health-check': {
name: 'Ephemeral Health Check',
result: HealthResult.Starting,
},
'chain-state': {
name: 'Chain State',
result: HealthResult.Loading,
message: 'Bitcoin is syncing from genesis',
},
'p2p-interface': {
name: 'P2P Interface',
result: HealthResult.Success,
message: 'the health check ran successfully',
},
'rpc-interface': {
name: 'RPC Interface',
result: HealthResult.Failure,
error: 'RPC interface unreachable.',
},
'unnecessary-health-check': {
name: 'Totally Unnecessary',
result: HealthResult.Disabled,
reason: 'You disabled this on purpose',
},
},
},
'dependency-errors': {},
},
'address-info': {
rpc: {
name: 'Bitcoin RPC',
description: `Bitcoin's RPC interface`,
addresses: [
'http://bitcoind-rpc-address.onion',
'https://bitcoind-rpc-address.local',
'https://192.168.1.1:8332',
],
ui: true,
},
p2p: {
name: 'Bitcoin P2P',
description: `Bitcoin's P2P interface`,
addresses: [
'bitcoin://bitcoind-rpc-address.onion',
'bitcoin://192.168.1.1:8333',
],
ui: true,
},
},
'current-dependencies': {},
'dependency-info': {},
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
'has-config': true,
},
},
lnd: {
state: PackageState.Installed,
icon: '/assets/img/service-icons/lnd.png',
manifest: {
id: 'lnd',
title: 'Lightning Network Daemon',
version: '0.11.1',
description: {
short: 'A bolt spec compliant client.',
long: 'More info about LND. More info about LND. More info about LND.',
},
assets: {
icon: 'icon.png',
},
'release-notes': 'Dual funded channels!',
license: 'MIT',
'wrapper-repo': 'https://github.com/start9labs/lnd-wrapper',
'upstream-repo': 'https://github.com/lightningnetwork/lnd',
'support-site': 'https://lightning.engineering/',
'marketing-site': 'https://lightning.engineering/',
'donation-url': null,
alerts: {
install: null,
uninstall: null,
restore:
'If this is a duplicate instance of the same LND node, you may loose your funds.',
start: 'Starting LND is good for your health.',
stop: null,
},
dependencies: {
bitcoind: {
version: '=0.21.0',
description: 'LND needs bitcoin to live.',
requirement: {
type: 'opt-out',
how: 'You can use an external node from your server if you prefer.',
},
},
'btc-rpc-proxy': {
version: '>=0.2.2',
description:
'As long as Bitcoin is pruned, LND needs Bitcoin Proxy to fetch block over the P2P network.',
requirement: {
type: 'opt-in',
how: `To use Proxy's user management system, go to LND config and select Bitcoin Proxy under Bitcoin config.`,
},
},
},
'os-version': '0.4.0',
},
installed: {
'last-backup': null,
status: {
configured: true,
main: {
status: PackageMainStatus.Stopped,
},
'dependency-errors': {
'btc-rpc-proxy': {
type: DependencyErrorType.ConfigUnsatisfied,
error: 'This is a config unsatisfied error',
},
},
},
'address-info': {
ui: {
name: 'Web UI',
description: 'The browser web interface for LND',
addresses: [
'http://lnd-ui-address.onion',
'https://lnd-ui-address.local',
'https://192.168.1.1:3449',
],
ui: true,
},
grpc: {
name: 'gRPC',
description: 'For connecting to LND gRPC interface',
addresses: [
'http://lnd-grpc-address.onion',
'https://lnd-grpc-address.local',
'https://192.168.1.1:3449',
],
ui: true,
},
},
'current-dependencies': {
bitcoind: {
'health-checks': [],
},
'btc-rpc-proxy': {
'health-checks': [],
},
},
'dependency-info': {
bitcoind: {
title: 'Bitcoin Core',
icon: 'assets/img/service-icons/bitcoind.svg',
},
'btc-rpc-proxy': {
title: 'Bitcoin Proxy',
icon: 'assets/img/service-icons/btc-rpc-proxy.png',
},
},
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
'has-config': true,
},
},
bitcoind: Mock.bitcoind,
lnd: Mock.lnd,
},
}

Some files were not shown because too many files have changed in this diff Show More