refactor app wizards completely (#1537)

* refactor app wizards completely

* display new and new options in config

Co-authored-by: Matt Hill <matthill@Matt-M1.start9.dev>
This commit is contained in:
Matt Hill
2022-06-14 18:25:43 -06:00
committed by Lucy C
parent 8e9d2b5314
commit 4ad9886517
52 changed files with 850 additions and 1126 deletions

View File

@@ -37,6 +37,7 @@
"patch-db-client": "file: ../../../patch-db/client",
"pbkdf2": "^3.1.2",
"rxjs": "^6.6.7",
"swiper": "^8.2.4",
"ts-matches": "^5.1.0",
"tslib": "^2.3.0",
"uuid": "^8.3.2",
@@ -5879,6 +5880,14 @@
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom7": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/dom7/-/dom7-4.0.4.tgz",
"integrity": "sha512-DSSgBzQ4rJWQp1u6o+3FVwMNnT5bzQbMb+o31TjYYeRi05uAcpF8koxdfzeoe5ElzPmua7W7N28YJhF7iEKqIw==",
"dependencies": {
"ssr-window": "^4.0.0"
}
},
"node_modules/domelementtype": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz",
@@ -12805,6 +12814,11 @@
"integrity": "sha512-ZPO9rECxzs5JIQ6G/2EfL1I9ho/BVZkx9HRKn8+0af7QgwAmumQ7XBFP1ggMyPMo+/tUbmv0HFdv4qifdO/9JA==",
"dev": true
},
"node_modules/ssr-window": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-4.0.2.tgz",
"integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ=="
},
"node_modules/ssri": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
@@ -13052,6 +13066,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/swiper": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-8.2.4.tgz",
"integrity": "sha512-TPq64KiZUt8lZY5ZEg75RjToT+RwfLomfKIpcFLy6+UCUp2kL7hHWslLxjFtcFeiwfG67RHFYbJnq6tsothcJQ==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/swiperjs"
},
{
"type": "open_collective",
"url": "http://opencollective.com/swiper"
}
],
"hasInstallScript": true,
"dependencies": {
"dom7": "^4.0.4",
"ssr-window": "^4.0.2"
},
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",
@@ -18631,6 +18668,14 @@
"entities": "^2.0.0"
}
},
"dom7": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/dom7/-/dom7-4.0.4.tgz",
"integrity": "sha512-DSSgBzQ4rJWQp1u6o+3FVwMNnT5bzQbMb+o31TjYYeRi05uAcpF8koxdfzeoe5ElzPmua7W7N28YJhF7iEKqIw==",
"requires": {
"ssr-window": "^4.0.0"
}
},
"domelementtype": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz",
@@ -23758,6 +23803,11 @@
"integrity": "sha512-ZPO9rECxzs5JIQ6G/2EfL1I9ho/BVZkx9HRKn8+0af7QgwAmumQ7XBFP1ggMyPMo+/tUbmv0HFdv4qifdO/9JA==",
"dev": true
},
"ssr-window": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-4.0.2.tgz",
"integrity": "sha512-ISv/Ch+ig7SOtw7G2+qkwfVASzazUnvlDTwypdLoPoySv+6MqlOV10VwPSE6EWkGjhW50lUmghPmpYZXMu/+AQ=="
},
"ssri": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz",
@@ -23939,6 +23989,15 @@
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true
},
"swiper": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-8.2.4.tgz",
"integrity": "sha512-TPq64KiZUt8lZY5ZEg75RjToT+RwfLomfKIpcFLy6+UCUp2kL7hHWslLxjFtcFeiwfG67RHFYbJnq6tsothcJQ==",
"requires": {
"dom7": "^4.0.4",
"ssr-window": "^4.0.2"
}
},
"symbol-observable": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz",

View File

@@ -51,6 +51,7 @@
"patch-db-client": "file: ../../../patch-db/client",
"pbkdf2": "^3.1.2",
"rxjs": "^6.6.7",
"swiper": "^8.2.4",
"ts-matches": "^5.1.0",
"tslib": "^2.3.0",
"uuid": "^8.3.2",

View File

@@ -22,7 +22,6 @@ export interface MarketplaceManifest<T = unknown> {
uninstall: string | null
restore: string | null
start: string | null
stop: string | null
}
dependencies: Record<string, Dependency<T>>
}

View File

@@ -3,6 +3,9 @@
<!-- 3rd party components -->
<qr-code value="hello"></qr-code>
<swiper>
<ng-template swiperSlide>Slide 1</ng-template>
</swiper>
<!-- Ionic components -->
<ion-action-sheet></ion-action-sheet>
@@ -46,7 +49,6 @@
<ion-segment-button></ion-segment-button>
<ion-select></ion-select>
<ion-select-option></ion-select-option>
<ion-slides></ion-slides>
<ion-spinner name="lines"></ion-spinner>
<ion-text></ion-text>
<ion-text><strong>load bold font</strong></ion-text>

View File

