From 85027dbffd8c6003b172c43b87c8ebf3b54ef1e8 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 4 Nov 2025 12:40:05 -0800 Subject: [PATCH] Fix: ensure custom field query propagation, change detection (#11291) --- ...om-fields-query-dropdown.component.spec.ts | 8 ++++ .../custom-fields-query-dropdown.component.ts | 42 +++++++++++++++---- .../utils/custom-field-query-element.spec.ts | 32 ++++++++++++-- .../app/utils/custom-field-query-element.ts | 19 ++++++++- 4 files changed, 86 insertions(+), 15 deletions(-) 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 index 4dcbceb13..69f89f74e 100644 --- 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 @@ -354,5 +354,13 @@ describe('CustomFieldsQueryDropdownComponent', () => { model.removeElement(atom) expect(completeSpy).toHaveBeenCalled() }) + + it('should subscribe to existing elements when queries are assigned', () => { + const expression = new CustomFieldQueryExpression() + const nextSpy = jest.spyOn(model.changed, 'next') + model.queries = [expression] + expression.changed.next(expression) + expect(nextSpy).toHaveBeenCalledWith(model) + }) }) }) 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 fc4e8ef19..7d8109c53 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 @@ -17,7 +17,7 @@ import { } from '@ng-bootstrap/ng-bootstrap' import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' -import { first, Subject, takeUntil } from 'rxjs' +import { first, Subject, Subscription, takeUntil } from 'rxjs' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CUSTOM_FIELD_QUERY_MAX_ATOMS, @@ -41,10 +41,27 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp import { DocumentLinkComponent } from '../input/document-link/document-link.component' export class CustomFieldQueriesModel { - public queries: CustomFieldQueryElement[] = [] + private _queries: CustomFieldQueryElement[] = [] + private rootSubscriptions: Subscription[] = [] public readonly changed = new Subject() + public get queries(): CustomFieldQueryElement[] { + return this._queries + } + + public set queries(value: CustomFieldQueryElement[]) { + this.teardownRootSubscriptions() + this._queries = value ?? [] + for (const element of this._queries) { + this.rootSubscriptions.push( + element.changed.subscribe(() => { + this.changed.next(this) + }) + ) + } + } + public clear(fireEvent = true) { this.queries = [] if (fireEvent) { @@ -107,14 +124,14 @@ export class CustomFieldQueriesModel { 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) + if (this.queries.length === 0) { + this.queries = [expression] + return } + ;( + (this.queries[0] as CustomFieldQueryExpression) + .value as CustomFieldQueryElement[] + ).push(expression) expression.changed.subscribe(() => { this.changed.next(this) }) @@ -166,6 +183,13 @@ export class CustomFieldQueriesModel { this.changed.next(this) } } + + private teardownRootSubscriptions() { + for (const subscription of this.rootSubscriptions) { + subscription.unsubscribe() + } + this.rootSubscriptions = [] + } } @Component({ 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 index 411dcd6f9..e01af7fd4 100644 --- a/src-ui/src/app/utils/custom-field-query-element.spec.ts +++ b/src-ui/src/app/utils/custom-field-query-element.spec.ts @@ -1,4 +1,3 @@ -import { fakeAsync, tick } from '@angular/core/testing' import { CustomFieldQueryElementType, CustomFieldQueryLogicalOperator, @@ -111,13 +110,38 @@ describe('CustomFieldQueryAtom', () => { expect(atom.serialize()).toEqual([1, 'operator', 'value']) }) - it('should emit changed on value change after debounce', fakeAsync(() => { + it('should emit changed on value change immediately', () => { const atom = new CustomFieldQueryAtom() const changeSpy = jest.spyOn(atom.changed, 'next') atom.value = 'new value' - tick(1000) expect(changeSpy).toHaveBeenCalled() - })) + }) + + it('should ignore duplicate array emissions', () => { + const atom = new CustomFieldQueryAtom() + atom.operator = CustomFieldQueryOperator.In + const changeSpy = jest.fn() + atom.changed.subscribe(changeSpy) + + atom.value = [1, 2] + expect(changeSpy).toHaveBeenCalledTimes(1) + + changeSpy.mockClear() + atom.value = [1, 2] + expect(changeSpy).not.toHaveBeenCalled() + }) + + it('should emit when array values differ while length matches', () => { + const atom = new CustomFieldQueryAtom() + atom.operator = CustomFieldQueryOperator.In + const changeSpy = jest.fn() + atom.changed.subscribe(changeSpy) + + atom.value = [1, 2] + changeSpy.mockClear() + atom.value = [1, 3] + expect(changeSpy).toHaveBeenCalledTimes(1) + }) }) describe('CustomFieldQueryExpression', () => { diff --git a/src-ui/src/app/utils/custom-field-query-element.ts b/src-ui/src/app/utils/custom-field-query-element.ts index 3438f2c85..34891641a 100644 --- a/src-ui/src/app/utils/custom-field-query-element.ts +++ b/src-ui/src/app/utils/custom-field-query-element.ts @@ -1,4 +1,4 @@ -import { Subject, debounceTime, distinctUntilChanged } from 'rxjs' +import { Subject, distinctUntilChanged } from 'rxjs' import { v4 as uuidv4 } from 'uuid' import { CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR, @@ -110,7 +110,22 @@ export class CustomFieldQueryAtom extends CustomFieldQueryElement { protected override connectValueModelChanged(): void { this.valueModelChanged - .pipe(debounceTime(1000), distinctUntilChanged()) + .pipe( + distinctUntilChanged((previous, current) => { + if (Array.isArray(previous) && Array.isArray(current)) { + if (previous.length !== current.length) { + return false + } + for (let i = 0; i < previous.length; i++) { + if (previous[i] !== current[i]) { + return false + } + } + return true + } + return previous === current + }) + ) .subscribe(() => { this.changed.next(this) })