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 - `Integer`: integer number e.g. 12
- `Number`: float number e.g. 12.3456 - `Number`: float number e.g. 12.3456
- `Monetary`: float number with exactly two decimals, e.g. 12.30 - `Monetary`: float number with exactly two decimals, e.g. 12.30
- `Document Link`: reference(s) to other document(s), displayed as links
## Share 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 { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component' import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.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 localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar' import localeAr from '@angular/common/locales/ar'
@ -259,6 +260,7 @@ function initializeApp(settings: SettingsService) {
CustomFieldsDropdownComponent, CustomFieldsDropdownComponent,
ProfileEditDialogComponent, ProfileEditDialogComponent,
PdfViewerComponent, PdfViewerComponent,
DocumentLinkComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@ -1,16 +1,16 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off"> <form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()"> <button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button> </button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text> <pngx-input-text i18n-title title="Name" formControlName="name" [error]="error?.name"></pngx-input-text>
<pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select> <pngx-input-select i18n-title title="Data type" [items]="getDataTypes()" formControlName="data_type"></pngx-input-select>
<small class="d-block mt-n2" *ngIf="typeFieldDisabled" i18n>Data type cannot be changed after a field is created</small> <small class="d-block mt-n2" *ngIf="typeFieldDisabled" i18n>Data type cannot be changed after a field is created</small>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button> <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive">Save</button>
</div> </div>
</form> </form>

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="row">
<div class="d-flex align-items-center" [class.col-md-3]="horizontal"> <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> <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-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-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-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> </div>
</ng-container> </ng-container>
</div> </div>

View File

@ -8,6 +8,7 @@ export enum PaperlessCustomFieldDataType {
Integer = 'integer', Integer = 'integer',
Float = 'float', Float = 'float',
Monetary = 'monetary', Monetary = 'monetary',
DocumentLink = 'documentlink',
} }
export const DATA_TYPE_LABELS = [ export const DATA_TYPE_LABELS = [
@ -39,6 +40,10 @@ export const DATA_TYPE_LABELS = [
id: PaperlessCustomFieldDataType.Url, id: PaperlessCustomFieldDataType.Url,
name: $localize`Url`, name: $localize`Url`,
}, },
{
id: PaperlessCustomFieldDataType.DocumentLink,
name: $localize`Document Link`,
},
] ]
export interface PaperlessCustomField extends ObjectWithId { 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.contrib.auth.models import User
from django.core.validators import MaxValueValidator from django.core.validators import MaxValueValidator
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.core.validators import int_list_validator
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -756,6 +757,7 @@ class CustomField(models.Model):
INT = ("integer", _("Integer")) INT = ("integer", _("Integer"))
FLOAT = ("float", _("Float")) FLOAT = ("float", _("Float"))
MONETARY = ("monetary", _("Monetary")) MONETARY = ("monetary", _("Monetary"))
DOCUMENTLINK = ("documentlink", _("Document Link"))
created = models.DateTimeField( created = models.DateTimeField(
_("created"), _("created"),
@ -834,6 +836,12 @@ class CustomFieldInstance(models.Model):
value_monetary = models.DecimalField(null=True, decimal_places=2, max_digits=12) 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: class Meta:
ordering = ("created",) ordering = ("created",)
verbose_name = _("custom field instance") verbose_name = _("custom field instance")
@ -868,6 +876,8 @@ class CustomFieldInstance(models.Model):
return self.value_float return self.value_float
elif self.field.data_type == CustomField.FieldDataType.MONETARY: elif self.field.data_type == CustomField.FieldDataType.MONETARY:
return self.value_monetary return self.value_monetary
elif self.field.data_type == CustomField.FieldDataType.DOCUMENTLINK:
return self.value_document_ids
raise NotImplementedError(self.field.data_type) raise NotImplementedError(self.field.data_type)

View File

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

View File

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