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:
shamoon
2023-11-05 17:26:51 -08:00
parent 1b02046ba7
commit a792bf1ca1
67 changed files with 3199 additions and 421 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@ import {
import { ConsumptionTemplatesComponent } from './components/manage/consumption-templates/consumption-templates.component'
import { MailComponent } from './components/manage/mail/mail.component'
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
@@ -189,6 +190,17 @@ export const routes: Routes = [
},
},
},
{
path: 'customfields',
component: CustomFieldsComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.CustomField,
},
},
},
{
path: 'templates',
component: ConsumptionTemplatesComponent,

View File

@@ -39,6 +39,7 @@ import { NgxFileDropModule } from 'ngx-file-drop'
import { TextComponent } from './components/common/input/text/text.component'
import { SelectComponent } from './components/common/input/select/select.component'
import { CheckComponent } from './components/common/input/check/check.component'
import { UrlComponent } from './components/common/input/url/url.component'
import { PasswordComponent } from './components/common/input/password/password.component'
import { SaveViewConfigDialogComponent } from './components/document-list/save-view-config-dialog/save-view-config-dialog.component'
import { TagsComponent } from './components/common/input/tags/tags.component'
@@ -101,6 +102,9 @@ import { MailComponent } from './components/manage/mail/mail.component'
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { FileDropComponent } from './components/file-drop/file-drop.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar'
@@ -200,6 +204,7 @@ function initializeApp(settings: SettingsService) {
TextComponent,
SelectComponent,
CheckComponent,
UrlComponent,
PasswordComponent,
SaveViewConfigDialogComponent,
TagsComponent,
@@ -246,6 +251,9 @@ function initializeApp(settings: SettingsService) {
MailComponent,
UsersAndGroupsComponent,
FileDropComponent,
CustomFieldsComponent,
CustomFieldEditDialogComponent,
CustomFieldsDropdownComponent,
],
imports: [
BrowserModule,

View File

@@ -159,17 +159,24 @@
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#hash"/>
</svg><span>&nbsp;<ng-container i18n>Document types</ng-container></span>
</svg><span>&nbsp;<ng-container i18n>Document Types</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#folder"/>
</svg><span>&nbsp;<ng-container i18n>Storage paths</ng-container></span>
</svg><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#ui-radios"/>
</svg><span>&nbsp;<ng-container i18n>Custom Fields</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ConsumptionTemplate }" tourAnchor="tour.consumption-templates">

View File

@@ -30,7 +30,7 @@ import { DocumentListViewService } from 'src/app/services/document-list-view.ser
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CdkDragDrop } from '@angular/cdk/drag-drop'
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
const saved_views = [
@@ -97,6 +97,7 @@ describe('AppFrameComponent', () => {
NgbModule,
FormsModule,
ReactiveFormsModule,
DragDropModule,
],
providers: [
SettingsService,

View File

@@ -0,0 +1,36 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose()">
<button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#ui-radios" />
</svg>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Custom Fields</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<pngx-input-select class="mb-3"
[items]="unusedFields"
bindLabel="name"
[(ngModel)]="field"
[placeholder]="placeholderText"
[notFoundText]="notFoundText"
[disableCreateNew]="!canCreateFields"
(createNew)="createField($event)"
bindValue="id">
</pngx-input-select>
<div class="btn-toolbar" role="toolbar">
<button class="btn btn-sm btn-outline-secondary me-auto" type="button" (click)="createField()" [disabled]="!canCreateFields">
<svg fill="currentColor" class="buttonicon-sm me-1 mb-1">
<use xlink:href="assets/bootstrap-icons.svg#asterisk"/>
</svg><ng-container i18n>Create New Field</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary me-1" type="button" (click)="addField(); fieldDropdown.close()" [disabled]="field === undefined">
<svg fill="currentColor" class="buttonicon me-1">
<use xlink:href="assets/bootstrap-icons.svg#plus-circle"/>
</svg><ng-container i18n>Add</ng-container>
</button>
</div>
</li>
</ul>
</div>
</div>

View File

@@ -0,0 +1,24 @@
.custom-fields-dropdown {
min-width: 350px;
// correct position on mobile
@media (max-width: 575.98px) {
&.show {
margin-left: -175px !important;
}
}
}
::ng-deep .ng-select .ng-select-container .ng-value-container .ng-placeholder,
::ng-deep .ng-select .ng-option,
::ng-deep .ng-select .ng-select-container .ng-value-container .ng-value {
font-size: 0.875rem;
}
::ng-deep .paperless-input-select .ng-select {
min-height: calc(1em + 0.75rem + 5px);
}
::ng-deep .paperless-input-select .ng-select .ng-select-container .ng-value-container .ng-input {
top: 4px;
}

View File

@@ -0,0 +1,137 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { ToastService } from 'src/app/services/toast.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { of } from 'rxjs'
import {
PaperlessCustomField,
PaperlessCustomFieldDataType,
} from 'src/app/data/paperless-custom-field'
import { SelectComponent } from '../input/select/select.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
NgbDropdownModule,
NgbModal,
NgbModalModule,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { By } from '@angular/platform-browser'
const fields: PaperlessCustomField[] = [
{
id: 0,
name: 'Field 1',
data_type: PaperlessCustomFieldDataType.Integer,
},
{
id: 1,
name: 'Field 2',
data_type: PaperlessCustomFieldDataType.String,
},
]
describe('CustomFieldsDropdownComponent', () => {
let component: CustomFieldsDropdownComponent
let fixture: ComponentFixture<CustomFieldsDropdownComponent>
let customFieldService: CustomFieldsService
let toastService: ToastService
let modalService: NgbModal
let httpController: HttpTestingController
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [CustomFieldsDropdownComponent, SelectComponent],
imports: [
HttpClientTestingModule,
NgSelectModule,
FormsModule,
ReactiveFormsModule,
NgbModalModule,
NgbDropdownModule,
],
})
customFieldService = TestBed.inject(CustomFieldsService)
httpController = TestBed.inject(HttpTestingController)
toastService = TestBed.inject(ToastService)
modalService = TestBed.inject(NgbModal)
jest.spyOn(customFieldService, 'listAll').mockReturnValue(
of({
all: fields.map((f) => f.id),
count: fields.length,
results: fields.concat([]),
})
)
fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support add field', () => {
let addedField
component.added.subscribe((f) => (addedField = f))
component.documentId = 11
component.field = fields[0].id
component.addField()
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 creating field, show error if necessary', () => {
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 getFieldsSpy = jest.spyOn(
CustomFieldsDropdownComponent.prototype as any,
'getFields'
)
const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
// fail first
editDialog.failed.emit({ error: 'error creating field' })
expect(toastErrorSpy).toHaveBeenCalled()
expect(getFieldsSpy).not.toHaveBeenCalled()
// succeed
editDialog.succeeded.emit(fields[0])
expect(toastInfoSpy).toHaveBeenCalled()
expect(getFieldsSpy).toHaveBeenCalled()
})
it('should support creating field with name', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
component.createField('Foo bar')
expect(modal).not.toBeUndefined()
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
expect(editDialog.object.name).toEqual('Foo bar')
})
})

