diff --git a/app/components/filter/filter_component.rb b/app/components/filter/filter_component.rb index 4b18dfea7c08..9654b3b706ce 100644 --- a/app/components/filter/filter_component.rb +++ b/app/components/filter/filter_component.rb @@ -28,12 +28,10 @@ # See COPYRIGHT and LICENSE files for more details. # ++ module Filter - # rubocop:disable OpenProject/AddPreviewForViewComponent class FilterComponent < ApplicationComponent OPERATORS_WITHOUT_VALUES = %w[* !* t w].freeze TURBO_FRAME_ID = "filter_component" - # rubocop:enable OpenProject/AddPreviewForViewComponent options :query # The path used for fetching the filter section lazily from the backend upon opening it. # If none is provided, the filters are rendered right away. @@ -113,15 +111,15 @@ def additional_filter_attributes(filter) end def custom_field_list_autocomplete_options(filter) - options = if filter.custom_field.version? - { - items: filter.allowed_values.map { |name, id, project_name| { name:, id:, project_name: } }, - groupBy: "project_name" - } - else - { items: filter.allowed_values.map { |name, id| { name:, id: } } } - end - autocomplete_options.merge(options).merge(model: filter.values) + all_items = if filter.custom_field.version? + filter.allowed_values.map { |name, id, project_name| { name:, id:, project_name: } } + else + filter.allowed_values.map { |name, id| { name:, id: } } + end + selected = filter.values + options = { items: all_items } + options[:groupBy] = "project_name" if filter.custom_field.version? + autocomplete_options.merge(options).merge(model: all_items.select { |item| selected.include?(item[:id]) }) end def custom_field_hierarchy_autocomplete_options(filter) @@ -129,14 +127,17 @@ def custom_field_hierarchy_autocomplete_options(filter) path = name.split(" / ") { name: path.last, id:, depth: path.length - 1 } end + selected = filter.values - autocomplete_options.merge({ items: }).merge(model: filter.values) + autocomplete_options.merge({ items: }).merge(model: items.select { |item| selected.include?(item[:id]) }) end def list_autocomplete_options(filter) + all_items = filter.allowed_values.map { |name, id| { name:, id: } } + selected = filter.values autocomplete_options.merge( - items: filter.allowed_values.map { |name, id| { name:, id: } }, - model: filter.values + items: all_items, + model: all_items.select { |item| selected.include?(item[:id]) } ) end diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c81142293382..1f00ec3ddb0e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -53,8 +53,8 @@ "@mantine/core": "^8.3.13", "@mantine/hooks": "^8.3.6", "@mantine/utils": "^6.0.22", - "@ng-select/ng-option-highlight": "^20.6.3", - "@ng-select/ng-select": "^20.1.0", + "@ng-select/ng-option-highlight": "^21.8.0", + "@ng-select/ng-select": "^21.8.0", "@ngneat/content-loader": "^7.0.0", "@openproject/octicons-angular": "^19.32.0", "@openproject/primer-view-components": "^0.84.1", @@ -7119,32 +7119,33 @@ } }, "node_modules/@ng-select/ng-option-highlight": { - "version": "20.6.3", - "resolved": "https://registry.npmjs.org/@ng-select/ng-option-highlight/-/ng-option-highlight-20.6.3.tgz", - "integrity": "sha512-PD0ri0i5BIzhZU4MgKWKYtOHDAzyvSv7m6/1OtyS7wl4rlas7EFEEzx/NqpI4Uyi217BP0rv3unBh7R7sK1UOQ==", + "version": "21.8.0", + "resolved": "https://registry.npmjs.org/@ng-select/ng-option-highlight/-/ng-option-highlight-21.8.0.tgz", + "integrity": "sha512-A8EMXR9dU6VuItzqoKMiadzQIBDsrhwTTVmmxCsv4XCfpRF32rr+fTyIT1FKVE9DgHOuloi42Hdt6c/NF0iOyg==", + "license": "MIT", "dependencies": { "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/common": "^20.0.0", - "@angular/core": "^20.0.0" + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0" } }, "node_modules/@ng-select/ng-select": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-20.1.0.tgz", - "integrity": "sha512-Co4JWm2vOUaXjy/JbKJergIpXOf7hR83q04uCs4Jfthf4IVLaIonthVu62cl7T4MWXDWx4e7emarcJ1JgvYZkQ==", + "version": "21.8.0", + "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-21.8.0.tgz", + "integrity": "sha512-PXJFfitM7VO/ynqjJ3wKGoRETU6y0ARjcz5bA5V420fX0H6WyU3O91Ex5uFDNx08DzT4fU1GHPs0KXxI+LYOVg==", "license": "MIT", "dependencies": { "tslib": "^2.8.1" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.12.0 || ^24.0.0" }, "peerDependencies": { - "@angular/common": "^20.0.0", - "@angular/core": "^20.0.0", - "@angular/forms": "^20.0.0" + "@angular/common": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/forms": "^21.0.0" } }, "node_modules/@ngneat/content-loader": { @@ -30183,17 +30184,17 @@ } }, "@ng-select/ng-option-highlight": { - "version": "20.6.3", - "resolved": "https://registry.npmjs.org/@ng-select/ng-option-highlight/-/ng-option-highlight-20.6.3.tgz", - "integrity": "sha512-PD0ri0i5BIzhZU4MgKWKYtOHDAzyvSv7m6/1OtyS7wl4rlas7EFEEzx/NqpI4Uyi217BP0rv3unBh7R7sK1UOQ==", + "version": "21.8.0", + "resolved": "https://registry.npmjs.org/@ng-select/ng-option-highlight/-/ng-option-highlight-21.8.0.tgz", + "integrity": "sha512-A8EMXR9dU6VuItzqoKMiadzQIBDsrhwTTVmmxCsv4XCfpRF32rr+fTyIT1FKVE9DgHOuloi42Hdt6c/NF0iOyg==", "requires": { "tslib": "^2.3.0" } }, "@ng-select/ng-select": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-20.1.0.tgz", - "integrity": "sha512-Co4JWm2vOUaXjy/JbKJergIpXOf7hR83q04uCs4Jfthf4IVLaIonthVu62cl7T4MWXDWx4e7emarcJ1JgvYZkQ==", + "version": "21.8.0", + "resolved": "https://registry.npmjs.org/@ng-select/ng-select/-/ng-select-21.8.0.tgz", + "integrity": "sha512-PXJFfitM7VO/ynqjJ3wKGoRETU6y0ARjcz5bA5V420fX0H6WyU3O91Ex5uFDNx08DzT4fU1GHPs0KXxI+LYOVg==", "requires": { "tslib": "^2.8.1" } diff --git a/frontend/package.json b/frontend/package.json index 3ea57a86d738..efbaa6261c8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -108,8 +108,8 @@ "@mantine/core": "^8.3.13", "@mantine/hooks": "^8.3.6", "@mantine/utils": "^6.0.22", - "@ng-select/ng-option-highlight": "^20.6.3", - "@ng-select/ng-select": "^20.1.0", + "@ng-select/ng-option-highlight": "^21.8.0", + "@ng-select/ng-select": "^21.8.0", "@ngneat/content-loader": "^7.0.0", "@openproject/octicons-angular": "^19.32.0", "@openproject/primer-view-components": "^0.84.1", diff --git a/frontend/src/app/core/global_search/input/global-search-input.component.ts b/frontend/src/app/core/global_search/input/global-search-input.component.ts index 35158e66d939..83c2b43deeb3 100644 --- a/frontend/src/app/core/global_search/input/global-search-input.component.ts +++ b/frontend/src/app/core/global_search/input/global-search-input.component.ts @@ -37,8 +37,8 @@ import { import { RecentItemsService } from 'core-app/core/recent-items.service'; import { populateInputsFromDataset } from 'core-app/shared/components/dataset-inputs'; import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; -import { NgOption } from '@ng-select/ng-select/index'; import { announce } from '@primer/live-region-element'; +import { NgOption } from '@ng-select/ng-select'; interface SearchResultItem { id:string; @@ -82,9 +82,11 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { public expanded = false; + private _searchTermInitialized = false; + // Computed placeholder that changes based on expanded state public get effectivePlaceholder():string { - return this.expanded + return this.expanded ? this.I18n.t('js.global_search.search_placeholder_expanded') : this.placeholder; } @@ -146,8 +148,6 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { } ngAfterViewInit():void { - // check searchterm on init, expand / collapse search bar and set correct classes - this.searchTerm = this.currentQuery || ''; this.currentValue = ''; this.toggleTopMenuClass(); } @@ -157,7 +157,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { } public set searchTerm(searchTerm:string) { - this.ngSelectComponent.ngSelectInstance.searchTerm = searchTerm; + this.ngSelectComponent.ngSelectInstance.filter(searchTerm); } public get searchTerm():string { @@ -224,6 +224,11 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { } public onFocus():void { + if (!this._searchTermInitialized) { + this._searchTermInitialized = true; + this.searchTerm = this.currentQuery ?? ''; + this.currentValue = this.searchTerm; + } this.expanded = true; this.toggleTopMenuClass(); this.ngSelectComponent.openSelect(); @@ -232,7 +237,7 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { public onFocusOut():void { if (!this.deviceService.isMobile) { this.expanded = (this.searchTerm !== null && this.searchTerm.length > 0); - this.ngSelectComponent.ngSelectInstance.isOpen = false; + this.ngSelectComponent.ngSelectInstance.isOpen.set(false); this.selectedItem = undefined; this.toggleTopMenuClass(); } @@ -287,8 +292,10 @@ export class GlobalSearchInputComponent implements AfterViewInit, OnDestroy { } private autocompleteWorkPackages():Observable<(WorkPackageResource|SearchOptionItem)[]> { - const query = this.searchTerm; - if (query === null || /^\s+$/.test(query)) { + // ng-select v21 initializes _searchTerm as null (signal). Treat null as '' so that + // the initial typeahead emission triggers loadRecentItems() instead of returning empty. + const query = this.searchTerm ?? ''; + if (/^\s+$/.test(query)) { return of([]); } diff --git a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts index d126b2a93b9f..e33d6e80534b 100644 --- a/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-relations/wp-relations-create/wp-relations-autocomplete/wp-relations-autocomplete.component.ts @@ -42,6 +42,7 @@ import { } from 'core-app/features/work-packages/services/notifications/work-package-notification.service'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { TOpAutocompleterResource } from 'core-app/shared/components/autocompleter/op-autocompleter/typings'; +import { repositionDropdownBugfix } from 'core-app/shared/components/autocompleter/op-autocompleter/autocompleter.helper'; export interface IWorkPackageAutocompleteItem extends WorkPackageResource { id:string, @@ -92,14 +93,7 @@ export class WorkPackageRelationsAutocompleteComponent extends OpAutocompleterCo } opened() { - // Force reposition as a workaround for BUG - // https://github.com/ng-select/ng-select/issues/1259 - setTimeout(() => { - this.ngSelectInstance.dropdownPanel.adjustPosition(); - document.querySelector(this.hiddenOverflowContainer)?.addEventListener('scroll', () => { - this.ngSelectInstance.close(); - }, { once: true }); - }, 25); + repositionDropdownBugfix(this.ngSelectInstance); } getAutocompleterData(query:string|null):Observable { diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/autocompleter.helper.ts b/frontend/src/app/shared/components/autocompleter/op-autocompleter/autocompleter.helper.ts index e3c9caf9e131..1264138a8a90 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/autocompleter.helper.ts +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/autocompleter.helper.ts @@ -1,9 +1,6 @@ interface NgSelectShim { appendTo?:string; - dropdownPanel?:{ - _updateXPosition():void; - _updateYPosition():void; - } + dropdownPanel?:(() => { adjustPosition():void })|{ adjustPosition():void }; } // Force reposition as a workaround for BUG @@ -12,10 +9,10 @@ export function repositionDropdownBugfix(component?:unknown) { const instance = component as NgSelectShim; if (instance?.appendTo && instance?.dropdownPanel) { setTimeout(() => { - // eslint-disable-next-line no-underscore-dangle - instance.dropdownPanel?._updateXPosition(); - // eslint-disable-next-line no-underscore-dangle - instance.dropdownPanel?._updateYPosition(); + // dropdownPanel is a Signal in ng-select v21+, call it to get the panel instance + const panelOrSignal = instance.dropdownPanel; + const panel = typeof panelOrSignal === 'function' ? panelOrSignal() : panelOrSignal; + panel?.adjustPosition(); }, 25); } } diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts index f60b346e4717..e54f12128c93 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/op-autocompleter.component.ts @@ -511,7 +511,7 @@ export class OpAutocompleterComponent { fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; - expect(select.isOpen).toBeFalse(); + expect(select.isOpen()).toBeFalse(); select.open(); select.focus(); - expect(select.isOpen).toBeTrue(); + expect(select.isOpen()).toBeTrue(); expect(select.itemsList.items.length).toEqual(0); @@ -161,11 +161,11 @@ describe('autocompleter', () => { fixture.detectChanges(); const select = fixture.componentInstance.ngSelectInstance; - expect(select.isOpen).toBeFalse(); + expect(select.isOpen()).toBeFalse(); select.open(); select.focus(); - expect(select.isOpen).toBeTrue(); + expect(select.isOpen()).toBeTrue(); expect(select.itemsList.items.length).toEqual(0); diff --git a/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts index 079ef05844d7..bccfbf33c820 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/multi-select-edit-field.component.ts @@ -36,6 +36,7 @@ import { NgSelectComponent } from '@ng-select/ng-select'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { UserResource } from 'core-app/features/hal/resources/user-resource'; +import { repositionDropdownBugfix } from 'core-app/shared/components/autocompleter/op-autocompleter/autocompleter.helper'; @Component({ templateUrl: './multi-select-edit-field.component.html', @@ -152,9 +153,7 @@ export class MultiSelectEditFieldComponent extends EditFieldComponent implements } public repositionDropdown() { - if (this.ngSelectComponent && this.ngSelectComponent.dropdownPanel) { - setTimeout(() => this.ngSelectComponent.dropdownPanel.adjustPosition(), 0); - } + repositionDropdownBugfix(this.ngSelectComponent); } private openAutocompleteSelectField() { diff --git a/frontend/src/global_styles/content/_autocomplete.sass b/frontend/src/global_styles/content/_autocomplete.sass index e86ef1a8d5e6..480741668e2f 100644 --- a/frontend/src/global_styles/content/_autocomplete.sass +++ b/frontend/src/global_styles/content/_autocomplete.sass @@ -181,7 +181,6 @@ div.autocomplete @extend %autocomplete-description .ng-option, .ng-optgroup - line-height: 22px font-size: var(--body-font-size) .op-avatar diff --git a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts index 1a1ed4a0e6bf..7250e3cbcdca 100644 --- a/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/filter/filters-form.controller.ts @@ -101,11 +101,11 @@ export default class FiltersFormController extends Controller { this.formLoadedResolver = resolve; }); - private boundListener = this.sendForm.bind(this); + private boundListener:() => void; initialize() { // Initialize runs anytime an element with a controller connected to the DOM for the first time - this.sendForm = debounce(this.boundListener, 300); + this.boundListener = debounce(this.sendForm.bind(this), 300); } connect() { diff --git a/spec/features/projects/lists/filters_spec.rb b/spec/features/projects/lists/filters_spec.rb index 6d7a928628b6..d2b42aa97c86 100644 --- a/spec/features/projects/lists/filters_spec.rb +++ b/spec/features/projects/lists/filters_spec.rb @@ -264,7 +264,6 @@ def load_and_open_filters(user) "Status", "is (OR)", ["On track"]) - wait_for_reload expect(page).to have_text(green_project.name) expect(page).to have_no_text(no_status_project.name) @@ -273,7 +272,6 @@ def load_and_open_filters(user) "Status", "is not empty", []) - wait_for_reload expect(page).to have_text(green_project.name) expect(page).to have_no_text(no_status_project.name) @@ -282,7 +280,6 @@ def load_and_open_filters(user) "Status", "is empty", []) - wait_for_reload expect(page).to have_no_text(green_project.name) expect(page).to have_text(no_status_project.name) @@ -291,7 +288,6 @@ def load_and_open_filters(user) "Status", "is not", ["On track"]) - wait_for_reload expect(page).to have_no_text(green_project.name) expect(page).to have_text(no_status_project.name) @@ -796,7 +792,7 @@ def load_and_open_filters(user) projects_page.expect_projects_not_listed(development_project) projects_page.expect_projects_in_order(project, public_project) - projects_page.remove_filter("project_phase_any") + wait_for_turbo_stream { projects_page.remove_filter("project_phase_any") } projects_page.expect_projects_in_order(development_project, project, public_project) @@ -807,7 +803,7 @@ def load_and_open_filters(user) projects_page.expect_projects_not_listed(development_project) projects_page.expect_projects_in_order(project, public_project) - projects_page.remove_filter("project_phase_any") + wait_for_turbo_stream { projects_page.remove_filter("project_phase_any") } projects_page.expect_projects_in_order(development_project, project, public_project) @@ -819,7 +815,7 @@ def load_and_open_filters(user) projects_page.expect_projects_not_listed(development_project) projects_page.expect_projects_in_order(project, public_project) - projects_page.remove_filter("project_phase_any") + wait_for_turbo_stream { projects_page.remove_filter("project_phase_any") } projects_page.expect_projects_in_order(development_project, project, public_project) @@ -830,7 +826,7 @@ def load_and_open_filters(user) projects_page.expect_projects_not_listed(development_project) projects_page.expect_projects_in_order(project, public_project) - projects_page.remove_filter("project_phase_any") + wait_for_turbo_stream { projects_page.remove_filter("project_phase_any") } projects_page.expect_projects_in_order(development_project, project, public_project) diff --git a/spec/features/work_packages/table/queries/filter_spec.rb b/spec/features/work_packages/table/queries/filter_spec.rb index 0e4ab78b8fb7..ed1e69277440 100644 --- a/spec/features/work_packages/table/queries/filter_spec.rb +++ b/spec/features/work_packages/table/queries/filter_spec.rb @@ -484,6 +484,7 @@ # content contains single hit with numbers filters.remove_filter "attachmentContent" + loading_indicator_saveguard filters.add_filter_by("Attachment content", "contains", @@ -495,6 +496,7 @@ wp_table.ensure_work_package_not_listed! wp_without_attachment, wp_with_attachment_b filters.remove_filter "attachmentContent" + loading_indicator_saveguard # content does not contain filters.add_filter_by("Attachment content", @@ -507,6 +509,7 @@ wp_table.ensure_work_package_not_listed! wp_with_attachment_a filters.remove_filter "attachmentContent" + loading_indicator_saveguard # ignores special characters filters.add_filter_by("Attachment content", @@ -519,6 +522,7 @@ wp_table.ensure_work_package_not_listed! wp_without_attachment, wp_with_attachment_b filters.remove_filter "attachmentContent" + loading_indicator_saveguard # file name contains filters.add_filter_by("Attachment file name", @@ -531,6 +535,7 @@ wp_table.ensure_work_package_not_listed! wp_without_attachment, wp_with_attachment_b filters.remove_filter "attachmentFileName" + loading_indicator_saveguard # file name does not contain filters.add_filter_by("Attachment file name", diff --git a/spec/features/work_packages/table/queries/responsible_filter_spec.rb b/spec/features/work_packages/table/queries/responsible_filter_spec.rb index 43e54a1711d4..753a6766a24b 100644 --- a/spec/features/work_packages/table/queries/responsible_filter_spec.rb +++ b/spec/features/work_packages/table/queries/responsible_filter_spec.rb @@ -83,6 +83,7 @@ filters.open filters.expect_filter_by("Accountable", "is (OR)", [other_user.name], "responsible") filters.remove_filter "responsible" + loading_indicator_saveguard filters.add_filter_by("Accountable", "is (OR)", [placeholder_user.name], "responsible") wp_table.ensure_work_package_not_listed!(work_package_user_responsible) diff --git a/spec/support/pages/projects/index.rb b/spec/support/pages/projects/index.rb index d2426a4d3843..727c3094473e 100644 --- a/spec/support/pages/projects/index.rb +++ b/spec/support/pages/projects/index.rb @@ -41,39 +41,33 @@ def path(*) end def expect_projects_listed(*projects, archived: false) - within_table do - projects.each do |project| - displayed_name = if archived - "#{project.name} (Archived)" - else - project.name - end - - expect(page).to have_text(displayed_name, normalize_ws: true) - end + projects.each do |project| + displayed_name = if archived + "#{project.name} (Archived)" + else + project.name + end + + expect(page).to have_css("#project-table", text: displayed_name, normalize_ws: true) end end def expect_projects_not_listed(*projects) - within_table do - projects.each do |project| - case project - when Project - expect(page).to have_no_text(project.name) - when String - expect(page).to have_no_text(project) - else - raise ArgumentError, "#{project.inspect} is not a Project or a String" - end + projects.each do |project| + case project + when Project + expect(page).to have_no_css("#project-table", text: project.name) + when String + expect(page).to have_no_css("#project-table", text: project) + else + raise ArgumentError, "#{project.inspect} is not a Project or a String" end end end def expect_project_at_place(project, place) - within_table do - expect(page) - .to have_css(".project:nth-of-type(#{place}) td.name", text: project.name) - end + expect(page) + .to have_css("#project-table .project:nth-of-type(#{place}) td.name", text: project.name) end def expect_projects_in_order(*projects) @@ -246,18 +240,26 @@ def filter_by_name_and_identifier(value, send_keys: false) def set_advanced_filter(name, human_name, human_operator = nil, values = [], send_keys: false) selected_filter = select_filter(name, human_name) + # Detect filter type before apply_operator, which may trigger Turbo stream + # updates that make the selected_filter node reference stale (ObsoleteNode) + is_autocomplete = autocomplete_filter?(selected_filter) + is_date_or_datetime = date_filter?(selected_filter) || date_time_filter?(selected_filter) + within(selected_filter) do apply_operator(name, human_operator) + end + + return unless values.any? - return unless values.any? + # Re-find element as apply_operator may have triggered DOM updates + selected_filter = page.find("li[data-filter-name='#{name}']") + within(selected_filter) do if boolean_filter?(name) set_toggle_filter(values) - elsif autocomplete_filter?(selected_filter) - select(human_operator, from: "operator") + elsif is_autocomplete set_autocomplete_filter(values) - elsif date_filter?(selected_filter) || date_time_filter?(selected_filter) - select(human_operator, from: "operator") + elsif is_date_or_datetime wait_for_network_idle set_created_at_filter(human_operator, values, send_keys:) end