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
         };
     }
 

Reply via email to