View File

@@ -0,0 +1,131 @@
import {
Component,
EventEmitter,
Input,
OnDestroy,
Output,
} from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first, takeUntil } from 'rxjs'
import { PaperlessCustomField } from 'src/app/data/paperless-custom-field'
import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { ToastService } from 'src/app/services/toast.service'
import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import {
PermissionAction,
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
@Component({
selector: 'pngx-custom-fields-dropdown',
templateUrl: './custom-fields-dropdown.component.html',
styleUrls: ['./custom-fields-dropdown.component.scss'],
})
export class CustomFieldsDropdownComponent implements OnDestroy {
@Input()
documentId: number
@Input()
disabled: boolean = false
@Input()
existingFields: PaperlessCustomFieldInstance[] = []
@Output()
added: EventEmitter<PaperlessCustomField> = new EventEmitter()
@Output()
created: EventEmitter<PaperlessCustomField> = new EventEmitter()
private customFields: PaperlessCustomField[] = []
public unusedFields: PaperlessCustomField[]
public name: string
public field: number
private unsubscribeNotifier: Subject<any> = 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,
PermissionType.CustomField
)
}
constructor(
private customFieldsService: CustomFieldsService,
private modalService: NgbModal,
private toastService: ToastService,
private permissionsService: PermissionsService
) {
this.getFields()
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
}
private getFields() {
this.customFieldsService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.customFields = result.results
this.updateUnusedFields()
})
}
public getCustomFieldFromInstance(
instance: PaperlessCustomFieldInstance
): PaperlessCustomField {
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
)
)
}
onOpenClose() {
this.field = undefined
this.updateUnusedFields()
}
addField() {
this.added.emit(this.customFields.find((f) => f.id === this.field))
}
createField(newName: string = null) {
const modal = this.modalService.open(CustomFieldEditDialogComponent)
if (newName) modal.componentInstance.object = { name: newName }
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
this.customFieldsService.clearCache()
this.getFields()
this.created.emit(newField)
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((e) => {
this.toastService.showError($localize`Error saving field.`, e)
})
}
}

View File

@@ -0,0 +1,16 @@
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<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>
<small class="d-block mt-n2" *ngIf="typeFieldDisabled" i18n>Data type cannot be changed after a field is created</small>
</div>
<div class="modal-footer">
<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>
</div>
</form>

View File

@@ -0,0 +1,67 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { CustomFieldEditDialogComponent } from './custom-field-edit-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { SettingsService } from 'src/app/services/settings.service'
import { SelectComponent } from '../../input/select/select.component'
import { TextComponent } from '../../input/text/text.component'
import { EditDialogMode } from '../edit-dialog.component'
describe('CustomFieldEditDialogComponent', () => {
let component: CustomFieldEditDialogComponent
let settingsService: SettingsService
let fixture: ComponentFixture<CustomFieldEditDialogComponent>
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [
CustomFieldEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
SafeHtmlPipe,
],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
NgbModule,
],
}).compileComponents()
fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
it('should disable data type select on edit', () => {
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
component.ngOnInit()
expect(component.objectForm.get('data_type').disabled).toBeTruthy()
})
})

View File

@@ -0,0 +1,60 @@
import { Component, OnInit } from '@angular/core'
import { FormGroup, FormControl } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import {
DATA_TYPE_LABELS,
PaperlessCustomField,
} from 'src/app/data/paperless-custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
@Component({
selector: 'pngx-custom-field-edit-dialog',
templateUrl: './custom-field-edit-dialog.component.html',
styleUrls: ['./custom-field-edit-dialog.component.scss'],
})
export class CustomFieldEditDialogComponent
extends EditDialogComponent<PaperlessCustomField>
implements OnInit
{
constructor(
service: CustomFieldsService,
activeModal: NgbActiveModal,
userService: UserService,
settingsService: SettingsService
) {
super(service, activeModal, userService, settingsService)
}
ngOnInit(): void {
super.ngOnInit()
if (this.typeFieldDisabled) {
this.objectForm.get('data_type').disable()
}
}
getCreateTitle() {
return $localize`Create new custom field`
}
getEditTitle() {
return $localize`Edit custom field`
}
getForm(): FormGroup {
return new FormGroup({
name: new FormControl(null),
data_type: new FormControl(null),
})
}
getDataTypes() {
return DATA_TYPE_LABELS
}
get typeFieldDisabled(): boolean {
return this.dialogMode === EditDialogMode.EDIT
}
}

View File

