Feature: bulk edit custom field values (#8428)

This commit is contained in:
shamoon
2024-12-09 09:35:49 -08:00
committed by GitHub
parent 8574d28c6f
commit e4f69dc945
18 changed files with 709 additions and 105 deletions

View File

@@ -90,6 +90,9 @@
(opened)="openCustomFieldsDropdown()"
[(selectionModel)]="customFieldsSelectionModel"
[documentCounts]="customFieldDocumentCounts"
extraButtonTitle="Set values"
i18n-extraButtonTitle
(extraButton)="setCustomFieldValues($event)"
(apply)="setCustomFields($event)">
</pngx-filterable-dropdown>
}

View File

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

View File

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

View File

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

View File

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

View File

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