mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Merge branch 'dev'
This commit is contained in:
		@@ -309,10 +309,15 @@ describe('SettingsComponent', () => {
 | 
			
		||||
    component.store.getValue()['displayLanguage'] = 'en-US'
 | 
			
		||||
    component.store.getValue()['updateCheckingEnabled'] = false
 | 
			
		||||
    component.settingsForm.value.displayLanguage = 'en-GB'
 | 
			
		||||
    component.settingsForm.value.updateCheckingEnabled = true
 | 
			
		||||
    jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true))
 | 
			
		||||
    jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
 | 
			
		||||
    component.saveSettings()
 | 
			
		||||
    expect(toast.actionName).toEqual('Reload now')
 | 
			
		||||
 | 
			
		||||
    component.settingsForm.value.updateCheckingEnabled = true
 | 
			
		||||
    component.saveSettings()
 | 
			
		||||
 | 
			
		||||
    expect(toast.actionName).toEqual('Reload now')
 | 
			
		||||
    toast.action()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should allow setting theme color, visually apply change immediately but not save', () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -4,16 +4,16 @@
 | 
			
		||||
    (click)="isMenuCollapsed = !isMenuCollapsed">
 | 
			
		||||
    <span class="navbar-toggler-icon"></span>
 | 
			
		||||
  </button>
 | 
			
		||||
  <a class="navbar-brand d-flex col-auto col-md-3 col-lg-2 me-0 px-3 py-3 order-sm-0"
 | 
			
		||||
    [ngClass]="{ 'slim': slimSidebarEnabled, 'd-flex col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
 | 
			
		||||
  <a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
 | 
			
		||||
    [ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
 | 
			
		||||
    routerLink="/dashboard"
 | 
			
		||||
    tourAnchor="tour.intro">
 | 
			
		||||
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" class="me-2" fill="currentColor">
 | 
			
		||||
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" height="1.5em" fill="currentColor">
 | 
			
		||||
      <path
 | 
			
		||||
        d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z"
 | 
			
		||||
        transform="translate(0 0)" />
 | 
			
		||||
    </svg>
 | 
			
		||||
    <div class="ms-2 d-inline-block" [class.visually-hidden]="slimSidebarEnabled">
 | 
			
		||||
    <div class="ms-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
 | 
			
		||||
      @if (customAppTitle?.length) {
 | 
			
		||||
        <div class="d-flex flex-column align-items-start">
 | 
			
		||||
          <span class="title">{{customAppTitle}}</span>
 | 
			
		||||
 
 | 
			
		||||
@@ -493,12 +493,17 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
    expect(changedResult.getExcludedItems()).toEqual(items)
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('FilterableDropdownSelectionModel should sort items by state', () => {
 | 
			
		||||
    component.items = items
 | 
			
		||||
  it('selection model should sort items by state', () => {
 | 
			
		||||
    component.items = items.concat([{ id: null, name: 'Null B' }])
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    selectionModel.toggle(items[1].id)
 | 
			
		||||
    selectionModel.apply()
 | 
			
		||||
    expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]])
 | 
			
		||||
    expect(selectionModel.itemsSorted).toEqual([
 | 
			
		||||
      nullItem,
 | 
			
		||||
      { id: null, name: 'Null B' },
 | 
			
		||||
      items[1],
 | 
			
		||||
      items[0],
 | 
			
		||||
    ])
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should set support create, keep open model and call createRef method', fakeAsync(() => {
 | 
			
		||||
@@ -542,4 +547,34 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 | 
			
		||||
    tick(300)
 | 
			
		||||
    expect(createSpy).toHaveBeenCalled()
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should exclude item and trigger change event', () => {
 | 
			
		||||
    const id = 1
 | 
			
		||||
    const state = ToggleableItemState.Selected
 | 
			
		||||
    component.selectionModel = selectionModel
 | 
			
		||||
    component.manyToOne = true
 | 
			
		||||
    component.selectionModel.singleSelect = true
 | 
			
		||||
    component.selectionModel.intersection = Intersection.Include
 | 
			
		||||
    component.selectionModel['temporarySelectionStates'].set(id, state)
 | 
			
		||||
    const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
 | 
			
		||||
    component.selectionModel.exclude(id)
 | 
			
		||||
    expect(component.selectionModel.temporaryLogicalOperator).toBe(
 | 
			
		||||
      LogicalOperator.And
 | 
			
		||||
    )
 | 
			
		||||
    expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
 | 
			
		||||
      ToggleableItemState.Excluded
 | 
			
		||||
    )
 | 
			
		||||
    expect(component.selectionModel['temporarySelectionStates'].size).toBe(1)
 | 
			
		||||
    expect(changedSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should initialize selection states and apply changes', () => {
 | 
			
		||||
    selectionModel.items = items
 | 
			
		||||
    const map = new Map<number, ToggleableItemState>()
 | 
			
		||||
    map.set(1, ToggleableItemState.Selected)
 | 
			
		||||
    map.set(2, ToggleableItemState.Excluded)
 | 
			
		||||
    selectionModel.init(map)
 | 
			
		||||
    expect(selectionModel.getSelectedItems()).toEqual([items[0]])
 | 
			
		||||
    expect(selectionModel.getExcludedItems()).toEqual([items[1]])
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -275,7 +275,7 @@ export class FilterableDropdownSelectionModel {
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  init(map) {
 | 
			
		||||
  init(map: Map<number, ToggleableItemState>) {
 | 
			
		||||
    this.temporarySelectionStates = map
 | 
			
		||||
    this.apply()
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -118,4 +118,18 @@ describe('SelectComponent', () => {
 | 
			
		||||
    tick(3000)
 | 
			
		||||
    expect(clearSpy).toHaveBeenCalled()
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should emit filtered documents', () => {
 | 
			
		||||
    component.value = 10
 | 
			
		||||
    component.items = items
 | 
			
		||||
    const emitSpy = jest.spyOn(component.filterDocuments, 'emit')
 | 
			
		||||
    component.onFilterDocuments()
 | 
			
		||||
    expect(emitSpy).toHaveBeenCalledWith([items[2]])
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should return the correct filter button title', () => {
 | 
			
		||||
    component.title = 'Tag'
 | 
			
		||||
    const expectedTitle = `Filter documents with this ${component.title}`
 | 
			
		||||
    expect(component.filterButtonTitle).toEqual(expectedTitle)
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -169,4 +169,12 @@ describe('TagsComponent', () => {
 | 
			
		||||
    expect(component.getTag(2)).toEqual(tags[1])
 | 
			
		||||
    expect(component.getTag(4)).toBeUndefined()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should emit filtered documents', () => {
 | 
			
		||||
    component.value = [10]
 | 
			
		||||
    component.tags = tags
 | 
			
		||||
    const emitSpy = jest.spyOn(component.filterDocuments, 'emit')
 | 
			
		||||
    component.onFilterDocuments()
 | 
			
		||||
    expect(emitSpy).toHaveBeenCalledWith([tags[2]])
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -119,6 +119,8 @@ describe('UploadFileWidgetComponent', () => {
 | 
			
		||||
    const processingStatus = new FileStatus()
 | 
			
		||||
    processingStatus.phase = FileStatusPhase.WORKING
 | 
			
		||||
    expect(component.getStatusColor(processingStatus)).toEqual('primary')
 | 
			
		||||
    processingStatus.phase = FileStatusPhase.UPLOADING
 | 
			
		||||
    expect(component.getStatusColor(processingStatus)).toEqual('primary')
 | 
			
		||||
    const failedStatus = new FileStatus()
 | 
			
		||||
    failedStatus.phase = FileStatusPhase.FAILED
 | 
			
		||||
    expect(component.getStatusColor(failedStatus)).toEqual('danger')
 | 
			
		||||
 
 | 
			
		||||
@@ -634,11 +634,14 @@ export class DocumentDetailComponent
 | 
			
		||||
          // in case data changed while saving eg removing inbox_tags
 | 
			
		||||
          this.documentForm.patchValue(docValues)
 | 
			
		||||
          this.store.next(this.documentForm.value)
 | 
			
		||||
          this.openDocumentService.setDirty(this.document, false)
 | 
			
		||||
          this.toastService.showInfo($localize`Document saved successfully.`)
 | 
			
		||||
          close && this.close()
 | 
			
		||||
          this.networkActive = false
 | 
			
		||||
          this.error = null
 | 
			
		||||
          this.openDocumentService.refreshDocument(this.documentId)
 | 
			
		||||
          close &&
 | 
			
		||||
            this.close(() =>
 | 
			
		||||
              this.openDocumentService.refreshDocument(this.documentId)
 | 
			
		||||
            )
 | 
			
		||||
        },
 | 
			
		||||
        error: (error) => {
 | 
			
		||||
          this.networkActive = false
 | 
			
		||||
@@ -693,12 +696,13 @@ export class DocumentDetailComponent
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  close() {
 | 
			
		||||
  close(closedCallback: () => void = null) {
 | 
			
		||||
    this.openDocumentService
 | 
			
		||||
      .closeDocument(this.document)
 | 
			
		||||
      .pipe(first())
 | 
			
		||||
      .subscribe((closed) => {
 | 
			
		||||
        if (!closed) return
 | 
			
		||||
        if (closedCallback) closedCallback()
 | 
			
		||||
        if (this.documentListViewService.activeSavedViewId) {
 | 
			
		||||
          this.router.navigate([
 | 
			
		||||
            'view',
 | 
			
		||||
 
 | 
			
		||||
@@ -381,6 +381,28 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
    expect(component.textFilter).toBeNull()
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should ingest text filter content with relative dates that are not in quick list', fakeAsync(() => {
 | 
			
		||||
    expect(component.dateAddedRelativeDate).toBeNull()
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_FULLTEXT_QUERY,
 | 
			
		||||
        value: 'added:[-2 week to now]',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.dateAddedRelativeDate).toBeNull()
 | 
			
		||||
    expect(component.textFilter).toEqual('added:[-2 week to now]')
 | 
			
		||||
 | 
			
		||||
    expect(component.dateCreatedRelativeDate).toBeNull()
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_FULLTEXT_QUERY,
 | 
			
		||||
        value: 'created:[-2 week to now]',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.dateCreatedRelativeDate).toBeNull()
 | 
			
		||||
    expect(component.textFilter).toEqual('created:[-2 week to now]')
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should ingest text filter rules for more like', fakeAsync(() => {
 | 
			
		||||
    const moreLikeSpy = jest.spyOn(documentService, 'get')
 | 
			
		||||
    moreLikeSpy.mockReturnValue(of({ id: 1, title: 'Foo Bar' }))
 | 
			
		||||
@@ -1372,6 +1394,34 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
    ])
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should leave relative dates not in quick list intact', fakeAsync(() => {
 | 
			
		||||
    component.textFilterInput.nativeElement.value = 'created:[-2 week to now]'
 | 
			
		||||
    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
 | 
			
		||||
    const textFieldTargetDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(NgbDropdownItem)
 | 
			
		||||
    )[4]
 | 
			
		||||
    textFieldTargetDropdown.triggerEventHandler('click')
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    tick(400)
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_FULLTEXT_QUERY,
 | 
			
		||||
        value: 'created:[-2 week to now]',
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
 | 
			
		||||
    component.textFilterInput.nativeElement.value = 'added:[-2 month to now]'
 | 
			
		||||
    component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    tick(400)
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_FULLTEXT_QUERY,
 | 
			
		||||
        value: 'added:[-2 month to now]',
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should convert user input to correct filter rules on date added after', fakeAsync(() => {
 | 
			
		||||
    const dateAddedDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(DateDropdownComponent)
 | 
			
		||||
 
 | 
			
		||||
@@ -362,10 +362,11 @@ export class FilterEditorComponent
 | 
			
		||||
                    this.dateCreatedRelativeDate =
 | 
			
		||||
                      RELATIVE_DATE_QUERYSTRINGS.find(
 | 
			
		||||
                        (qS) => qS.dateQuery == match[1]
 | 
			
		||||
                      )?.relativeDate
 | 
			
		||||
                      )?.relativeDate ?? null
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              )
 | 
			
		||||
              if (this.dateCreatedRelativeDate === null) textQueryArgs.push(arg) // relative query not in the quick list
 | 
			
		||||
            } else if (arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)) {
 | 
			
		||||
              ;[...arg.matchAll(RELATIVE_DATE_QUERY_REGEXP_ADDED)].forEach(
 | 
			
		||||
                (match) => {
 | 
			
		||||
@@ -373,10 +374,11 @@ export class FilterEditorComponent
 | 
			
		||||
                    this.dateAddedRelativeDate =
 | 
			
		||||
                      RELATIVE_DATE_QUERYSTRINGS.find(
 | 
			
		||||
                        (qS) => qS.dateQuery == match[1]
 | 
			
		||||
                      )?.relativeDate
 | 
			
		||||
                      )?.relativeDate ?? null
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              )
 | 
			
		||||
              if (this.dateAddedRelativeDate === null) textQueryArgs.push(arg) // relative query not in the quick list
 | 
			
		||||
            } else {
 | 
			
		||||
              textQueryArgs.push(arg)
 | 
			
		||||
            }
 | 
			
		||||
@@ -787,27 +789,6 @@ export class FilterEditorComponent
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (
 | 
			
		||||
      this.dateCreatedRelativeDate == null &&
 | 
			
		||||
      this.dateAddedRelativeDate == null
 | 
			
		||||
    ) {
 | 
			
		||||
      const existingRule = filterRules.find(
 | 
			
		||||
        (fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
 | 
			
		||||
      )
 | 
			
		||||
      if (
 | 
			
		||||
        existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_CREATED) ||
 | 
			
		||||
        existingRule?.value.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)
 | 
			
		||||
      ) {
 | 
			
		||||
        // remove any existing date query
 | 
			
		||||
        existingRule.value = existingRule.value
 | 
			
		||||
          .replace(RELATIVE_DATE_QUERY_REGEXP_CREATED, '')
 | 
			
		||||
          .replace(RELATIVE_DATE_QUERY_REGEXP_ADDED, '')
 | 
			
		||||
        if (existingRule.value.replace(',', '').trim() === '') {
 | 
			
		||||
          // if its empty now, remove it entirely
 | 
			
		||||
          filterRules.splice(filterRules.indexOf(existingRule), 1)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) {
 | 
			
		||||
      filterRules.push({
 | 
			
		||||
        rule_type: FILTER_OWNER,
 | 
			
		||||
 
 | 
			
		||||
@@ -2,10 +2,10 @@
 | 
			
		||||
  <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedObjects.size === 0">
 | 
			
		||||
    <i-bs  name="x"></i-bs> <ng-container i18n>Clear selection</ng-container>
 | 
			
		||||
    </button>
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
 | 
			
		||||
      <i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
 | 
			
		||||
    </button>
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-danger me-5" (click)="delete()" [disabled]="!userOwnsAll || selectedObjects.size === 0">
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-danger me-5" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
 | 
			
		||||
      <i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
 | 
			
		||||
    </button>
 | 
			
		||||
    <button type="button" class="btn btn-sm btn-outline-primary" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
 | 
			
		||||
@@ -88,39 +88,33 @@
 | 
			
		||||
                <div class="btn-group d-none d-sm-block">
 | 
			
		||||
                  <button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
 | 
			
		||||
                    <i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container>
 | 
			
		||||
                  </button>
 | 
			
		||||
                  <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
 | 
			
		||||
                    <i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
 | 
			
		||||
                  </button>
 | 
			
		||||
                  <pngx-confirm-button
 | 
			
		||||
                    label="Delete"
 | 
			
		||||
                    i18n-label
 | 
			
		||||
                    (confirm)="deleteObject(object)"
 | 
			
		||||
                    *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }"
 | 
			
		||||
                    [disabled]="!userCanDelete(object)"
 | 
			
		||||
                    buttonClasses=" btn-sm btn-outline-danger"
 | 
			
		||||
                    iconName="trash">
 | 
			
		||||
                  </pngx-confirm-button>
 | 
			
		||||
                </div>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          }
 | 
			
		||||
        </tbody>
 | 
			
		||||
      </table>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    @if (!isLoading) {
 | 
			
		||||
      <div class="d-flex mb-2">
 | 
			
		||||
        @if (collectionSize > 0) {
 | 
			
		||||
          <div>
 | 
			
		||||
            <ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
 | 
			
		||||
            @if (selectedObjects.size > 0) {
 | 
			
		||||
               ({{selectedObjects.size}} selected)
 | 
			
		||||
            }
 | 
			
		||||
                    </button>
 | 
			
		||||
                    <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
 | 
			
		||||
                      <i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
 | 
			
		||||
                      </button>
 | 
			
		||||
                      <button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
 | 
			
		||||
                        <i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
 | 
			
		||||
                        </button>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </td>
 | 
			
		||||
                  </tr>
 | 
			
		||||
                }
 | 
			
		||||
              </tbody>
 | 
			
		||||
            </table>
 | 
			
		||||
          </div>
 | 
			
		||||
        }
 | 
			
		||||
        @if (collectionSize > 20) {
 | 
			
		||||
          <ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
 | 
			
		||||
        }
 | 
			
		||||
      </div>
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
          @if (!isLoading) {
 | 
			
		||||
            <div class="d-flex mb-2">
 | 
			
		||||
              @if (collectionSize > 0) {
 | 
			
		||||
                <div>
 | 
			
		||||
                  <ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
 | 
			
		||||
                  @if (selectedObjects.size > 0) {
 | 
			
		||||
                     ({{selectedObjects.size}} selected)
 | 
			
		||||
                  }
 | 
			
		||||
                </div>
 | 
			
		||||
              }
 | 
			
		||||
              @if (collectionSize > 20) {
 | 
			
		||||
                <ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
 | 
			
		||||
              }
 | 
			
		||||
            </div>
 | 
			
		||||
          }
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,6 @@ import {
 | 
			
		||||
  NgbModalModule,
 | 
			
		||||
  NgbModalRef,
 | 
			
		||||
  NgbPaginationModule,
 | 
			
		||||
  NgbPopoverModule,
 | 
			
		||||
} from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { of, throwError } from 'rxjs'
 | 
			
		||||
import { Tag } from 'src/app/data/tag'
 | 
			
		||||
@@ -24,7 +23,10 @@ import { TagService } from 'src/app/services/rest/tag.service'
 | 
			
		||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
 | 
			
		||||
import { TagListComponent } from '../tag-list/tag-list.component'
 | 
			
		||||
import { ManagementListComponent } from './management-list.component'
 | 
			
		||||
import { PermissionsService } from 'src/app/services/permissions.service'
 | 
			
		||||
import {
 | 
			
		||||
  PermissionAction,
 | 
			
		||||
  PermissionsService,
 | 
			
		||||
} from 'src/app/services/permissions.service'
 | 
			
		||||
import { ToastService } from 'src/app/services/toast.service'
 | 
			
		||||
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
 | 
			
		||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
 | 
			
		||||
@@ -38,7 +40,6 @@ import { MATCH_NONE } from 'src/app/data/matching-model'
 | 
			
		||||
import { MATCH_LITERAL } from 'src/app/data/matching-model'
 | 
			
		||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
 | 
			
		||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
			
		||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
 | 
			
		||||
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
 | 
			
		||||
 | 
			
		||||
const tags: Tag[] = [
 | 
			
		||||
@@ -67,6 +68,7 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
  let modalService: NgbModal
 | 
			
		||||
  let toastService: ToastService
 | 
			
		||||
  let documentListViewService: DocumentListViewService
 | 
			
		||||
  let permissionsService: PermissionsService
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
@@ -78,20 +80,8 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
        SafeHtmlPipe,
 | 
			
		||||
        ConfirmDialogComponent,
 | 
			
		||||
        PermissionsDialogComponent,
 | 
			
		||||
        ConfirmButtonComponent,
 | 
			
		||||
      ],
 | 
			
		||||
      providers: [
 | 
			
		||||
        {
 | 
			
		||||
          provide: PermissionsService,
 | 
			
		||||
          useValue: {
 | 
			
		||||
            currentUserCan: () => true,
 | 
			
		||||
            currentUserHasObjectPermissions: () => true,
 | 
			
		||||
            currentUserOwnsObject: () => true,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        DatePipe,
 | 
			
		||||
        PermissionsGuard,
 | 
			
		||||
      ],
 | 
			
		||||
      providers: [DatePipe, PermissionsGuard],
 | 
			
		||||
      imports: [
 | 
			
		||||
        HttpClientTestingModule,
 | 
			
		||||
        NgbPaginationModule,
 | 
			
		||||
@@ -100,7 +90,6 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
        NgbModalModule,
 | 
			
		||||
        RouterTestingModule.withRoutes(routes),
 | 
			
		||||
        NgxBootstrapIconsModule.pick(allIcons),
 | 
			
		||||
        NgbPopoverModule,
 | 
			
		||||
      ],
 | 
			
		||||
    }).compileComponents()
 | 
			
		||||
 | 
			
		||||
@@ -119,6 +108,14 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
      )
 | 
			
		||||
    permissionsService = TestBed.inject(PermissionsService)
 | 
			
		||||
    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
 | 
			
		||||
      .mockReturnValue(true)
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(permissionsService, 'currentUserOwnsObject')
 | 
			
		||||
      .mockReturnValue(true)
 | 
			
		||||
    modalService = TestBed.inject(NgbModal)
 | 
			
		||||
    toastService = TestBed.inject(ToastService)
 | 
			
		||||
    documentListViewService = TestBed.inject(DocumentListViewService)
 | 
			
		||||
@@ -197,23 +194,27 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support delete, show notification on error / success', () => {
 | 
			
		||||
    let modal: NgbModalRef
 | 
			
		||||
    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
 | 
			
		||||
    const toastErrorSpy = jest.spyOn(toastService, 'showError')
 | 
			
		||||
    const deleteSpy = jest.spyOn(tagService, 'delete')
 | 
			
		||||
    const reloadSpy = jest.spyOn(component, 'reloadData')
 | 
			
		||||
 | 
			
		||||
    const deleteButton = fixture.debugElement.query(
 | 
			
		||||
      By.directive(ConfirmButtonComponent)
 | 
			
		||||
    )
 | 
			
		||||
    const deleteButton = fixture.debugElement.queryAll(By.css('button'))[8]
 | 
			
		||||
    deleteButton.triggerEventHandler('click')
 | 
			
		||||
 | 
			
		||||
    expect(modal).not.toBeUndefined()
 | 
			
		||||
    const editDialog = modal.componentInstance as ConfirmDialogComponent
 | 
			
		||||
 | 
			
		||||
    // fail first
 | 
			
		||||
    deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
 | 
			
		||||
    deleteButton.nativeElement.dispatchEvent(new Event('confirm'))
 | 
			
		||||
    editDialog.confirmClicked.emit()
 | 
			
		||||
    expect(toastErrorSpy).toHaveBeenCalled()
 | 
			
		||||
    expect(reloadSpy).not.toHaveBeenCalled()
 | 
			
		||||
 | 
			
		||||
    // succeed
 | 
			
		||||
    deleteSpy.mockReturnValueOnce(of(true))
 | 
			
		||||
    deleteButton.nativeElement.dispatchEvent(new Event('confirm'))
 | 
			
		||||
    editDialog.confirmClicked.emit()
 | 
			
		||||
    expect(reloadSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
@@ -312,4 +313,10 @@ describe('ManagementListComponent', () => {
 | 
			
		||||
    expect(bulkEditSpy).toHaveBeenCalled()
 | 
			
		||||
    expect(successToastSpy).toHaveBeenCalled()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should disallow bulk permissions or delete objects if no global perms', () => {
 | 
			
		||||
    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
 | 
			
		||||
    expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()
 | 
			
		||||
    expect(component.userCanBulkEdit(PermissionAction.Change)).toBeFalsy()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -22,6 +22,7 @@ import {
 | 
			
		||||
} from 'src/app/directives/sortable.directive'
 | 
			
		||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
 | 
			
		||||
import {
 | 
			
		||||
  PermissionAction,
 | 
			
		||||
  PermissionsService,
 | 
			
		||||
  PermissionType,
 | 
			
		||||
} from 'src/app/services/permissions.service'
 | 
			
		||||
@@ -194,21 +195,34 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
 | 
			
		||||
    ])
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  deleteObject(object: T) {
 | 
			
		||||
    this.service
 | 
			
		||||
      .delete(object)
 | 
			
		||||
      .pipe(takeUntil(this.unsubscribeNotifier))
 | 
			
		||||
      .subscribe({
 | 
			
		||||
        next: () => {
 | 
			
		||||
          this.reloadData()
 | 
			
		||||
        },
 | 
			
		||||
        error: (error) => {
 | 
			
		||||
          this.toastService.showError(
 | 
			
		||||
            $localize`Error while deleting element`,
 | 
			
		||||
            error
 | 
			
		||||
          )
 | 
			
		||||
        },
 | 
			
		||||
      })
 | 
			
		||||
  openDeleteDialog(object: T) {
 | 
			
		||||
    var activeModal = this.modalService.open(ConfirmDialogComponent, {
 | 
			
		||||
      backdrop: 'static',
 | 
			
		||||
    })
 | 
			
		||||
    activeModal.componentInstance.title = $localize`Confirm delete`
 | 
			
		||||
    activeModal.componentInstance.messageBold = this.getDeleteMessage(object)
 | 
			
		||||
    activeModal.componentInstance.message = $localize`Associated documents will not be deleted.`
 | 
			
		||||
    activeModal.componentInstance.btnClass = 'btn-danger'
 | 
			
		||||
    activeModal.componentInstance.btnCaption = $localize`Delete`
 | 
			
		||||
    activeModal.componentInstance.confirmClicked.subscribe(() => {
 | 
			
		||||
      activeModal.componentInstance.buttonsEnabled = false
 | 
			
		||||
      this.service
 | 
			
		||||
        .delete(object)
 | 
			
		||||
        .pipe(takeUntil(this.unsubscribeNotifier))
 | 
			
		||||
        .subscribe({
 | 
			
		||||
          next: () => {
 | 
			
		||||
            activeModal.close()
 | 
			
		||||
            this.reloadData()
 | 
			
		||||
          },
 | 
			
		||||
          error: (error) => {
 | 
			
		||||
            activeModal.componentInstance.buttonsEnabled = true
 | 
			
		||||
            this.toastService.showError(
 | 
			
		||||
              $localize`Error while deleting element`,
 | 
			
		||||
              error
 | 
			
		||||
            )
 | 
			
		||||
          },
 | 
			
		||||
        })
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get nameFilter() {
 | 
			
		||||
@@ -234,7 +248,9 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get userOwnsAll(): boolean {
 | 
			
		||||
  userCanBulkEdit(action: PermissionAction): boolean {
 | 
			
		||||
    if (!this.permissionsService.currentUserCan(action, this.permissionType))
 | 
			
		||||
      return false
 | 
			
		||||
    let ownsAll: boolean = true
 | 
			
		||||
    const objects = this.data.filter((o) => this.selectedObjects.has(o.id))
 | 
			
		||||
    ownsAll = objects.every((o) =>
 | 
			
		||||
 
 | 
			
		||||
@@ -17,6 +17,7 @@ describe('DirtyFormGuard', () => {
 | 
			
		||||
  let guard: DirtyFormGuard
 | 
			
		||||
  let component: DirtyComponent
 | 
			
		||||
  let route: ActivatedRoute
 | 
			
		||||
  let modalService: NgbModal
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
@@ -37,6 +38,7 @@ describe('DirtyFormGuard', () => {
 | 
			
		||||
 | 
			
		||||
    guard = TestBed.inject(DirtyFormGuard)
 | 
			
		||||
    route = TestBed.inject(ActivatedRoute)
 | 
			
		||||
    modalService = TestBed.inject(NgbModal)
 | 
			
		||||
    const fixture = TestBed.createComponent(GenericDirtyComponent)
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
 | 
			
		||||
@@ -57,9 +59,14 @@ describe('DirtyFormGuard', () => {
 | 
			
		||||
    component.isDirty$ = true
 | 
			
		||||
    const confirmSpy = jest.spyOn(guard, 'confirmChanges')
 | 
			
		||||
    const canDeactivate = guard.canDeactivate(component, route.snapshot)
 | 
			
		||||
    let modal
 | 
			
		||||
    modalService.activeInstances.subscribe((instances) => {
 | 
			
		||||
      modal = instances[0]
 | 
			
		||||
    })
 | 
			
		||||
    canDeactivate.subscribe()
 | 
			
		||||
 | 
			
		||||
    expect(canDeactivate).toHaveProperty('source') // Observable
 | 
			
		||||
    expect(confirmSpy).toHaveBeenCalled()
 | 
			
		||||
    modal.componentInstance.confirmClicked.next()
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -108,6 +108,7 @@ describe('OpenDocumentsService', () => {
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should close documents', () => {
 | 
			
		||||
    openDocumentsService.closeDocument({ id: 999 } as any)
 | 
			
		||||
    subscriptions.push(
 | 
			
		||||
      openDocumentsService.openDocument(documents[0]).subscribe()
 | 
			
		||||
    )
 | 
			
		||||
@@ -128,15 +129,21 @@ describe('OpenDocumentsService', () => {
 | 
			
		||||
    subscriptions.push(
 | 
			
		||||
      openDocumentsService.openDocument(documents[0]).subscribe()
 | 
			
		||||
    )
 | 
			
		||||
    openDocumentsService.setDirty({ id: 999 }, true) // coverage
 | 
			
		||||
    openDocumentsService.setDirty(documents[0], false)
 | 
			
		||||
    expect(openDocumentsService.hasDirty()).toBeFalsy()
 | 
			
		||||
    openDocumentsService.setDirty(documents[0], true)
 | 
			
		||||
    expect(openDocumentsService.hasDirty()).toBeTruthy()
 | 
			
		||||
    let openModal
 | 
			
		||||
    modalService.activeInstances.subscribe((instances) => {
 | 
			
		||||
      openModal = instances[0]
 | 
			
		||||
    })
 | 
			
		||||
    const modalSpy = jest.spyOn(modalService, 'open')
 | 
			
		||||
    subscriptions.push(
 | 
			
		||||
      openDocumentsService.closeDocument(documents[0]).subscribe()
 | 
			
		||||
    )
 | 
			
		||||
    expect(modalSpy).toHaveBeenCalled()
 | 
			
		||||
    openModal.componentInstance.confirmClicked.next()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should allow set dirty status, warn on closeAll', () => {
 | 
			
		||||
@@ -148,9 +155,14 @@ describe('OpenDocumentsService', () => {
 | 
			
		||||
    )
 | 
			
		||||
    openDocumentsService.setDirty(documents[0], true)
 | 
			
		||||
    expect(openDocumentsService.hasDirty()).toBeTruthy()
 | 
			
		||||
    let openModal
 | 
			
		||||
    modalService.activeInstances.subscribe((instances) => {
 | 
			
		||||
      openModal = instances[0]
 | 
			
		||||
    })
 | 
			
		||||
    const modalSpy = jest.spyOn(modalService, 'open')
 | 
			
		||||
    subscriptions.push(openDocumentsService.closeAll().subscribe())
 | 
			
		||||
    expect(modalSpy).toHaveBeenCalled()
 | 
			
		||||
    openModal.componentInstance.confirmClicked.next()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should load open documents from localStorage', () => {
 | 
			
		||||
 
 | 
			
		||||
@@ -58,12 +58,25 @@ describe(`Additional service tests for MailAccountService`, () => {
 | 
			
		||||
  it('should support patchMany', () => {
 | 
			
		||||
    subscription = service.patchMany(mail_accounts).subscribe()
 | 
			
		||||
    mail_accounts.forEach((mail_account) => {
 | 
			
		||||
      const reqs = httpTestingController.match(
 | 
			
		||||
      const req = httpTestingController.expectOne(
 | 
			
		||||
        `${environment.apiBaseUrl}${endpoint}/${mail_account.id}/`
 | 
			
		||||
      )
 | 
			
		||||
      expect(reqs).toHaveLength(1)
 | 
			
		||||
      expect(reqs[0].request.method).toEqual('PATCH')
 | 
			
		||||
      expect(req.request.method).toEqual('PATCH')
 | 
			
		||||
      req.flush(mail_account)
 | 
			
		||||
    })
 | 
			
		||||
    httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support reload', () => {
 | 
			
		||||
    service['reload']()
 | 
			
		||||
    const req = httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
 | 
			
		||||
    )
 | 
			
		||||
    expect(req.request.method).toEqual('GET')
 | 
			
		||||
    req.flush({ results: mail_accounts })
 | 
			
		||||
    expect(service.allAccounts).toEqual(mail_accounts)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
 
 | 
			
		||||
@@ -76,12 +76,26 @@ describe(`Additional service tests for MailRuleService`, () => {
 | 
			
		||||
  it('should support patchMany', () => {
 | 
			
		||||
    subscription = service.patchMany(mail_rules).subscribe()
 | 
			
		||||
    mail_rules.forEach((mail_rule) => {
 | 
			
		||||
      const reqs = httpTestingController.match(
 | 
			
		||||
      const req = httpTestingController.expectOne(
 | 
			
		||||
        `${environment.apiBaseUrl}${endpoint}/${mail_rule.id}/`
 | 
			
		||||
      )
 | 
			
		||||
      expect(reqs).toHaveLength(1)
 | 
			
		||||
      expect(reqs[0].request.method).toEqual('PATCH')
 | 
			
		||||
      expect(req.request.method).toEqual('PATCH')
 | 
			
		||||
      req.flush(mail_rule)
 | 
			
		||||
    })
 | 
			
		||||
    const reloadReq = httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
 | 
			
		||||
    )
 | 
			
		||||
    reloadReq.flush({ results: mail_rules })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support reload', () => {
 | 
			
		||||
    service['reload']()
 | 
			
		||||
    const req = httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
 | 
			
		||||
    )
 | 
			
		||||
    expect(req.request.method).toEqual('GET')
 | 
			
		||||
    req.flush({ results: mail_rules })
 | 
			
		||||
    expect(service.allRules).toEqual(mail_rules)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
 
 | 
			
		||||
@@ -262,7 +262,7 @@ a.btn-link:focus-visible,
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    .ng-dropdown-panel .ng-dropdown-panel-items .ng-option.ng-option-marked {
 | 
			
		||||
      background-color: var(--pngx-bg-darker) !important;
 | 
			
		||||
      background-color: var(--pngx-bg-alt) !important;
 | 
			
		||||
      color: var(--pngx-body-color-accent) !important;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -439,7 +439,7 @@ ul.pagination {
 | 
			
		||||
    color: var(--bs-body-color);
 | 
			
		||||
 | 
			
		||||
    &:hover, &:focus {
 | 
			
		||||
      background-color: var(--pngx-bg-darker);
 | 
			
		||||
      background-color: var(--pngx-bg-alt);
 | 
			
		||||
      color: var(--bs-body-color);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user