@@ -1,4 +1,12 @@
import { Directive, ElementRef, Input, OnInit, ViewChild } from '@angular/core'
import {
Directive,
ElementRef,
EventEmitter,
Input,
OnInit,
Output,
ViewChild,
} from '@angular/core'
import { ControlValueAccessor } from '@angular/forms'
import { v4 as uuidv4 } from 'uuid'
@@ -41,6 +49,18 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
@Input()
error: string
@Input()
hint: string
@Input()
horizontal: boolean = false
@Input()
removable: boolean = false
@Output()
removed: EventEmitter<AbstractInputComponent<any>> = new EventEmitter()
value: T
ngOnInit(): void {
@@ -48,7 +68,4 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
}
inputId: string
@Input()
hint: string
}

View File

@@ -1,5 +1,19 @@
<div class="mb-3 form-check">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
<label class="form-check-label" [for]="inputId">{{title}}</label>
<div *ngIf="hint" class="form-text text-muted">{{hint}}</div>
<div class="mb-3">
<div class="row">
<div *ngIf="horizontal" class="d-flex align-items-center position-relative hidden-button-container col-md-3">
<label class="form-label mb-md-0" [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 [ngClass]="{'col-md-9': horizontal, 'align-items-center': horizontal, 'd-flex': horizontal}">
<div class="form-check">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
<label *ngIf="!horizontal" class="form-check-label" [for]="inputId">{{title}}</label>
<div *ngIf="hint" class="form-text text-muted">{{hint}}</div>
</div>
</div>
</div>
</div>

View File

@@ -1,26 +1,37 @@
<div class="mb-3">
<label class="form-label" [for]="inputId">{{title}}</label>
<div class="input-group" [class.is-invalid]="error">
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-calendar" viewBox="0 0 16 16">
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
</svg>
</button>
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ fitlerButtonTitle }}">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#filter" />
</svg>
</button>
<div class="mb-3" [class.pb-3]="error">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
<label class="form-label mb-md-0" [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="position-relative" [class.col-md-9]="horizontal">
<div class="input-group" [class.is-invalid]="error">
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" class="buttonicon">
<use _ngcontent-ng-c3750736003="" xlink:href="assets/bootstrap-icons.svg#calendar"></use>
</svg>
</button>
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ fitlerButtonTitle }}">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#filter" />
</svg>
</button>
</div>
<div class="invalid-feedback position-absolute top-100" i18n>Invalid date.</div>
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
<small *ngIf="getSuggestions().length > 0">
<span i18n>Suggestions:</span>&nbsp;
<ng-container *ngFor="let s of getSuggestions()">
<a (click)="onSuggestionClick(s)" [routerLink]="[]">{{s}}</a>&nbsp;
</ng-container>
</small>
</div>
</div>
<div class="invalid-feedback" i18n>Invalid date.</div>
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
<small *ngIf="getSuggestions().length > 0">
<span i18n>Suggestions:</span>&nbsp;
<ng-container *ngFor="let s of getSuggestions()">
<a (click)="onSuggestionClick(s)" [routerLink]="[]">{{s}}</a>&nbsp;
</ng-container>
</small>
</div>

View File

@@ -1,12 +1,22 @@
<div class="mb-3">
<label class="form-label" [for]="inputId">{{title}}</label>
<div class="input-group" [class.is-invalid]="error">
<input #inputField type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
<button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
<div class="mb-3" [class.pb-3]="error">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
<label class="form-label mb-md-0" [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="position-relative" [class.col-md-9]="horizontal">
<div class="input-group" [class.is-invalid]="error">
<input #inputField type="number" class="form-control" [step]="step" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
<button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
</div>
<div class="invalid-feedback position-absolute top-100">
{{error}}
</div>
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
</div>
</div>
<div class="invalid-feedback">
{{error}}
</div>
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
</div>

View File

@@ -46,4 +46,18 @@ describe('NumberComponent', () => {
component.nextAsn()
expect(component.value).toEqual(1002)
})
it('should support float & monetary values', () => {
component.writeValue(11.13)
expect(component.value).toEqual(11)
component.step = 0.01
component.writeValue(11.1)
expect(component.value).toEqual('11.10')
component.step = 0.1
component.writeValue(12.3456)
expect(component.value).toEqual(12.3456)
// float (step = .1) doesnt force 2 decimals
component.writeValue(11.1)
expect(component.value).toEqual(11.1)
})
})

View File

@@ -19,6 +19,9 @@ export class NumberComponent extends AbstractInputComponent<number> {
@Input()
showAdd: boolean = true
@Input()
step: number = 1
constructor(private documentService: DocumentService) {
super()
}
@@ -32,4 +35,10 @@ export class NumberComponent extends AbstractInputComponent<number> {
this.onChange(this.value)
})
}
writeValue(newValue: any): void {
if (this.step === 1) newValue = parseInt(newValue, 10)
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
super.writeValue(newValue)
}
}

View File

@@ -1,44 +1,54 @@
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<label *ngIf="title" class="form-label" [for]="inputId">{{title}}</label>
<div [class.input-group]="allowCreateNew || showFilter">
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
[style.color]="textColor"
[style.background]="backgroundColor"
[class.private]="isPrivate"
[clearable]="allowNull"
[items]="items"
[addTag]="allowCreateNew && addItemRef"
addTagText="Add item"
i18n-addTagText="Used for both types, correspondents, storage paths"
[placeholder]="placeholder"
[multiple]="multiple"
[bindLabel]="bindLabel"
bindValue="id"
(change)="onChange(value)"
(search)="onSearch($event)"
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
</ng-select>
<button *ngIf="allowCreateNew" class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus" />
</svg>
</button>
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#filter" />
</svg>
<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 mb-md-0" [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>
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
<small *ngIf="getSuggestions().length > 0">
<span i18n>Suggestions:</span>&nbsp;
<ng-container *ngFor="let s of getSuggestions()">
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>&nbsp;
</ng-container>
</small>
<div [class.col-md-9]="horizontal">
<div [class.input-group]="allowCreateNew || showFilter">
<ng-select name="inputId" [(ngModel)]="value"
[disabled]="disabled"
[style.color]="textColor"
[style.background]="backgroundColor"
[class.private]="isPrivate"
[clearable]="allowNull"
[items]="items"
[addTag]="allowCreateNew && addItemRef"
addTagText="Add item"
i18n-addTagText="Used for both types, correspondents, storage paths"
[placeholder]="placeholder"
[notFoundText]="notFoundText"
[multiple]="multiple"
[bindLabel]="bindLabel"
bindValue="id"
(change)="onChange(value)"
(search)="onSearch($event)"
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
</ng-select>
<button *ngIf="allowCreateNew" class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus" />
</svg>
</button>
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#filter" />
</svg>
</button>
</div>
<small *ngIf="hint" class="form-text text-muted">{{hint}}</small>
<small *ngIf="getSuggestions().length > 0">
<span i18n>Suggestions:</span>&nbsp;
<ng-container *ngFor="let s of getSuggestions()">
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>&nbsp;
</ng-container>
</small>
</div>
</div>
</div>

