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-checkbox></ion-checkbox>
<ion-content></ion-content>
<ion-fab></ion-fab>
<ion-fab-button></ion-fab-button>
<ion-footer></ion-footer>
<ion-grid></ion-grid>
<ion-header></ion-header>

View File

@@ -1,17 +1,27 @@
<ion-header>
<ion-toolbar>
<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-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-button (click)="submit()">Preview</ion-button>
<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>

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 { ActivatedRoute } from '@angular/router'
import { ModalController } from '@ionic/angular'
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 { ConfigSpec } from '../../../pkg-config/config-types'
import { ErrorToastService } from '../../../services/error-toast.service'
@Component({
@@ -11,21 +15,31 @@ import { ErrorToastService } from '../../../services/error-toast.service'
styleUrls: ['dev-config.page.scss'],
})
export class DevConfigPage {
projectId: string
editorOptions = { theme: 'vs-dark', language: 'yaml' }
code: string
code: string = ''
saving: boolean = false
constructor(
private readonly route: ActivatedRoute,
private readonly errToast: ErrorToastService,
private readonly modalCtrl: ModalController,
private readonly patchDb: PatchDbService,
private readonly api: ApiService,
) {}
ngOnInit() {
this.code = yaml
.dump(SAMPLE_CODE)
.replace(/warning:/g, '# Optional\n warning:')
this.projectId = this.route.snapshot.paramMap.get('projectId')
this.patchDb
.watch$('ui', 'dev', this.projectId, 'config')
.pipe(take(1))
.subscribe(config => {
this.code = config
})
}
async submit() {
async preview() {
let doc: any
try {
doc = yaml.load(this.code)
@@ -51,56 +65,21 @@ export class DevConfigPage {
})
await modal.present()
}
}
const SAMPLE_CODE: 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',
},
@debounce(1000)
async save() {
this.saving = true
try {
await this.api.setDbValue({
pointer: `/dev/${this.projectId}/config`,
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

@@ -1,17 +1,27 @@
<ion-header>
<ion-toolbar>
<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-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-button (click)="submit()">Preview</ion-button>
<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>

View File

@@ -1,5 +1,11 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
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'
@Component({
@@ -8,12 +14,31 @@ import { MarkdownPage } from '../../../modals/markdown/markdown.page'
styleUrls: ['dev-instructions.page.scss'],
})
export class DevInstructionsPage {
projectId: string
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({
componentProps: {
title: 'Instructions Sample',
@@ -24,4 +49,21 @@ export class DevInstructionsPage {
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 { IonicModule } from '@ionic/angular'
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 { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
const routes: Routes = [
{
path: '',
component: DeveloperPage,
component: DeveloperListPage,
},
]
@@ -21,6 +21,6 @@ const routes: Routes = [
BadgeMenuComponentModule,
BackupReportPageModule,
],
declarations: [DeveloperPage],
declarations: [DeveloperListPage],
})
export class DeveloperPageModule {}
export class DeveloperListPageModule {}

View File

@@ -4,23 +4,36 @@
<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>Components</ion-item-divider>
<ion-item button detail (click)="navToInstructions()">
<ion-icon slot="start" name="list-outline"></ion-icon>
<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>
<h2>Instructions Generator</h2>
<p>Create instructions and see how they will appear to the end user</p>
<ion-text color="dark">Create project</ion-text>
</ion-label>
</ion-item>
<ion-item button detail (click)="navToConfig()">
<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
button
*ngFor="let entry of devData | keyvalue"
(click)="goToProject(entry.key)"
>
<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-content>

View File

@@ -1,23 +1,211 @@
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 { NavController } from '@ionic/angular'
import { DestroyService } from '@start9labs/shared'
import { takeUntil } from 'rxjs/operators'
@Component({
selector: 'developer-list',
templateUrl: 'developer-list.page.html',
styleUrls: ['developer-list.page.scss'],
providers: [DestroyService],
})
export class DeveloperPage {
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 navCtrl: NavController,
private readonly route: ActivatedRoute,
private readonly destroy$: DestroyService,
private readonly patch: PatchDbService,
) {}
navToConfig() {
this.navCtrl.navigateForward(['config'], { relativeTo: this.route })
ngOnInit() {
this.patch
.watch$('ui', 'dev')
.pipe(takeUntil(this.destroy$))
.subscribe(dd => {
this.devData = dd
})
}
navToInstructions() {
this.navCtrl.navigateForward(['instructions'], { relativeTo: this.route })
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}`,
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 = [
{
path: '',
redirectTo: 'projects',
pathMatch: 'full',
},
{
path: 'projects',
loadChildren: () =>
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: () =>
import('./dev-config/dev-config.module').then(m => m.DevConfigPageModule),
},
{
path: 'instructions',
path: 'projects/:projectId/instructions',
loadChildren: () =>
import('./dev-instructions/dev-instructions.module').then(
m => m.DevInstructionsPageModule,

View File

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

View File

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