port 040 config (#2657)

* port 040 config, WIP

* update fixtures

* use taiga modal for backups too

* fix: update Taiga UI and refactor everything to work

* chore: package-lock

* fix interfaces and mocks for interfaces

* better mocks

* function to transform old spec to new

* delete unused fns

* delete unused FE config utils

* fix exports from sdk

* reorganize exports

* functions to translate config

* rename unionSelectKey and unionValueKey

* Adding in the transformation of the getConfig to the new types.

* chore: add Taiga UI to preloader

---------

Co-authored-by: waterplea <alexander@inkin.ru>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: J H <dragondef@gmail.com>
This commit is contained in:
Matt Hill
2024-07-10 11:58:02 -06:00
committed by GitHub
parent 822dd5e100
commit f76e822381
173 changed files with 9761 additions and 9200 deletions

View File

@@ -1,21 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular'
import { AppConfigPage } from './app-config.page'
import { TextSpinnerComponentModule } from '@start9labs/shared'
import { FormObjectComponentModule } from 'src/app/components/form-object/form-object.component.module'
@NgModule({
declarations: [AppConfigPage],
imports: [
CommonModule,
FormsModule,
IonicModule,
TextSpinnerComponentModule,
FormObjectComponentModule,
ReactiveFormsModule,
],
exports: [AppConfigPage],
})
export class AppConfigPageModule {}

View File

@@ -1,144 +0,0 @@
<ion-header>
<ion-toolbar>
<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-toolbar>
</ion-header>
<ion-content class="ion-padding" *ngIf="pkg.stateInfo.manifest as manifest">
<!-- loading -->
<text-spinner
*ngIf="loading; else notLoading"
[text]="loadingText"
></text-spinner>
<!-- not loading -->
<ng-template #notLoading>
<ion-item *ngIf="loadingError; else noError">
<ion-label>
<ion-text color="danger">{{ loadingError }}</ion-text>
</ion-label>
</ion-item>
<ng-template #noError>
<ng-container *ngIf="configForm && !pkg.status.configured">
<ng-container *ngIf="!original; else hasOriginal">
<h2
*ngIf="!configForm.dirty"
class="ion-padding-bottom header-details"
>
<ion-text color="success">
{{ manifest.title }} has been automatically configured with
recommended defaults. Make whatever changes you want, then click
"Save".
</ion-text>
</h2>
</ng-container>
<ng-template #hasOriginal>
<h2 *ngIf="hasNewOptions" class="ion-padding-bottom header-details">
<ion-text color="success">
New config options! To accept the default values, click "Save".
You may also customize these new options below.
</ion-text>
</h2>
</ng-template>
</ng-container>
<!-- auto-config -->
<ion-item
lines="none"
*ngIf="dependentInfo"
class="rec-item"
style="margin-bottom: 48px"
>
<ion-label>
<h2 style="display: flex; align-items: center">
<img
style="width: 18px; margin: 4px"
[src]="pkg.icon"
[alt]="manifest.title"
/>
<ion-text
style="margin: 5px; font-family: 'Montserrat'; font-size: 18px"
>
{{ manifest.title }}
</ion-text>
</h2>
<p>
<ion-text color="dark">
The following modifications have been made to {{ manifest.title }}
to satisfy {{ dependentInfo.title }}:
<ul>
<li *ngFor="let d of diff" [innerHtml]="d"></li>
</ul>
To accept these modifications, click "Save".
</ion-text>
</p>
</ion-label>
</ion-item>
<!-- no options -->
<ion-item *ngIf="!hasConfig">
<ion-label>
<p>
No config options for {{ manifest.title }} {{ manifest.version }}.
</p>
</ion-label>
</ion-item>
<!-- has config -->
<form
*ngIf="configForm && configSpec"
[formGroup]="configForm"
novalidate
>
<form-object
[objectSpec]="configSpec"
[formGroup]="configForm"
[current]="configForm.value"
[original]="original"
(hasNewOptions)="hasNewOptions = true"
></form-object>
</form>
</ng-template>
</ng-template>
</ion-content>
<ion-footer>
<ion-toolbar>
<ng-container *ngIf="!loading && !loadingError">
<ion-buttons *ngIf="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 slot="end" class="ion-padding-end">
<ion-button
*ngIf="hasConfig"
fill="solid"
color="primary"
[disabled]="saving"
(click)="tryConfigure()"
class="enter-click btn-128"
[class.no-click]="saving"
>
Save
</ion-button>
<ion-button
*ngIf="!hasConfig"
fill="solid"
color="dark"
(click)="dismiss()"
class="enter-click btn-128"
>
Close
</ion-button>
</ion-buttons>
</ng-container>
</ion-toolbar>
</ion-footer>

View File

@@ -1,12 +0,0 @@
.notifier-item {
margin: 12px;
margin-top: 0px;
border-radius: 12px;
// kills the lines
--border-width: 0;
--inner-border-width: 0;
}
.header-details {
font-size: 20px;
}

View File

@@ -1,349 +0,0 @@
import { Component, Input } from '@angular/core'
import {
AlertController,
ModalController,
LoadingController,
IonicSafeString,
} from '@ionic/angular'
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 { ConfigSpec } from 'src/app/pkg-config/config-types'
import {
DataModel,
InstalledState,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import { UntypedFormGroup } from '@angular/forms'
import {
convertValuesRecursive,
FormService,
} from 'src/app/services/form.service'
import { compare, Operation, getValueByPointer } from 'fast-json-patch'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import {
getAllPackages,
getManifest,
getPackage,
isInstalled,
} from 'src/app/util/get-package-data'
import { Breakages } from 'src/app/services/api/api.types'
@Component({
selector: 'app-config',
templateUrl: './app-config.page.html',
styleUrls: ['./app-config.page.scss'],
})
export class AppConfigPage {
@Input() pkgId!: string
@Input() dependentInfo?: DependentInfo
pkg!: PackageDataEntry<InstalledState>
loadingText = ''
configSpec?: ConfigSpec
configForm?: UntypedFormGroup
original?: object // only if existing config
diff?: string[] // only if dependent info
loading = true
hasNewOptions = false
saving = false
loadingError: string | IonicSafeString = ''
constructor(
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly formService: FormService,
private readonly patch: PatchDB<DataModel>,
) {}
get hasConfig() {
return this.pkg.stateInfo.manifest.hasConfig
}
async ngOnInit() {
try {
const pkg = await getPackage(this.patch, this.pkgId)
if (!pkg || !isInstalled(pkg)) return
this.pkg = pkg
if (!this.hasConfig) return
let newConfig: object | undefined
let patch: Operation[] | undefined
if (this.dependentInfo) {
this.loadingText = `Setting properties to accommodate ${this.dependentInfo.title}`
const {
oldConfig: oc,
newConfig: nc,
spec: s,
} = await this.embassyApi.dryConfigureDependency({
dependencyId: this.pkgId,
dependentId: this.dependentInfo.id,
})
this.original = oc
newConfig = nc
this.configSpec = s
patch = compare(this.original, newConfig)
} else {
this.loadingText = 'Loading Config'
const { config: c, spec: s } = await this.embassyApi.getPackageConfig({
id: this.pkgId,
})
this.original = c
this.configSpec = s
}
this.configForm = this.formService.createForm(
this.configSpec,
newConfig || this.original,
)
if (patch) {
this.diff = this.getDiff(patch)
this.markDirty(patch)
}
} catch (e: any) {
this.loadingError = getErrorMessage(e)
} finally {
this.loading = false
}
}
resetDefaults() {
this.configForm = this.formService.createForm(this.configSpec!)
const patch = compare(this.original || {}, this.configForm.value)
this.markDirty(patch)
}
async dismiss() {
if (this.configForm?.dirty) {
this.presentAlertUnsaved()
} else {
this.modalCtrl.dismiss()
}
}
async tryConfigure() {
convertValuesRecursive(this.configSpec!, this.configForm!)
if (this.configForm!.invalid) {
document
.getElementsByClassName('validation-error')[0]
?.scrollIntoView({ behavior: 'smooth' })
return
}
this.saving = true
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patch))) {
this.dryConfigure()
} else {
this.configure()
}
}
private async dryConfigure() {
const loader = await this.loadingCtrl.create({
message: 'Checking dependent services...',
})
await loader.present()
try {
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config: this.configForm!.value,
})
if (isEmptyObject(breakages)) {
this.configure(loader)
} else {
await loader.dismiss()
const proceed = await this.presentAlertBreakages(breakages)
if (proceed) {
this.configure()
} else {
this.saving = false
}
}
} catch (e: any) {
this.errToast.present(e)
this.saving = false
loader.dismiss()
}
}
private async configure(loader?: HTMLIonLoadingElement) {
const message = 'Saving...'
if (loader) {
loader.message = message
} else {
loader = await this.loadingCtrl.create({ message })
await loader.present()
}
try {
await this.embassyApi.setPackageConfig({
id: this.pkgId,
config: this.configForm!.value,
})
this.modalCtrl.dismiss()
} catch (e: any) {
this.errToast.present(e)
} finally {
this.saving = false
loader.dismiss()
}
}
private async presentAlertBreakages(breakages: Breakages): Promise<boolean> {
let message: string =
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
const localPkgs = await getAllPackages(this.patch)
const bullets = Object.keys(breakages).map(id => {
const title = getManifest(localPkgs[id]).title
return `<li><b>${title}</b></li>`
})
message = `${message}${bullets}</ul>`
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'Warning',
message,
buttons: [
{
text: 'Cancel',
role: 'cancel',
handler: () => {
resolve(false)
},
},
{
text: 'Continue',
handler: () => {
resolve(true)
},
cssClass: 'enter-click',
},
],
cssClass: 'alert-warning-message',
})
await alert.present()
})
}
private getDiff(patch: Operation[]): string[] {
return patch.map(op => {
let message: string
switch (op.op) {
case 'add':
message = `Added ${this.getNewValue(op.value)}`
break
case 'remove':
message = `Removed ${this.getOldValue(op.path)}`
break
case 'replace':
message = `Changed from ${this.getOldValue(
op.path,
)} to ${this.getNewValue(op.value)}`
break
default:
message = `Unknown operation`
}
let displayPath: string
const arrPath = op.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (typeof arrPath[arrPath.length - 1] === 'number') {
arrPath.pop()
}
displayPath = arrPath.join(' &rarr; ')
return `${displayPath}: ${message}`
})
}
private getOldValue(path: any): string {
const val = getValueByPointer(this.original, path)
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'entry'
} else {
return 'list'
}
}
private getNewValue(val: any): string {
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'new entry'
} else {
return 'new list'
}
}
private markDirty(patch: Operation[]) {
patch.forEach(op => {
const arrPath = op.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (op.op !== 'remove') this.configForm!.get(arrPath)?.markAsDirty()
if (typeof arrPath[arrPath.length - 1] === 'number') {
const prevPath = arrPath.slice(0, arrPath.length - 1)
this.configForm!.get(prevPath)?.markAsDirty()
}
})
}
private async presentAlertUnsaved() {
const alert = await this.alertCtrl.create({
header: 'Unsaved Changes',
message: 'You have unsaved changes. Are you sure you want to leave?',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: `Leave`,
handler: () => {
this.modalCtrl.dismiss()
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
}

View File

@@ -1,16 +1,12 @@
import { Component, Input } from '@angular/core'
import {
LoadingController,
ModalController,
IonicSafeString,
} from '@ionic/angular'
import { getErrorMessage } from '@start9labs/shared'
import { IonicSafeString, ModalController } from '@ionic/angular'
import { getErrorMessage, LoadingService } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { take } from 'rxjs'
import { BackupInfo } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDB } from 'patch-db-client'
import { AppRecoverOption } from './to-options.pipe'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { take } from 'rxjs'
import { AppRecoverOption } from './to-options.pipe'
@Component({
selector: 'app-recover-select',
@@ -30,7 +26,7 @@ export class AppRecoverSelectPage {
constructor(
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
private readonly loader: LoadingService,
private readonly embassyApi: ApiService,
private readonly patch: PatchDB<DataModel>,
) {}
@@ -45,10 +41,7 @@ export class AppRecoverSelectPage {
async restore(options: AppRecoverOption[]): Promise<void> {
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
const loader = await this.loadingCtrl.create({
message: 'Initializing...',
})
await loader.present()
const loader = this.loader.open('Initializing...').subscribe()
try {
await this.embassyApi.restorePackages({
@@ -61,7 +54,7 @@ export class AppRecoverSelectPage {
} catch (e: any) {
this.error = getErrorMessage(e)
} finally {
loader.dismiss()
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,104 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
} from '@angular/core'
import { compare, getValueByPointer, Operation } from 'fast-json-patch'
import { isObject } from '@start9labs/shared'
import { tuiIsNumber } from '@taiga-ui/cdk'
import { CommonModule } from '@angular/common'
import { TuiNotificationModule } from '@taiga-ui/core'
@Component({
selector: 'config-dep',
template: `
<tui-notification>
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
{{ package }}
</h3>
The following modifications have been made to {{ package }} to satisfy
{{ dep }}:
<ul>
<li *ngFor="let d of diff" [innerHTML]="d"></li>
</ul>
To accept these modifications, click "Save".
</tui-notification>
`,
standalone: true,
imports: [CommonModule, TuiNotificationModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConfigDepComponent implements OnChanges {
@Input()
package = ''
@Input()
dep = ''
@Input()
original: object = {}
@Input()
value: object = {}
diff: string[] = []
ngOnChanges() {
this.diff = compare(this.original, this.value).map(
op => `${this.getPath(op)}: ${this.getMessage(op)}`,
)
}
private getPath(operation: Operation): string {
const path = operation.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (tuiIsNumber(path[path.length - 1])) {
path.pop()
}
return path.join(' &rarr; ')
}
private getMessage(operation: Operation): string {
switch (operation.op) {
case 'add':
return `Added ${this.getNewValue(operation.value)}`
case 'remove':
return `Removed ${this.getOldValue(operation.path)}`
case 'replace':
return `Changed from ${this.getOldValue(
operation.path,
)} to ${this.getNewValue(operation.value)}`
default:
return `Unknown operation`
}
}
private getOldValue(path: any): string {
const val = getValueByPointer(this.original, path)
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'entry'
} else {
return 'list'
}
}
private getNewValue(val: any): string {
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'new entry'
} else {
return 'new list'
}
}
}

View File

@@ -0,0 +1,281 @@
import { CommonModule } from '@angular/common'
import { Component, Inject, ViewChild } from '@angular/core'
import {
ErrorService,
getErrorMessage,
isEmptyObject,
LoadingService,
} from '@start9labs/shared'
import { CT } from '@start9labs/start-sdk'
import { TuiButtonModule } from '@taiga-ui/experimental'
import {
TuiDialogContext,
TuiDialogService,
TuiLoaderModule,
TuiModeModule,
TuiNotificationModule,
} from '@taiga-ui/core'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
import { compare, Operation } from 'fast-json-patch'
import { PatchDB } from 'patch-db-client'
import { endWith, firstValueFrom, Subscription } from 'rxjs'
import { ActionButton, FormComponent } from 'src/app/components/form.component'
import { InvalidService } from 'src/app/components/form/invalid.service'
import { ConfigDepComponent } from 'src/app/modals/config-dep.component'
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import {
getAllPackages,
getManifest,
getPackage,
} from 'src/app/util/get-package-data'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { Breakages } from 'src/app/services/api/api.types'
import { DependentInfo } from 'src/app/types/dependent-info'
export interface PackageConfigData {
readonly pkgId: string
readonly dependentInfo?: DependentInfo
}
@Component({
template: `
<tui-loader
*ngIf="loadingText"
size="l"
[textContent]="loadingText"
></tui-loader>
<tui-notification
*ngIf="!loadingText && (loadingError || !pkg)"
status="error"
>
<div [innerHTML]="loadingError"></div>
</tui-notification>
<ng-container
*ngIf="
!loadingText && !loadingError && pkg && (pkg | toManifest) as manifest
"
>
<tui-notification *ngIf="success" status="success">
{{ manifest.title }} has been automatically configured with recommended
defaults. Make whatever changes you want, then click "Save".
</tui-notification>
<config-dep
*ngIf="dependentInfo && value && original"
[package]="manifest.title"
[dep]="dependentInfo.title"
[original]="original"
[value]="value"
></config-dep>
<tui-notification *ngIf="!manifest.hasConfig" status="warning">
No config options for {{ manifest.title }} {{ manifest.version }}.
</tui-notification>
<app-form
tuiMode="onDark"
[spec]="spec"
[value]="value || {}"
[buttons]="buttons"
[patch]="patch"
>
<button
tuiButton
appearance="flat"
type="reset"
[style.margin-right]="'auto'"
>
Reset Defaults
</button>
</app-form>
</ng-container>
`,
styles: [
`
tui-notification {
font-size: 1rem;
margin-bottom: 1rem;
}
`,
],
standalone: true,
imports: [
CommonModule,
FormComponent,
TuiLoaderModule,
TuiNotificationModule,
TuiButtonModule,
TuiModeModule,
ConfigDepComponent,
UiPipeModule,
],
providers: [InvalidService],
})
export class ConfigModal {
@ViewChild(FormComponent)
private readonly form?: FormComponent<Record<string, any>>
readonly pkgId = this.context.data.pkgId
readonly dependentInfo = this.context.data.dependentInfo
loadingError = ''
loadingText = this.dependentInfo
? `Setting properties to accommodate ${this.dependentInfo.title}`
: 'Loading Config'
pkg?: PackageDataEntry
spec: CT.InputSpec = {}
patch: Operation[] = []
buttons: ActionButton<any>[] = [
{
text: 'Save',
handler: value => this.save(value),
},
]
original: object | null = null
value: object | null = null
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<void, PackageConfigData>,
private readonly dialogs: TuiDialogService,
private readonly errorService: ErrorService,
private readonly loader: LoadingService,
private readonly embassyApi: ApiService,
private readonly patchDb: PatchDB<DataModel>,
) {}
get success(): boolean {
return (
!!this.form &&
!this.form.form.dirty &&
!this.original &&
!this.pkg?.status?.configured
)
}
async ngOnInit() {
try {
this.pkg = await getPackage(this.patchDb, this.pkgId)
if (!this.pkg) {
this.loadingError = 'This service does not exist'
return
}
if (this.dependentInfo) {
const depConfig = await this.embassyApi.dryConfigureDependency({
dependencyId: this.pkgId,
dependentId: this.dependentInfo.id,
})
this.original = depConfig.oldConfig
this.value = depConfig.newConfig || this.original
this.spec = depConfig.spec
this.patch = compare(this.original, this.value)
} else {
const { config, spec } = await this.embassyApi.getPackageConfig({
id: this.pkgId,
})
this.original = config
this.value = config
this.spec = spec
}
} catch (e: any) {
this.loadingError = String(getErrorMessage(e))
} finally {
this.loadingText = ''
}
}
private async save(config: any) {
const loader = new Subscription()
try {
await this.uploadFiles(config, loader)
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patchDb))) {
await this.configureDeps(config, loader)
} else {
await this.configure(config, loader)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async uploadFiles(config: Record<string, any>, loader: Subscription) {
loader.unsubscribe()
loader.closed = false
// TODO: Could be nested files
const keys = Object.keys(config).filter(key => config[key] instanceof File)
const message = `Uploading File${keys.length > 1 ? 's' : ''}...`
if (!keys.length) return
loader.add(this.loader.open(message).subscribe())
const hashes = await Promise.all(
keys.map(key => this.embassyApi.uploadFile(config[key])),
)
keys.forEach((key, i) => (config[key] = hashes[i]))
}
private async configureDeps(
config: Record<string, any>,
loader: Subscription,
) {
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Checking dependent services...').subscribe())
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config,
})
loader.unsubscribe()
loader.closed = false
if (isEmptyObject(breakages) || (await this.approveBreakages(breakages))) {
await this.configure(config, loader)
}
}
private async configure(config: Record<string, any>, loader: Subscription) {
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Saving...').subscribe())
await this.embassyApi.setPackageConfig({ id: this.pkgId, config })
this.context.$implicit.complete()
}
private async approveBreakages(breakages: Breakages): Promise<boolean> {
const packages = await getAllPackages(this.patchDb)
const message =
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
const content = `${message}${Object.keys(breakages).map(
id => `<li><b>${getManifest(packages[id]).title}</b></li>`,
)}</ul>`
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
return firstValueFrom(
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
)
}
}

View File

@@ -1,12 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { EnumListPage } from './enum-list.page'
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [EnumListPage],
imports: [CommonModule, IonicModule, FormsModule],
exports: [EnumListPage],
})
export class EnumListPageModule {}

View File

@@ -1,45 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ spec.name }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-item-group>
<ion-item-divider style="padding-bottom: 6px">
<ion-buttons slot="end">
<ion-button fill="clear" (click)="toggleSelectAll()">
<b>{{ selectAll ? 'Select All' : 'Deselect All' }}</b>
</ion-button>
</ion-buttons>
</ion-item-divider>
<ion-item *ngFor="let option of options | keyvalue : asIsOrder">
<ion-label>{{ spec.spec['value-names'][option.key] }}</ion-label>
<ion-checkbox
slot="end"
[(ngModel)]="option.value"
(click)="toggleSelected(option.key)"
></ion-checkbox>
</ion-item>
</ion-item-group>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button
fill="solid"
color="primary"
(click)="save()"
class="enter-click btn-128"
>
Done
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -1,50 +0,0 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { ValueSpecListOf } from 'src/app/pkg-config/config-types'
@Component({
selector: 'enum-list',
templateUrl: './enum-list.page.html',
styleUrls: ['./enum-list.page.scss'],
})
export class EnumListPage {
@Input() key!: string
@Input() spec!: ValueSpecListOf<'enum'>
@Input() current: string[] = []
options: { [option: string]: boolean } = {}
selectAll = false
constructor(private readonly modalCtrl: ModalController) {}
ngOnInit() {
for (let val of this.spec.spec.values || []) {
this.options[val] = this.current.includes(val)
}
// if none are selected, set selectAll to true
this.selectAll = Object.values(this.options).some(k => !k)
}
dismiss() {
this.modalCtrl.dismiss()
}
save() {
this.modalCtrl.dismiss(
Object.keys(this.options).filter(key => this.options[key]),
)
}
toggleSelectAll() {
Object.keys(this.options).forEach(k => (this.options[k] = this.selectAll))
this.selectAll = !this.selectAll
}
toggleSelected(key: string) {
this.options[key] = !this.options[key]
}
asIsOrder() {
return 0
}
}

View File

@@ -1,19 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { GenericFormPage } from './generic-form.page'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { FormObjectComponentModule } from 'src/app/components/form-object/form-object.component.module'
@NgModule({
declarations: [GenericFormPage],
imports: [
CommonModule,
IonicModule,
FormsModule,
ReactiveFormsModule,
FormObjectComponentModule,
],
exports: [GenericFormPage],
})
export class GenericFormPageModule {}

View File

@@ -1,35 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ title }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form
[formGroup]="formGroup"
(ngSubmit)="handleClick(submitBtn.handler)"
novalidate
>
<form-object [objectSpec]="spec" [formGroup]="formGroup"></form-object>
<button hidden type="submit"></button>
</form>
</ion-content>
<ion-footer>
<ion-toolbar class="footer">
<ion-buttons slot="end">
<ion-button
class="ion-padding-end"
*ngFor="let button of buttons"
(click)="handleClick(button.handler)"
>
{{ button.text }}
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -1,9 +0,0 @@
button:disabled,
button[disabled]{
border: 1px solid #999999;
background-color: #cccccc;
color: #666666;
}
button {
color: var(--ion-color-primary);
}

View File

@@ -1,60 +0,0 @@
import { Component, Input } from '@angular/core'
import { UntypedFormGroup } from '@angular/forms'
import { ModalController } from '@ionic/angular'
import {
convertValuesRecursive,
FormService,
} from 'src/app/services/form.service'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
export interface ActionButton {
text: string
handler: (value: any) => Promise<boolean>
isSubmit?: boolean
}
@Component({
selector: 'generic-form',
templateUrl: './generic-form.page.html',
styleUrls: ['./generic-form.page.scss'],
})
export class GenericFormPage {
@Input() title!: string
@Input() spec!: ConfigSpec
@Input() buttons!: ActionButton[]
@Input() initialValue: object = {}
submitBtn!: ActionButton
formGroup!: UntypedFormGroup
constructor(
private readonly modalCtrl: ModalController,
private readonly formService: FormService,
) {}
ngOnInit() {
this.formGroup = this.formService.createForm(this.spec, this.initialValue)
this.submitBtn = this.buttons.find(btn => btn.isSubmit) || {
text: '',
handler: () => Promise.resolve(true),
}
}
async dismiss(): Promise<void> {
this.modalCtrl.dismiss()
}
async handleClick(handler: ActionButton['handler']): Promise<void> {
convertValuesRecursive(this.spec, this.formGroup)
if (this.formGroup.invalid) {
document
.getElementsByClassName('validation-error')[0]
?.scrollIntoView({ behavior: 'smooth' })
return
}
const success = await handler(this.formGroup.value)
if (success !== false) this.modalCtrl.dismiss()
}
}

View File

@@ -1,67 +0,0 @@
<ion-content>
<div
style="margin: 24px 24px 12px 24px; display: flex; flex-direction: column"
>
<ion-item style="padding-bottom: 8px">
<ion-label>
<h1>{{ options.title }}</h1>
<br />
<p>{{ options.message }}</p>
<ng-container *ngIf="options.warning">
<br />
<p>
<ion-text color="warning">{{ options.warning }}</ion-text>
</p>
</ng-container>
</ion-label>
</ion-item>
<form (ngSubmit)="submit()">
<div style="margin: 0 0 24px 16px">
<p class="input-label">{{ options.label }}</p>
<ion-item
lines="none"
[color]="(theme$ | async) === 'Light' ? 'light' : 'dark'"
>
<ion-input
#mainInput
type="text"
name="value"
[ngModel]="masked ? maskedValue : value"
(ngModelChange)="transformInput($event)"
[placeholder]="options.placeholder"
(ionChange)="error = ''"
></ion-input>
<ion-button
slot="end"
*ngIf="options.useMask"
fill="clear"
color="light"
(click)="toggleMask()"
>
<ion-icon
slot="icon-only"
[name]="!masked ? 'eye-off-outline' : 'eye-outline'"
size="small"
></ion-icon>
</ion-button>
</ion-item>
<!-- error -->
<p *ngIf="error">
<ion-text color="danger">{{ error }}</ion-text>
</p>
</div>
<div class="ion-text-right">
<ion-button fill="clear" (click)="cancel()">Cancel</ion-button>
<ion-button
fill="clear"
type="submit"
[disabled]="!value && !options.nullable"
>
{{ options.buttonText }}
</ion-button>
</div>
</form>
</div>
</ion-content>

View File

@@ -1,20 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { GenericInputComponent } from './generic-input.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharedPipesModule } from '@start9labs/shared'
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [GenericInputComponent],
imports: [
CommonModule,
IonicModule,
FormsModule,
RouterModule.forChild([]),
SharedPipesModule,
],
exports: [GenericInputComponent],
})
export class GenericInputComponentModule {}

View File

@@ -1,96 +0,0 @@
import { Component, inject, Input, ViewChild } from '@angular/core'
import { ModalController, IonicSafeString, IonInput } from '@ionic/angular'
import { getErrorMessage, THEME } from '@start9labs/shared'
import { MaskPipe } from 'src/app/pipes/mask/mask.pipe'
@Component({
selector: 'generic-input',
templateUrl: './generic-input.component.html',
styleUrls: ['./generic-input.component.scss'],
providers: [MaskPipe],
})
export class GenericInputComponent {
@ViewChild('mainInput') elem?: IonInput
@Input() options!: GenericInputOptions
value!: string
masked!: boolean
maskedValue?: string
error: string | IonicSafeString = ''
readonly theme$ = inject(THEME)
constructor(
private readonly modalCtrl: ModalController,
private readonly mask: MaskPipe,
) {}
ngOnInit() {
const defaultOptions: Partial<GenericInputOptions> = {
buttonText: 'Submit',
placeholder: 'Enter value',
nullable: false,
useMask: false,
initialValue: '',
}
this.options = {
...defaultOptions,
...this.options,
}
this.masked = !!this.options.useMask
this.value = this.options.initialValue || ''
}
ngAfterViewInit() {
setTimeout(() => this.elem?.setFocus(), 400)
}
toggleMask() {
this.masked = !this.masked
}
cancel() {
this.modalCtrl.dismiss()
}
transformInput(newValue: string) {
let i = 0
this.value = newValue
.split('')
.map(x => (x === '●' ? this.value[i++] : x))
.join('')
this.maskedValue = this.mask.transform(this.value)
}
async submit() {
const value = this.value.trim()
if (!value && !this.options.nullable) return
try {
await this.options.submitFn(value)
this.modalCtrl.dismiss(undefined, 'success')
} catch (e: any) {
this.error = getErrorMessage(e)
}
}
}
export interface GenericInputOptions {
// required
title: string
message: string
label: string
submitFn: (value: string) => Promise<any>
// optional
warning?: string
buttonText?: string
placeholder?: string
nullable?: boolean
useMask?: boolean
initialValue?: string
}

View File

@@ -1,17 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { MarketplaceSettingsPage } from './marketplace-settings.page'
import { SharedPipesModule } from '@start9labs/shared'
import { StoreIconComponentModule } from 'src/app/components/store-icon/store-icon.component.module'
@NgModule({
imports: [
CommonModule,
IonicModule,
SharedPipesModule,
StoreIconComponentModule,
],
declarations: [MarketplaceSettingsPage],
})
export class MarketplaceSettingsPageModule {}

View File

@@ -1,67 +1,30 @@
<ion-header>
<ion-toolbar>
<ion-title>Change Registry</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group *ngIf="stores$ | async as stores">
<ion-item-divider>Default Registries</ion-item-divider>
<ion-item
*ngFor="let s of stores.standard"
detail="false"
[button]="!s.selected"
(click)="s.selected ? '' : presentAction(s)"
<ng-container *ngIf="stores$ | async as stores">
<h3 class="g-title">Default Registries</h3>
<button
*ngFor="let registry of stores.standard"
tuiCell
[disabled]="registry.selected"
[registry]="registry"
(click)="connect(registry.url)"
></button>
<h3 class="g-title">Custom Registries</h3>
<button tuiCell (click)="add()" [style.width]="'-webkit-fill-available'">
<tui-icon icon="tuiIconPlus" [style.margin-inline.rem]="'0.5'"></tui-icon>
<div tuiTitle>Add custom registry</div>
</button>
<div *ngFor="let registry of stores.alt" class="connect-container">
<button
tuiCell
[registry]="registry"
(click)="connect(registry.url)"
></button>
<button
tuiIconButton
appearance="icon"
iconLeft="tuiIconTrash"
(click)="delete(registry.url, registry.name)"
>
<ion-avatar slot="start">
<store-icon [url]="s.url"></store-icon>
</ion-avatar>
<ion-label>
<h2>{{ s.name }}</h2>
<p>{{ s.url }}</p>
</ion-label>
<ion-icon
*ngIf="s.selected"
slot="end"
size="large"
name="checkmark"
color="success"
></ion-icon>
</ion-item>
<ion-item-divider>Custom Registries</ion-item-divider>
<ion-item button detail="false" (click)="presentModalAdd()">
<ion-icon slot="start" name="add" color="dark"></ion-icon>
<ion-label>
<ion-text color="dark">
<b>Add custom registry</b>
</ion-text>
</ion-label>
</ion-item>
<ion-item
*ngFor="let a of stores.alt"
detail="false"
[button]="!a.selected"
(click)="a.selected ? '' : presentAction(a, true)"
>
<store-icon slot="start" [url]="a.url" size="36px"></store-icon>
<ion-label>
<h2>{{ a.name }}</h2>
<p>{{ a.url }}</p>
</ion-label>
<ion-icon
*ngIf="a.selected"
slot="end"
size="large"
name="checkmark"
color="success"
></ion-icon>
</ion-item>
</ion-item-group>
</ion-content>
Delete
</button>
</div>
</ng-container>

View File

@@ -0,0 +1,5 @@
.connect-container {
display: flex;
flex-direction: row;
align-items: center;
}

View File

@@ -1,276 +1,230 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
ViewChild,
} from '@angular/core'
import {
ActionSheetController,
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import { ActionSheetButton } from '@ionic/core'
import { ErrorToastService, sameUrl, toUrl } from '@start9labs/shared'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ValueSpecObject } from 'src/app/pkg-config/config-types'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import {
ErrorService,
LoadingService,
sameUrl,
toUrl,
} from '@start9labs/shared'
import { CT } from '@start9labs/start-sdk'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import {
TuiButtonModule,
TuiCellModule,
TuiIconModule,
TuiTitleModule,
} from '@taiga-ui/experimental'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { PatchDB } from 'patch-db-client'
import { DataModel, UIStore } from 'src/app/services/patch-db/data-model'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { combineLatest, filter, firstValueFrom, Subscription } from 'rxjs'
import { map } from 'rxjs/operators'
import { combineLatest, firstValueFrom } from 'rxjs'
import { FormComponent } from 'src/app/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { DataModel, UIStore } from 'src/app/services/patch-db/data-model'
import { MarketplaceRegistryComponent } from './registry.component'
@Component({
standalone: true,
imports: [
CommonModule,
TuiCellModule,
TuiIconModule,
TuiTitleModule,
TuiButtonModule,
MarketplaceRegistryComponent,
],
selector: 'marketplace-settings',
templateUrl: 'marketplace-settings.page.html',
styleUrls: ['marketplace-settings.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarketplaceSettingsPage {
stores$ = combineLatest([
this.marketplaceService.getKnownHosts$(),
this.marketplaceService.getSelectedHost$(),
]).pipe(
map(([stores, selected]) => {
const toSlice = stores.map(s => ({
...s,
selected: sameUrl(s.url, selected.url),
}))
// 0 and 1 are prod and community
const standard = toSlice.slice(0, 2)
// 2 and beyond are alts
const alt = toSlice.slice(2)
return { standard, alt }
}),
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formDialog = inject(FormDialogService)
private readonly dialogs = inject(TuiDialogService)
private readonly marketplace = inject(
AbstractMarketplaceService,
) as MarketplaceService
private readonly hosts$ = inject(PatchDB<DataModel>).watch$(
'ui',
'marketplace',
'knownHosts',
)
constructor(
private readonly api: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly errToast: ErrorToastService,
private readonly actionCtrl: ActionSheetController,
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
private readonly patch: PatchDB<DataModel>,
private readonly alertCtrl: AlertController,
) {}
readonly stores$ = combineLatest([
this.marketplace.getKnownHosts$(),
this.marketplace.getSelectedHost$(),
]).pipe(
map(([stores, selected]) =>
stores.map(s => ({
...s,
selected: sameUrl(s.url, selected.url),
})),
),
// 0 and 1 are prod and community, 2 and beyond are alts
map(stores => ({ standard: stores.slice(0, 2), alt: stores.slice(2) })),
)
async dismiss() {
this.modalCtrl.dismiss()
}
async presentModalAdd() {
async add() {
const { name, spec } = getMarketplaceValueSpec()
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: name,
this.formDialog.open(FormComponent, {
label: name,
data: {
spec,
buttons: [
{
text: 'Save for Later',
handler: (value: { url: string }) => {
this.saveOnly(value.url)
},
handler: async ({ url }: { url: string }) => this.save(url),
},
{
text: 'Save and Connect',
handler: (value: { url: string }) => {
this.saveAndConnect(value.url)
},
handler: async ({ url }: { url: string }) => this.save(url, true),
isSubmit: true,
},
],
},
cssClass: 'alertlike-modal',
})
await modal.present()
}
delete(url: string, name: string = '') {
this.dialogs
.open(TUI_PROMPT, getPromptOptions(name))
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting...').subscribe()
const hosts = await firstValueFrom(this.hosts$)
const filtered: { [url: string]: UIStore } = Object.keys(hosts)
.filter(key => !sameUrl(key, url))
.reduce(
(prev, curr) => ({
...prev,
[curr]: hosts[curr],
}),
{},
)
async presentAction(
{ url, name }: { url: string; name?: string },
canDelete = false,
) {
const buttons: ActionSheetButton[] = [
{
text: 'Connect',
handler: () => {
this.connect(url)
},
},
]
if (canDelete) {
buttons.unshift({
text: 'Delete',
role: 'destructive',
handler: () => {
this.presentAlertDelete(url, name!)
},
try {
await this.api.setDbValue(['marketplace', 'knownHosts'], filtered)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
const action = await this.actionCtrl.create({
header: name,
mode: 'ios',
buttons,
})
await action.present()
}
private async presentAlertDelete(url: string, name: string) {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: `Are you sure you want to delete ${name}?`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
handler: () => this.delete(url),
cssClass: 'enter-click',
},
],
})
await alert.present()
}
private async connect(
async connect(
url: string,
loader?: HTMLIonLoadingElement,
loader: Subscription = new Subscription(),
): Promise<void> {
const message = 'Changing Registry...'
if (!loader) {
loader = await this.loadingCtrl.create({ message })
await loader.present()
} else {
loader.message = message
}
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Changing Registry...').subscribe())
try {
await this.api.setDbValue<string>(['marketplace', 'selected-url'], url)
await this.api.setDbValue<string>(['marketplace', 'selectedUrl'], url)
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
} finally {
loader.dismiss()
this.dismiss()
loader.unsubscribe()
}
}
private async saveOnly(rawUrl: string): Promise<void> {
const loader = await this.loadingCtrl.create()
private async save(rawUrl: string, connect = false): Promise<boolean> {
const loader = this.loader.open('Loading').subscribe()
const url = new URL(rawUrl).toString()
try {
const url = new URL(rawUrl).toString()
await this.validateAndSave(url, loader)
if (connect) await this.connect(url, loader)
return true
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
return false
} finally {
loader.dismiss()
}
}
private async saveAndConnect(rawUrl: string): Promise<void> {
const loader = await this.loadingCtrl.create()
try {
const url = new URL(rawUrl).toString()
await this.validateAndSave(url, loader)
await this.connect(url, loader)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
this.dismiss()
loader.unsubscribe()
}
}
private async validateAndSave(
url: string,
loader: HTMLIonLoadingElement,
loader: Subscription,
): Promise<void> {
// Error on duplicates
const hosts = await firstValueFrom(
this.patch.watch$('ui', 'marketplace', 'knownHosts'),
)
const hosts = await firstValueFrom(this.hosts$)
const currentUrls = Object.keys(hosts).map(toUrl)
if (currentUrls.includes(url)) throw new Error('marketplace already added')
if (currentUrls.includes(url)) throw new Error('Marketplace already added')
// Validate
loader.message = 'Validating marketplace...'
await loader.present()
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Validating marketplace...').subscribe())
const { name } = await firstValueFrom(
this.marketplaceService.fetchInfo$(url),
)
const { name } = await firstValueFrom(this.marketplace.fetchInfo$(url))
// Save
loader.message = 'Saving...'
loader.unsubscribe()
loader.closed = false
loader.add(this.loader.open('Saving...').subscribe())
await this.api.setDbValue<{ name: string }>(
['marketplace', 'knownHosts', url],
{ name },
)
}
private async delete(url: string): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Deleting...',
})
await loader.present()
const hosts = await firstValueFrom(
this.patch.watch$('ui', 'marketplace', 'knownHosts'),
)
const filtered: { [url: string]: UIStore } = Object.keys(hosts)
.filter(key => !sameUrl(key, url))
.reduce((prev, curr) => {
const name = hosts[curr]
return {
...prev,
[curr]: name,
}
}, {})
try {
await this.api.setDbValue<{ [url: string]: UIStore }>(
['marketplace', 'knownHosts'],
filtered,
)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
await this.api.setDbValue(['marketplace', 'knownHosts', url], { name })
}
}
function getMarketplaceValueSpec(): ValueSpecObject {
export const MARKETPLACE_REGISTRY = new PolymorpheusComponent(
MarketplaceSettingsPage,
)
function getMarketplaceValueSpec(): CT.ValueSpecObject {
return {
type: 'object',
name: 'Add Custom Registry',
description: null,
warning: null,
spec: {
url: {
type: 'string',
type: 'text',
name: 'URL',
description: 'A fully-qualified URL of the custom registry',
nullable: false,
inputmode: 'url',
required: true,
masked: false,
copyable: false,
pattern: `https?:\/\/[a-zA-Z0-9][a-zA-Z0-9-\.]+[a-zA-Z0-9]\.[^\s]{2,}`,
'pattern-description': 'Must be a valid URL',
minLength: null,
maxLength: null,
patterns: [
{
regex: `https?:\/\/[a-zA-Z0-9][a-zA-Z0-9-\.]+[a-zA-Z0-9]\.[^\s]{2,}`,
description: 'Must be a valid URL',
},
],
placeholder: 'e.g. https://example.org',
default: null,
warning: null,
disabled: false,
immutable: false,
generate: null,
},
},
}
}
function getPromptOptions(
name: string,
): Partial<TuiDialogOptions<TuiPromptData>> {
return {
label: 'Confirm',
size: 's',
data: {
content: `Are you sure you want to delete ${name}?`,
yes: 'Delete',
no: 'Cancel',
},
}
}

View File

@@ -0,0 +1,42 @@
import { NgIf } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
import { ConfigService } from 'src/app/services/config.service'
import { StoreIconComponent } from './store-icon.component'
@Component({
standalone: true,
selector: '[registry]',
template: `
<store-icon
[url]="registry.url"
[marketplace]="marketplace"
size="40px"
></store-icon>
<div tuiTitle>
{{ registry.name }}
<div tuiSubtitle>{{ registry.url }}</div>
</div>
<tui-icon
*ngIf="registry.selected; else content"
icon="tuiIconCheck"
[style.color]="'var(--tui-positive)'"
></tui-icon>
<ng-template #content><ng-content></ng-content></ng-template>
`,
styles: [':host { border-radius: 0.25rem; width: stretch; }'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf, StoreIconComponent, TuiIconModule, TuiTitleModule],
})
export class MarketplaceRegistryComponent {
readonly marketplace = inject(ConfigService).marketplace
@Input()
registry!: { url: string; selected: boolean; name?: string }
}

View File

@@ -0,0 +1,46 @@
import { NgIf } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { sameUrl } from '@start9labs/shared'
@Component({
standalone: true,
selector: 'store-icon',
template: `
<img
*ngIf="icon; else noIcon"
[style.border-radius.%]="100"
[style.max-width]="size || '100%'"
[src]="icon"
alt="Marketplace Icon"
/>
<ng-template #noIcon>
<img
[style.max-width]="size || '100%'"
[style.border-radius]="0"
src="assets/img/storefront-outline.png"
alt="Marketplace Icon"
/>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgIf],
})
export class StoreIconComponent {
@Input()
url = ''
@Input()
size?: string
@Input()
marketplace!: any
get icon() {
const { start9, community } = this.marketplace
if (sameUrl(this.url, start9)) {
return 'assets/img/icon_transparent.png'
} else if (sameUrl(this.url, community)) {
return 'assets/img/community-store.png'
}
return null
}
}

View File

@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { LoadingController, ModalController } from '@ionic/angular'
import { ApiService } from '../../services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import { ModalController } from '@ionic/angular'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { EOSService } from 'src/app/services/eos.service'
import { ApiService } from '../../services/api/embassy-api.service'
@Component({
selector: 'os-update',
@@ -15,8 +15,8 @@ export class OSUpdatePage {
constructor(
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly loader: LoadingService,
private readonly errorService: ErrorService,
private readonly embassyApi: ApiService,
private readonly eosService: EOSService,
) {}
@@ -39,18 +39,15 @@ export class OSUpdatePage {
}
async updateEOS() {
const loader = await this.loadingCtrl.create({
message: 'Beginning update...',
})
await loader.present()
const loader = this.loader.open('Beginning update...').subscribe()
try {
await this.embassyApi.updateServer()
this.dismiss()
} catch (e: any) {
this.errToast.present(e)
this.errorService.handleError(e)
} finally {
loader.dismiss()
loader.unsubscribe()
}
}

View File

@@ -0,0 +1,123 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiAutoFocusModule } from '@taiga-ui/cdk'
import { TuiDialogContext, TuiTextfieldControllerModule } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { TuiInputModule } from '@taiga-ui/kit'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
@Component({
standalone: true,
template: `
<p>{{ options.message }}</p>
<p *ngIf="options.warning" class="warning">{{ options.warning }}</p>
<form (ngSubmit)="submit(value.trim())">
<tui-input
tuiAutoFocus
[tuiTextfieldLabelOutside]="!options.label"
[tuiTextfieldCustomContent]="options.useMask ? toggle : ''"
[ngModelOptions]="{ standalone: true }"
[(ngModel)]="value"
>
{{ options.label }}
<span *ngIf="options.required !== false && options.label">*</span>
<input
tuiTextfield
[class.masked]="options.useMask && masked && value"
[placeholder]="options.placeholder || ''"
/>
</tui-input>
<footer class="g-buttons">
<button
tuiButton
type="button"
appearance="secondary"
(click)="cancel()"
>
Cancel
</button>
<button tuiButton [disabled]="!value && options.required !== false">
{{ options.buttonText || 'Submit' }}
</button>
</footer>
</form>
<ng-template #toggle>
<button
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
(click)="masked = !masked"
></button>
</ng-template>
`,
styles: [
`
.warning {
color: var(--tui-warning-fill);
}
.button {
pointer-events: auto;
margin-left: 0.25rem;
}
.masked {
-webkit-text-security: disc;
}
`,
],
imports: [
CommonModule,
FormsModule,
TuiInputModule,
TuiButtonModule,
TuiTextfieldControllerModule,
TuiAutoFocusModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PromptModal {
masked = this.options.useMask
value = this.options.initialValue || ''
constructor(
@Inject(POLYMORPHEUS_CONTEXT)
private readonly context: TuiDialogContext<string, PromptOptions>,
) {}
get options(): PromptOptions {
return this.context.data
}
cancel() {
this.context.$implicit.complete()
}
submit(value: string) {
if (value || !this.options.required) {
this.context.$implicit.next(value)
}
}
}
export const PROMPT = new PolymorpheusComponent(PromptModal)
export interface PromptOptions {
message: string
label?: string
warning?: string
buttonText?: string
placeholder?: string
required?: boolean
useMask?: boolean
initialValue?: string | null
}