]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: allow create objects from bulk edit (#5667)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Tue, 6 Feb 2024 15:31:07 +0000 (07:31 -0800)
committerGitHub <noreply@github.com>
Tue, 6 Feb 2024 15:31:07 +0000 (15:31 +0000)
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.html
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.ts
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts

index 599faa988cd07f0404632cbb08ad6b9435f59a81..cac21771673d120e7f6a434c6ee4ea3b22cb6726 100644 (file)
         </div>
       }
       @if (editing) {
-        <button class="list-group-item list-group-item-action bg-light" (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 ((selectionModel.itemsSorted | filter: filterText).length === 0 && createRef !== undefined) {
+          <button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
+            <small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
+            <i-bs width="1.5em" height="1em" name="plus"></i-bs>
+          </button>
+        }
+        @if ((selectionModel.itemsSorted | filter: filterText).length > 0) {
+          <button class="list-group-item list-group-item-action bg-light" (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 (!editing && manyToOne) {
         <div class="list-group-item list-group-item-note pt-1 pb-2">
index f88667f3436e3ef84e67b47ca2f2eecdc27058e3..58aa029ee51e668bba512634cb67ec4ed3914b77 100644 (file)
@@ -500,4 +500,46 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
     selectionModel.apply()
     expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]])
   })
+
+  it('should set support create, keep open model and call createRef method', fakeAsync(() => {
+    component.items = items
+    component.icon = 'tag-fill'
+    component.selectionModel = selectionModel
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    tick(100)
+
+    component.filterText = 'Test Filter Text'
+    component.createRef = jest.fn()
+    component.createClicked()
+    expect(component.creating).toBeTruthy()
+    expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
+    const openSpy = jest.spyOn(component.dropdown, 'open')
+    component.dropdownOpenChange(false)
+    expect(openSpy).toHaveBeenCalled() // should keep open
+  }))
+
+  it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
+    component.items = items
+    component.icon = 'tag-fill'
+    component.editing = true
+    component.createRef = jest.fn()
+    const createSpy = jest.spyOn(component, 'createClicked')
+    expect(component.selectionModel.getSelectedItems()).toEqual([])
+    fixture.nativeElement
+      .querySelector('button')
+      .dispatchEvent(new MouseEvent('click')) // open
+    fixture.detectChanges()
+    tick(100)
+    component.filterText = 'FooBar'
+    fixture.detectChanges()
+    component.listFilterTextInput.nativeElement.dispatchEvent(
+      new KeyboardEvent('keyup', { key: 'Enter' })
+    )
+    expect(component.selectionModel.getSelectedItems()).toEqual([])
+    tick(300)
+    expect(createSpy).toHaveBeenCalled()
+  }))
 })
index 26b036db9e9fedda02980a9ac5a9ccc338dc0bc1..bb1a9da2715a556b8f0ba2842523dbbc4c6a8bdf 100644 (file)
@@ -398,6 +398,11 @@ export class FilterableDropdownComponent {
   @Input()
   disabled = false
 
+  @Input()
+  createRef: (name) => void
+
+  creating: boolean = false
+
   @Output()
   apply = new EventEmitter<ChangedItems>()
 
@@ -437,6 +442,11 @@ export class FilterableDropdownComponent {
     }
   }
 
+  createClicked() {
+    this.creating = true
+    this.createRef(this.filterText)
+  }
+
   dropdownOpenChange(open: boolean): void {
     if (open) {
       setTimeout(() => {
@@ -448,9 +458,14 @@ export class FilterableDropdownComponent {
       }
       this.opened.next(this)
     } else {
-      this.filterText = ''
-      if (this.applyOnClose && this.selectionModel.isDirty()) {
-        this.apply.emit(this.selectionModel.diff())
+      if (this.creating) {
+        this.dropdown.open()
+        this.creating = false
+      } else {
+        this.filterText = ''
+        if (this.applyOnClose && this.selectionModel.isDirty()) {
+          this.apply.emit(this.selectionModel.diff())
+        }
       }
     }
   }
@@ -466,6 +481,8 @@ export class FilterableDropdownComponent {
           this.dropdown.close()
         }
       }, 200)
+    } else if (filtered.length == 0 && this.createRef) {
+      this.createClicked()
     }
   }
 
