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() { | search_index() { | ||||||
|  |  | ||||||
| 	local -r index_version=8 | 	local -r index_version=9 | ||||||
| 	local -r index_version_file=${DATA_DIR}/.index_version | 	local -r index_version_file=${DATA_DIR}/.index_version | ||||||
|  |  | ||||||
| 	if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then | 	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 }) => { | test('date filtering', async ({ page }) => { | ||||||
|   await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) |   await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) | ||||||
|   await page.goto('/documents') |   await page.goto('/documents') | ||||||
|   await page.getByRole('button', { name: 'Created' }).click() |   await page.getByRole('button', { name: 'Dates' }).click() | ||||||
|   await page.getByRole('menuitem', { name: 'Last 3 months' }).click() |   await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click() | ||||||
|   await expect(page.locator('pngx-document-list')).toHaveText(/one document/i) |   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: 'Dates Clear selected' }).click() | ||||||
|   await page.getByRole('button', { name: 'Created' }).click() |   await page.getByRole('button', { name: 'Dates' }).click() | ||||||
|   await page |   await page | ||||||
|     .getByRole('menuitem', { name: 'After mm/dd/yyyy' }) |     .getByRole('menuitem', { name: 'After mm/dd/yyyy' }) | ||||||
|     .getByRole('button') |     .getByRole('button') | ||||||
|  |     .first() | ||||||
|     .click() |     .click() | ||||||
|   await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') |   await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') | ||||||
|   await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') |   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 { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component' | ||||||
| import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.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 { 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 { 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 { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component' | ||||||
| import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component' | import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component' | ||||||
| @@ -140,6 +140,7 @@ import { | |||||||
|   boxes, |   boxes, | ||||||
|   calendar, |   calendar, | ||||||
|   calendarEvent, |   calendarEvent, | ||||||
|  |   calendarEventFill, | ||||||
|   cardChecklist, |   cardChecklist, | ||||||
|   cardHeading, |   cardHeading, | ||||||
|   caretDown, |   caretDown, | ||||||
| @@ -235,6 +236,7 @@ const icons = { | |||||||
|   boxes, |   boxes, | ||||||
|   calendar, |   calendar, | ||||||
|   calendarEvent, |   calendarEvent, | ||||||
|  |   calendarEventFill, | ||||||
|   cardChecklist, |   cardChecklist, | ||||||
|   cardHeading, |   cardHeading, | ||||||
|   caretDown, |   caretDown, | ||||||
| @@ -407,7 +409,7 @@ function initializeApp(settings: SettingsService) { | |||||||
|     FilterEditorComponent, |     FilterEditorComponent, | ||||||
|     FilterableDropdownComponent, |     FilterableDropdownComponent, | ||||||
|     ToggleableDropdownButtonComponent, |     ToggleableDropdownButtonComponent, | ||||||
|     DateDropdownComponent, |     DatesDropdownComponent, | ||||||
|     DocumentCardLargeComponent, |     DocumentCardLargeComponent, | ||||||
|     DocumentCardSmallComponent, |     DocumentCardSmallComponent, | ||||||
|     BulkEditorComponent, |     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 { | .date-dropdown { | ||||||
|   white-space: nowrap; |   white-space: nowrap; | ||||||
| 
 | 
 | ||||||
|  |   @media(min-width: 768px) { | ||||||
|  |     --bs-dropdown-min-width: 40rem; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   .btn-link { |   .btn-link { | ||||||
|     line-height: 1; |     line-height: 1; | ||||||
|   } |   } | ||||||
| @@ -4,12 +4,12 @@ import { | |||||||
|   fakeAsync, |   fakeAsync, | ||||||
|   tick, |   tick, | ||||||
| } from '@angular/core/testing' | } from '@angular/core/testing' | ||||||
| let fixture: ComponentFixture<DateDropdownComponent> | let fixture: ComponentFixture<DatesDropdownComponent> | ||||||
| import { | import { | ||||||
|   DateDropdownComponent, |   DatesDropdownComponent, | ||||||
|   DateSelection, |   DateSelection, | ||||||
|   RelativeDate, |   RelativeDate, | ||||||
| } from './date-dropdown.component' | } from './dates-dropdown.component' | ||||||
| import { HttpClientTestingModule } from '@angular/common/http/testing' | import { HttpClientTestingModule } from '@angular/common/http/testing' | ||||||
| import { NgbModule } from '@ng-bootstrap/ng-bootstrap' | import { NgbModule } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { SettingsService } from 'src/app/services/settings.service' | 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 { DatePipe } from '@angular/common' | ||||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||||
| 
 | 
 | ||||||
| describe('DateDropdownComponent', () => { | describe('DatesDropdownComponent', () => { | ||||||
|   let component: DateDropdownComponent |   let component: DatesDropdownComponent | ||||||
|   let settingsService: SettingsService |   let settingsService: SettingsService | ||||||
|   let settingsSpy |   let settingsSpy | ||||||
| 
 | 
 | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     TestBed.configureTestingModule({ |     TestBed.configureTestingModule({ | ||||||
|       declarations: [ |       declarations: [ | ||||||
|         DateDropdownComponent, |         DatesDropdownComponent, | ||||||
|         ClearableBadgeComponent, |         ClearableBadgeComponent, | ||||||
|         CustomDatePipe, |         CustomDatePipe, | ||||||
|       ], |       ], | ||||||
| @@ -44,7 +44,7 @@ describe('DateDropdownComponent', () => { | |||||||
|     settingsService = TestBed.inject(SettingsService) |     settingsService = TestBed.inject(SettingsService) | ||||||
|     settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat') |     settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat') | ||||||
| 
 | 
 | ||||||
|     fixture = TestBed.createComponent(DateDropdownComponent) |     fixture = TestBed.createComponent(DatesDropdownComponent) | ||||||
|     component = fixture.componentInstance |     component = fixture.componentInstance | ||||||
| 
 | 
 | ||||||
|     fixture.detectChanges() |     fixture.detectChanges() | ||||||
| @@ -57,7 +57,7 @@ describe('DateDropdownComponent', () => { | |||||||
| 
 | 
 | ||||||
|   it('should support date input, emit change', fakeAsync(() => { |   it('should support date input, emit change', fakeAsync(() => { | ||||||
|     let result: string |     let result: string | ||||||
|     component.dateAfterChange.subscribe((date) => (result = date)) |     component.createdDateAfterChange.subscribe((date) => (result = date)) | ||||||
|     const input: HTMLInputElement = fixture.nativeElement.querySelector('input') |     const input: HTMLInputElement = fixture.nativeElement.querySelector('input') | ||||||
|     input.value = '5/30/2023' |     input.value = '5/30/2023' | ||||||
|     input.dispatchEvent(new Event('change')) |     input.dispatchEvent(new Event('change')) | ||||||
| @@ -78,45 +78,69 @@ describe('DateDropdownComponent', () => { | |||||||
|   it('should support relative dates', fakeAsync(() => { |   it('should support relative dates', fakeAsync(() => { | ||||||
|     let result: DateSelection |     let result: DateSelection | ||||||
|     component.datesSet.subscribe((date) => (result = date)) |     component.datesSet.subscribe((date) => (result = date)) | ||||||
|     component.setRelativeDate(null) |     component.setCreatedRelativeDate(null) | ||||||
|     component.setRelativeDate(RelativeDate.LAST_7_DAYS) |     component.setCreatedRelativeDate(RelativeDate.LAST_7_DAYS) | ||||||
|  |     component.setAddedRelativeDate(null) | ||||||
|  |     component.setAddedRelativeDate(RelativeDate.LAST_7_DAYS) | ||||||
|     tick(500) |     tick(500) | ||||||
|     expect(result).toEqual({ |     expect(result).toEqual({ | ||||||
|       after: null, |       createdAfter: null, | ||||||
|       before: null, |       createdBefore: null, | ||||||
|       relativeDateID: RelativeDate.LAST_7_DAYS, |       createdRelativeDateID: RelativeDate.LAST_7_DAYS, | ||||||
|  |       addedAfter: null, | ||||||
|  |       addedBefore: null, | ||||||
|  |       addedRelativeDateID: RelativeDate.LAST_7_DAYS, | ||||||
|     }) |     }) | ||||||
|   })) |   })) | ||||||
| 
 | 
 | ||||||
|   it('should support report if active', () => { |   it('should support report if active', () => { | ||||||
|     component.relativeDate = RelativeDate.LAST_7_DAYS |     component.createdRelativeDate = RelativeDate.LAST_7_DAYS | ||||||
|     expect(component.isActive).toBeTruthy() |     expect(component.isActive).toBeTruthy() | ||||||
|     component.relativeDate = null |     component.createdRelativeDate = null | ||||||
|     component.dateAfter = '2023-05-30' |     component.createdDateAfter = '2023-05-30' | ||||||
|     expect(component.isActive).toBeTruthy() |     expect(component.isActive).toBeTruthy() | ||||||
|     component.dateAfter = null |     component.createdDateAfter = null | ||||||
|     component.dateBefore = '2023-05-30' |     component.createdDateBefore = '2023-05-30' | ||||||
|     expect(component.isActive).toBeTruthy() |     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() |     expect(component.isActive).toBeFalsy() | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   it('should support reset', () => { |   it('should support reset', () => { | ||||||
|     component.dateAfter = '2023-05-30' |     component.createdDateAfter = '2023-05-30' | ||||||
|     component.reset() |     component.reset() | ||||||
|     expect(component.dateAfter).toBeNull() |     expect(component.createdDateAfter).toBeNull() | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   it('should support clearAfter', () => { |   it('should support clearAfter', () => { | ||||||
|     component.dateAfter = '2023-05-30' |     component.createdDateAfter = '2023-05-30' | ||||||
|     component.clearAfter() |     component.clearCreatedAfter() | ||||||
|     expect(component.dateAfter).toBeNull() |     expect(component.createdDateAfter).toBeNull() | ||||||
|  | 
 | ||||||
|  |     component.addedDateAfter = '2023-05-30' | ||||||
|  |     component.clearAddedAfter() | ||||||
|  |     expect(component.addedDateAfter).toBeNull() | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   it('should support clearBefore', () => { |   it('should support clearBefore', () => { | ||||||
|     component.dateBefore = '2023-05-30' |     component.createdDateBefore = '2023-05-30' | ||||||
|     component.clearBefore() |     component.clearCreatedBefore() | ||||||
|     expect(component.dateBefore).toBeNull() |     expect(component.createdDateBefore).toBeNull() | ||||||
|  | 
 | ||||||
|  |     component.addedDateBefore = '2023-05-30' | ||||||
|  |     component.clearAddedBefore() | ||||||
|  |     expect(component.addedDateBefore).toBeNull() | ||||||
|   }) |   }) | ||||||
| 
 | 
 | ||||||
|   it('should limit keyboard events', () => { |   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)"> |               (apply)="setStoragePaths($event)"> | ||||||
|             </pngx-filterable-dropdown> |             </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> | ||||||
|         <div class="d-flex align-items-center gap-2 ms-auto"> |         <div class="d-flex align-items-center gap-2 ms-auto"> | ||||||
|           <div class="btn-toolbar"> |           <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 { IsNumberPipe } from 'src/app/pipes/is-number.pipe' | ||||||
| import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-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 { 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 = { | const selectionData: SelectionData = { | ||||||
|   selected_tags: [ |   selected_tags: [ | ||||||
| @@ -68,6 +71,10 @@ const selectionData: SelectionData = { | |||||||
|     { id: 66, document_count: 3 }, |     { id: 66, document_count: 3 }, | ||||||
|     { id: 55, document_count: 0 }, |     { id: 55, document_count: 0 }, | ||||||
|   ], |   ], | ||||||
|  |   selected_custom_fields: [ | ||||||
|  |     { id: 77, document_count: 3 }, | ||||||
|  |     { id: 88, document_count: 0 }, | ||||||
|  |   ], | ||||||
| } | } | ||||||
|  |  | ||||||
| describe('BulkEditorComponent', () => { | describe('BulkEditorComponent', () => { | ||||||
| @@ -82,6 +89,7 @@ describe('BulkEditorComponent', () => { | |||||||
|   let correspondentsService: CorrespondentService |   let correspondentsService: CorrespondentService | ||||||
|   let documentTypeService: DocumentTypeService |   let documentTypeService: DocumentTypeService | ||||||
|   let storagePathService: StoragePathService |   let storagePathService: StoragePathService | ||||||
|  |   let customFieldsService: CustomFieldsService | ||||||
|   let httpTestingController: HttpTestingController |   let httpTestingController: HttpTestingController | ||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
| @@ -148,6 +156,18 @@ describe('BulkEditorComponent', () => { | |||||||
|               }), |               }), | ||||||
|           }, |           }, | ||||||
|         }, |         }, | ||||||
|  |         { | ||||||
|  |           provide: CustomFieldsService, | ||||||
|  |           useValue: { | ||||||
|  |             listAll: () => | ||||||
|  |               of({ | ||||||
|  |                 results: [ | ||||||
|  |                   { id: 77, name: 'customfield1' }, | ||||||
|  |                   { id: 88, name: 'customfield2' }, | ||||||
|  |                 ], | ||||||
|  |               }), | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|         FilterPipe, |         FilterPipe, | ||||||
|         SettingsService, |         SettingsService, | ||||||
|         { |         { | ||||||
| @@ -189,6 +209,7 @@ describe('BulkEditorComponent', () => { | |||||||
|     correspondentsService = TestBed.inject(CorrespondentService) |     correspondentsService = TestBed.inject(CorrespondentService) | ||||||
|     documentTypeService = TestBed.inject(DocumentTypeService) |     documentTypeService = TestBed.inject(DocumentTypeService) | ||||||
|     storagePathService = TestBed.inject(StoragePathService) |     storagePathService = TestBed.inject(StoragePathService) | ||||||
|  |     customFieldsService = TestBed.inject(CustomFieldsService) | ||||||
|     httpTestingController = TestBed.inject(HttpTestingController) |     httpTestingController = TestBed.inject(HttpTestingController) | ||||||
|  |  | ||||||
|     fixture = TestBed.createComponent(BulkEditorComponent) |     fixture = TestBed.createComponent(BulkEditorComponent) | ||||||
| @@ -262,6 +283,22 @@ describe('BulkEditorComponent', () => { | |||||||
|     expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1) |     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', () => { |   it('should execute modify tags bulk operation', () => { | ||||||
|     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) |     jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) | ||||||
|     jest |     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', () => { |   it('should only execute bulk operations when changes are detected', () => { | ||||||
|     component.setTags({ |     component.setTags({ | ||||||
|       itemsToAdd: [], |       itemsToAdd: [], | ||||||
| @@ -696,6 +849,10 @@ describe('BulkEditorComponent', () => { | |||||||
|       itemsToAdd: [], |       itemsToAdd: [], | ||||||
|       itemsToRemove: [], |       itemsToRemove: [], | ||||||
|     }) |     }) | ||||||
|  |     component.setCustomFields({ | ||||||
|  |       itemsToAdd: [], | ||||||
|  |       itemsToRemove: [], | ||||||
|  |     }) | ||||||
|     httpTestingController.expectNone( |     httpTestingController.expectNone( | ||||||
|       `${environment.apiBaseUrl}documents/bulk_edit/` |       `${environment.apiBaseUrl}documents/bulk_edit/` | ||||||
|     ) |     ) | ||||||
| @@ -1179,4 +1336,56 @@ describe('BulkEditorComponent', () => { | |||||||
|     ) |     ) | ||||||
|     expect(component.storagePaths).toEqual(storagePaths.results) |     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 { 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 { 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 { 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({ | @Component({ | ||||||
|   selector: 'pngx-bulk-editor', |   selector: 'pngx-bulk-editor', | ||||||
| @@ -55,15 +58,18 @@ export class BulkEditorComponent | |||||||
|   correspondents: Correspondent[] |   correspondents: Correspondent[] | ||||||
|   documentTypes: DocumentType[] |   documentTypes: DocumentType[] | ||||||
|   storagePaths: StoragePath[] |   storagePaths: StoragePath[] | ||||||
|  |   customFields: CustomField[] | ||||||
|  |  | ||||||
|   tagSelectionModel = new FilterableDropdownSelectionModel() |   tagSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   correspondentSelectionModel = new FilterableDropdownSelectionModel() |   correspondentSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   documentTypeSelectionModel = new FilterableDropdownSelectionModel() |   documentTypeSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   storagePathsSelectionModel = new FilterableDropdownSelectionModel() |   storagePathsSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|  |   customFieldsSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   tagDocumentCounts: SelectionDataItem[] |   tagDocumentCounts: SelectionDataItem[] | ||||||
|   correspondentDocumentCounts: SelectionDataItem[] |   correspondentDocumentCounts: SelectionDataItem[] | ||||||
|   documentTypeDocumentCounts: SelectionDataItem[] |   documentTypeDocumentCounts: SelectionDataItem[] | ||||||
|   storagePathDocumentCounts: SelectionDataItem[] |   storagePathDocumentCounts: SelectionDataItem[] | ||||||
|  |   customFieldDocumentCounts: SelectionDataItem[] | ||||||
|   awaitingDownload: boolean |   awaitingDownload: boolean | ||||||
|  |  | ||||||
|   unsubscribeNotifier: Subject<any> = new Subject() |   unsubscribeNotifier: Subject<any> = new Subject() | ||||||
| @@ -85,6 +91,7 @@ export class BulkEditorComponent | |||||||
|     private settings: SettingsService, |     private settings: SettingsService, | ||||||
|     private toastService: ToastService, |     private toastService: ToastService, | ||||||
|     private storagePathService: StoragePathService, |     private storagePathService: StoragePathService, | ||||||
|  |     private customFieldService: CustomFieldsService, | ||||||
|     private permissionService: PermissionsService |     private permissionService: PermissionsService | ||||||
|   ) { |   ) { | ||||||
|     super() |     super() | ||||||
| @@ -166,6 +173,17 @@ export class BulkEditorComponent | |||||||
|         .pipe(first()) |         .pipe(first()) | ||||||
|         .subscribe((result) => (this.storagePaths = result.results)) |         .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 |     this.downloadForm | ||||||
|       .get('downloadFileTypeArchive') |       .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[]) { |   private _localizeList(items: MatchingModel[]) { | ||||||
|     if (items.length == 0) { |     if (items.length == 0) { | ||||||
|       return '' |       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) { |   createTag(name: string) { | ||||||
|     let modal = this.modalService.open(TagEditDialogComponent, { |     let modal = this.modalService.open(TagEditDialogComponent, { | ||||||
|       backdrop: 'static', |       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() { |   applyDelete() { | ||||||
|     let modal = this.modalService.open(ConfirmDialogComponent, { |     let modal = this.modalService.open(ConfirmDialogComponent, { | ||||||
|       backdrop: 'static', |       backdrop: 'static', | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ import { RouterTestingModule } from '@angular/router/testing' | |||||||
| import { routes } from 'src/app/app-routing.module' | import { routes } from 'src/app/app-routing.module' | ||||||
| import { FilterEditorComponent } from './filter-editor/filter-editor.component' | import { FilterEditorComponent } from './filter-editor/filter-editor.component' | ||||||
| import { PermissionsFilterDropdownComponent } from '../common/permissions-filter-dropdown/permissions-filter-dropdown.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 { FilterableDropdownComponent } from '../common/filterable-dropdown/filterable-dropdown.component' | ||||||
| import { PageHeaderComponent } from '../common/page-header/page-header.component' | import { PageHeaderComponent } from '../common/page-header/page-header.component' | ||||||
| import { BulkEditorComponent } from './bulk-editor/bulk-editor.component' | import { BulkEditorComponent } from './bulk-editor/bulk-editor.component' | ||||||
| @@ -113,7 +113,7 @@ describe('DocumentListComponent', () => { | |||||||
|         PageHeaderComponent, |         PageHeaderComponent, | ||||||
|         FilterEditorComponent, |         FilterEditorComponent, | ||||||
|         FilterableDropdownComponent, |         FilterableDropdownComponent, | ||||||
|         DateDropdownComponent, |         DatesDropdownComponent, | ||||||
|         PermissionsFilterDropdownComponent, |         PermissionsFilterDropdownComponent, | ||||||
|         ToggleableDropdownButtonComponent, |         ToggleableDropdownButtonComponent, | ||||||
|         BulkEditorComponent, |         BulkEditorComponent, | ||||||
|   | |||||||
| @@ -70,22 +70,28 @@ | |||||||
|           [documentCounts]="storagePathDocumentCounts" |           [documentCounts]="storagePathDocumentCounts" | ||||||
|           [allowSelectNone]="true"></pngx-filterable-dropdown> |           [allowSelectNone]="true"></pngx-filterable-dropdown> | ||||||
|         } |         } | ||||||
|       </div> |  | ||||||
|       <div class="d-flex flex-wrap gap-2"> |         @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { | ||||||
|         <pngx-date-dropdown |           <pngx-filterable-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title | ||||||
|           title="Created" 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()" |           (datesSet)="updateRules()" | ||||||
|           [(dateBefore)]="dateCreatedBefore" |           [(createdDateBefore)]="dateCreatedBefore" | ||||||
|           [(dateAfter)]="dateCreatedAfter" |           [(createdDateAfter)]="dateCreatedAfter" | ||||||
|         [(relativeDate)]="dateCreatedRelativeDate"></pngx-date-dropdown> |           [(createdRelativeDate)]="dateCreatedRelativeDate" | ||||||
|         <pngx-date-dropdown |           [(addedDateBefore)]="dateAddedBefore" | ||||||
|           title="Added" i18n-title |           [(addedDateAfter)]="dateAddedAfter" | ||||||
|           (datesSet)="updateRules()" |           [(addedRelativeDate)]="dateAddedRelativeDate"> | ||||||
|           [(dateBefore)]="dateAddedBefore" |         </pngx-dates-dropdown> | ||||||
|           [(dateAfter)]="dateAddedAfter" |  | ||||||
|         [(relativeDate)]="dateAddedRelativeDate"></pngx-date-dropdown> |  | ||||||
|       </div> |  | ||||||
|       <div class="d-flex flex-wrap"> |  | ||||||
|         <pngx-permissions-filter-dropdown |         <pngx-permissions-filter-dropdown | ||||||
|           title="Permissions" i18n-title |           title="Permissions" i18n-title | ||||||
|           (ownerFilterSet)="updateRules()" |           (ownerFilterSet)="updateRules()" | ||||||
|   | |||||||
| @@ -49,8 +49,12 @@ import { | |||||||
|   FILTER_OWNER_ANY, |   FILTER_OWNER_ANY, | ||||||
|   FILTER_OWNER_DOES_NOT_INCLUDE, |   FILTER_OWNER_DOES_NOT_INCLUDE, | ||||||
|   FILTER_OWNER_ISNULL, |   FILTER_OWNER_ISNULL, | ||||||
|   FILTER_CUSTOM_FIELDS, |   FILTER_CUSTOM_FIELDS_TEXT, | ||||||
|   FILTER_SHARED_BY_USER, |   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' | } from 'src/app/data/filter-rule-type' | ||||||
| import { Correspondent } from 'src/app/data/correspondent' | import { Correspondent } from 'src/app/data/correspondent' | ||||||
| import { DocumentType } from 'src/app/data/document-type' | 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 { UserService } from 'src/app/services/rest/user.service' | ||||||
| import { SettingsService } from 'src/app/services/settings.service' | import { SettingsService } from 'src/app/services/settings.service' | ||||||
| import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' | 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 { | import { | ||||||
|   FilterableDropdownComponent, |   FilterableDropdownComponent, | ||||||
|   LogicalOperator, |   LogicalOperator, | ||||||
| @@ -86,6 +90,8 @@ import { | |||||||
|   PermissionsService, |   PermissionsService, | ||||||
| } from 'src/app/services/permissions.service' | } from 'src/app/services/permissions.service' | ||||||
| import { environment } from 'src/environments/environment' | 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[] = [ | 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[] = [ | const users: User[] = [ | ||||||
|   { |   { | ||||||
|     id: 1, |     id: 1, | ||||||
| @@ -156,7 +175,7 @@ describe('FilterEditorComponent', () => { | |||||||
|         IfPermissionsDirective, |         IfPermissionsDirective, | ||||||
|         ClearableBadgeComponent, |         ClearableBadgeComponent, | ||||||
|         ToggleableDropdownButtonComponent, |         ToggleableDropdownButtonComponent, | ||||||
|         DateDropdownComponent, |         DatesDropdownComponent, | ||||||
|         CustomDatePipe, |         CustomDatePipe, | ||||||
|       ], |       ], | ||||||
|       providers: [ |       providers: [ | ||||||
| @@ -187,6 +206,12 @@ describe('FilterEditorComponent', () => { | |||||||
|             listAll: () => of({ results: storage_paths }), |             listAll: () => of({ results: storage_paths }), | ||||||
|           }, |           }, | ||||||
|         }, |         }, | ||||||
|  |         { | ||||||
|  |           provide: CustomFieldsService, | ||||||
|  |           useValue: { | ||||||
|  |             listAll: () => of({ results: custom_fields }), | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|         { |         { | ||||||
|           provide: UserService, |           provide: UserService, | ||||||
|           useValue: { |           useValue: { | ||||||
| @@ -285,7 +310,7 @@ describe('FilterEditorComponent', () => { | |||||||
|     expect(component.textFilter).toEqual(null) |     expect(component.textFilter).toEqual(null) | ||||||
|     component.filterRules = [ |     component.filterRules = [ | ||||||
|       { |       { | ||||||
|         rule_type: FILTER_CUSTOM_FIELDS, |         rule_type: FILTER_CUSTOM_FIELDS_TEXT, | ||||||
|         value: 'foo', |         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(() => { |   it('should ingest filter rules for owner', fakeAsync(() => { | ||||||
|     expect(component.permissionsSelectionModel.ownerFilter).toEqual( |     expect(component.permissionsSelectionModel.ownerFilter).toEqual( | ||||||
|       OwnerFilterType.NONE |       OwnerFilterType.NONE | ||||||
| @@ -1053,7 +1182,7 @@ describe('FilterEditorComponent', () => { | |||||||
|     expect(component.textFilterTarget).toEqual('custom-fields') |     expect(component.textFilterTarget).toEqual('custom-fields') | ||||||
|     expect(component.filterRules).toEqual([ |     expect(component.filterRules).toEqual([ | ||||||
|       { |       { | ||||||
|         rule_type: FILTER_CUSTOM_FIELDS, |         rule_type: FILTER_CUSTOM_FIELDS_TEXT, | ||||||
|         value: 'foo', |         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(() => { |   it('should convert user input to correct filter rules on date created after', fakeAsync(() => { | ||||||
|     const dateCreatedDropdown = fixture.debugElement.queryAll( |     const dateCreatedDropdown = fixture.debugElement.queryAll( | ||||||
|       By.directive(DateDropdownComponent) |       By.directive(DatesDropdownComponent) | ||||||
|     )[0] |     )[0] | ||||||
|     const dateCreatedAfter = dateCreatedDropdown.queryAll(By.css('input'))[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(() => { |   it('should convert user input to correct filter rules on date created before', fakeAsync(() => { | ||||||
|     const dateCreatedDropdown = fixture.debugElement.queryAll( |     const dateCreatedDropdown = fixture.debugElement.queryAll( | ||||||
|       By.directive(DateDropdownComponent) |       By.directive(DatesDropdownComponent) | ||||||
|     )[0] |     )[0] | ||||||
|     const dateCreatedBefore = dateCreatedDropdown.queryAll(By.css('input'))[1] |     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(() => { |   it('should convert user input to correct filter rules on date created with relative date', fakeAsync(() => { | ||||||
|     const dateCreatedDropdown = fixture.debugElement.queryAll( |     const dateCreatedDropdown = fixture.debugElement.queryAll( | ||||||
|       By.directive(DateDropdownComponent) |       By.directive(DatesDropdownComponent) | ||||||
|     )[0] |     )[0] | ||||||
|     const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll( |     const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll( | ||||||
|       By.css('button') |       By.css('button') | ||||||
| @@ -1378,7 +1576,7 @@ describe('FilterEditorComponent', () => { | |||||||
|   it('should carry over text filtering on date created with relative date', fakeAsync(() => { |   it('should carry over text filtering on date created with relative date', fakeAsync(() => { | ||||||
|     component.textFilter = 'foo' |     component.textFilter = 'foo' | ||||||
|     const dateCreatedDropdown = fixture.debugElement.queryAll( |     const dateCreatedDropdown = fixture.debugElement.queryAll( | ||||||
|       By.directive(DateDropdownComponent) |       By.directive(DatesDropdownComponent) | ||||||
|     )[0] |     )[0] | ||||||
|     const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll( |     const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll( | ||||||
|       By.css('button') |       By.css('button') | ||||||
| @@ -1423,10 +1621,10 @@ describe('FilterEditorComponent', () => { | |||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   it('should convert user input to correct filter rules on date added after', fakeAsync(() => { |   it('should convert user input to correct filter rules on date added after', fakeAsync(() => { | ||||||
|     const dateAddedDropdown = fixture.debugElement.queryAll( |     const datesDropdown = fixture.debugElement.query( | ||||||
|       By.directive(DateDropdownComponent) |       By.directive(DatesDropdownComponent) | ||||||
|     )[1] |     ) | ||||||
|     const dateAddedAfter = dateAddedDropdown.queryAll(By.css('input'))[0] |     const dateAddedAfter = datesDropdown.queryAll(By.css('input'))[2] | ||||||
|  |  | ||||||
|     dateAddedAfter.nativeElement.value = '05/14/2023' |     dateAddedAfter.nativeElement.value = '05/14/2023' | ||||||
|     // dateAddedAfter.triggerEventHandler('change') |     // dateAddedAfter.triggerEventHandler('change') | ||||||
| @@ -1443,10 +1641,10 @@ describe('FilterEditorComponent', () => { | |||||||
|   })) |   })) | ||||||
|  |  | ||||||
|   it('should convert user input to correct filter rules on date added before', fakeAsync(() => { |   it('should convert user input to correct filter rules on date added before', fakeAsync(() => { | ||||||
|     const dateAddedDropdown = fixture.debugElement.queryAll( |     const datesDropdown = fixture.debugElement.query( | ||||||
|       By.directive(DateDropdownComponent) |       By.directive(DatesDropdownComponent) | ||||||
|     )[1] |     ) | ||||||
|     const dateAddedBefore = dateAddedDropdown.queryAll(By.css('input'))[1] |     const dateAddedBefore = datesDropdown.queryAll(By.css('input'))[2] | ||||||
|  |  | ||||||
|     dateAddedBefore.nativeElement.value = '05/14/2023' |     dateAddedBefore.nativeElement.value = '05/14/2023' | ||||||
|     // dateAddedBefore.triggerEventHandler('change') |     // 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(() => { |   it('should convert user input to correct filter rules on date added with relative date', fakeAsync(() => { | ||||||
|     const dateAddedDropdown = fixture.debugElement.queryAll( |     const datesDropdown = fixture.debugElement.query( | ||||||
|       By.directive(DateDropdownComponent) |       By.directive(DatesDropdownComponent) | ||||||
|     )[1] |     ) | ||||||
|     const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll( |     const dateCreatedBeforeRelativeButton = datesDropdown.queryAll( | ||||||
|       By.css('button') |       By.css('button') | ||||||
|     )[1] |     )[1] | ||||||
|     dateAddedBeforeRelativeButton.triggerEventHandler('click') |     dateCreatedBeforeRelativeButton.triggerEventHandler('click') | ||||||
|     fixture.detectChanges() |     fixture.detectChanges() | ||||||
|     tick(400) |     tick(400) | ||||||
|     expect(component.filterRules).toEqual([ |     expect(component.filterRules).toEqual([ | ||||||
|       { |       { | ||||||
|         rule_type: FILTER_FULLTEXT_QUERY, |         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(() => { |   it('should carry over text filtering on date added with relative date', fakeAsync(() => { | ||||||
|     component.textFilter = 'foo' |     component.textFilter = 'foo' | ||||||
|     const dateAddedDropdown = fixture.debugElement.queryAll( |     const datesDropdown = fixture.debugElement.query( | ||||||
|       By.directive(DateDropdownComponent) |       By.directive(DatesDropdownComponent) | ||||||
|     )[1] |     ) | ||||||
|     const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll( |     const dateCreatedBeforeRelativeButton = datesDropdown.queryAll( | ||||||
|       By.css('button') |       By.css('button') | ||||||
|     )[1] |     )[1] | ||||||
|     dateAddedBeforeRelativeButton.triggerEventHandler('click') |     dateCreatedBeforeRelativeButton.triggerEventHandler('click') | ||||||
|     fixture.detectChanges() |     fixture.detectChanges() | ||||||
|     tick(400) |     tick(400) | ||||||
|     expect(component.filterRules).toEqual([ |     expect(component.filterRules).toEqual([ | ||||||
|       { |       { | ||||||
|         rule_type: FILTER_FULLTEXT_QUERY, |         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: 32, document_count: 1 }, | ||||||
|         { id: 33, document_count: 0 }, |         { 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') |     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 = [ |     component.filterRules = [ | ||||||
|       { |       { | ||||||
|         rule_type: FILTER_TITLE, |         rule_type: FILTER_TITLE, | ||||||
|   | |||||||
| @@ -48,8 +48,12 @@ import { | |||||||
|   FILTER_OWNER_DOES_NOT_INCLUDE, |   FILTER_OWNER_DOES_NOT_INCLUDE, | ||||||
|   FILTER_OWNER_ISNULL, |   FILTER_OWNER_ISNULL, | ||||||
|   FILTER_OWNER_ANY, |   FILTER_OWNER_ANY, | ||||||
|   FILTER_CUSTOM_FIELDS, |   FILTER_CUSTOM_FIELDS_TEXT, | ||||||
|   FILTER_SHARED_BY_USER, |   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' | } from 'src/app/data/filter-rule-type' | ||||||
| import { | import { | ||||||
|   FilterableDropdownSelectionModel, |   FilterableDropdownSelectionModel, | ||||||
| @@ -65,7 +69,7 @@ import { | |||||||
| import { Document } from 'src/app/data/document' | import { Document } from 'src/app/data/document' | ||||||
| import { StoragePath } from 'src/app/data/storage-path' | import { StoragePath } from 'src/app/data/storage-path' | ||||||
| import { StoragePathService } from 'src/app/services/rest/storage-path.service' | 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 { | import { | ||||||
|   OwnerFilterType, |   OwnerFilterType, | ||||||
|   PermissionsSelectionModel, |   PermissionsSelectionModel, | ||||||
| @@ -76,6 +80,8 @@ import { | |||||||
|   PermissionsService, |   PermissionsService, | ||||||
| } from 'src/app/services/permissions.service' | } from 'src/app/services/permissions.service' | ||||||
| import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | 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 = 'title' | ||||||
| const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content' | const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content' | ||||||
| @@ -208,6 +214,16 @@ export class FilterEditorComponent | |||||||
|             return $localize`Without any tag` |             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: |         case FILTER_TITLE: | ||||||
|           return $localize`Title: ${rule.value}` |           return $localize`Title: ${rule.value}` | ||||||
|  |  | ||||||
| @@ -234,7 +250,8 @@ export class FilterEditorComponent | |||||||
|     private correspondentService: CorrespondentService, |     private correspondentService: CorrespondentService, | ||||||
|     private documentService: DocumentService, |     private documentService: DocumentService, | ||||||
|     private storagePathService: StoragePathService, |     private storagePathService: StoragePathService, | ||||||
|     public permissionsService: PermissionsService |     public permissionsService: PermissionsService, | ||||||
|  |     private customFieldService: CustomFieldsService | ||||||
|   ) { |   ) { | ||||||
|     super() |     super() | ||||||
|   } |   } | ||||||
| @@ -246,11 +263,13 @@ export class FilterEditorComponent | |||||||
|   correspondents: Correspondent[] = [] |   correspondents: Correspondent[] = [] | ||||||
|   documentTypes: DocumentType[] = [] |   documentTypes: DocumentType[] = [] | ||||||
|   storagePaths: StoragePath[] = [] |   storagePaths: StoragePath[] = [] | ||||||
|  |   customFields: CustomField[] = [] | ||||||
|  |  | ||||||
|   tagDocumentCounts: SelectionDataItem[] |   tagDocumentCounts: SelectionDataItem[] | ||||||
|   correspondentDocumentCounts: SelectionDataItem[] |   correspondentDocumentCounts: SelectionDataItem[] | ||||||
|   documentTypeDocumentCounts: SelectionDataItem[] |   documentTypeDocumentCounts: SelectionDataItem[] | ||||||
|   storagePathDocumentCounts: SelectionDataItem[] |   storagePathDocumentCounts: SelectionDataItem[] | ||||||
|  |   customFieldDocumentCounts: SelectionDataItem[] | ||||||
|  |  | ||||||
|   _textFilter = '' |   _textFilter = '' | ||||||
|   _moreLikeId: number |   _moreLikeId: number | ||||||
| @@ -288,6 +307,7 @@ export class FilterEditorComponent | |||||||
|   correspondentSelectionModel = new FilterableDropdownSelectionModel() |   correspondentSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   documentTypeSelectionModel = new FilterableDropdownSelectionModel() |   documentTypeSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|   storagePathSelectionModel = new FilterableDropdownSelectionModel() |   storagePathSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|  |   customFieldSelectionModel = new FilterableDropdownSelectionModel() | ||||||
|  |  | ||||||
|   dateCreatedBefore: string |   dateCreatedBefore: string | ||||||
|   dateCreatedAfter: string |   dateCreatedAfter: string | ||||||
| @@ -322,6 +342,7 @@ export class FilterEditorComponent | |||||||
|     this.storagePathSelectionModel.clear(false) |     this.storagePathSelectionModel.clear(false) | ||||||
|     this.tagSelectionModel.clear(false) |     this.tagSelectionModel.clear(false) | ||||||
|     this.correspondentSelectionModel.clear(false) |     this.correspondentSelectionModel.clear(false) | ||||||
|  |     this.customFieldSelectionModel.clear(false) | ||||||
|     this._textFilter = null |     this._textFilter = null | ||||||
|     this._moreLikeId = null |     this._moreLikeId = null | ||||||
|     this.dateAddedBefore = null |     this.dateAddedBefore = null | ||||||
| @@ -347,7 +368,7 @@ export class FilterEditorComponent | |||||||
|           this._textFilter = rule.value |           this._textFilter = rule.value | ||||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_ASN |           this.textFilterTarget = TEXT_FILTER_TARGET_ASN | ||||||
|           break |           break | ||||||
|         case FILTER_CUSTOM_FIELDS: |         case FILTER_CUSTOM_FIELDS_TEXT: | ||||||
|           this._textFilter = rule.value |           this._textFilter = rule.value | ||||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS |           this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS | ||||||
|           break |           break | ||||||
| @@ -488,6 +509,36 @@ export class FilterEditorComponent | |||||||
|             false |             false | ||||||
|           ) |           ) | ||||||
|           break |           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: |         case FILTER_ASN_ISNULL: | ||||||
|           this.textFilterTarget = TEXT_FILTER_TARGET_ASN |           this.textFilterTarget = TEXT_FILTER_TARGET_ASN | ||||||
|           this.textFilterModifier = |           this.textFilterModifier = | ||||||
| @@ -595,7 +646,7 @@ export class FilterEditorComponent | |||||||
|       this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS |       this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS | ||||||
|     ) { |     ) { | ||||||
|       filterRules.push({ |       filterRules.push({ | ||||||
|         rule_type: FILTER_CUSTOM_FIELDS, |         rule_type: FILTER_CUSTOM_FIELDS_TEXT, | ||||||
|         value: this._textFilter, |         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) { |     if (this.dateCreatedBefore) { | ||||||
|       filterRules.push({ |       filterRules.push({ | ||||||
|         rule_type: FILTER_CREATED_BEFORE, |         rule_type: FILTER_CREATED_BEFORE, | ||||||
| @@ -845,6 +925,8 @@ export class FilterEditorComponent | |||||||
|       selectionData?.selected_correspondents ?? null |       selectionData?.selected_correspondents ?? null | ||||||
|     this.storagePathDocumentCounts = |     this.storagePathDocumentCounts = | ||||||
|       selectionData?.selected_storage_paths ?? null |       selectionData?.selected_storage_paths ?? null | ||||||
|  |     this.customFieldDocumentCounts = | ||||||
|  |       selectionData?.selected_custom_fields ?? null | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   rulesModified: boolean = false |   rulesModified: boolean = false | ||||||
| @@ -905,6 +987,16 @@ export class FilterEditorComponent | |||||||
|         .listAll() |         .listAll() | ||||||
|         .subscribe((result) => (this.storagePaths = result.results)) |         .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>() |     this.textFilterDebounce = new Subject<string>() | ||||||
|  |  | ||||||
| @@ -961,6 +1053,10 @@ export class FilterEditorComponent | |||||||
|     this.storagePathSelectionModel.apply() |     this.storagePathSelectionModel.apply() | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   onCustomFieldsDropdownOpen() { | ||||||
|  |     this.customFieldSelectionModel.apply() | ||||||
|  |   } | ||||||
|  |  | ||||||
|   updateTextFilter(text) { |   updateTextFilter(text) { | ||||||
|     this._textFilter = text |     this._textFilter = text | ||||||
|     this.documentService.searchQuery = 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_OWNER_DOES_NOT_INCLUDE = 35 | ||||||
| export const FILTER_SHARED_BY_USER = 37 | 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[] = [ | export const FILTER_RULE_TYPES: FilterRuleType[] = [ | ||||||
|   { |   { | ||||||
| @@ -281,11 +285,36 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [ | |||||||
|     multi: true, |     multi: true, | ||||||
|   }, |   }, | ||||||
|   { |   { | ||||||
|     id: FILTER_CUSTOM_FIELDS, |     id: FILTER_CUSTOM_FIELDS_TEXT, | ||||||
|     filtervar: 'custom_fields__icontains', |     filtervar: 'custom_fields__icontains', | ||||||
|     datatype: 'string', |     datatype: 'string', | ||||||
|     multi: false, |     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 { | export interface FilterRuleType { | ||||||
|   | |||||||
| @@ -36,6 +36,7 @@ export interface SelectionData { | |||||||
|   selected_correspondents: SelectionDataItem[] |   selected_correspondents: SelectionDataItem[] | ||||||
|   selected_tags: SelectionDataItem[] |   selected_tags: SelectionDataItem[] | ||||||
|   selected_document_types: SelectionDataItem[] |   selected_document_types: SelectionDataItem[] | ||||||
|  |   selected_custom_fields: SelectionDataItem[] | ||||||
| } | } | ||||||
|  |  | ||||||
| @Injectable({ | @Injectable({ | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ from documents.data_models import ConsumableDocument | |||||||
| from documents.data_models import DocumentMetadataOverrides | from documents.data_models import DocumentMetadataOverrides | ||||||
| from documents.data_models import DocumentSource | from documents.data_models import DocumentSource | ||||||
| from documents.models import Correspondent | from documents.models import Correspondent | ||||||
|  | from documents.models import CustomFieldInstance | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
| from documents.models import StoragePath | from documents.models import StoragePath | ||||||
| @@ -120,6 +121,30 @@ def modify_tags(doc_ids, add_tags, remove_tags): | |||||||
|     return "OK" |     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): | def delete(doc_ids): | ||||||
|     Document.objects.filter(id__in=doc_ids).delete() |     Document.objects.filter(id__in=doc_ids).delete() | ||||||
|  |  | ||||||
|   | |||||||
| @@ -199,6 +199,25 @@ class DocumentFilterSet(FilterSet): | |||||||
|  |  | ||||||
|     custom_fields__icontains = CustomFieldsFilter() |     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() |     shared_by__id = SharedByUser() | ||||||
|  |  | ||||||
|     class Meta: |     class Meta: | ||||||
|   | |||||||
| @@ -70,6 +70,8 @@ def get_schema(): | |||||||
|         num_notes=NUMERIC(sortable=True, signed=False), |         num_notes=NUMERIC(sortable=True, signed=False), | ||||||
|         custom_fields=TEXT(), |         custom_fields=TEXT(), | ||||||
|         custom_field_count=NUMERIC(sortable=True, signed=False), |         custom_field_count=NUMERIC(sortable=True, signed=False), | ||||||
|  |         has_custom_fields=BOOLEAN(), | ||||||
|  |         custom_fields_id=KEYWORD(commas=True), | ||||||
|         owner=TEXT(), |         owner=TEXT(), | ||||||
|         owner_id=NUMERIC(), |         owner_id=NUMERIC(), | ||||||
|         has_owner=BOOLEAN(), |         has_owner=BOOLEAN(), | ||||||
| @@ -125,6 +127,9 @@ def update_document(writer: AsyncWriter, doc: Document): | |||||||
|     custom_fields = ",".join( |     custom_fields = ",".join( | ||||||
|         [str(c) for c in CustomFieldInstance.objects.filter(document=doc)], |         [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 |     asn = doc.archive_serial_number | ||||||
|     if asn is not None and ( |     if asn is not None and ( | ||||||
|         asn < Document.ARCHIVE_SERIAL_NUMBER_MIN |         asn < Document.ARCHIVE_SERIAL_NUMBER_MIN | ||||||
| @@ -166,6 +171,8 @@ def update_document(writer: AsyncWriter, doc: Document): | |||||||
|         num_notes=len(notes), |         num_notes=len(notes), | ||||||
|         custom_fields=custom_fields, |         custom_fields=custom_fields, | ||||||
|         custom_field_count=len(doc.custom_fields.all()), |         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=doc.owner.username if doc.owner else None, | ||||||
|         owner_id=doc.owner.id if doc.owner else None, |         owner_id=doc.owner.id if doc.owner else None, | ||||||
|         has_owner=doc.owner is not None, |         has_owner=doc.owner is not None, | ||||||
| @@ -206,7 +213,10 @@ class DelayedQuery: | |||||||
|         "created": ("created", ["date__lt", "date__gt"]), |         "created": ("created", ["date__lt", "date__gt"]), | ||||||
|         "checksum": ("checksum", ["icontains", "istartswith"]), |         "checksum": ("checksum", ["icontains", "istartswith"]), | ||||||
|         "original_filename": ("original_filename", ["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): |     def _get_query(self): | ||||||
| @@ -220,6 +230,12 @@ class DelayedQuery: | |||||||
|                 criterias.append(query.Term("has_tag", self.evalBoolean(value))) |                 criterias.append(query.Term("has_tag", self.evalBoolean(value))) | ||||||
|                 continue |                 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 |             # Don't process query params without a filter | ||||||
|             if "__" not in key: |             if "__" not in key: | ||||||
|                 continue |                 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")), |         (35, _("does not have owner in")), | ||||||
|         (36, _("has custom field value")), |         (36, _("has custom field value")), | ||||||
|         (37, _("is shared by me")), |         (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( |     saved_view = models.ForeignKey( | ||||||
|   | |||||||
| @@ -905,6 +905,7 @@ class BulkEditSerializer( | |||||||
|             "add_tag", |             "add_tag", | ||||||
|             "remove_tag", |             "remove_tag", | ||||||
|             "modify_tags", |             "modify_tags", | ||||||
|  |             "modify_custom_fields", | ||||||
|             "delete", |             "delete", | ||||||
|             "redo_ocr", |             "redo_ocr", | ||||||
|             "set_permissions", |             "set_permissions", | ||||||
| @@ -929,6 +930,17 @@ class BulkEditSerializer( | |||||||
|                 f"Some tags in {name} don't exist or were specified twice.", |                 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): |     def validate_method(self, method): | ||||||
|         if method == "set_correspondent": |         if method == "set_correspondent": | ||||||
|             return bulk_edit.set_correspondent |             return bulk_edit.set_correspondent | ||||||
| @@ -942,6 +954,8 @@ class BulkEditSerializer( | |||||||
|             return bulk_edit.remove_tag |             return bulk_edit.remove_tag | ||||||
|         elif method == "modify_tags": |         elif method == "modify_tags": | ||||||
|             return bulk_edit.modify_tags |             return bulk_edit.modify_tags | ||||||
|  |         elif method == "modify_custom_fields": | ||||||
|  |             return bulk_edit.modify_custom_fields | ||||||
|         elif method == "delete": |         elif method == "delete": | ||||||
|             return bulk_edit.delete |             return bulk_edit.delete | ||||||
|         elif method == "redo_ocr": |         elif method == "redo_ocr": | ||||||
| @@ -1017,6 +1031,23 @@ class BulkEditSerializer( | |||||||
|         else: |         else: | ||||||
|             raise serializers.ValidationError("remove_tags not specified") |             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): |     def _validate_owner(self, owner): | ||||||
|         ownerUser = User.objects.get(pk=owner) |         ownerUser = User.objects.get(pk=owner) | ||||||
|         if ownerUser is None: |         if ownerUser is None: | ||||||
| @@ -1079,6 +1110,8 @@ class BulkEditSerializer( | |||||||
|             self._validate_parameters_modify_tags(parameters) |             self._validate_parameters_modify_tags(parameters) | ||||||
|         elif method == bulk_edit.set_storage_path: |         elif method == bulk_edit.set_storage_path: | ||||||
|             self._validate_storage_path(parameters) |             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: |         elif method == bulk_edit.set_permissions: | ||||||
|             self._validate_parameters_set_permissions(parameters) |             self._validate_parameters_set_permissions(parameters) | ||||||
|         elif method == bulk_edit.rotate: |         elif method == bulk_edit.rotate: | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ from rest_framework import status | |||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
| from documents.models import Correspondent | from documents.models import Correspondent | ||||||
|  | from documents.models import CustomField | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
| from documents.models import StoragePath | from documents.models import StoragePath | ||||||
| @@ -49,6 +50,8 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase): | |||||||
|         self.doc3.tags.add(self.t2) |         self.doc3.tags.add(self.t2) | ||||||
|         self.doc4.tags.add(self.t1, self.t2) |         self.doc4.tags.add(self.t1, self.t2) | ||||||
|         self.sp1 = StoragePath.objects.create(name="sp1", path="Something/{checksum}") |         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") |     @mock.patch("documents.serialisers.bulk_edit.set_correspondent") | ||||||
|     def test_api_set_correspondent(self, m): |     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) |         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||||
|         m.assert_not_called() |         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") |     @mock.patch("documents.serialisers.bulk_edit.delete") | ||||||
|     def test_api_delete(self, m): |     def test_api_delete(self, m): | ||||||
|         m.return_value = "OK" |         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): |     def test_search_filtering_respect_owner(self): | ||||||
|         """ |         """ | ||||||
|         GIVEN: |         GIVEN: | ||||||
|   | |||||||
| @@ -11,6 +11,8 @@ from guardian.shortcuts import get_users_with_perms | |||||||
|  |  | ||||||
| from documents import bulk_edit | from documents import bulk_edit | ||||||
| from documents.models import Correspondent | from documents.models import Correspondent | ||||||
|  | from documents.models import CustomField | ||||||
|  | from documents.models import CustomFieldInstance | ||||||
| from documents.models import Document | from documents.models import Document | ||||||
| from documents.models import DocumentType | from documents.models import DocumentType | ||||||
| from documents.models import StoragePath | 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 |         # 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]) |         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): |     def test_delete(self): | ||||||
|         self.assertEqual(Document.objects.count(), 5) |         self.assertEqual(Document.objects.count(), 5) | ||||||
|         bulk_edit.delete([self.doc1.id, self.doc2.id]) |         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( |         r = Response( | ||||||
|             { |             { | ||||||
|                 "selected_correspondents": [ |                 "selected_correspondents": [ | ||||||
| @@ -1081,6 +1093,10 @@ class SelectionDataView(GenericAPIView): | |||||||
|                     {"id": t.id, "document_count": t.document_count} |                     {"id": t.id, "document_count": t.document_count} | ||||||
|                     for t in storage_paths |                     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