@@ -2,11 +2,11 @@ import { CommonModule } from '@angular/common'
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'
import { IonicModule } from '@ionic/angular'
import { QrCodeModule } from 'ng-qrcode'
import { SwiperModule } from 'swiper/angular'
import { PreloaderComponent } from './preloader.component'
@NgModule({
imports: [CommonModule, IonicModule, QrCodeModule],
imports: [CommonModule, IonicModule, QrCodeModule, SwiperModule],
declarations: [PreloaderComponent],
exports: [PreloaderComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],

View File

@@ -0,0 +1,4 @@
<h1>
<ion-text color="warning">Warning</ion-text>
</h1>
<div class="ion-text-left" [innerHTML]="params.message | markdown"></div>

View File

@@ -0,0 +1,17 @@
import { Component, Input } from '@angular/core'
import { BaseSlide } from '../wizard-types'
@Component({
selector: 'alert',
templateUrl: './alert.component.html',
styleUrls: ['../app-wizard.component.scss'],
})
export class AlertComponent implements BaseSlide {
@Input() params: {
message: string
}
async load() {}
loading = false
}

View File

@@ -0,0 +1,90 @@
<ion-header>
<ion-toolbar>
<div style="padding: 10px 0">
<ion-title style="font-size: 32px">{{ params.title }}</ion-title>
<div class="underline"></div>
<ion-title>
<i
>{{ params.action | titlecase
}}<span *ngIf="params.version"
>: {{ params.version | displayEmver }}</span
></i
>
</ion-title>
</div>
<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>
<div style="padding: 36px; height: 100%">
<swiper
*ngIf="!error; else hasError"
(swiper)="setSwiperInstance($event)"
(slideNextTransitionStart)="loadSlide()"
>
<ng-template swiperSlide *ngFor="let slide of params.slides">
<alert
#components
*ngIf="slide.selector === 'alert'"
[params]="slide.params"
></alert>
<notes
#components
*ngIf="slide.selector === 'notes'"
[params]="slide.params"
></notes>
<dependents
#components
*ngIf="slide.selector === 'dependents'"
[params]="slide.params"
(onSuccess)="next()"
(onError)="setError($event)"
></dependents>
<complete
#components
*ngIf="slide.selector === 'complete'"
[params]="slide.params"
(onSuccess)="dismiss('success')"
(onError)="setError($event)"
></complete>
</ng-template>
</swiper>
<ng-template #hasError>
<p>
<ion-text color="danger">{{ error }}</ion-text>
</p>
</ng-template>
</div>
</ion-content>
<ion-footer>
<ion-toolbar>
<ng-container *ngIf="!initializing && swiper">
<ion-buttons slot="end" style="padding-right: 8px">
<ion-button
*ngIf="error; else noError"
(click)="dismiss()"
class="enter-click"
>
<b>Dismiss</b>
</ion-button>
<ng-template #noError>
<ion-button
*ngIf="!currentSlide.loading && !swiper.isEnd"
(click)="next()"
class="enter-click"
[class.no-click]="currentSlide.loading"
>
<b>Continue</b>
</ion-button>
</ng-template>
</ion-buttons>
</ng-container>
</ion-toolbar>
</ion-footer>

View File

@@ -1,6 +1,6 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { InstallWizardComponent } from './install-wizard.component'
import { AppWizardComponent } from './app-wizard.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { EmverPipesModule } from '@start9labs/shared'
@@ -8,9 +8,10 @@ import { DependentsComponentModule } from './dependents/dependents.component.mod
import { CompleteComponentModule } from './complete/complete.component.module'
import { NotesComponentModule } from './notes/notes.component.module'
import { AlertComponentModule } from './alert/alert.component.module'
import { SwiperModule } from 'swiper/angular'
@NgModule({
declarations: [InstallWizardComponent],
declarations: [AppWizardComponent],
imports: [
CommonModule,
IonicModule,
@@ -20,7 +21,8 @@ import { AlertComponentModule } from './alert/alert.component.module'
CompleteComponentModule,
NotesComponentModule,
AlertComponentModule,
SwiperModule,
],
exports: [InstallWizardComponent],
exports: [AppWizardComponent],
})
export class InstallWizardComponentModule {}
export class AppWizardComponentModule {}

View File

@@ -0,0 +1,6 @@
.underline {
margin: 6px 0 8px 16px;
border-style: solid;
border-width: 0px 0px 1px 0px;
border-color: #404040;
}

View File

@@ -0,0 +1,107 @@
import {
Component,
Input,
QueryList,
ViewChild,
ViewChildren,
} from '@angular/core'
import { IonContent, ModalController } from '@ionic/angular'
import { CompleteComponent } from './complete/complete.component'
import { DependentsComponent } from './dependents/dependents.component'
import { AlertComponent } from './alert/alert.component'
import { NotesComponent } from './notes/notes.component'
import { WizardAction } from './wizard-types'
import SwiperCore, { Swiper } from 'swiper'
import { IonicSlides } from '@ionic/angular'
import { BaseSlide } from './wizard-types'
SwiperCore.use([IonicSlides])
@Component({
selector: 'app-wizard',
templateUrl: './app-wizard.component.html',
styleUrls: ['./app-wizard.component.scss'],
})
export class AppWizardComponent {
@Input() params: {
action: WizardAction
title: string
slides: SlideDefinition[]
version?: string
}
// content container so we can scroll to top between slide transitions
@ViewChild(IonContent) content: IonContent
swiper: Swiper
//a slide component gives us hook into a slide. Allows us to call load when slide comes into view
@ViewChildren('components')
slideComponentsQL: QueryList<BaseSlide>
get slideComponents(): BaseSlide[] {
return this.slideComponentsQL.toArray()
}
get currentSlide(): BaseSlide {
return this.slideComponents[this.currentIndex]
}
get currentIndex(): number {
return this.swiper.activeIndex
}
initializing = true
error = ''
constructor(private readonly modalController: ModalController) {}
ionViewDidEnter() {
this.initializing = false
this.swiper.allowTouchMove = false
this.loadSlide()
}
setSwiperInstance(swiper: any) {
this.swiper = swiper
}
dismiss(role = 'cancelled') {
this.modalController.dismiss(null, role)
}
async next() {
await this.content.scrollToTop()
this.swiper.slideNext(500)
}
setError(e: any) {
console.log(e)
this.error = e
}
async loadSlide() {
this.currentSlide.load()
}
}
export type SlideDefinition =
| { selector: 'alert'; params: AlertComponent['params'] }
| { selector: 'notes'; params: NotesComponent['params'] }
| { selector: 'dependents'; params: DependentsComponent['params'] }
| { selector: 'complete'; params: CompleteComponent['params'] }
export async function wizardModal(
modalController: ModalController,
params: AppWizardComponent['params'],
): Promise<boolean> {
const modal = await modalController.create({
backdropDismiss: false,
cssClass: 'wizard-modal',
component: AppWizardComponent,
componentProps: { params },
})
await modal.present()
return modal.onDidDismiss().then(({ role }) => role === 'success')
}

View File

@@ -0,0 +1,4 @@
<div style="padding: 32px">
<ion-spinner color="warning" name="lines"></ion-spinner>
<p>{{ message }}</p>
</div>

View File

@@ -5,14 +5,8 @@ import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
@NgModule({
declarations: [
CompleteComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
],
declarations: [CompleteComponent],
imports: [CommonModule, IonicModule, RouterModule.forChild([])],
exports: [CompleteComponent],
})
export class CompleteComponentModule {}

View File

@@ -0,0 +1,34 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { capitalizeFirstLetter } from '@start9labs/shared'
import { BaseSlide } from '../wizard-types'
@Component({
selector: 'complete',
templateUrl: './complete.component.html',
styleUrls: ['../app-wizard.component.scss'],
})
export class CompleteComponent implements BaseSlide {
@Input() params: {
verb: string // loader verb: '*stopping* ...'
title: string
Fn: () => Promise<any>
}
@Output() onSuccess: EventEmitter<void> = new EventEmitter()
@Output() onError: EventEmitter<string> = new EventEmitter()
message: string
loading = true
async load() {
this.message =
capitalizeFirstLetter(this.params.verb) + ' ' + this.params.title
try {
await this.params.Fn()
this.onSuccess.emit()
} catch (e: any) {
this.onError.emit(`Error: ${e.message || e}`)
}
}
}

View File

@@ -0,0 +1,25 @@
<div *ngIf="loading; else loaded" style="padding: 32px">
<ion-spinner color="warning" name="lines"></ion-spinner>
<p>Checking for installed services which depend on {{ params.title }}...</p>
</div>
<ng-template #loaded>
<h1><ion-text color="warning">Warning</ion-text></h1>
<p>{{ warningMessage }}</p>
<ng-container *ngIf="pkgs$ | async as pkgs">
<ion-item-group>
<ion-item-divider class="ion-padding-bottom">
Affected Services
</ion-item-divider>
<ion-item *ngFor="let dep of breakages | keyvalue">
<ion-thumbnail slot="start">
<img alt="" [src]="pkgs[dep.key]['static-files'].icon" />
</ion-thumbnail>
<ion-label>
{{ pkgs[dep.key].manifest.title }}
</ion-label>
</ion-item>
</ion-item-group>
</ng-container>
</ng-template>

View File

@@ -0,0 +1,51 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { Breakages } from 'src/app/services/api/api.types'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { capitalizeFirstLetter, isEmptyObject } from '@start9labs/shared'
import { BaseSlide } from '../wizard-types'
@Component({
selector: 'dependents',
templateUrl: './dependents.component.html',
styleUrls: ['./dependents.component.scss', '../app-wizard.component.scss'],
})
export class DependentsComponent implements BaseSlide {
@Input() params: {
title: string
verb: string // *Uninstalling* will cause problems...
Fn: () => Promise<Breakages>
}
@Output() onSuccess: EventEmitter<void> = new EventEmitter()
@Output() onError: EventEmitter<string> = new EventEmitter()
breakages: Breakages
warningMessage: string | undefined
loading = true
readonly pkgs$ = this.patch.watch$('package-data')
constructor(public readonly patch: PatchDbService) {}
async load() {
try {
this.breakages = await this.params.Fn()
if (this.breakages && !isEmptyObject(this.breakages)) {
this.warningMessage =
capitalizeFirstLetter(this.params.verb) +
' ' +
this.params.title +
' will prohibit the following services from functioning properly.'
} else {
this.onSuccess.emit()
}
} catch (e: any) {
this.onError.emit(
`Error fetching dependent service information: ${e.message || e}`,
)
} finally {
this.loading = false
}
}
}

View File

@@ -0,0 +1,14 @@
<div class="ion-text-left">
<h1>Release Notes</h1>
<br />
<div *ngFor="let v of params.versions">
<h4>
<b>
{{ v.version }}
</b>
</h4>
<hr style="height: 0; border-width: 1px" />
<div [innerHTML]="v.notes | markdown"></div>
<br />
</div>
</div>

View File

@@ -1,22 +1,20 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject, Subject } from 'rxjs'
import { BaseSlide } from '../wizard-types'
@Component({
selector: 'notes',
templateUrl: './notes.component.html',
styleUrls: ['../install-wizard.component.scss'],
styleUrls: ['../app-wizard.component.scss'],
})
export class NotesComponent {
export class NotesComponent implements BaseSlide {
@Input() params: {
versions: { version: string; notes: string }[]
title: string
titleColor: string
headline: string
}
load() {}
loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>()
loading = false
async load() {}
asIsOrder() {
return 0

View File

@@ -0,0 +1,269 @@
import { Inject, Injectable } from '@angular/core'
import { exists } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { Manifest } from 'src/app/services/patch-db/data-model'
import { ApiService } from '../../services/api/embassy-api.service'
import { AppWizardComponent, SlideDefinition } from './app-wizard.component'
import { ConfigService } from 'src/app/services/config.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { first } from 'rxjs/operators'
@Injectable({ providedIn: 'root' })
export class WizardDefs {
constructor(
private readonly embassyApi: ApiService,
private readonly config: ConfigService,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
) {}
update(values: {
id: string
title: string
version: string
installAlert?: string
}): AppWizardComponent['params'] {
const { id, title, version, installAlert } = values
const slides: Array<SlideDefinition | undefined> = [
installAlert
? {
selector: 'alert',
params: {
message: installAlert,
},
}
: undefined,
{
selector: 'dependents',
params: {
verb: 'updating',
title,
Fn: () => this.embassyApi.dryUpdatePackage({ id, version }),
},
},
{
selector: 'complete',
params: {
verb: 'beginning update for',
title,
Fn: () =>
this.marketplaceService
.installPackage({
id,
'version-spec': version ? `=${version}` : undefined,
})
.pipe(first())
.toPromise(),
},
},
]
return {
action: 'update',
title,
version,
slides: slides.filter(exists),
}
}
updateOS(values: {
version: string
releaseNotes: { [version: string]: string }
headline: string
}): AppWizardComponent['params'] {
const { version, releaseNotes, headline } = values
const versions = Object.keys(releaseNotes)
.sort()
.reverse()
.map(version => {
return {
version,
notes: releaseNotes[version],
}
})
const title = 'EmbassyOS'
const slides: SlideDefinition[] = [
{
selector: 'notes',
params: {
versions,
headline,
},
},
{
selector: 'complete',
params: {
verb: 'beginning update for',
title,
Fn: () =>
this.embassyApi.updateServer({
'marketplace-url': this.config.marketplace.url,
}),
},
},
]
return {
action: 'update',
title,
version,
slides: slides.filter(exists),
}
}
downgrade(values: {
id: string
title: string
version: string
installAlert?: string
}): AppWizardComponent['params'] {
const { id, title, version, installAlert } = values
const slides: Array<SlideDefinition | undefined> = [
installAlert
? {
selector: 'alert',
params: {
message: installAlert,
},
}
: undefined,
{
selector: 'dependents',
params: {
verb: 'downgrading',
title,
Fn: () => this.embassyApi.dryUpdatePackage({ id, version }),
},
},
{
selector: 'complete',
params: {
verb: 'beginning downgrade for',
title,
Fn: () =>
this.marketplaceService
.installPackage({
id,
'version-spec': version ? `=${version}` : undefined,
})
.pipe(first())
.toPromise(),
},
},
]
return {
action: 'downgrade',
title,
version,
slides: slides.filter(exists),
}
}
uninstall(values: {
id: string
title: string
uninstallAlert?: string
}): AppWizardComponent['params'] {
const { id, title, uninstallAlert } = values
const slides: SlideDefinition[] = [
{
selector: 'alert',
params: {
message: uninstallAlert || defaultUninstallWarning(title),
},
},
{
selector: 'dependents',
params: {
verb: 'uninstalling',
title,
Fn: () => this.embassyApi.dryUninstallPackage({ id }),
},
},
{
selector: 'complete',
params: {
verb: 'uninstalling',
title,
Fn: () => this.embassyApi.uninstallPackage({ id }),
},
},
]
return {
action: 'uninstall',
title,
slides: slides.filter(exists),
}
}
stop(values: { id: string; title: string }): AppWizardComponent['params'] {
const { title, id } = values
const slides: SlideDefinition[] = [
{
selector: 'dependents',
params: {
verb: 'stopping',
title,
Fn: () => this.embassyApi.dryStopPackage({ id }),
},
},
{
selector: 'complete',
params: {
verb: 'stopping',
title,
Fn: () => this.embassyApi.stopPackage({ id }),
},
},
]
return {
action: 'stop',
title,
slides: slides.filter(exists),
}
}
configure(values: {
manifest: Manifest
config: object
}): AppWizardComponent['params'] {
const { manifest, config } = values
const { id, title } = manifest
const slides: SlideDefinition[] = [
{
selector: 'dependents',
params: {
verb: 'saving config for',
title,
Fn: () => this.embassyApi.drySetPackageConfig({ id, config }),
},
},
{
selector: 'complete',
params: {
verb: 'configuring',
title,
Fn: () => this.embassyApi.setPackageConfig({ id, config }),
},
},
]
return {
action: 'configure',
title,
slides: slides.filter(exists),
}
}
}
const defaultUninstallWarning = (serviceName: string) =>
`Uninstalling ${serviceName} will result in the deletion of its data.`

View File

@@ -0,0 +1,11 @@
export type WizardAction =
| 'update'
| 'downgrade'
| 'uninstall'
| 'stop'
| 'configure'
export interface BaseSlide {
load: () => Promise<void>
loading: boolean
}

View File

@@ -22,6 +22,7 @@
<span>{{ data.spec.name }}</span>
<ion-text color="success" *ngIf="data.new">&nbsp;(New)</ion-text>
<ion-text color="success" *ngIf="data.newOptions">&nbsp;(New Options)</ion-text>
<ion-text color="warning" *ngIf="data.edited">&nbsp;(Edited)</ion-text>
<span

View File

@@ -51,7 +51,7 @@
<form-label
[data]="{
spec: spec,
new: !!current && current[entry.key] === undefined,
new: original?.[entry.key] === undefined,
edited: entry.value.dirty
}"
></form-label>
@@ -116,9 +116,20 @@
size="small"
></ion-icon>
</ion-button>
<ion-label
><b>{{ spec.name }}</b></ion-label
<ion-label>
<b>
{{ spec.name }}
<ion-text *ngIf="entry.value.dirty" color="warning">
(Edited)</ion-text
>
<ion-text
*ngIf="original?.[entry.key] === undefined"
color="success"
>
(New)</ion-text
>
</b>
</ion-label>
<!-- boolean -->
<ion-toggle
*ngIf="spec.type === 'boolean'"
@@ -157,7 +168,8 @@
<form-label
[data]="{
spec: spec,
new: !!current && current[entry.key] === undefined,
new: original?.[entry.key] === undefined,
newOptions: objectDisplay[entry.key].hasNewOptions,
edited: entry.value.dirty
}"
></form-label>
@@ -190,7 +202,8 @@
: spec.spec
"
[formGroup]="$any(entry.value)"
[current]="current ? current[entry.key] : undefined"
[current]="current?.[entry.key]"
[original]="original?.[entry.key]"
[unionSpec]="spec.type === 'union' ? spec : undefined"
(onExpand)="resize(entry.key)"
></form-object>
@@ -208,7 +221,7 @@
<form-label
[data]="{
spec: spec,
new: !!current && current[entry.key] === undefined,
new: original?.[entry.key] === undefined,
edited: entry.value.dirty
}"
></form-label>
@@ -282,6 +295,7 @@
"
[formGroup]="abstractControl"
[current]="current?.[entry.key]?.[i]"
[original]="original?.[entry.key]?.[i]"
[unionSpec]="
spec.subtype === 'union' ? $any(spec.spec) : undefined
"
@@ -347,7 +361,7 @@
<form-label
[data]="{
spec: spec,
new: !!current && current[entry.key] === undefined,
new: original?.[entry.key] === undefined,
edited: entry.value.dirty
}"
></form-label>

View File

@@ -36,12 +36,14 @@ export class FormObjectComponent {
@Input() formGroup: FormGroup
@Input() unionSpec?: ValueSpecUnion
@Input() current?: { [key: string]: any }
@Input() showEdited: boolean = false
@Input() original?: { [key: string]: any }
@Output() onInputChange = new EventEmitter<void>()
@Output() onExpand = new EventEmitter<void>()
warningAck: { [key: string]: boolean } = {}
unmasked: { [key: string]: boolean } = {}
objectDisplay: { [key: string]: { expanded: boolean; height: string } } = {}
objectDisplay: {
[key: string]: { expanded: boolean; height: string; hasNewOptions: boolean }
} = {}
objectListDisplay: {
[key: string]: { expanded: boolean; height: string; displayAs: string }[]
} = {}
@@ -74,9 +76,23 @@ export class FormObjectComponent {
}
})
} else if (['object', 'union'].includes(spec.type)) {
let hasNewOptions = false
// We can only really show new children for objects, not unions.
if (spec.type === 'object') {
hasNewOptions = Object.keys(spec.spec).some(childKey => {
const parentValue = this.original?.[key]
return !!parentValue && parentValue[childKey] === undefined
})
} else if (spec.type === 'union') {
// @TODO
hasNewOptions = false
}
this.objectDisplay[key] = {
expanded: false,
height: '0px',
hasNewOptions,
}
}
})
@@ -110,6 +126,7 @@ export class FormObjectComponent {
this.objectDisplay[key] = {
expanded: false,
height: '0px',
hasNewOptions: false,
}
}
},
@@ -354,6 +371,7 @@ interface HeaderData {
spec: ValueSpec
edited: boolean
new: boolean
newOptions?: boolean
invalid?: boolean
}

View File

@@ -1,10 +0,0 @@
<div class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label [color]="params.titleColor" style="font-size: xx-large; font-weight: bold;">
{{ params.title }}
</ion-label>
</div>
<div class="long-message" [innerHTML]="params.message | markdown"></div>
</div>
</div>

View File

@@ -1,20 +0,0 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject, Subject } from 'rxjs'
@Component({
selector: 'alert',
templateUrl: './alert.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class AlertComponent {
@Input() params: {
title: string
message: string
titleColor: string
}
loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>()
load () { }
}

View File

@@ -1,4 +0,0 @@
<div *ngIf="loading$ | async" class="center-spinner">
<ion-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">{{ message }}</ion-label>
</div>

View File

@@ -1,50 +0,0 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject, from, Subject } from 'rxjs'
import { takeUntil } from 'rxjs/operators'
import { capitalizeFirstLetter } from '@start9labs/shared'
import { markAsLoadingDuring$ } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'complete',
templateUrl: './complete.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class CompleteComponent {
@Input() params: {
action: WizardAction
verb: string // loader verb: '*stopping* ...'
title: string
executeAction: () => Promise<any>
}
@Input() transitions: {
cancel: () => any
next: (prevResult?: any) => any
final: () => any
error: (e: Error) => any
}
loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>()
message: string
load() {
markAsLoadingDuring$(this.loading$, from(this.params.executeAction()))
.pipe(takeUntil(this.cancel$))
.subscribe({
error: e =>
this.transitions.error(
new Error(`${this.params.action} failed: ${e.message || e}`),
),
complete: () => this.transitions.final(),
})
}
ngOnInit() {
this.message = `${capitalizeFirstLetter(this.params.verb)} ${
this.params.title
}...`
}
}

View File

@@ -1,37 +0,0 @@
<div *ngIf="loading$ | async" class="center-spinner">
<ion-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">
Checking for installed services which depend on
{{ params.title }}...
</ion-label>
</div>
<div *ngIf="!(loading$ | async) && !!dependentViolation" class="slide-content">
<div class="wrapper">
<div class="warning">
<ion-label color="warning" class="label">WARNING</ion-label>
</div>
<div class="long-message">
{{ dependentViolation }}
</div>
<div *ngIf="pkgs$ | async as pkgs" class="items">
<div class="affected">
<ion-text color="warning">Affected Services</ion-text>
</div>
<ion-item
style="--ion-item-background: margin-top: 5px"
*ngFor="let dep of dependentBreakages | keyvalue"
>
<ion-thumbnail class="thumbnail" slot="start">
<img alt="" [src]="pkgs[dep.key]['static-files'].icon" />
</ion-thumbnail>
<ion-label>
<h5>{{ pkgs[dep.key].manifest.title }}</h5>
</ion-label>
</ion-item>
</div>
</div>
</div>

View File

@@ -1,35 +0,0 @@
.wrapper {
margin-top: 25px;
}
.warning {
margin: 15px;
display: flex;
justify-content: center;
align-items: center;
}
.label {
font-size: xx-large;
font-weight: bold;
}
.items {
margin: 25px 0;
}
.affected {
border-width: 0 0 1px 0;
font-size: unset;
text-align: left;
font-weight: bold;
margin-left: 13px;
border-style: solid;
border-color: var(--ion-color-light-tint);
}
.thumbnail {
position: relative;
height: 4vh;
width: 4vh;
}

View File

