mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-11-03 03:16:10 -06:00 
			
		
		
		
	Feature: custom fields filtering & bulk editing (#6484)
This commit is contained in:
		@@ -80,7 +80,7 @@ django_checks() {
 | 
			
		||||
 | 
			
		||||
search_index() {
 | 
			
		||||
 | 
			
		||||
	local -r index_version=8
 | 
			
		||||
	local -r index_version=9
 | 
			
		||||
	local -r index_version_file=${DATA_DIR}/.index_version
 | 
			
		||||
 | 
			
		||||
	if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
 | 
			
		||||
 
 | 
			
		||||
@@ -81,14 +81,15 @@ test('text filtering', async ({ page }) => {
 | 
			
		||||
test('date filtering', async ({ page }) => {
 | 
			
		||||
  await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
 | 
			
		||||
  await page.goto('/documents')
 | 
			
		||||
  await page.getByRole('button', { name: 'Created' }).click()
 | 
			
		||||
  await page.getByRole('menuitem', { name: 'Last 3 months' }).click()
 | 
			
		||||
  await page.getByRole('button', { name: 'Dates' }).click()
 | 
			
		||||
  await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
 | 
			
		||||
  await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
 | 
			
		||||
  await page.getByRole('button', { name: 'Created Clear selected' }).click()
 | 
			
		||||
  await page.getByRole('button', { name: 'Created' }).click()
 | 
			
		||||
  await page.getByRole('button', { name: 'Dates Clear selected' }).click()
 | 
			
		||||
  await page.getByRole('button', { name: 'Dates' }).click()
 | 
			
		||||
  await page
 | 
			
		||||
    .getByRole('menuitem', { name: 'After mm/dd/yyyy' })
 | 
			
		||||
    .getByRole('button')
 | 
			
		||||
    .first()
 | 
			
		||||
    .click()
 | 
			
		||||
  await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
 | 
			
		||||
  await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
 | 
			
		||||
 
 | 
			
		||||
@@ -31,7 +31,7 @@ import { ToastsComponent } from './components/common/toasts/toasts.component'
 | 
			
		||||
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'
 | 
			
		||||
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'
 | 
			
		||||
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
 | 
			
		||||
import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component'
 | 
			
		||||
import { DatesDropdownComponent } from './components/common/dates-dropdown/dates-dropdown.component'
 | 
			
		||||
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'
 | 
			
		||||
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'
 | 
			
		||||
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
 | 
			
		||||
@@ -140,6 +140,7 @@ import {
 | 
			
		||||
  boxes,
 | 
			
		||||
  calendar,
 | 
			
		||||
  calendarEvent,
 | 
			
		||||
  calendarEventFill,
 | 
			
		||||
  cardChecklist,
 | 
			
		||||
  cardHeading,
 | 
			
		||||
  caretDown,
 | 
			
		||||
@@ -235,6 +236,7 @@ const icons = {
 | 
			
		||||
  boxes,
 | 
			
		||||
  calendar,
 | 
			
		||||
  calendarEvent,
 | 
			
		||||
  calendarEventFill,
 | 
			
		||||
  cardChecklist,
 | 
			
		||||
  cardHeading,
 | 
			
		||||
  caretDown,
 | 
			
		||||
@@ -407,7 +409,7 @@ function initializeApp(settings: SettingsService) {
 | 
			
		||||
    FilterEditorComponent,
 | 
			
		||||
    FilterableDropdownComponent,
 | 
			
		||||
    ToggleableDropdownButtonComponent,
 | 
			
		||||
    DateDropdownComponent,
 | 
			
		||||
    DatesDropdownComponent,
 | 
			
		||||
    DocumentCardLargeComponent,
 | 
			
		||||
    DocumentCardSmallComponent,
 | 
			
		||||
    BulkEditorComponent,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,71 +0,0 @@
 | 
			
		||||
<div class="btn-group w-100" ngbDropdown role="group">
 | 
			
		||||
  <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="dateBefore || dateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
 | 
			
		||||
    {{title}}
 | 
			
		||||
    <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
 | 
			
		||||
  </button>
 | 
			
		||||
  <div class="dropdown-menu date-dropdown shadow pt-0" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
 | 
			
		||||
    <div class="list-group list-group-flush">
 | 
			
		||||
      @for (rd of relativeDates; track rd) {
 | 
			
		||||
        <button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setRelativeDate(rd.id)">
 | 
			
		||||
          <div class="selected-icon">
 | 
			
		||||
            @if (relativeDate === rd.id) {
 | 
			
		||||
              <i-bs width="1em" height="1em" name="check"></i-bs>
 | 
			
		||||
            }
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="d-flex justify-content-between w-100 align-items-center ps-2">
 | 
			
		||||
            <div class="pe-2 pe-lg-4">
 | 
			
		||||
              {{rd.name}}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="text-muted small pe-2">
 | 
			
		||||
              <span class="small">
 | 
			
		||||
                {{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container>
 | 
			
		||||
              </span>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </button>
 | 
			
		||||
      }
 | 
			
		||||
      <div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
 | 
			
		||||
 | 
			
		||||
        <div class="mb-2 d-flex flex-row w-100 justify-content-between small">
 | 
			
		||||
          <div i18n>After</div>
 | 
			
		||||
          @if (dateAfter) {
 | 
			
		||||
            <a class="btn btn-link p-0 m-0" (click)="clearAfter()">
 | 
			
		||||
              <i-bs width="1em" height="1em" name="x"></i-bs>
 | 
			
		||||
              <small i18n>Clear</small>
 | 
			
		||||
            </a>
 | 
			
		||||
          }
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="input-group input-group-sm">
 | 
			
		||||
          <input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
 | 
			
		||||
            maxlength="10" [(ngModel)]="dateAfter" ngbDatepicker #dateAfterPicker="ngbDatepicker">
 | 
			
		||||
          <button class="btn btn-outline-secondary" (click)="dateAfterPicker.toggle()" type="button">
 | 
			
		||||
            <i-bs width="1em" height="1em" name="calendar"></i-bs>
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
 | 
			
		||||
 | 
			
		||||
        <div class="mb-2 d-flex flex-row w-100 justify-content-between small">
 | 
			
		||||
          <div i18n>Before</div>
 | 
			
		||||
          @if (dateBefore) {
 | 
			
		||||
            <a class="btn btn-link p-0 m-0" (click)="clearBefore()">
 | 
			
		||||
              <i-bs width="1em" height="1em" name="x"></i-bs>
 | 
			
		||||
              <small i18n>Clear</small>
 | 
			
		||||
            </a>
 | 
			
		||||
          }
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <div class="input-group input-group-sm">
 | 
			
		||||
          <input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
 | 
			
		||||
            maxlength="10" [(ngModel)]="dateBefore" ngbDatepicker #dateBeforePicker="ngbDatepicker">
 | 
			
		||||
          <button class="btn btn-outline-secondary" (click)="dateBeforePicker.toggle()" type="button">
 | 
			
		||||
            <i-bs width="1em" height="1em" name="calendar"></i-bs>
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -1,164 +0,0 @@
 | 
			
		||||
import {
 | 
			
		||||
  Component,
 | 
			
		||||
  EventEmitter,
 | 
			
		||||
  Input,
 | 
			
		||||
  Output,
 | 
			
		||||
  OnInit,
 | 
			
		||||
  OnDestroy,
 | 
			
		||||
} from '@angular/core'
 | 
			
		||||
import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { Subject, Subscription } from 'rxjs'
 | 
			
		||||
import { debounceTime } from 'rxjs/operators'
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service'
 | 
			
		||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
 | 
			
		||||
 | 
			
		||||
export interface DateSelection {
 | 
			
		||||
  before?: string
 | 
			
		||||
  after?: string
 | 
			
		||||
  relativeDateID?: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum RelativeDate {
 | 
			
		||||
  LAST_7_DAYS = 0,
 | 
			
		||||
  LAST_MONTH = 1,
 | 
			
		||||
  LAST_3_MONTHS = 2,
 | 
			
		||||
  LAST_YEAR = 3,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'pngx-date-dropdown',
 | 
			
		||||
  templateUrl: './date-dropdown.component.html',
 | 
			
		||||
  styleUrls: ['./date-dropdown.component.scss'],
 | 
			
		||||
  providers: [{ provide: NgbDateAdapter, useClass: ISODateAdapter }],
 | 
			
		||||
})
 | 
			
		||||
export class DateDropdownComponent implements OnInit, OnDestroy {
 | 
			
		||||
  constructor(settings: SettingsService) {
 | 
			
		||||
    this.datePlaceHolder = settings.getLocalizedDateInputFormat()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  relativeDates = [
 | 
			
		||||
    {
 | 
			
		||||
      id: RelativeDate.LAST_7_DAYS,
 | 
			
		||||
      name: $localize`Last 7 days`,
 | 
			
		||||
      date: new Date().setDate(new Date().getDate() - 7),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      id: RelativeDate.LAST_MONTH,
 | 
			
		||||
      name: $localize`Last month`,
 | 
			
		||||
      date: new Date().setMonth(new Date().getMonth() - 1),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      id: RelativeDate.LAST_3_MONTHS,
 | 
			
		||||
      name: $localize`Last 3 months`,
 | 
			
		||||
      date: new Date().setMonth(new Date().getMonth() - 3),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      id: RelativeDate.LAST_YEAR,
 | 
			
		||||
      name: $localize`Last year`,
 | 
			
		||||
      date: new Date().setFullYear(new Date().getFullYear() - 1),
 | 
			
		||||
    },
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  datePlaceHolder: string
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  dateBefore: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  dateBeforeChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  dateAfter: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  dateAfterChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  relativeDate: RelativeDate
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  relativeDateChange = new EventEmitter<number>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  datesSet = new EventEmitter<DateSelection>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  disabled: boolean = false
 | 
			
		||||
 | 
			
		||||
  get isActive(): boolean {
 | 
			
		||||
    return (
 | 
			
		||||
      this.relativeDate !== null ||
 | 
			
		||||
      this.dateAfter?.length > 0 ||
 | 
			
		||||
      this.dateBefore?.length > 0
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private datesSetDebounce$ = new Subject()
 | 
			
		||||
 | 
			
		||||
  private sub: Subscription
 | 
			
		||||
 | 
			
		||||
  ngOnInit() {
 | 
			
		||||
    this.sub = this.datesSetDebounce$.pipe(debounceTime(400)).subscribe(() => {
 | 
			
		||||
      this.onChange()
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy() {
 | 
			
		||||
    if (this.sub) {
 | 
			
		||||
      this.sub.unsubscribe()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  reset() {
 | 
			
		||||
    this.dateBefore = null
 | 
			
		||||
    this.dateAfter = null
 | 
			
		||||
    this.relativeDate = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setRelativeDate(rd: RelativeDate) {
 | 
			
		||||
    this.dateBefore = null
 | 
			
		||||
    this.dateAfter = null
 | 
			
		||||
    this.relativeDate = this.relativeDate == rd ? null : rd
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChange() {
 | 
			
		||||
    this.dateBeforeChange.emit(this.dateBefore)
 | 
			
		||||
    this.dateAfterChange.emit(this.dateAfter)
 | 
			
		||||
    this.relativeDateChange.emit(this.relativeDate)
 | 
			
		||||
    this.datesSet.emit({
 | 
			
		||||
      after: this.dateAfter,
 | 
			
		||||
      before: this.dateBefore,
 | 
			
		||||
      relativeDateID: this.relativeDate,
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChangeDebounce() {
 | 
			
		||||
    this.relativeDate = null
 | 
			
		||||
    this.datesSetDebounce$.next({
 | 
			
		||||
      after: this.dateAfter,
 | 
			
		||||
      before: this.dateBefore,
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearBefore() {
 | 
			
		||||
    this.dateBefore = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearAfter() {
 | 
			
		||||
    this.dateAfter = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // prevent chars other than numbers and separators
 | 
			
		||||
  onKeyPress(event: KeyboardEvent) {
 | 
			
		||||
    if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {
 | 
			
		||||
      event.preventDefault()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,143 @@
 | 
			
		||||
<div class="btn-group w-100" ngbDropdown role="group">
 | 
			
		||||
  <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateBefore || createdDateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
 | 
			
		||||
    <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
 | 
			
		||||
    <div class="d-none d-sm-inline"> {{title}}</div>
 | 
			
		||||
    <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
 | 
			
		||||
  </button>
 | 
			
		||||
  <div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
 | 
			
		||||
    <div class="row d-flex">
 | 
			
		||||
      <div class="col border-end">
 | 
			
		||||
        <div class="list-group list-group-flush">
 | 
			
		||||
          <h6 class="dropdown-header border-bottom" i18n>Created</h6>
 | 
			
		||||
          @for (rd of relativeDates; track rd) {
 | 
			
		||||
            <button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setCreatedRelativeDate(rd.id)">
 | 
			
		||||
              <div class="selected-icon">
 | 
			
		||||
                @if (createdRelativeDate === rd.id) {
 | 
			
		||||
                  <i-bs width="1em" height="1em" name="check"></i-bs>
 | 
			
		||||
                }
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="d-flex justify-content-between w-100 align-items-center ps-2">
 | 
			
		||||
                <div class="pe-2 pe-lg-4">
 | 
			
		||||
                  {{rd.name}}
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="text-muted small pe-2">
 | 
			
		||||
                  <span class="small">
 | 
			
		||||
                    {{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container>
 | 
			
		||||
                  </span>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </button>
 | 
			
		||||
          }
 | 
			
		||||
          <div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
 | 
			
		||||
 | 
			
		||||
            <div class="mb-2 d-flex flex-row w-100 justify-content-between small">
 | 
			
		||||
              <div i18n>After</div>
 | 
			
		||||
              @if (createdDateAfter) {
 | 
			
		||||
                <a class="btn btn-link p-0 m-0" (click)="clearCreatedAfter()">
 | 
			
		||||
                  <i-bs width="1em" height="1em" name="x"></i-bs>
 | 
			
		||||
                  <small i18n>Clear</small>
 | 
			
		||||
                </a>
 | 
			
		||||
              }
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="input-group input-group-sm">
 | 
			
		||||
              <input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
 | 
			
		||||
                maxlength="10" [(ngModel)]="createdDateAfter" ngbDatepicker #createdDateAfterPicker="ngbDatepicker">
 | 
			
		||||
              <button class="btn btn-outline-secondary" (click)="createdDateAfterPicker.toggle()" type="button">
 | 
			
		||||
                <i-bs width="1em" height="1em" name="calendar"></i-bs>
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
 | 
			
		||||
 | 
			
		||||
            <div class="mb-2 d-flex flex-row w-100 justify-content-between small">
 | 
			
		||||
              <div i18n>Before</div>
 | 
			
		||||
              @if (createdDateBefore) {
 | 
			
		||||
                <a class="btn btn-link p-0 m-0" (click)="clearCreatedBefore()">
 | 
			
		||||
                  <i-bs width="1em" height="1em" name="x"></i-bs>
 | 
			
		||||
                  <small i18n>Clear</small>
 | 
			
		||||
                </a>
 | 
			
		||||
              }
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="input-group input-group-sm">
 | 
			
		||||
              <input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
 | 
			
		||||
                maxlength="10" [(ngModel)]="createdDateBefore" ngbDatepicker #createdDateBeforePicker="ngbDatepicker">
 | 
			
		||||
              <button class="btn btn-outline-secondary" (click)="createdDateBeforePicker.toggle()" type="button">
 | 
			
		||||
                <i-bs width="1em" height="1em" name="calendar"></i-bs>
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="col">
 | 
			
		||||
        <h6 class="dropdown-header border-bottom" i18n>Added</h6>
 | 
			
		||||
        <div class="list-group list-group-flush">
 | 
			
		||||
          @for (rd of relativeDates; track rd) {
 | 
			
		||||
            <button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setAddedRelativeDate(rd.id)">
 | 
			
		||||
              <div class="selected-icon">
 | 
			
		||||
                @if (addedRelativeDate === rd.id) {
 | 
			
		||||
                  <i-bs width="1em" height="1em" name="check"></i-bs>
 | 
			
		||||
                }
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="d-flex justify-content-between w-100 align-items-center ps-2">
 | 
			
		||||
                <div class="pe-2 pe-lg-4">
 | 
			
		||||
                  {{rd.name}}
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="text-muted small pe-2">
 | 
			
		||||
                  <span class="small">
 | 
			
		||||
                    {{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container>
 | 
			
		||||
                  </span>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </button>
 | 
			
		||||
          }
 | 
			
		||||
          <div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
 | 
			
		||||
 | 
			
		||||
            <div class="mb-2 d-flex flex-row w-100 justify-content-between small">
 | 
			
		||||
              <div i18n>After</div>
 | 
			
		||||
              @if (addedDateAfter) {
 | 
			
		||||
                <a class="btn btn-link p-0 m-0" (click)="clearAddedAfter()">
 | 
			
		||||
                  <i-bs width="1em" height="1em" name="x"></i-bs>
 | 
			
		||||
                  <small i18n>Clear</small>
 | 
			
		||||
                </a>
 | 
			
		||||
              }
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="input-group input-group-sm">
 | 
			
		||||
              <input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
 | 
			
		||||
                maxlength="10" [(ngModel)]="addedDateAfter" ngbDatepicker #addedDateAfterPicker="ngbDatepicker">
 | 
			
		||||
              <button class="btn btn-outline-secondary" (click)="addedDateAfterPicker.toggle()" type="button">
 | 
			
		||||
                <i-bs width="1em" height="1em" name="calendar"></i-bs>
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
 | 
			
		||||
 | 
			
		||||
            <div class="mb-2 d-flex flex-row w-100 justify-content-between small">
 | 
			
		||||
              <div i18n>Before</div>
 | 
			
		||||
              @if (addedDateBefore) {
 | 
			
		||||
                <a class="btn btn-link p-0 m-0" (click)="clearAddedBefore()">
 | 
			
		||||
                  <i-bs width="1em" height="1em" name="x"></i-bs>
 | 
			
		||||
                  <small i18n>Clear</small>
 | 
			
		||||
                </a>
 | 
			
		||||
              }
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div class="input-group input-group-sm">
 | 
			
		||||
              <input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
 | 
			
		||||
                maxlength="10" [(ngModel)]="addedDateBefore" ngbDatepicker #addedDateBeforePicker="ngbDatepicker">
 | 
			
		||||
              <button class="btn btn-outline-secondary" (click)="addedDateBeforePicker.toggle()" type="button">
 | 
			
		||||
                <i-bs width="1em" height="1em" name="calendar"></i-bs>
 | 
			
		||||
              </button>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -1,6 +1,10 @@
 | 
			
		||||
.date-dropdown {
 | 
			
		||||
  white-space: nowrap;
 | 
			
		||||
 | 
			
		||||
  @media(min-width: 768px) {
 | 
			
		||||
    --bs-dropdown-min-width: 40rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .btn-link {
 | 
			
		||||
    line-height: 1;
 | 
			
		||||
  }
 | 
			
		||||
@@ -4,12 +4,12 @@ import {
 | 
			
		||||
  fakeAsync,
 | 
			
		||||
  tick,
 | 
			
		||||
} from '@angular/core/testing'
 | 
			
		||||
let fixture: ComponentFixture<DateDropdownComponent>
 | 
			
		||||
let fixture: ComponentFixture<DatesDropdownComponent>
 | 
			
		||||
import {
 | 
			
		||||
  DateDropdownComponent,
 | 
			
		||||
  DatesDropdownComponent,
 | 
			
		||||
  DateSelection,
 | 
			
		||||
  RelativeDate,
 | 
			
		||||
} from './date-dropdown.component'
 | 
			
		||||
} from './dates-dropdown.component'
 | 
			
		||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
 | 
			
		||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service'
 | 
			
		||||
@@ -19,15 +19,15 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 | 
			
		||||
import { DatePipe } from '@angular/common'
 | 
			
		||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
 | 
			
		||||
 | 
			
		||||
describe('DateDropdownComponent', () => {
 | 
			
		||||
  let component: DateDropdownComponent
 | 
			
		||||
describe('DatesDropdownComponent', () => {
 | 
			
		||||
  let component: DatesDropdownComponent
 | 
			
		||||
  let settingsService: SettingsService
 | 
			
		||||
  let settingsSpy
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    TestBed.configureTestingModule({
 | 
			
		||||
      declarations: [
 | 
			
		||||
        DateDropdownComponent,
 | 
			
		||||
        DatesDropdownComponent,
 | 
			
		||||
        ClearableBadgeComponent,
 | 
			
		||||
        CustomDatePipe,
 | 
			
		||||
      ],
 | 
			
		||||
@@ -44,7 +44,7 @@ describe('DateDropdownComponent', () => {
 | 
			
		||||
    settingsService = TestBed.inject(SettingsService)
 | 
			
		||||
    settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat')
 | 
			
		||||
 | 
			
		||||
    fixture = TestBed.createComponent(DateDropdownComponent)
 | 
			
		||||
    fixture = TestBed.createComponent(DatesDropdownComponent)
 | 
			
		||||
    component = fixture.componentInstance
 | 
			
		||||
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
@@ -57,7 +57,7 @@ describe('DateDropdownComponent', () => {
 | 
			
		||||
 | 
			
		||||
  it('should support date input, emit change', fakeAsync(() => {
 | 
			
		||||
    let result: string
 | 
			
		||||
    component.dateAfterChange.subscribe((date) => (result = date))
 | 
			
		||||
    component.createdDateAfterChange.subscribe((date) => (result = date))
 | 
			
		||||
    const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
 | 
			
		||||
    input.value = '5/30/2023'
 | 
			
		||||
    input.dispatchEvent(new Event('change'))
 | 
			
		||||
@@ -78,45 +78,69 @@ describe('DateDropdownComponent', () => {
 | 
			
		||||
  it('should support relative dates', fakeAsync(() => {
 | 
			
		||||
    let result: DateSelection
 | 
			
		||||
    component.datesSet.subscribe((date) => (result = date))
 | 
			
		||||
    component.setRelativeDate(null)
 | 
			
		||||
    component.setRelativeDate(RelativeDate.LAST_7_DAYS)
 | 
			
		||||
    component.setCreatedRelativeDate(null)
 | 
			
		||||
    component.setCreatedRelativeDate(RelativeDate.LAST_7_DAYS)
 | 
			
		||||
    component.setAddedRelativeDate(null)
 | 
			
		||||
    component.setAddedRelativeDate(RelativeDate.LAST_7_DAYS)
 | 
			
		||||
    tick(500)
 | 
			
		||||
    expect(result).toEqual({
 | 
			
		||||
      after: null,
 | 
			
		||||
      before: null,
 | 
			
		||||
      relativeDateID: RelativeDate.LAST_7_DAYS,
 | 
			
		||||
      createdAfter: null,
 | 
			
		||||
      createdBefore: null,
 | 
			
		||||
      createdRelativeDateID: RelativeDate.LAST_7_DAYS,
 | 
			
		||||
      addedAfter: null,
 | 
			
		||||
      addedBefore: null,
 | 
			
		||||
      addedRelativeDateID: RelativeDate.LAST_7_DAYS,
 | 
			
		||||
    })
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should support report if active', () => {
 | 
			
		||||
    component.relativeDate = RelativeDate.LAST_7_DAYS
 | 
			
		||||
    component.createdRelativeDate = RelativeDate.LAST_7_DAYS
 | 
			
		||||
    expect(component.isActive).toBeTruthy()
 | 
			
		||||
    component.relativeDate = null
 | 
			
		||||
    component.dateAfter = '2023-05-30'
 | 
			
		||||
    component.createdRelativeDate = null
 | 
			
		||||
    component.createdDateAfter = '2023-05-30'
 | 
			
		||||
    expect(component.isActive).toBeTruthy()
 | 
			
		||||
    component.dateAfter = null
 | 
			
		||||
    component.dateBefore = '2023-05-30'
 | 
			
		||||
    component.createdDateAfter = null
 | 
			
		||||
    component.createdDateBefore = '2023-05-30'
 | 
			
		||||
    expect(component.isActive).toBeTruthy()
 | 
			
		||||
    component.dateBefore = null
 | 
			
		||||
    component.createdDateBefore = null
 | 
			
		||||
 | 
			
		||||
    component.addedRelativeDate = RelativeDate.LAST_7_DAYS
 | 
			
		||||
    expect(component.isActive).toBeTruthy()
 | 
			
		||||
    component.addedRelativeDate = null
 | 
			
		||||
    component.addedDateAfter = '2023-05-30'
 | 
			
		||||
    expect(component.isActive).toBeTruthy()
 | 
			
		||||
    component.addedDateAfter = null
 | 
			
		||||
    component.addedDateBefore = '2023-05-30'
 | 
			
		||||
    expect(component.isActive).toBeTruthy()
 | 
			
		||||
    component.addedDateBefore = null
 | 
			
		||||
 | 
			
		||||
    expect(component.isActive).toBeFalsy()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support reset', () => {
 | 
			
		||||
    component.dateAfter = '2023-05-30'
 | 
			
		||||
    component.createdDateAfter = '2023-05-30'
 | 
			
		||||
    component.reset()
 | 
			
		||||
    expect(component.dateAfter).toBeNull()
 | 
			
		||||
    expect(component.createdDateAfter).toBeNull()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support clearAfter', () => {
 | 
			
		||||
    component.dateAfter = '2023-05-30'
 | 
			
		||||
    component.clearAfter()
 | 
			
		||||
    expect(component.dateAfter).toBeNull()
 | 
			
		||||
    component.createdDateAfter = '2023-05-30'
 | 
			
		||||
    component.clearCreatedAfter()
 | 
			
		||||
    expect(component.createdDateAfter).toBeNull()
 | 
			
		||||
 | 
			
		||||
    component.addedDateAfter = '2023-05-30'
 | 
			
		||||
    component.clearAddedAfter()
 | 
			
		||||
    expect(component.addedDateAfter).toBeNull()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support clearBefore', () => {
 | 
			
		||||
    component.dateBefore = '2023-05-30'
 | 
			
		||||
    component.clearBefore()
 | 
			
		||||
    expect(component.dateBefore).toBeNull()
 | 
			
		||||
    component.createdDateBefore = '2023-05-30'
 | 
			
		||||
    component.clearCreatedBefore()
 | 
			
		||||
    expect(component.createdDateBefore).toBeNull()
 | 
			
		||||
 | 
			
		||||
    component.addedDateBefore = '2023-05-30'
 | 
			
		||||
    component.clearAddedBefore()
 | 
			
		||||
    expect(component.addedDateBefore).toBeNull()
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should limit keyboard events', () => {
 | 
			
		||||
@@ -0,0 +1,219 @@
 | 
			
		||||
import {
 | 
			
		||||
  Component,
 | 
			
		||||
  EventEmitter,
 | 
			
		||||
  Input,
 | 
			
		||||
  Output,
 | 
			
		||||
  OnInit,
 | 
			
		||||
  OnDestroy,
 | 
			
		||||
} from '@angular/core'
 | 
			
		||||
import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'
 | 
			
		||||
import { Subject, Subscription } from 'rxjs'
 | 
			
		||||
import { debounceTime } from 'rxjs/operators'
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service'
 | 
			
		||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
 | 
			
		||||
 | 
			
		||||
export interface DateSelection {
 | 
			
		||||
  createdBefore?: string
 | 
			
		||||
  createdAfter?: string
 | 
			
		||||
  createdRelativeDateID?: number
 | 
			
		||||
  addedBefore?: string
 | 
			
		||||
  addedAfter?: string
 | 
			
		||||
  addedRelativeDateID?: number
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum RelativeDate {
 | 
			
		||||
  LAST_7_DAYS = 0,
 | 
			
		||||
  LAST_MONTH = 1,
 | 
			
		||||
  LAST_3_MONTHS = 2,
 | 
			
		||||
  LAST_YEAR = 3,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'pngx-dates-dropdown',
 | 
			
		||||
  templateUrl: './dates-dropdown.component.html',
 | 
			
		||||
  styleUrls: ['./dates-dropdown.component.scss'],
 | 
			
		||||
  providers: [{ provide: NgbDateAdapter, useClass: ISODateAdapter }],
 | 
			
		||||
})
 | 
			
		||||
export class DatesDropdownComponent implements OnInit, OnDestroy {
 | 
			
		||||
  constructor(settings: SettingsService) {
 | 
			
		||||
    this.datePlaceHolder = settings.getLocalizedDateInputFormat()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  relativeDates = [
 | 
			
		||||
    {
 | 
			
		||||
      id: RelativeDate.LAST_7_DAYS,
 | 
			
		||||
      name: $localize`Last 7 days`,
 | 
			
		||||
      date: new Date().setDate(new Date().getDate() - 7),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      id: RelativeDate.LAST_MONTH,
 | 
			
		||||
      name: $localize`Last month`,
 | 
			
		||||
      date: new Date().setMonth(new Date().getMonth() - 1),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      id: RelativeDate.LAST_3_MONTHS,
 | 
			
		||||
      name: $localize`Last 3 months`,
 | 
			
		||||
      date: new Date().setMonth(new Date().getMonth() - 3),
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      id: RelativeDate.LAST_YEAR,
 | 
			
		||||
      name: $localize`Last year`,
 | 
			
		||||
      date: new Date().setFullYear(new Date().getFullYear() - 1),
 | 
			
		||||
    },
 | 
			
		||||
  ]
 | 
			
		||||
 | 
			
		||||
  datePlaceHolder: string
 | 
			
		||||
 | 
			
		||||
  // created
 | 
			
		||||
  @Input()
 | 
			
		||||
  createdDateBefore: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  createdDateBeforeChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  createdDateAfter: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  createdDateAfterChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  createdRelativeDate: RelativeDate
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  createdRelativeDateChange = new EventEmitter<number>()
 | 
			
		||||
 | 
			
		||||
  // added
 | 
			
		||||
  @Input()
 | 
			
		||||
  addedDateBefore: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  addedDateBeforeChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  addedDateAfter: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  addedDateAfterChange = new EventEmitter<string>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  addedRelativeDate: RelativeDate
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  addedRelativeDateChange = new EventEmitter<number>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  title: string
 | 
			
		||||
 | 
			
		||||
  @Output()
 | 
			
		||||
  datesSet = new EventEmitter<DateSelection>()
 | 
			
		||||
 | 
			
		||||
  @Input()
 | 
			
		||||
  disabled: boolean = false
 | 
			
		||||
 | 
			
		||||
  get isActive(): boolean {
 | 
			
		||||
    return (
 | 
			
		||||
      this.createdRelativeDate !== null ||
 | 
			
		||||
      this.createdDateAfter?.length > 0 ||
 | 
			
		||||
      this.createdDateBefore?.length > 0 ||
 | 
			
		||||
      this.addedRelativeDate !== null ||
 | 
			
		||||
      this.addedDateAfter?.length > 0 ||
 | 
			
		||||
      this.addedDateBefore?.length > 0
 | 
			
		||||
    )
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private datesSetDebounce$ = new Subject()
 | 
			
		||||
 | 
			
		||||
  private sub: Subscription
 | 
			
		||||
 | 
			
		||||
  ngOnInit() {
 | 
			
		||||
    this.sub = this.datesSetDebounce$.pipe(debounceTime(400)).subscribe(() => {
 | 
			
		||||
      this.onChange()
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ngOnDestroy() {
 | 
			
		||||
    if (this.sub) {
 | 
			
		||||
      this.sub.unsubscribe()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  reset() {
 | 
			
		||||
    this.createdDateBefore = null
 | 
			
		||||
    this.createdDateAfter = null
 | 
			
		||||
    this.createdRelativeDate = null
 | 
			
		||||
    this.addedDateBefore = null
 | 
			
		||||
    this.addedDateAfter = null
 | 
			
		||||
    this.addedRelativeDate = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setCreatedRelativeDate(rd: RelativeDate) {
 | 
			
		||||
    this.createdDateBefore = null
 | 
			
		||||
    this.createdDateAfter = null
 | 
			
		||||
    this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setAddedRelativeDate(rd: RelativeDate) {
 | 
			
		||||
    this.addedDateBefore = null
 | 
			
		||||
    this.addedDateAfter = null
 | 
			
		||||
    this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChange() {
 | 
			
		||||
    this.createdDateBeforeChange.emit(this.createdDateBefore)
 | 
			
		||||
    this.createdDateAfterChange.emit(this.createdDateAfter)
 | 
			
		||||
    this.createdRelativeDateChange.emit(this.createdRelativeDate)
 | 
			
		||||
    this.addedDateBeforeChange.emit(this.addedDateBefore)
 | 
			
		||||
    this.addedDateAfterChange.emit(this.addedDateAfter)
 | 
			
		||||
    this.addedRelativeDateChange.emit(this.addedRelativeDate)
 | 
			
		||||
    this.datesSet.emit({
 | 
			
		||||
      createdAfter: this.createdDateAfter,
 | 
			
		||||
      createdBefore: this.createdDateBefore,
 | 
			
		||||
      createdRelativeDateID: this.createdRelativeDate,
 | 
			
		||||
      addedAfter: this.addedDateAfter,
 | 
			
		||||
      addedBefore: this.addedDateBefore,
 | 
			
		||||
      addedRelativeDateID: this.addedRelativeDate,
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onChangeDebounce() {
 | 
			
		||||
    this.createdRelativeDate = null
 | 
			
		||||
    this.addedRelativeDate = null
 | 
			
		||||
    this.datesSetDebounce$.next({
 | 
			
		||||
      createdAfter: this.createdDateAfter,
 | 
			
		||||
      createdBefore: this.createdDateBefore,
 | 
			
		||||
      addedAfter: this.addedDateAfter,
 | 
			
		||||
      addedBefore: this.addedDateBefore,
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearCreatedBefore() {
 | 
			
		||||
    this.createdDateBefore = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearCreatedAfter() {
 | 
			
		||||
    this.createdDateAfter = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearAddedBefore() {
 | 
			
		||||
    this.addedDateBefore = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  clearAddedAfter() {
 | 
			
		||||
    this.addedDateAfter = null
 | 
			
		||||
    this.onChange()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // prevent chars other than numbers and separators
 | 
			
		||||
  onKeyPress(event: KeyboardEvent) {
 | 
			
		||||
    if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {
 | 
			
		||||
      event.preventDefault()
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -74,6 +74,20 @@
 | 
			
		||||
              (apply)="setStoragePaths($event)">
 | 
			
		||||
            </pngx-filterable-dropdown>
 | 
			
		||||
          }
 | 
			
		||||
          @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
 | 
			
		||||
            <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
 | 
			
		||||
              filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
 | 
			
		||||
              [items]="customFields"
 | 
			
		||||
              [disabled]="!userCanEditAll"
 | 
			
		||||
              [editing]="true"
 | 
			
		||||
              [applyOnClose]="applyOnClose"
 | 
			
		||||
              [createRef]="createCustomField.bind(this)"
 | 
			
		||||
              (opened)="openCustomFieldsDropdown()"
 | 
			
		||||
              [(selectionModel)]="customFieldsSelectionModel"
 | 
			
		||||
              [documentCounts]="customFieldDocumentCounts"
 | 
			
		||||
              (apply)="setCustomFields($event)">
 | 
			
		||||
            </pngx-filterable-dropdown>
 | 
			
		||||
          }
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="d-flex align-items-center gap-2 ms-auto">
 | 
			
		||||
          <div class="btn-toolbar">
 | 
			
		||||
 
 | 
			
		||||
@@ -55,6 +55,9 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
 | 
			
		||||
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
 | 
			
		||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
 | 
			
		||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
 | 
			
		||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 | 
			
		||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
 | 
			
		||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
 | 
			
		||||
 | 
			
		||||
const selectionData: SelectionData = {
 | 
			
		||||
  selected_tags: [
 | 
			
		||||
@@ -68,6 +71,10 @@ const selectionData: SelectionData = {
 | 
			
		||||
    { id: 66, document_count: 3 },
 | 
			
		||||
    { id: 55, document_count: 0 },
 | 
			
		||||
  ],
 | 
			
		||||
  selected_custom_fields: [
 | 
			
		||||
    { id: 77, document_count: 3 },
 | 
			
		||||
    { id: 88, document_count: 0 },
 | 
			
		||||
  ],
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
describe('BulkEditorComponent', () => {
 | 
			
		||||
@@ -82,6 +89,7 @@ describe('BulkEditorComponent', () => {
 | 
			
		||||
  let correspondentsService: CorrespondentService
 | 
			
		||||
  let documentTypeService: DocumentTypeService
 | 
			
		||||
  let storagePathService: StoragePathService
 | 
			
		||||
  let customFieldsService: CustomFieldsService
 | 
			
		||||
  let httpTestingController: HttpTestingController
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
@@ -148,6 +156,18 @@ describe('BulkEditorComponent', () => {
 | 
			
		||||
              }),
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          provide: CustomFieldsService,
 | 
			
		||||
          useValue: {
 | 
			
		||||
            listAll: () =>
 | 
			
		||||
              of({
 | 
			
		||||
                results: [
 | 
			
		||||
                  { id: 77, name: 'customfield1' },
 | 
			
		||||
                  { id: 88, name: 'customfield2' },
 | 
			
		||||
                ],
 | 
			
		||||
              }),
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        FilterPipe,
 | 
			
		||||
        SettingsService,
 | 
			
		||||
        {
 | 
			
		||||
@@ -189,6 +209,7 @@ describe('BulkEditorComponent', () => {
 | 
			
		||||
    correspondentsService = TestBed.inject(CorrespondentService)
 | 
			
		||||
    documentTypeService = TestBed.inject(DocumentTypeService)
 | 
			
		||||
    storagePathService = TestBed.inject(StoragePathService)
 | 
			
		||||
    customFieldsService = TestBed.inject(CustomFieldsService)
 | 
			
		||||
    httpTestingController = TestBed.inject(HttpTestingController)
 | 
			
		||||
 | 
			
		||||
    fixture = TestBed.createComponent(BulkEditorComponent)
 | 
			
		||||
@@ -262,6 +283,22 @@ describe('BulkEditorComponent', () => {
 | 
			
		||||
    expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should apply selection data to custom fields menu', () => {
 | 
			
		||||
    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    expect(
 | 
			
		||||
      component.customFieldsSelectionModel.getSelectedItems()
 | 
			
		||||
    ).toHaveLength(0)
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(documentListViewService, 'selected', 'get')
 | 
			
		||||
      .mockReturnValue(new Set([3, 5, 7]))
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(documentService, 'getSelectionData')
 | 
			
		||||
      .mockReturnValue(of(selectionData))
 | 
			
		||||
    component.openCustomFieldsDropdown()
 | 
			
		||||
    expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should execute modify tags bulk operation', () => {
 | 
			
		||||
    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
 | 
			
		||||
    jest
 | 
			
		||||
@@ -679,6 +716,122 @@ describe('BulkEditorComponent', () => {
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should execute modify custom fields bulk operation', () => {
 | 
			
		||||
    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(documentListViewService, 'documents', 'get')
 | 
			
		||||
      .mockReturnValue([{ id: 3 }, { id: 4 }])
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(documentListViewService, 'selected', 'get')
 | 
			
		||||
      .mockReturnValue(new Set([3, 4]))
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
 | 
			
		||||
      .mockReturnValue(true)
 | 
			
		||||
    component.showConfirmationDialogs = false
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    component.setCustomFields({
 | 
			
		||||
      itemsToAdd: [{ id: 101 }],
 | 
			
		||||
      itemsToRemove: [{ id: 102 }],
 | 
			
		||||
    })
 | 
			
		||||
    let req = httpTestingController.expectOne(
 | 
			
		||||
      `${environment.apiBaseUrl}documents/bulk_edit/`
 | 
			
		||||
    )
 | 
			
		||||
    req.flush(true)
 | 
			
		||||
    expect(req.request.body).toEqual({
 | 
			
		||||
      documents: [3, 4],
 | 
			
		||||
      method: 'modify_custom_fields',
 | 
			
		||||
      parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
 | 
			
		||||
    })
 | 
			
		||||
    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
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => {
 | 
			
		||||
    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(documentListViewService, 'selected', 'get')
 | 
			
		||||
      .mockReturnValue(new Set([3, 4]))
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
 | 
			
		||||
      .mockReturnValue(true)
 | 
			
		||||
    component.showConfirmationDialogs = true
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    component.setCustomFields({
 | 
			
		||||
      itemsToAdd: [{ id: 101 }],
 | 
			
		||||
      itemsToRemove: [{ id: 102 }],
 | 
			
		||||
    })
 | 
			
		||||
    expect(modal).not.toBeUndefined()
 | 
			
		||||
    modal.componentInstance.confirm()
 | 
			
		||||
    httpTestingController
 | 
			
		||||
      .expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
 | 
			
		||||
      .flush(true)
 | 
			
		||||
    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
 | 
			
		||||
 | 
			
		||||
    // coverage for modal messages
 | 
			
		||||
    component.setCustomFields({
 | 
			
		||||
      itemsToAdd: [{ id: 101 }],
 | 
			
		||||
      itemsToRemove: [],
 | 
			
		||||
    })
 | 
			
		||||
    component.setCustomFields({
 | 
			
		||||
      itemsToAdd: [{ id: 101 }, { id: 102 }],
 | 
			
		||||
      itemsToRemove: [],
 | 
			
		||||
    })
 | 
			
		||||
    component.setCustomFields({
 | 
			
		||||
      itemsToAdd: [],
 | 
			
		||||
      itemsToRemove: [{ id: 101 }, { id: 102 }],
 | 
			
		||||
    })
 | 
			
		||||
    component.setCustomFields({
 | 
			
		||||
      itemsToAdd: [{ id: 100 }],
 | 
			
		||||
      itemsToRemove: [{ id: 101 }, { id: 102 }],
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should set modal dialog text accordingly for custom fields edit confirmation', () => {
 | 
			
		||||
    let modal: NgbModalRef
 | 
			
		||||
    modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
 | 
			
		||||
    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(documentListViewService, 'documents', 'get')
 | 
			
		||||
      .mockReturnValue([{ id: 3 }, { id: 4 }])
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(documentListViewService, 'selected', 'get')
 | 
			
		||||
      .mockReturnValue(new Set([3, 4]))
 | 
			
		||||
    jest
 | 
			
		||||
      .spyOn(permissionsService, 'currentUserHasObjectPermissions')
 | 
			
		||||
      .mockReturnValue(true)
 | 
			
		||||
    component.showConfirmationDialogs = true
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    component.setCustomFields({
 | 
			
		||||
      itemsToAdd: [],
 | 
			
		||||
      itemsToRemove: [{ id: 101, name: 'CustomField 101' }],
 | 
			
		||||
    })
 | 
			
		||||
    expect(modal.componentInstance.message).toEqual(
 | 
			
		||||
      'This operation will remove the custom field "CustomField 101" from 2 selected document(s).'
 | 
			
		||||
    )
 | 
			
		||||
    modal.close()
 | 
			
		||||
    component.setCustomFields({
 | 
			
		||||
      itemsToAdd: [{ id: 101, name: 'CustomField 101' }],
 | 
			
		||||
      itemsToRemove: [],
 | 
			
		||||
    })
 | 
			
		||||
    expect(modal.componentInstance.message).toEqual(
 | 
			
		||||
      'This operation will assign the custom field "CustomField 101" to 2 selected document(s).'
 | 
			
		||||
    )
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should only execute bulk operations when changes are detected', () => {
 | 
			
		||||
    component.setTags({
 | 
			
		||||
      itemsToAdd: [],
 | 
			
		||||
@@ -696,6 +849,10 @@ describe('BulkEditorComponent', () => {
 | 
			
		||||
      itemsToAdd: [],
 | 
			
		||||
      itemsToRemove: [],
 | 
			
		||||
    })
 | 
			
		||||
    component.setCustomFields({
 | 
			
		||||
      itemsToAdd: [],
 | 
			
		||||
      itemsToRemove: [],
 | 
			
		||||
    })
 | 
			
		||||
    httpTestingController.expectNone(
 | 
			
		||||
      `${environment.apiBaseUrl}documents/bulk_edit/`
 | 
			
		||||
    )
 | 
			
		||||
@@ -1179,4 +1336,56 @@ describe('BulkEditorComponent', () => {
 | 
			
		||||
    )
 | 
			
		||||
    expect(component.storagePaths).toEqual(storagePaths.results)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  it('should support create new custom field', () => {
 | 
			
		||||
    const name = 'New Custom Field'
 | 
			
		||||
    const newCustomField = { id: 101, name: 'New Custom Field' }
 | 
			
		||||
    const customFields: Results<CustomField> = {
 | 
			
		||||
      results: [
 | 
			
		||||
        {
 | 
			
		||||
          id: 1,
 | 
			
		||||
          name: 'Custom Field 1',
 | 
			
		||||
          data_type: CustomFieldDataType.String,
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          id: 2,
 | 
			
		||||
          name: 'Custom Field 2',
 | 
			
		||||
          data_type: CustomFieldDataType.String,
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
      count: 2,
 | 
			
		||||
      all: [1, 2],
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const modalInstance = {
 | 
			
		||||
      componentInstance: {
 | 
			
		||||
        dialogMode: EditDialogMode.CREATE,
 | 
			
		||||
        object: { name },
 | 
			
		||||
        succeeded: of(newCustomField),
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
    const customFieldsListAllSpy = jest.spyOn(customFieldsService, 'listAll')
 | 
			
		||||
    customFieldsListAllSpy.mockReturnValue(of(customFields))
 | 
			
		||||
 | 
			
		||||
    const customFieldsSelectionModelToggleSpy = jest.spyOn(
 | 
			
		||||
      component.customFieldsSelectionModel,
 | 
			
		||||
      'toggle'
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
 | 
			
		||||
    modalServiceOpenSpy.mockReturnValue(modalInstance as any)
 | 
			
		||||
 | 
			
		||||
    component.createCustomField(name)
 | 
			
		||||
 | 
			
		||||
    expect(modalServiceOpenSpy).toHaveBeenCalledWith(
 | 
			
		||||
      CustomFieldEditDialogComponent,
 | 
			
		||||
      { backdrop: 'static' }
 | 
			
		||||
    )
 | 
			
		||||
    expect(customFieldsListAllSpy).toHaveBeenCalled()
 | 
			
		||||
 | 
			
		||||
    expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith(
 | 
			
		||||
      newCustomField.id
 | 
			
		||||
    )
 | 
			
		||||
    expect(component.customFields).toEqual(customFields.results)
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 
 | 
			
		||||
@@ -41,6 +41,9 @@ import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/docume
 | 
			
		||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
 | 
			
		||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
 | 
			
		||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
 | 
			
		||||
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'
 | 
			
		||||
 | 
			
		||||
@Component({
 | 
			
		||||
  selector: 'pngx-bulk-editor',
 | 
			
		||||
@@ -55,15 +58,18 @@ export class BulkEditorComponent
 | 
			
		||||
  correspondents: Correspondent[]
 | 
			
		||||
  documentTypes: DocumentType[]
 | 
			
		||||
  storagePaths: StoragePath[]
 | 
			
		||||
  customFields: CustomField[]
 | 
			
		||||
 | 
			
		||||
  tagSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  correspondentSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  documentTypeSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  storagePathsSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  customFieldsSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  tagDocumentCounts: SelectionDataItem[]
 | 
			
		||||
  correspondentDocumentCounts: SelectionDataItem[]
 | 
			
		||||
  documentTypeDocumentCounts: SelectionDataItem[]
 | 
			
		||||
  storagePathDocumentCounts: SelectionDataItem[]
 | 
			
		||||
  customFieldDocumentCounts: SelectionDataItem[]
 | 
			
		||||
  awaitingDownload: boolean
 | 
			
		||||
 | 
			
		||||
  unsubscribeNotifier: Subject<any> = new Subject()
 | 
			
		||||
@@ -85,6 +91,7 @@ export class BulkEditorComponent
 | 
			
		||||
    private settings: SettingsService,
 | 
			
		||||
    private toastService: ToastService,
 | 
			
		||||
    private storagePathService: StoragePathService,
 | 
			
		||||
    private customFieldService: CustomFieldsService,
 | 
			
		||||
    private permissionService: PermissionsService
 | 
			
		||||
  ) {
 | 
			
		||||
    super()
 | 
			
		||||
@@ -166,6 +173,17 @@ export class BulkEditorComponent
 | 
			
		||||
        .pipe(first())
 | 
			
		||||
        .subscribe((result) => (this.storagePaths = result.results))
 | 
			
		||||
    }
 | 
			
		||||
    if (
 | 
			
		||||
      this.permissionService.currentUserCan(
 | 
			
		||||
        PermissionAction.View,
 | 
			
		||||
        PermissionType.CustomField
 | 
			
		||||
      )
 | 
			
		||||
    ) {
 | 
			
		||||
      this.customFieldService
 | 
			
		||||
        .listAll()
 | 
			
		||||
        .pipe(first())
 | 
			
		||||
        .subscribe((result) => (this.customFields = result.results))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.downloadForm
 | 
			
		||||
      .get('downloadFileTypeArchive')
 | 
			
		||||
@@ -297,6 +315,19 @@ export class BulkEditorComponent
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  openCustomFieldsDropdown() {
 | 
			
		||||
    this.documentService
 | 
			
		||||
      .getSelectionData(Array.from(this.list.selected))
 | 
			
		||||
      .pipe(first())
 | 
			
		||||
      .subscribe((s) => {
 | 
			
		||||
        this.customFieldDocumentCounts = s.selected_custom_fields
 | 
			
		||||
        this.applySelectionData(
 | 
			
		||||
          s.selected_custom_fields,
 | 
			
		||||
          this.customFieldsSelectionModel
 | 
			
		||||
        )
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private _localizeList(items: MatchingModel[]) {
 | 
			
		||||
    if (items.length == 0) {
 | 
			
		||||
      return ''
 | 
			
		||||
@@ -495,6 +526,74 @@ export class BulkEditorComponent
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setCustomFields(changedCustomFields: ChangedItems) {
 | 
			
		||||
    if (
 | 
			
		||||
      changedCustomFields.itemsToAdd.length == 0 &&
 | 
			
		||||
      changedCustomFields.itemsToRemove.length == 0
 | 
			
		||||
    )
 | 
			
		||||
      return
 | 
			
		||||
 | 
			
		||||
    if (this.showConfirmationDialogs) {
 | 
			
		||||
      let modal = this.modalService.open(ConfirmDialogComponent, {
 | 
			
		||||
        backdrop: 'static',
 | 
			
		||||
      })
 | 
			
		||||
      modal.componentInstance.title = $localize`Confirm custom field assignment`
 | 
			
		||||
      if (
 | 
			
		||||
        changedCustomFields.itemsToAdd.length == 1 &&
 | 
			
		||||
        changedCustomFields.itemsToRemove.length == 0
 | 
			
		||||
      ) {
 | 
			
		||||
        let customField = changedCustomFields.itemsToAdd[0]
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      } else if (
 | 
			
		||||
        changedCustomFields.itemsToAdd.length > 1 &&
 | 
			
		||||
        changedCustomFields.itemsToRemove.length == 0
 | 
			
		||||
      ) {
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
 | 
			
		||||
          changedCustomFields.itemsToAdd
 | 
			
		||||
        )} to ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      } else if (
 | 
			
		||||
        changedCustomFields.itemsToAdd.length == 0 &&
 | 
			
		||||
        changedCustomFields.itemsToRemove.length == 1
 | 
			
		||||
      ) {
 | 
			
		||||
        let customField = changedCustomFields.itemsToRemove[0]
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      } else if (
 | 
			
		||||
        changedCustomFields.itemsToAdd.length == 0 &&
 | 
			
		||||
        changedCustomFields.itemsToRemove.length > 1
 | 
			
		||||
      ) {
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
 | 
			
		||||
          changedCustomFields.itemsToRemove
 | 
			
		||||
        )} from ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      } else {
 | 
			
		||||
        modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
 | 
			
		||||
          changedCustomFields.itemsToAdd
 | 
			
		||||
        )} and remove the custom fields ${this._localizeList(
 | 
			
		||||
          changedCustomFields.itemsToRemove
 | 
			
		||||
        )} on ${this.list.selected.size} selected document(s).`
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      modal.componentInstance.btnClass = 'btn-warning'
 | 
			
		||||
      modal.componentInstance.btnCaption = $localize`Confirm`
 | 
			
		||||
      modal.componentInstance.confirmClicked
 | 
			
		||||
        .pipe(takeUntil(this.unsubscribeNotifier))
 | 
			
		||||
        .subscribe(() => {
 | 
			
		||||
          this.executeBulkOperation(modal, 'modify_custom_fields', {
 | 
			
		||||
            add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
 | 
			
		||||
            remove_custom_fields: changedCustomFields.itemsToRemove.map(
 | 
			
		||||
              (f) => f.id
 | 
			
		||||
            ),
 | 
			
		||||
          })
 | 
			
		||||
        })
 | 
			
		||||
    } else {
 | 
			
		||||
      this.executeBulkOperation(null, 'modify_custom_fields', {
 | 
			
		||||
        add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
 | 
			
		||||
        remove_custom_fields: changedCustomFields.itemsToRemove.map(
 | 
			
		||||
          (f) => f.id
 | 
			
		||||
        ),
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  createTag(name: string) {
 | 
			
		||||
    let modal = this.modalService.open(TagEditDialogComponent, {
 | 
			
		||||
      backdrop: 'static',
 | 
			
		||||
@@ -581,6 +680,27 @@ export class BulkEditorComponent
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  createCustomField(name: string) {
 | 
			
		||||
    let modal = this.modalService.open(CustomFieldEditDialogComponent, {
 | 
			
		||||
      backdrop: 'static',
 | 
			
		||||
    })
 | 
			
		||||
    modal.componentInstance.dialogMode = EditDialogMode.CREATE
 | 
			
		||||
    modal.componentInstance.object = { name }
 | 
			
		||||
    modal.componentInstance.succeeded
 | 
			
		||||
      .pipe(
 | 
			
		||||
        switchMap((newCustomField) => {
 | 
			
		||||
          return this.customFieldService
 | 
			
		||||
            .listAll()
 | 
			
		||||
            .pipe(map((customFields) => ({ newCustomField, customFields })))
 | 
			
		||||
        })
 | 
			
		||||
      )
 | 
			
		||||
      .pipe(takeUntil(this.unsubscribeNotifier))
 | 
			
		||||
      .subscribe(({ newCustomField, customFields }) => {
 | 
			
		||||
        this.customFields = customFields.results
 | 
			
		||||
        this.customFieldsSelectionModel.toggle(newCustomField.id)
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  applyDelete() {
 | 
			
		||||
    let modal = this.modalService.open(ConfirmDialogComponent, {
 | 
			
		||||
      backdrop: 'static',
 | 
			
		||||
 
 | 
			
		||||
@@ -5,7 +5,7 @@ import { RouterTestingModule } from '@angular/router/testing'
 | 
			
		||||
import { routes } from 'src/app/app-routing.module'
 | 
			
		||||
import { FilterEditorComponent } from './filter-editor/filter-editor.component'
 | 
			
		||||
import { PermissionsFilterDropdownComponent } from '../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
 | 
			
		||||
import { DateDropdownComponent } from '../common/date-dropdown/date-dropdown.component'
 | 
			
		||||
import { DatesDropdownComponent } from '../common/dates-dropdown/dates-dropdown.component'
 | 
			
		||||
import { FilterableDropdownComponent } from '../common/filterable-dropdown/filterable-dropdown.component'
 | 
			
		||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
 | 
			
		||||
import { BulkEditorComponent } from './bulk-editor/bulk-editor.component'
 | 
			
		||||
@@ -113,7 +113,7 @@ describe('DocumentListComponent', () => {
 | 
			
		||||
        PageHeaderComponent,
 | 
			
		||||
        FilterEditorComponent,
 | 
			
		||||
        FilterableDropdownComponent,
 | 
			
		||||
        DateDropdownComponent,
 | 
			
		||||
        DatesDropdownComponent,
 | 
			
		||||
        PermissionsFilterDropdownComponent,
 | 
			
		||||
        ToggleableDropdownButtonComponent,
 | 
			
		||||
        BulkEditorComponent,
 | 
			
		||||
 
 | 
			
		||||
@@ -70,22 +70,28 @@
 | 
			
		||||
          [documentCounts]="storagePathDocumentCounts"
 | 
			
		||||
          [allowSelectNone]="true"></pngx-filterable-dropdown>
 | 
			
		||||
        }
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="d-flex flex-wrap gap-2">
 | 
			
		||||
        <pngx-date-dropdown
 | 
			
		||||
          title="Created" i18n-title
 | 
			
		||||
 | 
			
		||||
        @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
 | 
			
		||||
          <pngx-filterable-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
 | 
			
		||||
          filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
 | 
			
		||||
          [items]="customFields"
 | 
			
		||||
          [manyToOne]="true"
 | 
			
		||||
          [(selectionModel)]="customFieldSelectionModel"
 | 
			
		||||
          (selectionModelChange)="updateRules()"
 | 
			
		||||
          (opened)="onCustomFieldsDropdownOpen()"
 | 
			
		||||
          [documentCounts]="customFieldDocumentCounts"
 | 
			
		||||
          [allowSelectNone]="true"></pngx-filterable-dropdown>
 | 
			
		||||
        }
 | 
			
		||||
        <pngx-dates-dropdown
 | 
			
		||||
          title="Dates" i18n-title
 | 
			
		||||
          (datesSet)="updateRules()"
 | 
			
		||||
          [(dateBefore)]="dateCreatedBefore"
 | 
			
		||||
          [(dateAfter)]="dateCreatedAfter"
 | 
			
		||||
        [(relativeDate)]="dateCreatedRelativeDate"></pngx-date-dropdown>
 | 
			
		||||
        <pngx-date-dropdown
 | 
			
		||||
          title="Added" i18n-title
 | 
			
		||||
          (datesSet)="updateRules()"
 | 
			
		||||
          [(dateBefore)]="dateAddedBefore"
 | 
			
		||||
          [(dateAfter)]="dateAddedAfter"
 | 
			
		||||
        [(relativeDate)]="dateAddedRelativeDate"></pngx-date-dropdown>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="d-flex flex-wrap">
 | 
			
		||||
          [(createdDateBefore)]="dateCreatedBefore"
 | 
			
		||||
          [(createdDateAfter)]="dateCreatedAfter"
 | 
			
		||||
          [(createdRelativeDate)]="dateCreatedRelativeDate"
 | 
			
		||||
          [(addedDateBefore)]="dateAddedBefore"
 | 
			
		||||
          [(addedDateAfter)]="dateAddedAfter"
 | 
			
		||||
          [(addedRelativeDate)]="dateAddedRelativeDate">
 | 
			
		||||
        </pngx-dates-dropdown>
 | 
			
		||||
        <pngx-permissions-filter-dropdown
 | 
			
		||||
          title="Permissions" i18n-title
 | 
			
		||||
          (ownerFilterSet)="updateRules()"
 | 
			
		||||
 
 | 
			
		||||
@@ -49,8 +49,12 @@ import {
 | 
			
		||||
  FILTER_OWNER_ANY,
 | 
			
		||||
  FILTER_OWNER_DOES_NOT_INCLUDE,
 | 
			
		||||
  FILTER_OWNER_ISNULL,
 | 
			
		||||
  FILTER_CUSTOM_FIELDS,
 | 
			
		||||
  FILTER_CUSTOM_FIELDS_TEXT,
 | 
			
		||||
  FILTER_SHARED_BY_USER,
 | 
			
		||||
  FILTER_HAS_CUSTOM_FIELDS_ANY,
 | 
			
		||||
  FILTER_HAS_ANY_CUSTOM_FIELDS,
 | 
			
		||||
  FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
 | 
			
		||||
  FILTER_HAS_CUSTOM_FIELDS_ALL,
 | 
			
		||||
} from 'src/app/data/filter-rule-type'
 | 
			
		||||
import { Correspondent } from 'src/app/data/correspondent'
 | 
			
		||||
import { DocumentType } from 'src/app/data/document-type'
 | 
			
		||||
@@ -68,7 +72,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
 | 
			
		||||
import { UserService } from 'src/app/services/rest/user.service'
 | 
			
		||||
import { SettingsService } from 'src/app/services/settings.service'
 | 
			
		||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
 | 
			
		||||
import { DateDropdownComponent } from '../../common/date-dropdown/date-dropdown.component'
 | 
			
		||||
import { DatesDropdownComponent } from '../../common/dates-dropdown/dates-dropdown.component'
 | 
			
		||||
import {
 | 
			
		||||
  FilterableDropdownComponent,
 | 
			
		||||
  LogicalOperator,
 | 
			
		||||
@@ -86,6 +90,8 @@ import {
 | 
			
		||||
  PermissionsService,
 | 
			
		||||
} from 'src/app/services/permissions.service'
 | 
			
		||||
import { environment } from 'src/environments/environment'
 | 
			
		||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
 | 
			
		||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 | 
			
		||||
 | 
			
		||||
const tags: Tag[] = [
 | 
			
		||||
  {
 | 
			
		||||
@@ -131,6 +137,19 @@ const storage_paths: StoragePath[] = [
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const custom_fields: CustomField[] = [
 | 
			
		||||
  {
 | 
			
		||||
    id: 42,
 | 
			
		||||
    data_type: CustomFieldDataType.String,
 | 
			
		||||
    name: 'CustomField42',
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 43,
 | 
			
		||||
    data_type: CustomFieldDataType.String,
 | 
			
		||||
    name: 'CustomField43',
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const users: User[] = [
 | 
			
		||||
  {
 | 
			
		||||
    id: 1,
 | 
			
		||||
@@ -156,7 +175,7 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
        IfPermissionsDirective,
 | 
			
		||||
        ClearableBadgeComponent,
 | 
			
		||||
        ToggleableDropdownButtonComponent,
 | 
			
		||||
        DateDropdownComponent,
 | 
			
		||||
        DatesDropdownComponent,
 | 
			
		||||
        CustomDatePipe,
 | 
			
		||||
      ],
 | 
			
		||||
      providers: [
 | 
			
		||||
@@ -187,6 +206,12 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
            listAll: () => of({ results: storage_paths }),
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          provide: CustomFieldsService,
 | 
			
		||||
          useValue: {
 | 
			
		||||
            listAll: () => of({ results: custom_fields }),
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          provide: UserService,
 | 
			
		||||
          useValue: {
 | 
			
		||||
@@ -285,7 +310,7 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
    expect(component.textFilter).toEqual(null)
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_CUSTOM_FIELDS,
 | 
			
		||||
        rule_type: FILTER_CUSTOM_FIELDS_TEXT,
 | 
			
		||||
        value: 'foo',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
@@ -806,6 +831,110 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
    ]
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should ingest filter rules for has all custom fields', fakeAsync(() => {
 | 
			
		||||
    expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
 | 
			
		||||
      0
 | 
			
		||||
    )
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
 | 
			
		||||
        value: '42',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
 | 
			
		||||
        value: '43',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.customFieldSelectionModel.logicalOperator).toEqual(
 | 
			
		||||
      LogicalOperator.And
 | 
			
		||||
    )
 | 
			
		||||
    expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
 | 
			
		||||
      custom_fields
 | 
			
		||||
    )
 | 
			
		||||
    // coverage
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
 | 
			
		||||
        value: null,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    component.toggleTag(2) // coverage
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should ingest filter rules for has any custom fields', fakeAsync(() => {
 | 
			
		||||
    expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
 | 
			
		||||
      0
 | 
			
		||||
    )
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
 | 
			
		||||
        value: '42',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
 | 
			
		||||
        value: '43',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.customFieldSelectionModel.logicalOperator).toEqual(
 | 
			
		||||
      LogicalOperator.Or
 | 
			
		||||
    )
 | 
			
		||||
    expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
 | 
			
		||||
      custom_fields
 | 
			
		||||
    )
 | 
			
		||||
    // coverage
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
 | 
			
		||||
        value: null,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should ingest filter rules for has any custom field', fakeAsync(() => {
 | 
			
		||||
    expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
 | 
			
		||||
      0
 | 
			
		||||
    )
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
 | 
			
		||||
        value: '1',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
 | 
			
		||||
      1
 | 
			
		||||
    )
 | 
			
		||||
    expect(component.customFieldSelectionModel.get(null)).toBeTruthy()
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should ingest filter rules for exclude tag(s)', fakeAsync(() => {
 | 
			
		||||
    expect(component.customFieldSelectionModel.getExcludedItems()).toHaveLength(
 | 
			
		||||
      0
 | 
			
		||||
    )
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
 | 
			
		||||
        value: '42',
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
 | 
			
		||||
        value: '43',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.customFieldSelectionModel.logicalOperator).toEqual(
 | 
			
		||||
      LogicalOperator.And
 | 
			
		||||
    )
 | 
			
		||||
    expect(component.customFieldSelectionModel.getExcludedItems()).toEqual(
 | 
			
		||||
      custom_fields
 | 
			
		||||
    )
 | 
			
		||||
    // coverage
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
 | 
			
		||||
        value: null,
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should ingest filter rules for owner', fakeAsync(() => {
 | 
			
		||||
    expect(component.permissionsSelectionModel.ownerFilter).toEqual(
 | 
			
		||||
      OwnerFilterType.NONE
 | 
			
		||||
@@ -1053,7 +1182,7 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
    expect(component.textFilterTarget).toEqual('custom-fields')
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_CUSTOM_FIELDS,
 | 
			
		||||
        rule_type: FILTER_CUSTOM_FIELDS_TEXT,
 | 
			
		||||
        value: 'foo',
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
@@ -1317,9 +1446,78 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
    ])
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should convert user input to correct filter rules on custom field select not assigned', fakeAsync(() => {
 | 
			
		||||
    const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(FilterableDropdownComponent)
 | 
			
		||||
    )[4]
 | 
			
		||||
    customFieldsFilterableDropdown.triggerEventHandler('opened')
 | 
			
		||||
    const customFieldButton = customFieldsFilterableDropdown.queryAll(
 | 
			
		||||
      By.directive(ToggleableDropdownButtonComponent)
 | 
			
		||||
    )[0]
 | 
			
		||||
    customFieldButton.triggerEventHandler('toggle')
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
 | 
			
		||||
        value: 'false',
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {
 | 
			
		||||
    const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(FilterableDropdownComponent)
 | 
			
		||||
    )[4] // CF dropdown
 | 
			
		||||
    customFieldsFilterableDropdown.triggerEventHandler('opened')
 | 
			
		||||
    const customFieldButtons = customFieldsFilterableDropdown.queryAll(
 | 
			
		||||
      By.directive(ToggleableDropdownButtonComponent)
 | 
			
		||||
    )
 | 
			
		||||
    customFieldButtons[1].triggerEventHandler('toggle')
 | 
			
		||||
    customFieldButtons[2].triggerEventHandler('toggle')
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
 | 
			
		||||
        value: custom_fields[0].id.toString(),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
 | 
			
		||||
        value: custom_fields[1].id.toString(),
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
    const toggleOperatorButtons = customFieldsFilterableDropdown.queryAll(
 | 
			
		||||
      By.css('input[type=radio]')
 | 
			
		||||
    )
 | 
			
		||||
    toggleOperatorButtons[1].nativeElement.checked = true
 | 
			
		||||
    toggleOperatorButtons[1].triggerEventHandler('change')
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
 | 
			
		||||
        value: custom_fields[0].id.toString(),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
 | 
			
		||||
        value: custom_fields[1].id.toString(),
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
    customFieldButtons[2].triggerEventHandler('exclude')
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
 | 
			
		||||
        value: custom_fields[0].id.toString(),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
 | 
			
		||||
        value: custom_fields[1].id.toString(),
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should convert user input to correct filter rules on date created after', fakeAsync(() => {
 | 
			
		||||
    const dateCreatedDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(DateDropdownComponent)
 | 
			
		||||
      By.directive(DatesDropdownComponent)
 | 
			
		||||
    )[0]
 | 
			
		||||
    const dateCreatedAfter = dateCreatedDropdown.queryAll(By.css('input'))[0]
 | 
			
		||||
 | 
			
		||||
@@ -1339,7 +1537,7 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
 | 
			
		||||
  it('should convert user input to correct filter rules on date created before', fakeAsync(() => {
 | 
			
		||||
    const dateCreatedDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(DateDropdownComponent)
 | 
			
		||||
      By.directive(DatesDropdownComponent)
 | 
			
		||||
    )[0]
 | 
			
		||||
    const dateCreatedBefore = dateCreatedDropdown.queryAll(By.css('input'))[1]
 | 
			
		||||
 | 
			
		||||
@@ -1359,7 +1557,7 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
 | 
			
		||||
  it('should convert user input to correct filter rules on date created with relative date', fakeAsync(() => {
 | 
			
		||||
    const dateCreatedDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(DateDropdownComponent)
 | 
			
		||||
      By.directive(DatesDropdownComponent)
 | 
			
		||||
    )[0]
 | 
			
		||||
    const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll(
 | 
			
		||||
      By.css('button')
 | 
			
		||||
@@ -1378,7 +1576,7 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
  it('should carry over text filtering on date created with relative date', fakeAsync(() => {
 | 
			
		||||
    component.textFilter = 'foo'
 | 
			
		||||
    const dateCreatedDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(DateDropdownComponent)
 | 
			
		||||
      By.directive(DatesDropdownComponent)
 | 
			
		||||
    )[0]
 | 
			
		||||
    const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll(
 | 
			
		||||
      By.css('button')
 | 
			
		||||
@@ -1423,10 +1621,10 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should convert user input to correct filter rules on date added after', fakeAsync(() => {
 | 
			
		||||
    const dateAddedDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(DateDropdownComponent)
 | 
			
		||||
    )[1]
 | 
			
		||||
    const dateAddedAfter = dateAddedDropdown.queryAll(By.css('input'))[0]
 | 
			
		||||
    const datesDropdown = fixture.debugElement.query(
 | 
			
		||||
      By.directive(DatesDropdownComponent)
 | 
			
		||||
    )
 | 
			
		||||
    const dateAddedAfter = datesDropdown.queryAll(By.css('input'))[2]
 | 
			
		||||
 | 
			
		||||
    dateAddedAfter.nativeElement.value = '05/14/2023'
 | 
			
		||||
    // dateAddedAfter.triggerEventHandler('change')
 | 
			
		||||
@@ -1443,10 +1641,10 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should convert user input to correct filter rules on date added before', fakeAsync(() => {
 | 
			
		||||
    const dateAddedDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(DateDropdownComponent)
 | 
			
		||||
    )[1]
 | 
			
		||||
    const dateAddedBefore = dateAddedDropdown.queryAll(By.css('input'))[1]
 | 
			
		||||
    const datesDropdown = fixture.debugElement.query(
 | 
			
		||||
      By.directive(DatesDropdownComponent)
 | 
			
		||||
    )
 | 
			
		||||
    const dateAddedBefore = datesDropdown.queryAll(By.css('input'))[2]
 | 
			
		||||
 | 
			
		||||
    dateAddedBefore.nativeElement.value = '05/14/2023'
 | 
			
		||||
    // dateAddedBefore.triggerEventHandler('change')
 | 
			
		||||
@@ -1463,38 +1661,38 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should convert user input to correct filter rules on date added with relative date', fakeAsync(() => {
 | 
			
		||||
    const dateAddedDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(DateDropdownComponent)
 | 
			
		||||
    )[1]
 | 
			
		||||
    const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll(
 | 
			
		||||
    const datesDropdown = fixture.debugElement.query(
 | 
			
		||||
      By.directive(DatesDropdownComponent)
 | 
			
		||||
    )
 | 
			
		||||
    const dateCreatedBeforeRelativeButton = datesDropdown.queryAll(
 | 
			
		||||
      By.css('button')
 | 
			
		||||
    )[1]
 | 
			
		||||
    dateAddedBeforeRelativeButton.triggerEventHandler('click')
 | 
			
		||||
    dateCreatedBeforeRelativeButton.triggerEventHandler('click')
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    tick(400)
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_FULLTEXT_QUERY,
 | 
			
		||||
        value: 'added:[-1 week to now]',
 | 
			
		||||
        value: 'created:[-1 week to now]',
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
  }))
 | 
			
		||||
 | 
			
		||||
  it('should carry over text filtering on date added with relative date', fakeAsync(() => {
 | 
			
		||||
    component.textFilter = 'foo'
 | 
			
		||||
    const dateAddedDropdown = fixture.debugElement.queryAll(
 | 
			
		||||
      By.directive(DateDropdownComponent)
 | 
			
		||||
    )[1]
 | 
			
		||||
    const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll(
 | 
			
		||||
    const datesDropdown = fixture.debugElement.query(
 | 
			
		||||
      By.directive(DatesDropdownComponent)
 | 
			
		||||
    )
 | 
			
		||||
    const dateCreatedBeforeRelativeButton = datesDropdown.queryAll(
 | 
			
		||||
      By.css('button')
 | 
			
		||||
    )[1]
 | 
			
		||||
    dateAddedBeforeRelativeButton.triggerEventHandler('click')
 | 
			
		||||
    dateCreatedBeforeRelativeButton.triggerEventHandler('click')
 | 
			
		||||
    fixture.detectChanges()
 | 
			
		||||
    tick(400)
 | 
			
		||||
    expect(component.filterRules).toEqual([
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_FULLTEXT_QUERY,
 | 
			
		||||
        value: 'foo,added:[-1 week to now]',
 | 
			
		||||
        value: 'foo,created:[-1 week to now]',
 | 
			
		||||
      },
 | 
			
		||||
    ])
 | 
			
		||||
  }))
 | 
			
		||||
@@ -1645,6 +1843,10 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
        { id: 32, document_count: 1 },
 | 
			
		||||
        { id: 33, document_count: 0 },
 | 
			
		||||
      ],
 | 
			
		||||
      selected_custom_fields: [
 | 
			
		||||
        { id: 42, document_count: 1 },
 | 
			
		||||
        { id: 43, document_count: 0 },
 | 
			
		||||
      ],
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
@@ -1719,6 +1921,24 @@ describe('FilterEditorComponent', () => {
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.generateFilterName()).toEqual('Without any tag')
 | 
			
		||||
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
 | 
			
		||||
        value: '42',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.generateFilterName()).toEqual(
 | 
			
		||||
      `Custom fields: ${custom_fields[0].name}`
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
 | 
			
		||||
        value: 'false',
 | 
			
		||||
      },
 | 
			
		||||
    ]
 | 
			
		||||
    expect(component.generateFilterName()).toEqual('Without any custom field')
 | 
			
		||||
 | 
			
		||||
    component.filterRules = [
 | 
			
		||||
      {
 | 
			
		||||
        rule_type: FILTER_TITLE,
 | 
			
		||||
 
 | 
			
		||||
@@ -48,8 +48,12 @@ import {
 | 
			
		||||
  FILTER_OWNER_DOES_NOT_INCLUDE,
 | 
			
		||||
  FILTER_OWNER_ISNULL,
 | 
			
		||||
  FILTER_OWNER_ANY,
 | 
			
		||||
  FILTER_CUSTOM_FIELDS,
 | 
			
		||||
  FILTER_CUSTOM_FIELDS_TEXT,
 | 
			
		||||
  FILTER_SHARED_BY_USER,
 | 
			
		||||
  FILTER_HAS_CUSTOM_FIELDS_ANY,
 | 
			
		||||
  FILTER_HAS_CUSTOM_FIELDS_ALL,
 | 
			
		||||
  FILTER_HAS_ANY_CUSTOM_FIELDS,
 | 
			
		||||
  FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
 | 
			
		||||
} from 'src/app/data/filter-rule-type'
 | 
			
		||||
import {
 | 
			
		||||
  FilterableDropdownSelectionModel,
 | 
			
		||||
@@ -65,7 +69,7 @@ import {
 | 
			
		||||
import { Document } from 'src/app/data/document'
 | 
			
		||||
import { StoragePath } from 'src/app/data/storage-path'
 | 
			
		||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
 | 
			
		||||
import { RelativeDate } from '../../common/date-dropdown/date-dropdown.component'
 | 
			
		||||
import { RelativeDate } from '../../common/dates-dropdown/dates-dropdown.component'
 | 
			
		||||
import {
 | 
			
		||||
  OwnerFilterType,
 | 
			
		||||
  PermissionsSelectionModel,
 | 
			
		||||
@@ -76,6 +80,8 @@ import {
 | 
			
		||||
  PermissionsService,
 | 
			
		||||
} from 'src/app/services/permissions.service'
 | 
			
		||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
 | 
			
		||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
 | 
			
		||||
import { CustomField } from 'src/app/data/custom-field'
 | 
			
		||||
 | 
			
		||||
const TEXT_FILTER_TARGET_TITLE = 'title'
 | 
			
		||||
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
 | 
			
		||||
@@ -208,6 +214,16 @@ export class FilterEditorComponent
 | 
			
		||||
            return $localize`Without any tag`
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
        case FILTER_HAS_CUSTOM_FIELDS_ALL:
 | 
			
		||||
          return $localize`Custom fields: ${this.customFields.find(
 | 
			
		||||
            (f) => f.id == +rule.value
 | 
			
		||||
          )?.name}`
 | 
			
		||||
 | 
			
		||||
        case FILTER_HAS_ANY_CUSTOM_FIELDS:
 | 
			
		||||
          if (rule.value == 'false') {
 | 
			
		||||
            return $localize`Without any custom field`
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
        case FILTER_TITLE:
 | 
			
		||||
          return $localize`Title: ${rule.value}`
 | 
			
		||||
 | 
			
		||||
@@ -234,7 +250,8 @@ export class FilterEditorComponent
 | 
			
		||||
    private correspondentService: CorrespondentService,
 | 
			
		||||
    private documentService: DocumentService,
 | 
			
		||||
    private storagePathService: StoragePathService,
 | 
			
		||||
    public permissionsService: PermissionsService
 | 
			
		||||
    public permissionsService: PermissionsService,
 | 
			
		||||
    private customFieldService: CustomFieldsService
 | 
			
		||||
  ) {
 | 
			
		||||
    super()
 | 
			
		||||
  }
 | 
			
		||||
@@ -246,11 +263,13 @@ export class FilterEditorComponent
 | 
			
		||||
  correspondents: Correspondent[] = []
 | 
			
		||||
  documentTypes: DocumentType[] = []
 | 
			
		||||
  storagePaths: StoragePath[] = []
 | 
			
		||||
  customFields: CustomField[] = []
 | 
			
		||||
 | 
			
		||||
  tagDocumentCounts: SelectionDataItem[]
 | 
			
		||||
  correspondentDocumentCounts: SelectionDataItem[]
 | 
			
		||||
  documentTypeDocumentCounts: SelectionDataItem[]
 | 
			
		||||
  storagePathDocumentCounts: SelectionDataItem[]
 | 
			
		||||
  customFieldDocumentCounts: SelectionDataItem[]
 | 
			
		||||
 | 
			
		||||
  _textFilter = ''
 | 
			
		||||
  _moreLikeId: number
 | 
			
		||||
@@ -288,6 +307,7 @@ export class FilterEditorComponent
 | 
			
		||||
  correspondentSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  documentTypeSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  storagePathSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
  customFieldSelectionModel = new FilterableDropdownSelectionModel()
 | 
			
		||||
 | 
			
		||||
  dateCreatedBefore: string
 | 
			
		||||
  dateCreatedAfter: string
 | 
			
		||||
@@ -322,6 +342,7 @@ export class FilterEditorComponent
 | 
			
		||||
    this.storagePathSelectionModel.clear(false)
 | 
			
		||||
    this.tagSelectionModel.clear(false)
 | 
			
		||||
    this.correspondentSelectionModel.clear(false)
 | 
			
		||||
    this.customFieldSelectionModel.clear(false)
 | 
			
		||||
    this._textFilter = null
 | 
			
		||||
    this._moreLikeId = null
 | 
			
		||||
    this.dateAddedBefore = null
 | 
			
		||||
@@ -347,7 +368,7 @@ export class FilterEditorComponent
 | 
			
		||||
          this._textFilter = rule.value
 | 
			
		||||
          this.textFilterTarget = TEXT_FILTER_TARGET_ASN
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_CUSTOM_FIELDS:
 | 
			
		||||
        case FILTER_CUSTOM_FIELDS_TEXT:
 | 
			
		||||
          this._textFilter = rule.value
 | 
			
		||||
          this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS
 | 
			
		||||
          break
 | 
			
		||||
@@ -488,6 +509,36 @@ export class FilterEditorComponent
 | 
			
		||||
            false
 | 
			
		||||
          )
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_HAS_CUSTOM_FIELDS_ALL:
 | 
			
		||||
          this.customFieldSelectionModel.logicalOperator = LogicalOperator.And
 | 
			
		||||
          this.customFieldSelectionModel.set(
 | 
			
		||||
            rule.value ? +rule.value : null,
 | 
			
		||||
            ToggleableItemState.Selected,
 | 
			
		||||
            false
 | 
			
		||||
          )
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_HAS_CUSTOM_FIELDS_ANY:
 | 
			
		||||
          this.customFieldSelectionModel.logicalOperator = LogicalOperator.Or
 | 
			
		||||
          this.customFieldSelectionModel.set(
 | 
			
		||||
            rule.value ? +rule.value : null,
 | 
			
		||||
            ToggleableItemState.Selected,
 | 
			
		||||
            false
 | 
			
		||||
          )
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_HAS_ANY_CUSTOM_FIELDS:
 | 
			
		||||
          this.customFieldSelectionModel.set(
 | 
			
		||||
            null,
 | 
			
		||||
            ToggleableItemState.Selected,
 | 
			
		||||
            false
 | 
			
		||||
          )
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS:
 | 
			
		||||
          this.customFieldSelectionModel.set(
 | 
			
		||||
            rule.value ? +rule.value : null,
 | 
			
		||||
            ToggleableItemState.Excluded,
 | 
			
		||||
            false
 | 
			
		||||
          )
 | 
			
		||||
          break
 | 
			
		||||
        case FILTER_ASN_ISNULL:
 | 
			
		||||
          this.textFilterTarget = TEXT_FILTER_TARGET_ASN
 | 
			
		||||
          this.textFilterModifier =
 | 
			
		||||
@@ -595,7 +646,7 @@ export class FilterEditorComponent
 | 
			
		||||
      this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
 | 
			
		||||
    ) {
 | 
			
		||||
      filterRules.push({
 | 
			
		||||
        rule_type: FILTER_CUSTOM_FIELDS,
 | 
			
		||||
        rule_type: FILTER_CUSTOM_FIELDS_TEXT,
 | 
			
		||||
        value: this._textFilter,
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
@@ -703,6 +754,35 @@ export class FilterEditorComponent
 | 
			
		||||
          })
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
    if (this.customFieldSelectionModel.isNoneSelected()) {
 | 
			
		||||
      filterRules.push({
 | 
			
		||||
        rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
 | 
			
		||||
        value: 'false',
 | 
			
		||||
      })
 | 
			
		||||
    } else {
 | 
			
		||||
      const customFieldFilterType =
 | 
			
		||||
        this.customFieldSelectionModel.logicalOperator == LogicalOperator.And
 | 
			
		||||
          ? FILTER_HAS_CUSTOM_FIELDS_ALL
 | 
			
		||||
          : FILTER_HAS_CUSTOM_FIELDS_ANY
 | 
			
		||||
      this.customFieldSelectionModel
 | 
			
		||||
        .getSelectedItems()
 | 
			
		||||
        .filter((field) => field.id)
 | 
			
		||||
        .forEach((field) => {
 | 
			
		||||
          filterRules.push({
 | 
			
		||||
            rule_type: customFieldFilterType,
 | 
			
		||||
            value: field.id?.toString(),
 | 
			
		||||
          })
 | 
			
		||||
        })
 | 
			
		||||
      this.customFieldSelectionModel
 | 
			
		||||
        .getExcludedItems()
 | 
			
		||||
        .filter((field) => field.id)
 | 
			
		||||
        .forEach((field) => {
 | 
			
		||||
          filterRules.push({
 | 
			
		||||
            rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
 | 
			
		||||
            value: field.id?.toString(),
 | 
			
		||||
          })
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
    if (this.dateCreatedBefore) {
 | 
			
		||||
      filterRules.push({
 | 
			
		||||
        rule_type: FILTER_CREATED_BEFORE,
 | 
			
		||||
@@ -845,6 +925,8 @@ export class FilterEditorComponent
 | 
			
		||||
      selectionData?.selected_correspondents ?? null
 | 
			
		||||
    this.storagePathDocumentCounts =
 | 
			
		||||
      selectionData?.selected_storage_paths ?? null
 | 
			
		||||
    this.customFieldDocumentCounts =
 | 
			
		||||
      selectionData?.selected_custom_fields ?? null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  rulesModified: boolean = false
 | 
			
		||||
@@ -905,6 +987,16 @@ export class FilterEditorComponent
 | 
			
		||||
        .listAll()
 | 
			
		||||
        .subscribe((result) => (this.storagePaths = result.results))
 | 
			
		||||
    }
 | 
			
		||||
    if (
 | 
			
		||||
      this.permissionsService.currentUserCan(
 | 
			
		||||
        PermissionAction.View,
 | 
			
		||||
        PermissionType.CustomField
 | 
			
		||||
      )
 | 
			
		||||
    ) {
 | 
			
		||||
      this.customFieldService
 | 
			
		||||
        .listAll()
 | 
			
		||||
        .subscribe((result) => (this.customFields = result.results))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.textFilterDebounce = new Subject<string>()
 | 
			
		||||
 | 
			
		||||
@@ -961,6 +1053,10 @@ export class FilterEditorComponent
 | 
			
		||||
    this.storagePathSelectionModel.apply()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  onCustomFieldsDropdownOpen() {
 | 
			
		||||
    this.customFieldSelectionModel.apply()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  updateTextFilter(text) {
 | 
			
		||||
    this._textFilter = text
 | 
			
		||||
    this.documentService.searchQuery = text
 | 
			
		||||
 
 | 
			
		||||
@@ -47,7 +47,11 @@ export const FILTER_OWNER_ISNULL = 34
 | 
			
		||||
export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
 | 
			
		||||
export const FILTER_SHARED_BY_USER = 37
 | 
			
		||||
 | 
			
		||||
export const FILTER_CUSTOM_FIELDS = 36
 | 
			
		||||
export const FILTER_CUSTOM_FIELDS_TEXT = 36
 | 
			
		||||
export const FILTER_HAS_CUSTOM_FIELDS_ALL = 38
 | 
			
		||||
export const FILTER_HAS_CUSTOM_FIELDS_ANY = 39
 | 
			
		||||
export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
 | 
			
		||||
export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41
 | 
			
		||||
 | 
			
		||||
export const FILTER_RULE_TYPES: FilterRuleType[] = [
 | 
			
		||||
  {
 | 
			
		||||
@@ -281,11 +285,36 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
 | 
			
		||||
    multi: true,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: FILTER_CUSTOM_FIELDS,
 | 
			
		||||
    id: FILTER_CUSTOM_FIELDS_TEXT,
 | 
			
		||||
    filtervar: 'custom_fields__icontains',
 | 
			
		||||
    datatype: 'string',
 | 
			
		||||
    multi: false,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: FILTER_HAS_CUSTOM_FIELDS_ALL,
 | 
			
		||||
    filtervar: 'custom_fields__id__all',
 | 
			
		||||
    datatype: 'number',
 | 
			
		||||
    multi: true,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: FILTER_HAS_CUSTOM_FIELDS_ANY,
 | 
			
		||||
    filtervar: 'custom_fields__id__in',
 | 
			
		||||
    datatype: 'number',
 | 
			
		||||
    multi: true,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
 | 
			
		||||
    filtervar: 'custom_fields__id__none',
 | 
			
		||||
    datatype: 'number',
 | 
			
		||||
    multi: true,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: FILTER_HAS_ANY_CUSTOM_FIELDS,
 | 
			
		||||
    filtervar: 'has_custom_fields',
 | 
			
		||||
    datatype: 'boolean',
 | 
			
		||||
    multi: false,
 | 
			
		||||
    default: true,
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
export interface FilterRuleType {
 | 
			
		||||
 
 | 
			
		||||
@@ -36,6 +36,7 @@ export interface SelectionData {
 | 
			
		||||
  selected_correspondents: SelectionDataItem[]
 | 
			
		||||
  selected_tags: SelectionDataItem[]
 | 
			
		||||
  selected_document_types: SelectionDataItem[]
 | 
			
		||||
  selected_custom_fields: SelectionDataItem[]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@Injectable({
 | 
			
		||||
 
 | 
			
		||||
@@ -12,6 +12,7 @@ from documents.data_models import ConsumableDocument
 | 
			
		||||
from documents.data_models import DocumentMetadataOverrides
 | 
			
		||||
from documents.data_models import DocumentSource
 | 
			
		||||
from documents.models import Correspondent
 | 
			
		||||
from documents.models import CustomFieldInstance
 | 
			
		||||
from documents.models import Document
 | 
			
		||||
from documents.models import DocumentType
 | 
			
		||||
from documents.models import StoragePath
 | 
			
		||||
@@ -120,6 +121,30 @@ def modify_tags(doc_ids, add_tags, remove_tags):
 | 
			
		||||
    return "OK"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def modify_custom_fields(doc_ids, add_custom_fields, remove_custom_fields):
 | 
			
		||||
    qs = Document.objects.filter(id__in=doc_ids)
 | 
			
		||||
    affected_docs = [doc.id for doc in qs]
 | 
			
		||||
 | 
			
		||||
    fields_to_add = []
 | 
			
		||||
    for field in add_custom_fields:
 | 
			
		||||
        for doc_id in affected_docs:
 | 
			
		||||
            fields_to_add.append(
 | 
			
		||||
                CustomFieldInstance(
 | 
			
		||||
                    document_id=doc_id,
 | 
			
		||||
                    field_id=field,
 | 
			
		||||
                ),
 | 
			
		||||
            )
 | 
			
		||||
    CustomFieldInstance.objects.bulk_create(fields_to_add)
 | 
			
		||||
    CustomFieldInstance.objects.filter(
 | 
			
		||||
        document_id__in=affected_docs,
 | 
			
		||||
        field_id__in=remove_custom_fields,
 | 
			
		||||
    ).delete()
 | 
			
		||||
 | 
			
		||||
    bulk_update_documents.delay(document_ids=affected_docs)
 | 
			
		||||
 | 
			
		||||
    return "OK"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def delete(doc_ids):
 | 
			
		||||
    Document.objects.filter(id__in=doc_ids).delete()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -199,6 +199,25 @@ class DocumentFilterSet(FilterSet):
 | 
			
		||||
 | 
			
		||||
    custom_fields__icontains = CustomFieldsFilter()
 | 
			
		||||
 | 
			
		||||
    custom_fields__id__all = ObjectFilter(field_name="custom_fields__field")
 | 
			
		||||
 | 
			
		||||
    custom_fields__id__none = ObjectFilter(
 | 
			
		||||
        field_name="custom_fields__field",
 | 
			
		||||
        exclude=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    custom_fields__id__in = ObjectFilter(
 | 
			
		||||
        field_name="custom_fields__field",
 | 
			
		||||
        in_list=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    has_custom_fields = BooleanFilter(
 | 
			
		||||
        label="Has custom field",
 | 
			
		||||
        field_name="custom_fields",
 | 
			
		||||
        lookup_expr="isnull",
 | 
			
		||||
        exclude=True,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    shared_by__id = SharedByUser()
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
 
 | 
			
		||||
@@ -70,6 +70,8 @@ def get_schema():
 | 
			
		||||
        num_notes=NUMERIC(sortable=True, signed=False),
 | 
			
		||||
        custom_fields=TEXT(),
 | 
			
		||||
        custom_field_count=NUMERIC(sortable=True, signed=False),
 | 
			
		||||
        has_custom_fields=BOOLEAN(),
 | 
			
		||||
        custom_fields_id=KEYWORD(commas=True),
 | 
			
		||||
        owner=TEXT(),
 | 
			
		||||
        owner_id=NUMERIC(),
 | 
			
		||||
        has_owner=BOOLEAN(),
 | 
			
		||||
@@ -125,6 +127,9 @@ def update_document(writer: AsyncWriter, doc: Document):
 | 
			
		||||
    custom_fields = ",".join(
 | 
			
		||||
        [str(c) for c in CustomFieldInstance.objects.filter(document=doc)],
 | 
			
		||||
    )
 | 
			
		||||
    custom_fields_ids = ",".join(
 | 
			
		||||
        [str(f.field.id) for f in CustomFieldInstance.objects.filter(document=doc)],
 | 
			
		||||
    )
 | 
			
		||||
    asn = doc.archive_serial_number
 | 
			
		||||
    if asn is not None and (
 | 
			
		||||
        asn < Document.ARCHIVE_SERIAL_NUMBER_MIN
 | 
			
		||||
@@ -166,6 +171,8 @@ def update_document(writer: AsyncWriter, doc: Document):
 | 
			
		||||
        num_notes=len(notes),
 | 
			
		||||
        custom_fields=custom_fields,
 | 
			
		||||
        custom_field_count=len(doc.custom_fields.all()),
 | 
			
		||||
        has_custom_fields=len(custom_fields) > 0,
 | 
			
		||||
        custom_fields_id=custom_fields_ids if custom_fields_ids else None,
 | 
			
		||||
        owner=doc.owner.username if doc.owner else None,
 | 
			
		||||
        owner_id=doc.owner.id if doc.owner else None,
 | 
			
		||||
        has_owner=doc.owner is not None,
 | 
			
		||||
@@ -206,7 +213,10 @@ class DelayedQuery:
 | 
			
		||||
        "created": ("created", ["date__lt", "date__gt"]),
 | 
			
		||||
        "checksum": ("checksum", ["icontains", "istartswith"]),
 | 
			
		||||
        "original_filename": ("original_filename", ["icontains", "istartswith"]),
 | 
			
		||||
        "custom_fields": ("custom_fields", ["icontains", "istartswith"]),
 | 
			
		||||
        "custom_fields": (
 | 
			
		||||
            "custom_fields",
 | 
			
		||||
            ["icontains", "istartswith", "id__all", "id__in", "id__none"],
 | 
			
		||||
        ),
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    def _get_query(self):
 | 
			
		||||
@@ -220,6 +230,12 @@ class DelayedQuery:
 | 
			
		||||
                criterias.append(query.Term("has_tag", self.evalBoolean(value)))
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            if key == "has_custom_fields":
 | 
			
		||||
                criterias.append(
 | 
			
		||||
                    query.Term("has_custom_fields", self.evalBoolean(value)),
 | 
			
		||||
                )
 | 
			
		||||
                continue
 | 
			
		||||
 | 
			
		||||
            # Don't process query params without a filter
 | 
			
		||||
            if "__" not in key:
 | 
			
		||||
                continue
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,65 @@
 | 
			
		||||
# Generated by Django 4.2.11 on 2024-04-24 04:58
 | 
			
		||||
 | 
			
		||||
from django.db import migrations
 | 
			
		||||
from django.db import models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("documents", "1047_savedview_display_mode_and_more"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AlterField(
 | 
			
		||||
            model_name="savedviewfilterrule",
 | 
			
		||||
            name="rule_type",
 | 
			
		||||
            field=models.PositiveIntegerField(
 | 
			
		||||
                choices=[
 | 
			
		||||
                    (0, "title contains"),
 | 
			
		||||
                    (1, "content contains"),
 | 
			
		||||
                    (2, "ASN is"),
 | 
			
		||||
                    (3, "correspondent is"),
 | 
			
		||||
                    (4, "document type is"),
 | 
			
		||||
                    (5, "is in inbox"),
 | 
			
		||||
                    (6, "has tag"),
 | 
			
		||||
                    (7, "has any tag"),
 | 
			
		||||
                    (8, "created before"),
 | 
			
		||||
                    (9, "created after"),
 | 
			
		||||
                    (10, "created year is"),
 | 
			
		||||
                    (11, "created month is"),
 | 
			
		||||
                    (12, "created day is"),
 | 
			
		||||
                    (13, "added before"),
 | 
			
		||||
                    (14, "added after"),
 | 
			
		||||
                    (15, "modified before"),
 | 
			
		||||
                    (16, "modified after"),
 | 
			
		||||
                    (17, "does not have tag"),
 | 
			
		||||
                    (18, "does not have ASN"),
 | 
			
		||||
                    (19, "title or content contains"),
 | 
			
		||||
                    (20, "fulltext query"),
 | 
			
		||||
                    (21, "more like this"),
 | 
			
		||||
                    (22, "has tags in"),
 | 
			
		||||
                    (23, "ASN greater than"),
 | 
			
		||||
                    (24, "ASN less than"),
 | 
			
		||||
                    (25, "storage path is"),
 | 
			
		||||
                    (26, "has correspondent in"),
 | 
			
		||||
                    (27, "does not have correspondent in"),
 | 
			
		||||
                    (28, "has document type in"),
 | 
			
		||||
                    (29, "does not have document type in"),
 | 
			
		||||
                    (30, "has storage path in"),
 | 
			
		||||
                    (31, "does not have storage path in"),
 | 
			
		||||
                    (32, "owner is"),
 | 
			
		||||
                    (33, "has owner in"),
 | 
			
		||||
                    (34, "does not have owner"),
 | 
			
		||||
                    (35, "does not have owner in"),
 | 
			
		||||
                    (36, "has custom field value"),
 | 
			
		||||
                    (37, "is shared by me"),
 | 
			
		||||
                    (38, "has custom fields"),
 | 
			
		||||
                    (39, "has custom field in"),
 | 
			
		||||
                    (40, "does not have custom field in"),
 | 
			
		||||
                    (41, "does not have custom field"),
 | 
			
		||||
                ],
 | 
			
		||||
                verbose_name="rule type",
 | 
			
		||||
            ),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@@ -500,6 +500,10 @@ class SavedViewFilterRule(models.Model):
 | 
			
		||||
        (35, _("does not have owner in")),
 | 
			
		||||
        (36, _("has custom field value")),
 | 
			
		||||
        (37, _("is shared by me")),
 | 
			
		||||
        (38, _("has custom fields")),
 | 
			
		||||
        (39, _("has custom field in")),
 | 
			
		||||
        (40, _("does not have custom field in")),
 | 
			
		||||
        (41, _("does not have custom field")),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    saved_view = models.ForeignKey(
 | 
			
		||||
 
 | 
			
		||||
@@ -905,6 +905,7 @@ class BulkEditSerializer(
 | 
			
		||||
            "add_tag",
 | 
			
		||||
            "remove_tag",
 | 
			
		||||
            "modify_tags",
 | 
			
		||||
            "modify_custom_fields",
 | 
			
		||||
            "delete",
 | 
			
		||||
            "redo_ocr",
 | 
			
		||||
            "set_permissions",
 | 
			
		||||
@@ -929,6 +930,17 @@ class BulkEditSerializer(
 | 
			
		||||
                f"Some tags in {name} don't exist or were specified twice.",
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def _validate_custom_field_id_list(self, custom_fields, name="custom_fields"):
 | 
			
		||||
        if not isinstance(custom_fields, list):
 | 
			
		||||
            raise serializers.ValidationError(f"{name} must be a list")
 | 
			
		||||
        if not all(isinstance(i, int) for i in custom_fields):
 | 
			
		||||
            raise serializers.ValidationError(f"{name} must be a list of integers")
 | 
			
		||||
        count = CustomField.objects.filter(id__in=custom_fields).count()
 | 
			
		||||
        if not count == len(custom_fields):
 | 
			
		||||
            raise serializers.ValidationError(
 | 
			
		||||
                f"Some custom fields in {name} don't exist or were specified twice.",
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def validate_method(self, method):
 | 
			
		||||
        if method == "set_correspondent":
 | 
			
		||||
            return bulk_edit.set_correspondent
 | 
			
		||||
@@ -942,6 +954,8 @@ class BulkEditSerializer(
 | 
			
		||||
            return bulk_edit.remove_tag
 | 
			
		||||
        elif method == "modify_tags":
 | 
			
		||||
            return bulk_edit.modify_tags
 | 
			
		||||
        elif method == "modify_custom_fields":
 | 
			
		||||
            return bulk_edit.modify_custom_fields
 | 
			
		||||
        elif method == "delete":
 | 
			
		||||
            return bulk_edit.delete
 | 
			
		||||
        elif method == "redo_ocr":
 | 
			
		||||
@@ -1017,6 +1031,23 @@ class BulkEditSerializer(
 | 
			
		||||
        else:
 | 
			
		||||
            raise serializers.ValidationError("remove_tags not specified")
 | 
			
		||||
 | 
			
		||||
    def _validate_parameters_modify_custom_fields(self, parameters):
 | 
			
		||||
        if "add_custom_fields" in parameters:
 | 
			
		||||
            self._validate_custom_field_id_list(
 | 
			
		||||
                parameters["add_custom_fields"],
 | 
			
		||||
                "add_custom_fields",
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            raise serializers.ValidationError("add_custom_fields not specified")
 | 
			
		||||
 | 
			
		||||
        if "remove_custom_fields" in parameters:
 | 
			
		||||
            self._validate_custom_field_id_list(
 | 
			
		||||
                parameters["remove_custom_fields"],
 | 
			
		||||
                "remove_custom_fields",
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            raise serializers.ValidationError("remove_custom_fields not specified")
 | 
			
		||||
 | 
			
		||||
    def _validate_owner(self, owner):
 | 
			
		||||
        ownerUser = User.objects.get(pk=owner)
 | 
			
		||||
        if ownerUser is None:
 | 
			
		||||
@@ -1079,6 +1110,8 @@ class BulkEditSerializer(
 | 
			
		||||
            self._validate_parameters_modify_tags(parameters)
 | 
			
		||||
        elif method == bulk_edit.set_storage_path:
 | 
			
		||||
            self._validate_storage_path(parameters)
 | 
			
		||||
        elif method == bulk_edit.modify_custom_fields:
 | 
			
		||||
            self._validate_parameters_modify_custom_fields(parameters)
 | 
			
		||||
        elif method == bulk_edit.set_permissions:
 | 
			
		||||
            self._validate_parameters_set_permissions(parameters)
 | 
			
		||||
        elif method == bulk_edit.rotate:
 | 
			
		||||
 
 | 
			
		||||
@@ -7,6 +7,7 @@ from rest_framework import status
 | 
			
		||||
from rest_framework.test import APITestCase
 | 
			
		||||
 | 
			
		||||
from documents.models import Correspondent
 | 
			
		||||
from documents.models import CustomField
 | 
			
		||||
from documents.models import Document
 | 
			
		||||
from documents.models import DocumentType
 | 
			
		||||
from documents.models import StoragePath
 | 
			
		||||
@@ -49,6 +50,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
 | 
			
		||||
        self.doc3.tags.add(self.t2)
 | 
			
		||||
        self.doc4.tags.add(self.t1, self.t2)
 | 
			
		||||
        self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}")
 | 
			
		||||
        self.cf1 = CustomField.objects.create(name="cf1", data_type="text")
 | 
			
		||||
        self.cf2 = CustomField.objects.create(name="cf2", data_type="text")
 | 
			
		||||
 | 
			
		||||
    @mock.patch("documents.serialisers.bulk_edit.set_correspondent")
 | 
			
		||||
    def test_api_set_correspondent(self, m):
 | 
			
		||||
@@ -222,6 +225,135 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
        m.assert_not_called()
 | 
			
		||||
 | 
			
		||||
    @mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
 | 
			
		||||
    def test_api_modify_custom_fields(self, m):
 | 
			
		||||
        m.return_value = "OK"
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            "/api/documents/bulk_edit/",
 | 
			
		||||
            json.dumps(
 | 
			
		||||
                {
 | 
			
		||||
                    "documents": [self.doc1.id, self.doc3.id],
 | 
			
		||||
                    "method": "modify_custom_fields",
 | 
			
		||||
                    "parameters": {
 | 
			
		||||
                        "add_custom_fields": [self.cf1.id],
 | 
			
		||||
                        "remove_custom_fields": [self.cf2.id],
 | 
			
		||||
                    },
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
            content_type="application/json",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_200_OK)
 | 
			
		||||
        m.assert_called_once()
 | 
			
		||||
        args, kwargs = m.call_args
 | 
			
		||||
        self.assertListEqual(args[0], [self.doc1.id, self.doc3.id])
 | 
			
		||||
        self.assertEqual(kwargs["add_custom_fields"], [self.cf1.id])
 | 
			
		||||
        self.assertEqual(kwargs["remove_custom_fields"], [self.cf2.id])
 | 
			
		||||
 | 
			
		||||
    @mock.patch("documents.serialisers.bulk_edit.modify_custom_fields")
 | 
			
		||||
    def test_api_modify_custom_fields_invalid_params(self, m):
 | 
			
		||||
        """
 | 
			
		||||
        GIVEN:
 | 
			
		||||
            - API data to modify custom fields is malformed
 | 
			
		||||
        WHEN:
 | 
			
		||||
            - API to edit custom fields is called
 | 
			
		||||
        THEN:
 | 
			
		||||
            - API returns HTTP 400
 | 
			
		||||
            - modify_custom_fields is not called
 | 
			
		||||
        """
 | 
			
		||||
        m.return_value = "OK"
 | 
			
		||||
 | 
			
		||||
        # Missing add_custom_fields
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            "/api/documents/bulk_edit/",
 | 
			
		||||
            json.dumps(
 | 
			
		||||
                {
 | 
			
		||||
                    "documents": [self.doc1.id, self.doc3.id],
 | 
			
		||||
                    "method": "modify_custom_fields",
 | 
			
		||||
                    "parameters": {
 | 
			
		||||
                        "add_custom_fields": [self.cf1.id],
 | 
			
		||||
                    },
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
            content_type="application/json",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
        m.assert_not_called()
 | 
			
		||||
 | 
			
		||||
        # Missing remove_custom_fields
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            "/api/documents/bulk_edit/",
 | 
			
		||||
            json.dumps(
 | 
			
		||||
                {
 | 
			
		||||
                    "documents": [self.doc1.id, self.doc3.id],
 | 
			
		||||
                    "method": "modify_custom_fields",
 | 
			
		||||
                    "parameters": {
 | 
			
		||||
                        "remove_custom_fields": [self.cf1.id],
 | 
			
		||||
                    },
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
            content_type="application/json",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
        m.assert_not_called()
 | 
			
		||||
 | 
			
		||||
        # Not a list
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            "/api/documents/bulk_edit/",
 | 
			
		||||
            json.dumps(
 | 
			
		||||
                {
 | 
			
		||||
                    "documents": [self.doc1.id, self.doc3.id],
 | 
			
		||||
                    "method": "modify_custom_fields",
 | 
			
		||||
                    "parameters": {
 | 
			
		||||
                        "add_custom_fields": self.cf1.id,
 | 
			
		||||
                        "remove_custom_fields": self.cf2.id,
 | 
			
		||||
                    },
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
            content_type="application/json",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
        m.assert_not_called()
 | 
			
		||||
 | 
			
		||||
        # Not a list of integers
 | 
			
		||||
 | 
			
		||||
        # Missing remove_custom_fields
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            "/api/documents/bulk_edit/",
 | 
			
		||||
            json.dumps(
 | 
			
		||||
                {
 | 
			
		||||
                    "documents": [self.doc1.id, self.doc3.id],
 | 
			
		||||
                    "method": "modify_custom_fields",
 | 
			
		||||
                    "parameters": {
 | 
			
		||||
                        "add_custom_fields": ["foo"],
 | 
			
		||||
                        "remove_custom_fields": ["bar"],
 | 
			
		||||
                    },
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
            content_type="application/json",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
        m.assert_not_called()
 | 
			
		||||
 | 
			
		||||
        # Custom field ID not found
 | 
			
		||||
 | 
			
		||||
        # Missing remove_custom_fields
 | 
			
		||||
        response = self.client.post(
 | 
			
		||||
            "/api/documents/bulk_edit/",
 | 
			
		||||
            json.dumps(
 | 
			
		||||
                {
 | 
			
		||||
                    "documents": [self.doc1.id, self.doc3.id],
 | 
			
		||||
                    "method": "modify_custom_fields",
 | 
			
		||||
                    "parameters": {
 | 
			
		||||
                        "add_custom_fields": [self.cf1.id],
 | 
			
		||||
                        "remove_custom_fields": [99],
 | 
			
		||||
                    },
 | 
			
		||||
                },
 | 
			
		||||
            ),
 | 
			
		||||
            content_type="application/json",
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
 | 
			
		||||
        m.assert_not_called()
 | 
			
		||||
 | 
			
		||||
    @mock.patch("documents.serialisers.bulk_edit.delete")
 | 
			
		||||
    def test_api_delete(self, m):
 | 
			
		||||
        m.return_value = "OK"
 | 
			
		||||
 
 | 
			
		||||
@@ -920,6 +920,34 @@ class TestDocumentSearchApi(DirectoriesMixin, APITestCase):
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertIn(
 | 
			
		||||
            d4.id,
 | 
			
		||||
            search_query(
 | 
			
		||||
                "&has_custom_fields=1",
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertIn(
 | 
			
		||||
            d4.id,
 | 
			
		||||
            search_query(
 | 
			
		||||
                "&custom_fields__id__in=" + str(cf1.id),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertIn(
 | 
			
		||||
            d4.id,
 | 
			
		||||
            search_query(
 | 
			
		||||
                "&custom_fields__id__all=" + str(cf1.id),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertNotIn(
 | 
			
		||||
            d4.id,
 | 
			
		||||
            search_query(
 | 
			
		||||
                "&custom_fields__id__none=" + str(cf1.id),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_search_filtering_respect_owner(self):
 | 
			
		||||
        """
 | 
			
		||||
        GIVEN:
 | 
			
		||||
 
 | 
			
		||||
@@ -11,6 +11,8 @@ from guardian.shortcuts import get_users_with_perms
 | 
			
		||||
 | 
			
		||||
from documents import bulk_edit
 | 
			
		||||
from documents.models import Correspondent
 | 
			
		||||
from documents.models import CustomField
 | 
			
		||||
from documents.models import CustomFieldInstance
 | 
			
		||||
from documents.models import Document
 | 
			
		||||
from documents.models import DocumentType
 | 
			
		||||
from documents.models import StoragePath
 | 
			
		||||
@@ -186,6 +188,53 @@ class TestBulkEdit(DirectoriesMixin, TestCase):
 | 
			
		||||
        # TODO: doc3 should not be affected, but the query for that is rather complicated
 | 
			
		||||
        self.assertCountEqual(kwargs["document_ids"], [self.doc2.id, self.doc3.id])
 | 
			
		||||
 | 
			
		||||
    def test_modify_custom_fields(self):
 | 
			
		||||
        cf = CustomField.objects.create(
 | 
			
		||||
            name="cf1",
 | 
			
		||||
            data_type=CustomField.FieldDataType.STRING,
 | 
			
		||||
        )
 | 
			
		||||
        cf2 = CustomField.objects.create(
 | 
			
		||||
            name="cf2",
 | 
			
		||||
            data_type=CustomField.FieldDataType.INT,
 | 
			
		||||
        )
 | 
			
		||||
        cf3 = CustomField.objects.create(
 | 
			
		||||
            name="cf3",
 | 
			
		||||
            data_type=CustomField.FieldDataType.STRING,
 | 
			
		||||
        )
 | 
			
		||||
        CustomFieldInstance.objects.create(
 | 
			
		||||
            document=self.doc1,
 | 
			
		||||
            field=cf,
 | 
			
		||||
        )
 | 
			
		||||
        CustomFieldInstance.objects.create(
 | 
			
		||||
            document=self.doc2,
 | 
			
		||||
            field=cf,
 | 
			
		||||
        )
 | 
			
		||||
        CustomFieldInstance.objects.create(
 | 
			
		||||
            document=self.doc2,
 | 
			
		||||
            field=cf3,
 | 
			
		||||
        )
 | 
			
		||||
        bulk_edit.modify_custom_fields(
 | 
			
		||||
            [self.doc1.id, self.doc2.id],
 | 
			
		||||
            add_custom_fields=[cf2.id],
 | 
			
		||||
            remove_custom_fields=[cf.id],
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.doc1.refresh_from_db()
 | 
			
		||||
        self.doc2.refresh_from_db()
 | 
			
		||||
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            self.doc1.custom_fields.count(),
 | 
			
		||||
            1,
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            self.doc2.custom_fields.count(),
 | 
			
		||||
            2,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.async_task.assert_called_once()
 | 
			
		||||
        args, kwargs = self.async_task.call_args
 | 
			
		||||
        self.assertCountEqual(kwargs["document_ids"], [self.doc1.id, self.doc2.id])
 | 
			
		||||
 | 
			
		||||
    def test_delete(self):
 | 
			
		||||
        self.assertEqual(Document.objects.count(), 5)
 | 
			
		||||
        bulk_edit.delete([self.doc1.id, self.doc2.id])
 | 
			
		||||
 
 | 
			
		||||
@@ -1065,6 +1065,18 @@ class SelectionDataView(GenericAPIView):
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        custom_fields = CustomField.objects.annotate(
 | 
			
		||||
            document_count=Count(
 | 
			
		||||
                Case(
 | 
			
		||||
                    When(
 | 
			
		||||
                        fields__document__id__in=ids,
 | 
			
		||||
                        then=1,
 | 
			
		||||
                    ),
 | 
			
		||||
                    output_field=IntegerField(),
 | 
			
		||||
                ),
 | 
			
		||||
            ),
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        r = Response(
 | 
			
		||||
            {
 | 
			
		||||
                "selected_correspondents": [
 | 
			
		||||
@@ -1081,6 +1093,10 @@ class SelectionDataView(GenericAPIView):
 | 
			
		||||
                    {"id": t.id, "document_count": t.document_count}
 | 
			
		||||
                    for t in storage_paths
 | 
			
		||||
                ],
 | 
			
		||||
                "selected_custom_fields": [
 | 
			
		||||
                    {"id": t.id, "document_count": t.document_count}
 | 
			
		||||
                    for t in custom_fields
 | 
			
		||||
                ],
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user