mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Feature: bulk edit custom field values (#8428)
This commit is contained in:
@@ -133,6 +133,7 @@ import { DeletePagesConfirmDialogComponent } from './components/common/confirm-d
|
||||
import { TrashComponent } from './components/admin/trash/trash.component'
|
||||
import { EntriesComponent } from './components/common/input/entries/entries.component'
|
||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||
import { CustomFieldsBulkEditDialogComponent } from './components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||
import {
|
||||
airplane,
|
||||
archive,
|
||||
@@ -528,6 +529,7 @@ function initializeApp(settings: SettingsService) {
|
||||
TrashComponent,
|
||||
EntriesComponent,
|
||||
SavedViewsComponent,
|
||||
CustomFieldsBulkEditDialogComponent,
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
imports: [
|
||||
|
@@ -60,12 +60,18 @@
|
||||
</button>
|
||||
}
|
||||
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
|
||||
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
@if (extraButtonTitle) {
|
||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
|
||||
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
|
||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||
</button>
|
||||
}
|
||||
@if (!editing && manyToOne) {
|
||||
<div class="list-group-item list-group-item-note pt-1 pb-2">
|
||||
<small i18n>Click again to exclude items.</small>
|
||||
|
@@ -616,4 +616,24 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 't' }))
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support an extra button and not apply changes when clicked', () => {
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.extraButtonTitle = 'Extra'
|
||||
component.selectionModel = selectionModel
|
||||
component.applyOnClose = true
|
||||
let extraButtonClicked,
|
||||
applied = false
|
||||
component.extraButton.subscribe(() => (extraButtonClicked = true))
|
||||
component.apply.subscribe(() => (applied = true))
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
.dispatchEvent(new MouseEvent('click')) // open
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.nativeElement.textContent).toContain('Extra')
|
||||
component.extraButtonClicked()
|
||||
expect(extraButtonClicked).toBeTruthy()
|
||||
expect(applied).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
@@ -437,21 +437,6 @@ export class FilterableDropdownComponent
|
||||
@Input()
|
||||
createRef: (name) => void
|
||||
|
||||
creating: boolean = false
|
||||
|
||||
@Output()
|
||||
apply = new EventEmitter<ChangedItems>()
|
||||
|
||||
@Output()
|
||||
opened = new EventEmitter()
|
||||
|
||||
get modifierToggleEnabled(): boolean {
|
||||
return this.manyToOne
|
||||
? this.selectionModel.selectionSize() > 1 &&
|
||||
this.selectionModel.getExcludedItems().length == 0
|
||||
: !this.selectionModel.isNoneSelected()
|
||||
}
|
||||
|
||||
@Input()
|
||||
set documentCounts(counts: SelectionDataItem[]) {
|
||||
if (counts) {
|
||||
@@ -462,6 +447,27 @@ export class FilterableDropdownComponent
|
||||
@Input()
|
||||
shortcutKey: string
|
||||
|
||||
@Input()
|
||||
extraButtonTitle: string
|
||||
|
||||
creating: boolean = false
|
||||
|
||||
@Output()
|
||||
apply = new EventEmitter<ChangedItems>()
|
||||
|
||||
@Output()
|
||||
opened = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
extraButton = new EventEmitter<ChangedItems>()
|
||||
|
||||
get modifierToggleEnabled(): boolean {
|
||||
return this.manyToOne
|
||||
? this.selectionModel.selectionSize() > 1 &&
|
||||
this.selectionModel.getExcludedItems().length == 0
|
||||
: !this.selectionModel.isNoneSelected()
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||
}
|
||||
@@ -641,4 +647,13 @@ export class FilterableDropdownComponent
|
||||
this.selectionModel.get(item.id) !== ToggleableItemState.Selected
|
||||
)
|
||||
}
|
||||
|
||||
extraButtonClicked() {
|
||||
// don't apply changes when clicking the extra button
|
||||
const applyOnClose = this.applyOnClose
|
||||
this.applyOnClose = false
|
||||
this.dropdown.close()
|
||||
this.extraButton.emit(this.selectionModel.diff())
|
||||
this.applyOnClose = applyOnClose
|
||||
}
|
||||
}
|
||||
|
@@ -18,7 +18,7 @@
|
||||
<div class="ms-n2 me-1">
|
||||
<i-bs name="grip-vertical"></i-bs>
|
||||
</div>
|
||||
<h6 class="card-title mb-0" i18n></h6>
|
||||
<h6 class="card-title mb-0"></h6>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -90,6 +90,9 @@
|
||||
(opened)="openCustomFieldsDropdown()"
|
||||
[(selectionModel)]="customFieldsSelectionModel"
|
||||
[documentCounts]="customFieldDocumentCounts"
|
||||
extraButtonTitle="Set values"
|
||||
i18n-extraButtonTitle
|
||||
(extraButton)="setCustomFieldValues($event)"
|
||||
(apply)="setCustomFields($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
|
@@ -1416,4 +1416,55 @@ describe('BulkEditorComponent', () => {
|
||||
)
|
||||
expect(component.customFields).toEqual(customFields.results)
|
||||
})
|
||||
|
||||
it('should open the bulk edit custom field values dialog with correct parameters', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||
of({
|
||||
all: [3, 4],
|
||||
count: 2,
|
||||
results: [{ id: 3 }, { id: 4 }],
|
||||
})
|
||||
)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
fixture.detectChanges()
|
||||
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
|
||||
|
||||
component.customFields = [
|
||||
{ id: 1, name: 'Custom Field 1', data_type: CustomFieldDataType.String },
|
||||
{ id: 2, name: 'Custom Field 2', data_type: CustomFieldDataType.String },
|
||||
]
|
||||
|
||||
component.setCustomFieldValues({
|
||||
itemsToAdd: [{ id: 1 }, { id: 2 }],
|
||||
itemsToRemove: [1],
|
||||
} as any)
|
||||
|
||||
expect(modal.componentInstance.customFields).toEqual(component.customFields)
|
||||
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
|
||||
expect(modal.componentInstance.documents).toEqual([3, 4])
|
||||
|
||||
modal.componentInstance.failed.emit()
|
||||
expect(toastServiceShowErrorSpy).toHaveBeenCalled()
|
||||
expect(listReloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
modal.componentInstance.succeeded.emit()
|
||||
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
||||
expect(listReloadSpy).toHaveBeenCalled()
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
})
|
||||
})
|
||||
|
@@ -44,6 +44,7 @@ import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-c
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-bulk-editor',
|
||||
@@ -826,4 +827,38 @@ export class BulkEditorComponent
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
public setCustomFieldValues(changedCustomFields: ChangedItems) {
|
||||
const modal = this.modalService.open(CustomFieldsBulkEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'lg',
|
||||
})
|
||||
const dialog =
|
||||
modal.componentInstance as CustomFieldsBulkEditDialogComponent
|
||||
dialog.customFields = this.customFields
|
||||
dialog.fieldsToAddIds = changedCustomFields.itemsToAdd.map(
|
||||
(item) => item.id
|
||||
)
|
||||
dialog.fieldsToRemoveIds = changedCustomFields.itemsToRemove.map(
|
||||
(item) => item.id
|
||||
)
|
||||
|
||||
dialog.documents = Array.from(this.list.selected)
|
||||
dialog.succeeded.subscribe((result) => {
|
||||
this.toastService.showInfo(
|
||||
$localize`Bulk operation executed successfully`
|
||||
)
|
||||
this.list.reload()
|
||||
this.list.reduceSelectionToFilter()
|
||||
this.list.selected.forEach((id) => {
|
||||
this.openDocumentService.refreshDocument(id)
|
||||
})
|
||||
})
|
||||
dialog.failed.subscribe((error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error executing bulk operation`,
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -0,0 +1,81 @@
|
||||
<form [formGroup]="form" (ngSubmit)="save()" autocomplete="off">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title" i8n>{
|
||||
documents.length,
|
||||
plural,
|
||||
=1 {Set custom fields for 1 document} other {Set custom fields for {{documents.length}} documents}
|
||||
}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pngx-input-select i18n-title title="" multiple="true" [items]="customFields" [(ngModel)]="fieldsToAddIds"
|
||||
placeholder="Select custom fields" i18n-placeholder [ngModelOptions]="{standalone: true}">
|
||||
</pngx-input-select>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
@for (field of fieldsToAdd; track field.id) {
|
||||
<div class="d-flex gap-2">
|
||||
@switch (field.data_type) {
|
||||
@case (CustomFieldDataType.String) {
|
||||
<pngx-input-text formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
|
||||
</pngx-input-text>
|
||||
}
|
||||
@case (CustomFieldDataType.Date) {
|
||||
<pngx-input-date formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
|
||||
</pngx-input-date>
|
||||
}
|
||||
@case (CustomFieldDataType.Integer) {
|
||||
<pngx-input-number formControlName="{{field.id}}" class="w-100" [title]="field.name" [showAdd]="false"
|
||||
[horizontal]="true">
|
||||
</pngx-input-number>
|
||||
}
|
||||
@case (CustomFieldDataType.Float) {
|
||||
<pngx-input-number formControlName="{{field.id}}" class="w-100" [title]="field.name" [showAdd]="false"
|
||||
[step]=".1" [horizontal]="true">
|
||||
</pngx-input-number>
|
||||
}
|
||||
@case (CustomFieldDataType.Monetary) {
|
||||
<pngx-input-monetary formControlName="{{field.id}}" class="w-100" [title]="field.name"
|
||||
[defaultCurrency]="field.extra_data?.default_currency" [horizontal]="true">
|
||||
</pngx-input-monetary>
|
||||
}
|
||||
@case (CustomFieldDataType.Boolean) {
|
||||
<pngx-input-check formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
|
||||
</pngx-input-check>
|
||||
}
|
||||
@case (CustomFieldDataType.Url) {
|
||||
<pngx-input-url formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
|
||||
</pngx-input-url>
|
||||
}
|
||||
@case (CustomFieldDataType.DocumentLink) {
|
||||
<pngx-input-document-link formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
|
||||
</pngx-input-document-link>
|
||||
}
|
||||
@case (CustomFieldDataType.Select) {
|
||||
<pngx-input-select formControlName="{{field.id}}" class="w-100" [title]="field.name"
|
||||
[items]="field.extra_data.select_options" bindLabel="label" [allowNull]="true" [horizontal]="true">
|
||||
</pngx-input-select>
|
||||
}
|
||||
}
|
||||
<button type="button" class="btn btn-outline-danger mb-3" (click)="removeField(field.id)">
|
||||
<i-bs name="x"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@if (fieldsToRemoveIds.length) {
|
||||
<p class="mb-0 small"><em i18n>{
|
||||
fieldsToRemoveIds.length,
|
||||
plural,
|
||||
=1 {This operation will also remove 1 custom field from the selected documents.} other {This operation will also
|
||||
remove {{fieldsToRemoveIds.length}} custom fields from the selected documents.}
|
||||
}</em></p>
|
||||
}
|
||||
<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 || fieldsToRemoveIds.length + fieldsToAddIds.length === 0">Save</button>
|
||||
</div>
|
||||
</form>
|
@@ -0,0 +1,89 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog.component'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { provideHttpClient } from '@angular/common/http'
|
||||
|
||||
describe('CustomFieldsBulkEditDialogComponent', () => {
|
||||
let component: CustomFieldsBulkEditDialogComponent
|
||||
let fixture: ComponentFixture<CustomFieldsBulkEditDialogComponent>
|
||||
let documentService: DocumentService
|
||||
let activeModal: NgbActiveModal
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [CustomFieldsBulkEditDialogComponent, SelectComponent],
|
||||
imports: [FormsModule, ReactiveFormsModule, NgbModule, NgSelectModule],
|
||||
providers: [
|
||||
NgbActiveModal,
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(CustomFieldsBulkEditDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
activeModal = TestBed.inject(NgbActiveModal)
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should initialize form controls based on selected field ids', () => {
|
||||
component.customFields = [
|
||||
{ id: 1, name: 'Field 1', data_type: CustomFieldDataType.String },
|
||||
{ id: 2, name: 'Field 2', data_type: CustomFieldDataType.Integer },
|
||||
]
|
||||
component.fieldsToAddIds = [1, 2]
|
||||
expect(component.form.contains('1')).toBeTruthy()
|
||||
expect(component.form.contains('2')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should emit succeeded event and close modal on successful save', () => {
|
||||
const editSpy = jest
|
||||
.spyOn(documentService, 'bulkEdit')
|
||||
.mockReturnValue(of('Success'))
|
||||
const successSpy = jest.spyOn(component.succeeded, 'emit')
|
||||
|
||||
component.documents = [1, 2]
|
||||
component.fieldsToAddIds = [1]
|
||||
component.form.controls['1'].setValue('Value 1')
|
||||
component.save()
|
||||
|
||||
expect(editSpy).toHaveBeenCalled()
|
||||
expect(successSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should emit failed event on save error', () => {
|
||||
const editSpy = jest
|
||||
.spyOn(documentService, 'bulkEdit')
|
||||
.mockReturnValue(throwError(new Error('Error')))
|
||||
const failSpy = jest.spyOn(component.failed, 'emit')
|
||||
|
||||
component.documents = [1, 2]
|
||||
component.fieldsToAddIds = [1]
|
||||
component.form.controls['1'].setValue('Value 1')
|
||||
component.save()
|
||||
|
||||
expect(editSpy).toHaveBeenCalled()
|
||||
expect(failSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close modal on cancel', () => {
|
||||
const activeModalSpy = jest.spyOn(activeModal, 'close')
|
||||
component.cancel()
|
||||
expect(activeModalSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove field from selected fields', () => {
|
||||
component.fieldsToAddIds = [1, 2]
|
||||
component.removeField(1)
|
||||
expect(component.fieldsToAddIds).toEqual([2])
|
||||
})
|
||||
})
|
@@ -0,0 +1,90 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core'
|
||||
import { FormControl, FormGroup } from '@angular/forms'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { first } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-fields-bulk-edit-dialog',
|
||||
templateUrl: './custom-fields-bulk-edit-dialog.component.html',
|
||||
styleUrl: './custom-fields-bulk-edit-dialog.component.scss',
|
||||
})
|
||||
export class CustomFieldsBulkEditDialogComponent {
|
||||
CustomFieldDataType = CustomFieldDataType
|
||||
|
||||
@Output()
|
||||
succeeded = new EventEmitter()
|
||||
|
||||
@Output()
|
||||
failed = new EventEmitter()
|
||||
|
||||
public networkActive = false
|
||||
|
||||
public customFields: CustomField[] = []
|
||||
|
||||
private _fieldsToAdd: CustomField[] = [] // static object for change detection
|
||||
public get fieldsToAdd() {
|
||||
return this._fieldsToAdd
|
||||
}
|
||||
|
||||
private _fieldsToAddIds: number[] = []
|
||||
public get fieldsToAddIds() {
|
||||
return this._fieldsToAddIds
|
||||
}
|
||||
public set fieldsToAddIds(ids: number[]) {
|
||||
this._fieldsToAddIds = ids
|
||||
this._fieldsToAdd = this.customFields.filter((field) =>
|
||||
this._fieldsToAddIds.includes(field.id)
|
||||
)
|
||||
this.initForm()
|
||||
}
|
||||
|
||||
public fieldsToRemoveIds: number[] = []
|
||||
|
||||
public form: FormGroup = new FormGroup({})
|
||||
|
||||
public documents: number[] = []
|
||||
|
||||
constructor(
|
||||
private activeModal: NgbActiveModal,
|
||||
private documentService: DocumentService
|
||||
) {}
|
||||
|
||||
initForm() {
|
||||
Object.keys(this.form.controls).forEach((key) => {
|
||||
if (!this._fieldsToAddIds.includes(parseInt(key))) {
|
||||
this.form.removeControl(key)
|
||||
}
|
||||
})
|
||||
this._fieldsToAddIds.forEach((field_id) => {
|
||||
this.form.addControl(field_id.toString(), new FormControl(null))
|
||||
})
|
||||
}
|
||||
|
||||
public save() {
|
||||
this.documentService
|
||||
.bulkEdit(this.documents, 'modify_custom_fields', {
|
||||
add_custom_fields: this.form.value,
|
||||
remove_custom_fields: this.fieldsToRemoveIds,
|
||||
})
|
||||
.pipe(first())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.activeModal.close()
|
||||
this.succeeded.emit()
|
||||
},
|
||||
error: (error) => {
|
||||
this.failed.emit(error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public cancel() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
|
||||
public removeField(fieldId: number) {
|
||||
this.fieldsToAddIds = this._fieldsToAddIds.filter((id) => id !== fieldId)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user