You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by sc...@apache.org on 2025/09/23 14:49:36 UTC
(nifi) branch main updated: NIFI-14319: Create initial project structure for new NiFi Registry UI (#9855)
This is an automated email from the ASF dual-hosted git repository.
scottyaslan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 1c995f4876 NIFI-14319: Create initial project structure for new NiFi Registry UI (#9855)
1c995f4876 is described below
commit 1c995f487670db43a4cd33afc13eacccd865c0ff
Author: Shane Ardell <sa...@users.noreply.github.com>
AuthorDate: Tue Sep 23 09:49:29 2025 -0500
NIFI-14319: Create initial project structure for new NiFi Registry UI (#9855)
* NIFI-14319: init new registry app
* NIFI-14319: add unit test coverage
* NIFI-14319: update redirect value
* Remove commented code in droplet filter
* NIFI-14319: remove commented code from current user effects
* NIFI-14319: remove commented code from bucket reducer
* NIFI-14319: remove commented code from index.ts
* NIFI-14319: address initial feedback
* check if window.matchMedia has value before calling method
* add empty states to explorer table
* destroy direct store subscriptions
* add filtered count to droplet table filter
* NIFI-14319: add wrongly removed favicon back to old project
* NIFI-14319: fix CI build failures
* Linux solution: https://github.com/evanw/esbuild/issues/1819\#issuecomment-2426254293
* Windows solution: https://github.com/nrwl/nx/issues/29551
* NIFI-14319: fix alt value used for registry logo
* NIFI-14319: review feedback
* fix error service
* NIFI-14319: address naming and formatting feedback
* NIFI-14319: address review feedback
* fix multiple namings
* template cleanup
* configuration changes to align better with NiFi
* style fixes
* NIFI-14319: refactor droplet table into standalone component
* NIFI-14319: align banner error handling with NiFi UX
* NIFI-14319: align file upload UX with NiFi UI
* NIFI-14319: additional dialog header and button style tweaks
* NIFI-14319: update logo styling
* NIFI-14319: error handling improvements
* NIFI-14319: use NiFi file pick UX in import new flow dialog
* NIFI-14319: remove unused directory
* NIFI-14319: cleanup action types and fix missing import description
* NIFI-14319: address feedback
* use px units instead of calc for logo container
* use unique contexts for dialog errors
* update explorer page title
* margin and sizing class updates
* run prettier to fix formatting issues
* NIFI-14319: move context error into shared lib
* NIFI-14319: remove current user code
* NIFI-14319: remove old context error code from registry
* NIFI-14319: update explorer naming to resources
* NIFI-14319: remove circular dependency
* NIFI-14319: remove error abstractions from shared folder
* NIFI-14319: update out-of-sync lock file
* NIFI-14319: a few minor changes
* remove rej file
* remove unused deps
* add standalone to components missing that configuration
* hadle legacy routes
* NIFI-14319: restore ignore path for favicon in old ui gitignore
* NIFI-14319: update lock file
* NIFI-14319: fix build issues
---
.../frontend/apps/nifi-registry/.eslintrc.json | 46 +++
.../frontend/apps/nifi-registry/jest.config.ts | 38 +++
.../main/frontend/apps/nifi-registry/project.json | 134 ++++++++
.../frontend/apps/nifi-registry/proxy.config.mjs | 47 +++
.../src/app/app-routing.module.ts} | 52 +--
.../apps/nifi-registry/src/app/app.component.html | 23 ++
.../src/app/app.component.scss} | 15 -
.../src/app/app.component.spec.ts} | 19 +-
.../apps/nifi-registry/src/app/app.component.ts | 50 +++
.../apps/nifi-registry/src/app/app.module.ts | 90 +++++
.../resources/feature/resources-routing.module.ts | 65 ++++
.../resources/feature/resources.component.html | 57 ++++
.../resources/feature/resources.component.scss} | 13 +-
.../resources/feature/resources.component.spec.ts | 55 +++
.../pages/resources/feature/resources.component.ts | 186 ++++++++++
.../pages/resources/feature/resources.module.ts | 53 +++
.../delete-droplet-dialog.component.html | 28 ++
.../delete-droplet-dialog.component.scss} | 11 +-
.../delete-droplet-dialog.component.spec.ts | 81 +++++
.../delete-droplet-dialog.component.ts | 54 +++
.../droplet-table-filter.component.html | 53 +++
.../droplet-table-filter.component.scss} | 15 -
.../droplet-table-filter.component.spec.ts} | 18 +-
.../droplet-table-filter.component.ts | 142 ++++++++
.../ui/droplet-table/droplet-table.component.html | 135 ++++++++
.../ui/droplet-table/droplet-table.component.scss} | 13 -
.../droplet-table/droplet-table.component.spec.ts | 134 ++++++++
.../ui/droplet-table/droplet-table.component.ts | 137 ++++++++
.../droplet-versions-dialog.component.html | 94 +++++
.../droplet-versions-dialog.component.scss} | 11 +-
.../droplet-versions-dialog.component.spec.ts | 99 ++++++
.../droplet-versions-dialog.component.ts | 133 ++++++++
.../export-droplet-version-dialog.component.html | 49 +++
.../export-droplet-version-dialog.component.scss} | 11 +-
...export-droplet-version-dialog.component.spec.ts | 82 +++++
.../export-droplet-version-dialog.component.ts | 70 ++++
.../import-new-droplet-dialog.component.html | 100 ++++++
.../import-new-droplet-dialog.component.scss} | 11 +-
.../import-new-droplet-dialog.component.spec.ts | 143 ++++++++
.../import-new-droplet-dialog.component.ts | 136 ++++++++
...mport-new-droplet-version-dialog.component.html | 85 +++++
...port-new-droplet-version-dialog.component.scss} | 11 +-
...rt-new-droplet-version-dialog.component.spec.ts | 94 +++++
.../import-new-droplet-version-dialog.component.ts | 115 +++++++
.../src/app/service/buckets.service.ts | 42 +++
.../src/app/service/droplets.service.ts | 136 ++++++++
.../src/app/service/error-helper.service.ts | 45 +++
.../src/app/state/buckets/buckets.actions.ts} | 19 +-
.../src/app/state/buckets/buckets.effects.ts | 65 ++++
.../src/app/state/buckets/buckets.reducer.ts} | 31 +-
.../src/app/state/buckets/buckets.selectors.ts} | 18 +-
.../src/app/state/buckets/index.ts} | 31 +-
.../src/app/state/droplets/droplets.actions.ts | 111 ++++++
.../src/app/state/droplets/droplets.effects.ts | 379 +++++++++++++++++++++
.../src/app/state/droplets/droplets.reducer.ts | 114 +++++++
.../src/app/state/droplets/droplets.selectors.ts} | 24 +-
.../nifi-registry/src/app/state/droplets/index.ts | 67 ++++
.../src/app/state/error/error.actions.ts} | 16 +-
.../src/app/state/error/error.effects.ts} | 16 +-
.../src/app/state/error/error.reducer.ts | 51 +++
.../src/app/state/error/error.selectors.ts} | 17 +-
.../src/app/state/error/index.ts} | 34 +-
.../apps/nifi-registry/src/app/state/index.ts | 67 ++++
.../context-error-banner.component.html | 20 ++
.../context-error-banner.component.scss | 18 +
.../context-error-banner.component.spec.ts | 50 +++
.../context-error-banner.component.ts | 6 +-
.../app/ui/header/_header.component-theme.scss} | 32 +-
.../src/app/ui/header/header.component.html | 115 +++++++
.../src/app/ui/header/header.component.scss} | 38 ++-
.../src/app/ui/header/header.component.spec.ts} | 14 +-
.../src/app/ui/header/header.component.ts | 73 ++++
.../src/environments/environment.development.ts} | 17 +-
.../src/environments/environment.ts} | 17 +-
.../frontend/apps/nifi-registry/src/index.html | 29 ++
.../src/main.ts} | 17 +-
.../src/styles.scss} | 32 +-
.../src/test-setup.ts} | 15 +-
.../tailwind.config.js} | 26 +-
.../frontend/apps/nifi-registry/tsconfig.app.json | 9 +
.../main/frontend/apps/nifi-registry/tsconfig.json | 19 ++
.../frontend/apps/nifi-registry/tsconfig.spec.json | 11 +
.../main/frontend/apps/nifi/src/app/app.module.ts | 20 +-
.../component-access-policies.module.ts | 10 +-
.../global-access-policies.module.ts | 8 +-
.../app/pages/cluster/feature/cluster.module.ts | 4 +-
.../service/canvas-context-menu.service.ts | 4 +-
.../flow-designer/state/flow/flow.effects.spec.ts | 4 -
.../change-version-progress-dialog.ts | 2 +-
.../context-error-banner.component.ts | 2 +-
.../src/assets/icons/registry-background-logo.svg | 17 +
.../shared/src/assets/icons/registry-favicon.png | Bin 0 -> 388 bytes
.../src/assets/icons/registry-logo-web-app.svg | 17 +
.../error-banner/error-banner.component.html | 2 +-
.../error-banner/error-banner.component.scss | 0
.../error-banner/error-banner.component.spec.ts | 0
.../error-banner/error-banner.component.ts | 3 +-
.../frontend/libs/shared/src/components/index.ts | 1 +
98 files changed, 4579 insertions(+), 364 deletions(-)
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/.eslintrc.json b/nifi-frontend/src/main/frontend/apps/nifi-registry/.eslintrc.json
new file mode 100644
index 0000000000..1312176600
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/.eslintrc.json
@@ -0,0 +1,46 @@
+{
+ "root": true,
+ "ignorePatterns": ["!**/*"],
+ "parserOptions": {
+ "ecmaVersion": "latest"
+ },
+ "overrides": [
+ {
+ "files": ["*.ts"],
+ "extends": [
+ "plugin:@nx/angular",
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:@angular-eslint/recommended",
+ "plugin:@angular-eslint/template/process-inline-templates",
+ "plugin:prettier/recommended"
+ ],
+ "rules": {
+ "@angular-eslint/directive-selector": [
+ "error",
+ {
+ "type": "attribute",
+ "prefix": "",
+ "style": "camelCase"
+ }
+ ],
+ "@angular-eslint/component-selector": [
+ "error",
+ {
+ "type": "element",
+ "prefix": "",
+ "style": "kebab-case"
+ }
+ ],
+ "@angular-eslint/component-class-suffix": "off",
+ "@typescript-eslint/no-explicit-any": "off",
+ "@typescript-eslint/ban-ts-comment": "off",
+ "@typescript-eslint/no-non-null-assertion": "off",
+ "@typescript-eslint/no-this-alias": "warn",
+ "@typescript-eslint/no-unused-vars": "warn",
+ "no-useless-escape": "off",
+ "@angular-eslint/prefer-standalone": "off"
+ }
+ }
+ ]
+}
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
new file mode 100644
index 0000000000..3b71ab7bfb
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/jest.config.ts
@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+
+export default {
+ displayName: 'NiFi Registry',
+ preset: '../../jest.preset.js',
+ setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
+ coverageDirectory: '../../coverage/apps/nifi-registry',
+ transform: {
+ '^.+\\.(ts|mjs|js|html)$': [
+ 'jest-preset-angular',
+ {
+ tsconfig: '<rootDir>/tsconfig.spec.json',
+ stringifyContentPathRegex: '\\.(html|svg)$'
+ }
+ ]
+ },
+ 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'
+ ]
+};
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/project.json b/nifi-frontend/src/main/frontend/apps/nifi-registry/project.json
new file mode 100644
index 0000000000..0dcc095b82
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/project.json
@@ -0,0 +1,134 @@
+{
+ "name": "nifi-registry",
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
+ "projectType": "application",
+ "generators": {
+ "@schematics/angular:component": {
+ "style": "scss"
+ }
+ },
+ "prefix": "app",
+ "sourceRoot": "apps/nifi-registry/src",
+ "targets": {
+ "build": {
+ "executor": "@nx/angular:application",
+ "outputs": ["{options.outputPath}"],
+ "options": {
+ "outputPath": "dist/nifi-registry",
+ "index": "apps/nifi-registry/src/index.html",
+ "browser": "apps/nifi-registry/src/main.ts",
+ "polyfills": ["zone.js"],
+ "tsConfig": "apps/nifi-registry/tsconfig.app.json",
+ "inlineStyleLanguage": "scss",
+ "assets": [
+ {
+ "glob": "**/*.svg",
+ "input": "libs/shared/src/assets/",
+ "output": "./assets"
+ }
+ ],
+ "styles": ["apps/nifi-registry/src/styles.scss"],
+ "stylePreprocessorOptions": {
+ "includePaths": [""]
+ },
+ "scripts": [],
+ "preserveSymlinks": true
+ },
+ "configurations": {
+ "production": {
+ "budgets": [
+ {
+ "type": "initial",
+ "maximumWarning": "500kb",
+ "maximumError": "1mb"
+ },
+ {
+ "type": "anyComponentStyle",
+ "maximumWarning": "2kb",
+ "maximumError": "4kb"
+ }
+ ],
+ "outputHashing": "all",
+ "optimization": true
+ },
+ "development": {
+ "optimization": false,
+ "extractLicenses": false,
+ "sourceMap": true,
+ "preserveSymlinks": true,
+ "namedChunks": true,
+ "outputHashing": "none",
+ "assets": [
+ {
+ "glob": "**/*.svg",
+ "input": "libs/shared/src/assets/",
+ "output": "./assets"
+ }
+ ],
+ "fileReplacements": [
+ {
+ "replace": "apps/nifi-registry/src/environments/environment.ts",
+ "with": "apps/nifi-registry/src/environments/environment.development.ts"
+ }
+ ]
+ }
+ },
+ "defaultConfiguration": "production"
+ },
+ "serve": {
+ "executor": "@nx/angular:dev-server",
+ "configurations": {
+ "production": {
+ "buildTarget": "nifi-registry:build:production"
+ },
+ "development": {
+ "buildTarget": "nifi-registry:build:development",
+ "servePath": "/nifi-registry"
+ }
+ },
+ "defaultConfiguration": "development",
+ "options": {
+ "proxyConfig": "apps/nifi-registry/proxy.config.mjs",
+ "buildTarget": "nifi-registry:build",
+ "port": 4204
+ },
+ "continuous": true
+ },
+ "extract-i18n": {
+ "executor": "@angular-devkit/build-angular:extract-i18n",
+ "options": {
+ "buildTarget": "nifi-registry:build"
+ }
+ },
+ "lint": {
+ "executor": "@nx/eslint:lint",
+ "outputs": ["{options.outputFile}"]
+ },
+ "lint:fix": {
+ "executor": "@nx/eslint:lint",
+ "options": {
+ "fix": true
+ },
+ "outputs": ["{options.outputFile}"]
+ },
+ "prettier": {
+ "executor": "nx:run-commands",
+ "options": {
+ "command": "npm run prettier"
+ }
+ },
+ "prettier:format": {
+ "executor": "nx:run-commands",
+ "options": {
+ "command": "npm run prettier-format"
+ }
+ },
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+ "options": {
+ "jestConfig": "apps/nifi-registry/jest.config.ts"
+ }
+ }
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/proxy.config.mjs b/nifi-frontend/src/main/frontend/apps/nifi-registry/proxy.config.mjs
new file mode 100644
index 0000000000..19f9c3956b
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/proxy.config.mjs
@@ -0,0 +1,47 @@
+/*
+ * 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.
+ */
+
+const target = {
+ target: 'http://localhost:18080',
+ secure: false,
+ logLevel: 'debug',
+ changeOrigin: true,
+ headers: {
+ 'X-ProxyScheme': 'http',
+ 'X-ProxyPort': 4204
+ },
+ configure: (proxy, _options) => {
+ proxy.on('error', (err, _req, _res) => {
+ console.log('proxy error', err);
+ });
+ proxy.on('proxyReq', (proxyReq, req, _res) => {
+ console.log('Sending Request to the Target:', req.method, req.url);
+ });
+ proxy.on('proxyRes', (proxyRes, req, _res) => {
+ console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
+ });
+ },
+ bypass: function (req) {
+ if (req.url.startsWith('/nifi-registry/')) {
+ return req.url;
+ }
+ }
+};
+
+export default {
+ '/**': target
+};
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts
similarity index 50%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.ts
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts
index 9681c6c647..1895108bbe 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app-routing.module.ts
@@ -15,25 +15,37 @@
* limitations under the License.
*/
-import { Component, EventEmitter, Input, Output } from '@angular/core';
-import { NgClass } from '@angular/common';
-import { MatButtonModule } from '@angular/material/button';
+import { NgModule } from '@angular/core';
+import { RouterModule, Routes } from '@angular/router';
-@Component({
- selector: 'error-banner',
- imports: [MatButtonModule, NgClass],
- templateUrl: './error-banner.component.html',
- styleUrls: ['./error-banner.component.scss']
-})
-export class ErrorBanner {
- @Input() messages: string[] | null = null;
- @Input() showErrorIcon = true;
- @Input() showBorder = true;
- @Input() allowDismiss = true;
-
- @Output() dismiss: EventEmitter<void> = new EventEmitter<void>();
-
- dismissClicked(): void {
- this.dismiss.next();
+const routes: Routes = [
+ {
+ path: '',
+ redirectTo: 'explorer',
+ pathMatch: 'full'
+ },
+ {
+ 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'
}
-}
+ // TODO: buckets
+ // TODO: Users/groups
+ // TODO: Page not found
+];
+
+@NgModule({
+ imports: [
+ RouterModule.forRoot(routes, {
+ paramsInheritanceStrategy: 'always',
+ useHash: true
+ })
+ ],
+ exports: [RouterModule]
+})
+export class AppRoutingModule {}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.html
new file mode 100644
index 0000000000..5ea966d92c
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.html
@@ -0,0 +1,23 @@
+<!--
+ ~ 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 h-screen">
+ <header class="mb-5 nifi-registry-header">
+ <app-header></app-header>
+ </header>
+ <router-outlet></router-outlet>
+</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.scss
similarity index 76%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.scss
index 71ad5e3cd6..2944f98194 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.scss
@@ -14,18 +14,3 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
-@use '@angular/material' as mat;
-
-.banner-container {
- @include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.spec.ts
similarity index 72%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.spec.ts
index a1d0d010e5..dd833f9d9b 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.spec.ts
@@ -16,18 +16,19 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { AppComponent } from './app.component';
+import { RouterModule } from '@angular/router';
-import { ErrorBanner } from './error-banner.component';
+describe('AppComponent', () => {
+ let component: AppComponent;
+ let fixture: ComponentFixture<AppComponent>;
-describe('ErrorBanner', () => {
- let component: ErrorBanner;
- let fixture: ComponentFixture<ErrorBanner>;
-
- beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [ErrorBanner]
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [RouterModule.forRoot([])]
});
- fixture = TestBed.createComponent(ErrorBanner);
+
+ fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.ts
new file mode 100644
index 0000000000..2ebb7236f7
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.component.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 } from '@angular/core';
+import { ThemingService, Storage } from '@nifi/shared';
+
+@Component({
+ selector: 'nifi-registry-app-root',
+ templateUrl: './app.component.html',
+ styleUrl: './app.component.scss',
+ standalone: false
+})
+export class AppComponent {
+ title = 'nifi-registry';
+
+ constructor(
+ private storage: Storage,
+ private themingService: ThemingService
+ ) {
+ let theme = this.storage.getItem('theme');
+
+ // Initially check if dark mode is enabled on system
+ const darkModeOn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+
+ // If dark mode is enabled then directly switch to the dark-theme
+ this.themingService.toggleTheme(darkModeOn, theme);
+
+ if (window.matchMedia) {
+ // Watch for changes of the preference
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
+ theme = this.storage.getItem('theme');
+ this.themingService.toggleTheme(e.matches, theme);
+ });
+ }
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.module.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.module.ts
new file mode 100644
index 0000000000..982661189e
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/app.module.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 { NgModule } from '@angular/core';
+import { BrowserModule } from '@angular/platform-browser';
+import { AppRoutingModule } from './app-routing.module';
+import { AppComponent } from './app.component';
+import {
+ BrowserAnimationsModule,
+ provideAnimations,
+ provideNoopAnimations
+} from '@angular/platform-browser/animations';
+import { StoreModule } from '@ngrx/store';
+import { StoreDevtoolsModule } from '@ngrx/store-devtools';
+import { provideHttpClient, withInterceptors, withXsrfConfiguration } from '@angular/common/http';
+import { NavigationActionTiming, RouterState, StoreRouterConnectingModule } from '@ngrx/router-store';
+import { EffectsModule } from '@ngrx/effects';
+import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field';
+import { HeaderComponent } from './ui/header/header.component';
+import { rootReducers } from './state';
+import { ErrorEffects } from './state/error/error.effects';
+import { environment } from '../environments/environment';
+
+const entry = localStorage.getItem('disable-animations');
+let disableAnimations = '';
+
+try {
+ disableAnimations = entry !== null ? JSON.parse(entry).item : '';
+} catch (error) {
+ /* empty */
+}
+
+// honor OS settings if user has not explicitly disabled animations for the application
+try {
+ if (disableAnimations !== 'true' && disableAnimations !== 'false') {
+ disableAnimations = window.matchMedia('(prefers-reduced-motion: reduce)').matches.toString();
+ }
+} catch (error) {
+ /* empty */
+}
+
+@NgModule({
+ declarations: [AppComponent],
+ bootstrap: [AppComponent],
+ imports: [
+ BrowserModule,
+ AppRoutingModule,
+ BrowserAnimationsModule,
+ HeaderComponent,
+ StoreModule.forRoot(rootReducers),
+ StoreRouterConnectingModule.forRoot({
+ routerState: RouterState.Minimal,
+ navigationActionTiming: NavigationActionTiming.PostActivation
+ }),
+ EffectsModule.forRoot(ErrorEffects),
+ StoreDevtoolsModule.instrument({
+ maxAge: 25,
+ logOnly: environment.production,
+ autoPause: true,
+ name: 'NiFi Registry',
+ trace: !environment.production
+ })
+ ],
+ providers: [
+ disableAnimations === 'true' ? provideNoopAnimations() : provideAnimations(),
+ { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline' } },
+ provideHttpClient(
+ withInterceptors([]),
+ withXsrfConfiguration({
+ cookieName: '__Secure-Request-Token',
+ headerName: 'Request-Token'
+ })
+ )
+ ]
+})
+export class AppModule {}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources-routing.module.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources-routing.module.ts
new file mode 100644
index 0000000000..bc01e8e2b1
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources-routing.module.ts
@@ -0,0 +1,65 @@
+/*
+ * 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 { RouterModule, Routes } from '@angular/router';
+import { NgModule } from '@angular/core';
+import { ResourcesComponent } from './resources.component';
+
+const routes: Routes = [
+ {
+ path: '',
+ component: ResourcesComponent,
+ children: [
+ {
+ path: ':id',
+ component: ResourcesComponent
+ }
+ ]
+ },
+ // Backward compatibility routes for old NiFi Registry deep links
+ {
+ path: 'grid-list',
+ redirectTo: '',
+ pathMatch: 'full'
+ },
+ {
+ path: 'grid-list/buckets/:bucketId',
+ redirectTo: '',
+ pathMatch: 'full'
+ },
+ {
+ path: 'grid-list/buckets/:bucketId/:dropletType/:dropletId',
+ redirectTo: '',
+ pathMatch: 'full'
+ },
+ {
+ path: 'grid-list/buckets',
+ redirectTo: '',
+ pathMatch: 'full'
+ },
+ // Catch-all for any other grid-list sub-routes
+ {
+ path: 'grid-list/**',
+ redirectTo: ''
+ }
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+ exports: [RouterModule]
+})
+export class ResourcesRoutingModule {}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html
new file mode 100644
index 0000000000..36dd7f7e3b
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.html
@@ -0,0 +1,57 @@
+<!--
+~ 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">Resources</h3>
+ <context-error-banner [context]="ErrorContextKey.GLOBAL"></context-error-banner>
+ <div class="flex justify-between items-baseline">
+ <droplet-table-filter
+ [buckets]="buckets"
+ [filterTerm]="filterTerm"
+ [filterBucket]="filterBucket"
+ [filterColumn]="filterColumn"
+ [filterableColumns]="filterableColumns"
+ [filteredCount]="dataSource.filteredData.length"
+ [totalCount]="dataSource.data.length"
+ (filterChanged)="applyFilter($event)"></droplet-table-filter>
+ <div class="flex justify-end">
+ <button mat-icon-button class="primary-icon-button" (click)="openImportNewDropletDialog()">
+ <i class="fa fa-plus"></i>
+ </button>
+ </div>
+ </div>
+ @if (droplets$ | async; as droplets) {
+ <droplet-table
+ [buckets]="buckets"
+ [dataSource]="dataSource"
+ [selectedId]="selectedDropletId$ | async"
+ (selectDroplet)="selectDroplet($event)"
+ class="h-full flex"></droplet-table>
+ }
+
+ @if (dropletsState$ | async; as dropletsState) {
+ <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)="refreshDropletsListing()">
+ <i class="fa fa-refresh" [class.fa-spin]="dropletsState.status === 'loading'"></i>
+ </button>
+ </div>
+ </div>
+ }
+ </div>
+</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.scss
similarity index 80%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.scss
index 71ad5e3cd6..bdd8410601 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.scss
@@ -17,15 +17,6 @@
@use '@angular/material' as mat;
-.banner-container {
- @include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
+:host {
+ height: 100%;
}
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
new file mode 100644
index 0000000000..252eb51088
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.spec.ts
@@ -0,0 +1,55 @@
+/*
+ * 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 { RouterModule } from '@angular/router';
+import { provideMockStore } from '@ngrx/store/testing';
+import { ResourcesComponent } from './resources.component';
+import { initialState } from '../../../state/droplets/droplets.reducer';
+import { initialState as initialBucketState } from '../../../state/buckets/buckets.reducer';
+import { resourcesFeatureKey } from '../../../state';
+import { dropletsFeatureKey } from '../../../state/droplets';
+import { bucketsFeatureKey } from '../../../state/buckets';
+
+describe('Resources', () => {
+ let component: ResourcesComponent;
+ let fixture: ComponentFixture<ResourcesComponent>;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ declarations: [ResourcesComponent],
+ imports: [RouterModule],
+ providers: [
+ provideMockStore({
+ initialState: {
+ [resourcesFeatureKey]: {
+ [dropletsFeatureKey]: initialState,
+ [bucketsFeatureKey]: initialBucketState
+ }
+ }
+ })
+ ]
+ });
+ fixture = TestBed.createComponent(ResourcesComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
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
new file mode 100644
index 0000000000..8fd2868a5b
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.component.ts
@@ -0,0 +1,186 @@
+/*
+ * 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, OnInit } from '@angular/core';
+import {
+ selectDropletIdFromRoute,
+ selectDroplets,
+ selectDropletState
+} from '../../../state/droplets/droplets.selectors';
+import { loadDroplets, openImportNewDropletDialog, selectDroplet } from '../../../state/droplets/droplets.actions';
+import { Droplet } from '../../../state/droplets';
+import { Store } from '@ngrx/store';
+import { MatTableDataSource } from '@angular/material/table';
+import { Observable } from 'rxjs';
+import { Sort } from '@angular/material/sort';
+import { NiFiCommon } from '@nifi/shared';
+import { loadBuckets } from '../../../state/buckets/buckets.actions';
+import { Bucket } from '../../../state/buckets';
+import { selectBuckets } from '../../../state/buckets/buckets.selectors';
+import {
+ DropletTableFilterColumn,
+ DropletTableFilterContext
+} from './ui/droplet-table-filter/droplet-table-filter.component';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { ErrorContextKey } from '../../../state/error';
+
+@Component({
+ selector: 'resources',
+ templateUrl: './resources.component.html',
+ styleUrl: './resources.component.scss',
+ standalone: false
+})
+export class ResourcesComponent implements OnInit {
+ droplets$: Observable<Droplet[]> = this.store.select(selectDroplets).pipe(takeUntilDestroyed());
+ buckets$: Observable<Bucket[]> = this.store.select(selectBuckets).pipe(takeUntilDestroyed());
+ buckets: Bucket[] = [];
+ dataSource: MatTableDataSource<Droplet> = new MatTableDataSource<Droplet>();
+ displayedColumns: string[] = [
+ 'name',
+ 'type',
+ 'bucketName',
+ 'bucketIdentifier',
+ 'identifier',
+ 'versions',
+ 'actions'
+ ];
+
+ filterableColumns: DropletTableFilterColumn[] = [
+ { key: 'name', label: 'Name' },
+ { key: 'type', label: 'Type' },
+ { key: 'bucketName', label: 'Bucket' },
+ { key: 'bucketIdentifier', label: 'Bucket ID' },
+ { key: 'identifier', label: 'Resource ID' },
+ { key: 'versionCount', label: 'Versions' }
+ ];
+ sort: Sort = {
+ active: 'name',
+ direction: 'asc'
+ };
+ filterTerm = '';
+ filterBucket = 'All';
+ filterColumn = '';
+ dropletsState$ = this.store.select(selectDropletState);
+ selectedDropletId$ = this.store.select(selectDropletIdFromRoute);
+
+ constructor(
+ private store: Store,
+ private nifiCommon: NiFiCommon
+ ) {}
+
+ ngOnInit(): void {
+ this.store.dispatch(loadDroplets());
+ this.store.dispatch(loadBuckets());
+ this.droplets$.subscribe((droplets) => {
+ this.dataSource.data = [...droplets];
+ this.sortData(this.sort);
+ });
+ this.buckets$.subscribe((buckets) => {
+ this.buckets = [...buckets];
+ });
+
+ this.dataSource.filterPredicate = (data: Droplet, filter: string) => {
+ const { filterTerm, filterColumn, filterBucket } = JSON.parse(filter);
+ const matchBucket: boolean = filterBucket !== 'All';
+
+ if (!filterTerm && !filterBucket) {
+ return true;
+ }
+
+ if (matchBucket) {
+ if (data.bucketIdentifier !== filterBucket) {
+ return false;
+ }
+ }
+
+ if (filterTerm === '') {
+ return true;
+ }
+
+ const value = (data as any)[filterColumn];
+ if (typeof value === 'number') {
+ return value.toString().includes(filterTerm);
+ }
+ return this.nifiCommon.stringContains(value, filterTerm, true);
+ };
+ }
+
+ sortData(sort: Sort) {
+ this.sort = sort;
+ this.dataSource.data = this.sortVersions(this.dataSource.data, sort);
+ }
+
+ sortVersions(data: Droplet[], sort: Sort): Droplet[] {
+ if (!data) {
+ return [];
+ }
+ return data.slice().sort((a, b) => {
+ const isAsc = sort.direction === 'asc';
+ let retVal = 0;
+ switch (sort.active) {
+ case 'versions':
+ retVal = this.nifiCommon.compareNumber(a.versionCount, b.versionCount);
+ break;
+ case 'name':
+ retVal = this.nifiCommon.compareString(a.name, b.name);
+ break;
+ case 'bucketName':
+ retVal = this.nifiCommon.compareString(a.bucketName, b.bucketName);
+ break;
+ case 'bucketIdentifier':
+ retVal = this.nifiCommon.compareString(a.bucketIdentifier, b.bucketIdentifier);
+ break;
+ case 'identifier':
+ retVal = this.nifiCommon.compareString(a.identifier, b.identifier);
+ break;
+ case 'type':
+ retVal = this.nifiCommon.compareString(a.type, b.type);
+ break;
+ }
+ return retVal * (isAsc ? 1 : -1);
+ });
+ }
+
+ openImportNewDropletDialog() {
+ this.store.dispatch(openImportNewDropletDialog({ request: { buckets: this.buckets } }));
+ }
+
+ applyFilter(filter: DropletTableFilterContext) {
+ if (!filter || !this.dataSource) {
+ return;
+ }
+
+ this.dataSource.filter = JSON.stringify(filter);
+ }
+
+ refreshDropletsListing() {
+ this.store.dispatch(loadDroplets());
+ this.store.dispatch(loadBuckets());
+ }
+
+ selectDroplet(droplet: Droplet): void {
+ this.store.dispatch(
+ selectDroplet({
+ request: {
+ id: droplet.identifier
+ }
+ })
+ );
+ }
+
+ protected readonly ErrorContextKey = ErrorContextKey;
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts
new file mode 100644
index 0000000000..d5da391bc2
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/resources.module.ts
@@ -0,0 +1,53 @@
+/*
+ * 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 { StoreModule } from '@ngrx/store';
+import { EffectsModule } from '@ngrx/effects';
+import { ResourcesRoutingModule } from './resources-routing.module';
+import { ResourcesComponent } from './resources.component';
+import { DropletsEffects } from '../../../state/droplets/droplets.effects';
+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 { DropletTableFilterComponent } from './ui/droplet-table-filter/droplet-table-filter.component';
+import { MatButtonModule } from '@angular/material/button';
+import { DropletTableComponent } from './ui/droplet-table/droplet-table.component';
+import { ContextErrorBanner } from '../../../ui/common/context-error-banner/context-error-banner.component';
+
+@NgModule({
+ declarations: [ResourcesComponent],
+ exports: [ResourcesComponent],
+ imports: [
+ CommonModule,
+ MatTableModule,
+ MatSortModule,
+ MatMenuModule,
+ DropletTableFilterComponent,
+ MatButtonModule,
+ MatButtonModule,
+ DropletTableComponent,
+ ContextErrorBanner,
+ ResourcesRoutingModule,
+ StoreModule.forFeature(resourcesFeatureKey, reducers),
+ EffectsModule.forFeature([DropletsEffects, BucketsEffects])
+ ]
+})
+export class ResourcesModule {}
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
new file mode 100644
index 0000000000..8ce39f26a1
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.html
@@ -0,0 +1,28 @@
+<!--
+~ 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/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss
similarity index 83%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss
index 71ad5e3cd6..2677b47e02 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.scss
@@ -17,15 +17,6 @@
@use '@angular/material' as mat;
-.banner-container {
+.delete-droplet-dialog {
@include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
}
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
new file mode 100644
index 0000000000..15df3b091d
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.spec.ts
@@ -0,0 +1,81 @@
+/*
+ * 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
new file mode 100644
index 0000000000..c2251d7854
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/delete-droplet-dialog/delete-droplet-dialog.component.ts
@@ -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.
+ */
+
+import { Component, Inject } from '@angular/core';
+import { CommonModule } from '@angular/common';
+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: [CommonModule, MatDialogModule, MatButtonModule, ContextErrorBanner],
+ templateUrl: './delete-droplet-dialog.component.html',
+ styleUrl: './delete-droplet-dialog.component.scss'
+})
+export class DeleteDropletDialogComponent extends CloseOnEscapeDialog {
+ protected readonly ErrorContextKey = ErrorContextKey;
+ droplet: Droplet;
+
+ constructor(
+ @Inject(MAT_DIALOG_DATA) public data: DeleteDropletDialogData,
+ private store: Store
+ ) {
+ super();
+ 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-table-filter/droplet-table-filter.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.html
new file mode 100644
index 0000000000..ad7ac1ad7c
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.html
@@ -0,0 +1,53 @@
+<!--
+~ 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>
+ <mat-form-field subscriptSizing="dynamic">
+ <mat-label>Bucket</mat-label>
+ <mat-select formControlName="filterBucket">
+ <mat-option value="All"> All Buckets</mat-option>
+ @for (bucket of buckets; track bucket) {
+ <mat-option [value]="bucket.identifier"> {{ bucket.name }}</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/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.scss
similarity index 76%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.scss
index 71ad5e3cd6..2944f98194 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.scss
@@ -14,18 +14,3 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
-@use '@angular/material' as mat;
-
-.banner-container {
- @include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.spec.ts
similarity index 68%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.spec.ts
index a1d0d010e5..38ce3882b4 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.spec.ts
@@ -16,18 +16,18 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { DropletTableFilterComponent } from './droplet-table-filter.component';
-import { ErrorBanner } from './error-banner.component';
+describe('DropletTableFilterComponent', () => {
+ let component: DropletTableFilterComponent;
+ let fixture: ComponentFixture<DropletTableFilterComponent>;
-describe('ErrorBanner', () => {
- let component: ErrorBanner;
- let fixture: ComponentFixture<ErrorBanner>;
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [DropletTableFilterComponent]
+ }).compileComponents();
- beforeEach(() => {
- TestBed.configureTestingModule({
- imports: [ErrorBanner]
- });
- fixture = TestBed.createComponent(ErrorBanner);
+ fixture = TestBed.createComponent(DropletTableFilterComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.ts
new file mode 100644
index 0000000000..8813c5f255
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table-filter/droplet-table-filter.component.ts
@@ -0,0 +1,142 @@
+/*
+ * 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 { AfterViewInit, Component, DestroyRef, EventEmitter, inject, Input, Output } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatSelectModule } from '@angular/material/select';
+import { MatInputModule } from '@angular/material/input';
+import { Bucket } from 'apps/nifi-registry/src/app/state/buckets';
+import { debounceTime } from 'rxjs';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { MatButtonModule } from '@angular/material/button';
+
+export interface DropletTableFilterColumn {
+ key: string;
+ label: string;
+}
+
+export interface DropletTableFilterArgs {
+ filterTerm: string;
+ filterColumn: string;
+ filterBucket?: string;
+}
+
+export interface DropletTableFilterContext extends DropletTableFilterArgs {
+ changedField: string;
+}
+
+@Component({
+ selector: 'droplet-table-filter',
+ imports: [CommonModule, ReactiveFormsModule, MatFormFieldModule, MatSelectModule, MatInputModule, MatButtonModule],
+ templateUrl: './droplet-table-filter.component.html',
+ styleUrl: './droplet-table-filter.component.scss'
+})
+export class DropletTableFilterComponent implements AfterViewInit {
+ filterForm: FormGroup;
+ private _initialFilterColumn = 'name';
+ private _filterableColumns: DropletTableFilterColumn[] = [];
+ private _buckets: Bucket[] = [];
+ private destroyRef: DestroyRef = inject(DestroyRef);
+
+ @Input() set filterableColumns(filterableColumns: DropletTableFilterColumn[]) {
+ this._filterableColumns = filterableColumns;
+ }
+ get filterableColumns(): DropletTableFilterColumn[] {
+ return this._filterableColumns;
+ }
+
+ @Input() set buckets(filterableColumns: Bucket[]) {
+ this._buckets = filterableColumns;
+ }
+ get buckets(): Bucket[] {
+ return this._buckets;
+ }
+
+ @Input() set filterTerm(term: string) {
+ this.filterForm.controls['filterTerm']?.setValue(term);
+ }
+
+ @Input() set filterColumn(column: string) {
+ this._initialFilterColumn = column;
+ if (this.filterableColumns?.length > 0) {
+ if (this.filterableColumns.findIndex((col) => col.key === column) >= 0) {
+ this.filterForm.get('filterColumn')?.setValue(column);
+ } else {
+ this.filterForm.get('filterColumn')?.setValue(this.filterableColumns[0].key);
+ }
+ } else {
+ this.filterForm.get('filterColumn')?.setValue(this._initialFilterColumn);
+ }
+ }
+
+ @Input() set filterBucket(term: string) {
+ this.filterForm.controls['filterBucket']?.setValue(term);
+ }
+
+ @Input() totalCount = 0;
+ @Input() filteredCount = 0;
+
+ @Output() filterChanged: EventEmitter<DropletTableFilterContext> = new EventEmitter<DropletTableFilterContext>();
+
+ constructor(private formBuilder: FormBuilder) {
+ this.filterForm = this.formBuilder.group({
+ filterTerm: '',
+ filterColumn: this._initialFilterColumn || 'name',
+ filterBucket: 'All'
+ });
+ }
+
+ ngAfterViewInit() {
+ this.filterForm
+ .get('filterTerm')
+ ?.valueChanges.pipe(debounceTime(500), takeUntilDestroyed(this.destroyRef))
+ .subscribe((filterTerm: string) => {
+ const filterColumn = this.filterForm.get('filterColumn')?.value;
+ const filterBucket = this.filterForm.get('filterBucket')?.value;
+ this.applyFilter(filterTerm, filterColumn, filterBucket, 'filterTerm');
+ });
+
+ this.filterForm
+ .get('filterColumn')
+ ?.valueChanges.pipe(debounceTime(500), takeUntilDestroyed(this.destroyRef))
+ .subscribe((filterColumn: string) => {
+ const filterTerm = this.filterForm.get('filterTerm')?.value;
+ const filterBucket = this.filterForm.get('filterBucket')?.value;
+ this.applyFilter(filterTerm, filterColumn, filterBucket, 'filterColumn');
+ });
+
+ this.filterForm
+ .get('filterBucket')
+ ?.valueChanges.pipe(debounceTime(500), takeUntilDestroyed(this.destroyRef))
+ .subscribe((filterBucket: string) => {
+ const filterColumn = this.filterForm.get('filterColumn')?.value;
+ const filterTerm = this.filterForm.get('filterTerm')?.value;
+ this.applyFilter(filterTerm, filterColumn, filterBucket, 'filterBucket');
+ });
+ }
+
+ applyFilter(filterTerm: string, filterColumn: string, filterBucket: string, changedField: string) {
+ this.filterChanged.next({
+ filterColumn,
+ filterTerm,
+ filterBucket,
+ changedField
+ });
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html
new file mode 100644
index 0000000000..6d78cc578e
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.html
@@ -0,0 +1,135 @@
+<!--
+~ 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>
+ <!-- Bucket Column -->
+ <ng-container matColumnDef="bucketName">
+ <th mat-header-cell *matHeaderCellDef mat-sort-header>Bucket</th>
+ <td mat-cell *matCellDef="let item">
+ <div class="overflow-ellipsis overflow-hidden whitespace-nowrap" [title]="item.bucketName">
+ {{ item.bucketName }}
+ </div>
+ </td>
+ </ng-container>
+ <!-- Bucket ID Column -->
+ <ng-container matColumnDef="bucketIdentifier">
+ <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.bucketIdentifier">
+ {{ item.bucketIdentifier }}
+ </div>
+ </td>
+ </ng-container>
+ <!-- Resource ID Column -->
+ <ng-container matColumnDef="identifier">
+ <th mat-header-cell *matHeaderCellDef mat-sort-header>Resource 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>
+ <!-- Type Column -->
+ <ng-container matColumnDef="type">
+ <th mat-header-cell *matHeaderCellDef mat-sort-header>Type</th>
+ <td mat-cell *matCellDef="let item">
+ <div class="overflow-ellipsis overflow-hidden whitespace-nowrap" [title]="item.type">
+ {{ item.type }}
+ </div>
+ </td>
+ </ng-container>
+ <!-- Versions Column -->
+ <ng-container matColumnDef="versions">
+ <th mat-header-cell *matHeaderCellDef mat-sort-header>Versions</th>
+ <td mat-cell *matCellDef="let item">
+ <div
+ class="overflow-ellipsis overflow-hidden whitespace-nowrap"
+ [title]="item.versionCount">
+ {{ item.versionCount }}
+ </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)="openDropletVersionsDialog(item)">
+ <i class="fa fa-list primary-color mr-2"></i>
+ See Versions
+ </button>
+ <button mat-menu-item (click)="openImportNewDropletVersionDialog(item)">
+ <i class="fa fa-upload primary-color mr-2"></i>
+ Import new version
+ </button>
+ <button mat-menu-item (click)="openExportDropletVersionDialog(item)">
+ <i class="fa fa-download primary-color mr-2"></i>
+ Export version
+ </button>
+ <button mat-menu-item (click)="openDeleteDialog(item)">
+ <i class="fa fa-trash primary-color mr-2"></i>
+ Delete resource
+ </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 class="py-5" *ngIf="dataSource.data.length === 0 && buckets.length !== 0">
+ <p class="text-center">There are no resources to display.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.scss
similarity index 79%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.scss
index 71ad5e3cd6..d2e1d81533 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.scss
@@ -16,16 +16,3 @@
*/
@use '@angular/material' as mat;
-
-.banner-container {
- @include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.spec.ts
new file mode 100644
index 0000000000..c1dac9172d
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.spec.ts
@@ -0,0 +1,134 @@
+/*
+ * 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 { DropletTableComponent } from './droplet-table.component';
+import { provideMockStore } from '@ngrx/store/testing';
+import { CommonModule } from '@angular/common';
+import { MatButtonModule } from '@angular/material/button';
+import { MatMenuModule } from '@angular/material/menu';
+import { MatSortModule } from '@angular/material/sort';
+import { MatTableModule } from '@angular/material/table';
+
+describe('DropletTableComponent', () => {
+ let component: DropletTableComponent;
+ let fixture: ComponentFixture<DropletTableComponent>;
+ const droplets = [
+ {
+ bucketIdentifier: '39a248d9-4ac6-42b2-8288-4b9ee6b2e3ed',
+ bucketName: 'Bucket 2',
+ createdTimestamp: 1747167193176,
+ description: 'This is a test description',
+ identifier: 'a48c0576-9075-45e5-af4d-5a6e76682f42',
+ link: {
+ href: 'buckets/39a248d9-4ac6-42b2-8288-4b9ee6b2e3ed/flows/a48c0576-9075-45e5-af4d-5a6e76682f42',
+ params: {
+ rel: 'self'
+ }
+ },
+ modifiedTimestamp: 1747167193204,
+ name: 'Test Flow 1',
+ permissions: {
+ canDelete: true,
+ canRead: true,
+ canWrite: true
+ },
+ revision: {
+ version: 0
+ },
+ type: 'Flow',
+ versionCount: 1
+ }
+ ];
+
+ const buckets = [
+ {
+ allowBundleRedeploy: false,
+ allowPublicRead: false,
+ createdTimestamp: 1747167167303,
+ description: '',
+ identifier: '39a248d9-4ac6-42b2-8288-4b9ee6b2e3ed',
+ link: {
+ href: 'buckets/39a248d9-4ac6-42b2-8288-4b9ee6b2e3ed',
+ params: {
+ rel: 'self'
+ }
+ },
+ name: 'Bucket 2',
+ permissions: {
+ canDelete: true,
+ canRead: true,
+ canWrite: true
+ },
+ revision: {
+ version: 0
+ }
+ }
+ ];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ DropletTableComponent,
+ CommonModule,
+ MatTableModule,
+ MatSortModule,
+ MatMenuModule,
+ MatButtonModule,
+ MatButtonModule
+ ],
+ providers: [provideMockStore({})]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DropletTableComponent);
+ component = fixture.componentInstance;
+ component.buckets = buckets;
+ component.dataSource.data = [...droplets];
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render the droplet table with the correct headings', () => {
+ const rows = fixture.nativeElement.querySelectorAll('tr');
+
+ // Header row
+ const headerRow = rows[0];
+ expect(headerRow.cells[0].innerHTML).toContain('Name');
+ expect(headerRow.cells[1].innerHTML).toContain('Type');
+ expect(headerRow.cells[2].innerHTML).toContain('Bucket');
+ expect(headerRow.cells[3].innerHTML).toContain('Bucket ID');
+ expect(headerRow.cells[4].innerHTML).toContain('Resource ID');
+ expect(headerRow.cells[5].innerHTML).toContain('Versions');
+ });
+
+ it('should render the droplet table with the correct row values', () => {
+ const rows = fixture.nativeElement.querySelectorAll('tr');
+ expect(rows.length).toBe(2);
+
+ // Droplet row
+ const row1 = rows[1];
+ expect(row1.cells[0].innerHTML).toContain(droplets[0].name);
+ expect(row1.cells[1].innerHTML).toContain(droplets[0].type);
+ expect(row1.cells[2].innerHTML).toContain(droplets[0].bucketName);
+ expect(row1.cells[3].innerHTML).toContain(droplets[0].bucketIdentifier);
+ expect(row1.cells[4].innerHTML).toContain(droplets[0].identifier);
+ expect(row1.cells[5].innerHTML).toContain(droplets[0].versionCount.toString());
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts
new file mode 100644
index 0000000000..6c0a14725d
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-table/droplet-table.component.ts
@@ -0,0 +1,137 @@
+/*
+ * 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 } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { Droplet } from 'apps/nifi-registry/src/app/state/droplets';
+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 { Bucket } from 'apps/nifi-registry/src/app/state/buckets';
+import { Store } from '@ngrx/store';
+import {
+ openDeleteDropletDialog,
+ openExportDropletVersionDialog,
+ openDropletVersionsDialog,
+ openImportNewDropletVersionDialog
+} from 'apps/nifi-registry/src/app/state/droplets/droplets.actions';
+
+@Component({
+ selector: 'droplet-table',
+ imports: [CommonModule, MatTableModule, MatSortModule, MatMenuModule, MatButtonModule, MatButtonModule],
+ templateUrl: './droplet-table.component.html',
+ styleUrl: './droplet-table.component.scss'
+})
+export class DropletTableComponent implements OnInit {
+ @Input() buckets: Bucket[] = [];
+ @Input() dataSource: MatTableDataSource<Droplet> = new MatTableDataSource<Droplet>();
+ @Input() selectedId: string | null = null;
+
+ @Output() selectDroplet: EventEmitter<Droplet> = new EventEmitter<Droplet>();
+
+ displayedColumns: string[] = [
+ 'name',
+ 'type',
+ 'bucketName',
+ 'bucketIdentifier',
+ 'identifier',
+ 'versions',
+ 'actions'
+ ];
+ sort: Sort = {
+ active: 'name',
+ direction: 'asc'
+ };
+
+ constructor(
+ private nifiCommon: NiFiCommon,
+ private store: Store
+ ) {}
+
+ ngOnInit(): void {
+ this.sortData(this.sort);
+ }
+
+ sortData(sort: Sort) {
+ this.sort = sort;
+ this.dataSource.data = this.sortVersions(this.dataSource.data, sort);
+ }
+
+ sortVersions(data: Droplet[], sort: Sort): Droplet[] {
+ if (!data) {
+ return [];
+ }
+ return data.slice().sort((a, b) => {
+ const isAsc = sort.direction === 'asc';
+ let retVal = 0;
+ switch (sort.active) {
+ case 'versions':
+ retVal = this.nifiCommon.compareNumber(a.versionCount, b.versionCount);
+ break;
+ case 'name':
+ retVal = this.nifiCommon.compareString(a.name, b.name);
+ break;
+ case 'bucketName':
+ retVal = this.nifiCommon.compareString(a.bucketName, b.bucketName);
+ break;
+ case 'bucketIdentifier':
+ retVal = this.nifiCommon.compareString(a.bucketIdentifier, b.bucketIdentifier);
+ break;
+ case 'identifier':
+ retVal = this.nifiCommon.compareString(a.identifier, b.identifier);
+ break;
+ case 'type':
+ retVal = this.nifiCommon.compareString(a.type, b.type);
+ break;
+ }
+ return retVal * (isAsc ? 1 : -1);
+ });
+ }
+
+ select(droplet: Droplet) {
+ this.selectDroplet.next(droplet);
+ }
+
+ isSelected(droplet: Droplet): boolean {
+ if (this.selectedId) {
+ return this.selectedId === droplet.identifier;
+ }
+ return false;
+ }
+
+ openImportNewDropletVersionDialog(droplet: Droplet) {
+ this.store.dispatch(
+ openImportNewDropletVersionDialog({
+ request: { droplet }
+ })
+ );
+ }
+
+ openExportDropletVersionDialog(droplet: Droplet) {
+ this.store.dispatch(openExportDropletVersionDialog({ request: { droplet } }));
+ }
+
+ openDeleteDialog(droplet: Droplet) {
+ this.store.dispatch(openDeleteDropletDialog({ request: { droplet } }));
+ }
+
+ openDropletVersionsDialog(droplet: Droplet) {
+ this.store.dispatch(openDropletVersionsDialog({ 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.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.html
new file mode 100644
index 0000000000..5dafc3236f
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.html
@@ -0,0 +1,94 @@
+<!--
+~ 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>Resource Versions</h2>
+<div class="flow-versions-dialog">
+ <mat-dialog-content>
+ <context-error-banner [context]="ErrorContextKey.EXPORT_DROPLET_VERSION"></context-error-banner>
+ <div class="listing-table flex-1 relative dialog-tab-content">
+ <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">
+ <!-- Version Column -->
+ <ng-container matColumnDef="version">
+ <th mat-header-cell *matHeaderCellDef mat-sort-header>Version</th>
+ <td mat-cell *matCellDef="let item">
+ <div class="overflow-ellipsis overflow-hidden whitespace-nowrap" [title]="item.version">
+ {{ item.version }}
+ </div>
+ </td>
+ </ng-container>
+ <!-- Create Column -->
+ <ng-container matColumnDef="created">
+ <th mat-header-cell *matHeaderCellDef mat-sort-header>Created</th>
+ <td mat-cell *matCellDef="let item">
+ {{ formatTimestamp(item) }}
+ </td>
+ </ng-container>
+ <!-- Comments Column -->
+ <ng-container matColumnDef="comments">
+ <th mat-header-cell *matHeaderCellDef mat-sort-header>Comments</th>
+ <td mat-cell *matCellDef="let item">
+ <div class="overflow-ellipsis overflow-hidden whitespace-nowrap" [title]="item.comments">
+ {{ item.comments }}
+ </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
+ data-test-id="exportMenuBtn"
+ 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)="exportVersion(item.version)"
+ data-test-id="exportBtn">
+ <i class="fa fa-download primary-color mr-2"></i>
+ Export resource version
+ </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"></tr>
+ </table>
+ </div>
+ </div>
+ </mat-dialog-content>
+ <mat-dialog-actions align="end">
+ <button mat-button mat-dialog-close>Close</button>
+ </mat-dialog-actions>
+</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.scss
similarity index 83%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.scss
index 71ad5e3cd6..3316222cc3 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.scss
@@ -17,15 +17,6 @@
@use '@angular/material' as mat;
-.banner-container {
+.flow-versions-dialog {
@include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
}
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
new file mode 100644
index 0000000000..95aba2e8a5
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.spec.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 { DropletVersionsDialogComponent } from './droplet-versions-dialog.component';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Subject } from 'rxjs';
+import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+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 { exportDropletVersion } from 'apps/nifi-registry/src/app/state/droplets/droplets.actions';
+
+describe('DropletVersionsDialogComponent', () => {
+ let component: DropletVersionsDialogComponent;
+ let fixture: ComponentFixture<DropletVersionsDialogComponent>;
+ let store: MockStore;
+ const mockDroplet = {
+ 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: 1
+ };
+ const mockVersions = {
+ author: 'anonymous',
+ bucketIdentifier: '311c6295-d8b6-47bd-806e-b5ee27dd8187',
+ flowIdentifier: 'ed9f3f48-751b-4237-a8a7-a4a2020f1efe',
+ link: {
+ href: 'buckets/311c6295-d8b6-47bd-806e-b5ee27dd8187/flows/ed9f3f48-751b-4237-a8a7-a4a2020f1efe/versions/1',
+ params: { rel: 'content' }
+ },
+ timestamp: 1741788614197,
+ version: 1
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ DropletVersionsDialogComponent,
+ MatTableModule,
+ MatSortModule,
+ MatDialogModule,
+ MatMenuModule,
+ MatButtonModule
+ ],
+ providers: [
+ { provide: MAT_DIALOG_DATA, useValue: { droplet: mockDroplet, versions: mockVersions } },
+ {
+ provide: MatDialogRef,
+ useValue: {
+ close: () => null,
+ keydownEvents: () => new Subject<KeyboardEvent>()
+ }
+ },
+ provideMockStore({})
+ ]
+ }).compileComponents();
+
+ store = TestBed.inject(MockStore);
+ fixture = TestBed.createComponent(DropletVersionsDialogComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should export flow', () => {
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+ component.exportVersion(2);
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ exportDropletVersion({ request: { droplet: mockDroplet, version: 2 } })
+ );
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.ts
new file mode 100644
index 0000000000..96a8ae335c
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/droplet-versions-dialog/droplet-versions-dialog.component.ts
@@ -0,0 +1,133 @@
+/*
+ * 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 { CommonModule } from '@angular/common';
+import { Droplet } from 'apps/nifi-registry/src/app/state/droplets';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { Store } from '@ngrx/store';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { MatSortModule, Sort } from '@angular/material/sort';
+import { CloseOnEscapeDialog, NiFiCommon } from '@nifi/shared';
+import { MatMenuModule } from '@angular/material/menu';
+import { exportDropletVersion } from 'apps/nifi-registry/src/app/state/droplets/droplets.actions';
+import { MatButtonModule } from '@angular/material/button';
+import { ContextErrorBanner } from '../../../../../ui/common/context-error-banner/context-error-banner.component';
+import { ErrorContextKey } from '../../../../../state/error';
+
+interface Data {
+ droplet: Droplet;
+ versions: VersionedFlowSnapshotMetadata[];
+}
+
+interface VersionedFlowSnapshotMetadata {
+ bucketIdentifier: string;
+ flowIdentifier: string;
+ version: string;
+ timestamp: number;
+ author: string;
+ comments: string;
+ branch?: string;
+}
+
+@Component({
+ selector: 'app-flow-versions-dialog',
+ imports: [
+ CommonModule,
+ MatTableModule,
+ MatSortModule,
+ MatDialogModule,
+ MatMenuModule,
+ MatButtonModule,
+ ContextErrorBanner
+ ],
+ templateUrl: './droplet-versions-dialog.component.html',
+ styleUrl: './droplet-versions-dialog.component.scss'
+})
+export class DropletVersionsDialogComponent extends CloseOnEscapeDialog {
+ dataSource: MatTableDataSource<VersionedFlowSnapshotMetadata> =
+ new MatTableDataSource<VersionedFlowSnapshotMetadata>();
+
+ sort: Sort = {
+ active: 'created',
+ direction: 'desc'
+ };
+ displayedColumns: string[] = ['version', 'created', 'comments', 'actions'];
+ timeOffset = 0;
+
+ constructor(
+ @Inject(MAT_DIALOG_DATA) public data: Data,
+ private nifiCommon: NiFiCommon,
+ private store: Store
+ ) {
+ super();
+ this.dataSource.data = data.versions;
+ }
+
+ sortData(sort: Sort) {
+ this.sort = sort;
+ this.dataSource.data = this.sortVersions(this.dataSource.data, sort);
+ }
+
+ sortVersions(data: VersionedFlowSnapshotMetadata[], sort: Sort): VersionedFlowSnapshotMetadata[] {
+ if (!data) {
+ return [];
+ }
+ return data.slice().sort((a, b) => {
+ const isAsc = sort.direction === 'asc';
+ let retVal = 0;
+ switch (sort.active) {
+ case 'version':
+ retVal = this.compareVersion(a.version, b.version);
+ break;
+ case 'created':
+ retVal = this.nifiCommon.compareNumber(a.timestamp, b.timestamp);
+ break;
+ case 'comments':
+ retVal = this.nifiCommon.compareString(a.comments, b.comments);
+ break;
+ }
+ return retVal * (isAsc ? 1 : -1);
+ });
+ }
+
+ formatTimestamp(flowVersion: VersionedFlowSnapshotMetadata) {
+ // get the current user time to properly convert the server time
+ const now: Date = new Date();
+
+ // convert the user offset to millis
+ const userTimeOffset: number = now.getTimezoneOffset() * 60 * 1000;
+
+ // create the proper date by adjusting by the offsets
+ const date: Date = new Date(flowVersion.timestamp + userTimeOffset + this.timeOffset);
+ return this.nifiCommon.formatDateTime(date);
+ }
+
+ exportVersion(version: number) {
+ this.store.dispatch(exportDropletVersion({ request: { droplet: this.data.droplet, version } }));
+ }
+
+ private compareVersion(a: string, b: string): number {
+ if (this.nifiCommon.isNumber(a) && this.nifiCommon.isNumber(b)) {
+ return this.nifiCommon.compareNumber(parseInt(a, 10), parseInt(b, 10));
+ } else {
+ return this.nifiCommon.compareString(a, b);
+ }
+ }
+
+ protected readonly ErrorContextKey = ErrorContextKey;
+}
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.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component.html
new file mode 100644
index 0000000000..358d529620
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component.html
@@ -0,0 +1,49 @@
+<!--
+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>Export Version</h2>
+<div class="export-version-dialog">
+ <mat-dialog-content class="pb-2 flex flex-col justify-between start">
+ <context-error-banner [context]="ErrorContextKey.EXPORT_DROPLET_VERSION"></context-error-banner>
+ <div class="pb-2 label-name">
+ <label>Choose Version</label>
+ </div>
+ <div class="w-full bucket-dropdown-field">
+ <mat-form-field class="flex w-full">
+ <mat-select [(value)]="selectedVersion">
+ <mat-option
+ *ngFor="let snapshotMeta of [].constructor(droplet.versionCount); let i = index"
+ [value]="i + 1">
+ <span *ngIf="snapshotMeta === i">Latest (Version {{ i + 1 }})</span>
+ <span *ngIf="snapshotMeta !== i">Version {{ i + 1 }}</span>
+ </mat-option>
+ </mat-select>
+ </mat-form-field>
+ </div>
+ </mat-dialog-content>
+ <mat-dialog-actions align="end">
+ <button mat-button mat-dialog-close type="button">Cancel</button>
+ <button
+ mat-flat-button
+ class="pl-2"
+ data-automation-id="export-versioned-flow-button"
+ (click)="exportVersion()"
+ data-test-id="export-btn">
+ Export
+ </button>
+ </mat-dialog-actions>
+</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component.scss
similarity index 83%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component.scss
index 71ad5e3cd6..df15c3f913 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component.scss
@@ -17,15 +17,6 @@
@use '@angular/material' as mat;
-.banner-container {
+.export-version-dialog {
@include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
}
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
new file mode 100644
index 0000000000..7b8f295ec2
--- /dev/null
+++ 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
@@ -0,0 +1,82 @@
+/*
+ * 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 { ExportDropletVersionDialogComponent } from './export-droplet-version-dialog.component';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { DebugElement } from '@angular/core';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Subject } from 'rxjs';
+import { By } from '@angular/platform-browser';
+import { exportDropletVersion } from 'apps/nifi-registry/src/app/state/droplets/droplets.actions';
+
+describe('ExportDropletVersionDialogComponent', () => {
+ let component: ExportDropletVersionDialogComponent;
+ let fixture: ComponentFixture<ExportDropletVersionDialogComponent>;
+ 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(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ExportDropletVersionDialogComponent],
+ 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(ExportDropletVersionDialogComponent);
+ component = fixture.componentInstance;
+ debug = fixture.debugElement;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should export flow', () => {
+ const exportFlowSpy = jest.spyOn(store, 'dispatch');
+ const exportBtn = debug.query(By.css('[data-test-id=export-btn]')).nativeElement;
+ exportBtn.click();
+ expect(exportFlowSpy).toHaveBeenCalledWith(
+ exportDropletVersion({ request: { droplet: mockData, version: 2 } })
+ );
+ });
+});
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.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.ts
new file mode 100644
index 0000000000..602720fee8
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/export-droplet-version-dialog/export-droplet-version-dialog.component.ts
@@ -0,0 +1,70 @@
+/*
+ * 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 { CommonModule } from '@angular/common';
+import { MatFormField } from '@angular/material/form-field';
+import { MatSelectModule } from '@angular/material/select';
+import { MatInputModule } from '@angular/material/input';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { Droplet } from 'apps/nifi-registry/src/app/state/droplets';
+import { Store } from '@ngrx/store';
+import { exportDropletVersion } from 'apps/nifi-registry/src/app/state/droplets/droplets.actions';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { MatButtonModule } from '@angular/material/button';
+import { ContextErrorBanner } from '../../../../../ui/common/context-error-banner/context-error-banner.component';
+import { ErrorContextKey } from '../../../../../state/error';
+
+export interface ExportFlowVersionDialogData {
+ droplet: Droplet;
+}
+
+@Component({
+ selector: 'app-export-flow-version-dialog',
+ imports: [
+ CommonModule,
+ MatFormField,
+ MatSelectModule,
+ MatInputModule,
+ MatDialogModule,
+ MatButtonModule,
+ ContextErrorBanner
+ ],
+ templateUrl: './export-droplet-version-dialog.component.html',
+ styleUrl: './export-droplet-version-dialog.component.scss'
+})
+export class ExportDropletVersionDialogComponent extends CloseOnEscapeDialog {
+ droplet: Droplet;
+ selectedVersion: number;
+
+ constructor(
+ @Inject(MAT_DIALOG_DATA) public data: ExportFlowVersionDialogData,
+ private store: Store
+ ) {
+ super();
+ this.droplet = data.droplet;
+ this.selectedVersion = this.droplet.versionCount;
+ }
+
+ exportVersion() {
+ this.store.dispatch(
+ exportDropletVersion({ request: { droplet: this.droplet, version: this.selectedVersion } })
+ );
+ }
+
+ protected readonly ErrorContextKey = ErrorContextKey;
+}
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.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component.html
new file mode 100644
index 0000000000..4c0983b5db
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component.html
@@ -0,0 +1,100 @@
+<!--
+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>Import New Resource</h2>
+<div class="import-new-flow-dialog">
+ <form class="import-new-flow-form" [formGroup]="importNewFlowForm">
+ <mat-dialog-content>
+ <context-error-banner [context]="ErrorContextKey.CREATE_DROPLET"></context-error-banner>
+ <div class="flex justify-between items-center">
+ <div class="w-full flex flex-1 items-center">
+ <mat-form-field class="w-full">
+ <mat-label>Name</mat-label>
+ <input matInput type="text" placeholder="Create a unique name" formControlName="name" />
+ </mat-form-field>
+ <div class="version-count ml-2">v.1</div>
+ </div>
+ </div>
+ <mat-label class="flex mb-2">Resource</mat-label>
+ <mat-form-field class="w-full">
+ <input
+ class="pointer"
+ (click)="flowUploadControl.click()"
+ matInput
+ formControlName="definition"
+ type="text"
+ placeholder="Choose a file"
+ [readonly]="true" />
+ <button
+ matSuffix
+ type="button"
+ class="upload-flow-definition tertiary-color icon icon-template-import mr-2.5 pointer"
+ title="Browse"
+ (click)="flowUploadControl.click()">
+ <input type="file" #flowUploadControl class="hidden" (change)="attachFlowDefinition($event)" />
+ </button>
+ </mat-form-field>
+
+ @if (fileToUpload) {
+ <div>
+ <div class="flex flex-col gap-y-3 mb-3">
+ <div class="flex flex-col">
+ <div>File to upload</div>
+ <div class="flex justify-between items-center">
+ <div class="tertiary-color font-medium">{{ fileNameAttached }}</div>
+ <button
+ class="pointer"
+ aria-label="Remove attached resource"
+ (click)="removeAttachedFlowDefinition()">
+ X
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ }
+ <div class="w-full flex flex-col">
+ <mat-label class="mb-2">Bucket</mat-label>
+ <mat-form-field class="flex">
+ <mat-select
+ #bucketSelect
+ panelClass="bucket-dropdown-select"
+ placeholder="Choose a location"
+ formControlName="bucket">
+ <mat-option *ngFor="let bucket of writableBuckets" [value]="bucket.identifier">
+ {{ bucket.name }}
+ </mat-option>
+ </mat-select>
+ </mat-form-field>
+ </div>
+ <mat-form-field class="w-full">
+ <mat-label class="mb-2">Description</mat-label>
+ <textarea matInput class="mb-2 w-full" formControlName="description"></textarea>
+ </mat-form-field>
+ </mat-dialog-content>
+ </form>
+ <mat-dialog-actions align="end">
+ <button mat-button mat-dialog-close type="button">Cancel</button>
+ <button
+ [disabled]="!importNewFlowForm.valid"
+ class="ml-2"
+ data-automation-id="import-new-versioned-flow-button"
+ (click)="importNewFlow()"
+ type="button"
+ mat-flat-button>
+ Import
+ </button>
+ </mat-dialog-actions>
+</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component.scss
similarity index 83%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component.scss
index 71ad5e3cd6..48b091cd26 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component.scss
@@ -17,15 +17,6 @@
@use '@angular/material' as mat;
-.banner-container {
+.import-new-flow-dialog {
@include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
}
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
new file mode 100644
index 0000000000..48bfbd3492
--- /dev/null
+++ 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
@@ -0,0 +1,143 @@
+/*
+ * 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 { ImportNewDropletDialogComponent } from './import-new-droplet-dialog.component';
+import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { Subject } from 'rxjs';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { CommonModule } from '@angular/common';
+import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatSelectModule } from '@angular/material/select';
+import { MatInputModule } from '@angular/material/input';
+import { MatButtonModule } from '@angular/material/button';
+import { createNewDroplet } from 'apps/nifi-registry/src/app/state/droplets/droplets.actions';
+
+describe('ImportNewDropletDialogComponent', () => {
+ let component: ImportNewDropletDialogComponent;
+ let fixture: ComponentFixture<ImportNewDropletDialogComponent>;
+ let store: MockStore;
+ let form: FormGroup;
+ const buckets = [
+ {
+ allowBundleRedeploy: false,
+ allowPublicRead: false,
+ createdTimestamp: 1736800325035,
+ description: '',
+ identifier: '311c6295-d8b6-47bd-806e-b5ee27dd8187',
+ link: {
+ href: 'buckets/311c6295-d8b6-47bd-806e-b5ee27dd8187',
+ params: {
+ rel: 'self'
+ }
+ },
+ name: 'Test Bucket',
+ permissions: {
+ canDelete: true,
+ canRead: true,
+ canWrite: true
+ },
+ revision: {
+ version: 0
+ }
+ },
+ {
+ allowBundleRedeploy: false,
+ allowPublicRead: false,
+ createdTimestamp: 1738008373583,
+ description: '',
+ identifier: '6c2f8d9a-2d72-4637-86d7-3e125296db85',
+ link: {
+ href: 'buckets/6c2f8d9a-2d72-4637-86d7-3e125296db85',
+ params: {
+ rel: 'self'
+ }
+ },
+ name: 'Test Bucket #2',
+ permissions: {
+ canDelete: true,
+ canRead: true,
+ canWrite: false
+ },
+ revision: {
+ version: 0
+ }
+ }
+ ];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ ImportNewDropletDialogComponent,
+ CommonModule,
+ MatDialogModule,
+ FormsModule,
+ ReactiveFormsModule,
+ MatFormFieldModule,
+ MatSelectModule,
+ MatInputModule,
+ MatButtonModule
+ ],
+ providers: [
+ { provide: MAT_DIALOG_DATA, useValue: { buckets } },
+ {
+ provide: MatDialogRef,
+ useValue: {
+ close: () => null,
+ keydownEvents: () => new Subject<KeyboardEvent>()
+ }
+ },
+ provideMockStore({})
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ImportNewDropletDialogComponent);
+ store = TestBed.inject(MockStore);
+ component = fixture.componentInstance;
+ form = component.importNewFlowForm;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should filter writable buckets', () => {
+ expect(component.writableBuckets.length).toBe(1);
+ });
+
+ it('should import a new flow', () => {
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+ const file = new File([''], 'testFile');
+ form.get('name')?.setValue('Flow Import 1');
+ form.get('description')?.setValue('test description');
+ form.get('bucket')?.setValue('311c6295-d8b6-47bd-806e-b5ee27dd8187');
+ component.fileToUpload = file;
+ component.importNewFlow();
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ createNewDroplet({
+ request: {
+ bucket: buckets[0],
+ file: file,
+ name: 'Flow Import 1',
+ description: 'test description'
+ }
+ })
+ );
+ });
+});
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.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.ts
new file mode 100644
index 0000000000..303b3c581e
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component.ts
@@ -0,0 +1,136 @@
+/*
+ * 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, ElementRef, Inject, OnInit, ViewChild } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { CloseOnEscapeDialog, NiFiCommon } from '@nifi/shared';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatSelectModule } from '@angular/material/select';
+import { Bucket } from 'apps/nifi-registry/src/app/state/buckets';
+import { MatInputModule } from '@angular/material/input';
+import { Store } from '@ngrx/store';
+import { createNewDroplet } from 'apps/nifi-registry/src/app/state/droplets/droplets.actions';
+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';
+
+export interface ImportNewFlowDialogData {
+ buckets: Bucket[];
+}
+
+@Component({
+ selector: 'app-import-new-flow-dialog',
+ imports: [
+ CommonModule,
+ MatDialogModule,
+ FormsModule,
+ ReactiveFormsModule,
+ MatFormFieldModule,
+ MatSelectModule,
+ MatInputModule,
+ MatButtonModule,
+ ContextErrorBanner
+ ],
+ templateUrl: './import-new-droplet-dialog.component.html',
+ styleUrl: './import-new-droplet-dialog.component.scss'
+})
+export class ImportNewDropletDialogComponent extends CloseOnEscapeDialog implements OnInit {
+ @ViewChild('flowUploadControl') flowUploadControl!: ElementRef;
+
+ protected readonly ErrorContextKey = ErrorContextKey;
+
+ fileToUpload: File | null = null;
+ writableBuckets: Bucket[] = [];
+ buckets: Bucket[] = [];
+ importNewFlowForm: FormGroup;
+ fileNameAttached: string | null = '';
+
+ constructor(
+ @Inject(MAT_DIALOG_DATA) public data: ImportNewFlowDialogData,
+ private store: Store,
+ private formBuilder: FormBuilder,
+ private nifiCommon: NiFiCommon
+ ) {
+ super();
+ this.buckets = data.buckets;
+ this.importNewFlowForm = this.formBuilder.group({
+ name: new FormControl('', Validators.required),
+ description: new FormControl(null),
+ bucket: new FormControl('', Validators.required),
+ definition: new FormControl('', Validators.required)
+ });
+ }
+
+ ngOnInit() {
+ this.writableBuckets = this.filterWritableBuckets(this.buckets);
+
+ // if there's only 1 writable bucket, always set as the initial value in the bucket dropdown
+ if (this.writableBuckets.length === 1) {
+ const autoSelectedBucket = this.buckets.find((b) => b.identifier === this.writableBuckets[0].identifier);
+ this.importNewFlowForm.get('bucket')?.setValue(autoSelectedBucket?.identifier);
+ }
+ }
+
+ filterWritableBuckets(buckets: Bucket[]) {
+ const filteredWritableBuckets: Bucket[] = [];
+ buckets.forEach(function (b: Bucket) {
+ if (b.permissions.canWrite) {
+ filteredWritableBuckets.push(b);
+ }
+ });
+ return filteredWritableBuckets;
+ }
+
+ importNewFlow() {
+ const selectedBucket: Bucket = this.writableBuckets.find((b) => {
+ return b.identifier === this.importNewFlowForm.get('bucket')?.value;
+ })!;
+
+ this.store.dispatch(
+ createNewDroplet({
+ request: {
+ bucket: selectedBucket,
+ file: this.fileToUpload!,
+ name: this.importNewFlowForm.get('name')?.value,
+ description: this.importNewFlowForm.get('description')?.value || null
+ }
+ })
+ );
+ }
+
+ attachFlowDefinition(event: Event): void {
+ const target = event.target as HTMLInputElement;
+ const files = target.files as FileList;
+ const file = files.item(0);
+ if (file) {
+ this.importNewFlowForm.get('definition')?.setValue(this.nifiCommon.substringBeforeLast(file.name, '.'));
+ this.importNewFlowForm.get('definition')?.markAsDirty();
+ this.importNewFlowForm.get('description')?.setValue(null);
+ this.fileNameAttached = file.name;
+ this.fileToUpload = file;
+ }
+ }
+
+ removeAttachedFlowDefinition() {
+ this.importNewFlowForm.get('definition')?.setValue('');
+ this.flowUploadControl.nativeElement.value = '';
+ this.fileNameAttached = null;
+ this.fileToUpload = null;
+ }
+}
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.html 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.html
new file mode 100644
index 0000000000..9dc2c2fc42
--- /dev/null
+++ 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.html
@@ -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.
+-->
+
+<h2 mat-dialog-title>Import New Resource Version</h2>
+<div class="import-new-flow-version-dialog">
+ <form class="import-new-flow-version-form" [formGroup]="importNewFlowVersionForm">
+ <context-error-banner [context]="ErrorContextKey.IMPORT_DROPLET_VERSION"></context-error-banner>
+ <mat-dialog-content>
+ <div>
+ <mat-label class="mb-2">Name</mat-label>
+ <div class="flex justify-between items-center mb-2">
+ <div class="w-full tertiary-color font-medium">
+ {{ droplet.name }} v.{{ droplet.versionCount + 1 }}
+ </div>
+ </div>
+ </div>
+ <div>
+ <mat-label class="flex mb-2">Resource</mat-label>
+ <mat-form-field class="w-full">
+ <input
+ class="pointer"
+ (click)="flowUploadControl.click()"
+ matInput
+ formControlName="newFlowVersionDefinition"
+ type="text"
+ placeholder="Choose a file"
+ [readonly]="true" />
+ <button
+ matSuffix
+ type="button"
+ class="upload-flow-definition tertiary-color icon icon-template-import mr-2.5 pointer"
+ title="Browse"
+ (click)="flowUploadControl.click()">
+ <input type="file" #flowUploadControl class="hidden" (change)="attachFlowDefinition($event)" />
+ </button>
+ </mat-form-field>
+ </div>
+ @if (fileToUpload) {
+ <div>
+ <div class="flex flex-col gap-y-3 mb-3">
+ <div class="flex flex-col">
+ <div>File to upload</div>
+ <div class="flex justify-between items-center">
+ <div class="tertiary-color font-medium">{{ fileNameAttached }}</div>
+ <button aria-label="Remove attached resource" (click)="removeAttachedFlowDefinition()">
+ X
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ }
+ <mat-form-field class="w-full">
+ <mat-label class="mb-2">Version Comments</mat-label>
+ <textarea matInput class="mb-2 w-full" formControlName="newFlowVersionComments"></textarea>
+ </mat-form-field>
+ </mat-dialog-content>
+ </form>
+ <mat-dialog-actions align="end">
+ <button mat-button mat-dialog-close type="button">Cancel</button>
+ <button
+ [disabled]="!fileToUpload"
+ class="ml-2"
+ data-automation-id="import-new-versioned-flow-button"
+ (click)="importNewFlowVersion()"
+ type="submit"
+ mat-flat-button>
+ Import
+ </button>
+ </mat-dialog-actions>
+</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss 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.scss
similarity index 83%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to 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.scss
index 71ad5e3cd6..615a87bd7f 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ 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.scss
@@ -17,15 +17,6 @@
@use '@angular/material' as mat;
-.banner-container {
+.import-new-flow-version-dialog {
@include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
}
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
new file mode 100644
index 0000000000..c255d01073
--- /dev/null
+++ 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
@@ -0,0 +1,94 @@
+/*
+ * 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 { ImportNewDropletVersionDialogComponent } from './import-new-droplet-version-dialog.component';
+import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
+import { Subject } from 'rxjs';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { CommonModule } from '@angular/common';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatSelectModule } from '@angular/material/select';
+import { MatInputModule } from '@angular/material/input';
+import { MatButtonModule } from '@angular/material/button';
+
+describe('ImportNewDropletVersionDialogComponent', () => {
+ let component: ImportNewDropletVersionDialogComponent;
+ let fixture: ComponentFixture<ImportNewDropletVersionDialogComponent>;
+ let store: MockStore;
+ const mockDroplet = {
+ 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(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ ImportNewDropletVersionDialogComponent,
+ CommonModule,
+ MatDialogModule,
+ FormsModule,
+ ReactiveFormsModule,
+ MatFormFieldModule,
+ MatSelectModule,
+ MatInputModule,
+ MatButtonModule
+ ],
+ providers: [
+ { provide: MAT_DIALOG_DATA, useValue: { droplet: mockDroplet } },
+ {
+ provide: MatDialogRef,
+ useValue: {
+ close: () => null,
+ keydownEvents: () => new Subject<KeyboardEvent>()
+ }
+ },
+ provideMockStore({})
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ImportNewDropletVersionDialogComponent);
+ store = TestBed.inject(MockStore);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should import a new flow version to the correct bucket', () => {
+ const dispatchSpy = jest.spyOn(store, 'dispatch');
+ const file = new File([''], 'testFile');
+ component.fileToUpload = file;
+ component.name = 'testName';
+ component.description = 'testDescription';
+ component.importNewFlowVersion();
+ expect(dispatchSpy).toHaveBeenCalled();
+ });
+});
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.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.ts
new file mode 100644
index 0000000000..778bfd0f48
--- /dev/null
+++ 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.ts
@@ -0,0 +1,115 @@
+/*
+ * 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, ElementRef, Inject, ViewChild } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { CloseOnEscapeDialog, NiFiCommon } from '@nifi/shared';
+import { FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
+import { Store } from '@ngrx/store';
+import { importNewDropletVersion } from 'apps/nifi-registry/src/app/state/droplets/droplets.actions';
+import { Droplet } from 'apps/nifi-registry/src/app/state/droplets';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatSelectModule } from '@angular/material/select';
+import { MatInputModule } from '@angular/material/input';
+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';
+
+export interface ImportNewFlowVersionDialogData {
+ droplet: Droplet;
+}
+
+@Component({
+ selector: 'app-import-new-flow-version-dialog',
+ imports: [
+ CommonModule,
+ MatDialogModule,
+ FormsModule,
+ ReactiveFormsModule,
+ MatFormFieldModule,
+ MatSelectModule,
+ MatInputModule,
+ MatButtonModule,
+ ContextErrorBanner
+ ],
+ templateUrl: './import-new-droplet-version-dialog.component.html',
+ styleUrl: './import-new-droplet-version-dialog.component.scss'
+})
+export class ImportNewDropletVersionDialogComponent extends CloseOnEscapeDialog {
+ @ViewChild('flowUploadControl') flowUploadControl!: ElementRef;
+
+ protected readonly ErrorContextKey = ErrorContextKey;
+ extensions = 'application/json';
+ fileName: string | null = null;
+ fileToUpload: File | null = null;
+ name = '';
+ description = '';
+ droplet: Droplet;
+ comments = '';
+ importNewFlowVersionForm: FormGroup;
+ fileNameAttached: string | null = '';
+
+ constructor(
+ @Inject(MAT_DIALOG_DATA) public data: ImportNewFlowVersionDialogData,
+ private formBuilder: FormBuilder,
+ private store: Store,
+ private nifiCommon: NiFiCommon
+ ) {
+ super();
+ this.droplet = data.droplet;
+
+ this.importNewFlowVersionForm = this.formBuilder.group({
+ newFlowVersionDefinition: new FormControl('', Validators.required),
+ newFlowVersionComments: new FormControl(null)
+ });
+ }
+
+ attachFlowDefinition(event: Event): void {
+ const target = event.target as HTMLInputElement;
+ const files = target.files as FileList;
+ const file = files.item(0);
+ if (file) {
+ this.importNewFlowVersionForm
+ .get('newFlowVersionDefinition')
+ ?.setValue(this.nifiCommon.substringBeforeLast(file.name, '.'));
+ this.importNewFlowVersionForm.get('newFlowVersionDefinition')?.markAsDirty();
+ this.importNewFlowVersionForm.get('newFlowVersionComments')?.setValue(null);
+ this.fileNameAttached = file.name;
+ this.fileToUpload = file;
+ }
+ }
+
+ removeAttachedFlowDefinition() {
+ this.importNewFlowVersionForm.get('newFlowVersionDefinition')?.setValue('');
+ this.flowUploadControl.nativeElement.value = '';
+ this.fileNameAttached = null;
+ this.fileToUpload = null;
+ }
+
+ importNewFlowVersion() {
+ this.store.dispatch(
+ importNewDropletVersion({
+ request: {
+ href: this.droplet.link.href,
+ file: this.fileToUpload!,
+ description: this.importNewFlowVersionForm.get('newFlowVersionComments')?.value || null
+ }
+ })
+ );
+ }
+}
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
new file mode 100644
index 0000000000..fa4c5d3ec3
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/buckets.service.ts
@@ -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.
+ */
+
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { HttpClient } from '@angular/common/http';
+
+@Injectable({ providedIn: 'root' })
+export class BucketsService {
+ private static readonly API: string = '../nifi-registry-api';
+
+ constructor(private httpClient: HttpClient) {}
+
+ getBuckets(): Observable<any> {
+ // const mockError: HttpErrorResponse = new HttpErrorResponse({
+ // status: 404,
+ // statusText: 'Bad Gateway',
+ // url: `${BucketsService.API}/buckets`,
+ // error: {
+ // message: 'Mock error: unable to GET buckets.',
+ // timestamp: new Date().toISOString()
+ // }
+ // });
+ // return throwError(() => mockError);
+
+ return this.httpClient.get(`${BucketsService.API}/buckets`);
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/droplets.service.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/droplets.service.ts
new file mode 100644
index 0000000000..e4194a176d
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/droplets.service.ts
@@ -0,0 +1,136 @@
+/*
+ * 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 { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+import { HttpClient, HttpHeaders } from '@angular/common/http';
+
+@Injectable({ providedIn: 'root' })
+export class DropletsService {
+ private static readonly API: string = '../nifi-registry-api';
+
+ constructor(private httpClient: HttpClient) {}
+
+ getDroplets(bucketId?: string): Observable<any> {
+ // const mockError: HttpErrorResponse = new HttpErrorResponse({
+ // status: 404,
+ // statusText: 'Service Unavailable',
+ // url: bucketId
+ // ? `${DropletsService.API}/items/${bucketId}`
+ // : `${DropletsService.API}/items`,
+ // error: {
+ // message: 'Mock error: unable to GET droplets.',
+ // timestamp: new Date().toISOString()
+ // }
+ // });
+ //
+ // return throwError(() => mockError);
+
+ if (bucketId) {
+ return this.httpClient.get(`${DropletsService.API}/items/${bucketId}`);
+ }
+ return this.httpClient.get(`${DropletsService.API}/items`);
+ }
+
+ deleteDroplet(href: string): Observable<any> {
+ // const mockError: HttpErrorResponse = new HttpErrorResponse({
+ // status: 403,
+ // statusText: 'Forbidden',
+ // url: `${DropletsService.API}/${href}?version=0`,
+ // error: {
+ // message: 'Mock error: unable to delete droplet.',
+ // timestamp: new Date().toISOString()
+ // }
+ // });
+ // return throwError(() => mockError);
+
+ return this.httpClient.delete(`${DropletsService.API}/${href}?version=0`);
+ }
+
+ createNewDroplet(bucketUri: string, name: string, description: string): Observable<any> {
+ // const mockError: HttpErrorResponse = new HttpErrorResponse({
+ // status: 400,
+ // statusText: 'Bad Request',
+ // url: `${DropletsService.API}/${bucketUri}/flows`,
+ // error: {
+ // message: 'Mock error: unable to import resource.',
+ // details: { name, description },
+ // timestamp: new Date().toISOString()
+ // }
+ // });
+ // return throwError(() => mockError);
+
+ return this.httpClient.post(`${DropletsService.API}/${bucketUri}/flows`, {
+ name: name,
+ description: description
+ });
+ }
+
+ uploadDroplet(flowUri: string, file: File, description: string): Observable<any> {
+ // const mockError: HttpErrorResponse = new HttpErrorResponse({
+ // status: 413,
+ // statusText: 'Payload Too Large',
+ // url: `${DropletsService.API}/${flowUri}/versions/import`,
+ // error: {
+ // message: 'Mock error: uploaded file exceeds maximum size.',
+ // fileName: file?.name,
+ // timestamp: new Date().toISOString()
+ // }
+ // });
+ // return throwError(() => mockError);
+
+ return this.httpClient.post(`${DropletsService.API}/${flowUri}/versions/import`, file, {
+ headers: { Comments: description || '' } // using a short-circuit here because the backend does not accept null
+ });
+ }
+
+ exportDropletVersionedSnapshot(dropletUri: string, versionNumber: number): Observable<any> {
+ // const mockError: HttpErrorResponse = new HttpErrorResponse({
+ // status: 404,
+ // statusText: 'Not Found',
+ // url: `${DropletsService.API}/${dropletUri}/versions/${versionNumber}/export`,
+ // error: {
+ // message: 'Mock error: error exporting resource.',
+ // version: versionNumber,
+ // timestamp: new Date().toISOString()
+ // }
+ // });
+ // return throwError(() => mockError);
+
+ const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
+ return this.httpClient.get(`${DropletsService.API}/${dropletUri}/versions/${versionNumber}/export`, {
+ headers,
+ observe: 'response',
+ responseType: 'text'
+ });
+ }
+
+ getDropletSnapshotMetadata(dropletUri: string): Observable<any> {
+ // const mockError: HttpErrorResponse = new HttpErrorResponse({
+ // status: 500,
+ // statusText: 'Internal Server Error',
+ // url: `${DropletsService.API}/${dropletUri}/versions`,
+ // error: {
+ // message: 'Mock error: failed to retrieve snapshot metadata.',
+ // timestamp: new Date().toISOString()
+ // }
+ // });
+ // return throwError(() => mockError);
+
+ return this.httpClient.get(`${DropletsService.API}/${dropletUri}/versions`);
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/error-helper.service.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/error-helper.service.ts
new file mode 100644
index 0000000000..bffd7c42b4
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/service/error-helper.service.ts
@@ -0,0 +1,45 @@
+/*
+ * 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 { Injectable } from '@angular/core';
+import { HttpErrorResponse } from '@angular/common/http';
+
+@Injectable({ providedIn: 'root' })
+export class ErrorHelper {
+ getErrorString(errorResponse: HttpErrorResponse, prefix?: string): string {
+ // TODO: Update to handle actual error response structure from NiFi Registry REST API
+ let errorMessage = 'An unspecified error occurred.';
+ if (errorResponse) {
+ const err = errorResponse.error;
+ if (err) {
+ if (typeof err === 'string') {
+ errorMessage = err;
+ } else if (typeof err === 'object') {
+ // Prefer nested message fields from our mock errors
+ errorMessage = err.message || JSON.stringify(err);
+ } else {
+ errorMessage = `${err}`;
+ }
+ } else if (errorResponse.message) {
+ errorMessage = errorResponse.message;
+ } else if (errorResponse.status) {
+ errorMessage = `${errorResponse.status} ${errorResponse.statusText || ''}`.trim();
+ }
+ }
+ return prefix ? `${prefix} - [${errorMessage}]` : errorMessage;
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
similarity index 73%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
index 71ad5e3cd6..46a3df69d8 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.actions.ts
@@ -15,17 +15,12 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+import { createAction, props } from '@ngrx/store';
+import { LoadBucketsResponse } from '.';
-.banner-container {
- @include mat.button-density(-1);
+export const loadBuckets = createAction('[Buckets] Load Buckets');
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+export const loadBucketsSuccess = createAction(
+ '[Buckets] Load Buckets Success',
+ props<{ response: LoadBucketsResponse }>()
+);
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
new file mode 100644
index 0000000000..256ffe2a36
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.effects.ts
@@ -0,0 +1,65 @@
+/*
+ * 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 { 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 * 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';
+
+@Injectable()
+export class BucketsEffects {
+ constructor(
+ private bucketsService: BucketsService,
+ private errorHelper: ErrorHelper
+ ) {}
+
+ actions$ = inject(Actions);
+
+ loadBuckets$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(BucketsActions.loadBuckets),
+ switchMap(() => {
+ return from(
+ this.bucketsService.getBuckets().pipe(
+ map((response) =>
+ BucketsActions.loadBucketsSuccess({
+ response: {
+ buckets: response
+ }
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) => of(this.bannerError(errorResponse)))
+ )
+ );
+ })
+ )
+ );
+
+ private bannerError(errorResponse: HttpErrorResponse, context: ErrorContextKey = ErrorContextKey.GLOBAL) {
+ return DropletsActions.dropletsBannerError({
+ errorContext: {
+ errors: [this.errorHelper.getErrorString(errorResponse)],
+ context
+ }
+ });
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.reducer.ts
similarity index 59%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.reducer.ts
index 71ad5e3cd6..657c1f583c 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.reducer.ts
@@ -15,17 +15,24 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+import { createReducer, on } from '@ngrx/store';
+import { loadBuckets, loadBucketsSuccess } from './buckets.actions';
+import { BucketsState } from '.';
-.banner-container {
- @include mat.button-density(-1);
+export const initialState: BucketsState = {
+ buckets: [],
+ status: 'pending'
+};
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+export const bucketsReducer = createReducer(
+ initialState,
+ on(loadBuckets, (state) => ({
+ ...state,
+ status: 'loading' as const
+ })),
+ on(loadBucketsSuccess, (state, { response }) => ({
+ ...state,
+ buckets: response.buckets,
+ status: 'success' as const
+ }))
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
similarity index 61%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
index 71ad5e3cd6..7384865a9d 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/buckets.selectors.ts
@@ -15,17 +15,13 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+import { createFeatureSelector, createSelector } from '@ngrx/store';
+import { bucketsFeatureKey, BucketsState } from './index';
-.banner-container {
- @include mat.button-density(-1);
+import { resourcesFeatureKey, ResourcesState } from '..';
- .error-banner-message {
- word-break: break-word;
- }
+export const selectResourcesState = createFeatureSelector<ResourcesState>(resourcesFeatureKey);
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+export const selectBucketState = createSelector(selectResourcesState, (state) => state[bucketsFeatureKey]);
+
+export const selectBuckets = createSelector(selectBucketState, (state: BucketsState) => state.buckets);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/index.ts
similarity index 59%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/index.ts
index 71ad5e3cd6..771d43da4e 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/buckets/index.ts
@@ -15,17 +15,28 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+import { Permissions } from '@nifi/shared';
+import { Link, Revision } from '../index';
-.banner-container {
- @include mat.button-density(-1);
+export const bucketsFeatureKey = 'buckets';
- .error-banner-message {
- word-break: break-word;
- }
+export interface LoadBucketsResponse {
+ buckets: Bucket[];
+}
+
+export interface Bucket {
+ allowBundleRedeploy: boolean;
+ allowPublicRead: boolean;
+ createdTimestamp: number;
+ description: string;
+ identifier: string;
+ link: Link;
+ name: string;
+ permissions: Permissions;
+ revision: Revision;
+}
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
+export interface BucketsState {
+ buckets: Bucket[];
+ status: 'pending' | 'loading' | 'success';
}
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
new file mode 100644
index 0000000000..9ddecaaaf7
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.actions.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 { createAction, props } from '@ngrx/store';
+import {
+ DeleteDropletRequest,
+ Droplet,
+ ImportDropletDialog,
+ ImportDropletRequest,
+ ImportDropletVersionRequest,
+ LoadDropletsResponse
+} from '.';
+import { ErrorContext } from '../error';
+
+export const loadDroplets = createAction('[Droplets] Load Droplets');
+
+export const loadDropletsSuccess = createAction(
+ '[Droplets] Load Droplets User Success',
+ props<{ response: LoadDropletsResponse }>()
+);
+
+export const openDeleteDropletDialog = createAction(
+ '[Droplets] Open Delete Droplet Dialog',
+ props<{ request: DeleteDropletRequest }>()
+);
+
+export const deleteDroplet = createAction('[Droplets] Delete Droplet', props<{ request: DeleteDropletRequest }>());
+
+export const deleteDropletSuccess = createAction('[Droplets] Delete Droplet Success', props<{ response: Droplet }>());
+
+export const openImportNewDropletDialog = createAction(
+ '[Droplets] Open Import New Droplet Dialog',
+ props<{ request: ImportDropletDialog }>()
+);
+
+export const createNewDroplet = createAction(
+ '[Droplets] Create New Droplet',
+ props<{ request: ImportDropletRequest }>()
+);
+
+export const importNewDropletVersion = createAction(
+ '[Droplets] Import New Droplet Version',
+ props<{ request: ImportDropletVersionRequest }>()
+);
+
+export const createNewDropletSuccess = createAction(
+ `[Droplets] Create New Droplet Success`,
+ props<{ response: Droplet; request: ImportDropletVersionRequest }>()
+);
+
+export const importNewDropletVersionSuccess = createAction(
+ '[Droplets] Import New Droplet Version Success',
+ props<{ response: any }>()
+);
+
+export const openImportNewDropletVersionDialog = createAction(
+ '[Droplets] Open Import New Droplet Version Dialog',
+ props<{ request: { droplet: Droplet } }>()
+);
+
+export const openExportDropletVersionDialog = createAction(
+ `[Droplets] Open Export Droplet Version Dialog`,
+ props<{ request: { droplet: Droplet } }>()
+);
+
+export const exportDropletVersion = createAction(
+ `[Droplets] Export Droplet Version`,
+ props<{ request: { droplet: Droplet; version: number } }>()
+);
+
+export const exportDropletVersionSuccess = createAction(
+ `[Droplets] Export Droplet Version Success`,
+ props<{ response: any }>()
+);
+
+export const openDropletVersionsDialog = createAction(
+ `[Droplets] Open Droplet Versions Dialog`,
+ 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(
+ '[Droplets] Import New Droplet Version Error',
+ props<{ errorContext: ErrorContext; createdDroplet: Droplet }>()
+);
+
+export const importVersionForNewDroplet = createAction(
+ '[Droplets] Import Version For New Droplet',
+ props<{ request: ImportDropletVersionRequest; createdDroplet: Droplet }>()
+);
+
+// Utility no-op action for effects that need to emit a valid action on success. This allows dialogs to add global
+// context errors when the dialog is unable to async GET the data it needs in order to open.
+export const noOp = createAction('[Droplets] No Op');
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
new file mode 100644
index 0000000000..0729382ed4
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.effects.ts
@@ -0,0 +1,379 @@
+/*
+ * 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 { inject, Injectable } from '@angular/core';
+import { HttpErrorResponse } from '@angular/common/http';
+import { MatDialog } from '@angular/material/dialog';
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import { catchError, from, map, of, switchMap, tap } from 'rxjs';
+import { MEDIUM_DIALOG, SMALL_DIALOG, XL_DIALOG } from '@nifi/shared';
+import { 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
+} from '../../pages/resources/feature/ui/import-new-droplet-dialog/import-new-droplet-dialog.component';
+import {
+ ImportNewDropletVersionDialogComponent,
+ ImportNewFlowVersionDialogData
+} from '../../pages/resources/feature/ui/import-new-droplet-version-dialog/import-new-droplet-version-dialog.component';
+import {
+ ExportDropletVersionDialogComponent,
+ ExportFlowVersionDialogData
+} 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';
+
+@Injectable()
+export class DropletsEffects {
+ constructor(
+ private dropletsService: DropletsService,
+ private dialog: MatDialog,
+ private errorHelper: ErrorHelper,
+ private router: Router
+ ) {}
+
+ actions$ = inject(Actions);
+
+ loadDroplets$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(DropletsActions.loadDroplets),
+ switchMap(() => {
+ return from(
+ this.dropletsService.getDroplets().pipe(
+ map((response) =>
+ DropletsActions.loadDropletsSuccess({
+ response: {
+ droplets: response
+ }
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) => of(this.bannerError(errorResponse)))
+ )
+ );
+ })
+ )
+ );
+
+ openDeleteDropletDialog$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(DropletsActions.openDeleteDropletDialog),
+ tap(({ request }) => {
+ this.dialog.open<DeleteDropletDialogComponent, ExportFlowVersionDialogData>(
+ DeleteDropletDialogComponent,
+ {
+ ...SMALL_DIALOG,
+ autoFocus: false,
+ data: request
+ }
+ );
+ })
+ ),
+ { dispatch: false }
+ );
+
+ deleteDroplet$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(DropletsActions.deleteDroplet),
+ map((action) => action.request),
+ switchMap((request) =>
+ 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))
+ )
+ )
+ )
+ )
+ );
+
+ deleteDropletSuccess$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(DropletsActions.deleteDropletSuccess),
+ tap(() => this.dialog.closeAll())
+ ),
+ { dispatch: false }
+ );
+
+ openImportNewDropletDialog$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(DropletsActions.openImportNewDropletDialog),
+ map((action) => action.request),
+ tap((request) => {
+ this.dialog.open<ImportNewDropletDialogComponent, ImportNewFlowDialogData>(
+ ImportNewDropletDialogComponent,
+ {
+ ...MEDIUM_DIALOG,
+ autoFocus: false,
+ data: {
+ buckets: request.buckets
+ }
+ }
+ );
+ })
+ ),
+ { dispatch: false }
+ );
+
+ createNewDroplet$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(DropletsActions.createNewDroplet),
+ map((action) => action.request),
+ switchMap((request) =>
+ from(
+ this.dropletsService.createNewDroplet(request.bucket.link.href, request.name, request.description)
+ ).pipe(
+ map((res) =>
+ DropletsActions.createNewDropletSuccess({
+ response: res,
+ request: {
+ href: res.link.href,
+ file: request.file,
+ description: request.description
+ }
+ })
+ ),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(this.bannerError(errorResponse, ErrorContextKey.CREATE_DROPLET))
+ )
+ )
+ )
+ )
+ );
+
+ createNewDropletSuccess$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(DropletsActions.createNewDropletSuccess),
+ tap(() => this.dialog.closeAll()),
+ map((action) =>
+ DropletsActions.importVersionForNewDroplet({
+ request: action.request,
+ createdDroplet: action.response
+ })
+ )
+ )
+ );
+
+ importNewDroplet$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(DropletsActions.importNewDropletVersion),
+ map((action) => action.request),
+ switchMap(({ href, file, description }) =>
+ from(this.dropletsService.uploadDroplet(href, file, description)).pipe(
+ map((res) => DropletsActions.importNewDropletVersionSuccess({ response: res })),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(this.bannerError(errorResponse, ErrorContextKey.IMPORT_DROPLET_VERSION))
+ )
+ )
+ )
+ )
+ );
+
+ importVersionForNewDroplet$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(DropletsActions.importVersionForNewDroplet),
+ switchMap((action) =>
+ from(
+ this.dropletsService.uploadDroplet(
+ action.request.href,
+ action.request.file,
+ action.request.description
+ )
+ ).pipe(
+ map((res) => DropletsActions.importNewDropletVersionSuccess({ response: res })),
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ DropletsActions.importNewDropletVersionError({
+ errorContext: {
+ errors: [this.errorHelper.getErrorString(errorResponse)],
+ context: ErrorContextKey.IMPORT_DROPLET_VERSION
+ },
+ createdDroplet: action.createdDroplet
+ })
+ )
+ )
+ )
+ )
+ )
+ );
+
+ importNewDropletSuccess$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(DropletsActions.importNewDropletVersionSuccess),
+ tap(() => this.dialog.closeAll())
+ ),
+ { dispatch: false }
+ );
+
+ importNewDropletVersionError$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(DropletsActions.importNewDropletVersionError),
+ switchMap((action) =>
+ from(this.dropletsService.deleteDroplet(action.createdDroplet.link.href + '?version=0')).pipe(
+ switchMap(() => [DropletsActions.dropletsBannerError({ errorContext: action.errorContext })]),
+ catchError(() => [
+ // If deletion also fails, still show the original import error
+ DropletsActions.dropletsBannerError({ errorContext: action.errorContext })
+ ])
+ )
+ )
+ )
+ );
+
+ openImportNewDropletVersionDialog$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(DropletsActions.openImportNewDropletVersionDialog),
+ map((action) => action.request),
+ tap((request) => {
+ this.dialog.open<ImportNewDropletVersionDialogComponent, ImportNewFlowVersionDialogData>(
+ ImportNewDropletVersionDialogComponent,
+ {
+ ...MEDIUM_DIALOG,
+ autoFocus: false,
+ data: {
+ droplet: request.droplet
+ }
+ }
+ );
+ })
+ ),
+ { dispatch: false }
+ );
+
+ openExportDropletVersionDialog$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(DropletsActions.openExportDropletVersionDialog),
+ map((action) => action.request),
+ tap((request) => {
+ this.dialog.open<ExportDropletVersionDialogComponent, ExportFlowVersionDialogData>(
+ ExportDropletVersionDialogComponent,
+ {
+ ...MEDIUM_DIALOG,
+ autoFocus: false,
+ data: {
+ droplet: request.droplet
+ }
+ }
+ );
+ })
+ ),
+ { dispatch: false }
+ );
+
+ exportDropletVersion$ = createEffect(() => {
+ return this.actions$.pipe(
+ ofType(DropletsActions.exportDropletVersion),
+ map((action) => action.request),
+ switchMap((request) =>
+ from(
+ this.dropletsService.exportDropletVersionedSnapshot(request.droplet.link.href, request.version)
+ ).pipe(
+ tap((res) => {
+ const stringSnapshot = encodeURIComponent(res.body);
+ const filename = res.headers.get('Filename');
+
+ const anchorElement = document.createElement('a');
+ anchorElement.href = 'data:application/json;charset=utf-8,' + stringSnapshot;
+ anchorElement.download = filename;
+ anchorElement.setAttribute('style', 'display: none;');
+
+ document.body.appendChild(anchorElement);
+ anchorElement.click();
+ document.body.removeChild(anchorElement);
+
+ return res;
+ }),
+ map((res) => DropletsActions.exportDropletVersionSuccess({ response: res })),
+ catchError((errorResponse: HttpErrorResponse) => {
+ return of(this.bannerError(errorResponse, ErrorContextKey.EXPORT_DROPLET_VERSION));
+ })
+ )
+ )
+ );
+ });
+
+ exportDropletVersionSuccess$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(DropletsActions.exportDropletVersionSuccess),
+ tap(() => this.dialog.closeAll())
+ ),
+ { dispatch: false }
+ );
+
+ openDropletVersionsDialog$ = createEffect(() =>
+ this.actions$.pipe(
+ ofType(DropletsActions.openDropletVersionsDialog),
+ map((action) => action.request),
+ switchMap((request) =>
+ from(this.dropletsService.getDropletSnapshotMetadata(request.droplet.link.href)).pipe(
+ map((res) => {
+ this.dialog.open(DropletVersionsDialogComponent, {
+ ...XL_DIALOG,
+ autoFocus: false,
+ data: {
+ droplet: request.droplet,
+ versions: res
+ }
+ });
+ return DropletsActions.noOp();
+ }),
+ catchError((errorResponse: HttpErrorResponse) => of(this.bannerError(errorResponse)))
+ )
+ )
+ )
+ );
+
+ 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),
+ map((action) => action.errorContext),
+ switchMap((errorContext) => of(ErrorActions.addBannerError({ errorContext })))
+ )
+ );
+
+ private bannerError(errorResponse: HttpErrorResponse, context: ErrorContextKey = ErrorContextKey.GLOBAL) {
+ return DropletsActions.dropletsBannerError({
+ errorContext: {
+ errors: [this.errorHelper.getErrorString(errorResponse)],
+ context
+ }
+ });
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.reducer.ts
new file mode 100644
index 0000000000..aa0408b040
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.reducer.ts
@@ -0,0 +1,114 @@
+/*
+ * 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 { createReducer, on } from '@ngrx/store';
+import {
+ deleteDroplet,
+ deleteDropletSuccess,
+ importNewDropletVersion,
+ importNewDropletVersionSuccess,
+ createNewDropletSuccess,
+ loadDroplets,
+ loadDropletsSuccess,
+ dropletsBannerError,
+ importVersionForNewDroplet,
+ importNewDropletVersionError
+} from './droplets.actions';
+import { Droplet, DropletsState } from '.';
+import { produce } from 'immer';
+
+export const initialState: DropletsState = {
+ droplets: [],
+ status: 'pending',
+ saving: false
+};
+
+export const dropletsReducer = createReducer(
+ initialState,
+ on(loadDroplets, (state) => ({
+ ...state,
+ status: 'loading' as const
+ })),
+ on(loadDropletsSuccess, (state, { response }) => ({
+ ...state,
+ droplets: response.droplets,
+ status: 'success' as const
+ })),
+ on(deleteDroplet, (state) => ({
+ ...state,
+ saving: true
+ })),
+ on(deleteDropletSuccess, (state, { response }) => {
+ return produce(state, (draftState) => {
+ const componentIndex: number = draftState.droplets.findIndex(
+ (f: Droplet) => response.identifier === f.identifier
+ );
+ if (componentIndex > -1) {
+ draftState.droplets.splice(componentIndex, 1);
+ }
+ draftState.saving = false;
+ });
+ }),
+ on(importNewDropletVersion, (state) => ({
+ ...state,
+ saving: true
+ })),
+ on(createNewDropletSuccess, (state, { response }) => {
+ return produce(state, (draftState) => {
+ const componentIndex: number = draftState.droplets.findIndex(
+ (f: Droplet) => response.identifier === f.identifier
+ );
+ if (componentIndex === -1) {
+ draftState.droplets.push(response);
+ }
+ draftState.saving = false;
+ });
+ }),
+ on(importNewDropletVersionSuccess, (state, { response }) => {
+ return produce(state, (draftState) => {
+ const componentIndex: number = draftState.droplets.findIndex(
+ (f: Droplet) => response.flow.identifier === f.identifier
+ );
+ if (componentIndex === -1) {
+ draftState.droplets.push(response.flow);
+ } else {
+ draftState.droplets[componentIndex].versionCount++;
+ }
+ draftState.saving = false;
+ });
+ }),
+ on(dropletsBannerError, (state) => ({
+ ...state,
+ saving: false
+ })),
+ on(importVersionForNewDroplet, (state) => ({
+ ...state,
+ saving: true
+ })),
+ on(importNewDropletVersionError, (state, { createdDroplet }) => {
+ return produce(state, (draftState) => {
+ // Remove the created flow from state since it will be deleted
+ const componentIndex: number = draftState.droplets.findIndex(
+ (f: Droplet) => createdDroplet.identifier === f.identifier
+ );
+ if (componentIndex > -1) {
+ draftState.droplets.splice(componentIndex, 1);
+ }
+ draftState.saving = false;
+ });
+ })
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.selectors.ts
similarity index 53%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.selectors.ts
index 71ad5e3cd6..38db6cd29e 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/droplets.selectors.ts
@@ -15,17 +15,21 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+import { createFeatureSelector, createSelector } from '@ngrx/store';
+import { dropletsFeatureKey, DropletsState } from './index';
+import { selectCurrentRoute } from '@nifi/shared';
-.banner-container {
- @include mat.button-density(-1);
+import { resourcesFeatureKey, ResourcesState } from '..';
- .error-banner-message {
- word-break: break-word;
- }
+export const selectResourcesState = createFeatureSelector<ResourcesState>(resourcesFeatureKey);
+
+export const selectDropletState = createSelector(selectResourcesState, (state) => state[dropletsFeatureKey]);
+
+export const selectDroplets = createSelector(selectDropletState, (state: DropletsState) => state.droplets);
- ul {
- list-style-type: disc;
- list-style-position: inside;
+export const selectDropletIdFromRoute = createSelector(selectCurrentRoute, (route) => {
+ if (route) {
+ return route.params.id;
}
-}
+ return null;
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/index.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/index.ts
new file mode 100644
index 0000000000..7ae03247ee
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/droplets/index.ts
@@ -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.
+ */
+
+import { Bucket } from '../../state/buckets';
+import { Link, Revision, Permissions } from '../index';
+
+export const dropletsFeatureKey = 'droplets';
+
+export interface LoadDropletsResponse {
+ droplets: Droplet[];
+}
+
+export interface Droplet {
+ bucketIdentifier: string;
+ bucketName: string;
+ createdTimestamp: number;
+ description: string;
+ identifier: string;
+ link: Link;
+ modifiedTimestamp: number;
+ name: string;
+ permissions: Permissions;
+ revision: Revision;
+ type: string;
+ versionCount: number;
+}
+
+export interface DropletsState {
+ droplets: Droplet[];
+ status: 'pending' | 'loading' | 'success';
+ saving: boolean;
+}
+
+export interface DeleteDropletRequest {
+ droplet: Droplet;
+}
+
+export interface ImportDropletDialog {
+ buckets: Bucket[];
+}
+
+export interface ImportDropletRequest {
+ bucket: Bucket;
+ description: string;
+ file: File;
+ name: string;
+}
+
+export interface ImportDropletVersionRequest {
+ href: string;
+ file: File;
+ description: string;
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.actions.ts
similarity index 66%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.actions.ts
index 71ad5e3cd6..1c36c77d46 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.actions.ts
@@ -15,17 +15,11 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+import { createAction, props } from '@ngrx/store';
+import { ErrorContext, ErrorContextKey } from './index';
-.banner-container {
- @include mat.button-density(-1);
+export const addBannerError = createAction('[Error] Add Banner Error', props<{ errorContext: ErrorContext }>());
- .error-banner-message {
- word-break: break-word;
- }
+export const clearBannerErrors = createAction('[Error] Clear Banner Errors', props<{ context: ErrorContextKey }>());
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+export const resetErrorState = createAction('[Error] Reset Error State');
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.effects.ts
similarity index 76%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.effects.ts
index 71ad5e3cd6..fa3f2f8568 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.effects.ts
@@ -15,17 +15,7 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+import { Injectable } from '@angular/core';
-.banner-container {
- @include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+@Injectable()
+export class ErrorEffects {}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.reducer.ts
new file mode 100644
index 0000000000..c8f3f4c070
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.reducer.ts
@@ -0,0 +1,51 @@
+/*
+ * 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 { createReducer, on } from '@ngrx/store';
+import { ErrorState } from './index';
+import { resetErrorState, addBannerError, clearBannerErrors } from './error.actions';
+import { produce } from 'immer';
+
+export const initialState: ErrorState = {
+ bannerErrors: {}
+};
+
+export const errorReducer = createReducer(
+ initialState,
+ on(addBannerError, (state, { errorContext }) => {
+ return produce(state, (draftState) => {
+ if (draftState.bannerErrors === null) {
+ draftState.bannerErrors = {};
+ }
+ if (!draftState.bannerErrors[errorContext.context]) {
+ draftState.bannerErrors[errorContext.context] = [];
+ }
+
+ draftState.bannerErrors[errorContext.context].push(...errorContext.errors);
+ });
+ }),
+ on(clearBannerErrors, (state, { context }) => {
+ return produce(state, (draftState) => {
+ if (draftState.bannerErrors && draftState.bannerErrors[context]) {
+ delete draftState.bannerErrors[context];
+ }
+ });
+ }),
+ on(resetErrorState, () => ({
+ ...initialState
+ }))
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.selectors.ts
similarity index 61%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.selectors.ts
index 71ad5e3cd6..5a2c8cf731 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/error.selectors.ts
@@ -15,17 +15,12 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+import { createFeatureSelector, createSelector } from '@ngrx/store';
+import { BannerErrors, errorFeatureKey, ErrorState } from './index';
-.banner-container {
- @include mat.button-density(-1);
+export const selectErrorState = createFeatureSelector<ErrorState>(errorFeatureKey);
- .error-banner-message {
- word-break: break-word;
- }
+export const selectAllBannerErrors = createSelector(selectErrorState, (state: ErrorState) => state.bannerErrors);
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+export const selectBannerErrors = (context: string) =>
+ createSelector(selectAllBannerErrors, (bannerErrors: BannerErrors) => bannerErrors[context] || []);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
similarity index 56%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
index 71ad5e3cd6..621c666253 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/error/index.ts
@@ -15,17 +15,31 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+export const errorFeatureKey = 'error';
-.banner-container {
- @include mat.button-density(-1);
+export interface ErrorDetail {
+ title: string;
+ message: string;
+}
+
+export enum ErrorContextKey {
+ EXPORT_DROPLET_VERSION = 'droplet listing',
+ DELETE_DROPLET = 'delete droplet',
+ CREATE_DROPLET = 'create droplet',
+ IMPORT_DROPLET_VERSION = 'import droplet version',
+ GLOBAL = 'global'
+}
- .error-banner-message {
- word-break: break-word;
- }
+export interface ErrorContext {
+ context: ErrorContextKey;
+ errors: string[];
+}
+
+export interface BannerErrors {
+ // key should be the ErrorContextKey of the banner error
+ [key: string]: string[];
+}
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
+export interface ErrorState {
+ bannerErrors: BannerErrors;
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts
new file mode 100644
index 0000000000..ffb913e079
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/state/index.ts
@@ -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.
+ */
+
+import { routerReducer, RouterReducerState, DEFAULT_ROUTER_FEATURENAME } from '@ngrx/router-store';
+import { Action, ActionReducerMap, combineReducers } from '@ngrx/store';
+import { dropletsFeatureKey, DropletsState } from './droplets';
+import { dropletsReducer } from './droplets/droplets.reducer';
+import { bucketsFeatureKey, BucketsState } from './buckets';
+import { bucketsReducer } from './buckets/buckets.reducer';
+import { errorReducer } from './error/error.reducer';
+import { errorFeatureKey, ErrorState } from './error';
+
+export const resourcesFeatureKey = 'resources';
+
+export interface Params {
+ rel: string;
+}
+
+export interface Link {
+ href: string;
+ params: Params;
+}
+
+export interface Revision {
+ version: number;
+}
+
+export interface Permissions {
+ canRead: boolean;
+ canWrite: boolean;
+}
+
+export interface ResourcesState {
+ [dropletsFeatureKey]: DropletsState;
+ [bucketsFeatureKey]: BucketsState;
+}
+
+export function reducers(state: ResourcesState | undefined, action: Action) {
+ return combineReducers({
+ [dropletsFeatureKey]: dropletsReducer,
+ [bucketsFeatureKey]: bucketsReducer
+ })(state, action);
+}
+
+export interface NiFiRegistryState {
+ [DEFAULT_ROUTER_FEATURENAME]: RouterReducerState;
+ [errorFeatureKey]: ErrorState;
+}
+
+export const rootReducers: ActionReducerMap<NiFiRegistryState> = {
+ [DEFAULT_ROUTER_FEATURENAME]: routerReducer,
+ [errorFeatureKey]: errorReducer
+};
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.html b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.html
new file mode 100644
index 0000000000..20e35b964c
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.html
@@ -0,0 +1,20 @@
+<!--
+ ~ 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.
+ -->
+
+<error-banner [messages]="messages$ | async" (dismiss)="dismiss()"></error-banner>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.scss
new file mode 100644
index 0000000000..3d56d22bb3
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.scss
@@ -0,0 +1,18 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.spec.ts
new file mode 100644
index 0000000000..6d73d99aa5
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.spec.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 { ContextErrorBanner } from './context-error-banner.component';
+import { errorFeatureKey } from '../../../state/error';
+import { provideMockStore } from '@ngrx/store/testing';
+import { initialState as initialErrorState } from '../../../state/error/error.reducer';
+
+describe('ContextErrorBanner', () => {
+ let component: ContextErrorBanner;
+ let fixture: ComponentFixture<ContextErrorBanner>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ContextErrorBanner],
+ providers: [
+ provideMockStore({
+ initialState: {
+ [errorFeatureKey]: initialErrorState
+ }
+ })
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ContextErrorBanner);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-error-banner/context-error-banner.component.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.ts
similarity index 92%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-error-banner/context-error-banner.component.ts
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.ts
index 5d54cfdc5e..c05221f5a6 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-error-banner/context-error-banner.component.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/common/context-error-banner/context-error-banner.component.ts
@@ -19,13 +19,13 @@
import { Component, DestroyRef, inject, Input, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
-import { NiFiState } from '../../../state';
+import { NiFiRegistryState } from '../../../state';
import { Store } from '@ngrx/store';
import { clearBannerErrors } from '../../../state/error/error.actions';
import { Observable } from 'rxjs';
import { selectBannerErrors } from '../../../state/error/error.selectors';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { ErrorBanner } from '../error-banner/error-banner.component';
+import { ErrorBanner } from '@nifi/shared';
import { ErrorContextKey } from '../../../state/error';
@Component({
@@ -48,7 +48,7 @@ export class ContextErrorBanner implements OnDestroy {
messages$: Observable<string[]> | null = null;
private destroyRef: DestroyRef = inject(DestroyRef);
- constructor(private store: Store<NiFiState>) {}
+ constructor(private store: Store<NiFiRegistryState>) {}
ngOnDestroy(): void {
this.store.dispatch(clearBannerErrors({ context: this.context }));
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/_header.component-theme.scss
similarity index 57%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/_header.component-theme.scss
index 71ad5e3cd6..a8dd6f0751 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/_header.component-theme.scss
@@ -15,17 +15,33 @@
* limitations under the License.
*/
+@use 'sass:map';
@use '@angular/material' as mat;
-.banner-container {
- @include mat.button-density(-1);
+@mixin generate-theme() {
+ .nifi-registry-navigation {
+ background-color: var(--mat-sys-secondary);
- .error-banner-message {
- word-break: break-word;
- }
+ .global-menu-icon.global-menu {
+ @extend .secondary-contrast;
+ }
+
+ .global-menu-icon.global-menu:disabled {
+ @extend .secondary-contrast;
+ }
+
+ .current-user {
+ @extend .secondary-contrast;
+ font-family: var(--mat-sys-body-medium-font);
+ }
+
+ a {
+ @extend .secondary-contrast;
+ text-decoration-color: var(--mat-sys-on-secondary);
+ }
- ul {
- list-style-type: disc;
- list-style-position: inside;
+ a:hover {
+ opacity: 0.6;
+ }
}
}
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
new file mode 100644
index 0000000000..d991de643d
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.html
@@ -0,0 +1,115 @@
+<!--
+~ 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">
+ <nav class="nifi-registry-navigation select-none">
+ <div class="flex justify-between items-center h-[64px] pl-4">
+ <div class="flex items-center">
+ <div class="ml-2 mr-2 logo-container relative rounded-md">
+ <img
+ ngSrc="assets/icons/registry-logo-web-app.svg"
+ fill
+ priority
+ alt="nifi registry logo"
+ class="pointer p-3"
+ (click)="goHome()" />
+ </div>
+ <div class="h-[64px] w-[112px] mr-6 relative">
+ <img ngSrc="assets/icons/nifi-logo.svg" fill priority alt="NiFi Logo" />
+ </div>
+ </div>
+ <div class="flex justify-between items-center gap-x-1">
+ <div class="flex flex-col justify-between items-end gap-y-1">
+ <!-- TODO: current user/anonymous user -->
+ </div>
+ <button mat-button [matMenuTriggerFor]="globalMenu" class="global-menu-icon global-menu">
+ <i class="fa fa-navicon text-[32px]"></i>
+ </button>
+ <mat-menu #globalMenu="matMenu" xPosition="before">
+ <button mat-menu-item class="global-menu-item">Buckets</button>
+ <button mat-menu-item class="global-menu-item">Users/Groups</button>
+ <button mat-menu-item class="global-menu-item">
+ <i class="fa fa-info-circle primary-color mr-2"></i>
+ About
+ </button>
+ <button mat-menu-item [matMenuTriggerFor]="theming">
+ <i class="fa mr-2"></i>
+ Appearance
+ </button>
+ <button mat-menu-item [matMenuTriggerFor]="animations">
+ <i class="fa mr-2"></i>
+ Animations
+ </button>
+ </mat-menu>
+ <mat-menu #theming="matMenu" xPosition="before">
+ <button mat-menu-item class="global-menu-item" (click)="toggleTheme(LIGHT_THEME)">
+ @if (theme === LIGHT_THEME) {
+ <i class="fa fa-check primary-color mr-2"></i>
+ }
+ @if (theme !== LIGHT_THEME) {
+ <i class="fa mr-2"></i>
+ }
+ Light
+ </button>
+ <button mat-menu-item class="global-menu-item" (click)="toggleTheme(DARK_THEME)">
+ @if (theme === DARK_THEME) {
+ <i class="fa fa-check primary-color mr-2"></i>
+ }
+ @if (theme !== DARK_THEME) {
+ <i class="fa mr-2"></i>
+ }
+ Dark
+ </button>
+ <button mat-menu-item class="global-menu-item" (click)="toggleTheme(OS_SETTING)">
+ @if (theme === OS_SETTING || theme === null) {
+ <i class="fa fa-check primary-color mr-2"></i>
+ }
+ @if (theme !== OS_SETTING && theme !== null) {
+ <i class="fa mr-2"></i>
+ }
+ Device
+ </button>
+ </mat-menu>
+ <mat-menu #animations="matMenu" xPosition="before">
+ <button mat-menu-item class="global-menu-item" (click)="toggleAnimations('false')">
+ @if (disableAnimations === 'false') {
+ <i class="fa fa-check primary-color mr-2"></i>
+ } @else {
+ <i class="fa mr-2"></i>
+ }
+ Enabled
+ </button>
+ <button mat-menu-item class="global-menu-item" (click)="toggleAnimations('true')">
+ @if (disableAnimations === 'true') {
+ <i class="fa fa-check primary-color mr-2"></i>
+ } @else {
+ <i class="fa mr-2"></i>
+ }
+ Disabled
+ </button>
+ <button mat-menu-item class="global-menu-item" (click)="toggleAnimations()">
+ @if (disableAnimations === null) {
+ <i class="fa fa-check primary-color mr-2"></i>
+ } @else {
+ <i class="fa mr-2"></i>
+ }
+ Device
+ </button>
+ </mat-menu>
+ </div>
+ </div>
+ </nav>
+</div>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.scss
similarity index 56%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.scss
index 71ad5e3cd6..5bcf14cfd2 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.scss
@@ -15,17 +15,39 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+.nifi-registry-navigation {
+ .icon {
+ font-size: 32px;
+ }
+
+ .logo-container {
+ background-color: #2c3e44; // legacy registry header background color not part of theme
+ height: 52px;
+ width: 52px;
+ }
-.banner-container {
- @include mat.button-density(-1);
+ .current-user {
+ max-width: 250px;
+ text-overflow: ellipsis;
+ line-height: normal;
+ overflow: hidden;
+ white-space: nowrap;
+ }
- .error-banner-message {
- word-break: break-word;
+ a {
+ text-transform: uppercase;
}
- ul {
- list-style-type: disc;
- list-style-position: inside;
+ .global-menu {
+ height: 64px;
+ width: 64px;
+ border-radius: 0;
+ }
+}
+
+button.global-menu-item {
+ .fa,
+ .icon {
+ text-align: start;
}
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.spec.ts
similarity index 80%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.spec.ts
index a1d0d010e5..f55e94b453 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.spec.ts
@@ -16,18 +16,18 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HeaderComponent } from './header.component';
-import { ErrorBanner } from './error-banner.component';
-
-describe('ErrorBanner', () => {
- let component: ErrorBanner;
- let fixture: ComponentFixture<ErrorBanner>;
+describe('HeaderComponent', () => {
+ let component: HeaderComponent;
+ let fixture: ComponentFixture<HeaderComponent>;
beforeEach(() => {
TestBed.configureTestingModule({
- imports: [ErrorBanner]
+ imports: [HeaderComponent]
});
- fixture = TestBed.createComponent(ErrorBanner);
+
+ fixture = TestBed.createComponent(HeaderComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
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
new file mode 100644
index 0000000000..43a41a460d
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/app/ui/header/header.component.ts
@@ -0,0 +1,73 @@
+/*
+ * 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 } from '@angular/core';
+import { CommonModule, NgOptimizedImage } from '@angular/common';
+import { Router, RouterModule } from '@angular/router';
+import { MatButton } from '@angular/material/button';
+import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
+import { DARK_THEME, LIGHT_THEME, OS_SETTING, Storage, ThemingService } from '@nifi/shared';
+
+@Component({
+ selector: 'app-header',
+ standalone: true,
+ imports: [CommonModule, NgOptimizedImage, RouterModule, MatButton, MatMenu, MatMenuItem, MatMenuTrigger],
+ templateUrl: './header.component.html',
+ styleUrl: './header.component.scss'
+})
+export class HeaderComponent {
+ theme: any | undefined;
+ darkModeOn: boolean | undefined;
+ LIGHT_THEME: string = LIGHT_THEME;
+ DARK_THEME: string = DARK_THEME;
+ OS_SETTING: string = OS_SETTING;
+ disableAnimations: string | null;
+
+ constructor(
+ private storage: Storage,
+ private themingService: ThemingService,
+ private router: Router
+ ) {
+ this.darkModeOn = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
+ this.theme = this.storage.getItem('theme');
+ this.disableAnimations = this.storage.getItem('disable-animations');
+
+ if (window.matchMedia) {
+ // Watch for changes of the preference
+ window.matchMedia('(prefers-color-scheme: dark)').addListener((e) => {
+ this.darkModeOn = e.matches;
+ this.theme = this.storage.getItem('theme');
+ });
+ }
+ }
+
+ goHome() {
+ this.router.navigateByUrl('/resources');
+ }
+
+ toggleTheme(theme: string) {
+ this.theme = theme;
+ this.storage.setItem('theme', theme);
+ this.themingService.toggleTheme(!!this.darkModeOn, theme);
+ }
+
+ toggleAnimations(disableAnimations: string = '') {
+ this.disableAnimations = disableAnimations;
+ this.storage.setItem('disable-animations', this.disableAnimations.toString());
+ window.location.reload();
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/environments/environment.development.ts
similarity index 76%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/environments/environment.development.ts
index 71ad5e3cd6..584182e1d9 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/environments/environment.development.ts
@@ -15,17 +15,6 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
-
-.banner-container {
- @include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+export const environment = {
+ production: false
+};
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/environments/environment.ts
similarity index 76%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/environments/environment.ts
index 71ad5e3cd6..29d7aa9df3 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/environments/environment.ts
@@ -15,17 +15,6 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
-
-.banner-container {
- @include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+export const environment = {
+ production: true
+};
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
new file mode 100644
index 0000000000..933cfcf13d
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/index.html
@@ -0,0 +1,29 @@
+<!--
+ ~ 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.
+ -->
+
+<!doctype html>
+<html lang="en">
+ <head>
+ <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" />
+ </head>
+ <body class="mat-app-background mat-typography text-base">
+ <nifi-registry-app-root></nifi-registry-app-root>
+ </body>
+</html>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/main.ts
similarity index 76%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/main.ts
index 71ad5e3cd6..b6eaeef9ed 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/main.ts
@@ -15,17 +15,10 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
-.banner-container {
- @include mat.button-density(-1);
+import { AppModule } from './app/app.module';
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+platformBrowserDynamic()
+ .bootstrapModule(AppModule)
+ .catch((err) => console.error(err));
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/styles.scss
similarity index 52%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/styles.scss
index 71ad5e3cd6..49961a0512 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/styles.scss
@@ -16,16 +16,32 @@
*/
@use '@angular/material' as mat;
+@use 'sass:map';
-.banner-container {
- @include mat.button-density(-1);
+@use 'libs/shared/src/assets/styles/app' as app;
+@use 'app/ui/header/header.component-theme' as header;
- .error-banner-message {
- word-break: break-word;
- }
+@use 'font-awesome';
+@use 'libs/shared/src/assets/fonts/flowfont/flowfont.css';
+@use 'libs/shared/src/assets/themes/material';
+@use 'libs/shared/src/assets/styles/listing-table' as listing-table;
+
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+// only include these once (not needed for dark mode)
+@include app.styles();
+@include listing-table.styles();
+
+html {
+ @include app.generate-material-theme();
+ @include listing-table.generate-theme();
+ @include header.generate-theme();
- ul {
- list-style-type: disc;
- list-style-position: inside;
+ .dark-theme {
+ @include app.generate-material-theme();
+ @include listing-table.generate-theme();
+ @include header.generate-theme();
}
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/test-setup.ts
similarity index 76%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/src/test-setup.ts
index 71ad5e3cd6..d0be6ebc08 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/src/test-setup.ts
@@ -15,17 +15,6 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
-.banner-container {
- @include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+setupZoneTestEnv();
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/apps/nifi-registry/tailwind.config.js
similarity index 61%
copy from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
copy to nifi-frontend/src/main/frontend/apps/nifi-registry/tailwind.config.js
index 71ad5e3cd6..52c0148d7e 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/tailwind.config.js
@@ -6,7 +6,7 @@
* (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
+ * 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,
@@ -15,17 +15,15 @@
* limitations under the License.
*/
-@use '@angular/material' as mat;
+const { createGlobPatternsForDependencies } = require('@nx/angular/tailwind');
+const { join } = require('path');
+const baseTheme = require('../../libs/shared/tailwind.theme.json');
-.banner-container {
- @include mat.button-density(-1);
-
- .error-banner-message {
- word-break: break-word;
- }
-
- ul {
- list-style-type: disc;
- list-style-position: inside;
- }
-}
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+ content: [join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'), ...createGlobPatternsForDependencies(__dirname)],
+ theme: {
+ ...baseTheme
+ },
+ plugins: []
+};
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/tsconfig.app.json b/nifi-frontend/src/main/frontend/apps/nifi-registry/tsconfig.app.json
new file mode 100644
index 0000000000..994d3618ce
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/tsconfig.app.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../out-tsc/app",
+ "types": []
+ },
+ "files": ["src/main.ts"],
+ "include": ["src/**/*.d.ts"]
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/tsconfig.json b/nifi-frontend/src/main/frontend/apps/nifi-registry/tsconfig.json
new file mode 100644
index 0000000000..29abb3698f
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "outDir": "../../dist/out-tsc",
+ "forceConsistentCasingInFileNames": true,
+ "strict": true,
+ "noImplicitOverride": true,
+ "noPropertyAccessFromIndexSignature": true,
+ "noImplicitReturns": true,
+ "noFallthroughCasesInSwitch": true,
+ "useDefineForClassFields": false
+ },
+ "extends": "../../tsconfig.base.json",
+ "angularCompilerOptions": {
+ "enableI18nLegacyMessageIdFormat": false,
+ "strictInjectionParameters": true,
+ "strictInputAccessModifiers": true,
+ "strictTemplates": true
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi-registry/tsconfig.spec.json b/nifi-frontend/src/main/frontend/apps/nifi-registry/tsconfig.spec.json
new file mode 100644
index 0000000000..ad652198e3
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi-registry/tsconfig.spec.json
@@ -0,0 +1,11 @@
+/* To learn more about this file see: https://angular.io/config/tsconfig. */
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../out-tsc/spec",
+ "types": ["jest"],
+ "module": "ES2022",
+ "esModuleInterop": true
+ },
+ "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
+}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts
index da7e4f503c..4adf2ed4de 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts
@@ -59,11 +59,21 @@ import { CLIPBOARD_OPTIONS, provideMarkdown } from 'ngx-markdown';
import { CopyEffects } from './state/copy/copy.effects';
const entry = localStorage.getItem('disable-animations');
-let disableAnimations: string = entry !== null ? JSON.parse(entry).item : '';
+let disableAnimations: string = '';
+
+try {
+ disableAnimations = entry !== null ? JSON.parse(entry).item : '';
+} catch (error) {
+ /* empty */
+}
// honor OS settings if user has not explicitly disabled animations for the application
-if (disableAnimations !== 'true' && disableAnimations !== 'false') {
- disableAnimations = window.matchMedia('(prefers-reduced-motion: reduce)').matches.toString();
+try {
+ if (disableAnimations !== 'true' && disableAnimations !== 'false') {
+ disableAnimations = window.matchMedia('(prefers-reduced-motion: reduce)').matches.toString();
+ }
+} catch (error) {
+ /* empty */
}
export const customTooltipDefaults: MatTooltipDefaultOptions = {
@@ -104,7 +114,9 @@ export const customTooltipDefaults: MatTooltipDefaultOptions = {
StoreDevtoolsModule.instrument({
maxAge: 25,
logOnly: environment.production,
- autoPause: true
+ autoPause: true,
+ name: 'NiFi',
+ trace: !environment.production
}),
MatProgressSpinnerModule,
MatNativeDateModule,
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.module.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.module.ts
index 025d3757d3..7b2f406308 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.module.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.module.ts
@@ -17,18 +17,19 @@
import { NgModule } from '@angular/core';
import { ComponentAccessPolicies } from './component-access-policies.component';
-import { CommonModule } from '@angular/common';
+import { CommonModule, AsyncPipe } from '@angular/common';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { MatTableModule } from '@angular/material/table';
import { MatSortModule } from '@angular/material/sort';
import { MatInputModule } from '@angular/material/input';
+import { MatFormFieldModule } from '@angular/material/form-field';
import { ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { ComponentAccessPoliciesRoutingModule } from './component-access-policies-routing.module';
+import { RouterModule } from '@angular/router';
import { PolicyTable } from '../common/policy-table/policy-table.component';
import { MatButtonModule } from '@angular/material/button';
-import { ErrorBanner } from '../../../../ui/common/error-banner/error-banner.component';
-import { ComponentContext, NifiTooltipDirective } from '@nifi/shared';
+import { ComponentContext, NifiTooltipDirective, ErrorBanner } from '@nifi/shared';
import { ContextErrorBanner } from '../../../../ui/common/context-error-banner/context-error-banner.component';
@NgModule({
@@ -36,13 +37,16 @@ import { ContextErrorBanner } from '../../../../ui/common/context-error-banner/c
exports: [ComponentAccessPolicies],
imports: [
CommonModule,
+ AsyncPipe,
ComponentAccessPoliciesRoutingModule,
NgxSkeletonLoaderModule,
MatTableModule,
MatSortModule,
MatInputModule,
+ MatFormFieldModule,
ReactiveFormsModule,
MatSelectModule,
+ RouterModule,
NifiTooltipDirective,
PolicyTable,
MatButtonModule,
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.module.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.module.ts
index 68b97e1876..68162008e2 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.module.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.module.ts
@@ -17,18 +17,18 @@
import { NgModule } from '@angular/core';
import { GlobalAccessPolicies } from './global-access-policies.component';
-import { CommonModule } from '@angular/common';
+import { CommonModule, AsyncPipe } from '@angular/common';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { MatTableModule } from '@angular/material/table';
import { MatSortModule } from '@angular/material/sort';
import { MatInputModule } from '@angular/material/input';
+import { MatFormFieldModule } from '@angular/material/form-field';
import { ReactiveFormsModule } from '@angular/forms';
import { MatSelectModule } from '@angular/material/select';
import { GlobalAccessPoliciesRoutingModule } from './global-access-policies-routing.module';
-import { NifiTooltipDirective } from '@nifi/shared';
+import { NifiTooltipDirective, ErrorBanner } from '@nifi/shared';
import { PolicyTable } from '../common/policy-table/policy-table.component';
import { MatButtonModule } from '@angular/material/button';
-import { ErrorBanner } from '../../../../ui/common/error-banner/error-banner.component';
import { ContextErrorBanner } from '../../../../ui/common/context-error-banner/context-error-banner.component';
@NgModule({
@@ -36,11 +36,13 @@ import { ContextErrorBanner } from '../../../../ui/common/context-error-banner/c
exports: [GlobalAccessPolicies],
imports: [
CommonModule,
+ AsyncPipe,
GlobalAccessPoliciesRoutingModule,
NgxSkeletonLoaderModule,
MatTableModule,
MatSortModule,
MatInputModule,
+ MatFormFieldModule,
ReactiveFormsModule,
MatSelectModule,
NifiTooltipDirective,
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/feature/cluster.module.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/feature/cluster.module.ts
index 5e83cb1db9..49073179eb 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/feature/cluster.module.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/cluster/feature/cluster.module.ts
@@ -24,9 +24,10 @@ import { clusterFeatureKey, reducers } from '../state';
import { EffectsModule } from '@ngrx/effects';
import { ClusterListingEffects } from '../state/cluster-listing/cluster-listing.effects';
import { ClusterRoutingModule } from './cluster-routing.module';
+import { RouterModule } from '@angular/router';
import { MatTabsModule } from '@angular/material/tabs';
import { MatIconButton } from '@angular/material/button';
-import { ErrorBanner } from '../../../ui/common/error-banner/error-banner.component';
+import { ErrorBanner } from '@nifi/shared';
import { BannerText } from '../../../ui/common/banner-text/banner-text.component';
import { ContextErrorBanner } from '../../../ui/common/context-error-banner/context-error-banner.component';
@@ -41,6 +42,7 @@ import { ContextErrorBanner } from '../../../ui/common/context-error-banner/cont
EffectsModule.forFeature(ClusterListingEffects),
MatTabsModule,
MatIconButton,
+ RouterModule,
ErrorBanner,
BannerText,
ContextErrorBanner
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
index 353433539a..13ae7a098a 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts
@@ -279,7 +279,7 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
id: 'upstream-downstream',
menuItems: [
{
- condition: (selection: any) => {
+ condition: () => {
// TODO - hasUpstream
return false;
},
@@ -290,7 +290,7 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
}
},
{
- condition: (selection: any) => {
+ condition: () => {
// TODO - hasDownstream
return false;
},
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
index 91898dbddc..7c264d95c6 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
@@ -67,8 +67,6 @@ describe('FlowEffects', () => {
let propertyTableHelperService: PropertyTableHelperService;
let dialog: MatDialog;
let store: MockStore;
- let copyPasteService: CopyPasteService;
- let canvasView: CanvasView;
let verify: EventEmitter<VerifyPropertiesRequestContext>;
let editProcessor: EventEmitter<UpdateProcessorRequest>;
let startRequest: EventEmitter<StartComponentRequest>;
@@ -875,8 +873,6 @@ describe('FlowEffects', () => {
action$ = new ReplaySubject<Action>();
flowService = TestBed.inject(FlowService);
propertyTableHelperService = TestBed.inject(PropertyTableHelperService);
- copyPasteService = TestBed.inject(CopyPasteService);
- canvasView = TestBed.inject(CanvasView);
dialog = TestBed.inject(MatDialog);
store = TestBed.inject(MockStore);
verify = new EventEmitter<VerifyPropertiesRequestContext>();
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-progress-dialog/change-version-progress-dialog.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-progress-dialog/change-version-progress-dialog.ts
index 05356944ae..0da3259cce 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-progress-dialog/change-version-progress-dialog.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-progress-dialog/change-version-progress-dialog.ts
@@ -22,7 +22,7 @@ import { FlowUpdateRequestEntity } from '../../../../../state/flow';
import { Observable, of } from 'rxjs';
import { AsyncPipe } from '@angular/common';
import { MatProgressBar } from '@angular/material/progress-bar';
-import { ErrorBanner } from '../../../../../../../ui/common/error-banner/error-banner.component';
+import { ErrorBanner } from '@nifi/shared';
@Component({
selector: 'change-version-progress-dialog',
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-error-banner/context-error-banner.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-error-banner/context-error-banner.component.ts
index 5d54cfdc5e..6dd7d0f7a7 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-error-banner/context-error-banner.component.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-error-banner/context-error-banner.component.ts
@@ -25,7 +25,7 @@ import { clearBannerErrors } from '../../../state/error/error.actions';
import { Observable } from 'rxjs';
import { selectBannerErrors } from '../../../state/error/error.selectors';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
-import { ErrorBanner } from '../error-banner/error-banner.component';
+import { ErrorBanner } from '@nifi/shared';
import { ErrorContextKey } from '../../../state/error';
@Component({
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/assets/icons/registry-background-logo.svg b/nifi-frontend/src/main/frontend/libs/shared/src/assets/icons/registry-background-logo.svg
new file mode 100644
index 0000000000..bd7281516b
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/assets/icons/registry-background-logo.svg
@@ -0,0 +1,17 @@
+<!--
+ 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.
+-->
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 142.71 182.73"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:#6b8791;}.cls-3{fill:#8da4aa;}.cls-4{fill:#b0c1c6;}</style><linearGradient id="linear-gradient" x1="79.26" y1="128.13" x2="79.26" y2="78.82" gradientUnits="userSpaceOnUse"><stop offset="0.1" stop-color="#e2e6e8"/><stop offset="1" stop-color="#e2e6e8" stop-opacity="0"/></linearGradient></defs><title>Asset 5</title><g id= [...]
\ No newline at end of file
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/assets/icons/registry-favicon.png b/nifi-frontend/src/main/frontend/libs/shared/src/assets/icons/registry-favicon.png
new file mode 100644
index 0000000000..87dc8c96b1
Binary files /dev/null and b/nifi-frontend/src/main/frontend/libs/shared/src/assets/icons/registry-favicon.png differ
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/assets/icons/registry-logo-web-app.svg b/nifi-frontend/src/main/frontend/libs/shared/src/assets/icons/registry-logo-web-app.svg
new file mode 100644
index 0000000000..25cf2ae2c0
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/assets/icons/registry-logo-web-app.svg
@@ -0,0 +1,17 @@
+<!--
+ 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.
+-->
+<svg id="registryl-logo-mini" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 37.49 48"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:#acbbbf;}.cls-3{fill:#899ea3;}.cls-4{fill:#678289;}</style><linearGradient id="linear-gradient" x1="20.82" y1="33.66" x2="20.82" y2="20.7" gradientUnits="userSpaceOnUse"><stop offset="0.3" stop-color="#678289" stop-opacity="0.9"/><stop offset="1" stop-color="#678289" stop-opacity="0"/></linearGradien [...]
\ No newline at end of file
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.html b/nifi-frontend/src/main/frontend/libs/shared/src/components/error-banner/error-banner.component.html
similarity index 96%
rename from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.html
rename to nifi-frontend/src/main/frontend/libs/shared/src/components/error-banner/error-banner.component.html
index 85b96e53a7..f2a35e7df9 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.html
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/error-banner/error-banner.component.html
@@ -29,7 +29,7 @@
<div class="error-banner-message">{{ messages[0] }}</div>
} @else {
<ul>
- @for (message of messages; track $index) {
+ @for (message of messages; track message) {
<li class="error-banner-message">{{ message }}</li>
}
</ul>
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss b/nifi-frontend/src/main/frontend/libs/shared/src/components/error-banner/error-banner.component.scss
similarity index 100%
rename from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.scss
rename to nifi-frontend/src/main/frontend/libs/shared/src/components/error-banner/error-banner.component.scss
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/error-banner/error-banner.component.spec.ts
similarity index 100%
rename from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.spec.ts
rename to nifi-frontend/src/main/frontend/libs/shared/src/components/error-banner/error-banner.component.spec.ts
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/error-banner/error-banner.component.ts
similarity index 95%
rename from nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.ts
rename to nifi-frontend/src/main/frontend/libs/shared/src/components/error-banner/error-banner.component.ts
index 9681c6c647..831a907d28 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/error-banner/error-banner.component.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/error-banner/error-banner.component.ts
@@ -23,7 +23,8 @@ import { MatButtonModule } from '@angular/material/button';
selector: 'error-banner',
imports: [MatButtonModule, NgClass],
templateUrl: './error-banner.component.html',
- styleUrls: ['./error-banner.component.scss']
+ styleUrls: ['./error-banner.component.scss'],
+ standalone: true
})
export class ErrorBanner {
@Input() messages: string[] | null = null;
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts
index c3fd5c10fd..dd6735156e 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts
@@ -28,3 +28,4 @@ export * from './codemirror/themes/baseTheme';
export * from './yes-no-dialog/yes-no-dialog.component';
export * from './tooltips/property-hint-tip/property-hint-tip.component';
export * from './tooltips/text-tip/text-tip.component';
+export * from './error-banner/error-banner.component';