Feature: custom fields queries (#7761)

This commit is contained in:
shamoon
2024-10-02 17:15:42 -07:00
committed by GitHub
parent 2e3637d712
commit f8d79b012f
26 changed files with 2130 additions and 599 deletions

View File

@@ -0,0 +1,163 @@
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
@if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
}
</button>
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
<div class="list-group list-group-flush">
@for (element of selectionModel.queries; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
}
</div>
</div>
</div>
<ng-template #comparisonValueTemplate let-atom="atom">
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
<input class="form-control" placeholder="yyyy-mm-dd"
[(ngModel)]="atom.value"
ngbDatepicker
#d="ngbDatepicker" />
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button">
<i-bs name="calendar-event"></i-bs>
</button>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [disabled]="disabled">
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Select) {
<ng-select
class="paperless-input-select rounded-end"
[items]="getSelectOptionsForField(atom.field)"
[(ngModel)]="atom.value"
[disabled]="disabled"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
} @else {
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
}
</ng-template>
<ng-template #queryAtom let-atom="atom">
<div class="input-group input-group-sm">
<ng-select
class="paperless-input-select"
[items]="customFields"
[(ngModel)]="atom.field"
[disabled]="disabled"
bindLabel="name"
bindValue="id"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
<select class="w-25 form-select" [(ngModel)]="atom.operator" [disabled]="disabled">
<option *ngFor="let operator of getOperatorsForField(atom.field)" [ngValue]="operator.value">{{operator.label}}</option>
</select>
@switch (atom.operator) {
@case (CustomFieldQueryOperator.Exists) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
}
@case (CustomFieldQueryOperator.IsNull) {
<select class="w-25 form-select rounded-end" [(ngModel)]="atom.value" [disabled]="disabled">
<option value="true" i18n>True</option>
<option value="false" i18n>False</option>
</select>
}
@case (CustomFieldQueryOperator.GreaterThanOrEqual) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.LessThanOrEqual) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.GreaterThan) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.LessThan) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@case (CustomFieldQueryOperator.Contains) {
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
}
@case (CustomFieldQueryOperator.In) {
<ng-select
class="paperless-input-select rounded-end"
[items]="getSelectOptionsForField(atom.field)"
[(ngModel)]="atom.value"
[disabled]="disabled"
[multiple]="true"
(mousedown)="$event.stopImmediatePropagation()"
></ng-select>
}
@case (CustomFieldQueryOperator.Exact) {
<ng-container *ngTemplateOutlet="comparisonValueTemplate; context: { atom: atom }"></ng-container>
}
@default {
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
}
}
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled">
<i-bs name="x-circle"></i-bs>
</button>
</div>
</ng-template>
<ng-template #queryExpression let-expression="expression">
<div class="d-flex w-100">
<div class="d-flex flex-grow-1 flex-column">
<div class="btn-group btn-group-xs" role="group">
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorOr_{{expression.id}}" name="logicalOperatorOr_{{expression.id}}" value="OR" [disabled]="expression.depth > 0 && expression.value.length < 2">
<label class="btn btn-outline-primary" for="logicalOperatorOr_{{expression.id}}" i18n>Any</label>
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorAnd_{{expression.id}}" name="logicalOperatorAnd_{{expression.id}}" value="AND" [disabled]="expression.depth > 0 && expression.value.length < 2">
<label class="btn btn-outline-primary" for="logicalOperatorAnd_{{expression.id}}" i18n>All</label>
@if (expression.negatable) {
<input [(ngModel)]="expression.operator" type="radio" class="btn-check" id="logicalOperatorNot_{{expression.id}}" name="logicalOperatorNot_{{expression.id}}" value="NOT">
<label class="btn btn-outline-secondary" for="logicalOperatorNot_{{expression.id}}" i18n>Not</label>
}
</div>
<div class="list-group list-group-flush mb-n2">
@for (element of expression.value; track element.id; let i = $index) {
<div class="list-group-item px-0 d-flex flex-nowrap">
@switch (element.type) {
@case (CustomFieldQueryComponentType.Atom) {
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
}
@case (CustomFieldQueryComponentType.Expression) {
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
}
}
</div>
}
</div>
</div>
<div class="btn-group-vertical ms-2 ps-2 border-start" role="group" aria-label="Vertical button group">
<button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add query" i18n-title (click)="addAtom(expression)" [disabled]="disabled || expression.value.length === CUSTOM_FIELD_QUERY_MAX_ATOMS">
<i-bs name="node-plus"></i-bs>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary text-primary" title="Add expression" i18n-title (click)="addExpression(expression)" [disabled]="disabled || expression.depth === CUSTOM_FIELD_QUERY_MAX_DEPTH">
<i-bs name="braces"></i-bs>
</button>
@if (expression.depth > 0) {
<button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled">
<i-bs name="x-circle"></i-bs>
</button>
}
</div>
</div>
</ng-template>

View File

@@ -0,0 +1,43 @@
.dropdown-menu {
width: 370px;
@media(min-width: 768px) {
width: 600px;
}
}
::ng-deep .ng-select-container {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
height: 100% !important;
}
::ng-deep .rounded-end .ng-select-container {
border-top-right-radius: var(--bs-border-radius) !important;
border-bottom-right-radius: var(--bs-border-radius) !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
::ng-deep .ng-select {
max-width: 100px;
min-width: 35%;
font-size: 14px;
}
::ng-deep .doc-link-select {
padding-top: 0 !important;
border-top-right-radius: var(--bs-border-radius) !important;
border-bottom-right-radius: var(--bs-border-radius) !important;
background-image: none !important;
.ng-select-container,
.ng-select.ng-select-opened > .ng-select-container {
border: none !important;
min-height: 34px !important;
background: none !important;
}
.ng-select {
max-width: 200px;
min-width: 140px;
}
}

View File

@@ -0,0 +1,320 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
CustomFieldQueriesModel,
CustomFieldsQueryDropdownComponent,
} from './custom-fields-query-dropdown.component'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { of } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperatorGroups,
} from 'src/app/data/custom-field-query'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import {
CustomFieldQueryExpression,
CustomFieldQueryAtom,
CustomFieldQueryElement,
} from 'src/app/utils/custom-field-query-element'
const customFields = [
{
id: 1,
name: 'Test Field',
data_type: CustomFieldDataType.String,
extra_data: {},
},
{
id: 2,
name: 'Test Select Field',
data_type: CustomFieldDataType.Select,
extra_data: { select_options: ['Option 1', 'Option 2'] },
},
]
describe('CustomFieldsQueryDropdownComponent', () => {
let component: CustomFieldsQueryDropdownComponent
let fixture: ComponentFixture<CustomFieldsQueryDropdownComponent>
let customFieldsService: CustomFieldsService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CustomFieldsQueryDropdownComponent],
imports: [NgbDropdownModule, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
customFieldsService = TestBed.inject(CustomFieldsService)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
count: customFields.length,
all: customFields.map((f) => f.id),
results: customFields,
})
)
fixture = TestBed.createComponent(CustomFieldsQueryDropdownComponent)
component = fixture.componentInstance
component.icon = 'ui-radios'
fixture.detectChanges()
})
it('should initialize custom fields on creation', () => {
expect(component.customFields).toEqual(customFields)
})
it('should add an expression when opened if queries are empty', () => {
component.selectionModel.clear()
component.onOpenChange(true)
expect(component.selectionModel.queries.length).toBe(1)
})
it('should support reset the selection model', () => {
component.selectionModel.addExpression()
component.reset()
expect(component.selectionModel.isEmpty()).toBeTruthy()
})
it('should get operators for a field', () => {
const field: CustomField = {
id: 1,
name: 'Test Field',
data_type: CustomFieldDataType.String,
extra_data: {},
}
component.customFields = [field]
const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual(
[
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.Basic
],
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.String
],
].length
)
// Fallback to basic operators if field is not found
const operators2 = component.getOperatorsForField(2)
expect(operators2.length).toEqual(
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.Basic
].length
)
})
it('should get select options for a field', () => {
const field: CustomField = {
id: 1,
name: 'Test Field',
data_type: CustomFieldDataType.Select,
extra_data: { select_options: ['Option 1', 'Option 2'] },
}
component.customFields = [field]
const options = component.getSelectOptionsForField(1)
expect(options).toEqual(['Option 1', 'Option 2'])
// Fallback to empty array if field is not found
const options2 = component.getSelectOptionsForField(2)
expect(options2).toEqual([])
})
it('should remove an element from the selection model', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom()
;(expression.value as CustomFieldQueryElement[]).push(atom)
component.selectionModel.addExpression(expression)
component.removeElement(atom)
expect(component.selectionModel.isEmpty()).toBeTruthy()
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'icontains', 'test'],
[2, 'icontains', 'test'],
],
])
component.selectionModel.addExpression(expression2)
component.removeElement(expression2)
expect(component.selectionModel.isEmpty()).toBeTruthy()
})
it('should emit selectionModelChange when model changes', () => {
const nextSpy = jest.spyOn(component.selectionModelChange, 'next')
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
component.selectionModel.addAtom(atom)
atom.changed.next(atom)
expect(nextSpy).toHaveBeenCalled()
})
it('should complete selection model subscription when new selection model is set', () => {
const completeSpy = jest.spyOn(component.selectionModel.changed, 'complete')
const selectionModel = new CustomFieldQueriesModel()
component.selectionModel = selectionModel
expect(completeSpy).toHaveBeenCalled()
})
it('should support adding an atom', () => {
const expression = new CustomFieldQueryExpression()
component.addAtom(expression)
expect(expression.value.length).toBe(1)
})
it('should support adding an expression', () => {
const expression = new CustomFieldQueryExpression()
component.addExpression(expression)
expect(expression.value.length).toBe(1)
})
it('should support getting a custom field by ID', () => {
expect(component.getCustomFieldByID(1)).toEqual(customFields[0])
})
it('should sanitize name from title', () => {
component.title = 'Test Title'
expect(component.name).toBe('test_title')
})
describe('CustomFieldQueriesModel', () => {
let model: CustomFieldQueriesModel
beforeEach(() => {
model = new CustomFieldQueriesModel()
})
it('should initialize with empty queries', () => {
expect(model.queries).toEqual([])
})
it('should clear queries and fire event', () => {
const nextSpy = jest.spyOn(model.changed, 'next')
model.addExpression()
model.clear()
expect(model.queries).toEqual([])
expect(nextSpy).toHaveBeenCalledWith(model)
})
it('should clear queries without firing event', () => {
const nextSpy = jest.spyOn(model.changed, 'next')
model.addExpression()
model.clear(false)
expect(model.queries).toEqual([])
expect(nextSpy).not.toHaveBeenCalled()
})
it('should validate an empty model as invalid', () => {
expect(model.isValid()).toBeFalsy()
})
it('should validate a model with valid expression as valid', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
const atom2 = new CustomFieldQueryAtom([2, 'icontains', 'test'])
const expression2 = new CustomFieldQueryExpression()
expression2.addAtom(atom)
expression2.addAtom(atom2)
expression.addExpression(expression2)
model.addExpression(expression)
expect(model.isValid()).toBeTruthy()
})
it('should validate a model with invalid expression as invalid', () => {
const expression = new CustomFieldQueryExpression()
model.addExpression(expression)
expect(model.isValid()).toBeFalsy()
})
it('should validate an atom with in or contains operator', () => {
const atom = new CustomFieldQueryAtom([1, 'in', '[1,2,3]'])
expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
atom.operator = 'contains'
atom.value = [1, 2, 3]
expect(model['validateAtom'].apply(null, [atom])).toBeTruthy()
atom.value = null
expect(model['validateAtom'].apply(null, [atom])).toBeFalsy()
})
it('should check if model is empty', () => {
expect(model.isEmpty()).toBeTruthy()
model.addExpression()
expect(model.isEmpty()).toBeTruthy()
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
model.addAtom(atom)
expect(model.isEmpty()).toBeFalsy()
})
it('should add an atom to the model', () => {
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
model.addAtom(atom)
expect(model.queries.length).toBe(1)
expect(
(model.queries[0] as CustomFieldQueryExpression).value.length
).toBe(1)
})
it('should add an expression to the model, propagate changes', () => {
const expression = new CustomFieldQueryExpression()
model.addExpression(expression)
expect(model.queries.length).toBe(1)
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'icontains', 'test'],
[2, 'icontains', 'test'],
],
])
model.addExpression(expression2)
const nextSpy = jest.spyOn(model.changed, 'next')
expression2.changed.next(expression2)
expect(nextSpy).toHaveBeenCalled()
})
it('should remove an element from the model', () => {
const expression = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[1, 'icontains', 'test'],
[2, 'icontains', 'test'],
],
])
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
const expression2 = new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
[
[3, 'icontains', 'test'],
[4, 'icontains', 'test'],
],
])
expression.addAtom(atom)
expression2.addExpression(expression)
model.addExpression(expression2)
model.removeElement(atom)
expect(model.queries.length).toBe(1)
model.removeElement(expression2)
})
it('should fire changed event when an atom changes', () => {
const nextSpy = jest.spyOn(model.changed, 'next')
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
model.addAtom(atom)
atom.changed.next(atom)
expect(nextSpy).toHaveBeenCalledWith(model)
})
it('should complete changed subject when element is removed', () => {
const expression = new CustomFieldQueryExpression()
const atom = new CustomFieldQueryAtom([1, 'icontains', 'test'])
;(expression.value as CustomFieldQueryElement[]).push(atom)
model.addExpression(expression)
const completeSpy = jest.spyOn(atom.changed, 'complete')
model.removeElement(atom)
expect(completeSpy).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,294 @@
import {
Component,
EventEmitter,
Input,
Output,
ViewChild,
} from '@angular/core'
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first, takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import {
CustomFieldQueryElementType,
CustomFieldQueryOperator,
CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE,
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
CustomFieldQueryOperatorGroups,
CUSTOM_FIELD_QUERY_OPERATOR_LABELS,
CUSTOM_FIELD_QUERY_MAX_DEPTH,
CUSTOM_FIELD_QUERY_MAX_ATOMS,
} from 'src/app/data/custom-field-query'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import {
CustomFieldQueryElement,
CustomFieldQueryExpression,
CustomFieldQueryAtom,
} from 'src/app/utils/custom-field-query-element'
import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-options'
export class CustomFieldQueriesModel {
public queries: CustomFieldQueryElement[] = []
public readonly changed = new Subject<CustomFieldQueriesModel>()
public clear(fireEvent = true) {
this.queries = []
if (fireEvent) {
this.changed.next(this)
}
}
public isValid(): boolean {
return (
this.queries.length > 0 &&
this.validateExpression(this.queries[0] as CustomFieldQueryExpression)
)
}
public isEmpty(): boolean {
return (
this.queries.length === 0 ||
(this.queries.length === 1 && this.queries[0].value.length === 0)
)
}
private validateAtom(atom: CustomFieldQueryAtom) {
let valid = !!(atom.field && atom.operator && atom.value !== null)
if (
[
CustomFieldQueryOperator.In.valueOf(),
CustomFieldQueryOperator.Contains.valueOf(),
].includes(atom.operator) &&
atom.value
) {
valid = valid && atom.value.length > 0
}
return valid
}
private validateExpression(expression: CustomFieldQueryExpression) {
return (
expression.operator &&
expression.value.length > 0 &&
(expression.value as CustomFieldQueryElement[]).every((e) =>
e.type === CustomFieldQueryElementType.Atom
? this.validateAtom(e as CustomFieldQueryAtom)
: this.validateExpression(e as CustomFieldQueryExpression)
)
)
}
public addAtom(atom: CustomFieldQueryAtom) {
if (this.queries.length === 0) {
this.addExpression()
}
;(this.queries[0].value as CustomFieldQueryElement[]).push(atom)
atom.changed.subscribe(() => {
if (atom.field && atom.operator && atom.value) {
this.changed.next(this)
}
})
}
public addExpression(
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
) {
if (this.queries.length > 0) {
;(
(this.queries[0] as CustomFieldQueryExpression)
.value as CustomFieldQueryElement[]
).push(expression)
} else {
this.queries.push(expression)
}
expression.changed.subscribe(() => {
this.changed.next(this)
})
}
private findElement(
queryElement: CustomFieldQueryElement,
elements: any[]
): CustomFieldQueryElement {
for (let i = 0; i < elements.length; i++) {
if (elements[i] === queryElement) {
return elements.splice(i, 1)[0]
} else if (elements[i].type === CustomFieldQueryElementType.Expression) {
return this.findElement(
queryElement,
elements[i].value as CustomFieldQueryElement[]
)
}
}
}
public removeElement(queryElement: CustomFieldQueryElement) {
let foundComponent
for (let i = 0; i < this.queries.length; i++) {
let query = this.queries[i]
if (query === queryElement) {
foundComponent = this.queries.splice(i, 1)[0]
break
} else if (query.type === CustomFieldQueryElementType.Expression) {
foundComponent = this.findElement(queryElement, query.value as any[])
}
}
if (foundComponent) {
foundComponent.changed.complete()
if (this.isEmpty()) {
this.clear()
}
this.changed.next(this)
}
}
}
@Component({
selector: 'pngx-custom-fields-query-dropdown',
templateUrl: './custom-fields-query-dropdown.component.html',
styleUrls: ['./custom-fields-query-dropdown.component.scss'],
})
export class CustomFieldsQueryDropdownComponent {
public CustomFieldQueryComponentType = CustomFieldQueryElementType
public CustomFieldQueryOperator = CustomFieldQueryOperator
public CustomFieldDataType = CustomFieldDataType
public CUSTOM_FIELD_QUERY_MAX_DEPTH = CUSTOM_FIELD_QUERY_MAX_DEPTH
public CUSTOM_FIELD_QUERY_MAX_ATOMS = CUSTOM_FIELD_QUERY_MAX_ATOMS
public popperOptions = popperOptionsReenablePreventOverflow
@Input()
title: string
@Input()
filterPlaceholder: string = ''
@Input()
icon: string
@Input()
allowSelectNone: boolean = false
@Input()
editing = false
@Input()
applyOnClose = false
get name(): string {
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
}
@Input()
disabled: boolean = false
@ViewChild('dropdown') dropdown: NgbDropdown
private _selectionModel: CustomFieldQueriesModel
@Input()
set selectionModel(model: CustomFieldQueriesModel) {
if (this._selectionModel) {
this._selectionModel.changed.complete()
}
model.changed.subscribe(() => {
this.onModelChange()
})
this._selectionModel = model
}
get selectionModel(): CustomFieldQueriesModel {
return this._selectionModel
}
private onModelChange() {
if (this.selectionModel.isEmpty() || this.selectionModel.isValid()) {
this.selectionModelChange.next(this.selectionModel)
this.selectionModel.isEmpty() && this.dropdown?.close()
}
}
@Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
customFields: CustomField[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(protected customFieldsService: CustomFieldsService) {
this.selectionModel = new CustomFieldQueriesModel()
this.getFields()
this.reset()
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
}
public onOpenChange(open: boolean) {
if (open && this.selectionModel.queries.length === 0) {
this.selectionModel.addExpression()
}
}
public get isActive(): boolean {
return (
(this.selectionModel.queries[0] as CustomFieldQueryExpression)?.value
?.length > 0
)
}
private getFields() {
this.customFieldsService
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.customFields = result.results
})
}
public getCustomFieldByID(id: number): CustomField {
return this.customFields.find((field) => field.id === id)
}
public addAtom(expression: CustomFieldQueryExpression) {
expression.addAtom()
}
public addExpression(expression: CustomFieldQueryExpression) {
expression.addExpression()
}
public removeElement(element: CustomFieldQueryElement) {
this.selectionModel.removeElement(element)
}
public reset() {
this.selectionModel.clear(false)
this.selectionModel.changed.next(this.selectionModel)
}
getOperatorsForField(
fieldID: number
): Array<{ value: string; label: string }> {
const field = this.customFields.find((field) => field.id === fieldID)
const groups: CustomFieldQueryOperatorGroups[] = field
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic]
const operators = groups.flatMap(
(group) => CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[group]
)
return operators.map((operator) => ({
value: operator,
label: CUSTOM_FIELD_QUERY_OPERATOR_LABELS[operator],
}))
}
getSelectOptionsForField(fieldID: number): string[] {
const field = this.customFields.find((field) => field.id === fieldID)
if (field) {
return field.extra_data['select_options']
}
return []
}
}

