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 c2480a95bc0 NIFI-15854 - Support asset property types for connectors
(#11193)
c2480a95bc0 is described below
commit c2480a95bc08122974b1e851d84ca68405233f22
Author: Rob Fellows <[email protected]>
AuthorDate: Wed May 6 14:31:34 2026 -0400
NIFI-15854 - Support asset property types for connectors (#11193)
* NIFI-15854 - Support asset property types for connectors
* address pr feedback
* style progress bar properly
* Fix asset list controls rendering empty after Back navigation following a
failed upload by reconciling form-shape unsaved values against API-shape
AssetReferences in initializeForm.
* address review comments
---
.../libs/shared/src/assets/styles/_app.scss | 26 ++
.../asset-upload/asset-upload.component.html | 149 ++++++++
.../asset-upload/asset-upload.component.scss} | 47 ++-
.../asset-upload/asset-upload.component.spec.ts | 391 +++++++++++++++++++++
.../asset-upload/asset-upload.component.ts | 220 ++++++++++++
.../connector-property-input.component.html | 39 +-
.../connector-property-input.component.spec.ts | 232 +++++++++++-
.../connector-property-input.component.ts | 55 ++-
.../connector-configuration-step.component.spec.ts | 168 +++++++++
.../connector-configuration-step.component.ts | 45 ++-
.../frontend/libs/shared/src/components/index.ts | 1 +
.../drag-and-drop/drag-and-drop.directive.ts | 197 +++++++++++
.../frontend/libs/shared/src/directives/index.ts | 1 +
.../shared/src/services/value-reference.helper.ts | 8 +-
14 files changed, 1546 insertions(+), 33 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss
b/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss
index cd65dfe112b..5c91e1ee9d4 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/assets/styles/_app.scss
@@ -767,4 +767,30 @@
margin-right: 4px;
}
}
+
+ /* progress-bar status overrides */
+
+ .progress-bar-success {
+ @include mat.progress-bar-overrides(
+ (
+ active-indicator-color: var(--nf-success-default)
+ )
+ );
+ }
+
+ .progress-bar-caution {
+ @include mat.progress-bar-overrides(
+ (
+ active-indicator-color: var(--nf-caution-default)
+ )
+ );
+ }
+
+ .progress-bar-error {
+ @include mat.progress-bar-overrides(
+ (
+ active-indicator-color: var(--mat-sys-error)
+ )
+ );
+ }
}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.html
b/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.html
new file mode 100644
index 00000000000..1eac9fc2949
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.html
@@ -0,0 +1,149 @@
+<!--
+ ~ 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.
+ -->
+<mat-card
+ appearance="outlined"
+ class="asset-upload-container flex flex-col gap-3 p-4 min-h-[100px]
rounded-lg"
+ [class.disabled]="disabled"
+ [class.has-error]="hasError"
+ [class.has-content]="assets.length > 0 || uploadProgress.length > 0"
+ dragAndDrop
+ [allowMultipleFiles]="multiple"
+ [allowedFileTypes]="allowedFileTypes"
+ [maxFileSize]="maxFileSize"
+ (filesDropped)="onFilesDropped($event)"
+ (invalidDrop)="onInvalidDrop($event)"
+ data-qa="asset-drop-zone">
+ @if (assets.length === 0 && uploadProgress.length === 0) {
+ <div class="flex flex-col items-center justify-center gap-3">
+ <div class="flex items-center gap-2">
+ <i class="fa fa-cloud-upload" aria-hidden="true"></i>
+ <span class="tertiary-color">Drag and drop a file here</span>
+ </div>
+ <button
+ mat-stroked-button
+ type="button"
+ [disabled]="disabled"
+ (click)="openFileBrowser()"
+ data-qa="browse-files-button">
+ Browse
+ </button>
+ </div>
+ }
+
+ @if (assets.length > 0 || uploadProgress.length > 0) {
+ <div class="flex flex-col gap-4">
+ @for (asset of assets; track trackByAssetId($index, asset)) {
+ @if (asset.missingContent) {
+ <div
+ class="asset-item-missing-content flex items-center
justify-between gap-3 px-3 py-2 border rounded-md min-h-[44px]"
+ data-qa="uploaded-asset-missing-content">
+ <div class="flex items-start gap-2 min-w-0 flex-1">
+ <i
+ class="fa fa-warning caution-color shrink-0"
+ aria-hidden="true"
+ data-qa="asset-missing-warning-icon"></i>
+ <div class="text-sm caution-color min-w-0">Asset
content is missing</div>
+ </div>
+ <button
+ mat-icon-button
+ type="button"
+ class="primary-icon-button shrink-0"
+ [disabled]="disabled"
+ matTooltip="Delete"
+ aria-label="Delete asset"
+ (click)="onDeleteAsset(asset, $event)"
+ data-qa="delete-asset-button">
+ <i class="fa fa-trash" aria-hidden="true"></i>
+ </button>
+ </div>
+ } @else {
+ <div
+ class="flex items-center justify-between px-3 py-2
border rounded-md min-h-[44px]"
+ data-qa="uploaded-asset">
+ <div class="truncate" ellipsisTooltip>{{ asset.name
}}</div>
+ <button
+ mat-icon-button
+ type="button"
+ class="primary-icon-button shrink-0"
+ [disabled]="disabled"
+ matTooltip="Delete"
+ aria-label="Delete asset"
+ (click)="onDeleteAsset(asset, $event)"
+ data-qa="delete-asset-button">
+ <i class="fa fa-trash" aria-hidden="true"></i>
+ </button>
+ </div>
+ }
+ }
+
+ @for (progress of uploadProgress; track trackByFilename($index,
progress)) {
+ <div
+ class="progress-item flex items-center justify-between
gap-4 px-3 py-2 rounded-md border min-h-[44px]"
+ [class.error]="progress.status === 'error'"
+ data-qa="upload-progress">
+ <div class="truncate min-w-0">
+ <span>{{ progress.filename }}</span>
+ </div>
+ <div class="flex flex-row items-center gap-2 shrink-0
w-40">
+ <mat-progress-bar
+ mode="determinate"
+ [value]="progress.status === 'error' ? 100 :
progress.percentComplete"
+ [class.progress-bar-error]="progress.status ===
'error'">
+ </mat-progress-bar>
+ @if (progress.status === 'active') {
+ <span class="tertiary-color font-medium">{{
progress.percentComplete }}%</span>
+ }
+ @if (progress.status === 'error') {
+ <button
+ mat-icon-button
+ type="button"
+ class="primary-icon-button shrink-0"
+ [matTooltip]="progress.error || 'Upload
failed'"
+ aria-label="Dismiss failed upload"
+ (click)="onDismissFailedUpload(progress,
$event)"
+ data-qa="dismiss-failed-upload-button">
+ <i class="fa fa-times error-color"
aria-hidden="true"></i>
+ </button>
+ }
+ </div>
+ </div>
+ }
+
+ <hr />
+ <div class="flex items-center justify-center">
+ <button
+ mat-stroked-button
+ type="button"
+ [disabled]="disabled"
+ (click)="openFileBrowser()"
+ data-qa="browse-files-button">
+ Browse
+ </button>
+ </div>
+ </div>
+ }
+
+ <input
+ #fileInput
+ type="file"
+ class="hidden"
+ [accept]="acceptAttribute"
+ [multiple]="multiple"
+ [disabled]="disabled"
+ (change)="onFileInputChange($event)"
+ data-qa="file-input" />
+</mat-card>
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/directives/index.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.scss
similarity index 50%
copy from nifi-frontend/src/main/frontend/libs/shared/src/directives/index.ts
copy to
nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.scss
index 69f48c8dc47..08603c68dce 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/directives/index.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.scss
@@ -1,4 +1,4 @@
-/*
+/*!
* 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.
@@ -15,8 +15,43 @@
* limitations under the License.
*/
-export * from './nifi-tooltip.directive';
-export * from './copy/copy.directive';
-export * from './ellipsis-tooltip/ellipsis-tooltip.directive';
-export * from './spinner/nifi-spinner.directive';
-export * from './spinner/spinner.component';
+.asset-upload-container {
+ &.drop-allowed {
+ border: 1px dashed var(--mat-sys-primary);
+ * {
+ pointer-events: none;
+ }
+ }
+
+ &.drop-invalid {
+ border: 1px dashed var(--mat-sys-error);
+ * {
+ pointer-events: none;
+ }
+ }
+
+ &.disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+
+ &.has-error {
+ border: 1px solid var(--mat-sys-error);
+
+ &.drop-allowed {
+ border: 1px dashed var(--mat-sys-primary);
+ }
+
+ &.drop-invalid {
+ border: 1px dashed var(--mat-sys-error);
+ }
+ }
+}
+
+.asset-item-missing-content {
+ border-color: var(--nf-caution-default);
+}
+
+.progress-item.error {
+ border-color: var(--mat-sys-error);
+}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.spec.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.spec.ts
new file mode 100644
index 00000000000..e672282585e
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.spec.ts
@@ -0,0 +1,391 @@
+/*
+ * 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 { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { AssetUpload } from './asset-upload.component';
+import { AssetInfo, UploadProgressInfo } from '../../types';
+
+describe('AssetUpload', () => {
+ interface SetupOptions {
+ assets?: AssetInfo[];
+ uploadProgress?: UploadProgressInfo[];
+ multiple?: boolean;
+ disabled?: boolean;
+ allowedFileTypes?: string[];
+ maxFileSize?: number;
+ formControlValue?: string[] | string | null;
+ }
+
+ function createMockAsset(overrides: Partial<AssetInfo> = {}): AssetInfo {
+ return {
+ id: 'asset-123',
+ name: 'test-file.jar',
+ ...overrides
+ };
+ }
+
+ function createMockProgress(overrides: Partial<UploadProgressInfo> = {}):
UploadProgressInfo {
+ return {
+ filename: 'uploading-file.jar',
+ percentComplete: 50,
+ status: 'active',
+ ...overrides
+ };
+ }
+
+ async function setup(options: SetupOptions = {}): Promise<{
+ fixture: ComponentFixture<AssetUpload>;
+ component: AssetUpload;
+ formControl: FormControl;
+ }> {
+ await TestBed.configureTestingModule({
+ imports: [AssetUpload, ReactiveFormsModule, NoopAnimationsModule]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(AssetUpload);
+ const component = fixture.componentInstance;
+
+ component.assets = options.assets ?? [];
+ component.uploadProgress = options.uploadProgress ?? [];
+ component.multiple = options.multiple ?? false;
+ component.allowedFileTypes = options.allowedFileTypes ?? [];
+ component.maxFileSize = options.maxFileSize ?? 1024 * 1024 * 1024;
+
+ const formControl = new FormControl(options.formControlValue ?? null);
+
+ component.writeValue(options.formControlValue ?? null);
+
+ if (options.disabled) {
+ component.setDisabledState(true);
+ }
+
+ fixture.detectChanges();
+
+ return { fixture, component, formControl };
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should show drop zone when no assets are uploaded', async () => {
+ const { fixture } = await setup();
+ const dropZone =
fixture.nativeElement.querySelector('[data-qa="asset-drop-zone"]');
+ expect(dropZone).toBeTruthy();
+ });
+
+ it('should show drop zone with has-content class when single asset is
uploaded', async () => {
+ const { fixture } = await setup({
+ assets: [createMockAsset()],
+ multiple: false
+ });
+ const dropZone =
fixture.nativeElement.querySelector('[data-qa="asset-drop-zone"]');
+ expect(dropZone).toBeTruthy();
+ expect(dropZone.classList.contains('has-content')).toBe(true);
+ });
+
+ it('should show drop zone when assets are uploaded and multiple is
true', async () => {
+ const { fixture } = await setup({
+ assets: [createMockAsset()],
+ multiple: true
+ });
+ const dropZone =
fixture.nativeElement.querySelector('[data-qa="asset-drop-zone"]');
+ expect(dropZone).toBeTruthy();
+ });
+ });
+
+ describe('asset display', () => {
+ it('should display uploaded assets', async () => {
+ const { fixture } = await setup({
+ assets: [createMockAsset({ name: 'my-file.jar' })]
+ });
+
+ const assetItem =
fixture.nativeElement.querySelector('[data-qa="uploaded-asset"]');
+ expect(assetItem).toBeTruthy();
+ expect(assetItem.textContent).toContain('my-file.jar');
+ });
+
+ it('should display multiple uploaded assets', async () => {
+ const { fixture } = await setup({
+ assets: [
+ createMockAsset({ id: '1', name: 'file1.jar' }),
+ createMockAsset({ id: '2', name: 'file2.jar' })
+ ],
+ multiple: true
+ });
+
+ const assetItems =
fixture.nativeElement.querySelectorAll('[data-qa="uploaded-asset"]');
+ expect(assetItems.length).toBe(2);
+ });
+
+ it('should show delete button for each asset', async () => {
+ const { fixture } = await setup({
+ assets: [createMockAsset()]
+ });
+
+ const deleteButton =
fixture.nativeElement.querySelector('[data-qa="delete-asset-button"]');
+ expect(deleteButton).toBeTruthy();
+ });
+
+ it('should show warning UI when asset has missingContent', async () =>
{
+ const { fixture } = await setup({
+ assets: [createMockAsset({ name: 'gone.jar', missingContent:
true })]
+ });
+
+ const row =
fixture.nativeElement.querySelector('[data-qa="uploaded-asset-missing-content"]');
+ expect(row).toBeTruthy();
+ expect(row.textContent).not.toContain('gone.jar');
+
expect(fixture.nativeElement.querySelector('[data-qa="asset-missing-warning-icon"]')).toBeTruthy();
+ });
+
+ it('should not show asset id or name for missingContent row', async ()
=> {
+ const { fixture } = await setup({
+ assets: [createMockAsset({ id: 'asset-id-only', name:
'also-uuid', missingContent: true })]
+ });
+
+ const row =
fixture.nativeElement.querySelector('[data-qa="uploaded-asset-missing-content"]');
+ expect(row).toBeTruthy();
+ expect(row.textContent).not.toContain('asset-id-only');
+ expect(row.textContent).not.toContain('also-uuid');
+ });
+ });
+
+ describe('upload progress', () => {
+ it('should display upload progress', async () => {
+ const { fixture } = await setup({
+ uploadProgress: [createMockProgress({ percentComplete: 75 })]
+ });
+
+ const progressItem =
fixture.nativeElement.querySelector('[data-qa="upload-progress"]');
+ expect(progressItem).toBeTruthy();
+ expect(progressItem.textContent).toContain('75%');
+ });
+
+ it('should display error state for failed uploads', async () => {
+ const { fixture } = await setup({
+ uploadProgress: [createMockProgress({ status: 'error', error:
'Upload failed' })]
+ });
+
+ const progressItem =
fixture.nativeElement.querySelector('[data-qa="upload-progress"]');
+ expect(progressItem).toBeTruthy();
+ expect(progressItem.classList.contains('error')).toBe(true);
+ });
+ });
+
+ describe('file selection', () => {
+ it('should emit filesSelected when files are selected via input',
async () => {
+ const { component } = await setup();
+ const filesSelectedSpy = vi.spyOn(component.filesSelected, 'emit');
+
+ const mockFile = new File(['content'], 'test.jar', { type:
'application/java-archive' });
+ const mockFileList = {
+ 0: mockFile,
+ length: 1,
+ item: () => mockFile
+ } as unknown as FileList;
+
+ const mockEvent = {
+ target: { files: mockFileList, value: '' }
+ } as unknown as Event;
+
+ component.onFileInputChange(mockEvent);
+
+ expect(filesSelectedSpy).toHaveBeenCalledWith([mockFile]);
+ });
+
+ it('should emit filesSelected when files are dropped', async () => {
+ const { component } = await setup();
+ const filesSelectedSpy = vi.spyOn(component.filesSelected, 'emit');
+
+ const mockFile = new File(['content'], 'test.jar', { type:
'application/java-archive' });
+ const mockFileList = {
+ 0: mockFile,
+ length: 1,
+ item: () => mockFile,
+ [Symbol.iterator]: function* () {
+ yield mockFile;
+ }
+ } as unknown as FileList;
+
+ component.onFilesDropped(mockFileList);
+
+ expect(filesSelectedSpy).toHaveBeenCalledWith([mockFile]);
+ });
+
+ it('should only emit first file when multiple is false', async () => {
+ const { component } = await setup({ multiple: false });
+ const filesSelectedSpy = vi.spyOn(component.filesSelected, 'emit');
+
+ const mockFile1 = new File(['content1'], 'test1.jar', { type:
'application/java-archive' });
+ const mockFile2 = new File(['content2'], 'test2.jar', { type:
'application/java-archive' });
+ const mockFileList = {
+ 0: mockFile1,
+ 1: mockFile2,
+ length: 2,
+ item: (i: number) => (i === 0 ? mockFile1 : mockFile2),
+ [Symbol.iterator]: function* () {
+ yield mockFile1;
+ yield mockFile2;
+ }
+ } as unknown as FileList;
+
+ component.onFilesDropped(mockFileList);
+
+ expect(filesSelectedSpy).toHaveBeenCalledWith([mockFile1]);
+ });
+ });
+
+ describe('asset deletion', () => {
+ it('should emit deleteAsset when delete button is clicked', async ()
=> {
+ const asset = createMockAsset();
+ const { component } = await setup({ assets: [asset] });
+ const deleteAssetSpy = vi.spyOn(component.deleteAsset, 'emit');
+
+ const mockEvent = { stopPropagation: vi.fn() } as unknown as Event;
+ component.onDeleteAsset(asset, mockEvent);
+
+ expect(deleteAssetSpy).toHaveBeenCalledWith(asset);
+ });
+
+ it('should not emit deleteAsset when disabled', async () => {
+ const asset = createMockAsset();
+ const { component } = await setup({ assets: [asset], disabled:
true });
+ const deleteAssetSpy = vi.spyOn(component.deleteAsset, 'emit');
+
+ const mockEvent = { stopPropagation: vi.fn() } as unknown as Event;
+ component.onDeleteAsset(asset, mockEvent);
+
+ expect(deleteAssetSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('disabled state', () => {
+ it('should show drop zone with disabled class when disabled', async ()
=> {
+ const { fixture } = await setup({ disabled: true });
+ const dropZone =
fixture.nativeElement.querySelector('[data-qa="asset-drop-zone"]');
+ expect(dropZone).toBeTruthy();
+ expect(dropZone.classList.contains('disabled')).toBe(true);
+ });
+
+ it('should disable delete buttons when disabled', async () => {
+ const { fixture } = await setup({
+ assets: [createMockAsset()],
+ disabled: true
+ });
+
+ const deleteButton =
fixture.nativeElement.querySelector('[data-qa="delete-asset-button"]');
+ expect(deleteButton.disabled).toBe(true);
+ });
+ });
+
+ describe('ControlValueAccessor', () => {
+ it('should write value correctly for single asset', async () => {
+ const { component } = await setup();
+
+ component.writeValue('asset-123');
+
+ expect(component['_value']).toEqual(['asset-123']);
+ });
+
+ it('should write value correctly for multiple assets', async () => {
+ const { component } = await setup({ multiple: true });
+
+ component.writeValue(['asset-1', 'asset-2']);
+
+ expect(component['_value']).toEqual(['asset-1', 'asset-2']);
+ });
+
+ it('should handle null value', async () => {
+ const { component } = await setup();
+
+ component.writeValue(null);
+
+ expect(component['_value']).toEqual([]);
+ });
+
+ it('should set disabled state', async () => {
+ const { component } = await setup();
+
+ component.setDisabledState(true);
+
+ expect(component.disabled).toBe(true);
+ });
+ });
+
+ describe('validation state', () => {
+ it('should report hasError as false when there is no NgControl', async
() => {
+ const { component, fixture } = await setup();
+
+ expect(component.hasError).toBe(false);
+
+ fixture.detectChanges();
+ });
+
+ it('should report hasRequiredError as false when there is no
NgControl', async () => {
+ const { component } = await setup();
+
+ expect(component.hasRequiredError).toBe(false);
+ });
+ });
+
+ describe('UI helpers', () => {
+ it('should detect active uploads', async () => {
+ const { component } = await setup({
+ uploadProgress: [createMockProgress({ status: 'active' })]
+ });
+ expect(component.hasActiveUploads).toBe(true);
+ });
+
+ it('should not detect active uploads when complete', async () => {
+ const { component } = await setup({
+ uploadProgress: [createMockProgress({ status: 'complete' })]
+ });
+ expect(component.hasActiveUploads).toBe(false);
+ });
+
+ it('should generate accept attribute from allowed file types', async
() => {
+ const { component } = await setup({
+ allowedFileTypes: ['.jar', '.nar']
+ });
+ expect(component.acceptAttribute).toBe('.jar,.nar');
+ });
+ });
+
+ describe('trackBy functions', () => {
+ it('should track assets by ID', async () => {
+ const { component } = await setup();
+ const asset = createMockAsset({ id: 'unique-id' });
+
+ expect(component.trackByAssetId(0, asset)).toBe('unique-id');
+ });
+
+ it('should track progress by filename', async () => {
+ const { component } = await setup();
+ const progress = createMockProgress({ filename: 'unique-file.jar'
});
+
+ expect(component.trackByFilename(0,
progress)).toBe('unique-file.jar');
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.ts
new file mode 100644
index 00000000000..182c06ba1e9
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/asset-upload/asset-upload.component.ts
@@ -0,0 +1,220 @@
+/*
+ * 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 {
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ EventEmitter,
+ inject,
+ Input,
+ Output,
+ ViewChild
+} from '@angular/core';
+
+import { ControlValueAccessor, NgControl } from '@angular/forms';
+import { MatButton } from '@angular/material/button';
+import { MatCard } from '@angular/material/card';
+import { MatProgressBar } from '@angular/material/progress-bar';
+import { MatTooltip } from '@angular/material/tooltip';
+import { DragAndDropDirective } from
'../../directives/drag-and-drop/drag-and-drop.directive';
+import { EllipsisTooltipDirective } from
'../../directives/ellipsis-tooltip/ellipsis-tooltip.directive';
+import { AssetInfo, UploadProgressInfo } from '../../types';
+
+/**
+ * Reusable file-upload component for connector assets.
+ *
+ * Implements ControlValueAccessor so it can be bound to a reactive form
control. The form
+ * value is an array of asset IDs (string[]); when {@link multiple} is false
the control
+ * exposes a single-element array (or empty array).
+ *
+ * The component is intentionally label-agnostic. The parent is responsible
for:
+ * - rendering an external label / heading
+ * - performing the actual upload in response to {@link filesSelected}
+ * - supplying the current uploaded {@link assets} and in-flight {@link
uploadProgress}
+ */
+@Component({
+ selector: 'asset-upload',
+ standalone: true,
+ imports: [MatButton, MatCard, MatProgressBar, MatTooltip,
DragAndDropDirective, EllipsisTooltipDirective],
+ templateUrl: './asset-upload.component.html',
+ styleUrls: ['./asset-upload.component.scss']
+})
+export class AssetUpload implements ControlValueAccessor {
+ private cdr = inject(ChangeDetectorRef);
+
+ /** Currently uploaded assets to display. */
+ @Input() assets: AssetInfo[] = [];
+
+ /** Active upload progress states. */
+ @Input() uploadProgress: UploadProgressInfo[] = [];
+
+ /** Allow multiple files (false for ASSET, true for ASSET_LIST). */
+ @Input() multiple = false;
+
+ /** Optional file type restrictions (e.g., ['.jar', '.nar']). */
+ @Input() allowedFileTypes: string[] = [];
+
+ /** Optional max file size in bytes (default: 1GB to match the backend
cap). */
+ @Input() maxFileSize: number = 1024 * 1024 * 1024;
+
+ /** Emitted when files are selected via click-browse or drag-and-drop. */
+ @Output() filesSelected = new EventEmitter<File[]>();
+
+ /** Emitted when delete is requested for an uploaded asset. */
+ @Output() deleteAsset = new EventEmitter<AssetInfo>();
+
+ /** Emitted when the user dismisses a failed upload row. */
+ @Output() dismissFailedUpload = new EventEmitter<UploadProgressInfo>();
+
+ @ViewChild('fileInput') fileInput!: ElementRef<HTMLInputElement>;
+
+ private ngControl = inject(NgControl, { optional: true, self: true });
+
+ private _value: string[] = [];
+ disabled = false;
+ private onChange: (value: string[] | string | null) => void = () => {
+ /* noop until registerOnChange */
+ };
+ private onTouched: () => void = () => {
+ /* noop until registerOnTouched */
+ };
+
+ constructor() {
+ // Self-register as the value accessor to avoid the cyclic provider
dependency
+ // that arises if NG_VALUE_ACCESSOR is wired through a multi-provider
on this class.
+ if (this.ngControl) {
+ this.ngControl.valueAccessor = this;
+ }
+ }
+
+ //
========================================================================================
+ // ControlValueAccessor
+ //
========================================================================================
+
+ writeValue(value: string[] | string | null): void {
+ if (value === null || value === undefined) {
+ this._value = [];
+ } else if (Array.isArray(value)) {
+ this._value = value;
+ } else {
+ this._value = [value];
+ }
+ this.cdr.markForCheck();
+ }
+
+ registerOnChange(fn: (value: string[] | string | null) => void): void {
+ this.onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.onTouched = fn;
+ }
+
+ setDisabledState(isDisabled: boolean): void {
+ this.disabled = isDisabled;
+ this.cdr.markForCheck();
+ }
+
+ //
========================================================================================
+ // Validation State Accessors
+ //
========================================================================================
+
+ get hasError(): boolean {
+ return !!this.ngControl?.control?.invalid &&
!!this.ngControl?.control?.touched;
+ }
+
+ get isTouched(): boolean {
+ return !!this.ngControl?.control?.touched;
+ }
+
+ get hasRequiredError(): boolean {
+ return !!this.ngControl?.control?.hasError('required') &&
this.isTouched;
+ }
+
+ //
========================================================================================
+ // File Handling
+ //
========================================================================================
+
+ openFileBrowser(): void {
+ if (!this.disabled) {
+ this.fileInput.nativeElement.click();
+ }
+ }
+
+ onFileInputChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ if (input.files && input.files.length > 0) {
+ this.handleFiles(Array.from(input.files));
+ }
+ // Reset the input so re-selecting the same file fires another change
event.
+ input.value = '';
+ }
+
+ onFilesDropped(fileList: FileList): void {
+ this.handleFiles(Array.from(fileList));
+ }
+
+ onInvalidDrop(message: string): void {
+ // The parent component can react via the form's existing validators.
+ console.warn('Invalid file drop:', message);
+ }
+
+ private handleFiles(files: File[]): void {
+ if (this.disabled) return;
+
+ this.onTouched();
+
+ // In single-file mode, ignore extra files even if the OS file picker
permitted them.
+ const filesToEmit = this.multiple ? files : files.slice(0, 1);
+
+ this.filesSelected.emit(filesToEmit);
+ }
+
+ onDeleteAsset(asset: AssetInfo, event: Event): void {
+ event.stopPropagation();
+ if (!this.disabled) {
+ this.onTouched();
+ this.deleteAsset.emit(asset);
+ }
+ }
+
+ onDismissFailedUpload(progress: UploadProgressInfo, event: Event): void {
+ event.stopPropagation();
+ this.dismissFailedUpload.emit(progress);
+ }
+
+ //
========================================================================================
+ // UI State Helpers
+ //
========================================================================================
+
+ get hasActiveUploads(): boolean {
+ return this.uploadProgress.some((p) => p.status === 'active');
+ }
+
+ get acceptAttribute(): string {
+ return this.allowedFileTypes.join(',');
+ }
+
+ trackByAssetId(_index: number, asset: AssetInfo): string {
+ return asset.id;
+ }
+
+ trackByFilename(_index: number, progress: UploadProgressInfo): string {
+ return progress.filename;
+ }
+}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
index a7925f917da..b9583944126 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.html
@@ -26,7 +26,44 @@
</mat-checkbox>
}
@default {
- @if (shouldUseSelect()) {
+ @if (shouldUseAssetUpload()) {
+ <div class="w-full flex flex-col gap-1"
data-qa="property-input-asset-upload-block">
+ <div class="text-xs tertiary-color">
+ {{ prop.name }}
+ @if (prop.required) {
+ <span aria-hidden="true"> *</span>
+ }
+ </div>
+ <asset-upload
+ [formControl]="formControl"
+ [assets]="currentAssets()"
+ [uploadProgress]="assetUploadProgress()"
+ [multiple]="isMultipleAssets()"
+ (filesSelected)="onAssetFilesSelected($event)"
+ (deleteAsset)="onAssetDeleteRequested($event)"
+ (dismissFailedUpload)="onDismissFailedUpload($event)"
+ data-qa="property-input-asset-upload">
+ </asset-upload>
+ @if (prop.description && (!parentControl?.invalid ||
!parentControl?.touched)) {
+ <mat-hint class="text-xs">{{ prop.description
}}</mat-hint>
+ }
+ @if (parentControl?.hasError('required') &&
parentControl?.touched) {
+ <mat-error class="error-color text-xs"
data-qa="property-input-asset-required-error">
+ This field is required
+ </mat-error>
+ }
+ @if (parentControl?.hasError('assetContentMissing') &&
parentControl?.touched) {
+ <mat-error class="error-color text-xs"
data-qa="property-input-asset-missing-error">
+ Asset content is missing
+ </mat-error>
+ }
+ @if (parentControl?.hasError('verificationError')) {
+ <mat-error class="error-color text-xs"
data-qa="property-input-asset-verification-error">
+ {{ parentControl?.getError('verificationError') }}
+ </mat-error>
+ }
+ </div>
+ } @else if (shouldUseSelect()) {
<div class="w-full">
<div class="text-xs tertiary-color mb-1">
{{ prop.name }}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
index 7242b7e9969..b7bdddd9ba6 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
@@ -23,7 +23,13 @@ import { NoopAnimationsModule } from
'@angular/platform-browser/animations';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { ConnectorPropertyInput } from './connector-property-input.component';
-import { AllowableValue, ConnectorPropertyDescriptor,
PropertyAllowableValuesState } from '../../types';
+import {
+ AllowableValue,
+ AssetInfo,
+ ConnectorPropertyDescriptor,
+ PropertyAllowableValuesState,
+ UploadProgressInfo
+} from '../../types';
function makeProp(overrides: Partial<ConnectorPropertyDescriptor> = {}):
ConnectorPropertyDescriptor {
return {
@@ -42,6 +48,23 @@ function makeAllowable(value: string, displayName: string =
value): AllowableVal
};
}
+function makeAsset(overrides: Partial<AssetInfo> = {}): AssetInfo {
+ return {
+ id: 'asset-1',
+ name: 'asset-1.jar',
+ ...overrides
+ };
+}
+
+function makeProgress(overrides: Partial<UploadProgressInfo> = {}):
UploadProgressInfo {
+ return {
+ filename: 'in-flight.jar',
+ percentComplete: 30,
+ status: 'active',
+ ...overrides
+ };
+}
+
/**
* Host fixture that owns the parent FormControl and reactively passes signal
inputs
* to ConnectorPropertyInput. Use setters on the returned harness to drive
updates.
@@ -54,19 +77,41 @@ function makeAllowable(value: string, displayName: string =
value): AllowableVal
[formControl]="control"
[property]="property()"
[dynamicAllowableValuesState]="dynamicAllowableValuesState()"
- (requestAllowableValues)="onRequestAllowableValues()">
+ [currentAssets]="currentAssets()"
+ [assetUploadProgress]="assetUploadProgress()"
+ (requestAllowableValues)="onRequestAllowableValues()"
+ (assetFilesSelected)="onAssetFilesSelected($event)"
+ (assetDeleteRequested)="onAssetDeleteRequested($event)"
+
(dismissFailedUploadRequested)="onDismissFailedUploadRequested($event)">
</connector-property-input>
`
})
class HostComponent {
- control = new FormControl<string | null>(null);
+ control = new FormControl<string | string[] | null>(null);
property: WritableSignal<ConnectorPropertyDescriptor> = signal(makeProp());
dynamicAllowableValuesState: WritableSignal<PropertyAllowableValuesState |
null> = signal(null);
+ currentAssets: WritableSignal<AssetInfo[]> = signal([]);
+ assetUploadProgress: WritableSignal<UploadProgressInfo[]> = signal([]);
requestSpy = vi.fn();
+ assetFilesSelectedSpy = vi.fn();
+ assetDeleteRequestedSpy = vi.fn();
+ dismissFailedUploadRequestedSpy = vi.fn();
onRequestAllowableValues(): void {
this.requestSpy();
}
+
+ onAssetFilesSelected(files: File[]): void {
+ this.assetFilesSelectedSpy(files);
+ }
+
+ onAssetDeleteRequested(asset: AssetInfo): void {
+ this.assetDeleteRequestedSpy(asset);
+ }
+
+ onDismissFailedUploadRequested(progress: UploadProgressInfo): void {
+ this.dismissFailedUploadRequestedSpy(progress);
+ }
}
class MockResizeObserver {
@@ -82,7 +127,9 @@ async function setup(
options: {
property?: ConnectorPropertyDescriptor;
dynamicState?: PropertyAllowableValuesState | null;
- initialValue?: string | null;
+ initialValue?: string | string[] | null;
+ currentAssets?: AssetInfo[];
+ assetUploadProgress?: UploadProgressInfo[];
} = {}
) {
await TestBed.configureTestingModule({
@@ -101,10 +148,20 @@ async function setup(
if (options.initialValue !== undefined) {
host.control.setValue(options.initialValue);
}
+ if (options.currentAssets !== undefined) {
+ host.currentAssets.set(options.currentAssets);
+ }
+ if (options.assetUploadProgress !== undefined) {
+ host.assetUploadProgress.set(options.assetUploadProgress);
+ }
fixture.detectChanges();
await fixture.whenStable();
- fixture.detectChanges();
+ // Skip checkNoChanges on the post-stable CD so EllipsisTooltipDirective's
deferred
+ // overflow evaluation (which intentionally mutates MatTooltip.disabled in
a microtask)
+ // does not trigger NG0100 in dev mode. happy-dom returns
offsetWidth=0/scrollWidth=0,
+ // which causes the directive to flip MatTooltip.disabled from false to
true.
+ fixture.detectChanges(false);
const inputDebug =
fixture.debugElement.query(By.directive(ConnectorPropertyInput));
const inputComponent = inputDebug.componentInstance as
ConnectorPropertyInput;
@@ -432,6 +489,171 @@ describe('ConnectorPropertyInput', () => {
});
});
+ describe('asset rendering', () => {
+ it('renders an asset-upload (single) for an ASSET property and hides
the default text input', async () => {
+ const { fixture, inputComponent } = await setup({
+ property: makeProp({ type: 'ASSET' })
+ });
+
+ const block =
fixture.debugElement.query(By.css('[data-qa="property-input-asset-upload-block"]'));
+ const upload =
fixture.debugElement.query(By.css('[data-qa="property-input-asset-upload"]'));
+ const textInput =
fixture.debugElement.query(By.css('[data-qa="property-input-text"]'));
+ const select =
fixture.debugElement.query(By.css('[data-qa="property-input-select"]'));
+
+ expect(block).toBeTruthy();
+ expect(upload).toBeTruthy();
+
expect(upload.nativeElement.tagName.toLowerCase()).toBe('asset-upload');
+ expect(textInput).toBeNull();
+ expect(select).toBeNull();
+ expect(inputComponent.shouldUseAssetUpload()).toBe(true);
+ expect(inputComponent.isMultipleAssets()).toBe(false);
+ });
+
+ it('renders an asset-upload (multi) for an ASSET_LIST property', async
() => {
+ const { fixture, inputComponent } = await setup({
+ property: makeProp({ type: 'ASSET_LIST' })
+ });
+
+ const upload =
fixture.debugElement.query(By.css('[data-qa="property-input-asset-upload"]'));
+ const textarea =
fixture.debugElement.query(By.css('[data-qa="property-input-textarea"]'));
+
+ expect(upload).toBeTruthy();
+ expect(textarea).toBeNull();
+ expect(inputComponent.shouldUseAssetUpload()).toBe(true);
+ expect(inputComponent.isMultipleAssets()).toBe(true);
+ expect(upload.componentInstance.multiple).toBe(true);
+ });
+
+ it('renders the property name as the inline label inside the asset
block', async () => {
+ const { fixture } = await setup({
+ property: makeProp({ name: 'truststore', type: 'ASSET' })
+ });
+
+ const block: HTMLElement = fixture.debugElement.query(
+ By.css('[data-qa="property-input-asset-upload-block"]')
+ ).nativeElement;
+ expect(block.textContent).toContain('truststore');
+ });
+
+ it('passes the current assets and upload progress to the child', async
() => {
+ const asset = makeAsset({ id: 'a-1', name: 'file-a.jar' });
+ const progress = makeProgress({ filename: 'file-b.jar',
percentComplete: 60 });
+ const { fixture } = await setup({
+ property: makeProp({ type: 'ASSET' }),
+ currentAssets: [asset],
+ assetUploadProgress: [progress]
+ });
+
+ const upload =
fixture.debugElement.query(By.css('[data-qa="property-input-asset-upload"]'));
+ expect(upload.componentInstance.assets).toEqual([asset]);
+
expect(upload.componentInstance.uploadProgress).toEqual([progress]);
+ });
+
+ it('forwards filesSelected from the child up through the wrapper
output', async () => {
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'ASSET' })
+ });
+
+ const upload =
fixture.debugElement.query(By.css('[data-qa="property-input-asset-upload"]'));
+ const file = new File(['x'], 'creds.json', { type:
'application/json' });
+ upload.componentInstance.filesSelected.emit([file]);
+
+ expect(host.assetFilesSelectedSpy).toHaveBeenCalledWith([file]);
+ });
+
+ it('forwards deleteAsset from the child up through the wrapper
output', async () => {
+ const asset = makeAsset();
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'ASSET' }),
+ currentAssets: [asset]
+ });
+
+ const upload =
fixture.debugElement.query(By.css('[data-qa="property-input-asset-upload"]'));
+ upload.componentInstance.deleteAsset.emit(asset);
+
+ expect(host.assetDeleteRequestedSpy).toHaveBeenCalledWith(asset);
+ });
+
+ it('forwards dismissFailedUpload from the child up through the wrapper
output', async () => {
+ const progress = makeProgress({ status: 'error', error: 'boom' });
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'ASSET' }),
+ assetUploadProgress: [progress]
+ });
+
+ const upload =
fixture.debugElement.query(By.css('[data-qa="property-input-asset-upload"]'));
+ upload.componentInstance.dismissFailedUpload.emit(progress);
+
+
expect(host.dismissFailedUploadRequestedSpy).toHaveBeenCalledWith(progress);
+ });
+
+ it('shows a required error inside the asset block when the parent
control is required and touched', async () => {
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'ASSET', required: true })
+ });
+
+ host.control.setErrors({ required: true });
+ host.control.markAsTouched();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const requiredError = fixture.debugElement.query(
+
By.css('mat-error[data-qa="property-input-asset-required-error"]')
+ );
+ expect(requiredError).toBeTruthy();
+ expect(requiredError.nativeElement.textContent.trim()).toBe('This
field is required');
+ });
+
+ it('shows an assetContentMissing error inside the asset block when the
parent reports it', async () => {
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'ASSET' })
+ });
+
+ host.control.setErrors({ assetContentMissing: true });
+ host.control.markAsTouched();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const missingError = fixture.debugElement.query(
+
By.css('mat-error[data-qa="property-input-asset-missing-error"]')
+ );
+ expect(missingError).toBeTruthy();
+ expect(missingError.nativeElement.textContent.trim()).toBe('Asset
content is missing');
+ });
+
+ it('shows a verificationError inside the asset block when the parent
reports it', async () => {
+ const { fixture, host } = await setup({
+ property: makeProp({ type: 'ASSET' })
+ });
+
+ host.control.setErrors({ verificationError: 'Backend rejected
asset' });
+ host.control.markAsTouched();
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const verificationError = fixture.debugElement.query(
+
By.css('mat-error[data-qa="property-input-asset-verification-error"]')
+ );
+ expect(verificationError).toBeTruthy();
+
expect(verificationError.nativeElement.textContent.trim()).toBe('Backend
rejected asset');
+ });
+
+ it('does not render the asset-upload for a non-asset property
(regression guard)', async () => {
+ const { fixture } = await setup({
+ property: makeProp({ type: 'STRING' })
+ });
+
+ const upload =
fixture.debugElement.query(By.css('[data-qa="property-input-asset-upload"]'));
+ const textInput =
fixture.debugElement.query(By.css('[data-qa="property-input-text"]'));
+
+ expect(upload).toBeNull();
+ expect(textInput).toBeTruthy();
+ });
+ });
+
describe('textarea validation parity', () => {
async function setupStringList(errors: Record<string, unknown>):
Promise<{
fixture: ComponentFixture<HostComponent>;
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
index bcff63dc45b..27d7c161cda 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
@@ -32,6 +32,7 @@ import {
UploadProgressInfo
} from '../../types';
import { SearchableSelect } from
'../searchable-select/searchable-select.component';
+import { AssetUpload } from '../asset-upload/asset-upload.component';
/**
* Form control for a single connector property.
@@ -39,8 +40,9 @@ import { SearchableSelect } from
'../searchable-select/searchable-select.compone
* STRING/INTEGER/DOUBLE/FLOAT -> text input, BOOLEAN -> checkbox,
* STRING_LIST without allowable values -> textarea (comma-separated),
* allowable values (static or fetched) -> searchable-select
- * (multi-select when the property type is STRING_LIST).
- * SECRET, ASSET, and ASSET_LIST handling is deferred to follow-up PRs.
+ * (multi-select when the property type is STRING_LIST),
+ * ASSET / ASSET_LIST -> asset-upload (drop zone + uploaded list + progress).
+ * SECRET handling is deferred to a follow-up PR.
*
* Uses an internal FormControl bound to the actual input elements so that
* mat-form-field can detect error state. Validation state is synced from
@@ -58,7 +60,8 @@ import { SearchableSelect } from
'../searchable-select/searchable-select.compone
MatInput,
MatCheckbox,
MatProgressSpinner,
- SearchableSelect
+ SearchableSelect,
+ AssetUpload
],
templateUrl: './connector-property-input.component.html'
})
@@ -269,6 +272,10 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
return false;
}
+ if (this.shouldUseAssetUpload()) {
+ return false;
+ }
+
if (this.isDynamicValuesFetchFailed() ||
this.isDynamicValuesFetchEmpty()) {
return false;
}
@@ -294,19 +301,19 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
}
/**
- * Whether the property should be rendered as a plain text input.
- * Used for STRING/INTEGER/DOUBLE/FLOAT when a select is not appropriate.
- * STRING_LIST falls into the textarea branch instead.
+ * Whether the property should be rendered using the asset-upload
component.
+ * True for ASSET (single upload) and ASSET_LIST (multi-file upload).
*/
- shouldUseTextInput(): boolean {
- const prop = this.property();
- if (!prop) {
- return false;
- }
- if (prop.type === 'BOOLEAN' || prop.type === 'STRING_LIST') {
- return false;
- }
- return !this.shouldUseSelect();
+ shouldUseAssetUpload(): boolean {
+ const type = this.property()?.type;
+ return type === 'ASSET' || type === 'ASSET_LIST';
+ }
+
+ /**
+ * Whether the asset-upload should accept multiple files (ASSET_LIST only).
+ */
+ isMultipleAssets(): boolean {
+ return this.property()?.type === 'ASSET_LIST';
}
/**
@@ -364,6 +371,24 @@ export class ConnectorPropertyInput implements
ControlValueAccessor, DoCheck, On
return '';
}
+ //
========================================================================================
+ // The parent connector-configuration-step listens on the
+ // component's existing assetFilesSelected / assetDeleteRequested /
dismissFailedUploadRequested
+ // outputs and drives the upload service + wizard store from there.
+ //
========================================================================================
+
+ onAssetFilesSelected(files: File[]): void {
+ this.assetFilesSelected.emit(files);
+ }
+
+ onAssetDeleteRequested(asset: AssetInfo): void {
+ this.assetDeleteRequested.emit(asset);
+ }
+
+ onDismissFailedUpload(progress: UploadProgressInfo): void {
+ this.dismissFailedUploadRequested.emit(progress);
+ }
+
/**
* Emits requestAllowableValues exactly once per fetchable property
instance
* when the descriptor is fetchable and has no static allowable values.
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
index a9b0a6917db..47d6deed381 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
@@ -455,6 +455,174 @@ describe('SharedConnectorConfigurationStep', () => {
});
});
+ // ═══════════════════════════════════════════════════════
+ // ASSET / ASSET_LIST initialization shape reconciliation
+ // ═══════════════════════════════════════════════════════
+ //
+ // unsavedStepValues stores form-shape values (string id for ASSET,
+ // string[] for ASSET_LIST), while saved propertyValues are API-shape
+ // (AssetReference / AssetReference[]). initializeForm must reconcile
+ // form-shape unsaved values against API-shape apiValue so that:
+ // - the form control receives proper id strings,
+ // - initializeAssets receives proper AssetInfo[] for the store, and
+ // - asset names / missingContent flags survive the reconciliation
+ // when available from the API value.
+
+ describe('asset initialization shape reconciliation', () => {
+ const ASSET_ID = 'asset-1';
+ const ASSET_ID_2 = 'asset-2';
+ const ASSET_ID_3 = 'asset-3';
+
+ it('hydrates ASSET form control from API value when no unsaved value
exists', async () => {
+ const stepConfig = makeStepConfig('test-step',
[makeProp('single-asset', { type: 'ASSET' })], {
+ 'single-asset': {
+ valueType: 'ASSET_REFERENCE' as const,
+ assetReferences: [{ id: ASSET_ID, name: 'doc.pdf',
missingContent: false }]
+ }
+ });
+
+ const { component, mockStore } = await setup({ stepConfig });
+
+
expect(component.stepForm.get('single-asset')?.value).toBe(ASSET_ID);
+ expect(mockStore.initializeAssets).toHaveBeenCalledWith({
+ 'single-asset': [{ id: ASSET_ID, name: 'doc.pdf',
missingContent: false }]
+ });
+ });
+
+ it('hydrates ASSET_LIST form control from API value when no unsaved
value exists', async () => {
+ const stepConfig = makeStepConfig('test-step',
[makeProp('multi-asset', { type: 'ASSET_LIST' })], {
+ 'multi-asset': {
+ valueType: 'ASSET_REFERENCE' as const,
+ assetReferences: [
+ { id: ASSET_ID, name: 'a.pdf', missingContent: false },
+ { id: ASSET_ID_2, name: 'b.pdf', missingContent: false
},
+ { id: ASSET_ID_3, name: 'c.pdf', missingContent: false
}
+ ]
+ }
+ });
+
+ const { component, mockStore } = await setup({ stepConfig });
+
+
expect(component.stepForm.get('multi-asset')?.value).toEqual([ASSET_ID,
ASSET_ID_2, ASSET_ID_3]);
+ expect(mockStore.initializeAssets).toHaveBeenCalledWith({
+ 'multi-asset': [
+ { id: ASSET_ID, name: 'a.pdf', missingContent: false },
+ { id: ASSET_ID_2, name: 'b.pdf', missingContent: false },
+ { id: ASSET_ID_3, name: 'c.pdf', missingContent: false }
+ ]
+ });
+ });
+
+ it('reconciles a form-shape unsaved string against ASSET API value
(preserves name/missingContent)', async () => {
+ // unsavedStepValues stores the asset id as a plain string for an
ASSET property
+ const stepConfig = makeStepConfig('test-step',
[makeProp('single-asset', { type: 'ASSET' })], {
+ 'single-asset': {
+ valueType: 'ASSET_REFERENCE' as const,
+ assetReferences: [{ id: ASSET_ID, name: 'doc.pdf',
missingContent: false }]
+ }
+ });
+
+ const { component, mockStore } = await setup({
+ stepConfig,
+ unsavedValues: { 'single-asset': ASSET_ID }
+ });
+
+
expect(component.stepForm.get('single-asset')?.value).toBe(ASSET_ID);
+ // The store should receive a fully populated AssetInfo, not an
{id}-only stub
+ expect(mockStore.initializeAssets).toHaveBeenCalledWith({
+ 'single-asset': [{ id: ASSET_ID, name: 'doc.pdf',
missingContent: false }]
+ });
+ });
+
+ it('reconciles a form-shape unsaved string[] against ASSET_LIST API
value (preserves name/missingContent)', async () => {
+ // Regression guard: the failed-list-upload + Next + Back path
leaves
+ // unsavedStepValues['Test Asset List'] as ['id1','id2','id3']
(form shape).
+ // Without reconciliation, .map(a => a.id) yielded
[null,null,null] and the
+ // form/store rendered empty. With reconciliation, both should be
hydrated.
+ const stepConfig = makeStepConfig('test-step',
[makeProp('multi-asset', { type: 'ASSET_LIST' })], {
+ 'multi-asset': {
+ valueType: 'ASSET_REFERENCE' as const,
+ assetReferences: [
+ { id: ASSET_ID, name: 'a.pdf', missingContent: false },
+ { id: ASSET_ID_2, name: 'b.pdf', missingContent: false
},
+ { id: ASSET_ID_3, name: 'c.pdf', missingContent: false
}
+ ]
+ }
+ });
+
+ const { component, mockStore } = await setup({
+ stepConfig,
+ unsavedValues: { 'multi-asset': [ASSET_ID, ASSET_ID_2,
ASSET_ID_3] }
+ });
+
+
expect(component.stepForm.get('multi-asset')?.value).toEqual([ASSET_ID,
ASSET_ID_2, ASSET_ID_3]);
+ expect(mockStore.initializeAssets).toHaveBeenCalledWith({
+ 'multi-asset': [
+ { id: ASSET_ID, name: 'a.pdf', missingContent: false },
+ { id: ASSET_ID_2, name: 'b.pdf', missingContent: false },
+ { id: ASSET_ID_3, name: 'c.pdf', missingContent: false }
+ ]
+ });
+ });
+
+ it('keeps unsaved ASSET_LIST ids whose ids are not represented in API
value (id-only fallback)', async () => {
+ // If the unsaved set diverges from apiValue (e.g. user
added/removed entries
+ // before saving), unknown ids fall back to id-only
AssetReferences but still
+ // appear in the form and store.
+ const stepConfig = makeStepConfig('test-step',
[makeProp('multi-asset', { type: 'ASSET_LIST' })], {
+ 'multi-asset': {
+ valueType: 'ASSET_REFERENCE' as const,
+ assetReferences: [{ id: ASSET_ID, name: 'a.pdf',
missingContent: false }]
+ }
+ });
+
+ const { component, mockStore } = await setup({
+ stepConfig,
+ unsavedValues: { 'multi-asset': [ASSET_ID, 'unknown-id'] }
+ });
+
+
expect(component.stepForm.get('multi-asset')?.value).toEqual([ASSET_ID,
'unknown-id']);
+ expect(mockStore.initializeAssets).toHaveBeenCalledWith({
+ 'multi-asset': [
+ { id: ASSET_ID, name: 'a.pdf', missingContent: false },
+ { id: 'unknown-id', name: 'unknown-id' }
+ ]
+ });
+ });
+
+ it('falls back to API value when unsaved ASSET_LIST is empty or
absent', async () => {
+ const stepConfig = makeStepConfig('test-step',
[makeProp('multi-asset', { type: 'ASSET_LIST' })], {
+ 'multi-asset': {
+ valueType: 'ASSET_REFERENCE' as const,
+ assetReferences: [{ id: ASSET_ID, name: 'a.pdf',
missingContent: false }]
+ }
+ });
+
+ const { component } = await setup({
+ stepConfig,
+ unsavedValues: { 'multi-asset': [] }
+ });
+
+ expect(component.stepForm.get('multi-asset')?.value).toEqual([]);
+ });
+
+ it('initializes ASSET_LIST to empty array when neither unsaved nor API
value exist', async () => {
+ const stepConfig = makeStepConfig('test-step',
[makeProp('multi-asset', { type: 'ASSET_LIST' })]);
+ const { component, mockStore } = await setup({ stepConfig });
+
+ expect(component.stepForm.get('multi-asset')?.value).toEqual([]);
+ expect(mockStore.initializeAssets).toHaveBeenCalledWith({});
+ });
+
+ it('initializes ASSET to null when neither unsaved nor API value
exist', async () => {
+ const stepConfig = makeStepConfig('test-step',
[makeProp('single-asset', { type: 'ASSET' })]);
+ const { component, mockStore } = await setup({ stepConfig });
+
+ expect(component.stepForm.get('single-asset')?.value).toBeNull();
+ expect(mockStore.initializeAssets).toHaveBeenCalledWith({});
+ });
+ });
+
// ═══════════════════════════════════════════════════════
// Back navigation
// ═══════════════════════════════════════════════════════
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
index b90f5da3e79..9c473b59d7b 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
@@ -187,6 +187,7 @@ export class SharedConnectorConfigurationStep implements
SaveableStep, OnInit, O
*/
private syncFormControlsWithAssets(assetsByProperty: { [propertyName:
string]: AssetInfo[] }): void {
const config = this.stepConfiguration?.();
+
if (!config || !this.formReady) return;
for (const propertyName of Object.keys(assetsByProperty)) {
@@ -325,6 +326,7 @@ export class SharedConnectorConfigurationStep implements
SaveableStep, OnInit, O
private initializeForm(): void {
const stepData = this.stepConfiguration?.();
+
if (!stepData) {
this.stepForm = this.fb.group({});
this.formReady = true;
@@ -374,7 +376,44 @@ export class SharedConnectorConfigurationStep implements
SaveableStep, OnInit, O
// For ASSET types, collect assets for store state and set
form value to asset ID(s)
if (property.type === 'ASSET' || property.type ===
'ASSET_LIST') {
- const assetValue = (unsavedValue ?? apiValue ?? null) as
AssetReference | AssetReference[] | null;
+ // unsavedStepValues are stored in form shape (string id
for ASSET, string[] for
+ // ASSET_LIST), while apiValue is in API shape
(AssetReference / AssetReference[]).
+ // Reconcile by re-hydrating the unsaved id(s) against
apiValue so we keep names
+ // and missingContent flags when available.
+ const apiAssetReferences: AssetReference[] =
Array.isArray(apiValue)
+ ? (apiValue as AssetReference[])
+ : apiValue && typeof apiValue === 'object'
+ ? [apiValue as AssetReference]
+ : [];
+ let assetValue: AssetReference | AssetReference[] | null;
+ if (property.type === 'ASSET') {
+ if (typeof unsavedValue === 'string' && unsavedValue) {
+ assetValue =
+ apiAssetReferences.find((r) => r.id ===
unsavedValue) ??
+ ({ id: unsavedValue } as AssetReference);
+ } else if (unsavedValue &&
!Array.isArray(unsavedValue) && typeof unsavedValue === 'object') {
+ assetValue = unsavedValue as AssetReference;
+ } else {
+ assetValue = (apiValue ?? null) as AssetReference
| null;
+ }
+ } else {
+ if (Array.isArray(unsavedValue)) {
+ const ids = unsavedValue as Array<string |
AssetReference>;
+ assetValue = ids
+ .map((entry) => {
+ if (typeof entry === 'string') {
+ return (
+ apiAssetReferences.find((r) =>
r.id === entry) ??
+ ({ id: entry } as AssetReference)
+ );
+ }
+ return entry as AssetReference;
+ })
+ .filter((ref): ref is AssetReference => !!ref
&& !!ref.id);
+ } else {
+ assetValue = (apiValue ?? []) as AssetReference[];
+ }
+ }
const assets = this.buildAssetsFromValue(property.name,
property.type, assetValue);
if (assets.length > 0) {
propertyAssets[property.name] = assets;
@@ -761,7 +800,7 @@ export class SharedConnectorConfigurationStep implements
SaveableStep, OnInit, O
const formValues = this.stepForm.getRawValue();
- return {
+ const result = {
configurationStepName: stepData.configurationStepName,
configurationStepDescription:
stepData.configurationStepDescription,
dependencies: stepData.dependencies,
@@ -837,6 +876,8 @@ export class SharedConnectorConfigurationStep implements
SaveableStep, OnInit, O
// Only include groups that have changed values
.filter((group) => Object.keys(group.propertyValues).length >
0)
};
+
+ return result;
}
/**
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 3acf29a71f6..2ffa339d64a 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
@@ -47,3 +47,4 @@ export * from
'./connector-property-input/connector-property-input.component';
export * from './connector-detail-header/connector-detail-header.component';
export * from './searchable-select/searchable-select.component';
export * from './multi-select-option/multi-select-option.component';
+export * from './asset-upload/asset-upload.component';
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/directives/drag-and-drop/drag-and-drop.directive.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/directives/drag-and-drop/drag-and-drop.directive.ts
new file mode 100644
index 00000000000..b463d50518d
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/directives/drag-and-drop/drag-and-drop.directive.ts
@@ -0,0 +1,197 @@
+/*
+ * 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 { Directive, ElementRef, EventEmitter, HostBinding, HostListener,
Input, Output, inject } from '@angular/core';
+
+@Directive({
+ selector: '[dragAndDrop]',
+ standalone: true
+})
+export class DragAndDropDirective {
+ private element = inject<ElementRef<HTMLElement>>(ElementRef);
+
+ @Input() canDragAndDrop: (() => boolean) | null = null;
+ @Input() allowedFileTypes: string[] = [];
+ @Input() allowMultipleFiles = true;
+ @Input() maxFileSize: number | null = null;
+
+ @Output() filesDropped = new EventEmitter<FileList>();
+ @Output() invalidDrop = new EventEmitter<string>();
+
+ @HostBinding('class.drop-allowed') dropAllowed = false;
+ @HostBinding('class.drop-invalid') dropInvalid = false;
+
+ private dataTransferAllowedByFiles(fileList: FileList): boolean {
+ const files: File[] = Array.from(fileList);
+ if (files.length > 0) {
+ const fileCountAllowed = files.length === 1 ||
this.allowMultipleFiles;
+
+ let fileTypesAllowed = true;
+ if (this.allowedFileTypes.length > 0) {
+ fileTypesAllowed = files.every((file) => {
+ const name = file.name.toLowerCase();
+ return this.allowedFileTypes.some((fileType) =>
name.endsWith(fileType.toLowerCase()));
+ });
+ }
+
+ let fileSizesAllowed = true;
+ if (this.maxFileSize !== null) {
+ fileSizesAllowed = files.every((file) => this.maxFileSize !==
null && file.size <= this.maxFileSize);
+ }
+
+ if (!fileCountAllowed) {
+ this.invalidDrop.emit(`Invalid files selected. Only a single
file can be uploaded.`);
+ } else if (!fileTypesAllowed) {
+ this.invalidDrop.emit(
+ `Invalid file(s) selected. Allowed file types:
[${this.allowedFileTypes.join(', ')}]`
+ );
+ } else if (!fileSizesAllowed) {
+ this.invalidDrop.emit(
+ `Invalid file(s) selected. Maximum allowed file size:
[${this.maxFileSize} bytes]`
+ );
+ }
+
+ return fileCountAllowed && fileTypesAllowed && fileSizesAllowed;
+ }
+
+ return false;
+ }
+
+ /**
+ * Map of common file extensions to MIME types for dragover validation.
+ * During dragover the browser only exposes MIME types; filenames become
+ * available on drop. The allowed list is intentionally permissive so a
+ * dragover never falsely rejects files whose final extension check (in
+ * dataTransferAllowedByFiles) will be the source of truth.
+ */
+ private static readonly EXTENSION_TO_MIME: { [ext: string]: string[] } = {
+ '.jar': ['application/java-archive', 'application/x-java-archive',
'application/octet-stream'],
+ '.zip': ['application/zip', 'application/x-zip-compressed'],
+ '.json': ['application/json'],
+ '.xml': ['application/xml', 'text/xml'],
+ '.csv': ['text/csv'],
+ '.txt': ['text/plain'],
+ '.pdf': ['application/pdf'],
+ '.png': ['image/png'],
+ '.jpg': ['image/jpeg'],
+ '.jpeg': ['image/jpeg'],
+ '.gif': ['image/gif'],
+ '.svg': ['image/svg+xml']
+ };
+
+ private dataTransferAllowedByItems(dataTransferItemList:
DataTransferItemList): boolean {
+ const items: DataTransferItem[] = Array.from(dataTransferItemList);
+ if (items.length > 0) {
+ if (!this.allowMultipleFiles && items.length > 1) {
+ return false;
+ }
+
+ if (this.allowedFileTypes.length > 0) {
+ const allowedMimeTypes: string[] = [];
+ for (const ext of this.allowedFileTypes) {
+ const mimes =
DragAndDropDirective.EXTENSION_TO_MIME[ext.toLowerCase()];
+ if (mimes) {
+ allowedMimeTypes.push(...mimes);
+ }
+ }
+
+ if (allowedMimeTypes.length > 0) {
+ const allItemsAllowed = items.every((item) => {
+ // Empty type or octet-stream could be any file; allow
it during dragover
+ // and rely on the drop-time filename check to reject
mismatches.
+ if (!item.type || item.type ===
'application/octet-stream') {
+ return true;
+ }
+ return allowedMimeTypes.includes(item.type);
+ });
+
+ if (!allItemsAllowed) {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ // Angular 21 enables typeCheckHostBindings by default which infers $event
as Event
+ // rather than DragEvent (see angular/angular#40778). Cast to DragEvent
internally.
+ @HostListener('dragover', ['$event'])
+ onDragOver(event: Event) {
+ event.preventDefault();
+ event.stopPropagation();
+ const dragEvent = event as DragEvent;
+
+ if (dragEvent.dataTransfer) {
+ let canDrag = true;
+ if (this.canDragAndDrop !== null && !this.canDragAndDrop()) {
+ canDrag = false;
+ }
+
+ if (canDrag) {
+ this.dropAllowed =
this.dataTransferAllowedByItems(dragEvent.dataTransfer.items);
+ this.dropInvalid = !this.dropAllowed;
+ } else {
+ this.dropAllowed = false;
+ this.dropInvalid = true;
+ }
+ } else {
+ this.dropAllowed = false;
+ this.dropInvalid = false;
+ }
+ }
+
+ @HostListener('dragleave', ['$event'])
+ onDragLeave(event: Event) {
+ event.preventDefault();
+ event.stopPropagation();
+ // Only clear feedback when the pointer leaves the original host
element so that
+ // crossing over child elements does not reset drop-allowed state
mid-drag.
+ if (this.element.nativeElement === event.target) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.dropAllowed = false;
+ this.dropInvalid = false;
+ }
+ }
+
+ @HostListener('drop', ['$event'])
+ onDrop(event: Event) {
+ event.preventDefault();
+ event.stopPropagation();
+ const dragEvent = event as DragEvent;
+
+ let canDrag = true;
+
+ if (this.canDragAndDrop !== null && !this.canDragAndDrop()) {
+ canDrag = false;
+ }
+
+ if (canDrag) {
+ if (dragEvent.dataTransfer &&
this.dataTransferAllowedByFiles(dragEvent.dataTransfer.files)) {
+ this.filesDropped.emit(dragEvent.dataTransfer.files);
+ }
+ }
+
+ this.dropAllowed = false;
+ this.dropInvalid = false;
+ }
+}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/directives/index.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/directives/index.ts
index 69f48c8dc47..da8699d028f 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/directives/index.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/directives/index.ts
@@ -18,5 +18,6 @@
export * from './nifi-tooltip.directive';
export * from './copy/copy.directive';
export * from './ellipsis-tooltip/ellipsis-tooltip.directive';
+export * from './drag-and-drop/drag-and-drop.directive';
export * from './spinner/nifi-spinner.directive';
export * from './spinner/spinner.component';
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/services/value-reference.helper.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/services/value-reference.helper.ts
index 505154ebf0c..e0450edca68 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/services/value-reference.helper.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/services/value-reference.helper.ts
@@ -79,13 +79,13 @@ export function toValueReference(
if (propertyType === 'ASSET') {
if (value && typeof value === 'string') {
return {
- valueType: 'ASSET_REFERENCE',
+ valueType: 'ASSET_REFERENCE' as const,
assetReferences: [{ id: value }]
};
}
return {
value: null,
- valueType: 'STRING_LITERAL'
+ valueType: 'STRING_LITERAL' as const
};
}
@@ -93,13 +93,13 @@ export function toValueReference(
if (propertyType === 'ASSET_LIST') {
if (Array.isArray(value) && value.length > 0) {
return {
- valueType: 'ASSET_REFERENCE',
+ valueType: 'ASSET_REFERENCE' as const,
assetReferences: (value as string[]).map((id) => ({ id }))
};
}
return {
value: null,
- valueType: 'STRING_LITERAL'
+ valueType: 'STRING_LITERAL' as const
};
}