Feature: custom fields filtering & bulk editing (#6484)

This commit is contained in:
shamoon
2024-04-26 15:10:03 -07:00
committed by GitHub
parent bd4476d484
commit 63e1f9f5d3
28 changed files with 1563 additions and 323 deletions

View File

@@ -81,14 +81,15 @@ test('text filtering', async ({ page }) => {
test('date filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
await page.goto('/documents')
await page.getByRole('button', { name: 'Created' }).click()
await page.getByRole('menuitem', { name: 'Last 3 months' }).click()
await page.getByRole('button', { name: 'Dates' }).click()
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
await page.getByRole('button', { name: 'Created Clear selected' }).click()
await page.getByRole('button', { name: 'Created' }).click()
await page.getByRole('button', { name: 'Dates Clear selected' }).click()
await page.getByRole('button', { name: 'Dates' }).click()
await page
.getByRole('menuitem', { name: 'After mm/dd/yyyy' })
.getByRole('button')
.first()
.click()
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')

View File

@@ -31,7 +31,7 @@ import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component'
import { DatesDropdownComponent } from './components/common/dates-dropdown/dates-dropdown.component'
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
@@ -140,6 +140,7 @@ import {
boxes,
calendar,
calendarEvent,
calendarEventFill,
cardChecklist,
cardHeading,
caretDown,
@@ -235,6 +236,7 @@ const icons = {
boxes,
calendar,
calendarEvent,
calendarEventFill,
cardChecklist,
cardHeading,
caretDown,
@@ -407,7 +409,7 @@ function initializeApp(settings: SettingsService) {
FilterEditorComponent,
FilterableDropdownComponent,
ToggleableDropdownButtonComponent,
DateDropdownComponent,
DatesDropdownComponent,
DocumentCardLargeComponent,
DocumentCardSmallComponent,
BulkEditorComponent,

View File

@@ -1,71 +0,0 @@
<div class="btn-group w-100" ngbDropdown role="group">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="dateBefore || dateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
{{title}}
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button>
<div class="dropdown-menu date-dropdown shadow pt-0" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<div class="list-group list-group-flush">
@for (rd of relativeDates; track rd) {
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setRelativeDate(rd.id)">
<div class="selected-icon">
@if (relativeDate === rd.id) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
<div class="pe-2 pe-lg-4">
{{rd.name}}
</div>
<div class="text-muted small pe-2">
<span class="small">
{{ rd.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
</span>
</div>
</div>
</button>
}
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>After</div>
@if (dateAfter) {
<a class="btn btn-link p-0 m-0" (click)="clearAfter()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="dateAfter" ngbDatepicker #dateAfterPicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="dateAfterPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>Before</div>
@if (dateBefore) {
<a class="btn btn-link p-0 m-0" (click)="clearBefore()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="dateBefore" ngbDatepicker #dateBeforePicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="dateBeforePicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -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()
}
}
}

View File

@@ -0,0 +1,143 @@
<div class="btn-group w-100" ngbDropdown role="group">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateBefore || createdDateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button>
<div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<div class="row d-flex">
<div class="col border-end">
<div class="list-group list-group-flush">
<h6 class="dropdown-header border-bottom" i18n>Created</h6>
@for (rd of relativeDates; track rd) {
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setCreatedRelativeDate(rd.id)">
<div class="selected-icon">
@if (createdRelativeDate === rd.id) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
<div class="pe-2 pe-lg-4">
{{rd.name}}
</div>
<div class="text-muted small pe-2">
<span class="small">
{{ rd.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
</span>
</div>
</div>
</button>
}
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>After</div>
@if (createdDateAfter) {
<a class="btn btn-link p-0 m-0" (click)="clearCreatedAfter()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateAfter" ngbDatepicker #createdDateAfterPicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="createdDateAfterPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>Before</div>
@if (createdDateBefore) {
<a class="btn btn-link p-0 m-0" (click)="clearCreatedBefore()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateBefore" ngbDatepicker #createdDateBeforePicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="createdDateBeforePicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
</div>
</div>
<div class="col">
<h6 class="dropdown-header border-bottom" i18n>Added</h6>
<div class="list-group list-group-flush">
@for (rd of relativeDates; track rd) {
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setAddedRelativeDate(rd.id)">
<div class="selected-icon">
@if (addedRelativeDate === rd.id) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
<div class="pe-2 pe-lg-4">
{{rd.name}}
</div>
<div class="text-muted small pe-2">
<span class="small">
{{ rd.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
</span>
</div>
</div>
</button>
}
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>After</div>
@if (addedDateAfter) {
<a class="btn btn-link p-0 m-0" (click)="clearAddedAfter()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateAfter" ngbDatepicker #addedDateAfterPicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="addedDateAfterPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>Before</div>
@if (addedDateBefore) {
<a class="btn btn-link p-0 m-0" (click)="clearAddedBefore()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateBefore" ngbDatepicker #addedDateBeforePicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="addedDateBeforePicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,10 @@
.date-dropdown {
white-space: nowrap;
@media(min-width: 768px) {
--bs-dropdown-min-width: 40rem;
}
.btn-link {
line-height: 1;
}

View File

@@ -4,12 +4,12 @@ import {
fakeAsync,
tick,
} from '@angular/core/testing'
let fixture: ComponentFixture<DateDropdownComponent>
let fixture: ComponentFixture<DatesDropdownComponent>
import {
DateDropdownComponent,
DatesDropdownComponent,
DateSelection,
RelativeDate,
} from './date-dropdown.component'
} from './dates-dropdown.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { SettingsService } from 'src/app/services/settings.service'
@@ -19,15 +19,15 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DatePipe } from '@angular/common'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
describe('DateDropdownComponent', () => {
let component: DateDropdownComponent
describe('DatesDropdownComponent', () => {
let component: DatesDropdownComponent
let settingsService: SettingsService
let settingsSpy
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
DateDropdownComponent,
DatesDropdownComponent,
ClearableBadgeComponent,
CustomDatePipe,
],
@@ -44,7 +44,7 @@ describe('DateDropdownComponent', () => {
settingsService = TestBed.inject(SettingsService)
settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat')
fixture = TestBed.createComponent(DateDropdownComponent)
fixture = TestBed.createComponent(DatesDropdownComponent)
component = fixture.componentInstance
fixture.detectChanges()
@@ -57,7 +57,7 @@ describe('DateDropdownComponent', () => {
it('should support date input, emit change', fakeAsync(() => {
let result: string
component.dateAfterChange.subscribe((date) => (result = date))
component.createdDateAfterChange.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023'
input.dispatchEvent(new Event('change'))
@@ -78,45 +78,69 @@ describe('DateDropdownComponent', () => {
it('should support relative dates', fakeAsync(() => {
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
component.setRelativeDate(null)
component.setRelativeDate(RelativeDate.LAST_7_DAYS)
component.setCreatedRelativeDate(null)
component.setCreatedRelativeDate(RelativeDate.LAST_7_DAYS)
component.setAddedRelativeDate(null)
component.setAddedRelativeDate(RelativeDate.LAST_7_DAYS)
tick(500)
expect(result).toEqual({
after: null,
before: null,
relativeDateID: RelativeDate.LAST_7_DAYS,
createdAfter: null,
createdBefore: null,
createdRelativeDateID: RelativeDate.LAST_7_DAYS,
addedAfter: null,
addedBefore: null,
addedRelativeDateID: RelativeDate.LAST_7_DAYS,
})
}))
it('should support report if active', () => {
component.relativeDate = RelativeDate.LAST_7_DAYS
component.createdRelativeDate = RelativeDate.LAST_7_DAYS
expect(component.isActive).toBeTruthy()
component.relativeDate = null
component.dateAfter = '2023-05-30'
component.createdRelativeDate = null
component.createdDateAfter = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.dateAfter = null
component.dateBefore = '2023-05-30'
component.createdDateAfter = null
component.createdDateBefore = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.dateBefore = null
component.createdDateBefore = null
component.addedRelativeDate = RelativeDate.LAST_7_DAYS
expect(component.isActive).toBeTruthy()
component.addedRelativeDate = null
component.addedDateAfter = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.addedDateAfter = null
component.addedDateBefore = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.addedDateBefore = null
expect(component.isActive).toBeFalsy()
})
it('should support reset', () => {
component.dateAfter = '2023-05-30'
component.createdDateAfter = '2023-05-30'
component.reset()
expect(component.dateAfter).toBeNull()
expect(component.createdDateAfter).toBeNull()
})
it('should support clearAfter', () => {
component.dateAfter = '2023-05-30'
component.clearAfter()
expect(component.dateAfter).toBeNull()
component.createdDateAfter = '2023-05-30'
component.clearCreatedAfter()
expect(component.createdDateAfter).toBeNull()
component.addedDateAfter = '2023-05-30'
component.clearAddedAfter()
expect(component.addedDateAfter).toBeNull()
})
it('should support clearBefore', () => {
component.dateBefore = '2023-05-30'
component.clearBefore()
expect(component.dateBefore).toBeNull()
component.createdDateBefore = '2023-05-30'
component.clearCreatedBefore()
expect(component.createdDateBefore).toBeNull()
component.addedDateBefore = '2023-05-30'
component.clearAddedBefore()
expect(component.addedDateBefore).toBeNull()
})
it('should limit keyboard events', () => {

View File

@@ -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()
}
}
}

View File

@@ -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">

View File

@@ -55,6 +55,9 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
const selectionData: SelectionData = {
selected_tags: [
@@ -68,6 +71,10 @@ const selectionData: SelectionData = {
{ id: 66, document_count: 3 },
{ id: 55, document_count: 0 },
],
selected_custom_fields: [
{ id: 77, document_count: 3 },
{ id: 88, document_count: 0 },
],
}
describe('BulkEditorComponent', () => {
@@ -82,6 +89,7 @@ describe('BulkEditorComponent', () => {
let correspondentsService: CorrespondentService
let documentTypeService: DocumentTypeService
let storagePathService: StoragePathService
let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController
beforeEach(async () => {
@@ -148,6 +156,18 @@ describe('BulkEditorComponent', () => {
}),
},
},
{
provide: CustomFieldsService,
useValue: {
listAll: () =>
of({
results: [
{ id: 77, name: 'customfield1' },
{ id: 88, name: 'customfield2' },
],
}),
},
},
FilterPipe,
SettingsService,
{
@@ -189,6 +209,7 @@ describe('BulkEditorComponent', () => {
correspondentsService = TestBed.inject(CorrespondentService)
documentTypeService = TestBed.inject(DocumentTypeService)
storagePathService = TestBed.inject(StoragePathService)
customFieldsService = TestBed.inject(CustomFieldsService)
httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(BulkEditorComponent)
@@ -262,6 +283,22 @@ describe('BulkEditorComponent', () => {
expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1)
})
it('should apply selection data to custom fields menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
expect(
component.customFieldsSelectionModel.getSelectedItems()
).toHaveLength(0)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 5, 7]))
jest
.spyOn(documentService, 'getSelectionData')
.mockReturnValue(of(selectionData))
component.openCustomFieldsDropdown()
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
})
it('should execute modify tags bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
@@ -679,6 +716,122 @@ describe('BulkEditorComponent', () => {
)
})
it('should execute modify custom fields bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setCustomFields({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [{ id: 102 }],
})
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'modify_custom_fields',
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setCustomFields({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [{ id: 102 }],
})
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
// coverage for modal messages
component.setCustomFields({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
component.setCustomFields({
itemsToAdd: [{ id: 101 }, { id: 102 }],
itemsToRemove: [],
})
component.setCustomFields({
itemsToAdd: [],
itemsToRemove: [{ id: 101 }, { id: 102 }],
})
component.setCustomFields({
itemsToAdd: [{ id: 100 }],
itemsToRemove: [{ id: 101 }, { id: 102 }],
})
})
it('should set modal dialog text accordingly for custom fields edit confirmation', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setCustomFields({
itemsToAdd: [],
itemsToRemove: [{ id: 101, name: 'CustomField 101' }],
})
expect(modal.componentInstance.message).toEqual(
'This operation will remove the custom field "CustomField 101" from 2 selected document(s).'
)
modal.close()
component.setCustomFields({
itemsToAdd: [{ id: 101, name: 'CustomField 101' }],
itemsToRemove: [],
})
expect(modal.componentInstance.message).toEqual(
'This operation will assign the custom field "CustomField 101" to 2 selected document(s).'
)
})
it('should only execute bulk operations when changes are detected', () => {
component.setTags({
itemsToAdd: [],
@@ -696,6 +849,10 @@ describe('BulkEditorComponent', () => {
itemsToAdd: [],
itemsToRemove: [],
})
component.setCustomFields({
itemsToAdd: [],
itemsToRemove: [],
})
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
@@ -1179,4 +1336,56 @@ describe('BulkEditorComponent', () => {
)
expect(component.storagePaths).toEqual(storagePaths.results)
})
it('should support create new custom field', () => {
const name = 'New Custom Field'
const newCustomField = { id: 101, name: 'New Custom Field' }
const customFields: Results<CustomField> = {
results: [
{
id: 1,
name: 'Custom Field 1',
data_type: CustomFieldDataType.String,
},
{
id: 2,
name: 'Custom Field 2',
data_type: CustomFieldDataType.String,
},
],
count: 2,
all: [1, 2],
}
const modalInstance = {
componentInstance: {
dialogMode: EditDialogMode.CREATE,
object: { name },
succeeded: of(newCustomField),
},
}
const customFieldsListAllSpy = jest.spyOn(customFieldsService, 'listAll')
customFieldsListAllSpy.mockReturnValue(of(customFields))
const customFieldsSelectionModelToggleSpy = jest.spyOn(
component.customFieldsSelectionModel,
'toggle'
)
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
component.createCustomField(name)
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
CustomFieldEditDialogComponent,
{ backdrop: 'static' }
)
expect(customFieldsListAllSpy).toHaveBeenCalled()
expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith(
newCustomField.id
)
expect(component.customFields).toEqual(customFields.results)
})
})

View File

@@ -41,6 +41,9 @@ import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/docume
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@Component({
selector: 'pngx-bulk-editor',
@@ -55,15 +58,18 @@ export class BulkEditorComponent
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
customFields: CustomField[]
tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel()
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
customFieldDocumentCounts: SelectionDataItem[]
awaitingDownload: boolean
unsubscribeNotifier: Subject<any> = new Subject()
@@ -85,6 +91,7 @@ export class BulkEditorComponent
private settings: SettingsService,
private toastService: ToastService,
private storagePathService: StoragePathService,
private customFieldService: CustomFieldsService,
private permissionService: PermissionsService
) {
super()
@@ -166,6 +173,17 @@ export class BulkEditorComponent
.pipe(first())
.subscribe((result) => (this.storagePaths = result.results))
}
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.CustomField
)
) {
this.customFieldService
.listAll()
.pipe(first())
.subscribe((result) => (this.customFields = result.results))
}
this.downloadForm
.get('downloadFileTypeArchive')
@@ -297,6 +315,19 @@ export class BulkEditorComponent
})
}
openCustomFieldsDropdown() {
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.customFieldDocumentCounts = s.selected_custom_fields
this.applySelectionData(
s.selected_custom_fields,
this.customFieldsSelectionModel
)
})
}
private _localizeList(items: MatchingModel[]) {
if (items.length == 0) {
return ''
@@ -495,6 +526,74 @@ export class BulkEditorComponent
}
}
setCustomFields(changedCustomFields: ChangedItems) {
if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 0
)
return
if (this.showConfirmationDialogs) {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm custom field assignment`
if (
changedCustomFields.itemsToAdd.length == 1 &&
changedCustomFields.itemsToRemove.length == 0
) {
let customField = changedCustomFields.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length > 1 &&
changedCustomFields.itemsToRemove.length == 0
) {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} to ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 1
) {
let customField = changedCustomFields.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length > 1
) {
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
)} from ${this.list.selected.size} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} and remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
)} on ${this.list.selected.size} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.executeBulkOperation(modal, 'modify_custom_fields', {
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
remove_custom_fields: changedCustomFields.itemsToRemove.map(
(f) => f.id
),
})
})
} else {
this.executeBulkOperation(null, 'modify_custom_fields', {
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
remove_custom_fields: changedCustomFields.itemsToRemove.map(
(f) => f.id
),
})
}
}
createTag(name: string) {
let modal = this.modalService.open(TagEditDialogComponent, {
backdrop: 'static',
@@ -581,6 +680,27 @@ export class BulkEditorComponent
})
}
createCustomField(name: string) {
let modal = this.modalService.open(CustomFieldEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
modal.componentInstance.object = { name }
modal.componentInstance.succeeded
.pipe(
switchMap((newCustomField) => {
return this.customFieldService
.listAll()
.pipe(map((customFields) => ({ newCustomField, customFields })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newCustomField, customFields }) => {
this.customFields = customFields.results
this.customFieldsSelectionModel.toggle(newCustomField.id)
})
}
applyDelete() {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',

View File

@@ -5,7 +5,7 @@ import { RouterTestingModule } from '@angular/router/testing'
import { routes } from 'src/app/app-routing.module'
import { FilterEditorComponent } from './filter-editor/filter-editor.component'
import { PermissionsFilterDropdownComponent } from '../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
import { DateDropdownComponent } from '../common/date-dropdown/date-dropdown.component'
import { DatesDropdownComponent } from '../common/dates-dropdown/dates-dropdown.component'
import { FilterableDropdownComponent } from '../common/filterable-dropdown/filterable-dropdown.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { BulkEditorComponent } from './bulk-editor/bulk-editor.component'
@@ -113,7 +113,7 @@ describe('DocumentListComponent', () => {
PageHeaderComponent,
FilterEditorComponent,
FilterableDropdownComponent,
DateDropdownComponent,
DatesDropdownComponent,
PermissionsFilterDropdownComponent,
ToggleableDropdownButtonComponent,
BulkEditorComponent,

View File

@@ -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()"

View File

@@ -49,8 +49,12 @@ import {
FILTER_OWNER_ANY,
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_CUSTOM_FIELDS,
FILTER_CUSTOM_FIELDS_TEXT,
FILTER_SHARED_BY_USER,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
FILTER_HAS_CUSTOM_FIELDS_ALL,
} from 'src/app/data/filter-rule-type'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
@@ -68,7 +72,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { DateDropdownComponent } from '../../common/date-dropdown/date-dropdown.component'
import { DatesDropdownComponent } from '../../common/dates-dropdown/dates-dropdown.component'
import {
FilterableDropdownComponent,
LogicalOperator,
@@ -86,6 +90,8 @@ import {
PermissionsService,
} from 'src/app/services/permissions.service'
import { environment } from 'src/environments/environment'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
const tags: Tag[] = [
{
@@ -131,6 +137,19 @@ const storage_paths: StoragePath[] = [
},
]
const custom_fields: CustomField[] = [
{
id: 42,
data_type: CustomFieldDataType.String,
name: 'CustomField42',
},
{
id: 43,
data_type: CustomFieldDataType.String,
name: 'CustomField43',
},
]
const users: User[] = [
{
id: 1,
@@ -156,7 +175,7 @@ describe('FilterEditorComponent', () => {
IfPermissionsDirective,
ClearableBadgeComponent,
ToggleableDropdownButtonComponent,
DateDropdownComponent,
DatesDropdownComponent,
CustomDatePipe,
],
providers: [
@@ -187,6 +206,12 @@ describe('FilterEditorComponent', () => {
listAll: () => of({ results: storage_paths }),
},
},
{
provide: CustomFieldsService,
useValue: {
listAll: () => of({ results: custom_fields }),
},
},
{
provide: UserService,
useValue: {
@@ -285,7 +310,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toEqual(null)
component.filterRules = [
{
rule_type: FILTER_CUSTOM_FIELDS,
rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo',
},
]
@@ -806,6 +831,110 @@ describe('FilterEditorComponent', () => {
]
}))
it('should ingest filter rules for has all custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '42',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
)
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: null,
},
]
component.toggleTag(2) // coverage
}))
it('should ingest filter rules for has any custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '42',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: null,
},
]
}))
it('should ingest filter rules for has any custom field', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: '1',
},
]
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
1
)
expect(component.customFieldSelectionModel.get(null)).toBeTruthy()
}))
it('should ingest filter rules for exclude tag(s)', fakeAsync(() => {
expect(component.customFieldSelectionModel.getExcludedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: '42',
},
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
)
expect(component.customFieldSelectionModel.getExcludedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: null,
},
]
}))
it('should ingest filter rules for owner', fakeAsync(() => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE
@@ -1053,7 +1182,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilterTarget).toEqual('custom-fields')
expect(component.filterRules).toEqual([
{
rule_type: FILTER_CUSTOM_FIELDS,
rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo',
},
])
@@ -1317,9 +1446,78 @@ describe('FilterEditorComponent', () => {
])
}))
it('should convert user input to correct filter rules on custom field select not assigned', fakeAsync(() => {
const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent)
)[4]
customFieldsFilterableDropdown.triggerEventHandler('opened')
const customFieldButton = customFieldsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent)
)[0]
customFieldButton.triggerEventHandler('toggle')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
},
])
}))
it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {
const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent)
)[4] // CF dropdown
customFieldsFilterableDropdown.triggerEventHandler('opened')
const customFieldButtons = customFieldsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent)
)
customFieldButtons[1].triggerEventHandler('toggle')
customFieldButtons[2].triggerEventHandler('toggle')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[1].id.toString(),
},
])
const toggleOperatorButtons = customFieldsFilterableDropdown.queryAll(
By.css('input[type=radio]')
)
toggleOperatorButtons[1].nativeElement.checked = true
toggleOperatorButtons[1].triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: custom_fields[1].id.toString(),
},
])
customFieldButtons[2].triggerEventHandler('exclude')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: custom_fields[1].id.toString(),
},
])
}))
it('should convert user input to correct filter rules on date created after', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
By.directive(DatesDropdownComponent)
)[0]
const dateCreatedAfter = dateCreatedDropdown.queryAll(By.css('input'))[0]
@@ -1339,7 +1537,7 @@ describe('FilterEditorComponent', () => {
it('should convert user input to correct filter rules on date created before', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
By.directive(DatesDropdownComponent)
)[0]
const dateCreatedBefore = dateCreatedDropdown.queryAll(By.css('input'))[1]
@@ -1359,7 +1557,7 @@ describe('FilterEditorComponent', () => {
it('should convert user input to correct filter rules on date created with relative date', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
By.directive(DatesDropdownComponent)
)[0]
const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll(
By.css('button')
@@ -1378,7 +1576,7 @@ describe('FilterEditorComponent', () => {
it('should carry over text filtering on date created with relative date', fakeAsync(() => {
component.textFilter = 'foo'
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
By.directive(DatesDropdownComponent)
)[0]
const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll(
By.css('button')
@@ -1423,10 +1621,10 @@ describe('FilterEditorComponent', () => {
}))
it('should convert user input to correct filter rules on date added after', fakeAsync(() => {
const dateAddedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
)[1]
const dateAddedAfter = dateAddedDropdown.queryAll(By.css('input'))[0]
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
)
const dateAddedAfter = datesDropdown.queryAll(By.css('input'))[2]
dateAddedAfter.nativeElement.value = '05/14/2023'
// dateAddedAfter.triggerEventHandler('change')
@@ -1443,10 +1641,10 @@ describe('FilterEditorComponent', () => {
}))
it('should convert user input to correct filter rules on date added before', fakeAsync(() => {
const dateAddedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
)[1]
const dateAddedBefore = dateAddedDropdown.queryAll(By.css('input'))[1]
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
)
const dateAddedBefore = datesDropdown.queryAll(By.css('input'))[2]
dateAddedBefore.nativeElement.value = '05/14/2023'
// dateAddedBefore.triggerEventHandler('change')
@@ -1463,38 +1661,38 @@ describe('FilterEditorComponent', () => {
}))
it('should convert user input to correct filter rules on date added with relative date', fakeAsync(() => {
const dateAddedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
)[1]
const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll(
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
)
const dateCreatedBeforeRelativeButton = datesDropdown.queryAll(
By.css('button')
)[1]
dateAddedBeforeRelativeButton.triggerEventHandler('click')
dateCreatedBeforeRelativeButton.triggerEventHandler('click')
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'added:[-1 week to now]',
value: 'created:[-1 week to now]',
},
])
}))
it('should carry over text filtering on date added with relative date', fakeAsync(() => {
component.textFilter = 'foo'
const dateAddedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
)[1]
const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll(
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
)
const dateCreatedBeforeRelativeButton = datesDropdown.queryAll(
By.css('button')
)[1]
dateAddedBeforeRelativeButton.triggerEventHandler('click')
dateCreatedBeforeRelativeButton.triggerEventHandler('click')
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,added:[-1 week to now]',
value: 'foo,created:[-1 week to now]',
},
])
}))
@@ -1645,6 +1843,10 @@ describe('FilterEditorComponent', () => {
{ id: 32, document_count: 1 },
{ id: 33, document_count: 0 },
],
selected_custom_fields: [
{ id: 42, document_count: 1 },
{ id: 43, document_count: 0 },
],
}
})
@@ -1719,6 +1921,24 @@ describe('FilterEditorComponent', () => {
]
expect(component.generateFilterName()).toEqual('Without any tag')
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '42',
},
]
expect(component.generateFilterName()).toEqual(
`Custom fields: ${custom_fields[0].name}`
)
component.filterRules = [
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
},
]
expect(component.generateFilterName()).toEqual('Without any custom field')
component.filterRules = [
{
rule_type: FILTER_TITLE,

View File

@@ -48,8 +48,12 @@ import {
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_OWNER_ANY,
FILTER_CUSTOM_FIELDS,
FILTER_CUSTOM_FIELDS_TEXT,
FILTER_SHARED_BY_USER,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
} from 'src/app/data/filter-rule-type'
import {
FilterableDropdownSelectionModel,
@@ -65,7 +69,7 @@ import {
import { Document } from 'src/app/data/document'
import { StoragePath } from 'src/app/data/storage-path'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { RelativeDate } from '../../common/date-dropdown/date-dropdown.component'
import { RelativeDate } from '../../common/dates-dropdown/dates-dropdown.component'
import {
OwnerFilterType,
PermissionsSelectionModel,
@@ -76,6 +80,8 @@ import {
PermissionsService,
} from 'src/app/services/permissions.service'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField } from 'src/app/data/custom-field'
const TEXT_FILTER_TARGET_TITLE = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@@ -208,6 +214,16 @@ export class FilterEditorComponent
return $localize`Without any tag`
}
case FILTER_HAS_CUSTOM_FIELDS_ALL:
return $localize`Custom fields: ${this.customFields.find(
(f) => f.id == +rule.value
)?.name}`
case FILTER_HAS_ANY_CUSTOM_FIELDS:
if (rule.value == 'false') {
return $localize`Without any custom field`
}
case FILTER_TITLE:
return $localize`Title: ${rule.value}`
@@ -234,7 +250,8 @@ export class FilterEditorComponent
private correspondentService: CorrespondentService,
private documentService: DocumentService,
private storagePathService: StoragePathService,
public permissionsService: PermissionsService
public permissionsService: PermissionsService,
private customFieldService: CustomFieldsService
) {
super()
}
@@ -246,11 +263,13 @@ export class FilterEditorComponent
correspondents: Correspondent[] = []
documentTypes: DocumentType[] = []
storagePaths: StoragePath[] = []
customFields: CustomField[] = []
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
customFieldDocumentCounts: SelectionDataItem[]
_textFilter = ''
_moreLikeId: number
@@ -288,6 +307,7 @@ export class FilterEditorComponent
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
customFieldSelectionModel = new FilterableDropdownSelectionModel()
dateCreatedBefore: string
dateCreatedAfter: string
@@ -322,6 +342,7 @@ export class FilterEditorComponent
this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false)
this.customFieldSelectionModel.clear(false)
this._textFilter = null
this._moreLikeId = null
this.dateAddedBefore = null
@@ -347,7 +368,7 @@ export class FilterEditorComponent
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
break
case FILTER_CUSTOM_FIELDS:
case FILTER_CUSTOM_FIELDS_TEXT:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS
break
@@ -488,6 +509,36 @@ export class FilterEditorComponent
false
)
break
case FILTER_HAS_CUSTOM_FIELDS_ALL:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.And
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_HAS_CUSTOM_FIELDS_ANY:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.Or
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_HAS_ANY_CUSTOM_FIELDS:
this.customFieldSelectionModel.set(
null,
ToggleableItemState.Selected,
false
)
break
case FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS:
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_ASN_ISNULL:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier =
@@ -595,7 +646,7 @@ export class FilterEditorComponent
this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
) {
filterRules.push({
rule_type: FILTER_CUSTOM_FIELDS,
rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: this._textFilter,
})
}
@@ -703,6 +754,35 @@ export class FilterEditorComponent
})
})
}
if (this.customFieldSelectionModel.isNoneSelected()) {
filterRules.push({
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
})
} else {
const customFieldFilterType =
this.customFieldSelectionModel.logicalOperator == LogicalOperator.And
? FILTER_HAS_CUSTOM_FIELDS_ALL
: FILTER_HAS_CUSTOM_FIELDS_ANY
this.customFieldSelectionModel
.getSelectedItems()
.filter((field) => field.id)
.forEach((field) => {
filterRules.push({
rule_type: customFieldFilterType,
value: field.id?.toString(),
})
})
this.customFieldSelectionModel
.getExcludedItems()
.filter((field) => field.id)
.forEach((field) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: field.id?.toString(),
})
})
}
if (this.dateCreatedBefore) {
filterRules.push({
rule_type: FILTER_CREATED_BEFORE,
@@ -845,6 +925,8 @@ export class FilterEditorComponent
selectionData?.selected_correspondents ?? null
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? null
this.customFieldDocumentCounts =
selectionData?.selected_custom_fields ?? null
}
rulesModified: boolean = false
@@ -905,6 +987,16 @@ export class FilterEditorComponent
.listAll()
.subscribe((result) => (this.storagePaths = result.results))
}
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.CustomField
)
) {
this.customFieldService
.listAll()
.subscribe((result) => (this.customFields = result.results))
}
this.textFilterDebounce = new Subject<string>()
@@ -961,6 +1053,10 @@ export class FilterEditorComponent
this.storagePathSelectionModel.apply()
}
onCustomFieldsDropdownOpen() {
this.customFieldSelectionModel.apply()
}
updateTextFilter(text) {
this._textFilter = text
this.documentService.searchQuery = text

View File

@@ -47,7 +47,11 @@ export const FILTER_OWNER_ISNULL = 34
export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
export const FILTER_SHARED_BY_USER = 37
export const FILTER_CUSTOM_FIELDS = 36
export const FILTER_CUSTOM_FIELDS_TEXT = 36
export const FILTER_HAS_CUSTOM_FIELDS_ALL = 38
export const FILTER_HAS_CUSTOM_FIELDS_ANY = 39
export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41
export const FILTER_RULE_TYPES: FilterRuleType[] = [
{
@@ -281,11 +285,36 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
multi: true,
},
{
id: FILTER_CUSTOM_FIELDS,
id: FILTER_CUSTOM_FIELDS_TEXT,
filtervar: 'custom_fields__icontains',
datatype: 'string',
multi: false,
},
{
id: FILTER_HAS_CUSTOM_FIELDS_ALL,
filtervar: 'custom_fields__id__all',
datatype: 'number',
multi: true,
},
{
id: FILTER_HAS_CUSTOM_FIELDS_ANY,
filtervar: 'custom_fields__id__in',
datatype: 'number',
multi: true,
},
{
id: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
filtervar: 'custom_fields__id__none',
datatype: 'number',
multi: true,
},
{
id: FILTER_HAS_ANY_CUSTOM_FIELDS,
filtervar: 'has_custom_fields',
datatype: 'boolean',
multi: false,
default: true,
},
]
export interface FilterRuleType {

View File

@@ -36,6 +36,7 @@ export interface SelectionData {
selected_correspondents: SelectionDataItem[]
selected_tags: SelectionDataItem[]
selected_document_types: SelectionDataItem[]
selected_custom_fields: SelectionDataItem[]
}
@Injectable({