diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts
index a46c6c772..ff511ae27 100644
--- a/src-ui/src/app/app.module.ts
+++ b/src-ui/src/app/app.module.ts
@@ -103,6 +103,7 @@ import { DragDropModule } from '@angular/cdk/drag-drop'
import { FileDropComponent } from './components/file-drop/file-drop.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar'
@@ -250,6 +251,7 @@ function initializeApp(settings: SettingsService) {
FileDropComponent,
CustomFieldsComponent,
CustomFieldEditDialogComponent,
+ CustomFieldsDropdownComponent,
],
imports: [
BrowserModule,
diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html
index 3acf61cc4..49220d0ab 100644
--- a/src-ui/src/app/components/app-frame/app-frame.component.html
+++ b/src-ui/src/app/components/app-frame/app-frame.component.html
@@ -159,21 +159,21 @@
-
+
Document types
+ Document Types
-
+
Storage paths
+ Storage Paths
-
+
Custom Fields
diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html
new file mode 100644
index 000000000..c324efd8b
--- /dev/null
+++ b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html
@@ -0,0 +1,33 @@
+
+
+
+
diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.scss b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.scss
new file mode 100644
index 000000000..496fc8a4a
--- /dev/null
+++ b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.scss
@@ -0,0 +1,24 @@
+.custom-fields-dropdown {
+ min-width: 350px;
+
+ // correct position on mobile
+ @media (max-width: 575.98px) {
+ &.show {
+ margin-left: -175px !important;
+ }
+ }
+}
+
+::ng-deep .ng-select .ng-select-container .ng-value-container .ng-placeholder,
+::ng-deep .ng-select .ng-option,
+::ng-deep .ng-select .ng-select-container .ng-value-container .ng-value {
+ font-size: 0.875rem;
+}
+
+::ng-deep .paperless-input-select .ng-select {
+ min-height: calc(1em + 0.75rem + 5px);
+}
+
+::ng-deep .paperless-input-select .ng-select .ng-select-container .ng-value-container .ng-input {
+ top: 4px;
+}
diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.spec.ts b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.spec.ts
new file mode 100644
index 000000000..ade31217f
--- /dev/null
+++ b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.spec.ts
@@ -0,0 +1,125 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing'
+
+import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
+import {
+ HttpClientTestingModule,
+ HttpTestingController,
+} from '@angular/common/http/testing'
+import { ToastService } from 'src/app/services/toast.service'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { of } from 'rxjs'
+import {
+ PaperlessCustomField,
+ PaperlessCustomFieldDataType,
+} from 'src/app/data/paperless-custom-field'
+import { SelectComponent } from '../input/select/select.component'
+import { NgSelectModule } from '@ng-select/ng-select'
+import { FormsModule, ReactiveFormsModule } from '@angular/forms'
+import {
+ NgbModal,
+ NgbModalModule,
+ NgbModalRef,
+} from '@ng-bootstrap/ng-bootstrap'
+import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+import { By } from '@angular/platform-browser'
+
+const fields: PaperlessCustomField[] = [
+ {
+ id: 0,
+ name: 'Field 1',
+ data_type: PaperlessCustomFieldDataType.Integer,
+ },
+ {
+ id: 1,
+ name: 'Field 2',
+ data_type: PaperlessCustomFieldDataType.String,
+ },
+]
+
+describe('CustomFieldsDropdownComponent', () => {
+ let component: CustomFieldsDropdownComponent
+ let fixture: ComponentFixture
+ let customFieldService: CustomFieldsService
+ let toastService: ToastService
+ let modalService: NgbModal
+ let httpController: HttpTestingController
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ declarations: [CustomFieldsDropdownComponent, SelectComponent],
+ imports: [
+ HttpClientTestingModule,
+ NgSelectModule,
+ FormsModule,
+ ReactiveFormsModule,
+ NgbModalModule,
+ ],
+ })
+ customFieldService = TestBed.inject(CustomFieldsService)
+ httpController = TestBed.inject(HttpTestingController)
+ toastService = TestBed.inject(ToastService)
+ modalService = TestBed.inject(NgbModal)
+ jest.spyOn(customFieldService, 'listAll').mockReturnValue(
+ of({
+ all: fields.map((f) => f.id),
+ count: fields.length,
+ results: fields.concat([]),
+ })
+ )
+ fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
+ component = fixture.componentInstance
+ fixture.detectChanges()
+ })
+
+ it('should support add field', () => {
+ let addedField
+ component.added.subscribe((f) => (addedField = f))
+ component.documentId = 11
+ component.field = fields[0].id
+ component.addField()
+ expect(addedField).not.toBeUndefined()
+ })
+
+ it('should clear field on open / close, updated unused fields', () => {
+ component.field = fields[1].id
+ component.onOpenClose()
+ expect(component.field).toBeUndefined()
+
+ expect(component.unusedFields).toEqual(fields)
+ const updateSpy = jest.spyOn(
+ CustomFieldsDropdownComponent.prototype as any,
+ 'updateUnusedFields'
+ )
+ component.existingFields = [fields[1]]
+ component.onOpenClose()
+ expect(updateSpy).toHaveBeenCalled()
+ expect(component.unusedFields).toEqual([fields[0]])
+ })
+
+ it('should support creating field, show error if necessary', () => {
+ let modal: NgbModalRef
+ modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
+ const toastErrorSpy = jest.spyOn(toastService, 'showError')
+ const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
+ const getFieldsSpy = jest.spyOn(
+ CustomFieldsDropdownComponent.prototype as any,
+ 'getFields'
+ )
+
+ const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
+ createButton.triggerEventHandler('click')
+
+ expect(modal).not.toBeUndefined()
+ const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
+
+ // fail first
+ editDialog.failed.emit({ error: 'error creating field' })
+ expect(toastErrorSpy).toHaveBeenCalled()
+ expect(getFieldsSpy).not.toHaveBeenCalled()
+
+ // succeed
+ editDialog.succeeded.emit(fields[0])
+ expect(toastInfoSpy).toHaveBeenCalled()
+ expect(getFieldsSpy).toHaveBeenCalled()
+ })
+})
diff --git a/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts
new file mode 100644
index 000000000..385b9c2e6
--- /dev/null
+++ b/src-ui/src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.ts
@@ -0,0 +1,100 @@
+import {
+ Component,
+ EventEmitter,
+ Input,
+ OnDestroy,
+ Output,
+ SimpleChanges,
+} from '@angular/core'
+import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
+import { Subject, first, takeUntil } from 'rxjs'
+import { PaperlessCustomField } from 'src/app/data/paperless-custom-field'
+import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
+import { ToastService } from 'src/app/services/toast.service'
+import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
+
+@Component({
+ selector: 'pngx-custom-fields-dropdown',
+ templateUrl: './custom-fields-dropdown.component.html',
+ styleUrls: ['./custom-fields-dropdown.component.scss'],
+})
+export class CustomFieldsDropdownComponent implements OnDestroy {
+ @Input()
+ documentId: number
+
+ @Input()
+ disabled: boolean = false
+
+ @Input()
+ existingFields: PaperlessCustomField[] = []
+
+ @Output()
+ added = new EventEmitter()
+
+ private customFields: PaperlessCustomField[] = []
+ public unusedFields: PaperlessCustomField[]
+
+ public name: string
+
+ public field: number
+
+ private unsubscribeNotifier: Subject = new Subject()
+
+ get placeholderText(): string {
+ return $localize`Choose field`
+ }
+
+ constructor(
+ private customFieldsService: CustomFieldsService,
+ private modalService: NgbModal,
+ private toastService: ToastService
+ ) {
+ this.getFields()
+ }
+
+ ngOnDestroy(): void {
+ this.unsubscribeNotifier.next(this)
+ this.unsubscribeNotifier.complete()
+ }
+
+ private getFields() {
+ this.customFieldsService
+ .listAll()
+ .pipe(first(), takeUntil(this.unsubscribeNotifier))
+ .subscribe((result) => {
+ this.customFields = result.results
+ this.updateUnusedFields()
+ })
+ }
+
+ private updateUnusedFields() {
+ this.unusedFields = this.customFields.filter(
+ (f) => !this.existingFields.find((e) => e.id === f.id)
+ )
+ }
+
+ onOpenClose() {
+ this.field = undefined
+ this.updateUnusedFields()
+ }
+
+ addField() {
+ this.added.emit(this.customFields.find((f) => f.id === this.field))
+ }
+
+ createField() {
+ const modal = this.modalService.open(CustomFieldEditDialogComponent)
+ modal.componentInstance.succeeded
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe((newField) => {
+ this.toastService.showInfo($localize`Saved field "${newField.name}".`)
+ this.customFieldsService.clearCache()
+ this.getFields()
+ })
+ modal.componentInstance.failed
+ .pipe(takeUntil(this.unsubscribeNotifier))
+ .subscribe((e) => {
+ this.toastService.showError($localize`Error saving field.`, e)
+ })
+ }
+}
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.html b/src-ui/src/app/components/document-detail/document-detail.component.html
index 5b6f22309..10a06627c 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.html
+++ b/src-ui/src/app/components/document-detail/document-detail.component.html
@@ -48,6 +48,13 @@
+
+
+