Feature: implement document link custom field

This commit is contained in:
shamoon 2023-12-03 23:02:50 -08:00
parent 66efaedcbb
commit 6c4b56ae98
15 changed files with 380 additions and 42 deletions

View File

@ -343,6 +343,7 @@ The following custom field types are supported:
- `Integer`: integer number e.g. 12
- `Number`: float number e.g. 12.3456
- `Monetary`: float number with exactly two decimals, e.g. 12.30
- `Document Link`: reference(s) to other document(s), displayed as links
## Share Links

View File

@ -106,6 +106,7 @@ import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar'
@ -259,6 +260,7 @@ function initializeApp(settings: SettingsService) {
CustomFieldsDropdownComponent,
ProfileEditDialogComponent,
PdfViewerComponent,
DocumentLinkComponent,
],
imports: [
BrowserModule,

View File

@ -0,0 +1,46 @@
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
<label *ngIf="title" class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
<button type="button" *ngIf="removable" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
</div>
<div [class.col-md-9]="horizontal">
<div>
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
[disabled]="disabled"
[items]="foundDocuments$ | async"
placeholder="Search for documents"
[notFoundText]="notFoundText"
[multiple]="true"
bindLabel="title"
bindValue="id"
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[typeahead]="documentsInput$"
(change)="onChange(selectedDocuments)">
<ng-template ng-label-tmp let-document="item">
<svg class="sidebaricon" fill="currentColor" xmlns="http://www.w3.org/2000/svg" (click)="unselect(document)">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary">
<svg class="sidebaricon-sm me-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#file-text"/>
</svg><span>{{document.title}}</span>
</a>
</ng-template>
<ng-template ng-loadingspinner-tmp>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-template>
</ng-select>
</div>
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
</div>
</div>
</div>

View File

@ -0,0 +1,14 @@
::ng-deep .ng-select-container .ng-value-container .ng-value {
background-color: transparent !important;
border-color: transparent;
}
.sidebaricon {
cursor: pointer;
}
.badge {
font-size: .75rem;
// --bs-primary: var(--pngx-bg-alt);
// color: var(--pngx-primary-text-contrast);
}

View File

@ -0,0 +1,98 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgSelectModule } from '@ng-select/ng-select'
import { of, throwError } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { DocumentLinkComponent } from './document-link.component'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
const documents = [
{
id: 1,
title: 'Document 1 foo',
},
{
id: 12,
title: 'Document 12 bar',
},
{
id: 23,
title: 'Document 23 bar',
},
]
describe('DocumentLinkComponent', () => {
let component: DocumentLinkComponent
let fixture: ComponentFixture<DocumentLinkComponent>
let documentService: DocumentService
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [DocumentLinkComponent],
imports: [
HttpClientTestingModule,
NgSelectModule,
FormsModule,
ReactiveFormsModule,
],
})
documentService = TestBed.inject(DocumentService)
fixture = TestBed.createComponent(DocumentLinkComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should retrieve selected documents from APIs', () => {
const getSpy = jest.spyOn(documentService, 'getCachedMany')
getSpy.mockImplementation((ids) => {
return of(documents.filter((d) => ids.includes(d.id)))
})
component.writeValue([1])
expect(getSpy).toHaveBeenCalled()
})
it('should search API on select text input', () => {
const listSpy = jest.spyOn(documentService, 'listFiltered')
listSpy.mockImplementation(
(page, pageSize, sortField, sortReverse, filterRules, extraParams) => {
const docs = documents.filter((d) =>
d.title.includes(filterRules[0].value)
)
return of({
count: docs.length,
results: docs,
all: docs.map((d) => d.id),
})
}
)
component.documentsInput$.next('bar')
expect(listSpy).toHaveBeenCalledWith(
1,
null,
'created',
true,
[{ rule_type: FILTER_TITLE, value: 'bar' }],
{ truncate_content: true }
)
listSpy.mockReturnValueOnce(throwError(() => new Error()))
component.documentsInput$.next('foo')
})
it('should support unselect', () => {
const getSpy = jest.spyOn(documentService, 'getCachedMany')
getSpy.mockImplementation((ids) => {
return of(documents.filter((d) => ids.includes(d.id)))
})
component.writeValue([12, 23])
component.unselect({ id: 23 })
fixture.detectChanges()
expect(component.selectedDocuments).toEqual([documents[1]])
})
it('should use correct compare, trackBy functions', () => {
expect(component.compareDocuments(documents[0], { id: 1 })).toBeTruthy()
expect(component.compareDocuments(documents[0], { id: 2 })).toBeFalsy()
expect(component.trackByFn(documents[1])).toEqual(12)
})
})