View File

@@ -88,6 +88,12 @@ export class SelectComponent extends AbstractInputComponent<number> {
@Input()
showFilter: boolean = false
@Input()
notFoundText: string = $localize`No items found`
@Input()
disableCreateNew: boolean = false
@Output()
createNew = new EventEmitter<string>()
@@ -99,7 +105,7 @@ export class SelectComponent extends AbstractInputComponent<number> {
private _lastSearchTerm: string
get allowCreateNew(): boolean {
return this.createNew.observers.length > 0
return !this.disableCreateNew && this.createNew.observers.length > 0
}
get isPrivate(): boolean {

View File

@@ -1,51 +1,53 @@
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled">
<label class="form-label" for="tags" i18n>{{title}}</label>
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="suggestions">
<div class="row">
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
<label class="form-label mb-md-0" for="tags" i18n>{{title}}</label>
</div>
<div class="position-relative" [class.col-md-9]="horizontal">
<div class="input-group flex-nowrap">
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
[disabled]="disabled"
[multiple]="true"
[closeOnSelect]="false"
[clearSearchOnAdd]="true"
[hideSelected]="tags.length > 0"
[addTag]="allowCreate ? createTagRef : false"
addTagText="Add tag"
i18n-addTagText
(change)="onChange(value)">
<div class="input-group flex-nowrap">
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
[disabled]="disabled"
[multiple]="true"
[closeOnSelect]="false"
[clearSearchOnAdd]="true"
[hideSelected]="tags.length > 0"
[addTag]="allowCreate ? createTagRef : false"
addTagText="Add tag"
i18n-addTagText
(change)="onChange(value)">
<ng-template ng-label-tmp let-item="item">
<span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)">
<svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
<ng-template ng-label-tmp let-item="item">
<span class="tag-wrap tag-wrap-delete" (mousedown)="removeTag($event, item.id)">
<svg width="1.2em" height="1em" viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<use xlink:href="assets/bootstrap-icons.svg#x"/>
</svg>
<pngx-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag>
</span>
</ng-template>
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
<div class="tag-wrap">
<pngx-tag *ngIf="item.id && tags" class="me-2" [tag]="getTag(item.id)"></pngx-tag>
</div>
</ng-template>
</ng-select>
<button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus" />
</svg>
<pngx-tag *ngIf="item.id && tags" style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag>
</span>
</ng-template>
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
<div class="tag-wrap">
<pngx-tag *ngIf="item.id && tags" class="me-2" [tag]="getTag(item.id)"></pngx-tag>
</div>
</ng-template>
</ng-select>
<button *ngIf="allowCreate" class="btn btn-outline-secondary" type="button" (click)="createTag()" [disabled]="disabled">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#plus" />
</svg>
</button>
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="hasPrivate || this.value === null" i18n-title title="Filter documents with these Tags">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#filter" />
</svg>
</button>
</button>
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="hasPrivate || this.value === null" i18n-title title="Filter documents with these Tags">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#filter" />
</svg>
</button>
</div>
<small class="form-text text-muted" *ngIf="hint">{{hint}}</small>
<small *ngIf="getSuggestions().length > 0" class="position-absolute top-100">
<span i18n>Suggestions:</span>&nbsp;
<ng-container *ngFor="let tag of getSuggestions()">
<a (click)="addTag(tag.id)" [routerLink]="[]">{{tag?.name}}</a>&nbsp;
</ng-container>
</small>
</div>
</div>
<small class="form-text text-muted" *ngIf="hint">{{hint}}</small>
<small *ngIf="getSuggestions().length > 0">
<span i18n>Suggestions:</span>&nbsp;
<ng-container *ngFor="let tag of getSuggestions()">
<a (click)="addTag(tag.id)" [routerLink]="[]">{{tag.name}}</a>&nbsp;
</ng-container>
</small>
</div>

View File

@@ -77,6 +77,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@Input()
showFilter: boolean = false
@Input()
horizontal: boolean = false
@Output()
filterDocuments = new EventEmitter<PaperlessTag[]>()

View File

@@ -1,8 +1,19 @@
<div class="mb-3">
<label class="form-label" [for]="inputId">{{title}}</label>
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
<div class="invalid-feedback">
{{error}}
<div class="mb-3" [class.pb-3]="error">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
<label class="form-label mb-md-0" [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="position-relative" [class.col-md-9]="horizontal">
<input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
<div class="invalid-feedback position-absolute top-100">
{{error}}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,26 @@
<div class="mb-3" [class.pb-3]="error">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
<label class="form-label mb-md-0" [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 class="input-group" [class.is-invalid]="error">
<input #inputField type="url" class="form-control" [class.is-invalid]="error" placeholder="https://" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled">
<a class="btn btn-outline-secondary rounded-end" title="Open link" i18n-title [href]="value" target="_blank">
<svg class="buttonicon mb-1" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#box-arrow-up-right" />
</svg>
</a>
<div class="invalid-feedback position-absolute top-100">
{{error}}
</div>
</div>
<small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
</div>
</div>
</div>

View File

@@ -0,0 +1,36 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
ReactiveFormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms'
import { UrlComponent } from './url.component'
describe('TextComponent', () => {
let component: UrlComponent
let fixture: ComponentFixture<UrlComponent>
let input: HTMLInputElement
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [UrlComponent],
providers: [],
imports: [FormsModule, ReactiveFormsModule],
}).compileComponents()
fixture = TestBed.createComponent(UrlComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
input = component.inputField.nativeElement
})
it('should support use of input field', () => {
expect(component.value).toBeUndefined()
// TODO: why doesnt this work?
// input.value = 'foo'
// input.dispatchEvent(new Event('change'))
// fixture.detectChanges()
// expect(component.value).toEqual('foo')
})
})

View File

@@ -0,0 +1,21 @@
import { Component, forwardRef } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { AbstractInputComponent } from '../abstract-input'
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => UrlComponent),
multi: true,
},
],
selector: 'pngx-input-url',
templateUrl: './url.component.html',
styleUrls: ['./url.component.scss'],
})
export class UrlComponent extends AbstractInputComponent<string> {
constructor() {
super()
}
}

