Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NAS-129579 / 24.10-RC.1 / Add custom app form (by RehanY147) #10630

Merged
merged 3 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading