mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Feature: custom fields queries (#7761)
This commit is contained in:
@@ -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"> {{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>
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
@@ -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 []
|
||||
}
|
||||
}
|
@@ -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> <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> <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> <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> <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>
|
||||
|
@@ -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()
|
||||
}
|
||||
|
Reference in New Issue
Block a user