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
index 3240063aa..302dbfe77 100644
--- 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
@@ -1,5 +1,5 @@
.custom-fields-dropdown {
- min-width: 380px;
+ min-width: 300px;
// correct position on mobile
@media (max-width: 575.98px) {
@@ -8,13 +8,3 @@
}
}
}
-
-::ng-deep .custom-fields-dropdown .ng-select .ng-select-container .ng-value-container .ng-placeholder,
-::ng-deep .custom-fields-dropdown .ng-select .ng-option,
-::ng-deep .custom-fields-dropdown .ng-select .ng-select-container .ng-value-container .ng-value {
- font-size: 0.875rem;
-}
-
-::ng-deep .custom-fields-dropdown .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
index 7c24578e6..121591ef1 100644
--- 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
@@ -1,5 +1,9 @@
-import { ComponentFixture, TestBed } from '@angular/core/testing'
-
+import {
+ ComponentFixture,
+ TestBed,
+ fakeAsync,
+ tick,
+} from '@angular/core/testing'
import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ToastService } from 'src/app/services/toast.service'
@@ -71,28 +75,33 @@ describe('CustomFieldsDropdownComponent', () => {
let addedField
component.added.subscribe((f) => (addedField = f))
component.documentId = 11
- component.field = fields[0].id
- component.addField()
+ component.addField({ field: fields[0].id } as any)
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 = [{ field: fields[1].id } as any]
- component.onOpenClose()
- expect(updateSpy).toHaveBeenCalled()
- expect(component.unusedFields).toEqual([fields[0]])
+ it('should support filtering fields', () => {
+ const input = fixture.debugElement.query(By.css('input'))
+ input.nativeElement.value = 'Field 1'
+ input.triggerEventHandler('input', { target: input.nativeElement })
+ fixture.detectChanges()
+ expect(component.filteredFields.length).toEqual(1)
+ expect(component.filteredFields[0].name).toEqual('Field 1')
})
- it('should support creating field, show error if necessary', () => {
+ it('should support update unused fields', () => {
+ component.existingFields = [{ field: fields[0].id } as any]
+ component['updateUnusedFields']()
+ expect(component['unusedFields'].length).toEqual(1)
+ expect(component['unusedFields'][0].name).toEqual('Field 2')
+ })
+
+ it('should support getting data type label', () => {
+ expect(component.getDataTypeLabel(CustomFieldDataType.Integer)).toEqual(
+ 'Integer'
+ )
+ })
+
+ it('should support creating field, show error if necessary, then add', fakeAsync(() => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
@@ -101,8 +110,9 @@ describe('CustomFieldsDropdownComponent', () => {
CustomFieldsDropdownComponent.prototype as any,
'getFields'
)
+ const addFieldSpy = jest.spyOn(component, 'addField')
- const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
+ const createButton = fixture.debugElement.queryAll(By.css('button'))[3]
createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
@@ -115,9 +125,11 @@ describe('CustomFieldsDropdownComponent', () => {
// succeed
editDialog.succeeded.emit(fields[0])
+ tick(100)
expect(toastInfoSpy).toHaveBeenCalled()
expect(getFieldsSpy).toHaveBeenCalled()
- })
+ expect(addFieldSpy).toHaveBeenCalled()
+ }))
it('should support creating field with name', () => {
let modal: NgbModalRef
@@ -128,4 +140,106 @@ describe('CustomFieldsDropdownComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
expect(editDialog.object.name).toEqual('Foo bar')
})
+
+ it('should support arrow keyboard navigation', fakeAsync(() => {
+ fixture.nativeElement
+ .querySelector('button')
+ .dispatchEvent(new MouseEvent('click')) // open
+ fixture.detectChanges()
+ tick(100)
+ const filterInputEl: HTMLInputElement =
+ component.listFilterTextInput.nativeElement
+ expect(document.activeElement).toEqual(filterInputEl)
+ const itemButtons = Array.from(
+ (fixture.nativeElement as HTMLDivElement).querySelectorAll(
+ '.custom-fields-dropdown button'
+ )
+ ).filter((b) => b.textContent.includes('Field'))
+ filterInputEl.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
+ )
+ expect(document.activeElement).toEqual(itemButtons[0])
+ itemButtons[0].dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
+ )
+ expect(document.activeElement).toEqual(itemButtons[1])
+ itemButtons[1].dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
+ )
+ expect(document.activeElement).toEqual(itemButtons[0])
+ itemButtons[0].dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
+ )
+ expect(document.activeElement).toEqual(filterInputEl)
+ filterInputEl.value = 'foo'
+ component.filterText = 'foo'
+
+ // dont move focus if we're traversing the field
+ filterInputEl.selectionStart = 1
+ expect(document.activeElement).toEqual(filterInputEl)
+
+ // now we're at end, so move focus
+ filterInputEl.selectionStart = 3
+ filterInputEl.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
+ )
+ expect(document.activeElement).toEqual(itemButtons[0])
+ }))
+
+ it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
+ fixture.nativeElement
+ .querySelector('button')
+ .dispatchEvent(new MouseEvent('click')) // open
+ fixture.detectChanges()
+ tick(100)
+ const filterInputEl: HTMLInputElement =
+ component.listFilterTextInput.nativeElement
+ expect(document.activeElement).toEqual(filterInputEl)
+ const itemButtons = Array.from(
+ (fixture.nativeElement as HTMLDivElement).querySelectorAll(
+ '.custom-fields-dropdown button'
+ )
+ ).filter((b) => b.textContent.includes('Field'))
+ filterInputEl.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
+ )
+ itemButtons[0]['focus']() // normally handled by browser
+ itemButtons[0].dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
+ )
+ itemButtons[1]['focus']() // normally handled by browser
+ itemButtons[1].dispatchEvent(
+ new KeyboardEvent('keydown', {
+ key: 'Tab',
+ shiftKey: true,
+ bubbles: true,
+ })
+ )
+ itemButtons[0]['focus']() // normally handled by browser
+ itemButtons[0].dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
+ )
+ expect(document.activeElement).toEqual(itemButtons[1])
+ }))
+
+ it('should support enter keyboard navigation', fakeAsync(() => {
+ jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
+ const addFieldSpy = jest.spyOn(component, 'addField')
+ const createFieldSpy = jest.spyOn(component, 'createField')
+ component.filterText = 'Field 1'
+ component.listFilterEnter()
+ expect(addFieldSpy).toHaveBeenCalled()
+
+ component.filterText = 'Field 3'
+ component.listFilterEnter()
+ expect(createFieldSpy).toHaveBeenCalledWith('Field 3')
+
+ addFieldSpy.mockClear()
+ createFieldSpy.mockClear()
+
+ component.filterText = undefined
+ component.listFilterEnter()
+ expect(createFieldSpy).not.toHaveBeenCalled()
+ expect(addFieldSpy).not.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
index 79c0d1b58..652d7f3d8 100644
--- 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
@@ -1,13 +1,17 @@
import {
Component,
+ ElementRef,
EventEmitter,
Input,
OnDestroy,
Output,
+ QueryList,
+ ViewChild,
+ ViewChildren,
} from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first, takeUntil } from 'rxjs'
-import { CustomField } from 'src/app/data/custom-field'
+import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -39,23 +43,25 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
@Output()
created: EventEmitter
= new EventEmitter()
+ @ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
+ @ViewChildren('button') buttons: QueryList
+
private customFields: CustomField[] = []
- public unusedFields: CustomField[]
+ private unusedFields: CustomField[] = []
+ private keyboardIndex: number
- public name: string
+ public get filteredFields(): CustomField[] {
+ return this.unusedFields.filter(
+ (f) =>
+ !this.filterText ||
+ f.name.toLowerCase().includes(this.filterText.toLowerCase())
+ )
+ }
- public field: number
+ public filterText: string
private unsubscribeNotifier: Subject = new Subject()
- get placeholderText(): string {
- return $localize`Choose field`
- }
-
- get notFoundText(): string {
- return $localize`No unused fields found`
- }
-
get canCreateFields(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.Add,
@@ -87,28 +93,26 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
})
}
- public getCustomFieldFromInstance(
- instance: CustomFieldInstance
- ): CustomField {
- return this.customFields.find((f) => f.id === instance.field)
- }
-
private updateUnusedFields() {
this.unusedFields = this.customFields.filter(
- (f) =>
- !this.existingFields?.find(
- (e) => this.getCustomFieldFromInstance(e)?.id === f.id
- )
+ (f) => !this.existingFields?.find((e) => e.field === f.id)
)
}
- onOpenClose() {
- this.field = undefined
+ onOpenClose(open: boolean) {
+ if (open) {
+ setTimeout(() => {
+ this.listFilterTextInput.nativeElement.focus()
+ }, 100)
+ } else {
+ this.filterText = undefined
+ }
this.updateUnusedFields()
}
- addField() {
- this.added.emit(this.customFields.find((f) => f.id === this.field))
+ addField(field: CustomField) {
+ this.added.emit(field)
+ this.updateUnusedFields()
}
createField(newName: string = null) {
@@ -121,6 +125,7 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
this.customFieldsService.clearCache()
this.getFields()
this.created.emit(newField)
+ setTimeout(() => this.addField(newField), 100)
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
@@ -128,4 +133,82 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
this.toastService.showError($localize`Error saving field.`, e)
})
}
+
+ getDataTypeLabel(dataType: string) {
+ return DATA_TYPE_LABELS.find((l) => l.id === dataType)?.name
+ }
+
+ public listFilterEnter() {
+ if (this.filteredFields.length === 1) {
+ this.addField(this.filteredFields[0])
+ } else if (
+ this.filterText &&
+ this.filteredFields.length === 0 &&
+ this.canCreateFields
+ ) {
+ this.createField(this.filterText)
+ }
+ }
+
+ private focusNextButtonItem(setFocus: boolean = true) {
+ this.keyboardIndex = Math.min(
+ this.buttons.length - 1,
+ this.keyboardIndex + 1
+ )
+ if (setFocus) this.setButtonItemFocus()
+ }
+
+ focusPreviousButtonItem(setFocus: boolean = true) {
+ this.keyboardIndex = Math.max(0, this.keyboardIndex - 1)
+ if (setFocus) this.setButtonItemFocus()
+ }
+
+ setButtonItemFocus() {
+ this.buttons.get(this.keyboardIndex)?.nativeElement.focus()
+ }
+
+ public listKeyDown(event: KeyboardEvent) {
+ switch (event.key) {
+ case 'ArrowDown':
+ if (event.target instanceof HTMLInputElement) {
+ if (
+ !this.filterText ||
+ event.target.selectionStart === this.filterText.length
+ ) {
+ this.keyboardIndex = -1
+ this.focusNextButtonItem()
+ event.preventDefault()
+ }
+ } else if (event.target instanceof HTMLButtonElement) {
+ this.focusNextButtonItem()
+ event.preventDefault()
+ }
+ break
+ case 'ArrowUp':
+ if (event.target instanceof HTMLButtonElement) {
+ if (this.keyboardIndex === 0) {
+ this.listFilterTextInput.nativeElement.focus()
+ } else {
+ this.focusPreviousButtonItem()
+ }
+ event.preventDefault()
+ }
+ break
+ case 'Tab':
+ // just track the index in case user uses arrows
+ if (event.target instanceof HTMLInputElement) {
+ this.keyboardIndex = 0
+ } else if (event.target instanceof HTMLButtonElement) {
+ if (event.shiftKey) {
+ if (this.keyboardIndex > 0) {
+ this.focusPreviousButtonItem(false)
+ }
+ } else {
+ this.focusNextButtonItem(false)
+ }
+ }
+ default:
+ break
+ }
+ }
}
diff --git a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
index b26ad9024..b439c770f 100644
--- a/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
+++ b/src-ui/src/app/components/document-detail/document-detail.component.spec.ts
@@ -945,9 +945,9 @@ describe('DocumentDetailComponent', () => {
fixture.detectChanges()
expect(component.document.custom_fields).toHaveLength(initialLength - 1)
expect(component.customFieldFormFields).toHaveLength(initialLength - 1)
- expect(fixture.debugElement.nativeElement.textContent).not.toContain(
- 'Field 1'
- )
+ expect(
+ fixture.debugElement.query(By.css('form')).nativeElement.textContent
+ ).not.toContain('Field 1')
const updateSpy = jest.spyOn(documentService, 'update')
component.save(true)
expect(updateSpy.mock.lastCall[0].custom_fields).toHaveLength(