index 0c261df67f3d1bdc75b181b10ba8648e3a09e2e9..686c07bb37fbbaee3e07fd08b5c95f5a85321dc9 100644 (file)
@@ -25,6 +25,7 @@
               [editing]="true"
               [manyToOne]="true"
               [applyOnClose]="applyOnClose"
+              [createRef]="createTag.bind(this)"
               (opened)="openTagsDropdown()"
               [(selectionModel)]="tagSelectionModel"
               [documentCounts]="tagDocumentCounts"
@@ -38,6 +39,7 @@
               [disabled]="!userCanEditAll"
               [editing]="true"
               [applyOnClose]="applyOnClose"
+              [createRef]="createCorrespondent.bind(this)"
               (opened)="openCorrespondentDropdown()"
               [(selectionModel)]="correspondentSelectionModel"
               [documentCounts]="correspondentDocumentCounts"
@@ -51,6 +53,7 @@
               [disabled]="!userCanEditAll"
               [editing]="true"
               [applyOnClose]="applyOnClose"
+              [createRef]="createDocumentType.bind(this)"
               (opened)="openDocumentTypeDropdown()"
               [(selectionModel)]="documentTypeSelectionModel"
               [documentCounts]="documentTypeDocumentCounts"
@@ -64,6 +67,7 @@
               [disabled]="!userCanEditAll"
               [editing]="true"
               [applyOnClose]="applyOnClose"
+              [createRef]="createStoragePath.bind(this)"
               (opened)="openStoragePathDropdown()"
               [(selectionModel)]="storagePathsSelectionModel"
               [documentCounts]="storagePathDocumentCounts"
index 42f8b6d1d232cd962424b28d1483b0501b091bd7..4da9f36df9eb50485f980a810003b9b3c7e990c4 100644 (file)
@@ -42,6 +42,16 @@ import { NgSelectModule } from '@ng-select/ng-select'
 import { GroupService } from 'src/app/services/rest/group.service'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 import { SwitchComponent } from '../../common/input/switch/switch.component'
