]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: custom fields filtering & bulk editing (#6484)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Fri, 26 Apr 2024 22:10:03 +0000 (15:10 -0700)
committerGitHub <noreply@github.com>
Fri, 26 Apr 2024 22:10:03 +0000 (15:10 -0700)
28 files changed:
docker/docker-prepare.sh
src-ui/e2e/document-list/document-list.spec.ts
src-ui/src/app/app.module.ts
src-ui/src/app/components/common/date-dropdown/date-dropdown.component.html [deleted file]
src-ui/src/app/components/common/date-dropdown/date-dropdown.component.ts [deleted file]
src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html [new file with mode: 0644]
src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.scss [moved from src-ui/src/app/components/common/date-dropdown/date-dropdown.component.scss with 66% similarity]
src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.spec.ts [moved from src-ui/src/app/components/common/date-dropdown/date-dropdown.component.spec.ts with 61% similarity]
src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts [new file with mode: 0644]
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.html
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.spec.ts
src-ui/src/app/components/document-list/bulk-editor/bulk-editor.component.ts
src-ui/src/app/components/document-list/document-list.component.spec.ts
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.html
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.spec.ts
src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts
src-ui/src/app/data/filter-rule-type.ts
src-ui/src/app/services/rest/document.service.ts
src/documents/bulk_edit.py
src/documents/filters.py
src/documents/index.py
src/documents/migrations/1048_alter_savedviewfilterrule_rule_type.py [new file with mode: 0644]
src/documents/models.py
src/documents/serialisers.py
src/documents/tests/test_api_bulk_edit.py
src/documents/tests/test_api_search.py
src/documents/tests/test_bulk_edit.py
src/documents/views.py

index adf2be83980ddbdb235b4b69fb4a143b24fab7f8..30d1237e5bb6e9a62850da48e2c68c469995027f 100755 (executable)
@@ -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
index e974d36a1dc1e905700b4d9441993defacc7ef93..da2454e7f6b02baa0802dec61cb17d89815b19e0 100644 (file)
@@ -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')
index ab33840a2c647ca5a62a9f9cbf0ccd554d681422..416cfd129b795e51ef87cb5333715d46adb7acea 100644 (file)
@@ -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,
diff --git a/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.html b/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.html
deleted file mode 100644 (file)
index 8556495..0000000
+++ /dev/null
@@ -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' }} &ndash; <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>
diff --git a/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.ts b/src-ui/src/app/components/common/date-dropdown/date-dropdown.component.ts
deleted file mode 100644 (file)
index f474896..0000000
+++ /dev/null
@@ -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()
-    }
-  }
-}
diff --git a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.html
new file mode 100644 (file)
index 0000000..8991363
--- /dev/null
@@ -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">&nbsp;{{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' }} &ndash; <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' }} &ndash; <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>
similarity index 66%
rename from src-ui/src/app/components/common/date-dropdown/date-dropdown.component.scss
rename to src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.scss
index 83ac932338353572be5808dc12814ec0b5418381..f8e09e1b21e3fdcf6ec233aa670005e609caf9c6 100644 (file)
@@ -1,6 +1,10 @@
 .date-dropdown {
   white-space: nowrap;
 
+  @media(min-width: 768px) {
+    --bs-dropdown-min-width: 40rem;
+  }
+
   .btn-link {
     line-height: 1;
   }
similarity index 61%
rename from src-ui/src/app/components/common/date-dropdown/date-dropdown.component.spec.ts
rename to src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.spec.ts
index e445a73b713eed6695e9958b652cc879be23851b..03338f01409292ff020464aee24c7659288018f2 100644 (file)
@@ -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', () => {
diff --git a/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts b/src-ui/src/app/components/common/dates-dropdown/dates-dropdown.component.ts
new file mode 100644 (file)
index 0000000..966e964
--- /dev/null
@@ -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()
+    }
+  }
+}
index 86550256954ede3b77080d1048263ead4d7f1e57..e10d00a7aa034bc9d52ea7de164cffb53f45c3b8 100644 (file)
               (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">
index 127d7ef2b690cdb6ebfab865b479277e1a886a5a..cbc00c20d50dd5f2a0e3afb63691579e0219819c 100644 (file)
@@ -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)
+  })
 })
