From 922b17ba6bac2f6464ef37f0c79d902db4a487a6 Mon Sep 17 00:00:00 2001 From: Scott Aslan Date: Wed, 24 Sep 2025 17:52:08 -0400 Subject: [PATCH 1/6] NIFI-14320 buckets listing and management, (manage bucket policies is still TODO) --- .../apps/nifi-registry/jest.config.ts | 17 +- .../frontend/apps/nifi-registry/project.json | 10 + .../src/app/app-routing.module.ts | 5 +- .../buckets/feature/buckets-routing.module.ts | 39 ++++ .../buckets/feature/buckets.component.html | 54 +++++ .../buckets/feature/buckets.component.scss | 22 +++ .../buckets/feature/buckets.component.spec.ts | 80 ++++++++ .../buckets/feature/buckets.component.ts | 161 +++++++++++++++ .../pages/buckets/feature/buckets.module.ts | 74 +++++++ .../bucket-table-filter.component.html | 42 ++++ .../bucket-table-filter.component.scss | 16 ++ .../bucket-table-filter.component.spec.ts | 80 ++++++++ .../bucket-table-filter.component.ts | 103 ++++++++++ .../bucket-table/bucket-table.component.html | 95 +++++++++ .../bucket-table/bucket-table.component.scss | 16 ++ .../bucket-table.component.spec.ts | 127 ++++++++++++ .../ui/bucket-table/bucket-table.component.ts | 113 +++++++++++ .../create-bucket-dialog.component.html | 67 +++++++ .../create-bucket-dialog.component.scss | 16 ++ .../create-bucket-dialog.component.spec.ts | 86 ++++++++ .../create-bucket-dialog.component.ts | 85 ++++++++ .../delete-bucket-dialog.component.html | 31 +++ .../delete-bucket-dialog.component.scss | 16 ++ .../delete-bucket-dialog.component.spec.ts | 92 +++++++++ .../delete-bucket-dialog.component.ts | 56 ++++++ .../edit-bucket-dialog.component.html | 78 ++++++++ .../edit-bucket-dialog.component.scss | 16 ++ .../edit-bucket-dialog.component.spec.ts | 111 +++++++++++ .../edit-bucket-dialog.component.ts | 90 +++++++++ ...nage-bucket-policies-dialog.component.html | 30 +++ ...nage-bucket-policies-dialog.component.scss | 16 ++ ...manage-bucket-policies-dialog.component.ts | 36 ++++ .../feature/resources.component.spec.ts | 17 +- .../resources/feature/resources.component.ts | 12 +- .../delete-droplet-dialog.component.spec.ts | 8 +- .../droplet-versions-dialog.component.spec.ts | 8 +- ...t-droplet-version-dialog.component.spec.ts | 8 +- ...mport-new-droplet-dialog.component.spec.ts | 8 +- ...w-droplet-version-dialog.component.spec.ts | 8 +- .../src/app/service/buckets.service.ts | 61 +++++- .../src/app/state/buckets/buckets.actions.ts | 56 +++++- .../src/app/state/buckets/buckets.effects.ts | 185 ++++++++++++++++-- .../src/app/state/buckets/buckets.reducer.ts | 43 +++- .../app/state/buckets/buckets.selectors.ts | 10 + .../app/state/droplets/droplets.actions.ts | 2 - .../app/state/droplets/droplets.effects.ts | 14 -- .../src/app/state/error/index.ts | 3 + .../src/app/ui/header/header.component.html | 5 +- .../src/app/ui/header/header.component.ts | 12 +- .../apps/nifi-registry/src/index.html | 2 +- 50 files changed, 2272 insertions(+), 70 deletions(-) create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets-routing.module.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.html create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.scss create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.spec.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.html create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.scss create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.spec.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.html create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.scss create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.spec.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.html create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.scss create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.spec.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.html create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.scss create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.spec.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.html create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.scss create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.spec.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.scss create mode 100644 nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/jest.config.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/jest.config.ts index 3b71ab7bfb92..a9f8e24d9049 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/jest.config.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/jest.config.ts @@ -17,9 +17,17 @@ export default { displayName: 'NiFi Registry', + clearMocks: true, + coverageDirectory: '../../coverage/apps/nifi-registry', + extensionsToTreatAsEsm: ['.ts'], + preset: '../../jest.preset.js', + + // The test environment that will be used for testing + testEnvironment: '@happy-dom/jest-environment', + setupFilesAfterEnv: ['/src/test-setup.ts'], - coverageDirectory: '../../coverage/apps/nifi-registry', + transform: { '^.+\\.(ts|mjs|js|html)$': [ 'jest-preset-angular', @@ -29,10 +37,5 @@ export default { } ] }, - transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], - snapshotSerializers: [ - 'jest-preset-angular/build/serializers/no-ng-attributes', - 'jest-preset-angular/build/serializers/ng-snapshot', - 'jest-preset-angular/build/serializers/html-comment' - ] + transformIgnorePatterns: [] }; diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/project.json b/nifi-frontend/src/main/frontend/apps/nifi-registry/project.json index 0dcc095b82a0..562c83d622d6 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/project.json +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/project.json @@ -25,6 +25,11 @@ "glob": "**/*.svg", "input": "libs/shared/src/assets/", "output": "./assets" + }, + { + "glob": "**/*.png", + "input": "libs/shared/src/assets/", + "output": "./assets" } ], "styles": ["apps/nifi-registry/src/styles.scss"], @@ -63,6 +68,11 @@ "glob": "**/*.svg", "input": "libs/shared/src/assets/", "output": "./assets" + }, + { + "glob": "**/*.png", + "input": "libs/shared/src/assets/", + "output": "./assets" } ], "fileReplacements": [ diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts index 1895108bbea5..1bb19d7fc31a 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts @@ -28,13 +28,16 @@ const routes: Routes = [ path: 'explorer', loadChildren: () => import('./pages/resources/feature/resources.module').then((m) => m.ResourcesModule) }, + { + path: 'buckets', + loadChildren: () => import('./pages/buckets/feature/buckets.module').then((m) => m.BucketsModule) + }, // Backward compatibility: old app's default route { path: 'nifi-registry', redirectTo: 'explorer', pathMatch: 'full' } - // TODO: buckets // TODO: Users/groups // TODO: Page not found ]; diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets-routing.module.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets-routing.module.ts new file mode 100644 index 000000000000..c1152fc9ea29 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets-routing.module.ts @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { BucketsComponent } from './buckets.component'; + +const routes: Routes = [ + { + path: '', + component: BucketsComponent, + children: [ + { + path: ':id', + component: BucketsComponent + } + ] + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class BucketsRoutingModule {} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.html new file mode 100644 index 000000000000..2061b76294cc --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.html @@ -0,0 +1,54 @@ + + +
+
+

Buckets

+ +
+ +
+ +
+
+ @if (buckets$ | async; as buckets) { + + } + + @if (bucketsState$ | async; as bucketsState) { +
+
+ +
+
+ } +
+
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.scss new file mode 100644 index 000000000000..bdd8410601ac --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.scss @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@use '@angular/material' as mat; + +:host { + height: 100%; +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.spec.ts new file mode 100644 index 000000000000..e0cf59de5df9 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.spec.ts @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BucketsComponent } from './buckets.component'; +import { provideMockStore } from '@ngrx/store/testing'; +import { NiFiCommon } from '@nifi/shared'; +import { BucketTableComponent } from './ui/bucket-table/bucket-table.component'; +import { BucketTableFilterComponent } from './ui/bucket-table-filter/bucket-table-filter.component'; +import { ContextErrorBanner } from '../../../ui/common/context-error-banner/context-error-banner.component'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { Router } from '@angular/router'; + +describe('BucketsComponent', () => { + let component: BucketsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [BucketsComponent], + imports: [ + BucketTableComponent, + BucketTableFilterComponent, + ContextErrorBanner, + MatButtonModule, + MatIconModule + ], + providers: [ + provideMockStore({ + initialState: { + resources: { + buckets: { + buckets: [], + status: 'pending' + } + }, + error: { + bannerErrors: {} + } + } + }), + NiFiCommon, + { provide: Router, useValue: { navigate: jest.fn() } } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(BucketsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize with correct default values', () => { + expect(component.displayedColumns).toEqual(['name', 'description', 'identifier', 'actions']); + expect(component.sort).toEqual({ + active: 'name', + direction: 'asc' + }); + expect(component.filterTerm).toBe(''); + expect(component.filterColumn).toBe('name'); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.ts new file mode 100644 index 000000000000..626f44a119bc --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.ts @@ -0,0 +1,161 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { selectBucketIdFromRoute, selectBuckets, selectBucketsState } from '../../../state/buckets/buckets.selectors'; +import { loadBuckets, openCreateBucketDialog } from '../../../state/buckets/buckets.actions'; +import { Bucket } from '../../../state/buckets'; +import { Store } from '@ngrx/store'; +import { MatTableDataSource } from '@angular/material/table'; +import { Observable, Subject } from 'rxjs'; +import { Sort } from '@angular/material/sort'; +import { NiFiCommon } from '@nifi/shared'; +import { + BucketTableFilterColumn, + BucketTableFilterContext +} from './ui/bucket-table-filter/bucket-table-filter.component'; +import { ErrorContextKey } from '../../../state/error'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'buckets', + templateUrl: './buckets.component.html', + styleUrl: './buckets.component.scss', + standalone: false +}) +export class BucketsComponent implements OnInit, OnDestroy { + private store = inject(Store); + private nifiCommon = inject(NiFiCommon); + private router = inject(Router); + + buckets$: Observable = this.store.select(selectBuckets); + selectedBucketId$ = this.store.select(selectBucketIdFromRoute); + dataSource: MatTableDataSource = new MatTableDataSource(); + displayedColumns: string[] = ['name', 'description', 'identifier', 'actions']; + + filterableColumns: BucketTableFilterColumn[] = [ + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' }, + { key: 'identifier', label: 'Bucket ID' } + ]; + sort: Sort = { + active: 'name', + direction: 'asc' + }; + filterTerm = ''; + filterColumn = 'name'; + bucketsState$ = this.store.select(selectBucketsState); + + private destroy$ = new Subject(); + + ngOnInit(): void { + this.store.dispatch(loadBuckets()); + this.buckets$.subscribe((buckets) => { + this.dataSource.data = [...buckets]; + this.sortData(this.sort); + }); + + this.dataSource.filterPredicate = (data: Bucket, filter: string) => { + if (!filter) { + return true; + } + + const { filterTerm, filterColumn } = JSON.parse(filter); + + if (!filterTerm) { + return true; + } + + const value = filterColumn ? (data as any)[filterColumn] : undefined; + if (typeof value === 'number') { + return value.toString().includes(filterTerm); + } + if (value) { + return this.nifiCommon.stringContains(value, filterTerm, true); + } + // fall back to checking all string fields when column isn't set + return Object.keys(data).some((key) => { + const fieldValue = (data as any)[key]; + if (typeof fieldValue === 'string') { + return this.nifiCommon.stringContains(fieldValue, filterTerm, true); + } + if (typeof fieldValue === 'number') { + return fieldValue.toString().includes(filterTerm); + } + return false; + }); + }; + + this.dataSource.filter = JSON.stringify({ filterTerm: '', filterColumn: this.filterColumn }); + } + + sortData(sort: Sort) { + this.sort = sort; + this.dataSource.data = this.sortBuckets(this.dataSource.data, sort); + } + + sortBuckets(data: Bucket[], sort: Sort): Bucket[] { + if (!data) { + return []; + } + return data.slice().sort((a, b) => { + const isAsc = sort.direction === 'asc'; + let retVal = 0; + switch (sort.active) { + case 'name': + retVal = this.nifiCommon.compareString(a.name, b.name); + break; + case 'description': + retVal = this.nifiCommon.compareString(a.description, b.description); + break; + case 'identifier': + retVal = this.nifiCommon.compareString(a.identifier, b.identifier); + break; + } + return retVal * (isAsc ? 1 : -1); + }); + } + + openCreateBucketDialog() { + this.store.dispatch(openCreateBucketDialog()); + } + + applyFilter(filter: BucketTableFilterContext) { + if (!filter || !this.dataSource) { + return; + } + + this.filterTerm = filter.filterTerm; + this.filterColumn = filter.filterColumn; + this.dataSource.filter = JSON.stringify(filter); + } + + refreshBucketsListing() { + this.store.dispatch(loadBuckets()); + } + + selectBucket(bucket: Bucket): void { + this.router.navigate(['/buckets', bucket.identifier]); + } + + protected readonly ErrorContextKey = ErrorContextKey; + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts new file mode 100644 index 000000000000..03f1f53de553 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts @@ -0,0 +1,74 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule, FormsModule } from '@angular/forms'; +import { StoreModule } from '@ngrx/store'; +import { EffectsModule } from '@ngrx/effects'; +import { BucketsRoutingModule } from './buckets-routing.module'; +import { BucketsComponent } from './buckets.component'; +import { BucketsEffects } from '../../../state/buckets/buckets.effects'; +import { reducers, resourcesFeatureKey } from '../../../state'; +import { MatTableModule } from '@angular/material/table'; +import { MatSortModule } from '@angular/material/sort'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatDialogModule } from '@angular/material/dialog'; +import { BucketTableFilterComponent } from './ui/bucket-table-filter/bucket-table-filter.component'; +import { BucketTableComponent } from './ui/bucket-table/bucket-table.component'; +import { CreateBucketDialogComponent } from './ui/create-bucket-dialog/create-bucket-dialog.component'; +import { EditBucketDialogComponent } from './ui/edit-bucket-dialog/edit-bucket-dialog.component'; +import { DeleteBucketDialogComponent } from './ui/delete-bucket-dialog/delete-bucket-dialog.component'; +import { ManageBucketPoliciesDialogComponent } from './ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component'; +import { ContextErrorBanner } from '../../../ui/common/context-error-banner/context-error-banner.component'; + +@NgModule({ + declarations: [BucketsComponent], + exports: [BucketsComponent], + imports: [ + BucketTableFilterComponent, + BucketTableComponent, + CreateBucketDialogComponent, + EditBucketDialogComponent, + DeleteBucketDialogComponent, + ManageBucketPoliciesDialogComponent, + ContextErrorBanner, + CommonModule, + ReactiveFormsModule, + FormsModule, + MatTableModule, + MatSortModule, + MatMenuModule, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + MatCheckboxModule, + MatDialogModule, + BucketsRoutingModule, + StoreModule.forFeature(resourcesFeatureKey, reducers), + EffectsModule.forFeature([BucketsEffects]) + ] +}) +export class BucketsModule {} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.html new file mode 100644 index 000000000000..beee60ecee67 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.html @@ -0,0 +1,42 @@ + + +
+
+
+
+
+ + Filter + + +
+
+ + Filter By + + @for (option of filterableColumns; track option) { + {{ option.label }} + } + + +
+
+
+
Filter matched {{ filteredCount }} of {{ totalCount }}
+
+
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.scss new file mode 100644 index 000000000000..2944f9819474 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.scss @@ -0,0 +1,16 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.spec.ts new file mode 100644 index 000000000000..54d591dff16e --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.spec.ts @@ -0,0 +1,80 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { BucketTableFilterComponent, BucketTableFilterColumn } from './bucket-table-filter.component'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { By } from '@angular/platform-browser'; +import { ReactiveFormsModule } from '@angular/forms'; + +const columns: BucketTableFilterColumn[] = [ + { key: 'name', label: 'Name' }, + { key: 'description', label: 'Description' } +]; + +describe('BucketTableFilterComponent', () => { + let component: BucketTableFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + BucketTableFilterComponent, + MatFormFieldModule, + MatInputModule, + MatSelectModule, + NoopAnimationsModule, + ReactiveFormsModule + ] + }).compileComponents(); + + fixture = TestBed.createComponent(BucketTableFilterComponent); + component = fixture.componentInstance; + component.filterableColumns = columns; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should emit filter changes when term changes', fakeAsync(() => { + jest.spyOn(component.filterChanged, 'emit'); + component.filterForm.get('filterTerm')?.setValue('test'); + fixture.detectChanges(); + tick(250); // Wait for debounceTime + expect(component.filterChanged.emit).toHaveBeenCalledWith({ + filterTerm: 'test', + filterColumn: 'name', + changedField: 'filterTerm' + }); + })); + + it('should emit filter changes when column changes', fakeAsync(() => { + jest.spyOn(component.filterChanged, 'emit'); + component.filterForm.get('filterColumn')?.setValue('description'); + fixture.detectChanges(); + expect(component.filterChanged.emit).toHaveBeenCalledWith({ + filterTerm: '', + filterColumn: 'description', + changedField: 'filterColumn' + }); + })); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.ts new file mode 100644 index 000000000000..81f7170ef8c6 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.ts @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, EventEmitter, Input, Output, inject } from '@angular/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { debounceTime } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; + +export interface BucketTableFilterColumn { + key: string; + label: string; +} + +export interface BucketTableFilterContext { + filterTerm: string; + filterColumn: string; + changedField: string; +} + +@Component({ + selector: 'bucket-table-filter', + templateUrl: './bucket-table-filter.component.html', + styleUrl: './bucket-table-filter.component.scss', + standalone: true, + imports: [ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatSelectModule] +}) +export class BucketTableFilterComponent { + private formBuilder = inject(FormBuilder); + + filterForm: FormGroup = this.formBuilder.group({ + filterTerm: '', + filterColumn: '' + }); + + private _filterableColumns: BucketTableFilterColumn[] = []; + + @Input() set filterableColumns(columns: BucketTableFilterColumn[]) { + this._filterableColumns = columns ?? []; + if (this._filterableColumns.length > 0) { + const current = this.filterForm.get('filterColumn')?.value; + const valid = this._filterableColumns.some((column) => column.key === current); + const valueToApply = valid ? current : this._filterableColumns[0].key; + this.filterForm.get('filterColumn')?.setValue(valueToApply, { emitEvent: false }); + } + } + get filterableColumns(): BucketTableFilterColumn[] { + return this._filterableColumns; + } + + @Input() set filterTerm(term: string) { + this.filterForm.get('filterTerm')?.setValue(term ?? '', { emitEvent: false }); + } + + @Input() set filterColumn(column: string) { + if (column) { + this.filterForm.get('filterColumn')?.setValue(column, { emitEvent: false }); + } else if (this._filterableColumns.length > 0) { + this.filterForm.get('filterColumn')?.setValue(this._filterableColumns[0].key, { emitEvent: false }); + } + } + + @Input() filteredCount = 0; + @Input() totalCount = 0; + + @Output() filterChanged: EventEmitter = new EventEmitter(); + + constructor() { + this.filterForm + .get('filterTerm') + ?.valueChanges.pipe(debounceTime(250), takeUntilDestroyed()) + .subscribe((term: string) => + this.applyFilter(term ?? '', this.filterForm.get('filterColumn')?.value ?? '', 'filterTerm') + ); + + this.filterForm + .get('filterColumn') + ?.valueChanges.pipe(takeUntilDestroyed()) + .subscribe((column: string) => + this.applyFilter(this.filterForm.get('filterTerm')?.value ?? '', column ?? '', 'filterColumn') + ); + } + + private applyFilter(filterTerm: string, filterColumn: string, changedField: string) { + this.filterChanged.emit({ filterTerm, filterColumn, changedField }); + } +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.html new file mode 100644 index 000000000000..ee6db3c24251 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.html @@ -0,0 +1,95 @@ + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Name +
+ {{ item.name }} +
+
Description +
+ {{ item.description }} +
+
Bucket ID +
+ {{ item.identifier }} +
+
+
+ + + + + + +
+
+ @if (dataSource.data.length === 0) { +
+

There are no buckets to display.

+
+ } +
+
+
+
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.scss new file mode 100644 index 000000000000..2944f9819474 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.scss @@ -0,0 +1,16 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.spec.ts new file mode 100644 index 000000000000..d7e4395b191b --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.spec.ts @@ -0,0 +1,127 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BucketTableComponent } from './bucket-table.component'; +import { MatTableDataSource } from '@angular/material/table'; +import { Bucket } from '../../../../../state/buckets'; +import { provideMockStore } from '@ngrx/store/testing'; +import { NiFiCommon } from '@nifi/shared'; +import { + openDeleteBucketDialog, + openEditBucketDialog, + openManageBucketPoliciesDialog +} from '../../../../../state/buckets/buckets.actions'; +import { Store } from '@ngrx/store'; +import { By } from '@angular/platform-browser'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; + +const createBucket = (overrides: Partial = {}): Bucket => ({ + allowBundleRedeploy: false, + allowPublicRead: false, + createdTimestamp: Date.now(), + description: 'Test bucket', + identifier: 'bucket-1', + link: { + href: '', + params: { rel: '' } + }, + name: 'A Bucket', + permissions: { + canRead: true, + canWrite: true + }, + revision: { + version: 0 + }, + ...overrides +}); + +describe('BucketTableComponent', () => { + let component: BucketTableComponent; + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + BucketTableComponent, + MatTableModule, + MatSortModule, + MatMenuModule, + MatButtonModule, + MatIconModule, + NoopAnimationsModule + ], + providers: [provideMockStore(), NiFiCommon] + }).compileComponents(); + + fixture = TestBed.createComponent(BucketTableComponent); + component = fixture.componentInstance; + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); + + const buckets = [ + createBucket({ identifier: 'bucket-1', name: 'Alpha' }), + createBucket({ identifier: 'bucket-2', name: 'Bravo' }) + ]; + component.dataSource = new MatTableDataSource(buckets); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should emit when a bucket row is selected', () => { + jest.spyOn(component.selectBucket, 'next'); + component.select(component.dataSource.data[0]); + expect(component.selectBucket.next).toHaveBeenCalledWith(component.dataSource.data[0]); + }); + + it('should dispatch edit dialog action', () => { + component.openEditBucketDialog(component.dataSource.data[0]); + expect(store.dispatch).toHaveBeenCalledWith( + openEditBucketDialog({ request: { bucket: component.dataSource.data[0] } }) + ); + }); + + it('should dispatch delete dialog action', () => { + component.openDeleteBucketDialog(component.dataSource.data[0]); + expect(store.dispatch).toHaveBeenCalledWith( + openDeleteBucketDialog({ request: { bucket: component.dataSource.data[0] } }) + ); + }); + + it('should dispatch manage policies dialog action', () => { + component.openManageBucketPoliciesDialog(component.dataSource.data[0]); + expect(store.dispatch).toHaveBeenCalledWith( + openManageBucketPoliciesDialog({ request: { bucket: component.dataSource.data[0] } }) + ); + }); + + it('should sort data on init', () => { + component.sortData({ active: 'name', direction: 'desc' }); + expect(component.dataSource.data[0].name).toBe('Bravo'); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.ts new file mode 100644 index 000000000000..18010931c31f --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.ts @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { Bucket } from 'apps/nifi-registry/src/app/state/buckets'; +import { MatSortModule, Sort } from '@angular/material/sort'; +import { NiFiCommon } from '@nifi/shared'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatButtonModule } from '@angular/material/button'; +import { Store } from '@ngrx/store'; +import { + openDeleteBucketDialog, + openEditBucketDialog, + openManageBucketPoliciesDialog +} from 'apps/nifi-registry/src/app/state/buckets/buckets.actions'; +import { MatIconModule } from '@angular/material/icon'; + +@Component({ + selector: 'bucket-table', + standalone: true, + imports: [MatTableModule, MatSortModule, MatMenuModule, MatButtonModule, MatIconModule], + templateUrl: './bucket-table.component.html', + styleUrl: './bucket-table.component.scss' +}) +export class BucketTableComponent implements OnInit { + private nifiCommon = inject(NiFiCommon); + private store = inject(Store); + + @Input() dataSource: MatTableDataSource = new MatTableDataSource(); + @Input() selectedId: string | null = null; + + @Output() selectBucket: EventEmitter = new EventEmitter(); + + displayedColumns: string[] = ['name', 'description', 'identifier', 'actions']; + sort: Sort = { + active: 'name', + direction: 'asc' + }; + + ngOnInit(): void { + this.sortData(this.sort); + } + + sortData(sort: Sort) { + this.sort = sort; + this.dataSource.data = this.sortBuckets(this.dataSource.data, sort); + } + + sortBuckets(data: Bucket[], sort: Sort): Bucket[] { + if (!data) { + return []; + } + return data.slice().sort((a, b) => { + const isAsc = sort.direction === 'asc'; + let retVal = 0; + switch (sort.active) { + case 'name': + retVal = this.nifiCommon.compareString(a.name, b.name); + break; + case 'description': + retVal = this.nifiCommon.compareString(a.description, b.description); + break; + case 'identifier': + retVal = this.nifiCommon.compareString(a.identifier, b.identifier); + break; + break; + } + return retVal * (isAsc ? 1 : -1); + }); + } + + select(bucket: Bucket) { + this.selectBucket.next(bucket); + } + + isSelected(bucket: Bucket): boolean { + if (this.selectedId) { + return this.selectedId === bucket.identifier; + } + return false; + } + + openEditBucketDialog(bucket: Bucket) { + this.store.dispatch( + openEditBucketDialog({ + request: { bucket } + }) + ); + } + + openDeleteBucketDialog(bucket: Bucket) { + this.store.dispatch(openDeleteBucketDialog({ request: { bucket } })); + } + + openManageBucketPoliciesDialog(bucket: Bucket) { + this.store.dispatch(openManageBucketPoliciesDialog({ request: { bucket } })); + } +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.html new file mode 100644 index 000000000000..4e9ea200f41f --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.html @@ -0,0 +1,67 @@ + + +

New Bucket

+
+
+ + +
+ + Bucket Name + + @if (bucketForm.get('name')?.hasError('required')) { + Name is required + } + @if (bucketForm.get('name')?.hasError('maxlength')) { + Name cannot exceed 255 characters + } + + + Description + + @if (bucketForm.get('description')?.hasError('maxlength')) { + Description cannot exceed 1000 characters + } + + + Allow Public Read Access + + + + + + Keep this dialog open after creating bucket + +
+
+ + + + + +
+
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.scss new file mode 100644 index 000000000000..2944f9819474 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.scss @@ -0,0 +1,16 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.spec.ts new file mode 100644 index 000000000000..f031be73db36 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.spec.ts @@ -0,0 +1,86 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CreateBucketDialogComponent } from './create-bucket-dialog.component'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { FormBuilder } from '@angular/forms'; +import { provideMockStore } from '@ngrx/store/testing'; +import { MatDialogRef } from '@angular/material/dialog'; +import { createBucket } from '../../../../../state/buckets/buckets.actions'; +import { Store } from '@ngrx/store'; + +describe('CreateBucketDialogComponent', () => { + let component: CreateBucketDialogComponent; + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CreateBucketDialogComponent, NoopAnimationsModule], + providers: [ + FormBuilder, + provideMockStore({ + initialState: { + error: { + bannerErrors: {} + } + } + }), + { provide: MatDialogRef, useValue: { close: jest.fn() } } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(CreateBucketDialogComponent); + component = fixture.componentInstance; + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch create bucket action when form valid', () => { + component.bucketForm.patchValue({ + name: 'Test Bucket', + description: 'description', + allowPublicRead: true, + keepDialogOpen: true + }); + + component.onSubmit(); + + expect(store.dispatch).toHaveBeenCalledWith( + createBucket({ + request: { + name: 'Test Bucket', + description: 'description', + allowPublicRead: true + }, + keepDialogOpen: true + }) + ); + }); + + it('should not dispatch when form invalid', () => { + component.bucketForm.patchValue({ name: '', description: '' }); + component.onSubmit(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.ts new file mode 100644 index 000000000000..836c06ef1087 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.ts @@ -0,0 +1,85 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from '@angular/core'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { createBucket, CreateBucketRequest } from '../../../../../state/buckets/buckets.actions'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Store } from '@ngrx/store'; +import { ContextErrorBanner } from '../../../../../ui/common/context-error-banner/context-error-banner.component'; +import { ErrorContextKey } from '../../../../../state/error'; + +@Component({ + selector: 'create-bucket-dialog', + templateUrl: './create-bucket-dialog.component.html', + styleUrl: './create-bucket-dialog.component.scss', + standalone: true, + imports: [ + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatCheckboxModule, + MatIconModule, + ReactiveFormsModule, + MatTooltipModule, + ContextErrorBanner + ] +}) +export class CreateBucketDialogComponent { + private formBuilder = inject(FormBuilder); + bucketForm: FormGroup; + readonly supportsPublicRead = window?.location?.protocol === 'https:'; + private store = inject(Store); + + constructor() { + this.bucketForm = this.formBuilder.group({ + name: ['', [Validators.required, Validators.maxLength(255)]], + description: ['', [Validators.maxLength(1000)]], + allowPublicRead: [{ value: false, disabled: !this.supportsPublicRead }], + keepDialogOpen: [false] + }); + } + + onSubmit(): void { + if (this.bucketForm.valid) { + const rawValue = this.bucketForm.getRawValue(); + const { name, description, allowPublicRead, keepDialogOpen } = rawValue; + + const request: CreateBucketRequest = { + name, + description, + allowPublicRead + }; + + this.store.dispatch( + createBucket({ + request: request, + keepDialogOpen + }) + ); + } + } + + protected readonly ErrorContextKey = ErrorContextKey; +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.html new file mode 100644 index 000000000000..1eed456e9edb --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.html @@ -0,0 +1,31 @@ + + +

Delete Bucket

+ + +
+

All items stored in this bucket will be deleted as well.

+
+
+ + + + + diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.scss new file mode 100644 index 000000000000..2944f9819474 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.scss @@ -0,0 +1,16 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.spec.ts new file mode 100644 index 000000000000..7d674f25c4ee --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.spec.ts @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { DeleteBucketDialogComponent, DeleteBucketDialogData } from './delete-bucket-dialog.component'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { provideMockStore } from '@ngrx/store/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Bucket } from '../../../../../state/buckets'; +import { Store } from '@ngrx/store'; +import { deleteBucket } from '../../../../../state/buckets/buckets.actions'; + +const bucket: Bucket = { + allowBundleRedeploy: false, + allowPublicRead: false, + createdTimestamp: Date.now(), + description: 'desc', + identifier: 'bucket-1', + link: { + href: '', + params: { rel: '' } + }, + name: 'Bucket 1', + permissions: { + canRead: true, + canWrite: true + }, + revision: { + version: 1 + } +}; + +describe('DeleteBucketDialogComponent', () => { + let component: DeleteBucketDialogComponent; + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeleteBucketDialogComponent, NoopAnimationsModule], + providers: [ + provideMockStore({ + initialState: { + error: { + bannerErrors: {} + } + } + }), + { + provide: MAT_DIALOG_DATA, + useValue: { bucket } as DeleteBucketDialogData + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(DeleteBucketDialogComponent); + component = fixture.componentInstance; + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch delete action with correct payload', () => { + component.onDeleteBucket(); + expect(store.dispatch).toHaveBeenCalledWith( + deleteBucket({ + request: { + bucket, + version: bucket.revision.version + } + }) + ); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.ts new file mode 100644 index 000000000000..b5f08659e238 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.ts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from '@angular/core'; +import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { Bucket } from 'apps/nifi-registry/src/app/state/buckets'; +import { ContextErrorBanner } from '../../../../../ui/common/context-error-banner/context-error-banner.component'; +import { ErrorContextKey } from '../../../../../state/error'; +import { deleteBucket } from '../../../../../state/buckets/buckets.actions'; +import { Store } from '@ngrx/store'; + +export interface DeleteBucketDialogData { + bucket: Bucket; +} + +@Component({ + selector: 'delete-bucket-dialog', + templateUrl: './delete-bucket-dialog.component.html', + styleUrl: './delete-bucket-dialog.component.scss', + standalone: true, + imports: [MatDialogModule, MatButtonModule, ContextErrorBanner] +}) +export class DeleteBucketDialogComponent { + protected data = inject(MAT_DIALOG_DATA); + private store = inject(Store); + + onDeleteBucket(): void { + if (this.data.bucket) { + this.store.dispatch( + deleteBucket({ + request: { + bucket: this.data.bucket, + version: this.data.bucket.revision.version + } + }) + ); + } + } + + protected readonly ErrorContextKey = ErrorContextKey; +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.html new file mode 100644 index 000000000000..a447079bdbd7 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.html @@ -0,0 +1,78 @@ + + +

Edit Bucket

+
+ + +
+
+
Id
+
+ {{ data.bucket.identifier }} +
+
+ + Name + + @if (bucketForm.get('name')?.hasError('required')) { + Name is required + } + @if (bucketForm.get('name')?.hasError('maxlength')) { + Name cannot exceed 255 characters + } + + + + Description + + @if (bucketForm.get('description')?.hasError('maxlength')) { + Description cannot exceed 1000 characters + } + + + + Make publicly visible + + + + + + + Allow bundle overwrite + + + + +
+
+ + + + + +
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.scss new file mode 100644 index 000000000000..2944f9819474 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.scss @@ -0,0 +1,16 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.spec.ts new file mode 100644 index 000000000000..f26d62143926 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.spec.ts @@ -0,0 +1,111 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { EditBucketDialogComponent, EditBucketDialogData } from './edit-bucket-dialog.component'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Bucket } from '../../../../../state/buckets'; +import { provideMockStore } from '@ngrx/store/testing'; +import { updateBucket } from '../../../../../state/buckets/buckets.actions'; +import { Store } from '@ngrx/store'; +import { FormBuilder } from '@angular/forms'; + +const bucket: Bucket = { + allowBundleRedeploy: false, + allowPublicRead: false, + createdTimestamp: Date.now(), + description: 'desc', + identifier: 'bucket-1', + link: { + href: '', + params: { rel: '' } + }, + name: 'Bucket 1', + permissions: { + canRead: true, + canWrite: true + }, + revision: { + version: 1 + } +}; + +describe('EditBucketDialogComponent', () => { + let component: EditBucketDialogComponent; + let fixture: ComponentFixture; + let store: Store; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [EditBucketDialogComponent, NoopAnimationsModule], + providers: [ + FormBuilder, + provideMockStore({ + initialState: { + error: { + bannerErrors: {} + } + } + }), + { + provide: MAT_DIALOG_DATA, + useValue: { bucket } as EditBucketDialogData + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(EditBucketDialogComponent); + component = fixture.componentInstance; + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should dispatch update action when form valid', () => { + component.bucketForm.patchValue({ + name: 'Updated', + allowPublicRead: true + }); + component.onSaveBucket(); + + expect(store.dispatch).toHaveBeenCalledWith( + updateBucket({ + request: { + bucket: { + ...bucket, + name: 'Updated', + description: bucket.description, + allowPublicRead: true, + allowBundleRedeploy: bucket.allowBundleRedeploy + } + } + }) + ); + }); + + it('should not dispatch when form invalid', () => { + component.bucketForm.patchValue({ name: '' }); + component.onSaveBucket(); + expect(store.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.ts new file mode 100644 index 000000000000..752633813fcf --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.ts @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from '@angular/core'; +import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Bucket } from 'apps/nifi-registry/src/app/state/buckets'; +import { CopyDirective } from '@nifi/shared'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Store } from '@ngrx/store'; +import { updateBucket } from '../../../../../state/buckets/buckets.actions'; +import { ContextErrorBanner } from '../../../../../ui/common/context-error-banner/context-error-banner.component'; +import { ErrorContextKey } from '../../../../../state/error'; + +export interface EditBucketDialogData { + bucket: Bucket; +} + +@Component({ + selector: 'edit-bucket-dialog', + templateUrl: './edit-bucket-dialog.component.html', + styleUrl: './edit-bucket-dialog.component.scss', + standalone: true, + imports: [ + MatDialogModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatCheckboxModule, + ReactiveFormsModule, + CopyDirective, + MatTooltipModule, + ContextErrorBanner + ] +}) +export class EditBucketDialogComponent { + bucketForm: FormGroup; + protected data = inject(MAT_DIALOG_DATA); + protected formBuilder = inject(FormBuilder); + private store = inject(Store); + + constructor() { + this.bucketForm = this.formBuilder.group({ + name: [this.data.bucket.name, [Validators.required, Validators.maxLength(255)]], + description: [this.data.bucket.description || '', [Validators.maxLength(1000)]], + allowPublicRead: [this.data.bucket.allowPublicRead], + allowBundleRedeploy: [this.data.bucket.allowBundleRedeploy] + }); + } + + onSaveBucket(): void { + if (this.data.bucket && this.bucketForm.valid) { + const bucket: Bucket = { + ...this.data.bucket, + name: this.bucketForm.value.name, + description: this.bucketForm.value.description, + allowPublicRead: this.bucketForm.value.allowPublicRead, + allowBundleRedeploy: this.bucketForm.value.allowBundleRedeploy + }; + + this.store.dispatch( + updateBucket({ + request: { + bucket + } + }) + ); + } + } + + protected readonly ErrorContextKey = ErrorContextKey; +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html new file mode 100644 index 000000000000..7cf225b7ec6a --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.html @@ -0,0 +1,30 @@ + + +

Manage Bucket Policies

+ +
+

+ TODO: Manage policies for bucket: {{ data.bucket.name }} +

+ +
+
+ + + + diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.scss new file mode 100644 index 000000000000..2944f9819474 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.scss @@ -0,0 +1,16 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts new file mode 100644 index 000000000000..1593ce0ae0f2 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.ts @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject } from '@angular/core'; +import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { Bucket } from 'apps/nifi-registry/src/app/state/buckets'; + +export interface ManageBucketPoliciesDialogData { + bucket: Bucket; +} + +@Component({ + selector: 'manage-bucket-policies-dialog', + templateUrl: './manage-bucket-policies-dialog.component.html', + styleUrl: './manage-bucket-policies-dialog.component.scss', + standalone: true, + imports: [MatDialogModule, MatButtonModule] +}) +export class ManageBucketPoliciesDialogComponent { + protected data = inject(MAT_DIALOG_DATA); +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.spec.ts index 252eb510886c..7574886f9c95 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.spec.ts @@ -24,6 +24,11 @@ import { initialState as initialBucketState } from '../../../state/buckets/bucke import { resourcesFeatureKey } from '../../../state'; import { dropletsFeatureKey } from '../../../state/droplets'; import { bucketsFeatureKey } from '../../../state/buckets'; +import { DropletTableComponent } from './ui/droplet-table/droplet-table.component'; +import { DropletTableFilterComponent } from './ui/droplet-table-filter/droplet-table-filter.component'; +import { ContextErrorBanner } from '../../../ui/common/context-error-banner/context-error-banner.component'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; describe('Resources', () => { let component: ResourcesComponent; @@ -32,13 +37,23 @@ describe('Resources', () => { beforeEach(() => { TestBed.configureTestingModule({ declarations: [ResourcesComponent], - imports: [RouterModule], + imports: [ + RouterModule, + DropletTableComponent, + DropletTableFilterComponent, + ContextErrorBanner, + MatButtonModule, + MatIconModule + ], providers: [ provideMockStore({ initialState: { [resourcesFeatureKey]: { [dropletsFeatureKey]: initialState, [bucketsFeatureKey]: initialBucketState + }, + error: { + bannerErrors: {} } } }) diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts index a6688f3bb71b..fe40532b37a4 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts @@ -21,7 +21,7 @@ import { selectDroplets, selectDropletState } from '../../../state/droplets/droplets.selectors'; -import { loadDroplets, openImportNewDropletDialog, selectDroplet } from '../../../state/droplets/droplets.actions'; +import { loadDroplets, openImportNewDropletDialog } from '../../../state/droplets/droplets.actions'; import { Droplet } from '../../../state/droplets'; import { Store } from '@ngrx/store'; import { MatTableDataSource } from '@angular/material/table'; @@ -37,6 +37,7 @@ import { } from './ui/droplet-table-filter/droplet-table-filter.component'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ErrorContextKey } from '../../../state/error'; +import { Router } from '@angular/router'; @Component({ selector: 'resources', @@ -47,6 +48,7 @@ import { ErrorContextKey } from '../../../state/error'; export class ResourcesComponent implements OnInit { private store = inject(Store); private nifiCommon = inject(NiFiCommon); + private router = inject(Router); droplets$: Observable = this.store.select(selectDroplets).pipe(takeUntilDestroyed()); buckets$: Observable = this.store.select(selectBuckets).pipe(takeUntilDestroyed()); @@ -171,13 +173,7 @@ export class ResourcesComponent implements OnInit { } selectDroplet(droplet: Droplet): void { - this.store.dispatch( - selectDroplet({ - request: { - id: droplet.identifier - } - }) - ); + this.router.navigate(['/explorer', droplet.identifier]); } protected readonly ErrorContextKey = ErrorContextKey; diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.spec.ts index 15df3b091dcd..beaad0bc6c4e 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.spec.ts @@ -57,7 +57,13 @@ describe('DeleteDropletDialogComponent', () => { keydownEvents: () => new Subject() } }, - provideMockStore({}) + provideMockStore({ + initialState: { + error: { + bannerErrors: {} + } + } + }) ] }).compileComponents(); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.spec.ts index 95aba2e8a5aa..d4f9e56a5991 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.spec.ts @@ -75,7 +75,13 @@ describe('DropletVersionsDialogComponent', () => { keydownEvents: () => new Subject() } }, - provideMockStore({}) + provideMockStore({ + initialState: { + error: { + bannerErrors: {} + } + } + }) ] }).compileComponents(); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component.spec.ts index 7b8f295ec26d..dbfe86c79ed6 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component.spec.ts @@ -56,7 +56,13 @@ describe('ExportDropletVersionDialogComponent', () => { keydownEvents: () => new Subject() } }, - provideMockStore({}) + provideMockStore({ + initialState: { + error: { + bannerErrors: {} + } + } + }) ] }).compileComponents(); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component.spec.ts index 48bfbd34927e..eb3d34c35dcf 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component.spec.ts @@ -102,7 +102,13 @@ describe('ImportNewDropletDialogComponent', () => { keydownEvents: () => new Subject() } }, - provideMockStore({}) + provideMockStore({ + initialState: { + error: { + bannerErrors: {} + } + } + }) ] }).compileComponents(); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-version-dialog/import-new-droplet-version-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-version-dialog/import-new-droplet-version-dialog.component.spec.ts index c255d01073f5..30a004f1dae4 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-version-dialog/import-new-droplet-version-dialog.component.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-version-dialog/import-new-droplet-version-dialog.component.spec.ts @@ -68,7 +68,13 @@ describe('ImportNewDropletVersionDialogComponent', () => { keydownEvents: () => new Subject() } }, - provideMockStore({}) + provideMockStore({ + initialState: { + error: { + bannerErrors: {} + } + } + }) ] }).compileComponents(); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts index 1ac5412bfc43..cea12323b6cc 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts @@ -18,6 +18,8 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; +import { Bucket } from '../state/buckets'; +import { CreateBucketRequest, DeleteBucketRequest } from '../state/buckets/buckets.actions'; @Injectable({ providedIn: 'root' }) export class BucketsService { @@ -25,7 +27,7 @@ export class BucketsService { private static readonly API: string = '../nifi-registry-api'; - getBuckets(): Observable { + getBuckets(): Observable { // const mockError: HttpErrorResponse = new HttpErrorResponse({ // status: 404, // statusText: 'Bad Gateway', @@ -37,6 +39,61 @@ export class BucketsService { // }); // return throwError(() => mockError); - return this.httpClient.get(`${BucketsService.API}/buckets`); + return this.httpClient.get(`${BucketsService.API}/buckets`); + } + + createBucket(request: CreateBucketRequest): Observable { + // const mockError: HttpErrorResponse = new HttpErrorResponse({ + // status: 404, + // statusText: 'Bad Gateway', + // url: `${BucketsService.API}/buckets`, + // error: { + // message: 'Mock error: unable to create bucket.', + // timestamp: new Date().toISOString() + // } + // }); + // return throwError(() => mockError); + + return this.httpClient.post(`${BucketsService.API}/buckets`, { + ...request, + revision: { + version: 0 + } + }); + } + + updateBucket(request: { bucket: Bucket }): Observable { + // const mockError: HttpErrorResponse = new HttpErrorResponse({ + // status: 404, + // statusText: 'Bad Gateway', + // url: `${BucketsService.API}/buckets`, + // error: { + // message: 'Mock error: unable to update bucket.', + // timestamp: new Date().toISOString() + // } + // }); + // return throwError(() => mockError); + + return this.httpClient.put( + `${BucketsService.API}/buckets/${request.bucket.identifier}`, + request.bucket + ); + } + + deleteBucket(request: DeleteBucketRequest): Observable { + // const mockError: HttpErrorResponse = new HttpErrorResponse({ + // status: 404, + // statusText: 'Bad Gateway', + // url: `${BucketsService.API}/buckets`, + // error: { + // message: 'Mock error: unable to delete bucket.', + // timestamp: new Date().toISOString() + // } + // }); + // return throwError(() => mockError); + + return this.httpClient.delete( + `${BucketsService.API}/buckets/${request.bucket.identifier}?version=${request.version}` + ); } } diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts index 46a3df69d8b8..c612dc3f3e60 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts @@ -16,7 +16,18 @@ */ import { createAction, props } from '@ngrx/store'; -import { LoadBucketsResponse } from '.'; +import { Bucket, LoadBucketsResponse } from '.'; + +export interface CreateBucketRequest { + name: string; + description: string; + allowPublicRead: boolean; +} + +export interface DeleteBucketRequest { + bucket: Bucket; + version: number; +} export const loadBuckets = createAction('[Buckets] Load Buckets'); @@ -24,3 +35,46 @@ export const loadBucketsSuccess = createAction( '[Buckets] Load Buckets Success', props<{ response: LoadBucketsResponse }>() ); + +export const openCreateBucketDialog = createAction('[Buckets] Open Create Bucket Dialog'); + +export const createBucket = createAction( + '[Buckets] Create Bucket', + props<{ request: CreateBucketRequest; keepDialogOpen: boolean }>() +); + +export const createBucketSuccess = createAction( + '[Buckets] Create Bucket Success', + props<{ response: Bucket; keepDialogOpen: boolean }>() +); + +export const createBucketFailure = createAction('[Buckets] Create Bucket Failure'); + +export const openEditBucketDialog = createAction( + '[Buckets] Open Edit Bucket Dialog', + props<{ request: { bucket: Bucket } }>() +); + +export const updateBucket = createAction('[Buckets] Update Bucket', props<{ request: { bucket: Bucket } }>()); + +export const updateBucketSuccess = createAction('[Buckets] Update Bucket Success', props<{ response: Bucket }>()); + +export const updateBucketFailure = createAction('[Buckets] Update Bucket Failure'); + +export const openDeleteBucketDialog = createAction( + '[Buckets] Open Delete Bucket Dialog', + props<{ request: { bucket: Bucket } }>() +); + +export const deleteBucket = createAction('[Buckets] Delete Bucket', props<{ request: DeleteBucketRequest }>()); + +export const deleteBucketSuccess = createAction('[Buckets] Delete Bucket Success', props<{ response: Bucket }>()); + +export const deleteBucketFailure = createAction('[Buckets] Delete Bucket Failure'); + +export const openManageBucketPoliciesDialog = createAction( + '[Buckets] Open Manage Bucket Policies Dialog', + props<{ request: { bucket: Bucket } }>() +); + +export const bucketNoOp = createAction('[Buckets] No Op'); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts index 070ad3a38be5..3e7eefb3b5ef 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts @@ -16,44 +16,191 @@ */ import { inject, Injectable } from '@angular/core'; -import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { catchError, from, map, of, switchMap } from 'rxjs'; import { HttpErrorResponse } from '@angular/common/http'; +import { MatDialog } from '@angular/material/dialog'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { from, of } from 'rxjs'; +import { catchError, map, switchMap, tap } from 'rxjs/operators'; import * as BucketsActions from './buckets.actions'; import { BucketsService } from '../../service/buckets.service'; import { ErrorHelper } from '../../service/error-helper.service'; import { ErrorContextKey } from '../error'; -import * as DropletsActions from '../droplets/droplets.actions'; +import * as ErrorActions from '../error/error.actions'; +import { CreateBucketDialogComponent } from '../../pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component'; +import { EditBucketDialogComponent } from '../../pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component'; +import { DeleteBucketDialogComponent } from '../../pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component'; +import { ManageBucketPoliciesDialogComponent } from '../../pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component'; + +import { LARGE_DIALOG, MEDIUM_DIALOG } from '@nifi/shared'; @Injectable() export class BucketsEffects { private bucketsService = inject(BucketsService); private errorHelper = inject(ErrorHelper); - - actions$ = inject(Actions); + private dialog = inject(MatDialog); + private actions$ = inject(Actions); loadBuckets$ = createEffect(() => this.actions$.pipe( ofType(BucketsActions.loadBuckets), - switchMap(() => { - return from( - this.bucketsService.getBuckets().pipe( - map((response) => - BucketsActions.loadBucketsSuccess({ - response: { - buckets: response - } - }) - ), - catchError((errorResponse: HttpErrorResponse) => of(this.bannerError(errorResponse))) + switchMap(() => + from(this.bucketsService.getBuckets()).pipe( + map((response) => + BucketsActions.loadBucketsSuccess({ + response: { + buckets: response + } + }) + ), + catchError((errorResponse: HttpErrorResponse) => of(this.bannerError(errorResponse))) + ) + ) + ) + ); + + openCreateBucketDialog$ = createEffect( + () => + this.actions$.pipe( + ofType(BucketsActions.openCreateBucketDialog), + tap(() => { + this.dialog.open(CreateBucketDialogComponent, { + ...MEDIUM_DIALOG, + autoFocus: false + }); + }) + ), + { dispatch: false } + ); + + createBucket$ = createEffect(() => + this.actions$.pipe( + ofType(BucketsActions.createBucket), + switchMap(({ request, keepDialogOpen }) => + from(this.bucketsService.createBucket(request)).pipe( + map((bucket) => BucketsActions.createBucketSuccess({ response: bucket, keepDialogOpen })), + catchError((errorResponse: HttpErrorResponse) => + of( + BucketsActions.createBucketFailure(), + this.bannerError(errorResponse, ErrorContextKey.CREATE_BUCKET) + ) + ) + ) + ) + ) + ); + + createBucketSuccess$ = createEffect( + () => + this.actions$.pipe( + ofType(BucketsActions.createBucketSuccess), + tap(({ keepDialogOpen }) => { + if (!keepDialogOpen) { + this.dialog.closeAll(); + } + }) + ), + { dispatch: false } + ); + + openEditBucketDialog$ = createEffect( + () => + this.actions$.pipe( + ofType(BucketsActions.openEditBucketDialog), + tap(({ request }) => { + this.dialog.open(EditBucketDialogComponent, { + ...MEDIUM_DIALOG, + autoFocus: false, + data: { bucket: request.bucket } + }); + }) + ), + { dispatch: false } + ); + + updateBucket$ = createEffect(() => + this.actions$.pipe( + ofType(BucketsActions.updateBucket), + switchMap(({ request }) => + from(this.bucketsService.updateBucket(request)).pipe( + map((bucket) => BucketsActions.updateBucketSuccess({ response: bucket })), + catchError((errorResponse: HttpErrorResponse) => + of( + BucketsActions.updateBucketFailure(), + this.bannerError(errorResponse, ErrorContextKey.UPDATE_BUCKET) + ) + ) + ) + ) + ) + ); + + updateBucketSuccess$ = createEffect( + () => + this.actions$.pipe( + ofType(BucketsActions.updateBucketSuccess), + tap(() => this.dialog.closeAll()) + ), + { dispatch: false } + ); + + openDeleteBucketDialog$ = createEffect( + () => + this.actions$.pipe( + ofType(BucketsActions.openDeleteBucketDialog), + tap(({ request }) => { + this.dialog.open(DeleteBucketDialogComponent, { + ...MEDIUM_DIALOG, + autoFocus: false, + data: { bucket: request.bucket } + }); + }) + ), + { dispatch: false } + ); + + deleteBucket$ = createEffect(() => + this.actions$.pipe( + ofType(BucketsActions.deleteBucket), + switchMap(({ request }) => + from(this.bucketsService.deleteBucket(request)).pipe( + map((bucket) => BucketsActions.deleteBucketSuccess({ response: bucket })), + catchError((errorResponse: HttpErrorResponse) => + of( + BucketsActions.deleteBucketFailure(), + this.bannerError(errorResponse, ErrorContextKey.DELETE_BUCKET) + ) ) - ); - }) + ) + ) ) ); + deleteBucketSuccess$ = createEffect( + () => + this.actions$.pipe( + ofType(BucketsActions.deleteBucketSuccess), + tap(() => this.dialog.closeAll()) + ), + { dispatch: false } + ); + + openManageBucketPoliciesDialog$ = createEffect( + () => + this.actions$.pipe( + ofType(BucketsActions.openManageBucketPoliciesDialog), + tap(({ request }) => { + this.dialog.open(ManageBucketPoliciesDialogComponent, { + ...LARGE_DIALOG, + autoFocus: false, + data: { bucket: request.bucket } + }); + }) + ), + { dispatch: false } + ); + private bannerError(errorResponse: HttpErrorResponse, context: ErrorContextKey = ErrorContextKey.GLOBAL) { - return DropletsActions.dropletsBannerError({ + return ErrorActions.addBannerError({ errorContext: { errors: [this.errorHelper.getErrorString(errorResponse)], context diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.reducer.ts index 657c1f583cea..d609e5a56824 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.reducer.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.reducer.ts @@ -16,8 +16,15 @@ */ import { createReducer, on } from '@ngrx/store'; -import { loadBuckets, loadBucketsSuccess } from './buckets.actions'; -import { BucketsState } from '.'; +import { + createBucketSuccess, + deleteBucketSuccess, + loadBuckets, + loadBucketsSuccess, + updateBucketSuccess +} from './buckets.actions'; +import { Bucket, BucketsState } from '.'; +import { produce } from 'immer'; export const initialState: BucketsState = { buckets: [], @@ -34,5 +41,35 @@ export const bucketsReducer = createReducer( ...state, buckets: response.buckets, status: 'success' as const - })) + })), + on(createBucketSuccess, (state, { response }) => { + return produce(state, (draftState) => { + const componentIndex: number = draftState.buckets.findIndex( + (f: Bucket) => response.identifier === f.identifier + ); + if (componentIndex === -1) { + draftState.buckets.push(response); + } + }); + }), + on(deleteBucketSuccess, (state, { response }) => { + return produce(state, (draftState) => { + const componentIndex: number = draftState.buckets.findIndex( + (f: Bucket) => response.identifier === f.identifier + ); + if (componentIndex > -1) { + draftState.buckets.splice(componentIndex, 1); + } + }); + }), + on(updateBucketSuccess, (state, { response }) => { + return produce(state, (draftState) => { + const componentIndex: number = draftState.buckets.findIndex( + (f: Bucket) => response.identifier === f.identifier + ); + if (componentIndex > -1) { + draftState.buckets[componentIndex] = response; + } + }); + }) ); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts index 7384865a9d26..9ee3476afbde 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts @@ -19,9 +19,19 @@ import { createFeatureSelector, createSelector } from '@ngrx/store'; import { bucketsFeatureKey, BucketsState } from './index'; import { resourcesFeatureKey, ResourcesState } from '..'; +import { selectCurrentRoute } from '@nifi/shared'; export const selectResourcesState = createFeatureSelector(resourcesFeatureKey); export const selectBucketState = createSelector(selectResourcesState, (state) => state[bucketsFeatureKey]); export const selectBuckets = createSelector(selectBucketState, (state: BucketsState) => state.buckets); + +export const selectBucketsState = createSelector(selectBucketState, (state: BucketsState) => state); + +export const selectBucketIdFromRoute = createSelector(selectCurrentRoute, (route) => { + if (route) { + return route.params['id'] ?? null; + } + return null; +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.actions.ts index 9ddecaaaf799..1da5168c7c52 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.actions.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.actions.ts @@ -92,8 +92,6 @@ export const openDropletVersionsDialog = createAction( props<{ request: { droplet: Droplet } }>() ); -export const selectDroplet = createAction(`[Droplets] Select Droplet`, props<{ request: { id: string } }>()); - export const dropletsBannerError = createAction(`[Droplets] Banner Error`, props<{ errorContext: ErrorContext }>()); export const importNewDropletVersionError = createAction( diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.ts index 8e9ef9d32d76..1795555baaca 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.ts @@ -38,7 +38,6 @@ import { } from '../../pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component'; import { DropletVersionsDialogComponent } from '../../pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component'; import { ErrorHelper } from '../../service/error-helper.service'; -import { Router } from '@angular/router'; import { ErrorContextKey } from '../error'; import * as ErrorActions from '../../state/error/error.actions'; @@ -47,7 +46,6 @@ export class DropletsEffects { private dropletsService = inject(DropletsService); private dialog = inject(MatDialog); private errorHelper = inject(ErrorHelper); - private router = inject(Router); actions$ = inject(Actions); @@ -346,18 +344,6 @@ export class DropletsEffects { ) ); - selectDroplet$ = createEffect( - () => - this.actions$.pipe( - ofType(DropletsActions.selectDroplet), - map((action) => action.request), - tap((request) => { - this.router.navigate(['/explorer', request.id]); - }) - ), - { dispatch: false } - ); - dropletsBannerError$ = createEffect(() => this.actions$.pipe( ofType(DropletsActions.dropletsBannerError), diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts index 621c666253f2..a72877274bca 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts @@ -27,6 +27,9 @@ export enum ErrorContextKey { DELETE_DROPLET = 'delete droplet', CREATE_DROPLET = 'create droplet', IMPORT_DROPLET_VERSION = 'import droplet version', + CREATE_BUCKET = 'create bucket', + UPDATE_BUCKET = 'update bucket', + DELETE_BUCKET = 'delete bucket', GLOBAL = 'global' } diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html index 4acdb7f5fb22..01fa77a7d2e5 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html @@ -25,7 +25,7 @@ priority alt="nifi registry logo" class="pointer p-3" - (click)="goHome()" /> + (click)="navigateToResources()" />
NiFi Logo @@ -39,7 +39,8 @@ - + + - - diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.scss deleted file mode 100644 index 2944f9819474..000000000000 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.scss +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.spec.ts deleted file mode 100644 index 7d674f25c4ee..000000000000 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DeleteBucketDialogComponent, DeleteBucketDialogData } from './delete-bucket-dialog.component'; -import { MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { provideMockStore } from '@ngrx/store/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { Bucket } from '../../../../../state/buckets'; -import { Store } from '@ngrx/store'; -import { deleteBucket } from '../../../../../state/buckets/buckets.actions'; - -const bucket: Bucket = { - allowBundleRedeploy: false, - allowPublicRead: false, - createdTimestamp: Date.now(), - description: 'desc', - identifier: 'bucket-1', - link: { - href: '', - params: { rel: '' } - }, - name: 'Bucket 1', - permissions: { - canRead: true, - canWrite: true - }, - revision: { - version: 1 - } -}; - -describe('DeleteBucketDialogComponent', () => { - let component: DeleteBucketDialogComponent; - let fixture: ComponentFixture; - let store: Store; - - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [DeleteBucketDialogComponent, NoopAnimationsModule], - providers: [ - provideMockStore({ - initialState: { - error: { - bannerErrors: {} - } - } - }), - { - provide: MAT_DIALOG_DATA, - useValue: { bucket } as DeleteBucketDialogData - } - ] - }).compileComponents(); - - fixture = TestBed.createComponent(DeleteBucketDialogComponent); - component = fixture.componentInstance; - store = TestBed.inject(Store); - jest.spyOn(store, 'dispatch'); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should dispatch delete action with correct payload', () => { - component.onDeleteBucket(); - expect(store.dispatch).toHaveBeenCalledWith( - deleteBucket({ - request: { - bucket, - version: bucket.revision.version - } - }) - ); - }); -}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.ts deleted file mode 100644 index b5f08659e238..000000000000 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject } from '@angular/core'; -import { MatDialogModule, MAT_DIALOG_DATA } from '@angular/material/dialog'; -import { MatButtonModule } from '@angular/material/button'; -import { Bucket } from 'apps/nifi-registry/src/app/state/buckets'; -import { ContextErrorBanner } from '../../../../../ui/common/context-error-banner/context-error-banner.component'; -import { ErrorContextKey } from '../../../../../state/error'; -import { deleteBucket } from '../../../../../state/buckets/buckets.actions'; -import { Store } from '@ngrx/store'; - -export interface DeleteBucketDialogData { - bucket: Bucket; -} - -@Component({ - selector: 'delete-bucket-dialog', - templateUrl: './delete-bucket-dialog.component.html', - styleUrl: './delete-bucket-dialog.component.scss', - standalone: true, - imports: [MatDialogModule, MatButtonModule, ContextErrorBanner] -}) -export class DeleteBucketDialogComponent { - protected data = inject(MAT_DIALOG_DATA); - private store = inject(Store); - - onDeleteBucket(): void { - if (this.data.bucket) { - this.store.dispatch( - deleteBucket({ - request: { - bucket: this.data.bucket, - version: this.data.bucket.revision.version - } - }) - ); - } - } - - protected readonly ErrorContextKey = ErrorContextKey; -} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.html deleted file mode 100644 index 8ce39f26a176..000000000000 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.html +++ /dev/null @@ -1,28 +0,0 @@ - - -

Delete resource?

-
- - -

This action will delete all versions of {{ droplet.name }}

-
- - - - -
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss deleted file mode 100644 index 2677b47e025f..000000000000 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@use '@angular/material' as mat; - -.delete-droplet-dialog { - @include mat.button-density(-1); -} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.spec.ts deleted file mode 100644 index beaad0bc6c4e..000000000000 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DeleteDropletDialogComponent } from './delete-droplet-dialog.component'; -import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { MatButtonModule } from '@angular/material/button'; -import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { DebugElement } from '@angular/core'; -import { By } from '@angular/platform-browser'; -import { Subject } from 'rxjs'; -import { deleteDroplet } from 'apps/nifi-registry/src/app/state/droplets/droplets.actions'; - -describe('DeleteDropletDialogComponent', () => { - let component: DeleteDropletDialogComponent; - let fixture: ComponentFixture; - let debug: DebugElement; - let store: MockStore; - const mockData = { - bucketIdentifier: '1234', - bucketName: 'testBucket', - createdTimestamp: 123456789, - description: 'testDescription', - identifier: '1234', - link: { href: 'testHref', params: { rel: 'testRel' } }, - modifiedTimestamp: 123456789, - name: 'testName', - permissions: { canRead: true, canWrite: true }, - revision: { version: 1 }, - type: 'FLOW', - versionCount: 2 - }; - - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [DeleteDropletDialogComponent, MatDialogModule, MatButtonModule], - providers: [ - { provide: MAT_DIALOG_DATA, useValue: { droplet: mockData } }, - { - provide: MatDialogRef, - useValue: { - close: () => null, - keydownEvents: () => new Subject() - } - }, - provideMockStore({ - initialState: { - error: { - bannerErrors: {} - } - } - }) - ] - }).compileComponents(); - - store = TestBed.inject(MockStore); - fixture = TestBed.createComponent(DeleteDropletDialogComponent); - component = fixture.componentInstance; - debug = fixture.debugElement; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should delete droplet', () => { - const deleteDropletSpy = jest.spyOn(store, 'dispatch'); - const deleteBtn = debug.query(By.css('[data-test-id=delete-btn]')).nativeElement; - deleteBtn.click(); - expect(deleteDropletSpy).toHaveBeenCalledWith(deleteDroplet({ request: { droplet: mockData } })); - }); -}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.ts deleted file mode 100644 index 926091609c5e..000000000000 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Component, inject } from '@angular/core'; - -import { CloseOnEscapeDialog } from '@nifi/shared'; -import { Store } from '@ngrx/store'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; -import { deleteDroplet } from 'apps/nifi-registry/src/app/state/droplets/droplets.actions'; -import { Droplet } from 'apps/nifi-registry/src/app/state/droplets'; -import { MatButtonModule } from '@angular/material/button'; -import { ErrorContextKey } from 'apps/nifi-registry/src/app/state/error'; -import { ContextErrorBanner } from 'apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component'; - -interface DeleteDropletDialogData { - droplet: Droplet; -} - -@Component({ - selector: 'app-delete-droplet-dialog', - imports: [MatDialogModule, MatButtonModule, ContextErrorBanner], - templateUrl: './delete-droplet-dialog.component.html', - styleUrl: './delete-droplet-dialog.component.scss' -}) -export class DeleteDropletDialogComponent extends CloseOnEscapeDialog { - data = inject(MAT_DIALOG_DATA); - private store = inject(Store); - - protected readonly ErrorContextKey = ErrorContextKey; - droplet: Droplet; - - constructor() { - super(); - const data = this.data; - - this.droplet = data.droplet; - } - - deleteDroplet(droplet: Droplet) { - this.store.dispatch(deleteDroplet({ request: { droplet } })); - } -} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts new file mode 100644 index 000000000000..107b1bef37f9 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts @@ -0,0 +1,389 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { Observable, of, throwError } from 'rxjs'; +import { BucketsEffects } from './buckets.effects'; +import { BucketsService } from '../../service/buckets.service'; +import { ErrorHelper } from '../../service/error-helper.service'; +import { MatDialog } from '@angular/material/dialog'; +import * as BucketsActions from './buckets.actions'; +import * as ErrorActions from '../error/error.actions'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ErrorContextKey } from '../error'; +import { CreateBucketDialogComponent } from '../../pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component'; +import { ManageBucketPoliciesDialogComponent } from '../../pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component'; +import { YesNoDialog } from '@nifi/shared'; +import { Store } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { Bucket } from './index'; + +const createBucket = (overrides = {}): Bucket => ({ + identifier: 'bucket-1', + name: 'Test Bucket', + description: 'Test Description', + allowBundleRedeploy: false, + allowPublicRead: false, + createdTimestamp: 1632924000000, + revision: { + version: 1 + }, + permissions: { + canRead: true, + canWrite: true + }, + link: { + href: '/nifi-registry-api/buckets/bucket-1', + params: { + rel: 'self' + } + }, + ...overrides +}); + +describe('BucketsEffects', () => { + let actions$: Observable; + let effects: BucketsEffects; + let bucketsService: jest.Mocked; + let errorHelper: jest.Mocked; + let dialog: jest.Mocked; + let store: Store; + + beforeEach(() => { + const mockBucketsService = { + getBuckets: jest.fn(), + createBucket: jest.fn(), + updateBucket: jest.fn(), + deleteBucket: jest.fn() + }; + + const mockErrorHelper = { + getErrorString: jest.fn() + }; + + const mockDialog = { + open: jest.fn(), + closeAll: jest.fn() + }; + + TestBed.configureTestingModule({ + providers: [ + BucketsEffects, + provideMockActions(() => actions$), + provideMockStore(), + { provide: BucketsService, useValue: mockBucketsService }, + { provide: ErrorHelper, useValue: mockErrorHelper }, + { provide: MatDialog, useValue: mockDialog } + ] + }); + + effects = TestBed.inject(BucketsEffects); + bucketsService = TestBed.inject(BucketsService) as jest.Mocked; + errorHelper = TestBed.inject(ErrorHelper) as jest.Mocked; + dialog = TestBed.inject(MatDialog) as jest.Mocked; + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); + }); + + describe('loadBuckets$', () => { + it('should return loadBucketsSuccess with buckets on success', (done) => { + const buckets = [createBucket(), createBucket({ identifier: 'bucket-2' })]; + bucketsService.getBuckets.mockReturnValue(of(buckets)); + + actions$ = of(BucketsActions.loadBuckets()); + + effects.loadBuckets$.subscribe((action) => { + expect(action).toEqual( + BucketsActions.loadBucketsSuccess({ + response: { buckets } + }) + ); + done(); + }); + }); + + it('should return error action on failure', (done) => { + const error = new HttpErrorResponse({ status: 404, statusText: 'Not Found' }); + bucketsService.getBuckets.mockReturnValue(throwError(() => error)); + errorHelper.getErrorString.mockReturnValue('Error loading buckets'); + + actions$ = of(BucketsActions.loadBuckets()); + + effects.loadBuckets$.subscribe((action) => { + expect(action).toEqual( + ErrorActions.addBannerError({ + errorContext: { + errors: ['Error loading buckets'], + context: ErrorContextKey.GLOBAL + } + }) + ); + done(); + }); + }); + }); + + describe('openCreateBucketDialog$', () => { + it('should open create bucket dialog', (done) => { + actions$ = of(BucketsActions.openCreateBucketDialog()); + + effects.openCreateBucketDialog$.subscribe(() => { + expect(dialog.open).toHaveBeenCalledWith( + CreateBucketDialogComponent, + expect.objectContaining({ + autoFocus: false + }) + ); + done(); + }); + }); + }); + + describe('createBucket$', () => { + const bucket = createBucket(); + const request = { + name: bucket.name, + description: bucket.description, + allowPublicRead: bucket.allowPublicRead + }; + + it('should return createBucketSuccess on success', (done) => { + bucketsService.createBucket.mockReturnValue(of(bucket)); + + actions$ = of(BucketsActions.createBucket({ request, keepDialogOpen: false })); + + effects.createBucket$.subscribe((action) => { + expect(action).toEqual( + BucketsActions.createBucketSuccess({ + response: bucket, + keepDialogOpen: false + }) + ); + done(); + }); + }); + + it('should return error actions on failure', (done) => { + const error = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' }); + bucketsService.createBucket.mockReturnValue(throwError(() => error)); + errorHelper.getErrorString.mockReturnValue('Error creating bucket'); + + actions$ = of(BucketsActions.createBucket({ request, keepDialogOpen: false })); + + let actionCount = 0; + effects.createBucket$.subscribe((action) => { + if (actionCount === 0) { + expect(action).toEqual(BucketsActions.createBucketFailure()); + } else { + expect(action).toEqual( + ErrorActions.addBannerError({ + errorContext: { + errors: ['Error creating bucket'], + context: ErrorContextKey.CREATE_BUCKET + } + }) + ); + done(); + } + actionCount++; + }); + }); + }); + + describe('updateBucket$', () => { + const bucket = createBucket(); + const request = { bucket }; + + it('should return updateBucketSuccess on success', (done) => { + bucketsService.updateBucket.mockReturnValue(of(bucket)); + + actions$ = of(BucketsActions.updateBucket({ request })); + + effects.updateBucket$.subscribe((action) => { + expect(action).toEqual( + BucketsActions.updateBucketSuccess({ + response: bucket + }) + ); + done(); + }); + }); + + it('should return error actions on failure', (done) => { + const error = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' }); + bucketsService.updateBucket.mockReturnValue(throwError(() => error)); + errorHelper.getErrorString.mockReturnValue('Error updating bucket'); + + actions$ = of(BucketsActions.updateBucket({ request })); + + let actionCount = 0; + effects.updateBucket$.subscribe((action) => { + if (actionCount === 0) { + expect(action).toEqual(BucketsActions.updateBucketFailure()); + } else { + expect(action).toEqual( + ErrorActions.addBannerError({ + errorContext: { + errors: ['Error updating bucket'], + context: ErrorContextKey.UPDATE_BUCKET + } + }) + ); + done(); + } + actionCount++; + }); + }); + }); + + describe('deleteBucket$', () => { + const bucket = createBucket(); + const request = { bucket, version: bucket.revision.version }; + + it('should return deleteBucketSuccess on success', (done) => { + bucketsService.deleteBucket.mockReturnValue(of(bucket)); + + actions$ = of(BucketsActions.deleteBucket({ request })); + + effects.deleteBucket$.subscribe((action) => { + expect(action).toEqual( + BucketsActions.deleteBucketSuccess({ + response: bucket + }) + ); + done(); + }); + }); + + it('should return error actions on failure', (done) => { + const error = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' }); + bucketsService.deleteBucket.mockReturnValue(throwError(() => error)); + errorHelper.getErrorString.mockReturnValue('Error deleting bucket'); + + actions$ = of(BucketsActions.deleteBucket({ request })); + + let actionCount = 0; + effects.deleteBucket$.subscribe((action) => { + if (actionCount === 0) { + expect(action).toEqual(BucketsActions.deleteBucketFailure()); + } else { + expect(action).toEqual( + ErrorActions.snackBarError({ + error: 'Error deleting bucket' + }) + ); + done(); + } + actionCount++; + }); + }); + }); + + describe('openDeleteBucketDialog$', () => { + it('should open delete confirmation dialog', (done) => { + const bucket = createBucket(); + const mockDialogRef = { + componentInstance: { + yes: of(true) + } + }; + dialog.open.mockReturnValue(mockDialogRef as any); + + actions$ = of(BucketsActions.openDeleteBucketDialog({ request: { bucket } })); + + effects.openDeleteBucketDialog$.subscribe(() => { + expect(dialog.open).toHaveBeenCalledWith( + YesNoDialog, + expect.objectContaining({ + data: { + title: 'Delete Bucket', + message: 'All items stored in this bucket will be deleted as well.' + } + }) + ); + expect(store.dispatch).toHaveBeenCalledWith( + BucketsActions.deleteBucket({ + request: { + bucket, + version: bucket.revision.version + } + }) + ); + done(); + }); + }); + }); + + describe('openManageBucketPoliciesDialog$', () => { + it('should open manage bucket policies dialog', (done) => { + const bucket = createBucket(); + + actions$ = of(BucketsActions.openManageBucketPoliciesDialog({ request: { bucket } })); + + effects.openManageBucketPoliciesDialog$.subscribe(() => { + expect(dialog.open).toHaveBeenCalledWith( + ManageBucketPoliciesDialogComponent, + expect.objectContaining({ + autoFocus: false, + data: { bucket } + }) + ); + done(); + }); + }); + }); + + describe('dialog closing effects', () => { + it('should close dialogs on createBucketSuccess when keepDialogOpen is false', (done) => { + actions$ = of(BucketsActions.createBucketSuccess({ response: createBucket(), keepDialogOpen: false })); + + effects.createBucketSuccess$.subscribe(() => { + expect(dialog.closeAll).toHaveBeenCalled(); + done(); + }); + }); + + it('should not close dialogs on createBucketSuccess when keepDialogOpen is true', (done) => { + actions$ = of(BucketsActions.createBucketSuccess({ response: createBucket(), keepDialogOpen: true })); + + effects.createBucketSuccess$.subscribe(() => { + expect(dialog.closeAll).not.toHaveBeenCalled(); + done(); + }); + }); + + it('should close dialogs on updateBucketSuccess', (done) => { + actions$ = of(BucketsActions.updateBucketSuccess({ response: createBucket() })); + + effects.updateBucketSuccess$.subscribe(() => { + expect(dialog.closeAll).toHaveBeenCalled(); + done(); + }); + }); + + it('should close dialogs on deleteBucketSuccess', (done) => { + actions$ = of(BucketsActions.deleteBucketSuccess({ response: createBucket() })); + + effects.deleteBucketSuccess$.subscribe(() => { + expect(dialog.closeAll).toHaveBeenCalled(); + done(); + }); + }); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts index 3e7eefb3b5ef..39bedcb88923 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts @@ -19,7 +19,7 @@ import { inject, Injectable } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { MatDialog } from '@angular/material/dialog'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { from, of } from 'rxjs'; +import { from, of, take } from 'rxjs'; import { catchError, map, switchMap, tap } from 'rxjs/operators'; import * as BucketsActions from './buckets.actions'; import { BucketsService } from '../../service/buckets.service'; @@ -28,10 +28,11 @@ import { ErrorContextKey } from '../error'; import * as ErrorActions from '../error/error.actions'; import { CreateBucketDialogComponent } from '../../pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component'; import { EditBucketDialogComponent } from '../../pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component'; -import { DeleteBucketDialogComponent } from '../../pages/buckets/feature/ui/delete-bucket-dialog/delete-bucket-dialog.component'; import { ManageBucketPoliciesDialogComponent } from '../../pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component'; - -import { LARGE_DIALOG, MEDIUM_DIALOG } from '@nifi/shared'; +import { LARGE_DIALOG, MEDIUM_DIALOG, SMALL_DIALOG, YesNoDialog } from '@nifi/shared'; +import { deleteBucket } from './buckets.actions'; +import { Store } from '@ngrx/store'; +import { NiFiState } from '../../../../../nifi/src/app/state'; @Injectable() export class BucketsEffects { @@ -39,6 +40,7 @@ export class BucketsEffects { private errorHelper = inject(ErrorHelper); private dialog = inject(MatDialog); private actions$ = inject(Actions); + private store = inject>(Store); loadBuckets$ = createEffect(() => this.actions$.pipe( @@ -148,10 +150,22 @@ export class BucketsEffects { this.actions$.pipe( ofType(BucketsActions.openDeleteBucketDialog), tap(({ request }) => { - this.dialog.open(DeleteBucketDialogComponent, { - ...MEDIUM_DIALOG, - autoFocus: false, - data: { bucket: request.bucket } + const dialogRef = this.dialog.open(YesNoDialog, { + ...SMALL_DIALOG, + data: { + title: 'Delete Bucket', + message: `All items stored in this bucket will be deleted as well.` + } + }); + dialogRef.componentInstance.yes.pipe(take(1)).subscribe(() => { + this.store.dispatch( + deleteBucket({ + request: { + bucket: request.bucket, + version: request.bucket.revision.version + } + }) + ); }); }) ), @@ -167,7 +181,7 @@ export class BucketsEffects { catchError((errorResponse: HttpErrorResponse) => of( BucketsActions.deleteBucketFailure(), - this.bannerError(errorResponse, ErrorContextKey.DELETE_BUCKET) + ErrorActions.snackBarError({ error: this.errorHelper.getErrorString(errorResponse) }) ) ) ) diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.actions.ts index 1da5168c7c52..276087610844 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.actions.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.actions.ts @@ -42,6 +42,8 @@ export const deleteDroplet = createAction('[Droplets] Delete Droplet', props<{ r export const deleteDropletSuccess = createAction('[Droplets] Delete Droplet Success', props<{ response: Droplet }>()); +export const deleteDropletFailure = createAction('[Droplets] Delete Droplet Failure'); + export const openImportNewDropletDialog = createAction( '[Droplets] Open Import New Droplet Dialog', props<{ request: ImportDropletDialog }>() diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.spec.ts new file mode 100644 index 000000000000..4280e8c6c7a5 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.spec.ts @@ -0,0 +1,496 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { Observable, of, throwError } from 'rxjs'; +import { DropletsEffects } from './droplets.effects'; +import { DropletsService } from '../../service/droplets.service'; +import { ErrorHelper } from '../../service/error-helper.service'; +import { MatDialog } from '@angular/material/dialog'; +import * as DropletsActions from './droplets.actions'; +import * as ErrorActions from '../../state/error/error.actions'; +import { HttpErrorResponse, HttpHeaders, HttpResponse } from '@angular/common/http'; +import { ErrorContextKey } from '../error'; +import { ImportNewDropletDialogComponent } from '../../pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component'; +import { DropletVersionsDialogComponent } from '../../pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component'; +import { YesNoDialog } from '@nifi/shared'; +import { Store } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; +import { Bucket } from '../buckets'; + +const createDroplet = (overrides = {}) => ({ + identifier: 'droplet-1', + name: 'Test Droplet', + description: 'Test Description', + bucketIdentifier: 'bucket-1', + bucketName: 'Test Bucket', + createdTimestamp: 1632924000000, + modifiedTimestamp: 1632924000000, + type: 'FLOW', + permissions: { + canRead: true, + canWrite: true, + canDelete: true + }, + revision: { + version: 1 + }, + link: { + href: '/nifi-registry-api/buckets/bucket-1/flows/droplet-1', + params: { + rel: 'self' + } + }, + versionCount: 1, + ...overrides +}); + +const createBucket = (overrides = {}): Bucket => ({ + identifier: 'bucket-1', + name: 'Test Bucket', + description: 'Test Description', + createdTimestamp: 1632924000000, + allowBundleRedeploy: false, + allowPublicRead: false, + permissions: { + canRead: true, + canWrite: true + }, + revision: { + version: 1 + }, + link: { + href: '/nifi-registry-api/buckets/bucket-1', + params: { + rel: 'self' + } + }, + ...overrides +}); + +describe('DropletsEffects', () => { + let actions$: Observable; + let effects: DropletsEffects; + let dropletsService: jest.Mocked; + let errorHelper: jest.Mocked; + let dialog: jest.Mocked; + let store: Store; + + beforeEach(() => { + const mockDropletsService = { + getDroplets: jest.fn(), + deleteDroplet: jest.fn(), + createNewDroplet: jest.fn(), + uploadDroplet: jest.fn(), + exportDropletVersionedSnapshot: jest.fn(), + getDropletSnapshotMetadata: jest.fn() + }; + + const mockErrorHelper = { + getErrorString: jest.fn() + }; + + const mockDialog = { + open: jest.fn(), + closeAll: jest.fn() + }; + + TestBed.configureTestingModule({ + providers: [ + DropletsEffects, + provideMockActions(() => actions$), + provideMockStore(), + { provide: DropletsService, useValue: mockDropletsService }, + { provide: ErrorHelper, useValue: mockErrorHelper }, + { provide: MatDialog, useValue: mockDialog } + ] + }); + + effects = TestBed.inject(DropletsEffects); + dropletsService = TestBed.inject(DropletsService) as jest.Mocked; + errorHelper = TestBed.inject(ErrorHelper) as jest.Mocked; + dialog = TestBed.inject(MatDialog) as jest.Mocked; + store = TestBed.inject(Store); + jest.spyOn(store, 'dispatch'); + }); + + describe('loadDroplets$', () => { + it('should return loadDropletsSuccess with droplets on success', (done) => { + const droplets = [createDroplet(), createDroplet({ identifier: 'droplet-2' })]; + dropletsService.getDroplets.mockReturnValue(of(droplets)); + + actions$ = of(DropletsActions.loadDroplets()); + + effects.loadDroplets$.subscribe((action) => { + expect(action).toEqual( + DropletsActions.loadDropletsSuccess({ + response: { droplets } + }) + ); + done(); + }); + }); + + it('should return error action on failure', (done) => { + const error = new HttpErrorResponse({ status: 404, statusText: 'Not Found' }); + dropletsService.getDroplets.mockReturnValue(throwError(() => error)); + errorHelper.getErrorString.mockReturnValue('Error loading droplets'); + + actions$ = of(DropletsActions.loadDroplets()); + + effects.loadDroplets$.subscribe((action) => { + expect(action).toEqual( + DropletsActions.dropletsBannerError({ + errorContext: { + errors: ['Error loading droplets'], + context: ErrorContextKey.GLOBAL + } + }) + ); + done(); + }); + }); + }); + + describe('openDeleteDropletDialog$', () => { + it('should open delete confirmation dialog and dispatch delete action on confirmation', (done) => { + const droplet = createDroplet(); + const mockDialogRef = { + componentInstance: { + yes: of(true) + } + }; + dialog.open.mockReturnValue(mockDialogRef as any); + + actions$ = of(DropletsActions.openDeleteDropletDialog({ request: { droplet } })); + + effects.openDeleteDropletDialog$.subscribe(() => { + expect(dialog.open).toHaveBeenCalledWith( + YesNoDialog, + expect.objectContaining({ + data: { + title: 'Delete resource', + message: `This action will delete all versions of ${droplet.name}` + } + }) + ); + expect(store.dispatch).toHaveBeenCalledWith( + DropletsActions.deleteDroplet({ + request: { droplet } + }) + ); + done(); + }); + }); + }); + + describe('deleteDroplet$', () => { + const droplet = createDroplet(); + + it('should return deleteDropletSuccess on success', (done) => { + dropletsService.deleteDroplet.mockReturnValue(of(droplet)); + + actions$ = of(DropletsActions.deleteDroplet({ request: { droplet } })); + + effects.deleteDroplet$.subscribe((action) => { + expect(action).toEqual( + DropletsActions.deleteDropletSuccess({ + response: droplet + }) + ); + done(); + }); + }); + + it('should return error actions on failure', (done) => { + const error = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' }); + dropletsService.deleteDroplet.mockReturnValue(throwError(() => error)); + errorHelper.getErrorString.mockReturnValue('Error deleting droplet'); + + actions$ = of(DropletsActions.deleteDroplet({ request: { droplet } })); + + let actionCount = 0; + effects.deleteDroplet$.subscribe((action) => { + if (actionCount === 0) { + expect(action).toEqual(DropletsActions.deleteDropletFailure()); + } else { + expect(action).toEqual( + ErrorActions.snackBarError({ + error: 'Error deleting droplet' + }) + ); + done(); + } + actionCount++; + }); + }); + }); + + describe('openImportNewDropletDialog$', () => { + it('should open import new droplet dialog', (done) => { + const buckets = [createBucket(), createBucket({ identifier: 'bucket-2' })]; + + actions$ = of(DropletsActions.openImportNewDropletDialog({ request: { buckets } })); + + effects.openImportNewDropletDialog$.subscribe(() => { + expect(dialog.open).toHaveBeenCalledWith( + ImportNewDropletDialogComponent, + expect.objectContaining({ + autoFocus: false, + data: { buckets } + }) + ); + done(); + }); + }); + }); + + describe('createNewDroplet$', () => { + const bucket = createBucket(); + const request = { + bucket, + name: 'New Droplet', + description: 'New Description', + file: new File([], 'test.json') + }; + + it('should return createNewDropletSuccess and trigger version import on success', (done) => { + const newDroplet = createDroplet({ name: request.name, description: request.description }); + dropletsService.createNewDroplet.mockReturnValue(of(newDroplet)); + + actions$ = of(DropletsActions.createNewDroplet({ request })); + + effects.createNewDroplet$.subscribe((action) => { + expect(action).toEqual( + DropletsActions.createNewDropletSuccess({ + response: newDroplet, + request: { + href: newDroplet.link.href, + file: request.file, + description: request.description + } + }) + ); + done(); + }); + }); + + it('should return error action on failure', (done) => { + const error = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' }); + dropletsService.createNewDroplet.mockReturnValue(throwError(() => error)); + errorHelper.getErrorString.mockReturnValue('Error creating droplet'); + + actions$ = of(DropletsActions.createNewDroplet({ request })); + + effects.createNewDroplet$.subscribe((action) => { + expect(action).toEqual( + DropletsActions.dropletsBannerError({ + errorContext: { + errors: ['Error creating droplet'], + context: ErrorContextKey.CREATE_DROPLET + } + }) + ); + done(); + }); + }); + }); + + describe('importNewDropletVersion$', () => { + const droplet = createDroplet(); + const request = { + href: droplet.link.href, + file: new File([], 'test.json'), + description: 'New Version Description' + }; + + it('should return importNewDropletVersionSuccess on success', (done) => { + dropletsService.uploadDroplet.mockReturnValue(of(droplet)); + + actions$ = of(DropletsActions.importNewDropletVersion({ request })); + + effects.importNewDroplet$.subscribe((action) => { + expect(action).toEqual( + DropletsActions.importNewDropletVersionSuccess({ + response: droplet + }) + ); + done(); + }); + }); + + it('should return error action on failure', (done) => { + const error = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' }); + dropletsService.uploadDroplet.mockReturnValue(throwError(() => error)); + errorHelper.getErrorString.mockReturnValue('Error importing version'); + + actions$ = of(DropletsActions.importNewDropletVersion({ request })); + + effects.importNewDroplet$.subscribe((action) => { + expect(action).toEqual( + DropletsActions.dropletsBannerError({ + errorContext: { + errors: ['Error importing version'], + context: ErrorContextKey.IMPORT_DROPLET_VERSION + } + }) + ); + done(); + }); + }); + }); + + describe('exportDropletVersion$', () => { + const droplet = createDroplet(); + const request = { + droplet, + version: 1 + }; + + it('should return exportDropletVersionSuccess and trigger download on success', (done) => { + const headers = new HttpHeaders().set('Filename', 'test.json'); + const mockResponse = new HttpResponse({ + body: JSON.stringify({ content: 'test' }), + headers + }); + dropletsService.exportDropletVersionedSnapshot.mockReturnValue(of(mockResponse)); + + // Mock document methods + const mockAnchor = { + href: '', + download: '', + setAttribute: jest.fn(), + click: jest.fn() + }; + jest.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any); + jest.spyOn(document.body, 'appendChild').mockImplementation(); + jest.spyOn(document.body, 'removeChild').mockImplementation(); + + actions$ = of(DropletsActions.exportDropletVersion({ request })); + + effects.exportDropletVersion$.subscribe((action) => { + expect(action).toEqual( + DropletsActions.exportDropletVersionSuccess({ + response: mockResponse + }) + ); + expect(document.createElement).toHaveBeenCalledWith('a'); + expect(mockAnchor.click).toHaveBeenCalled(); + done(); + }); + }); + + it('should return error action on failure', (done) => { + const error = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' }); + dropletsService.exportDropletVersionedSnapshot.mockReturnValue(throwError(() => error)); + errorHelper.getErrorString.mockReturnValue('Error exporting version'); + + actions$ = of(DropletsActions.exportDropletVersion({ request })); + + effects.exportDropletVersion$.subscribe((action) => { + expect(action).toEqual( + DropletsActions.dropletsBannerError({ + errorContext: { + errors: ['Error exporting version'], + context: ErrorContextKey.EXPORT_DROPLET_VERSION + } + }) + ); + done(); + }); + }); + }); + + describe('openDropletVersionsDialog$', () => { + it('should open versions dialog with metadata', (done) => { + const droplet = createDroplet(); + const versions = [{ version: 1, userIdentity: 'user1', timestamp: Date.now() }]; + dropletsService.getDropletSnapshotMetadata.mockReturnValue(of(versions)); + + actions$ = of(DropletsActions.openDropletVersionsDialog({ request: { droplet } })); + + effects.openDropletVersionsDialog$.subscribe((action) => { + expect(dialog.open).toHaveBeenCalledWith( + DropletVersionsDialogComponent, + expect.objectContaining({ + autoFocus: false, + data: { droplet, versions } + }) + ); + expect(action).toEqual(DropletsActions.noOp()); + done(); + }); + }); + + it('should return error action on metadata fetch failure', (done) => { + const droplet = createDroplet(); + const error = new HttpErrorResponse({ status: 400, statusText: 'Bad Request' }); + dropletsService.getDropletSnapshotMetadata.mockReturnValue(throwError(() => error)); + errorHelper.getErrorString.mockReturnValue('Error fetching versions'); + + actions$ = of(DropletsActions.openDropletVersionsDialog({ request: { droplet } })); + + effects.openDropletVersionsDialog$.subscribe((action) => { + expect(action).toEqual( + DropletsActions.dropletsBannerError({ + errorContext: { + errors: ['Error fetching versions'], + context: ErrorContextKey.GLOBAL + } + }) + ); + done(); + }); + }); + }); + + describe('dialog closing effects', () => { + it('should close dialogs on deleteDropletSuccess', (done) => { + actions$ = of(DropletsActions.deleteDropletSuccess({ response: createDroplet() })); + + effects.deleteDropletSuccess$.subscribe(() => { + expect(dialog.closeAll).toHaveBeenCalled(); + done(); + }); + }); + + it('should close dialogs on importNewDropletVersionSuccess', (done) => { + actions$ = of(DropletsActions.importNewDropletVersionSuccess({ response: createDroplet() })); + + effects.importNewDropletSuccess$.subscribe(() => { + expect(dialog.closeAll).toHaveBeenCalled(); + done(); + }); + }); + + it('should close dialogs on exportDropletVersionSuccess', (done) => { + const headers = new HttpHeaders().set('Filename', 'test.json'); + const mockResponse = new HttpResponse({ + body: JSON.stringify({ content: 'test' }), + headers + }); + actions$ = of(DropletsActions.exportDropletVersionSuccess({ response: mockResponse })); + + effects.exportDropletVersionSuccess$.subscribe(() => { + expect(dialog.closeAll).toHaveBeenCalled(); + done(); + }); + }); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.ts index 1795555baaca..d54846d12307 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.ts @@ -19,11 +19,10 @@ import { inject, Injectable } from '@angular/core'; import { HttpErrorResponse } from '@angular/common/http'; import { MatDialog } from '@angular/material/dialog'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { catchError, from, map, of, switchMap, tap } from 'rxjs'; -import { MEDIUM_DIALOG, SMALL_DIALOG, XL_DIALOG } from '@nifi/shared'; +import { catchError, from, map, of, switchMap, take, tap } from 'rxjs'; +import { MEDIUM_DIALOG, SMALL_DIALOG, XL_DIALOG, YesNoDialog } from '@nifi/shared'; import { DropletsService } from '../../service/droplets.service'; import * as DropletsActions from './droplets.actions'; -import { DeleteDropletDialogComponent } from '../../pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component'; import { ImportNewDropletDialogComponent, ImportNewFlowDialogData @@ -40,12 +39,16 @@ import { DropletVersionsDialogComponent } from '../../pages/resources/feature/ui import { ErrorHelper } from '../../service/error-helper.service'; import { ErrorContextKey } from '../error'; import * as ErrorActions from '../../state/error/error.actions'; +import { Store } from '@ngrx/store'; +import { NiFiState } from '../../../../../nifi/src/app/state'; +import { deleteDroplet } from './droplets.actions'; @Injectable() export class DropletsEffects { private dropletsService = inject(DropletsService); private dialog = inject(MatDialog); private errorHelper = inject(ErrorHelper); + private store = inject>(Store); actions$ = inject(Actions); @@ -74,14 +77,22 @@ export class DropletsEffects { this.actions$.pipe( ofType(DropletsActions.openDeleteDropletDialog), tap(({ request }) => { - this.dialog.open( - DeleteDropletDialogComponent, - { - ...SMALL_DIALOG, - autoFocus: false, - data: request + const dialogRef = this.dialog.open(YesNoDialog, { + ...SMALL_DIALOG, + data: { + title: 'Delete resource', + message: `This action will delete all versions of ${request.droplet.name}` } - ); + }); + dialogRef.componentInstance.yes.pipe(take(1)).subscribe(() => { + this.store.dispatch( + deleteDroplet({ + request: { + droplet: request.droplet + } + }) + ); + }); }) ), { dispatch: false } @@ -95,7 +106,10 @@ export class DropletsEffects { from(this.dropletsService.deleteDroplet(request.droplet.link.href)).pipe( map((res) => DropletsActions.deleteDropletSuccess({ response: res })), catchError((errorResponse: HttpErrorResponse) => - of(this.bannerError(errorResponse, ErrorContextKey.DELETE_DROPLET)) + of( + DropletsActions.deleteDropletFailure(), + ErrorActions.snackBarError({ error: this.errorHelper.getErrorString(errorResponse) }) + ) ) ) ) diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.actions.ts index 1c36c77d4687..6976ad82937a 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.actions.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.actions.ts @@ -18,6 +18,8 @@ import { createAction, props } from '@ngrx/store'; import { ErrorContext, ErrorContextKey } from './index'; +export const snackBarError = createAction('[Error] Snackbar Error', props<{ error: string }>()); + export const addBannerError = createAction('[Error] Add Banner Error', props<{ errorContext: ErrorContext }>()); export const clearBannerErrors = createAction('[Error] Clear Banner Errors', props<{ context: ErrorContextKey }>()); diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.effects.ts index fa3f2f856840..17cbc1863549 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.effects.ts @@ -15,7 +15,26 @@ * limitations under the License. */ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import * as ErrorActions from '../../../../../nifi/src/app/state/error/error.actions'; +import { map, tap } from 'rxjs'; +import { MatSnackBar } from '@angular/material/snack-bar'; @Injectable() -export class ErrorEffects {} +export class ErrorEffects { + private actions$ = inject(Actions); + private snackBar = inject(MatSnackBar); + + snackBarError$ = createEffect( + () => + this.actions$.pipe( + ofType(ErrorActions.snackBarError), + map((action) => action.error), + tap((error) => { + this.snackBar.open(error, 'Dismiss', { duration: 30000 }); + }) + ), + { dispatch: false } + ); +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts index a72877274bca..8e270e0249c7 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts @@ -24,12 +24,10 @@ export interface ErrorDetail { export enum ErrorContextKey { EXPORT_DROPLET_VERSION = 'droplet listing', - DELETE_DROPLET = 'delete droplet', CREATE_DROPLET = 'create droplet', IMPORT_DROPLET_VERSION = 'import droplet version', CREATE_BUCKET = 'create bucket', UPDATE_BUCKET = 'update bucket', - DELETE_BUCKET = 'delete bucket', GLOBAL = 'global' } From 4a7132c8c23361a22b02a974b7ea140b04e62ed6 Mon Sep 17 00:00:00 2001 From: Scott Aslan Date: Mon, 29 Sep 2025 16:00:08 -0400 Subject: [PATCH 4/6] fix legacy routes --- .../apps/nifi-registry/src/app/app-routing.module.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts index 30f86ae7526a..0d8db493e543 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts @@ -40,13 +40,13 @@ const routes: Routes = [ children: [ { path: '', - redirectTo: 'buckets', + redirectTo: '/buckets', pathMatch: 'full' }, { path: 'manage/bucket/:bucketId', - outlet: 'sidenav', - redirectTo: 'buckets/:bucketId' + redirectTo: '/buckets/:bucketId', + pathMatch: 'full' } ] } From 8d097c0ed6b2386b5ea5b7ac99b483eb52fee0e4 Mon Sep 17 00:00:00 2001 From: Scott Aslan Date: Mon, 29 Sep 2025 16:43:27 -0400 Subject: [PATCH 5/6] align menu option, remove unused legacy redirect --- .../apps/nifi-registry/src/app/app-routing.module.ts | 5 ----- .../nifi-registry/src/app/ui/header/header.component.html | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts index 0d8db493e543..06d806199cce 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts @@ -42,11 +42,6 @@ const routes: Routes = [ path: '', redirectTo: '/buckets', pathMatch: 'full' - }, - { - path: 'manage/bucket/:bucketId', - redirectTo: '/buckets/:bucketId', - pathMatch: 'full' } ] } diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html index 01fa77a7d2e5..b5a59b8e154e 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html @@ -39,7 +39,7 @@ - +
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html index b5a59b8e154e..f49d392a7856 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html +++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html @@ -39,8 +39,12 @@ - - + +