@@ -1,74 +0,0 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject, from, Subject } from 'rxjs'
import { takeUntil, tap } from 'rxjs/operators'
import { Breakages } from 'src/app/services/api/api.types'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { capitalizeFirstLetter, isEmptyObject } from '@start9labs/shared'
import { markAsLoadingDuring$ } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'dependents',
templateUrl: './dependents.component.html',
styleUrls: [
'./dependents.component.scss',
'../install-wizard.component.scss',
],
})
export class DependentsComponent {
@Input() params: {
title: string
action: WizardAction //Are you sure you want to *uninstall*...,
verb: string // *Uninstalling* will cause problems...
fetchBreakages: () => Promise<Breakages>
}
@Input() transitions: {
cancel: () => any
next: (prevResult?: any) => any
final: () => any
error: (e: Error) => any
}
dependentBreakages: Breakages
dependentViolation: string | undefined
loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>()
readonly pkgs$ = this.patch.watch$('package-data')
constructor(public readonly patch: PatchDbService) {}
load() {
markAsLoadingDuring$(this.loading$, from(this.params.fetchBreakages()))
.pipe(
takeUntil(this.cancel$),
tap(breakages => (this.dependentBreakages = breakages)),
)
.subscribe({
complete: () => {
console.log('DEP BREAKS, ', this.dependentBreakages)
if (
this.dependentBreakages &&
!isEmptyObject(this.dependentBreakages)
) {
this.dependentViolation = `${capitalizeFirstLetter(
this.params.verb,
)} ${
this.params.title
} will prohibit the following services from functioning properly and may cause them to stop if they are currently running.`
} else {
this.transitions.next()
}
},
error: (e: Error) =>
this.transitions.error(
new Error(
`Fetching dependent service information failed: ${
e.message || e
}`,
),
),
})
}
}

View File

@@ -1,67 +0,0 @@
<ion-header style="height: 12vh">
<ion-toolbar>
<ion-label class="toolbar-label text-ellipses">
<h1 class="toolbar-title">{{ params.toolbar.title }}</h1>
<h3 style="font-size: large; font-style: italic">{{ params.toolbar.action }} <ion-text style="font-size: medium;">{{ params.toolbar.version | displayEmver }}</ion-text></h3>
</ion-label>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-slides *ngIf="!error" id="slide-show" style="--bullet-background: white" pager="false">
<ion-slide *ngFor="let def of params.slideDefinitions">
<!-- We can pass [transitions]="transitions" into the component if logic within the component needs to trigger a transition (not just bottom bar) -->
<alert #components *ngIf="def.slide.selector === 'alert'" [params]="def.slide.params" style="width: 100%;"></alert>
<notes #components *ngIf="def.slide.selector === 'notes'" [params]="def.slide.params" style="width: 100%;"></notes>
<dependents #components *ngIf="def.slide.selector === 'dependents'" [params]="def.slide.params" [transitions]="transitions"></dependents>
<complete #components *ngIf="def.slide.selector === 'complete'" [params]="def.slide.params" [transitions]="transitions"></complete>
</ion-slide>
</ion-slides>
<div *ngIf="error" class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label color="danger" style="font-size: xx-large; font-weight: bold;">
Error
</ion-label>
</div>
<div class="long-message">
{{ error }}
</div>
</div>
</div>
</ion-content>
<ion-footer>
<ion-toolbar style="padding: 8px;">
<ng-container *ngIf="!initializing && !error">
<!-- cancel button if loading/not loading -->
<ion-button slot="start" *ngIf="(currentSlide.loading$ | async) && currentBottomBar.cancel.whileLoading as cancel" (click)="transitions.cancel()" class="toolbar-button" fill="outline">
<ion-text *ngIf="cancel.text" [class.smaller-text]="cancel.text.length > 16">{{ cancel.text }}</ion-text>
<ion-icon *ngIf="!cancel.text" name="close"></ion-icon>
</ion-button>
<ion-button slot="start" *ngIf="!(currentSlide.loading$ | async) && currentBottomBar.cancel.afterLoading as cancel" (click)="transitions.cancel()" class="toolbar-button" fill="outline">
<ion-text *ngIf="cancel.text" [class.smaller-text]="cancel.text.length > 16">{{ cancel.text }}</ion-text>
<ion-icon *ngIf="!cancel.text" name="close"></ion-icon>
</ion-button>
<!-- next/finish buttons -->
<ng-container *ngIf="!(currentSlide.loading$ | async)">
<!-- next -->
<ion-button slot="end" *ngIf="currentBottomBar.next as next" (click)="callTransition(transitions.next)" fill="outline" class="toolbar-button enter-click" [class.no-click]="transitioning">
<ion-text [class.smaller-text]="next.length > 16">{{ next }}</ion-text>
</ion-button>
<!-- finish -->
<ion-button slot="end" *ngIf="currentBottomBar.finish as finish" (click)="callTransition(transitions.final)" fill="outline" class="toolbar-button enter-click" [class.no-click]="transitioning">
<ion-text [class.smaller-text]="finish.length > 16">{{ finish }}</ion-text>
</ion-button>
</ng-container>
</ng-container>
<ng-container *ngIf="error">
<ion-button slot="start" (click)="transitions.final()" style="text-transform: capitalize; font-weight: bolder;" color="danger">Dismiss</ion-button>
</ng-container>
</ion-toolbar>
</ion-footer>

View File

@@ -1,86 +0,0 @@
.toolbar-label {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
color: white;
padding: 8px 0px 8px 15px;
}
.toolbar-title {
font-size: x-large;
text-transform: capitalize;
border-style: solid;
border-width: 0px 0px 1px 0px;
border-color: #404040;
font-family: 'Montserrat';
}
.center-spinner {
min-height: 40vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
color:white;
}
.slide-content {
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
color:white;
min-height: 40vh
}
.notes-content {
text-align: left;
margin: 32px;
}
.status-label {
font-size: xx-large;
font-weight: bold;
}
.long-message {
margin-left: 5%;
margin-right: 5%;
padding: 10px;
font-size: small;
border-width: 0px 0px 1px 0px;
border-color: #393b40;
text-align: left;
}
@media (min-width:500px) {
.long-message {
margin-left: 5%;
margin-right: 5%;
padding: 10px;
font-size: medium;
border-width: 0px 0px 1px 0px;
border-color: #393b40;
text-align: left;
}
}
.toolbar-button {
text-transform: capitalize;
font-weight: bolder;
}
.smaller-text {
font-size: 14px;
}
.badge {
position: absolute;
width: 2vh;
height: 2vh;
border-radius: 50px;
left: -0.75vh;
top: -0.75vh;
}

View File

@@ -1,159 +0,0 @@
import {
Component,
Input,
NgZone,
QueryList,
ViewChild,
ViewChildren,
} from '@angular/core'
import { IonContent, IonSlides, ModalController } from '@ionic/angular'
import { capitalizeFirstLetter, pauseFor } from '@start9labs/shared'
import { CompleteComponent } from './complete/complete.component'
import { DependentsComponent } from './dependents/dependents.component'
import { AlertComponent } from './alert/alert.component'
import { NotesComponent } from './notes/notes.component'
import { Loadable } from './loadable'
import { WizardAction } from './wizard-types'
@Component({
selector: 'install-wizard',
templateUrl: './install-wizard.component.html',
styleUrls: ['./install-wizard.component.scss'],
})
export class InstallWizardComponent {
transitioning = false
@Input() params: {
// defines each slide along with bottom bar
slideDefinitions: SlideDefinition[]
toolbar: TopbarParams
}
// content container so we can scroll to top between slide transitions
@ViewChild(IonContent) contentContainer: IonContent
// slide container gives us hook into ion-slide, allowing for slide transitions
@ViewChild(IonSlides) slideContainer: IonSlides
//a slide component gives us hook into a slide. Allows us to call load when slide comes into view
@ViewChildren('components')
slideComponentsQL: QueryList<Loadable>
get slideComponents(): Loadable[] {
return this.slideComponentsQL.toArray()
}
private slideIndex = 0
get currentSlide(): Loadable {
return this.slideComponents[this.slideIndex]
}
get currentBottomBar(): SlideDefinition['bottomBar'] {
return this.params.slideDefinitions[this.slideIndex].bottomBar
}
initializing = true
error = ''
constructor(
private readonly modalController: ModalController,
private readonly zone: NgZone,
) {}
ngAfterViewInit() {
this.currentSlide.load()
this.slideContainer.update()
this.slideContainer.lockSwipes(true)
}
ionViewDidEnter() {
this.initializing = false
}
// process bottom bar buttons
private transition = (
info:
| { next: any }
| { error: Error }
| { cancelled: true }
| { final: true },
) => {
const i = info as {
next?: any
error?: Error
cancelled?: true
final?: true
}
if (i.cancelled) this.currentSlide.cancel$.next()
if (i.final || i.cancelled) return this.modalController.dismiss(i)
if (i.error) return (this.error = capitalizeFirstLetter(i.error.message))
this.moveToNextSlide(i.next)
}
// bottom bar button callbacks. Pass this into components if they need to trigger slide transitions independent of the bottom bar clicks
transitions = {
next: (prevResult: any) =>
this.transition({ next: prevResult || this.currentSlide.result }),
cancel: () => this.transition({ cancelled: true }),
final: () => this.transition({ final: true }),
error: (e: Error) => this.transition({ error: e }),
}
private async moveToNextSlide(prevResult?: any) {
if (this.slideComponents[this.slideIndex + 1] === undefined) {
return this.transition({ final: true })
}
this.zone.run(async () => {
this.slideComponents[this.slideIndex + 1].load(prevResult)
await pauseFor(50) // give the load ^ opportunity to propogate into slide before sliding it into view
this.slideIndex += 1
await this.slideContainer.lockSwipes(false)
await this.contentContainer.scrollToTop()
await this.slideContainer.slideNext(500)
await this.slideContainer.lockSwipes(true)
})
}
async callTransition(transition: Function) {
this.transitioning = true
await transition()
this.transitioning = false
}
}
export interface SlideDefinition {
slide:
| { selector: 'dependents'; params: DependentsComponent['params'] }
| { selector: 'complete'; params: CompleteComponent['params'] }
| { selector: 'alert'; params: AlertComponent['params'] }
| { selector: 'notes'; params: NotesComponent['params'] }
bottomBar: {
cancel: {
// indicates the existence of a cancel button, and whether to have text or an icon 'x' by default.
afterLoading?: { text?: string }
whileLoading?: { text?: string }
}
// indicates the existence of next or finish buttons (should only have one)
next?: string
finish?: string
}
}
export type TopbarParams = {
action: WizardAction
title: string
version: string
}
export async function wizardModal(
modalController: ModalController,
params: InstallWizardComponent['params'],
): Promise<{ cancelled?: true; final?: true; modal: HTMLIonModalElement }> {
const modal = await modalController.create({
backdropDismiss: false,
cssClass: 'wizard-modal',
component: InstallWizardComponent,
componentProps: { params },
})
await modal.present()
return modal.onWillDismiss().then(({ data }) => ({ ...data, modal }))
}

View File

@@ -1,24 +0,0 @@
import { BehaviorSubject, Observable, Subject } from 'rxjs'
import { concatMap, finalize } from 'rxjs/operators'
import { fromSync$, emitAfter$ } from 'src/app/util/rxjs.util'
export interface Loadable {
load: (prevResult?: any) => void
result?: any // fill this variable on slide 1 to get passed into the load on slide 2. If this variable is falsey, it will skip the next slide.
loading$: BehaviorSubject<boolean> // will be true during load function
cancel$: Subject<void> // will cancel load function
}
export function markAsLoadingDuring$<T> (trigger$: Subject<boolean>, o: Observable<T>): Observable<T> {
let shouldBeOn = true
const displayIfItsBeenAtLeast = 5 // ms
return fromSync$(() => {
emitAfter$(displayIfItsBeenAtLeast).subscribe(() => { if (shouldBeOn) trigger$.next(true) })
}).pipe(
concatMap(() => o),
finalize(() => {
trigger$.next(false)
shouldBeOn = false
}),
)
}

View File

@@ -1,16 +0,0 @@
<div class="slide-content">
<div class="notes-content">
<h1>{{ params.title }}</h1>
<br />
<div *ngFor="let v of params.versions">
<h4>
<b>
{{ v.version }}
</b>
</h4>
<hr style="height: 0; border-width: 1px" />
<div [innerHTML]="v.notes | markdown"></div>
<br />
</div>
</div>
</div>

View File