+import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
+import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
+import { Results } from 'src/app/data/results'
+import { Tag } from 'src/app/data/tag'
+import { Correspondent } from 'src/app/data/correspondent'
+import { DocumentType } from 'src/app/data/document-type'
+import { StoragePath } from 'src/app/data/storage-path'
+import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
+import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
+import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
 
 const selectionData: SelectionData = {
   selected_tags: [
@@ -65,6 +75,10 @@ describe('BulkEditorComponent', () => {
   let documentService: DocumentService
   let toastService: ToastService
   let modalService: NgbModal
+  let tagService: TagService
+  let correspondentsService: CorrespondentService
+  let documentTypeService: DocumentTypeService
+  let storagePathService: StoragePathService
   let httpTestingController: HttpTestingController
 
   beforeEach(async () => {
@@ -165,6 +179,10 @@ describe('BulkEditorComponent', () => {
     documentService = TestBed.inject(DocumentService)
     toastService = TestBed.inject(ToastService)
     modalService = TestBed.inject(NgbModal)
+    tagService = TestBed.inject(TagService)
+    correspondentsService = TestBed.inject(CorrespondentService)
+    documentTypeService = TestBed.inject(DocumentTypeService)
+    storagePathService = TestBed.inject(StoragePathService)
     httpTestingController = TestBed.inject(HttpTestingController)
 
     fixture = TestBed.createComponent(BulkEditorComponent)
@@ -902,4 +920,180 @@ describe('BulkEditorComponent', () => {
       `${environment.apiBaseUrl}documents/storage_paths/`
     )
   })
+
+  it('should support create new tag', () => {
+    const name = 'New Tag'
+    const newTag = { id: 101, name: 'New Tag' }
+    const tags: Results<Tag> = {
+      results: [
+        { id: 1, name: 'Tag 1' },
+        { id: 2, name: 'Tag 2' },
+      ],
+      count: 2,
+      all: [1, 2],
+    }
+
+    const modalInstance = {
+      componentInstance: {
+        dialogMode: EditDialogMode.CREATE,
+        object: { name },
+        succeeded: of(newTag),
+      },
+    }
+    const tagListAllSpy = jest.spyOn(tagService, 'listAll')
+    tagListAllSpy.mockReturnValue(of(tags))
+
+    const tagSelectionModelToggleSpy = jest.spyOn(
+      component.tagSelectionModel,
+      'toggle'
+    )
+
+    const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
+    modalServiceOpenSpy.mockReturnValue(modalInstance as any)
+
+    component.createTag(name)
+
+    expect(modalServiceOpenSpy).toHaveBeenCalledWith(TagEditDialogComponent, {
+      backdrop: 'static',
+    })
+    expect(tagListAllSpy).toHaveBeenCalled()
+
+    expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
+    expect(component.tags).toEqual(tags.results)
+  })
+
+  it('should support create new correspondent', () => {
+    const name = 'New Correspondent'
+    const newCorrespondent = { id: 101, name: 'New Correspondent' }
+    const correspondents: Results<Correspondent> = {
+      results: [
+        { id: 1, name: 'Correspondent 1' },
+        { id: 2, name: 'Correspondent 2' },
+      ],
+      count: 2,
+      all: [1, 2],
+    }
+
+    const modalInstance = {
+      componentInstance: {
+        dialogMode: EditDialogMode.CREATE,
+        object: { name },
+        succeeded: of(newCorrespondent),
+      },
+    }
+    const correspondentsListAllSpy = jest.spyOn(
+      correspondentsService,
+      'listAll'
+    )
+    correspondentsListAllSpy.mockReturnValue(of(correspondents))
+
+    const correspondentSelectionModelToggleSpy = jest.spyOn(
+      component.correspondentSelectionModel,
+      'toggle'
+    )
+
+    const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
+    modalServiceOpenSpy.mockReturnValue(modalInstance as any)
+
+    component.createCorrespondent(name)
+
+    expect(modalServiceOpenSpy).toHaveBeenCalledWith(
+      CorrespondentEditDialogComponent,
+      { backdrop: 'static' }
+    )
+    expect(correspondentsListAllSpy).toHaveBeenCalled()
+
+    expect(correspondentSelectionModelToggleSpy).toHaveBeenCalledWith(
+      newCorrespondent.id
+    )
+    expect(component.correspondents).toEqual(correspondents.results)
+  })
+
+  it('should support create new document type', () => {
+    const name = 'New Document Type'
+    const newDocumentType = { id: 101, name: 'New Document Type' }
+    const documentTypes: Results<DocumentType> = {
+      results: [
+        { id: 1, name: 'Document Type 1' },
+        { id: 2, name: 'Document Type 2' },
+      ],
+      count: 2,
+      all: [1, 2],
+    }
+
+    const modalInstance = {
+      componentInstance: {
+        dialogMode: EditDialogMode.CREATE,
+        object: { name },
+        succeeded: of(newDocumentType),
+      },
+    }
+    const documentTypesListAllSpy = jest.spyOn(documentTypeService, 'listAll')
+    documentTypesListAllSpy.mockReturnValue(of(documentTypes))
+
+    const documentTypeSelectionModelToggleSpy = jest.spyOn(
+      component.documentTypeSelectionModel,
+      'toggle'
+    )
+
+    const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
+    modalServiceOpenSpy.mockReturnValue(modalInstance as any)
+
+    component.createDocumentType(name)
+
+    expect(modalServiceOpenSpy).toHaveBeenCalledWith(
+      DocumentTypeEditDialogComponent,
+      { backdrop: 'static' }
+    )
+    expect(documentTypesListAllSpy).toHaveBeenCalled()
+
+    expect(documentTypeSelectionModelToggleSpy).toHaveBeenCalledWith(
+      newDocumentType.id
+    )
+    expect(component.documentTypes).toEqual(documentTypes.results)
+  })
+
+  it('should support create new storage path', () => {
+    const name = 'New Storage Path'
+    const newStoragePath = { id: 101, name: 'New Storage Path' }
+    const storagePaths: Results<StoragePath> = {
+      results: [
+        { id: 1, name: 'Storage Path 1' },
+        { id: 2, name: 'Storage Path 2' },
+      ],
+      count: 2,
+      all: [1, 2],
+    }
+
+    const modalInstance = {
+      componentInstance: {
+        dialogMode: EditDialogMode.CREATE,
+        object: { name },
+        succeeded: of(newStoragePath),
+      },
+    }
+    const storagePathsListAllSpy = jest.spyOn(storagePathService, 'listAll')
+    storagePathsListAllSpy.mockReturnValue(of(storagePaths))
+
+    const storagePathsSelectionModelToggleSpy = jest.spyOn(
+      component.storagePathsSelectionModel,
+      'toggle'
+    )
+
+    const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
+    modalServiceOpenSpy.mockReturnValue(modalInstance as any)
+
+    component.createStoragePath(name)
+
+    expect(modalServiceOpenSpy).toHaveBeenCalledWith(
+      StoragePathEditDialogComponent,
+      { backdrop: 'static' }
+    )
+    expect(storagePathsListAllSpy).toHaveBeenCalled()
+
+    expect(storagePathsSelectionModelToggleSpy).toHaveBeenCalledWith(
+      newStoragePath.id
+    )
+    expect(component.storagePaths).toEqual(storagePaths.results)
+  })
 })
index 49d4c070fb0bde36e74cf34d399aed75adc1850d..0bfb287cbe28726914a9ef435f4eb8c9ee7aa5a6 100644 (file)
@@ -33,7 +33,12 @@ import {
   PermissionType,
 } from 'src/app/services/permissions.service'
 import { FormControl, FormGroup } from '@angular/forms'
-import { first, Subject, takeUntil } from 'rxjs'
+import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
+import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
+import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
+import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
+import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
+import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
 
 @Component({
   selector: 'pngx-bulk-editor',
@@ -479,6 +484,92 @@ export class BulkEditorComponent
     }
   }
 
+  createTag(name: string) {
+    let modal = this.modalService.open(TagEditDialogComponent, {
+      backdrop: 'static',
+    })
+    modal.componentInstance.dialogMode = EditDialogMode.CREATE
+    modal.componentInstance.object = { name }
+    modal.componentInstance.succeeded
+      .pipe(
+        switchMap((newTag) => {
+          return this.tagService
+            .listAll()
+            .pipe(map((tags) => ({ newTag, tags })))
+        })
+      )
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(({ newTag, tags }) => {
+        this.tags = tags.results
+        this.tagSelectionModel.toggle(newTag.id)
+      })
+  }
+
+  createCorrespondent(name: string) {
+    let modal = this.modalService.open(CorrespondentEditDialogComponent, {
+      backdrop: 'static',
+    })
+    modal.componentInstance.dialogMode = EditDialogMode.CREATE
+    modal.componentInstance.object = { name }
+    modal.componentInstance.succeeded
+      .pipe(
+        switchMap((newCorrespondent) => {
+          return this.correspondentService
+            .listAll()
+            .pipe(
+              map((correspondents) => ({ newCorrespondent, correspondents }))
+            )
+        })
+      )
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(({ newCorrespondent, correspondents }) => {
+        this.correspondents = correspondents.results
+        this.correspondentSelectionModel.toggle(newCorrespondent.id)
+      })
+  }
+
+  createDocumentType(name: string) {
+    let modal = this.modalService.open(DocumentTypeEditDialogComponent, {
+      backdrop: 'static',
+    })
+    modal.componentInstance.dialogMode = EditDialogMode.CREATE
+    modal.componentInstance.object = { name }
+    modal.componentInstance.succeeded
+      .pipe(
+        switchMap((newDocumentType) => {
+          return this.documentTypeService
+            .listAll()
+            .pipe(map((documentTypes) => ({ newDocumentType, documentTypes })))
+        })
+      )
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(({ newDocumentType, documentTypes }) => {
+        this.documentTypes = documentTypes.results
+        this.documentTypeSelectionModel.toggle(newDocumentType.id)
+      })
+  }
+
+  createStoragePath(name: string) {
+    let modal = this.modalService.open(StoragePathEditDialogComponent, {
+      backdrop: 'static',
+    })
+    modal.componentInstance.dialogMode = EditDialogMode.CREATE
+    modal.componentInstance.object = { name }
+    modal.componentInstance.succeeded
+      .pipe(
+        switchMap((newStoragePath) => {
+          return this.storagePathService
+            .listAll()
+            .pipe(map((storagePaths) => ({ newStoragePath, storagePaths })))
+        })
+      )
+      .pipe(takeUntil(this.unsubscribeNotifier))
+      .subscribe(({ newStoragePath, storagePaths }) => {
+        this.storagePaths = storagePaths.results
+        this.storagePathsSelectionModel.toggle(newStoragePath.id)
+      })
+  }
+
   applyDelete() {
     let modal = this.modalService.open(ConfirmDialogComponent, {
       backdrop: 'static',