Custom Fields dropdown, skeleton of posting custom fields

This commit is contained in:
shamoon 2023-10-29 23:52:10 -07:00
parent 548ffd46b0
commit 877b9325c4
13 changed files with 360 additions and 66 deletions

View File

@ -103,6 +103,7 @@ 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'
@ -250,6 +251,7 @@ function initializeApp(settings: SettingsService) {
FileDropComponent,
CustomFieldsComponent,
CustomFieldEditDialogComponent,
CustomFieldsDropdownComponent,
],
imports: [
BrowserModule,

View File

@ -159,21 +159,21 @@
</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">
<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>

View File

@ -0,0 +1,33 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose()">
<button class="btn btn-sm btn-outline-primary me-2" 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"
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()">
<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,125 @@
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 {
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,
],
})
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 = [fields[1]]
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()
})
})

View File

@ -0,0 +1,100 @@
import {
Component,
EventEmitter,
Input,
OnDestroy,
Output,
SimpleChanges,
} 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 { 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'
@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: PaperlessCustomField[] = []
@Output()
added = 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`
}
constructor(
private customFieldsService: CustomFieldsService,
private modalService: NgbModal,
private toastService: ToastService
) {
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()
})
}
private updateUnusedFields() {
this.unusedFields = this.customFields.filter(
(f) => !this.existingFields.find((e) => e.id === f.id)
)
}
onOpenClose() {
this.field = undefined
this.updateUnusedFields()
}
addField() {
this.added.emit(this.customFields.find((f) => f.id === this.field))
}
createField() {
const modal = this.modalService.open(CustomFieldEditDialogComponent)
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newField) => {
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
this.customFieldsService.clearCache()
this.getFields()
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((e) => {
this.toastService.showError($localize`Error saving field.`, e)
})
}
}

View File

@ -48,6 +48,13 @@
</div>
</div>
<pngx-custom-fields-dropdown
[documentId]="documentId"
[disabled]="!userIsOwner"
[existingFields]="customFields"
(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()">
@ -93,9 +100,12 @@
<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 field of customFields">
<pngx-input-text [formControlName]="'custom-field-' + field.id" *ngIf="field.type === PaperlessCustomFieldDataType.String" [title]="field.name" [horizontal]="true"></pngx-input-text>
<pngx-input-date [formControlName]="'custom-field-' + field.id" *ngIf="field.type === PaperlessCustomFieldDataType.Date" [title]="field.name" [horizontal]="true"></pngx-input-date>
<ng-container *ngFor="let field of customFields; let i = index">
<div [formGroup]="customFieldFormFields.controls[i]">
<pngx-input-text formControlName="value" *ngIf="field.data_type === PaperlessCustomFieldDataType.String" [title]="field.name" [horizontal]="true"></pngx-input-text>
<pngx-input-date formControlName="value" *ngIf="field.data_type === PaperlessCustomFieldDataType.Date" [title]="field.name" [horizontal]="true"></pngx-input-date>
<pngx-input-number formControlName="value" *ngIf="field.data_type === PaperlessCustomFieldDataType.Integer" [title]="field.name" [horizontal]="true" [showAdd]="false"></pngx-input-number>
</div>
</ng-container>
</div>
</ng-template>

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,
@ -124,6 +124,7 @@ export class DocumentDetailComponent
archive_serial_number: new FormControl(),
tags: new FormControl([]),
permissions_form: new FormControl(null),
custom_fields: new FormArray([]),
})
previewCurrentPage: number = 1
@ -140,7 +141,7 @@ export class DocumentDetailComponent
ogDate: Date
public readonly PaperlessCustomFieldDataType = PaperlessCustomFieldDataType
customFields: PaperlessCustomField[]
customFields: PaperlessCustomField[] = []
@ViewChild('nav') nav: NgbNav
@ViewChild('pdfPreview') set pdfPreview(element) {
@ -173,8 +174,7 @@ export class DocumentDetailComponent
private storagePathService: StoragePathService,
private permissionsService: PermissionsService,
private userService: UserService,
private http: HttpClient,
private customFieldsService: CustomFieldsService
private http: HttpClient
) {
super()
}
@ -332,6 +332,7 @@ export class DocumentDetailComponent
owner: doc.owner,
set_permissions: doc.permissions,
},
custom_fields: doc.custom_fields,
})
this.isDirty$ = dirtyCheck(
@ -393,29 +394,7 @@ export class DocumentDetailComponent
updateComponent(doc: PaperlessDocument) {
this.document = doc
this.requiresPassword = false
// TODO: Custom fields
// this.customFieldsService
// .getFields(doc.id)
// .pipe(first())
// .subscribe({
// next: (fields) => {
// this.customFields = fields
// this.customFields.forEach((field) => {
// this.documentForm.addControl(
// `custom-field-${field.id}`,
// new FormControl(field.data)
// )
// })
// this.store.next(this.documentForm.value)
// console.log(fields)
// },
// error: (error) => {
// this.toastService.showError(
// $localize`Error retrieving custom fields`,
// error
// )
// },
// })
this.updateFormForCustomFields()
this.documentsService
.getMetadata(doc.id)
.pipe(first())
@ -464,6 +443,22 @@ export class DocumentDetailComponent
if (!this.userCanEdit) this.documentForm.disable()
}
get customFieldFormFields(): FormArray {
return this.documentForm.get('custom_fields') as FormArray
}
updateFormForCustomFields() {
this.customFieldFormFields.clear()
this.customFields.forEach((field) => {
this.customFieldFormFields.push(
new FormGroup({
parent: new FormControl(field.id),
value: new FormControl(null),
})
)
})
}
createDocumentType(newName: string) {
var modal = this.modalService.open(DocumentTypeEditDialogComponent, {
backdrop: 'static',
@ -543,11 +538,8 @@ export class DocumentDetailComponent
this.title = doc.title
this.documentForm.patchValue(doc)
// TODO: custom field reset
// this.customFields.forEach((field) => {
// this.documentForm
// .get(`custom-field-${field.id}`)
// .patchValue(field.data)
// })
this.customFields = doc.custom_fields
this.updateFormForCustomFields()
this.openDocumentService.setDirty(doc, false)
},
error: () => {
@ -856,4 +848,9 @@ export class DocumentDetailComponent
this.documentListViewService.quickFilter(filterRules)
}
addField(field: PaperlessCustomField) {
this.customFields.push(field)
this.updateFormForCustomFields()
}
}

View File

@ -0,0 +1,7 @@
import { ObjectWithId } from './object-with-id'
export interface PaperlessCustomFieldInstance extends ObjectWithId {
document: number // PaperlessDocument
field: number // PaperlessCustomField
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 { PaperlessCustomField } from './paperless-custom-field'
export interface SearchHit {
score?: number
@ -58,4 +59,6 @@ export interface PaperlessDocument extends ObjectWithPermissions {
notes?: PaperlessDocumentNote[]
__search_hit__?: SearchHit
custom_fields?: PaperlessCustomField[]
}

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

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

@ -3,6 +3,7 @@ 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',
@ -11,30 +12,4 @@ export class CustomFieldsService extends AbstractPaperlessService<PaperlessCusto
constructor(http: HttpClient) {
super(http, 'custom_fields')
}
// getFields(documentId: number): Observable<PaperlessCustomField[]> {
// return this.http.get<PaperlessCustomField[]>(
// this.getResourceUrl(documentId, 'custom_fields')
// )
// }
// addField(
// documentId: number,
// field: PaperlessCustomField
// ): Observable<PaperlessCustomField[]> {
// return this.http.post<PaperlessCustomField[]>(
// this.getResourceUrl(documentId, 'custom_fields'),
// field
// )
// }
// deleteField(
// documentId: number,
// fieldId: number
// ): Observable<PaperlessCustomField[]> {
// return this.http.delete<PaperlessCustomField[]>(
// this.getResourceUrl(documentId, 'custom_metadata'),
// { params: new HttpParams({ fromString: `id=${fieldId}` }) }
// )
// }
}