diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts new file mode 100644 index 000000000..55f566c89 --- /dev/null +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts @@ -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 + 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() + }) +}) diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts index 112eca35a..497a6a8ce 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.ts @@ -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 = new Subject() constructor(protected customFieldsService: CustomFieldsService) { + this.selectionModel = new CustomFieldQueriesModel() this.getFields() this.reset() } diff --git a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts index c1b3ec2f1..3356411c2 100644 --- a/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts +++ b/src-ui/src/app/components/document-list/filter-editor/filter-editor.component.ts @@ -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) ) } diff --git a/src-ui/src/app/data/custom-field-query.ts b/src-ui/src/app/data/custom-field-query.ts index 70fc4f374..7b0189b32 100644 --- a/src-ui/src/app/data/custom-field-query.ts +++ b/src-ui/src/app/data/custom-field-query.ts @@ -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 - 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() - this.valueModelChanged = new Subject() - 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 - ) - } -} diff --git a/src-ui/src/app/utils/custom-field-query-element.spec.ts b/src-ui/src/app/utils/custom-field-query-element.spec.ts new file mode 100644 index 000000000..2150f1560 --- /dev/null +++ b/src-ui/src/app/utils/custom-field-query-element.spec.ts @@ -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) + }) +}) diff --git a/src-ui/src/app/utils/custom-field-query-element.ts b/src-ui/src/app/utils/custom-field-query-element.ts new file mode 100644 index 000000000..90bab9c52 --- /dev/null +++ b/src-ui/src/app/utils/custom-field-query-element.ts @@ -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 + 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() + this.valueModelChanged = new Subject() + 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 + ) + } +}