View File

@@ -1,9 +1,9 @@
.h2 {
h3 {
min-height: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
.h2 {
h3 {
min-height: 2.8rem;
}
}

View File

@@ -1,5 +1,5 @@
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary me-2" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
<button class="btn btn-sm btn-outline-primary" id="shareLinksDropdown" [disabled]="disabled" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#link" />
</svg>

View File

@@ -26,7 +26,7 @@
</div>
</div>
<div ngbDropdown>
<div class="ms-auto" ngbDropdown>
<button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle>
<svg class="toolbaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#three-dots" />
@@ -48,59 +48,88 @@
</div>
</div>
<pngx-custom-fields-dropdown
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
class="me-2"
[documentId]="documentId"
[disabled]="!userIsOwner"
[existingFields]="document?.custom_fields"
(created)="refreshCustomFields()"
(added)="addField($event)">
</pngx-custom-fields-dropdown>
<pngx-share-links-dropdown [documentId]="documentId" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"></pngx-share-links-dropdown>
<button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Close" (click)="close()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x" />
</svg>
</button>
<div class="button-group">
<button type="button" class="btn btn-sm btn-outline-primary me-2" i18n-title title="Previous" (click)="previousDoc()" [disabled]="!hasPrevious()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-left" />
</svg>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" i18n-title title="Next" (click)="nextDoc()" [disabled]="!hasNext()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
</svg>
</button>
</div>
</pngx-page-header>
<div class="row">
<div class="col-md-6 col-xl-4 mb-4">
<form [formGroup]='documentForm' (ngSubmit)="save()">
<ul ngbNav #nav="ngbNav" class="nav-tabs" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
<div class="btn-toolbar mb-1 pb-3 border-bottom">
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#x" />
</svg>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Previous" (click)="previousDoc()" [disabled]="!hasPrevious()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-left" />
</svg>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Next" (click)="nextDoc()" [disabled]="!hasNext()">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#arrow-right" />
</svg>
</button>
</div>
<div class="btn-group ms-auto">
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<button *ngIf="hasNext()" type="button" class="btn btn-sm btn-outline-primary" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; next</button>
<button *ngIf="!hasNext()" type="button" class="btn btn-sm btn-outline-primary" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; close</button>
<button type="submit" class="btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
</ng-container>
</div>
</div>
<ul ngbNav #nav="ngbNav" class="nav-underline flex-nowrap flex-md-wrap overflow-auto" (navChange)="onNavChange($event)" [(activeId)]="activeNavID">
<li [ngbNavItem]="DocumentDetailNavIDs.Details">
<a ngbNavLink i18n>Details</a>
<ng-template ngbNavContent>
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" formControlName='archive_serial_number'></pngx-input-number>
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
[error]="error?.created_date"></pngx-input-date>
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
<div>
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
<pngx-input-date i18n-title title="Date created" formControlName="created_date" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
[error]="error?.created_date"></pngx-input-date>
<pngx-input-select [items]="correspondents" i18n-title title="Correspondent" formControlName="correspondent" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createCorrespondent($event)" [suggestions]="suggestions?.correspondents" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"></pngx-input-select>
<pngx-input-select [items]="documentTypes" i18n-title title="Document type" formControlName="document_type" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createDocumentType($event)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
<ng-container *ngFor="let fieldInstance of document?.custom_fields; let i = index">
<div [formGroup]="customFieldFormFields.controls[i]">
<pngx-input-text formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.String" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-text>
<pngx-input-date formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Date" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-date>
<pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Integer" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [error]="getCustomFieldError(i)"></pngx-input-number>
<pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Float" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".1" [error]="getCustomFieldError(i)"></pngx-input-number>
<pngx-input-number formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Monetary" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [showAdd]="false" [step]=".01" [error]="getCustomFieldError(i)"></pngx-input-number>
<pngx-input-check formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Boolean" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true"></pngx-input-check>
<pngx-input-url formControlName="value" *ngIf="getCustomFieldFromInstance(fieldInstance)?.data_type === PaperlessCustomFieldDataType.Url" [title]="getCustomFieldFromInstance(fieldInstance)?.name" [removable]="true" (removed)="removeField(fieldInstance)" [horizontal]="true" [error]="getCustomFieldError(i)"></pngx-input-url>
</div>
</ng-container>
</div>
</ng-template>
</li>
<li [ngbNavItem]="DocumentDetailNavIDs.Content">
<a ngbNavLink i18n>Content</a>
<ng-template ngbNavContent>
<div class="mb-3">
<div>
<textarea class="form-control" id="content" rows="20" formControlName='content' [class.rtl]="isRTL"></textarea>
</div>
</ng-template>
@@ -198,16 +227,8 @@
</li>
</ul>
<div [ngbNavOutlet]="nav" class="mt-2"></div>
<div [ngbNavOutlet]="nav" class="mt-3"></div>
<ng-container>
<button type="button" class="btn btn-outline-secondary me-2" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<button *ngIf="hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; next</button>
<button *ngIf="!hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; close</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
</ng-container>
</ng-container>
</form>
</div>

View File

@@ -67,6 +67,9 @@ import { PageHeaderComponent } from '../common/page-header/page-header.component
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { DocumentDetailComponent } from './document-detail.component'
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { PaperlessCustomFieldDataType } from 'src/app/data/paperless-custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
const doc: PaperlessDocument = {
id: 3,
@@ -94,8 +97,31 @@ const doc: PaperlessDocument = {
user: 2,
},
],
custom_fields: [
{
field: 0,
document: 3,
created: new Date(),
value: 'custom foo bar',
},
],
}
const customFields = [
{
id: 0,
name: 'Field 1',
data_type: PaperlessCustomFieldDataType.String,
created: new Date(),
},
{
id: 1,
name: 'Custom Field 2',
data_type: PaperlessCustomFieldDataType.Integer,
created: new Date(),
},
]
describe('DocumentDetailComponent', () => {
let component: DocumentDetailComponent
let fixture: ComponentFixture<DocumentDetailComponent>
@@ -107,6 +133,7 @@ describe('DocumentDetailComponent', () => {
let toastService: ToastService
let documentListViewService: DocumentListViewService
let settingsService: SettingsService
let customFieldsService: CustomFieldsService
let currentUserCan = true
let currentUserHasObjectPermissions = true
@@ -136,6 +163,7 @@ describe('DocumentDetailComponent', () => {
PdfViewerComponent,
SafeUrlPipe,
ShareLinksDropdownComponent,
CustomFieldsDropdownComponent,
],
providers: [
DocumentTitlePipe,
@@ -199,6 +227,7 @@ describe('DocumentDetailComponent', () => {
}),
},
},
CustomFieldsService,
{
provide: PermissionsService,
useValue: {
@@ -234,6 +263,7 @@ describe('DocumentDetailComponent', () => {
toastService = TestBed.inject(ToastService)
documentListViewService = TestBed.inject(DocumentListViewService)
settingsService = TestBed.inject(SettingsService)
customFieldsService = TestBed.inject(CustomFieldsService)
fixture = TestBed.createComponent(DocumentDetailComponent)
component = fixture.componentInstance
})
@@ -290,6 +320,13 @@ describe('DocumentDetailComponent', () => {
it('should load already-opened document via param', () => {
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(doc)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture.detectChanges() // calls ngOnInit
expect(component.document).toEqual(doc)
})
@@ -797,12 +834,92 @@ describe('DocumentDetailComponent', () => {
expect(toastSpy).toHaveBeenCalledWith('Error retrieving metadata', error)
})
it('should display custom fields', () => {
initNormally()
expect(fixture.debugElement.nativeElement.textContent).toContain(
customFields[0].name
)
})
it('should support add custom field, correctly send via post', () => {
initNormally()
const initialLength = doc.custom_fields.length
expect(component.customFieldFormFields).toHaveLength(initialLength)
component.addField(customFields[1])
fixture.detectChanges()
expect(component.document.custom_fields).toHaveLength(initialLength + 1)
expect(component.customFieldFormFields).toHaveLength(initialLength + 1)
expect(fixture.debugElement.nativeElement.textContent).toContain(
customFields[1].name
)
const updateSpy = jest.spyOn(documentService, 'update')
component.save(true)
expect(updateSpy.mock.lastCall[0].custom_fields).toHaveLength(2)
expect(updateSpy.mock.lastCall[0].custom_fields[1]).toEqual({
field: customFields[1].id,
value: null,
})
})
it('should support remove custom field, correctly send via post', () => {
initNormally()
const initialLength = doc.custom_fields.length
expect(component.customFieldFormFields).toHaveLength(initialLength)
component.removeField(doc.custom_fields[0])
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'
)
const updateSpy = jest.spyOn(documentService, 'update')
component.save(true)
expect(updateSpy.mock.lastCall[0].custom_fields).toHaveLength(
initialLength - 1
)
})
it('should show custom field errors', () => {
initNormally()
component.error = {
custom_fields: [
{},
{},
{ value: ['This field may not be null.'] },
{},
{ non_field_errors: ['Enter a valid URL.'] },
],
}
expect(component.getCustomFieldError(2)).toEqual([
'This field may not be null.',
])
expect(component.getCustomFieldError(4)).toEqual(['Enter a valid URL.'])
})
it('should refresh custom fields when created', () => {
initNormally()
const refreshSpy = jest.spyOn(component, 'refreshCustomFields')
fixture.debugElement
.query(By.directive(CustomFieldsDropdownComponent))
.triggerEventHandler('created')
expect(refreshSpy).toHaveBeenCalled()
})
function initNormally() {
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(of(Object.assign({}, doc)))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
jest
.spyOn(openDocumentsService, 'openDocument')
.mockReturnValueOnce(of(true))
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture.detectChanges()
}
})

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { FormArray, FormControl, FormGroup } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import {
NgbDateStruct,
@@ -63,7 +63,12 @@ import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
import { ObjectWithId } from 'src/app/data/object-with-id'
import { FilterRule } from 'src/app/data/filter-rule'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import { ShareLinksDropdownComponent } from '../common/share-links-dropdown/share-links-dropdown.component'
import {
PaperlessCustomField,
PaperlessCustomFieldDataType,
} from 'src/app/data/paperless-custom-field'
import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
enum DocumentDetailNavIDs {
Details = 1,
@@ -120,6 +125,7 @@ export class DocumentDetailComponent
archive_serial_number: new FormControl(),
tags: new FormControl([]),
permissions_form: new FormControl(null),
custom_fields: new FormArray([]),
})
previewCurrentPage: number = 1
@@ -135,6 +141,9 @@ export class DocumentDetailComponent
ogDate: Date
customFields: PaperlessCustomField[]
public readonly PaperlessCustomFieldDataType = PaperlessCustomFieldDataType
@ViewChild('nav') nav: NgbNav
@ViewChild('pdfPreview') set pdfPreview(element) {
// this gets called when compontent added or removed from DOM
@@ -166,6 +175,7 @@ export class DocumentDetailComponent
private storagePathService: StoragePathService,
private permissionsService: PermissionsService,
private userService: UserService,
private customFieldsService: CustomFieldsService,
private http: HttpClient
) {
super()
@@ -232,6 +242,8 @@ export class DocumentDetailComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.users = result.results))
this.getCustomFields()
this.route.paramMap
.pipe(
takeUntil(this.unsubscribeNotifier),
@@ -324,6 +336,7 @@ export class DocumentDetailComponent
owner: doc.owner,
set_permissions: doc.permissions,
},
custom_fields: doc.custom_fields,
})
this.isDirty$ = dirtyCheck(
@@ -385,6 +398,8 @@ export class DocumentDetailComponent
updateComponent(doc: PaperlessDocument) {
this.document = doc
this.requiresPassword = false
// this.customFields = doc.custom_fields.concat([])
this.updateFormForCustomFields()
this.documentsService
.getMetadata(doc.id)
.pipe(first())
@@ -433,6 +448,10 @@ export class DocumentDetailComponent
if (!this.userCanEdit) this.documentForm.disable()
}
get customFieldFormFields(): FormArray {
return this.documentForm.get('custom_fields') as FormArray
}
createDocumentType(newName: string) {
var modal = this.modalService.open(DocumentTypeEditDialogComponent, {
backdrop: 'static',
@@ -510,6 +529,7 @@ export class DocumentDetailComponent
set_permissions: doc.permissions,
}
this.title = doc.title
this.updateFormForCustomFields()
this.documentForm.patchValue(doc)
this.openDocumentService.setDirty(doc, false)
},
@@ -533,6 +553,7 @@ export class DocumentDetailComponent
close && this.close()
this.networkActive = false
this.error = null
this.openDocumentService.refreshDocument(this.documentId)
},
error: (error) => {
this.networkActive = false
@@ -819,4 +840,61 @@ export class DocumentDetailComponent
this.documentListViewService.quickFilter(filterRules)
}
private getCustomFields() {
this.customFieldsService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => (this.customFields = result.results))
}
public refreshCustomFields() {
this.customFieldsService.clearCache()
this.getCustomFields()
}
public getCustomFieldFromInstance(
instance: PaperlessCustomFieldInstance
): PaperlessCustomField {
return this.customFields?.find((f) => f.id === instance.field)
}
public getCustomFieldError(index: number) {
const fieldError = this.error?.custom_fields?.[index]
return fieldError?.['non_field_errors'] ?? fieldError?.['value']
}
private updateFormForCustomFields(emitEvent: boolean = false) {
this.customFieldFormFields.clear({ emitEvent: false })
this.document.custom_fields?.forEach((fieldInstance) => {
this.customFieldFormFields.push(
new FormGroup({
field: new FormControl(
this.getCustomFieldFromInstance(fieldInstance)?.id
),
value: new FormControl(fieldInstance.value),
}),
{ emitEvent }
)
})
}
public addField(field: PaperlessCustomField) {
this.document.custom_fields.push({
field: field.id,
value: null,
document: this.documentId,
created: new Date(),
})
this.updateFormForCustomFields(true)
}
public removeField(fieldInstance: PaperlessCustomFieldInstance) {
this.document.custom_fields.splice(
this.document.custom_fields.indexOf(fieldInstance),
1
)
this.updateFormForCustomFields(true)
this.documentForm.updateValueAndValidity()
}
}

View File

@@ -46,6 +46,7 @@ import {
FILTER_OWNER_ANY,
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_CUSTOM_FIELDS,
} from 'src/app/data/filter-rule-type'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
@@ -240,6 +241,18 @@ describe('FilterEditorComponent', () => {
expect(component.textFilterTarget).toEqual('asn') // TEXT_FILTER_TARGET_ASN
}))
it('should ingest text filter rules for custom fields', fakeAsync(() => {
expect(component.textFilter).toEqual(null)
component.filterRules = [
{
rule_type: FILTER_CUSTOM_FIELDS,
value: 'foo',
},
]
expect(component.textFilter).toEqual('foo')
expect(component.textFilterTarget).toEqual('custom-fields') // TEXT_FILTER_TARGET_CUSTOM_FIELDS
}))
it('should ingest text filter rules for doc asn is null', fakeAsync(() => {
expect(component.textFilterTarget).toEqual('title-content')
expect(component.textFilterModifier).toEqual('equals') // TEXT_FILTER_MODIFIER_EQUALS
@@ -956,12 +969,30 @@ describe('FilterEditorComponent', () => {
])
}))
it('should convert user input to correct filter rules on full text query', fakeAsync(() => {
it('should convert user input to correct filter rules on custom fields query', fakeAsync(() => {
component.textFilterInput.nativeElement.value = 'foo'
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
const textFieldTargetDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdownItem)
)[3]
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_CUSTOM_FIELDS
fixture.detectChanges()
tick(400)
expect(component.textFilterTarget).toEqual('custom-fields')
expect(component.filterRules).toEqual([
{
rule_type: FILTER_CUSTOM_FIELDS,
value: 'foo',
},
])
}))
it('should convert user input to correct filter rules on full text query', fakeAsync(() => {
component.textFilterInput.nativeElement.value = 'foo'
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
const textFieldTargetDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdownItem)
)[4]
textFieldTargetDropdown.triggerEventHandler('click') // TEXT_FILTER_TARGET_ASN
fixture.detectChanges()
tick(400)

View File

@@ -48,6 +48,7 @@ import {
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_OWNER_ANY,
FILTER_CUSTOM_FIELDS,
} from 'src/app/data/filter-rule-type'
import {
FilterableDropdownSelectionModel,
@@ -74,6 +75,7 @@ const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
const TEXT_FILTER_TARGET_ASN = 'asn'
const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
const TEXT_FILTER_MODIFIER_NULL = 'is null'
@@ -204,6 +206,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
name: $localize`Title & content`,
},
{ id: TEXT_FILTER_TARGET_ASN, name: $localize`ASN` },
{
id: TEXT_FILTER_TARGET_CUSTOM_FIELDS,
name: $localize`Custom fields`,
},
{
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
name: $localize`Advanced search`,
@@ -321,6 +327,10 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
break
case FILTER_CUSTOM_FIELDS:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS
break
case FILTER_FULLTEXT_QUERY:
let allQueryArgs = rule.value.split(',')
let textQueryArgs = []
@@ -552,6 +562,15 @@ export class FilterEditorComponent implements OnInit, OnDestroy {
})
}
}
if (
this._textFilter &&
this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
) {
filterRules.push({
rule_type: FILTER_CUSTOM_FIELDS,
value: this._textFilter,
})
}
if (
this._textFilter &&
this.textFilterTarget == TEXT_FILTER_TARGET_FULLTEXT_QUERY

View File

@@ -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>&nbsp;<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>&nbsp;<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>

View File

@@ -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()
})
})