View File

@@ -1,50 +1,57 @@
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
@if (title) {
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
}
@if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
}
</div>
<div [class.col-md-9]="horizontal">
<div>
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
[disabled]="disabled"
[items]="foundDocuments$ | async"
placeholder="Search for documents"
[notFoundText]="notFoundText"
[multiple]="true"
bindValue="id"
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[typeahead]="documentsInput$"
(change)="onChange(selectedDocuments)">
<ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center">
<button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span>
</a>
</div>
</ng-template>
<ng-template ng-loadingspinner-tmp>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-template>
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
</ng-template>
</ng-select>
@if (minimal) {
<ng-container *ngTemplateOutlet="select"></ng-container>
} @else {
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
<div class="row">
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
@if (title) {
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
}
@if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button>
}
</div>
<div [class.col-md-9]="horizontal">
<ng-container *ngTemplateOutlet="select"></ng-container>
@if (hint) {
<small class="form-text text-muted">{{hint}}</small>
}
</div>
@if (hint) {
<small class="form-text text-muted">{{hint}}</small>
}
</div>
</div>
</div>
}
<ng-template #select>
<ng-select name="inputId" [(ngModel)]="selectedDocuments"
[disabled]="disabled"
[items]="foundDocuments$ | async"
[placeholder]="placeholder"
[notFoundText]="notFoundText"
[multiple]="true"
bindValue="id"
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading"
[typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()"
(change)="onChange(selectedDocuments)">
<ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center">
<button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span>
</a>
</div>
</ng-template>
<ng-template ng-loadingspinner-tmp>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</ng-template>
<ng-template ng-option-tmp let-document="item" let-index="index" let-search="searchTerm">
<div>{{document.title}} <small class="text-muted">({{document.created | customDate:'shortDate'}})</small></div>
</ng-template>
</ng-select>
</ng-template>

View File

@@ -46,6 +46,12 @@ export class DocumentLinkComponent
@Input()
parentDocumentID: number
@Input()
minimal: boolean = false
@Input()
placeholder: string = $localize`Search for documents`
constructor(private documentsService: DocumentService) {
super()
}

View File

@@ -140,7 +140,7 @@
} @else {
@if (list.displayMode === DisplayMode.LARGE_CARDS) {
<div>
@for (d of list.documents; track trackByDocumentId($index, d)) {
@for (d of list.documents; track d.id) {
<pngx-document-card-large
[selected]="list.isSelected(d)"
(toggleSelected)="toggleSelected(d, $event)"
@@ -269,7 +269,7 @@
</tr>
</thead>
<tbody>
@for (d of list.documents; track trackByDocumentId($index, d)) {
@for (d of list.documents; track d.id) {
<tr (click)="toggleSelected(d, $event); $event.stopPropagation();" (dblclick)="openDocumentDetail(d)" [ngClass]="list.isSelected(d) ? 'table-row-selected' : ''">
<td>
<div class="form-check">
@@ -364,7 +364,7 @@
}
@if (list.displayMode === DisplayMode.SMALL_CARDS) {
<div class="row row-cols-paperless-cards">
@for (d of list.documents; track trackByDocumentId($index, d)) {
@for (d of list.documents; track d.id) {
<pngx-document-card-small class="p-0"
[selected]="list.isSelected(d)"
(toggleSelected)="toggleSelected(d, $event)"

View File

@@ -383,10 +383,6 @@ export class DocumentListComponent
])
}
trackByDocumentId(index, item: Document) {
return item.id
}
get notesEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
}

View File

@@ -86,15 +86,10 @@
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
<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"
<pngx-custom-fields-query-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
[(selectionModel)]="customFieldQueriesModel"
(selectionModelChange)="updateRules()"
(opened)="onCustomFieldsDropdownOpen()"
[documentCounts]="customFieldDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
></pngx-custom-fields-query-dropdown>
}
<pngx-dates-dropdown
title="Dates" i18n-title

View File

@@ -17,7 +17,7 @@ import {
NgbDropdownItem,
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectComponent } from '@ng-select/ng-select'
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
import { of, throwError } from 'rxjs'
import {
FILTER_TITLE,
@@ -55,6 +55,7 @@ import {
FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_CUSTOM_FIELDS_QUERY,
} from 'src/app/data/filter-rule-type'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
@@ -95,6 +96,12 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
import { RouterModule } from '@angular/router'
import { SearchService } from 'src/app/services/rest/search.service'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { CustomFieldsQueryDropdownComponent } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query'
import { CustomFieldQueryAtom } from 'src/app/utils/custom-field-query-element'
const tags: Tag[] = [
{
@@ -181,6 +188,7 @@ describe('FilterEditorComponent', () => {
ToggleableDropdownButtonComponent,
DatesDropdownComponent,
CustomDatePipe,
CustomFieldsQueryDropdownComponent,
],
imports: [
RouterModule,
@@ -190,6 +198,7 @@ describe('FilterEditorComponent', () => {
NgbDatepickerModule,
NgxBootstrapIconsModule.pick(allIcons),
NgbTypeaheadModule,
NgSelectModule,
],
providers: [
FilterPipe,
@@ -838,108 +847,79 @@ describe('FilterEditorComponent', () => {
]
}))
it('should ingest filter rules for has all custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
it('should ingest filter rules for custom fields all', fakeAsync(() => {
expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '42',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '43',
value: '42,43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
CustomFieldQueryLogicalOperator.And
)
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: null,
},
]
component.toggleTag(2) // coverage
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
expect(
(
component.customFieldQueriesModel.queries[0]
.value[0] as CustomFieldQueryAtom
).serialize()
).toEqual(['42', CustomFieldQueryOperator.Exists, 'true'])
}))
it('should ingest filter rules for has any custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '42',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '43',
value: '42,43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
CustomFieldQueryLogicalOperator.Or
)
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: null,
},
]
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
expect(
(
component.customFieldQueriesModel.queries[0]
.value[0] as CustomFieldQueryAtom
).serialize()
).toEqual(['42', CustomFieldQueryOperator.Exists, 'true'])
}))
it('should ingest filter rules for has any custom field', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
it('should ingest filter rules for custom field queries', fakeAsync(() => {
expect(component.customFieldQueriesModel.isEmpty()).toBeTruthy()
component.filterRules = [
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: '1',
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '["AND", [[42, "exists", "true"],[43, "exists", "true"]]]',
},
]
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
1
expect(component.customFieldQueriesModel.queries[0].operator).toEqual(
CustomFieldQueryLogicalOperator.And
)
expect(component.customFieldSelectionModel.get(null)).toBeTruthy()
}))
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(2)
expect(
(
component.customFieldQueriesModel.queries[0]
.value[0] as CustomFieldQueryAtom
).serialize()
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
it('should ingest filter rules for exclude tag(s)', fakeAsync(() => {
expect(component.customFieldSelectionModel.getExcludedItems()).toHaveLength(
0
)
// atom
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,
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '[42, "exists", "true"]',
},
]
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(1)
expect(
(
component.customFieldQueriesModel.queries[0]
.value[0] as CustomFieldQueryAtom
).serialize()
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
}))
it('should ingest filter rules for owner', fakeAsync(() => {
@@ -1453,71 +1433,37 @@ 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)
const customFieldsQueryDropdown = fixture.debugElement.queryAll(
By.directive(CustomFieldsQueryDropdownComponent)
)[0]
const customFieldToggleButton = customFieldsQueryDropdown.query(
By.css('button')
)
customFieldButtons[1].triggerEventHandler('toggle')
customFieldButtons[2].triggerEventHandler('toggle')
customFieldToggleButton.triggerEventHandler('click')
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]')
const customFieldButtons = customFieldsQueryDropdown.queryAll(
By.css('button')
)
toggleOperatorButtons[1].nativeElement.checked = true
toggleOperatorButtons[1].triggerEventHandler('change')
customFieldButtons[1].triggerEventHandler('click')
fixture.detectChanges()
const query = component.customFieldQueriesModel
.queries[0] as CustomFieldQueryAtom
query.field = custom_fields[0].id
const fieldSelect: NgSelectComponent = customFieldsQueryDropdown.queryAll(
By.directive(NgSelectComponent)
)[0].componentInstance
fieldSelect.open()
const options = customFieldsQueryDropdown.queryAll(By.css('.ng-option'))
options[0].nativeElement.click()
expect(component.customFieldQueriesModel.queries[0].value.length).toEqual(1)
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(),
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify([
CustomFieldQueryLogicalOperator.Or,
[[custom_fields[0].id, 'exists', 'true']],
]),
},
])
}))
@@ -1930,21 +1876,11 @@ describe('FilterEditorComponent', () => {
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '42',
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: '["AND",[["42","exists","true"],["43","exists","true"]]]',
},
]
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')
expect(component.generateFilterName()).toEqual(`Custom fields query`)
component.filterRules = [
{

View File

@@ -12,7 +12,7 @@ import {
import { Tag } from 'src/app/data/tag'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
import { Observable, Subject, Subscription, from } from 'rxjs'
import { Observable, Subject, from } from 'rxjs'
import {
catchError,
debounceTime,
@@ -62,7 +62,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
FILTER_CUSTOM_FIELDS_QUERY,
} from 'src/app/data/filter-rule-type'
import {
FilterableDropdownSelectionModel,
@@ -92,6 +92,15 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField } from 'src/app/data/custom-field'
import { SearchService } from 'src/app/services/rest/search.service'
import {
CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query'
import { CustomFieldQueriesModel } from '../../common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
import {
CustomFieldQueryExpression,
CustomFieldQueryAtom,
} from 'src/app/utils/custom-field-query-element'
const TEXT_FILTER_TARGET_TITLE = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@@ -225,15 +234,8 @@ 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_CUSTOM_FIELDS_QUERY:
return $localize`Custom fields query`
case FILTER_TITLE:
return $localize`Title: ${rule.value}`
@@ -321,7 +323,7 @@ export class FilterEditorComponent
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
customFieldSelectionModel = new FilterableDropdownSelectionModel()
customFieldQueriesModel = new CustomFieldQueriesModel()
dateCreatedBefore: string
dateCreatedAfter: string
@@ -356,7 +358,7 @@ export class FilterEditorComponent
this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false)
this.customFieldSelectionModel.clear(false)
this.customFieldQueriesModel.clear(false)
this._textFilter = null
this._moreLikeId = null
this.dateAddedBefore = null
@@ -523,34 +525,45 @@ export class FilterEditorComponent
false
)
break
case FILTER_CUSTOM_FIELDS_QUERY:
try {
const query = JSON.parse(rule.value)
if (Array.isArray(query)) {
if (query.length === 2) {
// expression
this.customFieldQueriesModel.addExpression(
new CustomFieldQueryExpression(query as any)
)
} else if (query.length === 3) {
// atom
this.customFieldQueriesModel.addAtom(
new CustomFieldQueryAtom(query as any)
)
}
}
} catch (e) {
// error handled by list view service
}
break
// Legacy custom field filters
case FILTER_HAS_CUSTOM_FIELDS_ALL:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.And
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
this.customFieldQueriesModel.addExpression(
new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.And,
rule.value
.split(',')
.map((id) => [id, CustomFieldQueryOperator.Exists, 'true']),
])
)
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
this.customFieldQueriesModel.addExpression(
new CustomFieldQueryExpression([
CustomFieldQueryLogicalOperator.Or,
rule.value
.split(',')
.map((id) => [id, CustomFieldQueryOperator.Exists, 'true']),
])
)
break
case FILTER_ASN_ISNULL:
@@ -768,34 +781,14 @@ export class FilterEditorComponent
})
})
}
if (this.customFieldSelectionModel.isNoneSelected()) {
let queries = this.customFieldQueriesModel.queries.map((query) =>
query.serialize()
)
if (queries.length > 0) {
filterRules.push({
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
value: JSON.stringify(queries[0]),
})
} 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({
@@ -1079,10 +1072,6 @@ export class FilterEditorComponent
this.storagePathSelectionModel.apply()
}
onCustomFieldsDropdownOpen() {
this.customFieldSelectionModel.apply()
}
updateTextFilter(text, updateRules = true) {
this._textFilter = text
if (updateRules) {