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

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

View File

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

View File

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

View File

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

View File

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

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