Refactor, add coverage
This commit is contained in:
parent
3823176beb
commit
a25be37c8e
@ -0,0 +1,141 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { 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,
|
||||
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()
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
@ -2,19 +2,21 @@ import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { Subject, first, takeUntil } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import {
|
||||
CustomFieldQueryAtom,
|
||||
CustomFieldQueryExpression,
|
||||
CustomFieldQueryElementType,
|
||||
CustomFieldQueryOperator,
|
||||
CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE,
|
||||
CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP,
|
||||
CustomFieldQueryOperatorGroups,
|
||||
CUSTOM_FIELD_QUERY_OPERATOR_LABELS,
|
||||
CustomFieldQueryElement,
|
||||
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'
|
||||
|
||||
export class CustomFieldQueriesModel {
|
||||
public queries: CustomFieldQueryElement[] = []
|
||||
@ -59,13 +61,13 @@ export class CustomFieldQueriesModel {
|
||||
)
|
||||
}
|
||||
|
||||
public addQuery(query: CustomFieldQueryAtom) {
|
||||
public addAtom(atom: CustomFieldQueryAtom) {
|
||||
if (this.queries.length === 0) {
|
||||
this.addExpression()
|
||||
}
|
||||
;(this.queries[0].value as CustomFieldQueryElement[]).push(query)
|
||||
query.changed.subscribe(() => {
|
||||
if (query.field && query.operator && query.value) {
|
||||
;(this.queries[0].value as CustomFieldQueryElement[]).push(atom)
|
||||
atom.changed.subscribe(() => {
|
||||
if (atom.field && atom.operator && atom.value) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
})
|
||||
@ -163,8 +165,7 @@ export class CustomFieldsQueryDropdownComponent {
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
private _selectionModel: CustomFieldQueriesModel =
|
||||
new CustomFieldQueriesModel()
|
||||
private _selectionModel: CustomFieldQueriesModel
|
||||
|
||||
@Input()
|
||||
set selectionModel(model: CustomFieldQueriesModel) {
|
||||
@ -195,6 +196,7 @@ export class CustomFieldsQueryDropdownComponent {
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(protected customFieldsService: CustomFieldsService) {
|
||||
this.selectionModel = new CustomFieldQueriesModel()
|
||||
this.getFields()
|
||||
this.reset()
|
||||
}
|
||||
|
@ -93,12 +93,14 @@ 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 {
|
||||
CustomFieldQueryExpression,
|
||||
CustomFieldQueryAtom,
|
||||
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'
|
||||
@ -546,7 +548,7 @@ export class FilterEditorComponent
|
||||
)
|
||||
} else if (query.length === 3) {
|
||||
// atom
|
||||
this.customFieldQueriesModel.addQuery(
|
||||
this.customFieldQueriesModel.addAtom(
|
||||
new CustomFieldQueryAtom(query as any)
|
||||
)
|
||||
}
|
||||
|
@ -1,6 +1,4 @@
|
||||
import { debounceTime, distinctUntilChanged, Subject } from 'rxjs'
|
||||
import { CustomFieldDataType } from './custom-field'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export enum CustomFieldQueryLogicalOperator {
|
||||
And = 'AND',
|
||||
@ -126,224 +124,3 @@ export enum CustomFieldQueryElementType {
|
||||
Atom = 'Atom',
|
||||
Expression = 'Expression',
|
||||
}
|
||||
|
||||
export class CustomFieldQueryElement {
|
||||
public readonly type: CustomFieldQueryElementType
|
||||
public changed: Subject<CustomFieldQueryElement>
|
||||
protected valueModelChanged: Subject<
|
||||
string | string[] | number[] | CustomFieldQueryElement[]
|
||||
>
|
||||
public depth: number = 0
|
||||
public id: string = uuidv4()
|
||||
|
||||
constructor(type: CustomFieldQueryElementType) {
|
||||
this.type = type
|
||||
this.changed = new Subject<CustomFieldQueryElement>()
|
||||
this.valueModelChanged = new Subject<string | CustomFieldQueryElement[]>()
|
||||
this.connectValueModelChanged()
|
||||
}
|
||||
|
||||
protected connectValueModelChanged() {
|
||||
// Allows overriding in subclasses
|
||||
this.valueModelChanged.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public serialize() {
|
||||
throw new Error('Implemented in subclass')
|
||||
}
|
||||
|
||||
protected _operator: string = null
|
||||
public set operator(value: string) {
|
||||
this._operator = value
|
||||
this.changed.next(this)
|
||||
}
|
||||
public get operator(): string {
|
||||
return this._operator
|
||||
}
|
||||
|
||||
protected _value: string | string[] | number[] | CustomFieldQueryElement[] =
|
||||
null
|
||||
public set value(
|
||||
value: string | string[] | number[] | CustomFieldQueryElement[]
|
||||
) {
|
||||
this._value = value
|
||||
this.valueModelChanged.next(value)
|
||||
}
|
||||
public get value(): string | string[] | number[] | CustomFieldQueryElement[] {
|
||||
return this._value
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomFieldQueryAtom extends CustomFieldQueryElement {
|
||||
protected _field: number
|
||||
set field(field: any) {
|
||||
this._field = parseInt(field, 10)
|
||||
this.changed.next(this)
|
||||
}
|
||||
get field(): number {
|
||||
return this._field
|
||||
}
|
||||
|
||||
override set operator(operator: string) {
|
||||
const newTypes: string[] =
|
||||
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR[operator]?.split('|')
|
||||
if (!newTypes) {
|
||||
this.value = null
|
||||
}
|
||||
if (!newTypes.includes(typeof this.value)) {
|
||||
if (newTypes.length === 1) {
|
||||
switch (newTypes[0]) {
|
||||
case 'string':
|
||||
this.value = ''
|
||||
break
|
||||
case 'boolean':
|
||||
this.value = 'true'
|
||||
break
|
||||
case 'number':
|
||||
try {
|
||||
this.value = parseFloat(this.value as string).toString()
|
||||
} catch (e) {
|
||||
this.value = null
|
||||
}
|
||||
break
|
||||
case 'array':
|
||||
this.value = []
|
||||
break
|
||||
default:
|
||||
this.value = null
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (newTypes.includes('number')) {
|
||||
try {
|
||||
this.value = parseFloat(this.value as string).toString()
|
||||
} catch (e) {
|
||||
this.value = null
|
||||
}
|
||||
} else {
|
||||
this.value = null
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
['true', 'false'].includes(this.value as string) &&
|
||||
newTypes.includes('string')
|
||||
) {
|
||||
this.value = ''
|
||||
}
|
||||
super.operator = operator
|
||||
}
|
||||
|
||||
override get operator(): string {
|
||||
// why?
|
||||
return super.operator
|
||||
}
|
||||
|
||||
constructor(queryArray: [number, string, string] = [null, null, null]) {
|
||||
super(CustomFieldQueryElementType.Atom)
|
||||
;[this._field, this._operator, this._value] = queryArray
|
||||
}
|
||||
|
||||
protected override connectValueModelChanged(): void {
|
||||
this.valueModelChanged
|
||||
.pipe(debounceTime(1000), distinctUntilChanged())
|
||||
.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public override serialize() {
|
||||
return [this._field, this._operator, this._value]
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomFieldQueryExpression extends CustomFieldQueryElement {
|
||||
constructor(
|
||||
expressionArray: [CustomFieldQueryLogicalOperator, any[]] = [
|
||||
CustomFieldQueryLogicalOperator.Or,
|
||||
null,
|
||||
]
|
||||
) {
|
||||
super(CustomFieldQueryElementType.Expression)
|
||||
let values
|
||||
;[this._operator, values] = expressionArray
|
||||
if (!values || values.length === 0) {
|
||||
this._value = []
|
||||
} else if (values?.length > 0 && values[0] instanceof Array) {
|
||||
this._value = values.map((value) => {
|
||||
if (value.length === 3) {
|
||||
const atom = new CustomFieldQueryAtom(value)
|
||||
atom.depth = this.depth + 1
|
||||
atom.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
return atom
|
||||
} else {
|
||||
const expression = new CustomFieldQueryExpression(value)
|
||||
expression.depth = this.depth + 1
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
return expression
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const expression = new CustomFieldQueryExpression(values as any)
|
||||
expression.depth = this.depth + 1
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
this._value = [expression]
|
||||
}
|
||||
}
|
||||
|
||||
public override serialize() {
|
||||
let value
|
||||
if (this._value instanceof Array) {
|
||||
value = this._value.map((atom) => atom.serialize())
|
||||
// If the expression is negated it should have only one child which is an expression
|
||||
if (
|
||||
this._operator === CustomFieldQueryLogicalOperator.Not &&
|
||||
value.length === 1
|
||||
) {
|
||||
value = value[0]
|
||||
}
|
||||
} else {
|
||||
value = value.serialize()
|
||||
}
|
||||
return [this._operator, value]
|
||||
}
|
||||
|
||||
public addAtom(
|
||||
atom: CustomFieldQueryAtom = new CustomFieldQueryAtom([
|
||||
null,
|
||||
CustomFieldQueryOperator.Exists,
|
||||
'true',
|
||||
])
|
||||
) {
|
||||
atom.depth = this.depth + 1
|
||||
;(this._value as CustomFieldQueryElement[]).push(atom)
|
||||
atom.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public addExpression(
|
||||
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
|
||||
) {
|
||||
expression.depth = this.depth + 1
|
||||
;(this._value as CustomFieldQueryElement[]).push(expression)
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public get negatable(): boolean {
|
||||
return (
|
||||
this.value.length === 1 &&
|
||||
(this.value[0] as CustomFieldQueryElement).type ===
|
||||
CustomFieldQueryElementType.Expression
|
||||
)
|
||||
}
|
||||
}
|
||||
|
144
src-ui/src/app/utils/custom-field-query-element.spec.ts
Normal file
144
src-ui/src/app/utils/custom-field-query-element.spec.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import {
|
||||
CustomFieldQueryElement,
|
||||
CustomFieldQueryAtom,
|
||||
CustomFieldQueryExpression,
|
||||
} from './custom-field-query-element'
|
||||
import {
|
||||
CustomFieldQueryElementType,
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
} from '../data/custom-field-query'
|
||||
|
||||
describe('CustomFieldQueryElement', () => {
|
||||
it('should initialize with correct type and id', () => {
|
||||
const element = new CustomFieldQueryElement(
|
||||
CustomFieldQueryElementType.Atom
|
||||
)
|
||||
expect(element.type).toBe(CustomFieldQueryElementType.Atom)
|
||||
expect(element.id).toBeDefined()
|
||||
})
|
||||
|
||||
it('should trigger changed on operator change', () => {
|
||||
const element = new CustomFieldQueryElement(
|
||||
CustomFieldQueryElementType.Atom
|
||||
)
|
||||
element.changed.subscribe((changedElement) => {
|
||||
expect(changedElement).toBe(element)
|
||||
})
|
||||
element.operator = CustomFieldQueryOperator.Exists
|
||||
})
|
||||
|
||||
it('should trigger changed subject on value change', () => {
|
||||
const element = new CustomFieldQueryElement(
|
||||
CustomFieldQueryElementType.Atom
|
||||
)
|
||||
element.changed.subscribe((changedElement) => {
|
||||
expect(changedElement).toBe(element)
|
||||
})
|
||||
element.value = 'new value'
|
||||
})
|
||||
|
||||
it('should throw error on serialize call', () => {
|
||||
const element = new CustomFieldQueryElement(
|
||||
CustomFieldQueryElementType.Atom
|
||||
)
|
||||
expect(() => element.serialize()).toThrow('Implemented in subclass')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CustomFieldQueryAtom', () => {
|
||||
it('should initialize with correct field, operator, and value', () => {
|
||||
const atom = new CustomFieldQueryAtom([1, 'operator', 'value'])
|
||||
expect(atom.field).toBe(1)
|
||||
expect(atom.operator).toBe('operator')
|
||||
expect(atom.value).toBe('value')
|
||||
})
|
||||
|
||||
it('should trigger changed subject on field change', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.changed.subscribe((changedAtom) => {
|
||||
expect(changedAtom).toBe(atom)
|
||||
})
|
||||
atom.field = 2
|
||||
})
|
||||
|
||||
it('should set value to null if operator is not found in CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.operator = 'nonexistent_operator'
|
||||
expect(atom.value).toBeNull()
|
||||
})
|
||||
|
||||
it('should set value to empty string if new type is string', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.operator = CustomFieldQueryOperator.IContains
|
||||
expect(atom.value).toBe('')
|
||||
})
|
||||
|
||||
it('should set value to "true" if new type is boolean', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.operator = CustomFieldQueryOperator.Exists
|
||||
expect(atom.value).toBe('true')
|
||||
})
|
||||
|
||||
it('should set value to empty array if new type is array', () => {
|
||||
const atom = new CustomFieldQueryAtom()
|
||||
atom.operator = CustomFieldQueryOperator.In
|
||||
expect(atom.value).toEqual([])
|
||||
})
|
||||
|
||||
it('should serialize correctly', () => {
|
||||
const atom = new CustomFieldQueryAtom([1, 'operator', 'value'])
|
||||
expect(atom.serialize()).toEqual([1, 'operator', 'value'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('CustomFieldQueryExpression', () => {
|
||||
it('should initialize with correct operator and value', () => {
|
||||
const expression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[],
|
||||
])
|
||||
expect(expression.operator).toBe(CustomFieldQueryLogicalOperator.And)
|
||||
expect(expression.value).toEqual([])
|
||||
})
|
||||
|
||||
it('should add atom correctly', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
const atom = new CustomFieldQueryAtom([
|
||||
1,
|
||||
CustomFieldQueryOperator.Exists,
|
||||
'true',
|
||||
])
|
||||
expression.addAtom(atom)
|
||||
expect(expression.value).toContain(atom)
|
||||
})
|
||||
|
||||
it('should add expression correctly', () => {
|
||||
const expression = new CustomFieldQueryExpression()
|
||||
const subExpression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.Or,
|
||||
[],
|
||||
])
|
||||
expression.addExpression(subExpression)
|
||||
expect(expression.value).toContain(subExpression)
|
||||
})
|
||||
|
||||
it('should serialize correctly', () => {
|
||||
const expression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[[1, 'operator', 'value']],
|
||||
])
|
||||
expect(expression.serialize()).toEqual([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[[1, 'operator', 'value']],
|
||||
])
|
||||
})
|
||||
|
||||
it('should be negatable if it has one child which is an expression', () => {
|
||||
const expression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.Not,
|
||||
[[CustomFieldQueryLogicalOperator.Or, []]],
|
||||
])
|
||||
expect(expression.negatable).toBe(true)
|
||||
})
|
||||
})
|
223
src-ui/src/app/utils/custom-field-query-element.ts
Normal file
223
src-ui/src/app/utils/custom-field-query-element.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import {
|
||||
CustomFieldQueryElementType,
|
||||
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR,
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
} from '../data/custom-field-query'
|
||||
|
||||
export class CustomFieldQueryElement {
|
||||
public readonly type: CustomFieldQueryElementType
|
||||
public changed: Subject<CustomFieldQueryElement>
|
||||
protected valueModelChanged: Subject<
|
||||
string | string[] | number[] | CustomFieldQueryElement[]
|
||||
>
|
||||
public depth: number = 0
|
||||
public id: string = uuidv4()
|
||||
|
||||
constructor(type: CustomFieldQueryElementType) {
|
||||
this.type = type
|
||||
this.changed = new Subject<CustomFieldQueryElement>()
|
||||
this.valueModelChanged = new Subject<string | CustomFieldQueryElement[]>()
|
||||
this.connectValueModelChanged()
|
||||
}
|
||||
|
||||
protected connectValueModelChanged() {
|
||||
// Allows overriding in subclasses
|
||||
this.valueModelChanged.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public serialize() {
|
||||
throw new Error('Implemented in subclass')
|
||||
}
|
||||
|
||||
protected _operator: string = null
|
||||
public set operator(value: string) {
|
||||
this._operator = value
|
||||
this.changed.next(this)
|
||||
}
|
||||
public get operator(): string {
|
||||
return this._operator
|
||||
}
|
||||
|
||||
protected _value: string | string[] | number[] | CustomFieldQueryElement[] =
|
||||
null
|
||||
public set value(
|
||||
value: string | string[] | number[] | CustomFieldQueryElement[]
|
||||
) {
|
||||
this._value = value
|
||||
this.valueModelChanged.next(value)
|
||||
}
|
||||
public get value(): string | string[] | number[] | CustomFieldQueryElement[] {
|
||||
return this._value
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomFieldQueryAtom extends CustomFieldQueryElement {
|
||||
protected _field: number
|
||||
set field(field: any) {
|
||||
this._field = parseInt(field, 10)
|
||||
this.changed.next(this)
|
||||
}
|
||||
get field(): number {
|
||||
return this._field
|
||||
}
|
||||
|
||||
override set operator(operator: string) {
|
||||
const newTypes: string[] =
|
||||
CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR[operator]?.split('|')
|
||||
if (!newTypes) {
|
||||
this.value = null
|
||||
} else {
|
||||
if (!newTypes.includes(typeof this.value)) {
|
||||
if (newTypes.length === 1) {
|
||||
switch (newTypes[0]) {
|
||||
case 'string':
|
||||
this.value = ''
|
||||
break
|
||||
case 'boolean':
|
||||
this.value = 'true'
|
||||
break
|
||||
case 'array':
|
||||
this.value = []
|
||||
break
|
||||
default:
|
||||
this.value = null
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (newTypes.includes('number')) {
|
||||
try {
|
||||
this.value = parseFloat(this.value as string).toString()
|
||||
} catch (e) {
|
||||
this.value = null
|
||||
}
|
||||
} else {
|
||||
this.value = null
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
['true', 'false'].includes(this.value as string) &&
|
||||
newTypes.includes('string')
|
||||
) {
|
||||
this.value = ''
|
||||
}
|
||||
}
|
||||
super.operator = operator
|
||||
}
|
||||
|
||||
override get operator(): string {
|
||||
// why?
|
||||
return super.operator
|
||||
}
|
||||
|
||||
constructor(queryArray: [number, string, string] = [null, null, null]) {
|
||||
super(CustomFieldQueryElementType.Atom)
|
||||
;[this._field, this._operator, this._value] = queryArray
|
||||
}
|
||||
|
||||
protected override connectValueModelChanged(): void {
|
||||
this.valueModelChanged
|
||||
.pipe(debounceTime(1000), distinctUntilChanged())
|
||||
.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public override serialize() {
|
||||
return [this._field, this._operator, this._value]
|
||||
}
|
||||
}
|
||||
|
||||
export class CustomFieldQueryExpression extends CustomFieldQueryElement {
|
||||
constructor(
|
||||
expressionArray: [CustomFieldQueryLogicalOperator, any[]] = [
|
||||
CustomFieldQueryLogicalOperator.Or,
|
||||
null,
|
||||
]
|
||||
) {
|
||||
super(CustomFieldQueryElementType.Expression)
|
||||
let values
|
||||
;[this._operator, values] = expressionArray
|
||||
if (!values || values.length === 0) {
|
||||
this._value = []
|
||||
} else if (values?.length > 0 && values[0] instanceof Array) {
|
||||
this._value = values.map((value) => {
|
||||
if (value.length === 3) {
|
||||
const atom = new CustomFieldQueryAtom(value)
|
||||
atom.depth = this.depth + 1
|
||||
atom.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
return atom
|
||||
} else {
|
||||
const expression = new CustomFieldQueryExpression(value)
|
||||
expression.depth = this.depth + 1
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
return expression
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const expression = new CustomFieldQueryExpression(values as any)
|
||||
expression.depth = this.depth + 1
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
this._value = [expression]
|
||||
}
|
||||
}
|
||||
|
||||
public override serialize() {
|
||||
let value
|
||||
if (this._value instanceof Array) {
|
||||
value = this._value.map((atom) => atom.serialize())
|
||||
// If the expression is negated it should have only one child which is an expression
|
||||
if (
|
||||
this._operator === CustomFieldQueryLogicalOperator.Not &&
|
||||
value.length === 1
|
||||
) {
|
||||
value = value[0]
|
||||
}
|
||||
} else {
|
||||
value = value.serialize()
|
||||
}
|
||||
return [this._operator, value]
|
||||
}
|
||||
|
||||
public addAtom(
|
||||
atom: CustomFieldQueryAtom = new CustomFieldQueryAtom([
|
||||
null,
|
||||
CustomFieldQueryOperator.Exists,
|
||||
'true',
|
||||
])
|
||||
) {
|
||||
atom.depth = this.depth + 1
|
||||
;(this._value as CustomFieldQueryElement[]).push(atom)
|
||||
atom.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public addExpression(
|
||||
expression: CustomFieldQueryExpression = new CustomFieldQueryExpression()
|
||||
) {
|
||||
expression.depth = this.depth + 1
|
||||
;(this._value as CustomFieldQueryElement[]).push(expression)
|
||||
expression.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
}
|
||||
|
||||
public get negatable(): boolean {
|
||||
return (
|
||||
this.value.length === 1 &&
|
||||
(this.value[0] as CustomFieldQueryElement).type ===
|
||||
CustomFieldQueryElementType.Expression
|
||||
)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user