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
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')
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'
boxes,
calendar,
calendarEvent,
+ calendarEventFill,
cardChecklist,
cardHeading,
caretDown,
boxes,
calendar,
calendarEvent,
+ calendarEventFill,
cardChecklist,
cardHeading,
caretDown,
FilterEditorComponent,
FilterableDropdownComponent,
ToggleableDropdownButtonComponent,
- DateDropdownComponent,
+ DatesDropdownComponent,
DocumentCardLargeComponent,
DocumentCardSmallComponent,
BulkEditorComponent,
+++ /dev/null
-<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>
+++ /dev/null
-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()
- }
- }
-}
--- /dev/null
+<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>
.date-dropdown {
white-space: nowrap;
+ @media(min-width: 768px) {
+ --bs-dropdown-min-width: 40rem;
+ }
+
.btn-link {
line-height: 1;
}
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'
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,
],
settingsService = TestBed.inject(SettingsService)
settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat')
- fixture = TestBed.createComponent(DateDropdownComponent)
+ fixture = TestBed.createComponent(DatesDropdownComponent)
component = fixture.componentInstance
fixture.detectChanges()
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'))
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', () => {
--- /dev/null
+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()
+ }
+ }
+}
(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">
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: [
{ 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', () => {
let correspondentsService: CorrespondentService
let documentTypeService: DocumentTypeService
let storagePathService: StoragePathService
+ let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController
beforeEach(async () => {
}),
},
},
+ {
+ provide: CustomFieldsService,
+ useValue: {
+ listAll: () =>
+ of({
+ results: [
+ { id: 77, name: 'customfield1' },
+ { id: 88, name: 'customfield2' },
+ ],
+ }),
+ },
+ },
FilterPipe,
SettingsService,
{
correspondentsService = TestBed.inject(CorrespondentService)
documentTypeService = TestBed.inject(DocumentTypeService)
storagePathService = TestBed.inject(StoragePathService)
+ customFieldsService = TestBed.inject(CustomFieldsService)
httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(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
)
})
+ 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: [],
itemsToAdd: [],
itemsToRemove: [],
})
+ component.setCustomFields({
+ itemsToAdd: [],
+ itemsToRemove: [],
+ })
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
)
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)
+ })
})
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',
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()
private settings: SettingsService,
private toastService: ToastService,
private storagePathService: StoragePathService,
+ private customFieldService: CustomFieldsService,
private permissionService: PermissionsService
) {
super()
.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')
})
}
+ 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 ''
}
}
+ 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',
})
}
+ 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',
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'
PageHeaderComponent,
FilterEditorComponent,
FilterableDropdownComponent,
- DateDropdownComponent,
+ DatesDropdownComponent,
PermissionsFilterDropdownComponent,
ToggleableDropdownButtonComponent,
BulkEditorComponent,
[documentCounts]="storagePathDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
}
- </div>
- <div class="d-flex flex-wrap gap-2">
- <pngx-date-dropdown
- title="Created" i18n-title
- (datesSet)="updateRules()"
- [(dateBefore)]="dateCreatedBefore"
- [(dateAfter)]="dateCreatedAfter"
- [(relativeDate)]="dateCreatedRelativeDate"></pngx-date-dropdown>
- <pngx-date-dropdown
- title="Added" i18n-title
+
+ @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
+ <pngx-filterable-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
+ filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
+ [items]="customFields"
+ [manyToOne]="true"
+ [(selectionModel)]="customFieldSelectionModel"
+ (selectionModelChange)="updateRules()"
+ (opened)="onCustomFieldsDropdownOpen()"
+ [documentCounts]="customFieldDocumentCounts"
+ [allowSelectNone]="true"></pngx-filterable-dropdown>
+ }
+ <pngx-dates-dropdown
+ title="Dates" i18n-title
(datesSet)="updateRules()"
- [(dateBefore)]="dateAddedBefore"
- [(dateAfter)]="dateAddedAfter"
- [(relativeDate)]="dateAddedRelativeDate"></pngx-date-dropdown>
- </div>
- <div class="d-flex flex-wrap">
+ [(createdDateBefore)]="dateCreatedBefore"
+ [(createdDateAfter)]="dateCreatedAfter"
+ [(createdRelativeDate)]="dateCreatedRelativeDate"
+ [(addedDateBefore)]="dateAddedBefore"
+ [(addedDateAfter)]="dateAddedAfter"
+ [(addedRelativeDate)]="dateAddedRelativeDate">
+ </pngx-dates-dropdown>
<pngx-permissions-filter-dropdown
title="Permissions" i18n-title
(ownerFilterSet)="updateRules()"
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'
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,
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[] = [
{
},
]
+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,
IfPermissionsDirective,
ClearableBadgeComponent,
ToggleableDropdownButtonComponent,
- DateDropdownComponent,
+ DatesDropdownComponent,
CustomDatePipe,
],
providers: [
listAll: () => of({ results: storage_paths }),
},
},
+ {
+ provide: CustomFieldsService,
+ useValue: {
+ listAll: () => of({ results: custom_fields }),
+ },
+ },
{
provide: UserService,
useValue: {
expect(component.textFilter).toEqual(null)
component.filterRules = [
{
- rule_type: FILTER_CUSTOM_FIELDS,
+ rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo',
},
]
]
}))
+ 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
expect(component.textFilterTarget).toEqual('custom-fields')
expect(component.filterRules).toEqual([
{
- rule_type: FILTER_CUSTOM_FIELDS,
+ rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo',
},
])
])
}))
+ 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]
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]
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')
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')
}))
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')
}))
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')
}))
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]',
},
])
}))
{ id: 32, document_count: 1 },
{ id: 33, document_count: 0 },
],
+ selected_custom_fields: [
+ { id: 42, document_count: 1 },
+ { id: 43, document_count: 0 },
+ ],
}
})
]
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,
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,
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,
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'
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}`
private correspondentService: CorrespondentService,
private documentService: DocumentService,
private storagePathService: StoragePathService,
- public permissionsService: PermissionsService
+ public permissionsService: PermissionsService,
+ private customFieldService: CustomFieldsService
) {
super()
}
correspondents: Correspondent[] = []
documentTypes: DocumentType[] = []
storagePaths: StoragePath[] = []
+ customFields: CustomField[] = []
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
+ customFieldDocumentCounts: SelectionDataItem[]
_textFilter = ''
_moreLikeId: number
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
+ customFieldSelectionModel = new FilterableDropdownSelectionModel()
dateCreatedBefore: string
dateCreatedAfter: string
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
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
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 =
this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
) {
filterRules.push({
- rule_type: FILTER_CUSTOM_FIELDS,
+ rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: this._textFilter,
})
}
})
})
}
+ 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,
selectionData?.selected_correspondents ?? null
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? null
+ this.customFieldDocumentCounts =
+ selectionData?.selected_custom_fields ?? null
}
rulesModified: boolean = false
.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>()
this.storagePathSelectionModel.apply()
}
+ onCustomFieldsDropdownOpen() {
+ this.customFieldSelectionModel.apply()
+ }
+
updateTextFilter(text) {
this._textFilter = text
this.documentService.searchQuery = text
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[] = [
{
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 {
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
+ selected_custom_fields: SelectionDataItem[]
}
@Injectable({
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
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()
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:
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(),
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
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,
"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):
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
--- /dev/null
+# 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",
+ ),
+ ),
+ ]
(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(
"add_tag",
"remove_tag",
"modify_tags",
+ "modify_custom_fields",
"delete",
"redo_ocr",
"set_permissions",
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
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":
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:
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:
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
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):
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"
),
)
+ 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:
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
# 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])
),
)
+ custom_fields = CustomField.objects.annotate(
+ document_count=Count(
+ Case(
+ When(
+ fields__document__id__in=ids,
+ then=1,
+ ),
+ output_field=IntegerField(),
+ ),
+ ),
+ )
+
r = Response(
{
"selected_correspondents": [
{"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
+ ],
},
)