rework installing page and add cancel install button (#2915)

* rework installing page and add cancel install button

* actually call cancel endpoint

* fix two bugs

* include translations in progress component

* cancellable installs

* fix: comments (#2916)

* fix: comments

* delete comments

* ensure trailing slash and no qp for new registry url

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>

* fix raspi

* bump sdk

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: Alex Inkin <alexander@inkin.ru>
This commit is contained in:
Matt Hill
2025-04-30 13:50:08 -06:00
committed by GitHub
parent 5c473eb9cc
commit e6f0067728
37 changed files with 431 additions and 269 deletions

View File

@@ -55,7 +55,7 @@ export class AppComponent {
)
.subscribe({
complete: async () => {
const loader = this.loader.open('' as i18nKey).subscribe()
const loader = this.loader.open().subscribe()
try {
await this.api.reboot()

View File

@@ -17,7 +17,6 @@ import { StoreIconComponentModule } from './store-icon/store-icon.component.modu
<ng-content />
}
`,
styles: [':host { border-radius: 0.25rem; width: stretch; }'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [StoreIconComponentModule, TuiIcon, TuiTitle],
})

View File

@@ -25,7 +25,7 @@ export type StoreIdentity = {
name: string
}
export type Marketplace = Record<string, StoreData | null>
export type Marketplace = Record<string, StoreDataWithUrl | null>
export type StoreData = {
info: T.RegistryInfo

View File

@@ -362,8 +362,8 @@ export default {
359: 'Die Partition enthält keine gültige Sicherung',
360: 'Sicherungsfortschritt',
361: 'Abgeschlossen',
362: 'Sicherung läuft',
363: 'Warten',
362: 'sicherung läuft',
363: 'warten',
364: 'Sicherung erstellt',
365: 'Wiederherstellung ausgewählt',
366: 'Initialisierung',
@@ -493,4 +493,9 @@ export default {
490: 'deutsch',
491: 'englisch',
492: 'Startmenü',
493: 'Installationsfortschritt',
494: 'Herunterladen',
495: 'Validierung',
496: 'in Bearbeitung',
497: 'abgeschlossen',
} satisfies i18n

View File

@@ -361,8 +361,8 @@ export const ENGLISH = {
'Drive partition does not contain a valid backup': 359,
'Backup Progress': 360,
'Complete': 361,
'Backing up': 362,
'Waiting': 363,
'backing up': 362,
'waiting': 363,
'Backup made': 364,
'Restore selected': 365,
'Initializing': 366,
@@ -492,4 +492,9 @@ export const ENGLISH = {
'german': 490,
'english': 491,
'Start Menu': 492,
'Install Progress': 493,
'Downloading': 494,
'Validating': 495,
'in progress': 496,
'complete': 497,
} as const

View File

@@ -362,8 +362,8 @@ export default {
359: 'La partición de la unidad no contiene una copia de seguridad válida',
360: 'Progreso de la copia de seguridad',
361: 'Completo',
362: 'Haciendo copia de seguridad',
363: 'Esperando',
362: 'haciendo copia de seguridad',
363: 'esperando',
364: 'Copia de seguridad realizada',
365: 'Restauración seleccionada',
366: 'Inicializando',
@@ -493,4 +493,9 @@ export default {
490: 'alemán',
491: 'inglés',
492: 'Menú de Inicio',
} as any satisfies i18n
493: 'Progreso de instalación',
494: 'Descargando',
495: 'Validando',
496: 'en progreso',
497: 'completo',
} satisfies i18n

View File

@@ -362,8 +362,8 @@ export default {
359: 'Partycja dysku nie zawiera prawidłowej kopii zapasowej',
360: 'Postęp tworzenia kopii zapasowej',
361: 'Zakończono',
362: 'Tworzenie kopii zapasowej',
363: 'Oczekiwanie',
362: 'tworzenie kopii zapasowej',
363: 'oczekiwanie',
364: 'Kopia zapasowa utworzona',
365: 'Wybrano przywracanie',
366: 'Inicjalizacja',
@@ -493,4 +493,9 @@ export default {
490: 'niemiecki',
491: 'angielski',
492: 'Menu Startowe',
493: 'Postęp instalacji',
494: 'Pobieranie',
495: 'Weryfikowanie',
496: 'w toku',
497: 'zakończono',
} satisfies i18n

View File

@@ -39,7 +39,7 @@ class LoadingComponent {
useFactory: () => new LoadingService(TUI_DIALOGS, LoadingComponent),
})
export class LoadingService extends TuiPopoverService<unknown> {
override open<G = void>(textContent: i18nKey) {
override open<G = void>(textContent: i18nKey | '' = '') {
return super.open<G>(textContent)
}
}

View File

@@ -127,7 +127,7 @@ export class MarketplaceControlsComponent {
async tryInstall() {
const currentUrl = this.file
? null
: await firstValueFrom(this.marketplaceService.getCurrentRegistryUrl$())
: await firstValueFrom(this.marketplaceService.currentRegistryUrl$)
const originalUrl = this.localPkg?.registry || null
if (!this.localPkg) {

View File

@@ -53,8 +53,7 @@ import { DialogService, i18nPipe } from '@start9labs/shared'
})
export class MarketplaceMenuComponent {
private readonly dialog = inject(DialogService)
private readonly marketplaceService = inject(MarketplaceService)
readonly registry$ = this.marketplaceService.getCurrentRegistry$()
readonly registry$ = inject(MarketplaceService).currentRegistry$
changeRegistry() {
this.dialog

View File

@@ -29,9 +29,7 @@ import { StorageService } from 'src/app/services/storage.service'
<div class="marketplace-content-inner">
<marketplace-notification [url]="(url$ | async) || ''" />
<div class="title-wrapper">
<h1>
{{ category$ | async | titlecase }}
</h1>
<h1>{{ category$ | async | titlecase }}</h1>
</div>
@if (registry$ | async; as registry) {
<section class="marketplace-content-list">
@@ -178,14 +176,14 @@ export default class MarketplaceComponent {
queryParamsHandling: 'merge',
})
} else {
this.marketplaceService.setRegistryUrl(registry)
this.marketplaceService.currentRegistryUrl$.next(registry)
}
}),
)
.subscribe()
readonly url$ = this.marketplaceService.getCurrentRegistryUrl$()
readonly url$ = this.marketplaceService.currentRegistryUrl$
readonly category$ = this.categoryService.getCategory$()
readonly query$ = this.categoryService.getQuery$()
readonly registry$ = this.marketplaceService.getCurrentRegistry$()
readonly registry$ = this.marketplaceService.currentRegistry$
}

View File

@@ -194,7 +194,7 @@ export class MarketplacePreviewComponent {
readonly flavors$ = this.flavor$.pipe(
switchMap(current =>
this.marketplaceService.getCurrentRegistry$().pipe(
this.marketplaceService.currentRegistry$.pipe(
map(({ packages }) =>
packages.filter(
({ id, flavor }) => id === this.pkgId && flavor !== current,

View File

@@ -41,7 +41,7 @@ import { StorageService } from 'src/app/services/storage.service'
></button>
}
<h3 class="g-title">{{ 'Custom Registries' | i18n }}</h3>
<button tuiCell (click)="add()" [style.width]="'-webkit-fill-available'">
<button tuiCell (click)="add()">
<tui-icon icon="@tui.plus" [style.margin-inline.rem]="'0.5'" />
<div tuiTitle>{{ 'Add custom registry' | i18n }}</div>
</button>
@@ -71,6 +71,10 @@ import { StorageService } from 'src/app/services/storage.service'
flex-direction: row;
align-items: center;
}
[tuiCell] {
width: stretch;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -102,8 +106,8 @@ export class MarketplaceRegistryModal {
private readonly storage = inject(StorageService)
readonly registries$ = combineLatest([
this.marketplaceService.getRegistries$(),
this.marketplaceService.getCurrentRegistryUrl$(),
this.marketplaceService.registries$,
this.marketplaceService.currentRegistryUrl$,
]).pipe(
map(([registries, currentUrl]) =>
registries.map(s => ({
@@ -185,7 +189,7 @@ export class MarketplaceRegistryModal {
loader.closed = false
loader.add(this.loader.open('Changing registry').subscribe())
try {
this.marketplaceService.setRegistryUrl(url)
this.marketplaceService.currentRegistryUrl$.next(url)
this.router.navigate([], {
queryParams: { registry: url },
queryParamsHandling: 'merge',
@@ -231,7 +235,7 @@ export class MarketplaceRegistryModal {
private async save(rawUrl: string, connect = false): Promise<boolean> {
const loader = this.loader.open('Loading').subscribe()
const url = new URL(rawUrl).toString()
const url = new URL(rawUrl).origin + '/'
try {
await this.validateAndSave(url, loader)

View File

@@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core'
import { MarketplacePkgBase } from '@start9labs/marketplace'
import { DialogService, i18nKey, i18nPipe, sameUrl } from '@start9labs/shared'
import { defaultIfEmpty, firstValueFrom } from 'rxjs'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { MarketplaceService } from 'src/app/services/marketplace.service'
@Injectable({
@@ -16,14 +16,12 @@ export class MarketplaceAlertsService {
url: string,
originalUrl: string | null,
): Promise<boolean> {
const registries = await firstValueFrom(
this.marketplaceService.getRegistries$(),
)
const registries = await firstValueFrom(this.marketplaceService.registries$)
const message = originalUrl
? `${this.i18n.transform('installed from')} ${registries.find(h => h.url === originalUrl) || originalUrl}`
? `${this.i18n.transform('installed from')} ${registries.find(r => sameUrl(r.url, originalUrl))?.name || originalUrl}`
: this.i18n.transform('sideloaded')
const currentName = registries.find(h => h.url === url) || url
const currentName = registries.find(h => sameUrl(h.url, url))?.name || url
return new Promise(async resolve => {
this.dialog

View File

@@ -1,31 +1,107 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiProgress } from '@taiga-ui/kit'
import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
@Component({
selector: '[progress]',
selector: 'service-install-progress',
template: `
<ng-content />
@if (progress | installingProgress; as percent) {
: {{ percent }}%
<progress
tuiProgressBar
<header>
{{ 'Install Progress' | i18n }}
<button
tuiButton
size="xs"
[style.color]="
progress === true
? 'var(--tui-text-positive)'
: 'var(--tui-text-action)'
"
[value]="percent / 100"
></progress>
appearance="primary-destructive"
[style.margin-inline-start]="'auto'"
(click)="cancel()"
>
{{ 'Cancel' | i18n }}
</button>
</header>
@for (
phase of pkg.stateInfo.installingInfo?.progress?.phases;
track $index
) {
<div *tuiLet="phase.progress | installingProgress as percent">
{{ $any(phase.name) | i18n }}:
@if (phase.progress === null) {
<span>{{ 'waiting' | i18n }}</span>
} @else if (phase.progress === true) {
<span>{{ 'complete' | i18n }}!</span>
} @else if (phase.progress === false || phase.progress.total === null) {
<span>{{ 'in progress' | i18n }}...</span>
} @else {
<span>{{ percent }}%</span>
}
<progress
tuiProgressBar
size="m"
[max]="100"
[class.g-positive]="phase.progress === true"
[value]="isPending(phase.progress) ? undefined : percent"
></progress>
</div>
}
`,
styles: [':host { line-height: 2rem }'],
styles: `
:host {
grid-column: span 6;
color: var(--tui-text-secondary);
}
div {
padding: 0.25rem 0;
}
span {
float: right;
text-transform: capitalize;
}
progress {
margin: 0.5rem 0;
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiProgress, InstallingProgressPipe],
imports: [TuiProgress, TuiLet, InstallingProgressPipe, i18nPipe, TuiButton],
})
export class ServiceProgressComponent {
@Input({ required: true }) progress!: T.Progress
export class ServiceInstallProgressComponent {
@Input({ required: true })
pkg!: PackageDataEntry
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
isPending(progress: T.Progress): boolean {
return (
!progress || (progress && progress !== true && progress.total === null)
)
}
async cancel() {
const loader = this.loader.open().subscribe()
try {
await this.api.cancelInstallPackage({ id: getManifest(this.pkg).id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -18,9 +18,7 @@ import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
@if (loading) {
<tui-loader size="s" />
} @else {
@if (healthy) {
<tui-icon icon="@tui.check" class="g-positive" />
} @else {
@if (!healthy) {
<tui-icon icon="@tui.triangle-alert" class="g-warning" />
}
}

View File

@@ -25,46 +25,45 @@ import { ServiceDependenciesComponent } from '../components/dependencies.compone
import { ServiceErrorComponent } from '../components/error.component'
import { ServiceHealthChecksComponent } from '../components/health-checks.component'
import { ServiceInterfacesComponent } from '../components/interfaces.component'
import { ServiceProgressComponent } from '../components/progress.component'
import { ServiceInstallProgressComponent } from '../components/progress.component'
import { ServiceStatusComponent } from '../components/status.component'
@Component({
template: `
<service-status
[connected]="!!connected()"
[installingInfo]="pkg()?.stateInfo?.installingInfo"
[status]="status()"
>
@if ($any(pkg()?.status)?.started; as started) {
<p class="g-secondary" [appUptime]="started"></p>
}
@if (installed() && connected() && pkg(); as pkg) {
<service-controls [pkg]="pkg" [status]="status()" />
}
</service-status>
@if (pkg(); as pkg) {
@if (installing()) {
<service-install-progress [pkg]="pkg" />
} @else if (installed()) {
<service-status
[connected]="!!connected()"
[installingInfo]="pkg.stateInfo.installingInfo"
[status]="status()"
>
@if ($any(pkg.status)?.started; as started) {
<p class="g-secondary" [appUptime]="started"></p>
}
@if (installed() && pkg(); as pkg) {
@if (pkg.status.main === 'error') {
<service-error [pkg]="pkg" />
}
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
@if (errors() | async; as errors) {
<service-dependencies
[pkg]="pkg"
[services]="services()"
[errors]="errors"
/>
}
<service-health-checks [checks]="health()" />
<service-action-requests [pkg]="pkg" [services]="services() || {}" />
}
@if (connected()) {
<service-controls [pkg]="pkg" [status]="status()" />
}
</service-status>
@if (installing() && pkg(); as pkg) {
@for (
item of pkg.stateInfo.installingInfo?.progress?.phases;
track $index
) {
<p [progress]="item.progress">{{ item.name }}</p>
@if (pkg.status.main === 'error') {
<service-error [pkg]="pkg" />
}
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
@if (errors() | async; as errors) {
<service-dependencies
[pkg]="pkg"
[services]="services()"
[errors]="errors"
/>
}
<service-health-checks [checks]="health()" />
<service-action-requests [pkg]="pkg" [services]="services() || {}" />
}
}
`,
@@ -94,7 +93,7 @@ import { ServiceStatusComponent } from '../components/status.component'
standalone: true,
imports: [
CommonModule,
ServiceProgressComponent,
ServiceInstallProgressComponent,
ServiceStatusComponent,
ServiceControlsComponent,
ServiceInterfacesComponent,

View File

@@ -27,13 +27,13 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
<span tuiSubtitle>
@if (progress.complete) {
<tui-icon icon="@tui.check" class="g-positive" />
{{ 'Complete' | i18n }}
{{ 'complete' | i18n }}
} @else {
@if ((pkg.key | tuiMapper: toStatus | async) === 'backingUp') {
<tui-loader size="s" />
{{ 'Backing up' | i18n }}
{{ 'backing up' | i18n }}
} @else {
{{ 'Waiting' | i18n }}...
{{ 'waiting' | i18n }}
}
}
</span>

View File

@@ -224,14 +224,12 @@ export default class UpdatesComponent {
readonly data = toSignal<UpdatesData>(
combineLatest({
hosts: this.marketplaceService
.getRegistries$(true)
.pipe(
tap(
([registry]) =>
!this.isMobile && registry && this.current.set(registry),
),
hosts: this.marketplaceService.filteredRegistries$.pipe(
tap(
([registry]) =>
!this.isMobile && registry && this.current.set(registry),
),
),
marketplace: this.marketplaceService.marketplace$,
localPkgs: inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData')
@@ -248,7 +246,7 @@ export default class UpdatesComponent {
),
),
),
errors: this.marketplaceService.getRequestErrors$(),
errors: this.marketplaceService.requestErrors$,
}),
)

View File

@@ -319,6 +319,9 @@ export namespace RR {
export type InstallPackageReq = T.InstallParams
export type InstallPackageRes = null
export type CancelInstallPackageReq = { id: string }
export type CancelInstallPackageRes = null
export type GetActionInputReq = { packageId: string; actionId: string } // package.action.get-input
export type GetActionInputRes = {
spec: IST.InputSpec

View File

@@ -325,6 +325,10 @@ export abstract class ApiService {
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes>
abstract cancelInstallPackage(
params: RR.CancelInstallPackageReq,
): Promise<RR.CancelInstallPackageRes>
abstract getActionInput(
params: RR.GetActionInputReq,
): Promise<RR.GetActionInputRes>

View File

@@ -560,6 +560,12 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.install', params })
}
async cancelInstallPackage(
params: RR.CancelInstallPackageReq,
): Promise<RR.CancelInstallPackageRes> {
return this.rpcRequest({ method: 'package.cancel-install', params })
}
async getActionInput(
params: RR.GetActionInputReq,
): Promise<RR.GetActionInputRes> {

View File

@@ -50,10 +50,7 @@ const PROGRESS: T.FullProgress = {
},
{
name: 'Installing',
progress: {
done: 0,
total: 40,
},
progress: null,
},
],
}
@@ -1077,6 +1074,22 @@ export class MockApiService extends ApiService {
return null
}
async cancelInstallPackage(
params: RR.CancelInstallPackageReq,
): Promise<RR.CancelInstallPackageRes> {
await pauseFor(500)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.id}`,
},
]
this.mockRevision(patch)
return null
}
async getActionInput(
params: RR.GetActionInputReq,
): Promise<RR.GetActionInputRes> {

View File

@@ -1,13 +1,12 @@
import { Injectable } from '@angular/core'
import { inject, Injectable } from '@angular/core'
import {
GetPackageRes,
Marketplace,
MarketplacePkg,
StoreData,
StoreDataWithUrl,
StoreIdentity,
} from '@start9labs/marketplace'
import { Exver, defaultRegistries, sameUrl } from '@start9labs/shared'
import { defaultRegistries, Exver, sameUrl } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client'
import {
@@ -40,29 +39,11 @@ const { start9, community } = defaultRegistries
providedIn: 'root',
})
export class MarketplaceService {
private readonly currentRegistryUrlSubject$ = new ReplaySubject<string>(1)
private readonly currentRegistryUrl$ = this.currentRegistryUrlSubject$.pipe(
distinctUntilChanged(),
)
private readonly api = inject(ApiService)
private readonly patch: PatchDB<DataModel> = inject(PatchDB)
private readonly exver = inject(Exver)
private readonly currentRegistry$: Observable<StoreDataWithUrl> =
this.currentRegistryUrl$.pipe(
switchMap(url => this.fetchRegistry$(url)),
filter(Boolean),
map(registry => {
registry.info.categories = {
all: {
name: 'All',
},
...registry.info.categories,
}
return registry
}),
shareReplay(1),
)
private readonly registries$: Observable<StoreIdentity[]> = this.patch
readonly registries$: Observable<StoreIdentity[]> = this.patch
.watch$('ui', 'registries')
.pipe(
map(registries => [
@@ -74,21 +55,23 @@ export class MarketplaceService {
]),
)
private readonly filteredRegistries$: Observable<StoreIdentity[]> =
combineLatest([
this.clientStorageService.showDevTools$,
this.registries$,
]).pipe(
map(([devMode, registries]) =>
devMode
? registries
: registries.filter(
({ url }) => !url.includes('alpha') && !url.includes('beta'),
),
),
)
// option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL
readonly filteredRegistries$: Observable<StoreIdentity[]> = combineLatest([
inject(ClientStorageService).showDevTools$,
this.registries$,
]).pipe(
map(([devMode, registries]) =>
devMode
? registries
: registries.filter(
({ url }) => !url.includes('alpha') && !url.includes('beta'),
),
),
)
private readonly requestErrors$ = new BehaviorSubject<string[]>([])
readonly currentRegistryUrl$ = new ReplaySubject<string>(1)
readonly requestErrors$ = new BehaviorSubject<string[]>([])
readonly marketplace$: Observable<Marketplace> = this.registries$.pipe(
startWith<StoreIdentity[]>([]),
@@ -102,11 +85,11 @@ export class MarketplaceService {
if (data?.info.name)
this.updateRegistryName(url, name, data.info.name)
}),
map<StoreData | null, [string, StoreData | null]>(data => [url, data]),
startWith<[string, StoreData | null]>([url, null]),
map(data => [url, data] satisfies [string, StoreDataWithUrl | null]),
startWith<[string, StoreDataWithUrl | null]>([url, null]),
),
),
scan<[string, StoreData | null], Record<string, StoreData | null>>(
scan<[string, StoreDataWithUrl | null], Marketplace>(
(requests, [url, store]) => {
requests[url] = store
@@ -114,32 +97,21 @@ export class MarketplaceService {
},
{},
),
shareReplay({ bufferSize: 1, refCount: true }),
shareReplay(1),
)
constructor(
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly clientStorageService: ClientStorageService,
private readonly exver: Exver,
) {}
getRegistries$(filtered = false): Observable<StoreIdentity[]> {
// option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL
return filtered ? this.filteredRegistries$ : this.registries$
}
getCurrentRegistryUrl$() {
return this.currentRegistryUrl$
}
setRegistryUrl(url: string) {
this.currentRegistryUrlSubject$.next(url)
}
getCurrentRegistry$(): Observable<StoreDataWithUrl> {
return this.currentRegistry$
}
readonly currentRegistry$: Observable<StoreDataWithUrl> = combineLatest([
this.marketplace$,
this.currentRegistryUrl$,
this.currentRegistryUrl$.pipe(
distinctUntilChanged(),
switchMap(url => this.fetchRegistry$(url).pipe(startWith(null))),
),
]).pipe(
map(([all, url, current]) => current || all[url]),
filter(Boolean),
shareReplay(1),
)
getPackage$(
id: string,
@@ -161,14 +133,12 @@ export class MarketplaceService {
)
}
fetchInfo$(url: string): Observable<T.RegistryInfo> {
return from(this.api.getRegistryInfo({ registry: url })).pipe(
fetchInfo$(registry: string): Observable<T.RegistryInfo> {
return from(this.api.getRegistryInfo({ registry })).pipe(
map(info => ({
...info,
categories: {
all: {
name: 'All',
},
all: { name: 'All' },
...info.categories,
},
})),
@@ -263,10 +233,6 @@ export class MarketplaceService {
}
}
getRequestErrors$(): Observable<string[]> {
return this.requestErrors$
}
async installPackage(
id: string,
version: string,