mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
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:
committed by
Aiden McClelland
parent
4c465850a2
commit
010be05920
143
frontend/package-lock.json
generated
143
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"> (New Options)</ion-text>
|
||||
<ion-text color="warning" *ngIf="data.edited"> (Edited)</ion-text>
|
||||
|
||||
<span *ngIf="data.required"> *</span>
|
||||
@@ -1,11 +0,0 @@
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.icon {
|
||||
--padding-start: 0;
|
||||
--padding-end: 4px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,5 +0,0 @@
|
||||
.error-message {
|
||||
margin-top: 2px;
|
||||
font-size: small;
|
||||
color: var(--ion-color-danger);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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> =
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -1,9 +0,0 @@
|
||||
button:disabled,
|
||||
button[disabled]{
|
||||
border: 1px solid #999999;
|
||||
background-color: #cccccc;
|
||||
color: #666666;
|
||||
}
|
||||
button {
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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'])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
},
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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 <email@example.com>
|
||||
<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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,11 @@ const routes: Routes = [
|
||||
m => m.ExperimentalFeaturesPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
loadChildren: () =>
|
||||
import('./email/email.module').then(m => m.EmailPageModule),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
.smaller {
|
||||
padding: 32px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.no-padding {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user