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