NIFI-14320 buckets listing and management, (manage bucket policies is… (#10346)
* NIFI-14320 buckets listing and management, (manage bucket policies is still TODO)
* address review feedback
* remove unused imports, fix broken unit test, update delete UX to use Yes/No dialog from shared, add test coverage for droplets and buckets effects
* fix legacy routes
* align menu option, remove unused legacy redirect
* remove empty state
This closes #10346
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 3b71ab7..a9f8e24 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',
- preset: '../../jest.preset.js',
- setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
+ 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: ['<rootDir>/src/test-setup.ts'],
+
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
@@ -29,10 +37,5 @@
}
]
},
- 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 0dcc095..562c83d 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 1895108..06d8061 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,25 @@
path: 'explorer',
loadChildren: () => import('./pages/resources/feature/resources.module').then((m) => m.ResourcesModule)
},
- // Backward compatibility: old app's default route
{
- path: 'nifi-registry',
- redirectTo: 'explorer',
- pathMatch: 'full'
+ path: 'buckets',
+ loadChildren: () => import('./pages/buckets/feature/buckets.module').then((m) => m.BucketsModule)
+ },
+ {
+ path: 'administration',
+ children: [
+ {
+ path: 'workflow',
+ children: [
+ {
+ path: '',
+ redirectTo: '/buckets',
+ 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 0000000..c1152fc
--- /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 0000000..2061b76
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.html
@@ -0,0 +1,54 @@
+<!--
+~ 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.
+-->
+
+<div class="pb-5 px-5 flex flex-col h-full">
+ <div class="h-full flex flex-col">
+ <h3 class="primary-color">Buckets</h3>
+ <context-error-banner [context]="ErrorContextKey.GLOBAL"></context-error-banner>
+ <div class="flex justify-between items-baseline">
+ <bucket-table-filter
+ [filterTerm]="filterTerm"
+ [filterColumn]="filterColumn"
+ [filterableColumns]="filterableColumns"
+ [filteredCount]="dataSource.filteredData.length"
+ [totalCount]="dataSource.data.length"
+ (filterChanged)="applyFilter($event)"></bucket-table-filter>
+ <div class="flex justify-end">
+ <button mat-icon-button class="primary-icon-button" (click)="openCreateBucketDialog()">
+ <i class="fa fa-plus"></i>
+ </button>
+ </div>
+ </div>
+ @if (buckets$ | async; as buckets) {
+ <bucket-table
+ [dataSource]="dataSource"
+ [selectedId]="selectedBucketId$ | async"
+ (selectBucket)="selectBucket($event)"
+ class="h-full flex"></bucket-table>
+ }
+
+ @if (bucketsState$ | async; as bucketsState) {
+ <div class="flex justify-between mt-2">
+ <div class="text-sm flex items-center gap-x-2">
+ <button mat-icon-button class="primary-icon-button" (click)="refreshBucketsListing()">
+ <i class="fa fa-refresh" [class.fa-spin]="bucketsState.status === 'loading'"></i>
+ </button>
+ </div>
+ </div>
+ }
+ </div>
+</div>
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/buckets/feature/buckets.component.scss
similarity index 92%
rename from nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss
rename to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.component.scss
index 2677b47..bdd8410 100644
--- 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/buckets/feature/buckets.component.scss
@@ -17,6 +17,6 @@
@use '@angular/material' as mat;
-.delete-droplet-dialog {
- @include mat.button-density(-1);
+: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 0000000..e0cf59d
--- /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<BucketsComponent>;
+
+ 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 0000000..626f44a
--- /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<Bucket[]> = this.store.select(selectBuckets);
+ selectedBucketId$ = this.store.select(selectBucketIdFromRoute);
+ dataSource: MatTableDataSource<Bucket> = new MatTableDataSource<Bucket>();
+ 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<void>();
+
+ 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 0000000..92777f8
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/buckets.module.ts
@@ -0,0 +1,72 @@
+/*
+ * 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 { 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,
+ 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 0000000..beee60e
--- /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 @@
+<!--
+~ 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.
+-->
+
+<div class="summary-table-filter-container">
+ <div>
+ <form [formGroup]="filterForm" class="my-2">
+ <div class="flex mt-2 gap-1 items-center">
+ <div>
+ <mat-form-field subscriptSizing="dynamic">
+ <mat-label>Filter</mat-label>
+ <input matInput type="text" class="small" formControlName="filterTerm" />
+ </mat-form-field>
+ </div>
+ <div>
+ <mat-form-field subscriptSizing="dynamic">
+ <mat-label>Filter By</mat-label>
+ <mat-select formControlName="filterColumn">
+ @for (option of filterableColumns; track option) {
+ <mat-option [value]="option.key"> {{ option.label }}</mat-option>
+ }
+ </mat-select>
+ </mat-form-field>
+ </div>
+ </div>
+ </form>
+ <div class="my-2 tertiary-color font-medium">Filter matched {{ filteredCount }} of {{ totalCount }}</div>
+ </div>
+</div>
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/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.scss
similarity index 88%
copy from nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.scss
index 2677b47..2944f98 100644
--- 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/buckets/feature/ui/bucket-table-filter/bucket-table-filter.component.scss
@@ -14,9 +14,3 @@
* 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/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 0000000..0bbba19
--- /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,79 @@
+/*
+ * 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 { ReactiveFormsModule } from '@angular/forms';
+
+const columns: BucketTableFilterColumn[] = [
+ { key: 'name', label: 'Name' },
+ { key: 'description', label: 'Description' }
+];
+
+describe('BucketTableFilterComponent', () => {
+ let component: BucketTableFilterComponent;
+ let fixture: ComponentFixture<BucketTableFilterComponent>;
+
+ 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(500); // 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 0000000..2d916aa
--- /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,104 @@
+/*
+ * 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, DestroyRef, 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);
+ private destroyRef = inject(DestroyRef);
+
+ 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<BucketTableFilterContext> = new EventEmitter<BucketTableFilterContext>();
+
+ constructor() {
+ this.filterForm
+ .get('filterTerm')
+ ?.valueChanges.pipe(debounceTime(500), takeUntilDestroyed(this.destroyRef))
+ .subscribe((term) => this.applyFilter(term, this.filterForm.get('filterColumn')?.value, 'filterTerm'));
+
+ this.filterForm
+ .get('filterColumn')
+ ?.valueChanges.pipe(takeUntilDestroyed())
+ .subscribe((column) => this.applyFilter(this.filterForm.get('filterTerm')?.value, column, 'filterColumn'));
+ }
+
+ private applyFilter(filterTerm: string, filterColumn: string, changedField: string) {
+ this.filterChanged.emit({
+ filterTerm: filterTerm ?? '',
+ filterColumn: 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 0000000..8a3c8ff
--- /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,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.
+-->
+
+<div class="flex flex-col flex-1 h-full gap-y-2">
+ <div class="listing-table select-none flex flex-1 h-full">
+ <div class="flex-1 relative">
+ <div class="absolute inset-0 overflow-y-auto overflow-x-hidden">
+ <table
+ mat-table
+ [dataSource]="dataSource"
+ matSort
+ matSortDisableClear
+ (matSortChange)="sortData($event)"
+ [matSortActive]="sort.active"
+ [matSortDirection]="sort.direction">
+ <!-- Name Column -->
+ <ng-container matColumnDef="name">
+ <th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
+ <td mat-cell *matCellDef="let item">
+ <div class="overflow-ellipsis overflow-hidden whitespace-nowrap" [title]="item.name">
+ {{ item.name }}
+ </div>
+ </td>
+ </ng-container>
+ <!-- Description Column -->
+ <ng-container matColumnDef="description">
+ <th mat-header-cell *matHeaderCellDef mat-sort-header>Description</th>
+ <td mat-cell *matCellDef="let item">
+ <div class="overflow-ellipsis overflow-hidden whitespace-nowrap" [title]="item.description">
+ {{ item.description }}
+ </div>
+ </td>
+ </ng-container>
+ <!-- Identifier Column -->
+ <ng-container matColumnDef="identifier">
+ <th mat-header-cell *matHeaderCellDef mat-sort-header>Bucket ID</th>
+ <td mat-cell *matCellDef="let item">
+ <div class="overflow-ellipsis overflow-hidden whitespace-nowrap" [title]="item.identifier">
+ {{ item.identifier }}
+ </div>
+ </td>
+ </ng-container>
+ <!-- Actions Column -->
+ <ng-container matColumnDef="actions">
+ <th mat-header-cell *matHeaderCellDef></th>
+ <td mat-cell *matCellDef="let item">
+ <div class="flex items-center justify-end gap-x-2">
+ <button
+ mat-icon-button
+ type="button"
+ [matMenuTriggerFor]="actionMenu"
+ class="h-16 w-16 flex items-center justify-center icon global-menu">
+ <i class="fa fa-ellipsis-v"></i>
+ </button>
+ <mat-menu #actionMenu="matMenu" xPosition="before">
+ <button mat-menu-item (click)="openEditBucketDialog(item)">Edit</button>
+ <button mat-menu-item (click)="openManageBucketPoliciesDialog(item)">
+ Manage Policies
+ </button>
+ <button mat-menu-item (click)="openDeleteBucketDialog(item)">Delete</button>
+ </mat-menu>
+ </div>
+ </td>
+ </ng-container>
+ <tr mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></tr>
+ <tr
+ mat-row
+ *matRowDef="let row; let even = even; columns: displayedColumns"
+ [class.even]="even"
+ (click)="select(row)"
+ [class.selected]="isSelected(row)"></tr>
+ </table>
+ </div>
+ </div>
+ </div>
+</div>
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/buckets/feature/ui/bucket-table/bucket-table.component.scss
similarity index 88%
copy from nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/bucket-table/bucket-table.component.scss
index 2677b47..2944f98 100644
--- 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/buckets/feature/ui/bucket-table/bucket-table.component.scss
@@ -14,9 +14,3 @@
* 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/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 0000000..4524732
--- /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,126 @@
+/*
+ * 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 { 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> = {}): 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<BucketTableComponent>;
+ 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<Bucket>(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 0000000..1801093
--- /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<Bucket> = new MatTableDataSource<Bucket>();
+ @Input() selectedId: string | null = null;
+
+ @Output() selectBucket: EventEmitter<Bucket> = new EventEmitter<Bucket>();
+
+ 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 0000000..4e9ea20
--- /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 @@
+<!--
+~ 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.
+-->
+
+<h2 mat-dialog-title>New Bucket</h2>
+<div class="create-bucket-dialog">
+ <form class="create-bucket-form" [formGroup]="bucketForm" (ngSubmit)="onSubmit()">
+ <mat-dialog-content>
+ <context-error-banner [context]="ErrorContextKey.CREATE_BUCKET"></context-error-banner>
+ <div class="flex flex-col gap-y-4">
+ <mat-form-field appearance="outline">
+ <mat-label>Bucket Name</mat-label>
+ <input matInput formControlName="name" placeholder="Enter a unique bucket name" />
+ @if (bucketForm.get('name')?.hasError('required')) {
+ <mat-error>Name is required</mat-error>
+ }
+ @if (bucketForm.get('name')?.hasError('maxlength')) {
+ <mat-error>Name cannot exceed 255 characters</mat-error>
+ }
+ </mat-form-field>
+ <mat-form-field appearance="outline">
+ <mat-label>Description</mat-label>
+ <textarea
+ matInput
+ formControlName="description"
+ placeholder="Describe the purpose of this bucket (optional)"
+ rows="3"></textarea>
+ @if (bucketForm.get('description')?.hasError('maxlength')) {
+ <mat-error>Description cannot exceed 1000 characters</mat-error>
+ }
+ </mat-form-field>
+ <mat-checkbox class="flex items-center" formControlName="allowPublicRead">
+ <mat-label
+ >Allow Public Read Access
+ <i
+ class="fa fa-info-circle"
+ [class.primary-color]="supportsPublicRead"
+ matTooltip="Allows unauthenticated users to read the items in this bucket. Overrides policies granting read access."
+ aria-label="Public read access information">
+ </i>
+ </mat-label>
+ </mat-checkbox>
+ <mat-checkbox class="flex items-center" formControlName="keepDialogOpen">
+ Keep this dialog open after creating bucket
+ </mat-checkbox>
+ </div>
+ </mat-dialog-content>
+
+ <mat-dialog-actions class="mt-2" align="end">
+ <button mat-button type="button" mat-dialog-close>Cancel</button>
+ <button mat-flat-button color="primary" type="submit" [disabled]="bucketForm.invalid">Create</button>
+ </mat-dialog-actions>
+ </form>
+</div>
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/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.scss
similarity index 88%
copy from nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.scss
index 2677b47..2944f98 100644
--- 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/buckets/feature/ui/create-bucket-dialog/create-bucket-dialog.component.scss
@@ -14,9 +14,3 @@
* 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/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 0000000..f031be7
--- /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<CreateBucketDialogComponent>;
+ 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 0000000..836c06e
--- /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/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 0000000..a447079
--- /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 @@
+<!--
+~ 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.
+-->
+
+<h2 mat-dialog-title>Edit Bucket</h2>
+<form [formGroup]="bucketForm" (ngSubmit)="onSaveBucket()">
+ <mat-dialog-content>
+ <context-error-banner [context]="ErrorContextKey.UPDATE_BUCKET"></context-error-banner>
+ <div class="flex flex-col gap-y-4">
+ <div class="flex flex-col mb-5">
+ <div>Id</div>
+ <div [copy]="data.bucket.identifier" class="tertiary-color font-medium">
+ {{ data.bucket.identifier }}
+ </div>
+ </div>
+ <mat-form-field appearance="outline">
+ <mat-label>Name</mat-label>
+ <input matInput formControlName="name" placeholder="Enter bucket name" />
+ @if (bucketForm.get('name')?.hasError('required')) {
+ <mat-error>Name is required</mat-error>
+ }
+ @if (bucketForm.get('name')?.hasError('maxlength')) {
+ <mat-error>Name cannot exceed 255 characters</mat-error>
+ }
+ </mat-form-field>
+
+ <mat-form-field appearance="outline">
+ <mat-label>Description</mat-label>
+ <textarea
+ matInput
+ formControlName="description"
+ placeholder="Enter bucket description (optional)"
+ rows="3"></textarea>
+ @if (bucketForm.get('description')?.hasError('maxlength')) {
+ <mat-error>Description cannot exceed 1000 characters</mat-error>
+ }
+ </mat-form-field>
+
+ <mat-checkbox formControlName="allowPublicRead">
+ <mat-label
+ >Make publicly visible
+ <i
+ class="fa fa-info-circle primary-color"
+ matTooltip="Allows read access to items in this bucket by unauthenticated users. Overrides any specific policies granting read access.">
+ </i>
+ </mat-label>
+ </mat-checkbox>
+
+ <mat-checkbox formControlName="allowBundleRedeploy">
+ <mat-label
+ >Allow bundle overwrite
+ <i
+ class="fa fa-info-circle primary-color"
+ matTooltip="Allows released bundles in this bucket to be overwritten.">
+ </i>
+ </mat-label>
+ </mat-checkbox>
+ </div>
+ </mat-dialog-content>
+
+ <mat-dialog-actions align="end">
+ <button mat-button type="button" mat-dialog-close>Cancel</button>
+ <button mat-flat-button color="primary" type="submit" [disabled]="bucketForm.invalid">Apply</button>
+ </mat-dialog-actions>
+</form>
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/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.scss
similarity index 88%
copy from nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.scss
index 2677b47..2944f98 100644
--- 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/buckets/feature/ui/edit-bucket-dialog/edit-bucket-dialog.component.scss
@@ -14,9 +14,3 @@
* 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/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 0000000..f26d621
--- /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<EditBucketDialogComponent>;
+ 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 0000000..7526338
--- /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<EditBucketDialogData>(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 0000000..7cf225b
--- /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 @@
+<!--
+~ 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.
+-->
+
+<h2 mat-dialog-title>Manage Bucket Policies</h2>
+<mat-dialog-content>
+ <div class="flex flex-col gap-y-4">
+ <p>
+ TODO: Manage policies for bucket: <strong>{{ data.bucket.name }}</strong>
+ </p>
+ <!-- TODO: Manage policies for bucket -->
+ </div>
+</mat-dialog-content>
+
+<mat-dialog-actions align="end">
+ <button mat-flat-button mat-dialog-close>Close</button>
+</mat-dialog-actions>
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/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.scss
similarity index 88%
copy from nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.scss
index 2677b47..2944f98 100644
--- 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/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component.scss
@@ -14,9 +14,3 @@
* 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/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 0000000..1593ce0
--- /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<ManageBucketPoliciesDialogData>(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 252eb51..7574886 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 { 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 @@
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 a6688f3..fe40532 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 @@
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 @@
} 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 @@
export class ResourcesComponent implements OnInit {
private store = inject(Store);
private nifiCommon = inject(NiFiCommon);
+ private router = inject(Router);
droplets$: Observable<Droplet[]> = this.store.select(selectDroplets).pipe(takeUntilDestroyed());
buckets$: Observable<Bucket[]> = this.store.select(selectBuckets).pipe(takeUntilDestroyed());
@@ -171,13 +173,7 @@
}
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.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 8ce39f2..0000000
--- 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 @@
-<!--
-~ 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.
--->
-
-<h2 mat-dialog-title>Delete resource?</h2>
-<div class="delete-droplet-dialog">
- <context-error-banner [context]="ErrorContextKey.DELETE_DROPLET"></context-error-banner>
- <mat-dialog-content>
- <p class="test">This action will delete all versions of {{ droplet.name }}</p>
- </mat-dialog-content>
- <mat-dialog-actions align="end">
- <button mat-button mat-dialog-close>No</button>
- <button mat-flat-button (click)="deleteDroplet(droplet)" class="ml-2" data-test-id="delete-btn">Yes</button>
- </mat-dialog-actions>
-</div>
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 15df3b0..0000000
--- 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,81 +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<DeleteDropletDialogComponent>;
- 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<KeyboardEvent>()
- }
- },
- provideMockStore({})
- ]
- }).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 9260916..0000000
--- 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<DeleteDropletDialogData>(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/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 95aba2e..d4f9e56 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 @@
keydownEvents: () => new Subject<KeyboardEvent>()
}
},
- 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 7b8f295..dbfe86c 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 @@
keydownEvents: () => new Subject<KeyboardEvent>()
}
},
- 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 48bfbd3..eb3d34c 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 @@
keydownEvents: () => new Subject<KeyboardEvent>()
}
},
- 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 c255d01..30a004f 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 @@
keydownEvents: () => new Subject<KeyboardEvent>()
}
},
- 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 1ac5412..cea1232 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 @@
private static readonly API: string = '../nifi-registry-api';
- getBuckets(): Observable<any> {
+ getBuckets(): Observable<Bucket[]> {
// const mockError: HttpErrorResponse = new HttpErrorResponse({
// status: 404,
// statusText: 'Bad Gateway',
@@ -37,6 +39,61 @@
// });
// return throwError(() => mockError);
- return this.httpClient.get(`${BucketsService.API}/buckets`);
+ return this.httpClient.get<Bucket[]>(`${BucketsService.API}/buckets`);
+ }
+
+ createBucket(request: CreateBucketRequest): Observable<Bucket> {
+ // 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<Bucket>(`${BucketsService.API}/buckets`, {
+ ...request,
+ revision: {
+ version: 0
+ }
+ });
+ }
+
+ updateBucket(request: { bucket: Bucket }): Observable<Bucket> {
+ // 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<Bucket>(
+ `${BucketsService.API}/buckets/${request.bucket.identifier}`,
+ request.bucket
+ );
+ }
+
+ deleteBucket(request: DeleteBucketRequest): Observable<Bucket> {
+ // 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<Bucket>(
+ `${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 46a3df6..c612dc3 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 @@
'[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.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.spec.ts
new file mode 100644
index 0000000..107b1be
--- /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<Action>;
+ let effects: BucketsEffects;
+ let bucketsService: jest.Mocked<BucketsService>;
+ let errorHelper: jest.Mocked<ErrorHelper>;
+ let dialog: jest.Mocked<MatDialog>;
+ 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<BucketsService>;
+ errorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ dialog = TestBed.inject(MatDialog) as jest.Mocked<MatDialog>;
+ 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 070ad3a..39bedcb 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,205 @@
*/
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, take } 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 { ManageBucketPoliciesDialogComponent } from '../../pages/buckets/feature/ui/manage-bucket-policies-dialog/manage-bucket-policies-dialog.component';
+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 {
private bucketsService = inject(BucketsService);
private errorHelper = inject(ErrorHelper);
-
- actions$ = inject(Actions);
+ private dialog = inject(MatDialog);
+ private actions$ = inject(Actions);
+ private store = inject<Store<NiFiState>>(Store);
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 }) => {
+ 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
+ }
+ })
+ );
+ });
+ })
+ ),
+ { 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(),
+ ErrorActions.snackBarError({ error: this.errorHelper.getErrorString(errorResponse) })
+ )
+ )
+ )
+ )
+ )
+ );
+
+ 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 657c1f5..d609e5a 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 @@
...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 7384865..9ee3476 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 { bucketsFeatureKey, BucketsState } from './index';
import { resourcesFeatureKey, ResourcesState } from '..';
+import { selectCurrentRoute } from '@nifi/shared';
export const selectResourcesState = createFeatureSelector<ResourcesState>(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 9ddecaa..2760876 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 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 }>()
@@ -92,8 +94,6 @@
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.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.spec.ts
new file mode 100644
index 0000000..4280e8c
--- /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<Action>;
+ let effects: DropletsEffects;
+ let dropletsService: jest.Mocked<DropletsService>;
+ let errorHelper: jest.Mocked<ErrorHelper>;
+ let dialog: jest.Mocked<MatDialog>;
+ 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<DropletsService>;
+ errorHelper = TestBed.inject(ErrorHelper) as jest.Mocked<ErrorHelper>;
+ dialog = TestBed.inject(MatDialog) as jest.Mocked<MatDialog>;
+ 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 8e9ef9d..d54846d 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 { 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
@@ -38,16 +37,18 @@
} 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';
+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 router = inject(Router);
+ private store = inject<Store<NiFiState>>(Store);
actions$ = inject(Actions);
@@ -76,14 +77,22 @@
this.actions$.pipe(
ofType(DropletsActions.openDeleteDropletDialog),
tap(({ request }) => {
- this.dialog.open<DeleteDropletDialogComponent, ExportFlowVersionDialogData>(
- 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 }
@@ -97,7 +106,10 @@
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) })
+ )
)
)
)
@@ -346,18 +358,6 @@
)
);
- 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/error.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.actions.ts
index 1c36c77..6976ad8 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 fa3f2f8..17cbc18 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 621c666..8e270e0 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,9 +24,10 @@
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',
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 4acdb7f..f49d392 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()" />
</div>
<div class="h-[64px] w-[112px] mr-6 relative">
<img ngSrc="assets/icons/nifi-logo.svg" fill priority alt="NiFi Logo" />
@@ -39,7 +39,12 @@
<i class="fa fa-navicon text-[32px]"></i>
</button>
<mat-menu #globalMenu="matMenu" xPosition="before">
- <button mat-menu-item class="global-menu-item"><i class="fa mr-2"></i>Buckets</button>
+ <button mat-menu-item class="global-menu-item" (click)="navigateToResources()">
+ <i class="fa mr-2"></i>Resources
+ </button>
+ <button mat-menu-item class="global-menu-item" (click)="navigateToWorkflow()">
+ <i class="fa mr-2"></i>Buckets
+ </button>
<button mat-menu-item class="global-menu-item"><i class="fa mr-2"></i>Users/Groups</button>
<button mat-menu-item class="global-menu-item">
<i class="fa fa-info-circle primary-color mr-2"></i>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.ts
index ba4d813..de407ac 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.ts
@@ -57,10 +57,6 @@
}
}
- goHome() {
- this.router.navigateByUrl('/resources');
- }
-
toggleTheme(theme: string) {
this.theme = theme;
this.storage.setItem('theme', theme);
@@ -72,4 +68,12 @@
this.storage.setItem('disable-animations', this.disableAnimations.toString());
window.location.reload();
}
+
+ navigateToResources() {
+ this.router.navigateByUrl('/explorer');
+ }
+
+ navigateToWorkflow() {
+ this.router.navigateByUrl('/buckets');
+ }
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/index.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/index.html
index 933cfcf..2d3d086 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/index.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/index.html
@@ -21,7 +21,7 @@
<meta charset="utf-8" />
<title>NiFi Registry</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
- <link rel="icon" type="image/x-icon" href="assets/icons/registry-favicon.png" />
+ <link rel="icon" type="image/png" href="assets/icons/registry-favicon.png" />
</head>
<body class="mat-app-background mat-typography text-base">
<nifi-registry-app-root></nifi-registry-app-root>