Feature/save code (#1222)

* Persist dev projects and auto save on edit

Co-authored-by: Drew Ansbacher <drew.ansbacher@spiredigital.com>
Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
This commit is contained in:
Drew Ansbacher
2022-02-16 14:55:16 -07:00
committed by GitHub
parent b57c35a3ba
commit d204c1dfba
16 changed files with 433 additions and 92 deletions

View File

@@ -185,8 +185,6 @@
<ion-card-header></ion-card-header> <ion-card-header></ion-card-header>
<ion-checkbox></ion-checkbox> <ion-checkbox></ion-checkbox>
<ion-content></ion-content> <ion-content></ion-content>
<ion-fab></ion-fab>
<ion-fab-button></ion-fab-button>
<ion-footer></ion-footer> <ion-footer></ion-footer>
<ion-grid></ion-grid> <ion-grid></ion-grid>
<ion-header></ion-header> <ion-header></ion-header>

View File

@@ -1,17 +1,27 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-buttons slot="start"> <ion-buttons slot="start">
<ion-back-button defaultHref="/developer"></ion-back-button> <ion-back-button
[defaultHref]="'/developer/projects/' + projectId"
></ion-back-button>
</ion-buttons> </ion-buttons>
<ion-title>Config</ion-title> <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-buttons slot="end">
<ion-button (click)="submit()">Preview</ion-button> <ion-button (click)="preview()">Preview</ion-button>
</ion-buttons> </ion-buttons>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content> <ion-content>
<ngx-monaco-editor <ngx-monaco-editor
(keyup)="save()"
[options]="editorOptions" [options]="editorOptions"
[(ngModel)]="code" [(ngModel)]="code"
></ngx-monaco-editor> ></ngx-monaco-editor>

View File

@@ -0,0 +1,7 @@
.centered{
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transform: scale(2);
}

View File

@@ -1,8 +1,12 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular' import { ModalController } from '@ionic/angular'
import * as yaml from 'js-yaml' import * as yaml from 'js-yaml'
import { take } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { debounce } from '../../../../../../shared/src/util/misc.util'
import { GenericFormPage } from '../../../modals/generic-form/generic-form.page' import { GenericFormPage } from '../../../modals/generic-form/generic-form.page'
import { ConfigSpec } from '../../../pkg-config/config-types'
import { ErrorToastService } from '../../../services/error-toast.service' import { ErrorToastService } from '../../../services/error-toast.service'
@Component({ @Component({
@@ -11,21 +15,31 @@ import { ErrorToastService } from '../../../services/error-toast.service'
styleUrls: ['dev-config.page.scss'], styleUrls: ['dev-config.page.scss'],
}) })
export class DevConfigPage { export class DevConfigPage {
projectId: string
editorOptions = { theme: 'vs-dark', language: 'yaml' } editorOptions = { theme: 'vs-dark', language: 'yaml' }
code: string code: string = ''
saving: boolean = false
constructor( constructor(
private readonly route: ActivatedRoute,
private readonly errToast: ErrorToastService, private readonly errToast: ErrorToastService,
private readonly modalCtrl: ModalController, private readonly modalCtrl: ModalController,
private readonly patchDb: PatchDbService,
private readonly api: ApiService,
) {} ) {}
ngOnInit() { ngOnInit() {
this.code = yaml this.projectId = this.route.snapshot.paramMap.get('projectId')
.dump(SAMPLE_CODE)
.replace(/warning:/g, '# Optional\n warning:') this.patchDb
.watch$('ui', 'dev', this.projectId, 'config')
.pipe(take(1))
.subscribe(config => {
this.code = config
})
} }
async submit() { async preview() {
let doc: any let doc: any
try { try {
doc = yaml.load(this.code) doc = yaml.load(this.code)
@@ -51,56 +65,21 @@ export class DevConfigPage {
}) })
await modal.present() await modal.present()
} }
}
const SAMPLE_CODE: ConfigSpec = { @debounce(1000)
'sample-string': { async save() {
type: 'string', this.saving = true
name: 'Example String Input', try {
nullable: false, await this.api.setDbValue({
masked: false, pointer: `/dev/${this.projectId}/config`,
copyable: false, value: this.code,
// optional })
warning: null, } catch (e) {
description: 'Example description for required string input.', this.errToast.present({
default: null, message: 'Auto save error: Your changes are not saved.',
placeholder: 'Enter string value', } as any)
pattern: '^[a-zA-Z0-9! _]+$', } finally {
'pattern-description': 'Must be alphanumeric (may contain underscore).', this.saving = false
}, }
'sample-number': { }
type: 'number',
name: 'Example Number Input',
nullable: false,
range: '[5,1000000]',
integral: true,
// optional
warning: 'Example warning to display when changing this number value.',
units: 'ms',
description: 'Example description for optional number input.',
default: null,
placeholder: 'Enter number value',
},
'sample-boolean': {
type: 'boolean',
name: 'Example Boolean Toggle',
// optional
warning: null,
description: 'Example description for boolean toggle',
default: true,
},
'sample-enum': {
type: 'enum',
name: 'Example Enum Select',
values: ['red', 'blue', 'green'],
'value-names': {
red: 'Red',
blue: 'Blue',
green: 'Green',
},
// optional
warning: 'Example warning to display when changing this enum value.',
description: 'Example description for enum select',
default: 'red',
},
} }

View File

@@ -1,17 +1,27 @@
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-buttons slot="start"> <ion-buttons slot="start">
<ion-back-button defaultHref="/developer"></ion-back-button> <ion-back-button
[defaultHref]="'/developer/projects/' + projectId"
></ion-back-button>
</ion-buttons> </ion-buttons>
<ion-title>Instructions</ion-title> <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-buttons slot="end">
<ion-button (click)="submit()">Preview</ion-button> <ion-button (click)="preview()">Preview</ion-button>
</ion-buttons> </ion-buttons>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content> <ion-content>
<ngx-monaco-editor <ngx-monaco-editor
(keyup)="save()"
[options]="editorOptions" [options]="editorOptions"
[(ngModel)]="code" [(ngModel)]="code"
></ngx-monaco-editor> ></ngx-monaco-editor>

View File

@@ -1,5 +1,11 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular' import { ModalController } from '@ionic/angular'
import { take } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { debounce } from '../../../../../../shared/src/util/misc.util'
import { MarkdownPage } from '../../../modals/markdown/markdown.page' import { MarkdownPage } from '../../../modals/markdown/markdown.page'
@Component({ @Component({
@@ -8,12 +14,31 @@ import { MarkdownPage } from '../../../modals/markdown/markdown.page'
styleUrls: ['dev-instructions.page.scss'], styleUrls: ['dev-instructions.page.scss'],
}) })
export class DevInstructionsPage { export class DevInstructionsPage {
projectId: string
editorOptions = { theme: 'vs-dark', language: 'markdown' } editorOptions = { theme: 'vs-dark', language: 'markdown' }
code: string = `# Create Instructions using Markdown! :)` code: string = ''
saving: boolean = false
constructor(private readonly modalCtrl: ModalController) {} constructor(
private readonly route: ActivatedRoute,
private readonly errToast: ErrorToastService,
private readonly modalCtrl: ModalController,
private readonly patchDb: PatchDbService,
private readonly api: ApiService,
) {}
async submit() { ngOnInit() {
this.projectId = this.route.snapshot.paramMap.get('projectId')
this.patchDb
.watch$('ui', 'dev', this.projectId, 'instructions')
.pipe(take(1))
.subscribe(config => {
this.code = config
})
}
async preview() {
const modal = await this.modalCtrl.create({ const modal = await this.modalCtrl.create({
componentProps: { componentProps: {
title: 'Instructions Sample', title: 'Instructions Sample',
@@ -24,4 +49,21 @@ export class DevInstructionsPage {
await modal.present() await modal.present()
} }
@debounce(1000)
async save() {
this.saving = true
try {
await this.api.setDbValue({
pointer: `/dev/${this.projectId}/instructions`,
value: this.code,
})
} catch (e) {
this.errToast.present({
message: 'Auto save error: Your changes are not saved.',
} as any)
} finally {
this.saving = false
}
}
} }

View File

@@ -2,14 +2,14 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router' import { RouterModule, Routes } from '@angular/router'
import { DeveloperPage } from './developer-list.page' import { DeveloperListPage } from './developer-list.page'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module' 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 { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
const routes: Routes = [ const routes: Routes = [
{ {
path: '', path: '',
component: DeveloperPage, component: DeveloperListPage,
}, },
] ]
@@ -21,6 +21,6 @@ const routes: Routes = [
BadgeMenuComponentModule, BadgeMenuComponentModule,
BackupReportPageModule, BackupReportPageModule,
], ],
declarations: [DeveloperPage], declarations: [DeveloperListPage],
}) })
export class DeveloperPageModule {} export class DeveloperListPageModule {}

View File

@@ -4,23 +4,36 @@
<ion-back-button></ion-back-button> <ion-back-button></ion-back-button>
</ion-buttons> </ion-buttons>
<ion-title>Developer Tools</ion-title> <ion-title>Developer Tools</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content> <ion-content>
<ion-item-divider>Components</ion-item-divider> <ion-item-divider>Projects</ion-item-divider>
<ion-item button detail (click)="navToInstructions()">
<ion-icon slot="start" name="list-outline"></ion-icon> <ion-item button detail="false" (click)="openCreateProjectModal()">
<ion-icon slot="start" name="add" color="dark"></ion-icon>
<ion-label> <ion-label>
<h2>Instructions Generator</h2> <ion-text color="dark">Create project</ion-text>
<p>Create instructions and see how they will appear to the end user</p>
</ion-label> </ion-label>
</ion-item> </ion-item>
<ion-item button detail (click)="navToConfig()">
<ion-icon slot="start" name="construct-outline"></ion-icon> <ion-item
<ion-label> button
<h2>Config Generator</h2> *ngFor="let entry of devData | keyvalue"
<p>Edit the config with YAML and see it in real time</p> (click)="goToProject(entry.key)"
</ion-label> >
<p>{{ entry.value.name }}</p>
<ion-button
slot="end"
fill="clear"
color="danger"
(click)="presentAlertDelete(entry.key, $event)"
>
<ion-icon slot="start" name="close"></ion-icon>
Delete
</ion-button>
</ion-item> </ion-item>
</ion-content> </ion-content>

View File

@@ -1,23 +1,211 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import {
AlertController,
LoadingController,
ModalController,
NavController,
} from '@ionic/angular'
import {
GenericInputComponent,
GenericInputOptions,
} from 'src/app/modals/generic-input/generic-input.component'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import * as yaml from 'js-yaml'
import { v4 } from 'uuid'
import { DevData } from 'src/app/services/patch-db/data-model'
import { Subscription } from 'rxjs'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute } from '@angular/router'
import { NavController } from '@ionic/angular' import { DestroyService } from '@start9labs/shared'
import { takeUntil } from 'rxjs/operators'
@Component({ @Component({
selector: 'developer-list', selector: 'developer-list',
templateUrl: 'developer-list.page.html', templateUrl: 'developer-list.page.html',
styleUrls: ['developer-list.page.scss'], styleUrls: ['developer-list.page.scss'],
providers: [DestroyService],
}) })
export class DeveloperPage { export class DeveloperListPage {
devData: DevData
constructor( constructor(
private readonly modalCtrl: ModalController,
private readonly api: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController,
private readonly navCtrl: NavController, private readonly navCtrl: NavController,
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly destroy$: DestroyService,
private readonly patch: PatchDbService,
) {} ) {}
navToConfig() { ngOnInit() {
this.navCtrl.navigateForward(['config'], { relativeTo: this.route }) this.patch
.watch$('ui', 'dev')
.pipe(takeUntil(this.destroy$))
.subscribe(dd => {
this.devData = dd
})
} }
navToInstructions() { async openCreateProjectModal() {
this.navCtrl.navigateForward(['instructions'], { relativeTo: this.route }) 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}`,
nullable: true,
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 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({
spinner: 'lines',
message: 'Creating Project...',
cssClass: 'loader',
})
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 }
if (this.devData) {
await this.api.setDbValue({ pointer: `/dev/${id}`, value: def })
} else {
await this.api.setDbValue({ pointer: `/dev`, value: { [id]: def } })
}
} catch (e) {
this.errToast.present({ message: `Error saving project data` } as any)
} finally {
loader.dismiss()
}
}
async presentAlertDelete(id: string, event: Event) {
event.stopPropagation()
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 delete(id: string) {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Removing Project...',
cssClass: 'loader',
})
await loader.present()
try {
const devDataToSave: DevData = JSON.parse(JSON.stringify(this.devData))
delete devDataToSave[id]
await this.api.setDbValue({ pointer: `/dev`, value: devDataToSave })
} catch (e) {
this.errToast.present({ message: `Error deleting project data` } as any)
} finally {
loader.dismiss()
}
}
async goToProject(id: string) {
await this.navCtrl.navigateForward([id], { relativeTo: this.route })
} }
} }
const SAMPLE_INSTUCTIONS = `# Create Instructions using Markdown! :)`
const SAMPLE_CONFIG: ConfigSpec = {
'sample-string': {
type: 'string',
name: 'Example String Input',
nullable: false,
masked: false,
copyable: false,
// optional
warning: null,
description: 'Example description for required string input.',
default: null,
placeholder: 'Enter string value',
pattern: '^[a-zA-Z0-9! _]+$',
'pattern-description': 'Must be alphanumeric (may contain underscore).',
},
'sample-number': {
type: 'number',
name: 'Example Number Input',
nullable: false,
range: '[5,1000000]',
integral: true,
// optional
warning: 'Example warning to display when changing this number value.',
units: 'ms',
description: 'Example description for optional number input.',
default: null,
placeholder: 'Enter number value',
},
'sample-boolean': {
type: 'boolean',
name: 'Example Boolean Toggle',
// optional
warning: null,
description: 'Example description for boolean toggle',
default: true,
},
'sample-enum': {
type: 'enum',
name: 'Example Enum Select',
values: ['red', 'blue', 'green'],
'value-names': {
red: 'Red',
blue: 'Blue',
green: 'Green',
},
// optional
warning: 'Example warning to display when changing this enum value.',
description: 'Example description for enum select',
default: 'red',
},
}

View File

@@ -0,0 +1,26 @@
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'
const routes: Routes = [
{
path: '',
component: DeveloperMenuPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
BadgeMenuComponentModule,
BackupReportPageModule,
],
declarations: [DeveloperMenuPage],
})
export class DeveloperMenuPageModule {}

View File

@@ -0,0 +1,26 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/developer"></ion-back-button>
</ion-buttons>
<ion-title>{{ patchDb.data.ui.dev[projectId].name}}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item-divider>Components</ion-item-divider>
<ion-item button detail routerLink="instructions" routerDirection="forward">
<ion-icon slot="start" name="list-outline"></ion-icon>
<ion-label>
<h2>Instructions Generator</h2>
<p>Create instructions and see how they will appear to the end user</p>
</ion-label>
</ion-item>
<ion-item button detail routerLink="config">
<ion-icon slot="start" name="construct-outline"></ion-icon>
<ion-label>
<h2>Config Generator</h2>
<p>Edit the config with YAML and see it in real time</p>
</ion-label>
</ion-item>
</ion-content>

View File

@@ -0,0 +1,20 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
@Component({
selector: 'developer-menu',
templateUrl: 'developer-menu.page.html',
styleUrls: ['developer-menu.page.scss'],
})
export class DeveloperMenuPage {
projectId: string
constructor(
private readonly route: ActivatedRoute,
public readonly patchDb: PatchDbService,
) {}
ngOnInit() {
this.projectId = this.route.snapshot.paramMap.get('projectId')
}
}

View File

@@ -4,18 +4,30 @@ import { Routes, RouterModule } from '@angular/router'
const routes: Routes = [ const routes: Routes = [
{ {
path: '', path: '',
redirectTo: 'projects',
pathMatch: 'full',
},
{
path: 'projects',
loadChildren: () => loadChildren: () =>
import('./developer-list/developer-list.module').then( import('./developer-list/developer-list.module').then(
m => m.DeveloperPageModule, m => m.DeveloperListPageModule,
), ),
}, },
{ {
path: 'config', path: 'projects/:projectId',
loadChildren: () =>
import('./developer-menu/developer-menu.module').then(
m => m.DeveloperMenuPageModule,
),
},
{
path: 'projects/:projectId/config',
loadChildren: () => loadChildren: () =>
import('./dev-config/dev-config.module').then(m => m.DevConfigPageModule), import('./dev-config/dev-config.module').then(m => m.DevConfigPageModule),
}, },
{ {
path: 'instructions', path: 'projects/:projectId/instructions',
loadChildren: () => loadChildren: () =>
import('./dev-instructions/dev-instructions.module').then( import('./dev-instructions/dev-instructions.module').then(
m => m.DevInstructionsPageModule, m => m.DevInstructionsPageModule,

View File

@@ -15,6 +15,7 @@ export const mockPatchData: DataModel = {
'pkg-order': [], 'pkg-order': [],
'ack-welcome': '1.0.0', 'ack-welcome': '1.0.0',
marketplace: undefined, marketplace: undefined,
dev: undefined,
}, },
'server-info': { 'server-info': {
id: 'embassy-abcdefgh', id: 'embassy-abcdefgh',

View File

@@ -14,6 +14,7 @@ export interface UIData {
'pkg-order': string[] 'pkg-order': string[]
'ack-welcome': string // EOS version 'ack-welcome': string // EOS version
marketplace: UIMarketplaceData marketplace: UIMarketplaceData
dev: DevData
} }
export interface UIMarketplaceData { export interface UIMarketplaceData {
@@ -26,6 +27,14 @@ export interface UIMarketplaceData {
} }
} }
export interface DevData {
[id: string]: {
name: string
instructions?: string
config?: string
}
}
export interface ServerInfo { export interface ServerInfo {
id: string id: string
version: string version: string