Skip to content

Commit

Permalink
NAS-131147 / 25.04 / Allow to edit yaml installed apps (#10804)
Browse files Browse the repository at this point in the history
* NAS-131147: Allow to edit yaml installed apps

* NAS-131147: Redirect to installed apps after custom app install

* NAS-131147: Use custom app form component to edit custom apps

* NAS-131147: Reload installed apps when custom app installed

* NAS-131147: Reload installed app when custom app is changed

* NAS-131147: Reduce delay
  • Loading branch information
denysbutenko authored Oct 10, 2024
1 parent 931c42e commit 07149af
Show file tree
Hide file tree
Showing 98 changed files with 582 additions and 163 deletions.
11 changes: 10 additions & 1 deletion src/app/helpers/json-to-yaml.helper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { marker as T } from '@biesbjerg/ngx-translate-extract-marker';
import { dump } from 'js-yaml';
import { dump, load } from 'js-yaml';

export function jsonToYaml(jsonData: unknown): string {
try {
Expand All @@ -18,3 +18,12 @@ export function jsonToYaml(jsonData: unknown): string {
return T('Error occurred');
}
}

export function yamlToJson(value: string): unknown {
try {
return load(value);
} catch (error) {
console.error(error);
return T('Error occurred');
}
}
14 changes: 13 additions & 1 deletion src/app/interfaces/app.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface App {
portals: Record<string, string>;
version: string;
migrated: boolean;
custom_app: boolean;
/**
* Present with `retrieve_config` query param.
*/
Expand Down Expand Up @@ -132,7 +133,18 @@ export interface AppCreate {
}

export interface AppUpdate {
values: Record<string, ChartFormValue>;
/**
* Required when `custom_app = false`
*/
values?: Record<string, ChartFormValue>;
/**
* Required attr when `custom_app = true`
*/
custom_compose_config?: Record<string, unknown>;
/**
* Optional attr when `custom_app = true`
*/
custom_compose_config_string?: string;
}

export interface AppUpgrade {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateService } from '@ngx-translate/core';
import { isArray, isPlainObject, unset } from 'lodash-es';
import {
isArray, isPlainObject, unset,
} from 'lodash-es';
import {
BehaviorSubject, Observable, of, Subject, Subscription, timer,
} from 'rxjs';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<ix-modal-header
[requiredRoles]="requiredRoles"
[title]="'Custom App' | translate"
[loading]="isLoading"
[title]="isNew() ? ('Add Custom App' | translate) : ('Edit Custom App' | translate)"
[loading]="isLoading()"
></ix-modal-header>

<mat-card>
Expand All @@ -15,12 +15,13 @@
formControlName="release_name"
[required]="true"
[label]="'Name' | translate"
[readonly]="!isNew()"
></ix-input>
<ix-code-editor
formControlName="custom_compose_config_string"
[language]="CodeEditorLanguage.Yaml"
[label]="'Custom Config' | translate"
[tooltip]="tooltip"
[tooltip]="'Custom app config in YAML format.' | translate"
[required]="true"
></ix-code-editor>
<ix-form-actions>
Expand All @@ -30,11 +31,11 @@
mat-button
color="primary"
ixTest="save"
[disabled]="!form.valid || isLoading"
[disabled]="!form.valid || isLoading()"
>
{{ 'Save' | translate }}
</button>
</ix-form-actions>
</form>
</mat-card-content>
</mat-card>
</mat-card>
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@ import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonHarness } from '@angular/material/button/testing';
import { Router } from '@angular/router';
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { MockComponent } from 'ng-mocks';
import { of } from 'rxjs';
import { mockAuth } from 'app/core/testing/utils/mock-auth.utils';
import { mockJob, mockWebSocket } from 'app/core/testing/utils/mock-websocket.utils';
import { AppState } from 'app/enums/app-state.enum';
import { App } from 'app/interfaces/app.interface';
import { jsonToYaml } from 'app/helpers/json-to-yaml.helper';
import { App, ChartFormValue } from 'app/interfaces/app.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { IxCodeEditorComponent } from 'app/modules/forms/ix-forms/components/ix-code-editor/ix-code-editor.component';
import { IxCodeEditorHarness } from 'app/modules/forms/ix-forms/components/ix-code-editor/ix-code-editor.harness';
import { IxInputComponent } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.component';
import { IxInputHarness } from 'app/modules/forms/ix-forms/components/ix-input/ix-input.harness';
import { IxSlideInRef } from 'app/modules/forms/ix-forms/components/ix-slide-in/ix-slide-in-ref';
import { SLIDE_IN_DATA } from 'app/modules/forms/ix-forms/components/ix-slide-in/ix-slide-in.token';
import { PageHeaderComponent } from 'app/modules/page-header/page-title-header/page-header.component';
import { CustomAppFormComponent } from 'app/pages/apps/components/custom-app-form/custom-app-form.component';
import { ApplicationsService } from 'app/pages/apps/services/applications.service';
Expand All @@ -33,6 +36,21 @@ const fakeApp = {
icon: 'path-to-icon',
train: 'stable',
},
custom_app: true,
config: {
services: {
nginx: {
image: 'nginx:1-alpine',
ports: [
'8089:80',
],
volumes: [
'./html5up-stellar/:/usr/share/nginx/html',
],
},
},
version: '3.8',
} as Record<string, ChartFormValue>,
} as App;

describe('CustomAppFormComponent', () => {
Expand All @@ -57,44 +75,78 @@ describe('CustomAppFormComponent', () => {
mockProvider(ErrorHandlerService),
mockProvider(DialogService, {
jobDialog: jest.fn(() => ({
afterClosed: jest.fn(() => of()),
afterClosed: jest.fn(() => of(true)),
})),
}),
mockProvider(IxSlideInRef),
mockProvider(IxSlideInRef, {
close: jest.fn(),
}),
mockWebSocket([
mockJob('app.create'),
mockJob('app.update'),
]),
mockProvider(Router),
],
});

beforeEach(() => {
spectator = createComponent();
function setupTest(app?: App): void {
spectator = createComponent({
providers: [
{ provide: SLIDE_IN_DATA, useValue: app || null },
],
});
loader = TestbedHarnessEnvironment.loader(spectator.fixture);
});
}

describe('create app', () => {
beforeEach(() => {
setupTest();
});

it('closes slide in when successfully submitted', async () => {
const appNameControl = await loader.getHarness(IxInputHarness);
await appNameControl.setValue('test');
const configControl = await loader.getHarness(IxCodeEditorHarness);
await configControl.setValue('config');
spectator.detectChanges();
const button = await loader.getHarness(MatButtonHarness);
await button.click();
it('checks save and closes slide in when successfully submitted', async () => {
const appNameControl = await loader.getHarness(IxInputHarness);
await appNameControl.setValue('test');
const configControl = await loader.getHarness(IxCodeEditorHarness);
await configControl.setValue('config');
spectator.detectChanges();
const button = await loader.getHarness(MatButtonHarness);
await button.click();

expect(spectator.inject(WebSocketService).job).toHaveBeenCalledWith('app.create', [{
custom_app: true,
custom_compose_config_string: 'config',
app_name: 'test',
}]);
expect(spectator.inject(DialogService).jobDialog).toHaveBeenCalled();
expect(spectator.inject(WebSocketService).job).toHaveBeenCalledWith(
'app.create',
[{
custom_app: true,
custom_compose_config_string: 'config',
app_name: 'test',
}],
);
expect(spectator.inject(DialogService).jobDialog).toHaveBeenCalled();
});

it('forbidden app names are not allowed', async () => {
const appNameControl = await loader.getHarness(IxInputHarness);
await appNameControl.setValue('test-app-one');
spectator.detectChanges();

const button = await loader.getHarness(MatButtonHarness);
expect(button.isDisabled()).toBeTruthy();
});
});

it('forbidden app names are not allowed', async () => {
const appNameControl = await loader.getHarness(IxInputHarness);
await appNameControl.setValue('test-app-one');
spectator.detectChanges();
describe('edit app', () => {
beforeEach(() => {
setupTest(fakeApp);
});

it('checks save and closes slide in when successfully submitted', async () => {
const button = await loader.getHarness(MatButtonHarness);
await button.click();

const button = await loader.getHarness(MatButtonHarness);
expect(button.isDisabled()).toBeTruthy();
expect(spectator.inject(WebSocketService).job).toHaveBeenCalledWith('app.update', [
'test-app-one',
{ custom_compose_config_string: jsonToYaml(fakeApp.config) },
]);
expect(spectator.inject(DialogService).jobDialog).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import {
ChangeDetectionStrategy, ChangeDetectorRef, Component,
ChangeDetectionStrategy, Component,
Inject,
OnInit,
signal,
} from '@angular/core';
import { Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { FormBuilder } from '@ngneat/reactive-forms';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateService } from '@ngx-translate/core';
import { map } from 'rxjs';
import { CodeEditorLanguage } from 'app/enums/code-editor-language.enum';
import { Role } from 'app/enums/role.enum';
import { AppCreate } from 'app/interfaces/app.interface';
import { jsonToYaml } from 'app/helpers/json-to-yaml.helper';
import { App, AppCreate } from 'app/interfaces/app.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { IxSlideInRef } from 'app/modules/forms/ix-forms/components/ix-slide-in/ix-slide-in-ref';
import { SLIDE_IN_DATA } from 'app/modules/forms/ix-forms/components/ix-slide-in/ix-slide-in.token';
import { forbiddenAsyncValues } from 'app/modules/forms/ix-forms/validators/forbidden-values-validation/forbidden-values-validation';
import { ApplicationsService } from 'app/pages/apps/services/applications.service';
import { ErrorHandlerService } from 'app/services/error-handler.service';
Expand All @@ -24,62 +29,95 @@ import { WebSocketService } from 'app/services/ws.service';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomAppFormComponent implements OnInit {
protected isNew = signal(true);
protected requiredRoles = [Role.AppsWrite];
protected readonly CodeEditorLanguage = CodeEditorLanguage;
protected form = this.fb.group({
release_name: ['', Validators.required],
custom_compose_config_string: ['\n\n', Validators.required],
});
protected isLoading = false;
protected tooltip = this.translate.instant('Add custom app config in Yaml format.');
protected isLoading = signal(false);
protected forbiddenAppNames$ = this.appService.getAllApps().pipe(map((apps) => apps.map((app) => app.name)));

constructor(
private fb: FormBuilder,
private translate: TranslateService,
private cdr: ChangeDetectorRef,
private ws: WebSocketService,
private errorHandler: ErrorHandlerService,
private dialogService: DialogService,
private appService: ApplicationsService,
private dialogRef: IxSlideInRef<CustomAppFormComponent>,
private router: Router,
@Inject(SLIDE_IN_DATA) public data: App,
) {}

ngOnInit(): void {
if (this.data) {
this.setAppForEdit(this.data);
} else {
this.setNewApp();
}
}

private setNewApp(): void {
this.addForbiddenAppNamesValidator();
}

private setAppForEdit(app: App): void {
this.isNew.set(false);
this.form.patchValue({
release_name: app.id,
custom_compose_config_string: jsonToYaml(app.config),
});
}

protected addForbiddenAppNamesValidator(): void {
this.form.controls.release_name.setAsyncValidators(forbiddenAsyncValues(this.forbiddenAppNames$));
this.form.controls.release_name.updateValueAndValidity();
}

protected onSubmit(): void {
this.isLoading = true;
this.cdr.markForCheck();
this.isLoading.set(true);
const data = this.form.value;

const appCreate$ = this.ws.job(
'app.create',
[{
custom_app: true,
app_name: data.release_name,
custom_compose_config_string: data.custom_compose_config_string,
} as AppCreate],
);

const appUpdate$ = this.ws.job('app.update', [
data.release_name,
{ custom_compose_config_string: data.custom_compose_config_string },
]);

const job$ = this.isNew() ? appCreate$ : appUpdate$;

this.dialogService.jobDialog(
this.ws.job(
'app.create',
[{
custom_app: true,
app_name: data.release_name,
custom_compose_config_string: data.custom_compose_config_string,
} as AppCreate],
),
job$,
{
title: this.translate.instant('Custom App'),
canMinimize: false,
description: this.translate.instant('Creating custom app'),
description: this.isNew()
? this.translate.instant('Creating custom app')
: this.translate.instant('Updating custom app'),
},
).afterClosed().pipe(
untilDestroyed(this),
).subscribe({
next: () => {
this.dialogRef.close();
if (this.isNew()) {
this.router.navigate(['/apps', 'installed']);
} else {
this.router.navigate(['/apps', 'installed', this.data.metadata.train, this.data.name]);
}
},
error: (error) => {
this.isLoading = false;
this.cdr.markForCheck();
this.isLoading.set(false);
this.errorHandler.showErrorModal(error);
},
});
Expand Down
Loading

0 comments on commit 07149af

Please sign in to comment.