@@ -1,395 +0,0 @@
import { Inject, Injectable } from '@angular/core'
import { exists } from '@start9labs/shared'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { Breakages } from 'src/app/services/api/api.types'
import { ApiService } from '../../services/api/embassy-api.service'
import {
InstallWizardComponent,
SlideDefinition,
TopbarParams,
} from './install-wizard.component'
import { ConfigService } from 'src/app/services/config.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { first } from 'rxjs/operators'
@Injectable({ providedIn: 'root' })
export class WizardBaker {
constructor(
private readonly embassyApi: ApiService,
private readonly config: ConfigService,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
) {}
update(values: {
id: string
title: string
version: string
installAlert?: string
}): InstallWizardComponent['params'] {
const { id, title, version, installAlert } = values
const action = 'update'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: Array<SlideDefinition | undefined> = [
installAlert
? {
slide: {
selector: 'alert',
params: {
title: 'Warning',
message: installAlert,
titleColor: 'warning',
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Next',
},
}
: undefined,
{
slide: {
selector: 'dependents',
params: {
action,
verb: 'updating',
title,
fetchBreakages: () =>
this.embassyApi
.dryUpdatePackage({ id, version })
.then(breakages => breakages),
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Update Anyway',
},
},
{
slide: {
selector: 'complete',
params: {
action,
verb: 'beginning update for',
title,
executeAction: () =>
this.marketplaceService
.installPackage({
id,
'version-spec': version ? `=${version}` : undefined,
})
.pipe(first())
.toPromise(),
},
},
bottomBar: {
cancel: { whileLoading: {} },
finish: 'Dismiss',
},
},
]
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
}
updateOS(values: {
version: string
releaseNotes: { [version: string]: string }
headline: string
}): InstallWizardComponent['params'] {
const { version, releaseNotes, headline } = values
const versions = Object.keys(releaseNotes)
.sort()
.reverse()
.map(version => {
return {
version,
notes: releaseNotes[version],
}
})
const action = 'update'
const title = 'EmbassyOS'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{
slide: {
selector: 'notes',
params: {
versions,
title: 'Release Notes',
titleColor: 'dark',
headline,
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Begin Update',
},
},
{
slide: {
selector: 'complete',
params: {
action,
verb: 'beginning update for',
title,
executeAction: () =>
this.embassyApi.updateServer({
'marketplace-url': this.config.marketplace.url,
}),
},
},
bottomBar: {
cancel: { whileLoading: {} },
finish: 'Dismiss',
},
},
]
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
}
downgrade(values: {
id: string
title: string
version: string
installAlert?: string
}): InstallWizardComponent['params'] {
const { id, title, version, installAlert } = values
const action = 'downgrade'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: Array<SlideDefinition | undefined> = [
installAlert
? {
slide: {
selector: 'alert',
params: {
title: 'Warning',
message: installAlert,
titleColor: 'warning',
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Next',
},
}
: undefined,
{
slide: {
selector: 'dependents',
params: {
action,
verb: 'downgrading',
title,
fetchBreakages: () =>
this.embassyApi
.dryUpdatePackage({ id, version })
.then(breakages => breakages),
},
},
bottomBar: {
cancel: {
whileLoading: {},
afterLoading: { text: 'Cancel' },
},
next: 'Downgrade Anyway',
},
},
{
slide: {
selector: 'complete',
params: {
action,
verb: 'beginning downgrade for',
title,
executeAction: () =>
this.marketplaceService
.installPackage({
id,
'version-spec': version ? `=${version}` : undefined,
})
.pipe(first())
.toPromise(),
},
},
bottomBar: {
cancel: { whileLoading: {} },
finish: 'Dismiss',
},
},
]
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
}
uninstall(values: {
id: string
title: string
version: string
uninstallAlert?: string
}): InstallWizardComponent['params'] {
const { id, title, version, uninstallAlert } = values
const action = 'uninstall'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{
slide: {
selector: 'alert',
params: {
title: 'Warning',
message: uninstallAlert || defaultUninstallWarning(title),
titleColor: 'warning',
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Continue',
},
},
{
slide: {
selector: 'dependents',
params: {
action,
verb: 'uninstalling',
title,
fetchBreakages: () =>
this.embassyApi
.dryUninstallPackage({ id })
.then(breakages => breakages),
},
},
bottomBar: {
cancel: {
whileLoading: {},
afterLoading: { text: 'Cancel' },
},
next: 'Uninstall',
},
},
{
slide: {
selector: 'complete',
params: {
action,
verb: 'uninstalling',
title,
executeAction: () => this.embassyApi.uninstallPackage({ id }),
},
},
bottomBar: {
finish: 'Dismiss',
cancel: {
whileLoading: {},
},
},
},
]
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
}
stop(values: {
id: string
title: string
version: string
}): InstallWizardComponent['params'] {
const { title, version, id } = values
const action = 'stop'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{
slide: {
selector: 'dependents',
params: {
action,
verb: 'stopping',
title,
fetchBreakages: () =>
this.embassyApi
.dryStopPackage({ id })
.then(breakages => breakages),
},
},
bottomBar: {
cancel: {
whileLoading: {},
afterLoading: { text: 'Cancel' },
},
next: 'Stop Anyway',
},
},
{
slide: {
selector: 'complete',
params: {
action,
verb: 'stopping',
title,
executeAction: () => this.embassyApi.stopPackage({ id }),
},
},
bottomBar: {
finish: 'Dismiss',
cancel: {
whileLoading: {},
},
},
},
]
return { toolbar, slideDefinitions }
}
configure(values: {
pkg: PackageDataEntry
breakages: Breakages
}): InstallWizardComponent['params'] {
const { breakages, pkg } = values
const { title, version } = pkg.manifest
const action = 'configure'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{
slide: {
selector: 'dependents',
params: {
action,
verb: 'saving config for',
title,
fetchBreakages: () => Promise.resolve(breakages),
},
},
bottomBar: {
cancel: {
afterLoading: { text: 'Cancel' },
},
next: 'Save Config Anyway',
},
},
]
return { toolbar, slideDefinitions }
}
}
const defaultUninstallWarning = (serviceName: string) =>
`Uninstalling ${serviceName} will result in the deletion of its data.`

View File

@@ -1,7 +0,0 @@
export type WizardAction =
'install'
| 'update'
| 'downgrade'
| 'uninstall'
| 'stop'
| 'configure'

View File

@@ -1,21 +1,11 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-title>Config</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>Config</ion-title>
<ion-buttons
*ngIf="!loadingText && !loadingError && hasConfig"
slot="end"
class="ion-padding-end"
>
<ion-button fill="clear" (click)="resetDefaults()">
<ion-icon slot="start" name="refresh"></ion-icon>
Reset Defaults
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
@@ -91,7 +81,7 @@
[objectSpec]="configSpec"
[formGroup]="configForm"
[current]="configForm.value"
[showEdited]="true"
[original]="original"
></form-object>
</form>
</ng-template>
@@ -100,6 +90,16 @@
<ion-footer>
<ion-toolbar>
<ion-buttons
*ngIf="!loadingText && !loadingError && hasConfig"
slot="start"
class="ion-padding-start"
>
<ion-button fill="clear" (click)="resetDefaults()">
<ion-icon slot="start" name="refresh"></ion-icon>
Reset Defaults
</ion-button>
</ion-buttons>
<ion-buttons
*ngIf="!loadingText && !loadingError"
slot="end"

View File

@@ -10,12 +10,11 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
ErrorToastService,
getErrorMessage,
isEmptyObject,
isObject,
} from '@start9labs/shared'
import { DependentInfo } from 'src/app/types/dependent-info'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { wizardModal } from 'src/app/components/app-wizard/app-wizard.component'
import { WizardDefs } from 'src/app/components/app-wizard/wizard-defs'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
@@ -46,7 +45,7 @@ export class AppConfigPage {
loadingError: string | IonicSafeString
constructor(
private readonly wizardBaker: WizardBaker,
private readonly wizards: WizardDefs,
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
@@ -138,6 +137,13 @@ export class AppConfigPage {
return
}
const hasDependents = !!Object.keys(
this.pkg?.installed?.['current-dependents'] || {},
).filter(depId => depId !== this.pkgId).length
const config = this.configForm.value
if (!hasDependents) {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: `Saving config. This could take a while...`,
@@ -147,24 +153,6 @@ export class AppConfigPage {
this.saving = true
try {
const config = this.configForm.value
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config,
})
if (!isEmptyObject(breakages['length']) && this.pkg) {
const { cancelled } = await wizardModal(
this.modalCtrl,
this.wizardBaker.configure({
pkg: this.pkg,
breakages,
}),
)
if (cancelled) return
}
await this.embassyApi.setPackageConfig({
id: this.pkgId,
config,
@@ -176,6 +164,19 @@ export class AppConfigPage {
this.saving = false
loader.dismiss()
}
} else {
const success = await wizardModal(
this.modalCtrl,
this.wizards.configure({
manifest: this.pkg!.manifest,
config,
}),
)
if (success) {
this.modalCtrl.dismiss()
}
}
}
private getDiff(patch: Operation[]): string[] {

View File

@@ -14,8 +14,8 @@ import {
PackageDataEntry,
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { wizardModal } from 'src/app/components/app-wizard/app-wizard.component'
import { WizardDefs } from 'src/app/components/app-wizard/wizard-defs'
import { Subscription } from 'rxjs'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { isEmptyObject, ErrorToastService, getPkgId } from '@start9labs/shared'
@@ -39,7 +39,7 @@ export class AppActionsPage {
private readonly alertCtrl: AlertController,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
private readonly wizardBaker: WizardBaker,
private readonly wizards: WizardDefs,
private readonly navCtrl: NavController,
private readonly patch: PatchDbService,
) {}
@@ -137,20 +137,20 @@ export class AppActionsPage {
}
async uninstall() {
const { id, title, version, alerts } = this.pkg.manifest
const data = await wizardModal(
const { id, title, alerts } = this.pkg.manifest
const success = await wizardModal(
this.modalCtrl,
this.wizardBaker.uninstall({
this.wizards.uninstall({
id,
title,
version,
uninstallAlert: alerts.uninstall || undefined,
}),
)
if (data.cancelled) return
if (success) {
return this.navCtrl.navigateRoot('/services')
}
}
private async executeAction(
actionId: string,

View File

@@ -4,7 +4,7 @@ import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppShowPage } from './app-show.page'
import { EmverPipesModule } from '@start9labs/shared'
import { InstallWizardComponentModule } from 'src/app/components/install-wizard/install-wizard.component.module'
import { AppWizardComponentModule } from 'src/app/components/app-wizard/app-wizard.component.module'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { AppConfigPageModule } from 'src/app/modals/app-config/app-config.module'
import { LaunchablePipeModule } from 'src/app/pipes/launchable/launchable.module'
@@ -50,7 +50,7 @@ const routes: Routes = [
StatusComponentModule,
IonicModule,
RouterModule.forChild(routes),
InstallWizardComponentModule,
AppWizardComponentModule,
AppConfigPageModule,
EmverPipesModule,
LaunchablePipeModule,

View File

@@ -12,15 +12,14 @@ import {
Status,
} from 'src/app/services/patch-db/data-model'
import { ErrorToastService } from '@start9labs/shared'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { wizardModal } from 'src/app/components/app-wizard/app-wizard.component'
import { WizardDefs } from 'src/app/components/app-wizard/wizard-defs'
import {
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ModalService } from 'src/app/services/modal.service'
import { DependencyInfo } from '../../pipes/to-dependencies.pipe'
@@ -51,8 +50,7 @@ export class AppShowStatusComponent {
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
private readonly wizardBaker: WizardBaker,
private readonly patch: PatchDbService,
private readonly wizards: WizardDefs,
private readonly launcherService: UiLauncherService,
private readonly modalService: ModalService,
) {}
@@ -108,7 +106,7 @@ export class AppShowStatusComponent {
}
async stop(): Promise<void> {
const { id, title, version } = this.pkg.manifest
const { id, title } = this.pkg.manifest
const hasDependents = !!Object.keys(
this.pkg.installed?.['current-dependents'] || {},
).filter(depId => depId !== id).length
@@ -130,10 +128,9 @@ export class AppShowStatusComponent {
} else {
wizardModal(
this.modalCtrl,
this.wizardBaker.stop({
this.wizards.stop({
id,
title,
version,
}),
)
}

View File

@@ -11,8 +11,8 @@ import {
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { wizardModal } from 'src/app/components/app-wizard/app-wizard.component'
import { WizardDefs } from 'src/app/components/app-wizard/wizard-defs'
import { LocalStorageService } from 'src/app/services/local-storage.service'
@Component({
@@ -33,7 +33,7 @@ export class MarketplaceShowControlsComponent {
constructor(
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly wizardBaker: WizardBaker,
private readonly wizards: WizardDefs,
private readonly navCtrl: NavController,
private readonly marketplaceService: AbstractMarketplaceService,
public readonly localStorageService: LocalStorageService,
@@ -81,16 +81,11 @@ export class MarketplaceShowControlsComponent {
installAlert: alerts.install || undefined,
}
const { cancelled } = await wizardModal(
wizardModal(
this.modalCtrl,
action === 'update'
? this.wizardBaker.update(value)
: this.wizardBaker.downgrade(value),
? this.wizards.update(value)
: this.wizards.downgrade(value),
)
if (cancelled) return
await pauseFor(250)
this.navCtrl.back()
}
}

View File

@@ -14,7 +14,7 @@ import {
AdditionalModule,
DependenciesModule,
} from '@start9labs/marketplace'
import { InstallWizardComponentModule } from 'src/app/components/install-wizard/install-wizard.component.module'
import { AppWizardComponentModule } from 'src/app/components/app-wizard/app-wizard.component.module'
import { MarketplaceStatusModule } from '../marketplace-status/marketplace-status.module'
import { MarketplaceShowPage } from './marketplace-show.page'
@@ -39,7 +39,7 @@ const routes: Routes = [
EmverPipesModule,
MarkdownPipeModule,
MarketplaceStatusModule,
InstallWizardComponentModule,
AppWizardComponentModule,
PackageModule,
AboutModule,
DependenciesModule,

View File

@@ -11,8 +11,8 @@ import { ActivatedRoute } from '@angular/router'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { Observable, of } from 'rxjs'
import { filter, map, take } from 'rxjs/operators'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { wizardModal } from 'src/app/components/app-wizard/app-wizard.component'
import { WizardDefs } from 'src/app/components/app-wizard/wizard-defs'
import { exists, isEmptyObject, ErrorToastService } from '@start9labs/shared'
import { EOSService } from 'src/app/services/eos.service'
import { LocalStorageService } from 'src/app/services/local-storage.service'
@@ -32,7 +32,7 @@ export class ServerShowPage {
constructor(
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly wizardBaker: WizardBaker,
private readonly wizards: WizardDefs,
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly embassyApi: ApiService,
@@ -70,7 +70,7 @@ export class ServerShowPage {
await wizardModal(
this.modalCtrl,
this.wizardBaker.updateOS({
this.wizards.updateOS({
version,
headline,
releaseNotes,

View File

@@ -55,8 +55,7 @@ export module Mock {
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: null,
stop: 'Stopping Bitcoin is bad for your health.',
start: 'Starting Bitcoin is good for your health.',
},
main: {
type: 'docker',
@@ -354,7 +353,6 @@ export module Mock {
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,
},
main: {
type: 'docker',
@@ -499,7 +497,6 @@ export module Mock {
uninstall: null,
restore: null,
start: null,
stop: null,
},
main: {
type: 'docker',
@@ -1662,10 +1659,8 @@ export module Mock {
},
}
export const MockConfig = {}
export const MockDependencyConfig = {
testnet: true,
export const MockConfig = {
testnet: undefined,
'object-list': [
{
'first-name': 'First',
@@ -1691,19 +1686,19 @@ export module Mock {
law1: 'The first law Amended',
law2: 'The second law',
},
rpcpass: null,
rpcpass: undefined,
rpcuser: '123',
rulemakers: [],
},
advanced: {
notifications: ['email', 'text', 'push'],
},
'bitcoin-node': undefined,
port: 20,
rpcallowip: undefined,
rpcauth: ['matt: 8273gr8qwoidm1uid91jeh8y23gdio1kskmwejkdnm'],
advanced: undefined,
}
export const MockDependencyConfig = MockConfig
export const bitcoind: PackageDataEntry = {
state: PackageState.Installed,
'static-files': {

View File

@@ -11,7 +11,7 @@ import {
export const mockPatchData: DataModel = {
ui: {
name: `Matt's Embassy`,
'auto-check-updates': false,
'auto-check-updates': true,
'pkg-order': [],
'ack-welcome': '1.0.0',
},
@@ -67,8 +67,7 @@ export const mockPatchData: DataModel = {
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: null,
stop: 'Stopping Bitcoin is bad for your health.',
start: 'Starting Bitcoin is good for your health.',
},
main: {
type: 'docker',
@@ -449,7 +448,6 @@ export const mockPatchData: DataModel = {
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,
},
main: {
type: 'docker',

View File

@@ -1,3 +1,6 @@
@import '~swiper/scss';
@import '~@ionic/angular/css/ionic-swiper';
@font-face {
font-family: 'Montserrat';
font-style: normal;
@@ -51,6 +54,12 @@
$subheader-height: 48px;
.swiper {
.swiper-slide {
display: unset;
}
}
.subheader-padding {
--padding-top: #{$subheader-height} + 10px;
}
@@ -209,13 +218,6 @@ ion-button {
}
}
ion-slides {
.slider-wrapper {
height: 100%;
width: 100%;
}
}
ion-back-button {
--background: var(--ion-color-light);
--color: var(--ion-color-dark);