mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Feature: Implement custom fields for documents (#4502)
Adds custom fields of certain data types, attachable to documents and searchable Co-Authored-By: Trenton H <797416+stumpylog@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<pngx-page-header title="Custom Fields">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
||||
<svg class="sidebaricon me-1" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#plus-circle" />
|
||||
</svg>
|
||||
<ng-container i18n>Add Field</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<ul class="list-group">
|
||||
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col" i18n>Data Type</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li *ngFor="let field of fields" class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
|
||||
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#pencil" />
|
||||
</svg> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
|
||||
<svg class="buttonicon-sm" fill="currentColor">
|
||||
<use xlink:href="assets/bootstrap-icons.svg#trash" />
|
||||
</svg> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li *ngIf="fields.length === 0" class="list-group-item" i18n>No fields defined.</li>
|
||||
</ul>
|
@@ -0,0 +1,162 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { CustomFieldsComponent } from './custom-fields.component'
|
||||
import {
|
||||
PaperlessCustomField,
|
||||
PaperlessCustomFieldDataType,
|
||||
} from 'src/app/data/paperless-custom-field'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbPaginationModule,
|
||||
NgbModalModule,
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
|
||||
const fields: PaperlessCustomField[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Field 1',
|
||||
data_type: PaperlessCustomFieldDataType.String,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Field 2',
|
||||
data_type: PaperlessCustomFieldDataType.Integer,
|
||||
},
|
||||
]
|
||||
|
||||
describe('CustomFieldsComponent', () => {
|
||||
let component: CustomFieldsComponent
|
||||
let fixture: ComponentFixture<CustomFieldsComponent>
|
||||
let customFieldsService: CustomFieldsService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
CustomFieldsComponent,
|
||||
IfPermissionsDirective,
|
||||
PageHeaderComponent,
|
||||
ConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
currentUserCan: () => true,
|
||||
currentUserHasObjectPermissions: () => true,
|
||||
currentUserOwnsObject: () => true,
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgbPaginationModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbModalModule,
|
||||
],
|
||||
})
|
||||
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
count: fields.length,
|
||||
all: fields.map((o) => o.id),
|
||||
results: fields,
|
||||
})
|
||||
)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
|
||||
fixture = TestBed.createComponent(CustomFieldsComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support create, show notification on error / success', () => {
|
||||
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 reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[0]
|
||||
createButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(fields[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support edit, show notification on error / success', () => {
|
||||
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 reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const editButton = fixture.debugElement.queryAll(By.css('button'))[1]
|
||||
editButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
expect(editDialog.object).toEqual(fields[0])
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error editing item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(fields[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support delete, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const deleteSpy = jest.spyOn(customFieldsService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const deleteButton = fixture.debugElement.queryAll(By.css('button'))[3]
|
||||
deleteButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as ConfirmDialogComponent
|
||||
|
||||
// fail first
|
||||
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
|
||||
editDialog.confirmClicked.emit()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
deleteSpy.mockReturnValueOnce(of(true))
|
||||
editDialog.confirmClicked.emit()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
@@ -0,0 +1,98 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import {
|
||||
DATA_TYPE_LABELS,
|
||||
PaperlessCustomField,
|
||||
} from 'src/app/data/paperless-custom-field'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-fields',
|
||||
templateUrl: './custom-fields.component.html',
|
||||
styleUrls: ['./custom-fields.component.scss'],
|
||||
})
|
||||
export class CustomFieldsComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
public fields: PaperlessCustomField[] = []
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
constructor(
|
||||
private customFieldsService: CustomFieldsService,
|
||||
public permissionsService: PermissionsService,
|
||||
private modalService: NgbModal,
|
||||
private toastService: ToastService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((r) => {
|
||||
this.fields = r.results
|
||||
})
|
||||
}
|
||||
|
||||
editField(field: PaperlessCustomField) {
|
||||
const modal = this.modalService.open(CustomFieldEditDialogComponent)
|
||||
modal.componentInstance.dialogMode = field
|
||||
? EditDialogMode.EDIT
|
||||
: EditDialogMode.CREATE
|
||||
modal.componentInstance.object = field
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newField) => {
|
||||
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
|
||||
this.customFieldsService.clearCache()
|
||||
this.reload()
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving field.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
deleteField(field: PaperlessCustomField) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete field`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this field.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.customFieldsService.delete(field).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Deleted field`)
|
||||
this.customFieldsService.clearCache()
|
||||
this.reload()
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error deleting field.`, e)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getDataType(field: PaperlessCustomField): string {
|
||||
return DATA_TYPE_LABELS.find((l) => l.id === field.data_type).name
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user