View File

@ -0,0 +1,115 @@
import { Component, forwardRef, OnInit, Input, OnDestroy } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import {
Subject,
Observable,
takeUntil,
concat,
of,
distinctUntilChanged,
tap,
switchMap,
map,
catchError,
} from 'rxjs'
import { FILTER_TITLE } from 'src/app/data/filter-rule-type'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { DocumentService } from 'src/app/services/rest/document.service'
import { AbstractInputComponent } from '../abstract-input'
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DocumentLinkComponent),
multi: true,
},
],
selector: 'pngx-input-document-link',
templateUrl: './document-link.component.html',
styleUrls: ['./document-link.component.scss'],
})
export class DocumentLinkComponent
extends AbstractInputComponent<any[]>
implements OnInit, OnDestroy
{
documentsInput$ = new Subject<string>()
foundDocuments$: Observable<PaperlessDocument[]>
loading = false
selectedDocuments: PaperlessDocument[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
@Input()
notFoundText: string = $localize`No documents found`
constructor(private documentsService: DocumentService) {
super()
}
ngOnInit() {
this.loadDocs()
}
writeValue(documentIDs: number[]): void {
this.loading = true
this.documentsService
.getCachedMany(documentIDs)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documents) => {
this.loading = false
this.selectedDocuments = documents
super.writeValue(documentIDs)
})
}
private loadDocs() {
this.foundDocuments$ = concat(
of([]), // default items
this.documentsInput$.pipe(
distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier),
tap(() => (this.loading = true)),
switchMap((title) =>
this.documentsService
.listFiltered(
1,
null,
'created',
true,
[{ rule_type: FILTER_TITLE, value: title }],
{ truncate_content: true }
)
.pipe(
map((results) => results.results),
catchError(() => of([])), // empty on error
tap(() => (this.loading = false))
)
)
)
)
}
unselect(document: PaperlessDocument): void {
this.selectedDocuments = this.selectedDocuments.filter(
(d) => d.id !== document.id
)
this.onChange(this.selectedDocuments.map((d) => d.id))
}
compareDocuments(
document: PaperlessDocument,
selectedDocument: PaperlessDocument
) {
return document.id === selectedDocument.id
}
trackByFn(item: PaperlessDocument) {
return item.id
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(true)
this.unsubscribeNotifier.complete()
}
}

View File

@ -1,4 +1,4 @@
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="suggestions">
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
<div class="row">
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
<label class="form-label" [class.mb-md-0]="horizontal" for="tags" i18n>{{title}}</label>

View File

@ -131,6 +131,7 @@
<pngx-input-number *ngSwitchCase="PaperlessCustomFieldDataType.Monetary" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
<pngx-input-check *ngSwitchCase="PaperlessCustomFieldDataType.Boolean" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
<pngx-input-url *ngSwitchCase="PaperlessCustomFieldDataType.Url" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
<pngx-input-document-link *ngSwitchCase="PaperlessCustomFieldDataType.DocumentLink" formControlName="value" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-document-link>
</div>
</ng-container>
</div>

View File

@ -8,6 +8,7 @@ export enum PaperlessCustomFieldDataType {
Integer = 'integer',
Float = 'float',
Monetary = 'monetary',
DocumentLink = 'documentlink',
}
export const DATA_TYPE_LABELS = [
@ -39,6 +40,10 @@ export const DATA_TYPE_LABELS = [
id: PaperlessCustomFieldDataType.Url,
name: $localize`Url`,
},
{
id: PaperlessCustomFieldDataType.DocumentLink,
name: $localize`Document Link`,
},
]
export interface PaperlessCustomField extends ObjectWithId {

View File

@ -1,23 +0,0 @@
# Generated by Django 4.2.7 on 2023-11-30 17:44
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1041_alter_consumptiontemplate_sources"),
]
operations = [
migrations.AddField(
model_name="consumptiontemplate",
name="assign_custom_fields",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="documents.customfield",
verbose_name="assign these custom fields",
),
),
]

View File