View File

@@ -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
}
}

View File

@@ -46,6 +46,8 @@ export const FILTER_OWNER_ANY = 33
export const FILTER_OWNER_ISNULL = 34
export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
export const FILTER_CUSTOM_FIELDS = 36
export const FILTER_RULE_TYPES: FilterRuleType[] = [
{
id: FILTER_TITLE,
@@ -271,6 +273,12 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'number',
multi: true,
},
{
id: FILTER_CUSTOM_FIELDS,
filtervar: 'custom_fields__icontains',
datatype: 'string',
multi: false,
},
]
export interface FilterRuleType {

View File

@@ -0,0 +1,9 @@
import { ObjectWithId } from './object-with-id'
import { PaperlessCustomField } from './paperless-custom-field'
export interface PaperlessCustomFieldInstance extends ObjectWithId {
document: number // PaperlessDocument
field: number // PaperlessCustomField
created: Date
value?: any
}

View File

@@ -0,0 +1,48 @@
import { ObjectWithId } from './object-with-id'
export enum PaperlessCustomFieldDataType {
String = 'string',
Url = 'url',
Date = 'date',
Boolean = 'boolean',
Integer = 'integer',
Float = 'float',
Monetary = 'monetary',
}
export const DATA_TYPE_LABELS = [
{
id: PaperlessCustomFieldDataType.Boolean,
name: $localize`Boolean`,
},
{
id: PaperlessCustomFieldDataType.Date,
name: $localize`Date`,
},
{
id: PaperlessCustomFieldDataType.Integer,
name: $localize`Integer`,
},
{
id: PaperlessCustomFieldDataType.Float,
name: $localize`Number`,
},
{
id: PaperlessCustomFieldDataType.Monetary,
name: $localize`Monetary`,
},
{
id: PaperlessCustomFieldDataType.String,
name: $localize`Text`,
},
{
id: PaperlessCustomFieldDataType.Url,
name: $localize`Url`,
},
]
export interface PaperlessCustomField extends ObjectWithId {
data_type: PaperlessCustomFieldDataType
name: string
created?: Date
}

View File

@@ -5,6 +5,7 @@ import { Observable } from 'rxjs'
import { PaperlessStoragePath } from './paperless-storage-path'
import { ObjectWithPermissions } from './object-with-permissions'
import { PaperlessDocumentNote } from './paperless-document-note'
import { PaperlessCustomFieldInstance } from './paperless-custom-field-instance'
export interface SearchHit {
score?: number
@@ -58,4 +59,6 @@ export interface PaperlessDocument extends ObjectWithPermissions {
notes?: PaperlessDocumentNote[]
__search_hit__?: SearchHit
custom_fields?: PaperlessCustomFieldInstance[]
}

View File

@@ -256,6 +256,10 @@ describe('PermissionsService', () => {
'view_consumptiontemplate',
'change_consumptiontemplate',
'delete_consumptiontemplate',
'add_customfield',
'view_customfield',
'change_customfield',
'delete_customfield',
],
{
username: 'testuser',

View File

@@ -26,6 +26,7 @@ export enum PermissionType {
Admin = '%s_logentry',
ShareLink = '%s_sharelink',
ConsumptionTemplate = '%s_consumptiontemplate',
CustomField = '%s_customfield',
}
@Injectable({

View File

@@ -0,0 +1,14 @@
import { HttpTestingController } from '@angular/common/http/testing'
import { Subscription } from 'rxjs'
import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { CustomFieldsService } from './custom-fields.service'
let httpTestingController: HttpTestingController
let service: CustomFieldsService
let subscription: Subscription
const endpoint = 'custom_fields'
// run common tests
commonAbstractPaperlessServiceTests(endpoint, CustomFieldsService)

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpParams } from '@angular/common/http'
import { AbstractPaperlessService } from './abstract-paperless-service'
import { Observable } from 'rxjs'
import { PaperlessCustomField } from 'src/app/data/paperless-custom-field'
import { PaperlessCustomFieldInstance } from 'src/app/data/paperless-custom-field-instance'
@Injectable({
providedIn: 'root',
})
export class CustomFieldsService extends AbstractPaperlessService<PaperlessCustomField> {
constructor(http: HttpClient) {
super(http, 'custom_fields')
}
}

View File

@@ -384,6 +384,14 @@ ul.pagination {
}
}
.nav-underline {
.nav-link {
&.active, &:hover, &:focus {
color: var(--bs-primary);
}
}
}
.ng-select-container,
.ng-select.ng-select-opened > .ng-select-container,
.ng-dropdown-panel,
@@ -661,3 +669,17 @@ code {
.cdk-drag-animating {
transition: transform 300ms cubic-bezier(0, 0, 0.2, 1);
}
.hidden-button-container {
button {
opacity: 0;
pointer-events: none;
transition: opacity .2s ease;
}
&:hover {
button {
opacity: 1;
pointer-events: initial;
}
}
}