index 556a1ff13824b88cc3bb5a055cdf6b2342f30414..1d3b4d0a9d71d40121620acb3f8711643c9a057b 100644 (file)
@@ -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',
index 237520f33dd04531d283469a19aecb438eb04377..4c2f48765a63cd5a89fdd5e72180644309c16d9e 100644 (file)
@@ -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,
index 89900e0876d0260933331b4aaa1a68fb350c1fe1..ccbe50cacf71d83dbdfc17a784ab13e606081837 100644 (file)
           [documentCounts]="storagePathDocumentCounts"
           [allowSelectNone]="true"></pngx-filterable-dropdown>
         }
-      </div>
-      <div class="d-flex flex-wrap gap-2">
-        <pngx-date-dropdown
-          title="Created" i18n-title
-          (datesSet)="updateRules()"
-          [(dateBefore)]="dateCreatedBefore"
-          [(dateAfter)]="dateCreatedAfter"
-        [(relativeDate)]="dateCreatedRelativeDate"></pngx-date-dropdown>
-        <pngx-date-dropdown
-          title="Added" 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)]="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()"
index e091dbf1580dbcf46067d319e11c2c05e0c235b5..f52907bf2839e77474e6c5aea0b5d855034070ec 100644 (file)
@@ -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,
index a6aafe049c72a07828ca9a07ce8468a40e234519..b59ae53f19fbf8baccb09d5659a8bd3d8714b2b0 100644 (file)
@@ -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
index ee09f165d26cf74ace5c58f41e31232a33db62a3..cd4700096438cfc617b80ae8f95434ab0f154c3e 100644 (file)
@@ -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 {
index 9780b958675f02e0c3c10ab5cd4541ec203e5084..4d17bbd249c8c0611c372d256f22388e94497be6 100644 (file)
@@ -36,6 +36,7 @@ export interface SelectionData {
   selected_correspondents: SelectionDataItem[]
   selected_tags: SelectionDataItem[]
   selected_document_types: SelectionDataItem[]
+  selected_custom_fields: SelectionDataItem[]
 }
 
 @Injectable({
index 362c28e20b8055969f565f1d1abbddb265e88ac1..04a4fec81f7d0f8d234700702562babe221e04bc 100644 (file)
@@ -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()
 
index 891f20dde15b607f85981faeda093cdc7f2bd1bd..c548cfa225ff400d97d3a70f781048c955ab77d0 100644 (file)
@@ -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:
index 388b994d8494cf509c69b487d62e603bfeda3575..98c43d1e883af64cbd8e34b9d5775c20679c5aa9 100644 (file)
@@ -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
diff --git a/src/documents/migrations/1048_alter_savedviewfilterrule_rule_type.py b/src/documents/migrations/1048_alter_savedviewfilterrule_rule_type.py
new file mode 100644 (file)
index 0000000..dc4d0bf
--- /dev/null
@@ -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",
+            ),
+        ),
+    ]
index 6d8a49350c8409a75ad7393387143511fac050d8..f3e5f22eddfacae3c1bd0c22928ee350a72ab74e 100644 (file)
@@ -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(
index 2512723aac0ea3a211cbb38930718d6c5db974dc..e95a7bacb6e8e5d7218e719efbdd2a9e78ac7a9c 100644 (file)
@@ -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:
index d659c82e8a46d08af188007a19109bac3b2c06bf..ae87d42849f5571499e35a7f07d6d915f11e9e9e 100644 (file)
@@ -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"
index 1b46f8e33033ba3917b18600df6d0c0d74f11d3b..cfbcce74ce6dce0b48fac02a3594e405780599c8 100644 (file)
@@ -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:
index 7de9439214931dcdf9ddbccb68b21f4b57812ed0..831fa94612916e9ce253ca57949409e9a2e3b75f 100644 (file)
@@ -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])
index d220d1aaa8d1fcc68b8fbc92cda030f0f1f597bd..e8c0bcc3a2de997763ab2a4102d31642144942ed 100644 (file)
@@ -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
+                ],
             },
         )