@ -0,0 +1,52 @@
# Generated by Django 4.2.7 on 2023-12-04 04:03
import django.core.validators
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1041_alter_consumptiontemplate_sources"),
]
operations = [
migrations.AddField(
model_name="consumptiontemplate",
name="assign_custom_fields",
field=models.ManyToManyField(
blank=True,
related_name="+",
to="documents.customfield",
verbose_name="assign these custom fields",
),
),
migrations.AddField(
model_name="customfieldinstance",
name="value_document_ids",
field=models.CharField(
max_length=128,
null=True,
validators=[django.core.validators.int_list_validator],
),
),
migrations.AlterField(
model_name="customfield",
name="data_type",
field=models.CharField(
choices=[
("string", "String"),
("url", "URL"),
("date", "Date"),
("boolean", "Boolean"),
("integer", "Integer"),
("float", "Float"),
("monetary", "Monetary"),
("documentlink", "Document Link"),
],
editable=False,
max_length=50,
verbose_name="data type",
),
),
]

View File

@ -15,6 +15,7 @@ from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator
from django.core.validators import int_list_validator
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@ -756,6 +757,7 @@ class CustomField(models.Model):
INT = ("integer", _("Integer"))
FLOAT = ("float", _("Float"))
MONETARY = ("monetary", _("Monetary"))
DOCUMENTLINK = ("documentlink", _("Document Link"))
created = models.DateTimeField(
_("created"),
@ -834,6 +836,12 @@ class CustomFieldInstance(models.Model):
value_monetary = models.DecimalField(null=True, decimal_places=2, max_digits=12)
value_document_ids = models.CharField(
validators=[int_list_validator],
max_length=128,
null=True,
)
class Meta:
ordering = ("created",)
verbose_name = _("custom field instance")
@ -868,6 +876,8 @@ class CustomFieldInstance(models.Model):
return self.value_float
elif self.field.data_type == CustomField.FieldDataType.MONETARY:
return self.value_monetary
elif self.field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
return self.value_document_ids
raise NotImplementedError(self.field.data_type)

View File

@ -1,4 +1,5 @@
import datetime
import json
import math
import re
import zoneinfo
@ -440,6 +441,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
CustomField.FieldDataType.INT: "value_int",
CustomField.FieldDataType.FLOAT: "value_float",
CustomField.FieldDataType.MONETARY: "value_monetary",
CustomField.FieldDataType.DOCUMENTLINK: "value_document_ids",
}
# An instance is attached to a document
document: Document = validated_data["document"]
@ -458,7 +460,11 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
return instance
def get_value(self, obj: CustomFieldInstance):
return obj.value
return (
obj.value
if (obj.field.data_type != CustomField.FieldDataType.DOCUMENTLINK)
else json.loads(obj.value)
)
def validate(self, data):
"""

View File

@ -34,7 +34,9 @@ class TestCustomField(DirectoriesMixin, APITestCase):
("date", "Invoiced Date"),
("integer", "Invoice #"),
("boolean", "Is Active"),
("float", "Total Paid"),
("float", "Average Value"),
("monetary", "Total Paid"),
("documentlink", "Related Documents"),
]:
resp = self.client.post(
self.ENDPOINT,
@ -96,6 +98,10 @@ class TestCustomField(DirectoriesMixin, APITestCase):
name="Test Custom Field Monetary",
data_type=CustomField.FieldDataType.MONETARY,
)
custom_field_documentlink = CustomField.objects.create(
name="Test Custom Field Doc Link",
data_type=CustomField.FieldDataType.DOCUMENTLINK,
)
date_value = date.today()
@ -131,6 +137,10 @@ class TestCustomField(DirectoriesMixin, APITestCase):
"field": custom_field_monetary.id,
"value": 11.10,
},
{
"field": custom_field_documentlink.id,
"value": [1, 2, 3],
},
],
},
format="json",
@ -150,11 +160,12 @@ class TestCustomField(DirectoriesMixin, APITestCase):
{"field": custom_field_url.id, "value": "https://example.com"},
{"field": custom_field_float.id, "value": 12.3456},
{"field": custom_field_monetary.id, "value": 11.10},
{"field": custom_field_documentlink.id, "value": [1, 2, 3]},
],
)
doc.refresh_from_db()
self.assertEqual(len(doc.custom_fields.all()), 7)
self.assertEqual(len(doc.custom_fields.all()), 8)
def test_change_custom_field_instance_value(self):
"""