diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts index 68124d541..79880c75b 100644 --- a/src-ui/src/app/app.module.ts +++ b/src-ui/src/app/app.module.ts @@ -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: [ diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html index 28ce03ad6..4b514a6dd 100644 --- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html +++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html @@ -66,6 +66,11 @@ } } + @if (extraButtonTitle) { + + } @if (!editing && manyToOne) {
Click again to exclude items. diff --git a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts index 2351dc0da..b7f7e2815 100644 --- a/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts +++ b/src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts @@ -434,21 +434,6 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit { @Input() createRef: (name) => void - creating: boolean = false - - @Output() - apply = new EventEmitter() - - @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) { @@ -459,6 +444,27 @@ export class FilterableDropdownComponent implements OnDestroy, OnInit { @Input() shortcutKey: string + @Input() + extraButtonTitle: string + + creating: boolean = false + + @Output() + apply = new EventEmitter() + + @Output() + opened = new EventEmitter() + + @Output() + extraButton = new EventEmitter() + + 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 } diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html index 2b9a20f7e..31c1c3957 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html @@ -90,6 +90,9 @@ (opened)="openCustomFieldsDropdown()" [(selectionModel)]="customFieldsSelectionModel" [documentCounts]="customFieldDocumentCounts" + extraButtonTitle="Set values" + i18n-extraButtonTitle + (extraButton)="setCustomFieldValues()" (apply)="setCustomFields($event)"> } diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts index 0dd056cfd..17cc86f30 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts @@ -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 }, + ] + jest + .spyOn(component.customFieldsSelectionModel, 'getSelectedItems') + .mockReturnValue([{ id: 1 }, { id: 2 }]) + + component.setCustomFieldValues() + + expect(modal.componentInstance.customFields).toEqual(component.customFields) + expect(modal.componentInstance.selectedFieldsIds).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 + }) }) diff --git a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts index 6892cc823..580392e00 100644 --- a/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts +++ b/src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts @@ -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,34 @@ export class BulkEditorComponent ) }) } + + public setCustomFieldValues() { + const modal = this.modalService.open(CustomFieldsBulkEditDialogComponent, { + backdrop: 'static', + size: 'lg', + }) + const dialog = + modal.componentInstance as CustomFieldsBulkEditDialogComponent + dialog.customFields = this.customFields + dialog.selectedFieldsIds = this.customFieldsSelectionModel + .getSelectedItems() + .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 + ) + }) + } } diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html new file mode 100644 index 000000000..519fceccf --- /dev/null +++ b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.html @@ -0,0 +1,32 @@ +
+ + + +
diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.scss b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts new file mode 100644 index 000000000..4ead91010 --- /dev/null +++ b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.spec.ts @@ -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 + 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.selectedFieldsIds = [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.selectedFieldsIds = [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.selectedFieldsIds = [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.selectedFieldsIds = [1, 2] + component.removeField(1) + expect(component.selectedFieldsIds).toEqual([2]) + }) +}) diff --git a/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts new file mode 100644 index 000000000..ce07045ec --- /dev/null +++ b/src-ui/src/app/components/document-list/bulk-editor/custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component.ts @@ -0,0 +1,85 @@ +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 } 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 { + @Output() + succeeded = new EventEmitter() + + @Output() + failed = new EventEmitter() + + public networkActive = false + + public customFields: CustomField[] = [] + + private _selectedFields: CustomField[] = [] // static object for change detection + public get selectedFields() { + return this._selectedFields + } + + private _selectedFieldIds: number[] = [] + public get selectedFieldsIds() { + return this._selectedFieldIds + } + public set selectedFieldsIds(ids: number[]) { + this._selectedFieldIds = ids + this._selectedFields = this.customFields.filter((field) => + this._selectedFieldIds.includes(field.id) + ) + this.initForm() + } + + public form: FormGroup = new FormGroup({}) + + public documents: number[] + + constructor( + private activeModal: NgbActiveModal, + private documentService: DocumentService + ) {} + + initForm() { + this.form = new FormGroup({}) + this._selectedFieldIds.forEach((field_id) => { + this.form.addControl(field_id.toString(), new FormControl(null)) + }) + } + + public save() { + console.log('save', this.form.value) + this.documentService + .bulkEdit(this.documents, 'modify_custom_fields', { + add_custom_fields: this.form.value, + remove_custom_fields: [], + }) + .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._selectedFieldIds = this._selectedFieldIds.filter( + (id) => id !== fieldId + ) + } +}