Skip to content

Commit

Permalink
NAS-129579 / 24.10-RC.1 / Add custom app form (by RehanY147) (#10630)
Browse files Browse the repository at this point in the history
* Empty commit to create PR on github.

You should reset it

* NAS-129579: Add custom app form

---------

Co-authored-by: Evgeny Stepanovych <[email protected]>
  • Loading branch information
bugclerk and undsoft authored Sep 9, 2024
1 parent 6c0ef9b commit 1a34f87
Show file tree
Hide file tree
Showing 97 changed files with 672 additions and 166 deletions.
6 changes: 4 additions & 2 deletions src/app/interfaces/app.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,13 @@ export interface ChartReleaseStats {
}

export interface AppCreate {
values: Record<string, ChartFormValue>;
values?: Record<string, ChartFormValue>;
app_name: string;
catalog_app: string;
train: string;
version: string;
version?: string;
custom_compose_config_string?: string;
custom_app?: boolean;
}

export interface AppUpdate {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
border-radius: 2px;
font-size: 12px;

max-height: 300px;
max-height: 700px;
overflow: scroll;
position: relative;

Expand Down
6 changes: 6 additions & 0 deletions src/app/pages/apps/apps-routing.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { marker as T } from '@biesbjerg/ngx-translate-extract-marker';
import { customApp, customAppTrain } from 'app/constants/catalog.constants';
import { AppWizardComponent } from 'app/pages/apps/components/app-wizard/app-wizard.component';
import { AppsScopeWrapperComponent } from 'app/pages/apps/components/apps-scope-wrapper.component';
import { AvailableAppsComponent } from 'app/pages/apps/components/available-apps/available-apps.component';
import { CategoryViewComponent } from 'app/pages/apps/components/available-apps/category-view/category-view.component';
import { CustomAppFormComponent } from 'app/pages/apps/components/custom-app-form/custom-app-form.component';
import { DockerImagesListComponent } from 'app/pages/apps/components/docker-images/docker-images-list/docker-images-list.component';
import { ContainerLogsComponent } from 'app/pages/apps/components/installed-apps/container-logs/container-logs.component';
import { ContainerShellComponent } from 'app/pages/apps/components/installed-apps/container-shell/container-shell.component';
Expand Down Expand Up @@ -86,6 +88,10 @@ const routes: Routes = [
path: ':category',
component: CategoryViewComponent,
},
{
path: `${customAppTrain}/${customApp}/install`,
component: CustomAppFormComponent,
},
{
path: ':train/:appId',
component: AppRouterOutletComponent,
Expand Down
2 changes: 2 additions & 0 deletions src/app/pages/apps/apps.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import { AppWizardComponent } from 'app/pages/apps/components/app-wizard/app-wizard.component';
import { AppsScopeWrapperComponent } from 'app/pages/apps/components/apps-scope-wrapper.component';
import { CatalogSettingsComponent } from 'app/pages/apps/components/catalog-settings/catalog-settings.component';
import { CustomAppFormComponent } from 'app/pages/apps/components/custom-app-form/custom-app-form.component';
import { DockerImageDeleteDialogComponent } from 'app/pages/apps/components/docker-images/docker-image-delete-dialog/docker-image-delete-dialog.component';
import { DockerImagesListComponent } from 'app/pages/apps/components/docker-images/docker-images-list/docker-images-list.component';
import { PullImageFormComponent } from 'app/pages/apps/components/docker-images/pull-image-form/pull-image-form.component';
Expand Down Expand Up @@ -99,6 +100,7 @@ import { InstalledAppsComponent } from './components/installed-apps/installed-ap
AppInfoCardComponent,
AppRowComponent,
AppDetailsPanelComponent,
CustomAppFormComponent,
AppWorkloadsCardComponent,
AppResourcesCardComponent,
AppNotesCardComponent,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
<!-- TODO: https://ixsystems.atlassian.net/browse/NAS-129579 -->
@if (false) {
<div
[matTooltip]="'Setup Pool To Create Custom App' | translate"
[matTooltipDisabled]="!(customAppDisabled$ | async)"
Expand All @@ -16,4 +14,3 @@
{{ 'Custom App' | translate }}
</a>
</div>
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { Router } from '@angular/router';
import { UntilDestroy } from '@ngneat/until-destroy';
import { map } from 'rxjs';
import { customAppTrain, customApp } from 'app/constants/catalog.constants';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { map, tap } from 'rxjs';
import { Role } from 'app/enums/role.enum';
import { customAppButtonElements } from 'app/pages/apps/components/available-apps/custom-app-button/custom-app-button.elements';
import { CustomAppFormComponent } from 'app/pages/apps/components/custom-app-form/custom-app-form.component';
import { DockerStore } from 'app/pages/apps/store/docker.store';
import { IxSlideInService } from 'app/services/ix-slide-in.service';

@UntilDestroy()
@Component({
Expand All @@ -25,9 +26,18 @@ export class CustomAppButtonComponent {
constructor(
private dockerStore: DockerStore,
private router: Router,
private ixSlideIn: IxSlideInService,
) { }

navigateToCustomAppCreation(): void {
this.router.navigate(['/apps', 'available', customAppTrain, customApp, 'install']);
const ref = this.ixSlideIn.open(CustomAppFormComponent, { wide: true });
ref.slideInClosed$.pipe(
tap(Boolean),
untilDestroyed(this),
).subscribe({
next: () => {
this.router.navigate(['/', 'apps']);
},
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<ix-modal-header
[requiredRoles]="requiredRoles"
[title]="'Custom App' | translate"
[loading]="isLoading"
></ix-modal-header>

<mat-card>
<mat-card-content>
<form
class="ix-form-container"
[formGroup]="form"
(submit)="onSubmit()"
>
<ix-input
formControlName="release_name"
[required]="true"
[label]="'Name' | translate"
></ix-input>
<ix-code-editor
formControlName="custom_compose_config_string"
[language]="CodeEditorLanguage.Yaml"
[label]="'Custom Config' | translate"
[tooltip]="tooltip"
[required]="true"
></ix-code-editor>
<ix-form-actions>
<button
*ixRequiresRoles="requiredRoles"
type="submit"
mat-button
color="primary"
ixTest="save"
[disabled]="!form.valid || isLoading"
>
{{ 'Save' | translate }}
</button>
</ix-form-actions>
</form>
</mat-card-content>
</mat-card>
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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 { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { MockModule } 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 { CatalogAppState } from 'app/enums/catalog-app-state.enum';
import { App } from 'app/interfaces/app.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { IxCodeEditorHarness } from 'app/modules/forms/ix-forms/components/ix-code-editor/ix-code-editor.harness';
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 { IxFormsModule } from 'app/modules/forms/ix-forms/ix-forms.module';
import { PageHeaderModule } from 'app/modules/page-header/page-header.module';
import { CustomAppFormComponent } from 'app/pages/apps/components/custom-app-form/custom-app-form.component';
import { ApplicationsService } from 'app/pages/apps/services/applications.service';
import { ErrorHandlerService } from 'app/services/error-handler.service';
import { WebSocketService } from 'app/services/ws.service';

const fakeApp = {
name: 'test-app-one',
version: '1',
id: 'test-app-one',
state: CatalogAppState.Running,
upgrade_available: true,
human_version: '2022.10_1.0.7',
metadata: {
app_version: '2022.10_1.0.8',
icon: 'path-to-icon',
train: 'stable',
},
} as App;

describe('CustomAppFormComponent', () => {
let spectator: Spectator<CustomAppFormComponent>;
let loader: HarnessLoader;

const createComponent = createComponentFactory({
component: CustomAppFormComponent,
imports: [
IxFormsModule,
MockModule(PageHeaderModule),
ReactiveFormsModule,
],
providers: [
mockAuth(),
mockProvider(ApplicationsService, {
getAllApps: jest.fn(() => {
return of([fakeApp]);
}),
}),
mockProvider(ErrorHandlerService),
mockProvider(DialogService, {
jobDialog: jest.fn(() => ({
afterClosed: jest.fn(() => of()),
})),
}),
mockProvider(IxSlideInRef),
mockWebSocket([
mockJob('app.create'),
]),
],
});

beforeEach(() => {
spectator = createComponent();
loader = TestbedHarnessEnvironment.loader(spectator.fixture);
});

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();

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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import {
ChangeDetectionStrategy, ChangeDetectorRef, Component,
OnInit,
} from '@angular/core';
import { Validators } from '@angular/forms';
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 { DialogService } from 'app/modules/dialog/dialog.service';
import { IxSlideInRef } from 'app/modules/forms/ix-forms/components/ix-slide-in/ix-slide-in-ref';
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';
import { WebSocketService } from 'app/services/ws.service';

@UntilDestroy()
@Component({
selector: 'ix-custom-app-form',
templateUrl: './custom-app-form.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CustomAppFormComponent implements OnInit {
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 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>,
) {}

ngOnInit(): void {
this.addForbiddenAppNamesValidator();
}

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();
const data = this.form.value;
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],
),
{
title: this.translate.instant('Custom App'),
canMinimize: false,
description: this.translate.instant('Creating custom app'),
},
).afterClosed().pipe(
untilDestroyed(this),
).subscribe({
next: () => {
this.dialogRef.close();
},
error: (error) => {
this.isLoading = false;
this.cdr.markForCheck();
this.errorHandler.showErrorModal(error);
},
});
}
}
3 changes: 3 additions & 0 deletions src/assets/i18n/af.json
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@
"Add any notes about this zvol.": "",
"Add bucket": "",
"Add catalog to system even if some trains are unhealthy.": "",
"Add custom app config in Yaml format.": "",
"Add entry": "",
"Add groups": "",
"Add iSCSI": "",
Expand Down Expand Up @@ -1043,6 +1044,7 @@
"Created by: {creationSource} ({creationType})": "",
"Creates dataset snapshots even when there have been no changes to the dataset from the last snapshot. Recommended for creating long-term restore points, multiple snapshot tasks pointed at the same datasets, or to be compatible with snapshot schedules or replications created in TrueNAS 11.2 and earlier.<br><br> For example, allowing empty snapshots for a monthly snapshot schedule allows that monthly snapshot to be taken, even when a daily snapshot task has already taken a snapshot of any changes to the dataset.": "",
"Creating ACME Certificate": "",
"Creating custom app": "",
"Creating or editing a <i>sysctl</i> immediately updates the Variable to the configured Value. A restart is required to apply <i>loader</i> or <i>rc.conf</i> tunables. Configured tunables remain in effect until deleted or Enabled is unset.": "",
"Creation Time": "",
"Credential": "",
Expand Down Expand Up @@ -1070,6 +1072,7 @@
"Custom ({customTransfers})": "",
"Custom ACME Server Directory URI": "",
"Custom App": "",
"Custom Config": "",
"Custom Name": "",
"Custom Transfers": "",
"Custom Value": "",
Expand Down
3 changes: 3 additions & 0 deletions src/assets/i18n/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@
"Add any notes about this zvol.": "",
"Add bucket": "",
"Add catalog to system even if some trains are unhealthy.": "",
"Add custom app config in Yaml format.": "",
"Add entry": "",
"Add groups": "",
"Add iSCSI": "",
Expand Down Expand Up @@ -1043,6 +1044,7 @@
"Created by: {creationSource} ({creationType})": "",
"Creates dataset snapshots even when there have been no changes to the dataset from the last snapshot. Recommended for creating long-term restore points, multiple snapshot tasks pointed at the same datasets, or to be compatible with snapshot schedules or replications created in TrueNAS 11.2 and earlier.<br><br> For example, allowing empty snapshots for a monthly snapshot schedule allows that monthly snapshot to be taken, even when a daily snapshot task has already taken a snapshot of any changes to the dataset.": "",
"Creating ACME Certificate": "",
"Creating custom app": "",
"Creating or editing a <i>sysctl</i> immediately updates the Variable to the configured Value. A restart is required to apply <i>loader</i> or <i>rc.conf</i> tunables. Configured tunables remain in effect until deleted or Enabled is unset.": "",
"Creation Time": "",
"Credential": "",
Expand Down Expand Up @@ -1070,6 +1072,7 @@
"Custom ({customTransfers})": "",
"Custom ACME Server Directory URI": "",
"Custom App": "",
"Custom Config": "",
"Custom Name": "",
"Custom Transfers": "",
"Custom Value": "",
Expand Down
Loading

0 comments on commit 1a34f87

Please sign in to comment.