diff --git a/docs/usage.md b/docs/usage.md index 4cb55613c..fed7412b3 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index 8d7ea5663..6910061d2 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -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, diff --git a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html index 94dc3297f..63f235b43 100644 --- a/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html +++ b/src-ui/src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component.html @@ -1,16 +1,16 @@
- - - -
+ + + + diff --git a/src-ui/src/app/components/common/input/document-link/document-link.component.html b/src-ui/src/app/components/common/input/document-link/document-link.component.html new file mode 100644 index 000000000..d02a1d3f4 --- /dev/null +++ b/src-ui/src/app/components/common/input/document-link/document-link.component.html @@ -0,0 +1,46 @@ +
+
+
+ + +
+
+
+ + + + + + + + + {{document.title}} + + + +
+
Loading...
+
+
+
+ {{hint}} +
+
+
diff --git a/src-ui/src/app/components/common/input/document-link/document-link.component.scss b/src-ui/src/app/components/common/input/document-link/document-link.component.scss new file mode 100644 index 000000000..bcaa4e849 --- /dev/null +++ b/src-ui/src/app/components/common/input/document-link/document-link.component.scss @@ -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); +} diff --git a/src-ui/src/app/components/common/input/document-link/document-link.component.spec.ts b/src-ui/src/app/components/common/input/document-link/document-link.component.spec.ts new file mode 100644 index 000000000..c99777205 --- /dev/null +++ b/src-ui/src/app/components/common/input/document-link/document-link.component.spec.ts @@ -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 + 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) + }) +}) diff --git a/src-ui/src/app/components/common/input/document-link/document-link.component.ts b/src-ui/src/app/components/common/input/document-link/document-link.component.ts new file mode 100644 index 000000000..23be11259 --- /dev/null +++ b/src-ui/src/app/components/common/input/document-link/document-link.component.ts @@ -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 + implements OnInit, OnDestroy +{ + documentsInput$ = new Subject() + foundDocuments$: Observable + loading = false + selectedDocuments: PaperlessDocument[] = [] + + private unsubscribeNotifier: Subject = 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() + } +} diff --git a/src-ui/src/app/components/common/input/tags/tags.component.html b/src-ui/src/app/components/common/input/tags/tags.component.html index a359bd387..3c93a167d 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.html +++ b/src-ui/src/app/components/common/input/tags/tags.component.html @@ -1,4 +1,4 @@ -
+
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 8b50e6f2e..ea14b750d 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 @@ -131,6 +131,7 @@ +
diff --git a/src-ui/src/app/data/paperless-custom-field.ts b/src-ui/src/app/data/paperless-custom-field.ts index 663e1507f..93bd34e33 100644 --- a/src-ui/src/app/data/paperless-custom-field.ts +++ b/src-ui/src/app/data/paperless-custom-field.ts @@ -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 { diff --git a/src/documents/migrations/1042_consumptiontemplate_assign_custom_fields.py b/src/documents/migrations/1042_consumptiontemplate_assign_custom_fields.py deleted file mode 100644 index 08d6062ea..000000000 --- a/src/documents/migrations/1042_consumptiontemplate_assign_custom_fields.py +++ /dev/null @@ -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", - ), - ), - ] diff --git a/src/documents/migrations/1042_consumptiontemplate_assign_custom_fields_and_more.py b/src/documents/migrations/1042_consumptiontemplate_assign_custom_fields_and_more.py new file mode 100644 index 000000000..150b4c2af --- /dev/null +++ b/src/documents/migrations/1042_consumptiontemplate_assign_custom_fields_and_more.py @@ -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", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index d688253de..a0da315a0 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -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) diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 2373a25dd..e0ecdd01a 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -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): """ diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py index 725bd9254..cde5f302c 100644 --- a/src/documents/tests/test_api_custom_fields.py +++ b/src/documents/tests/test_api_custom_fields.py @@ -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): """