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