Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,15 @@ public void add(FilterComponent filterComponent) {
if (filterComponent instanceof PropertyFilter) {
// Keep the registration so remove() can detach it; otherwise re-adding a component
// (e.g. on a filter re-navigation restore) would accumulate stale apply() listeners.
// Apply on the user's gesture only: a programmatic operation change (e.g. the URL binder
// restoring the filter state) must not fire a load of its own, consistently with the
// value path, which is gated by isFromClient in SingleFilterComponentBase.
Registration operationChangeRegistration = ((PropertyFilter<?>) filterComponent)
.addOperationChangeListener(operationChangeEvent -> apply());
.addOperationChangeListener(operationChangeEvent -> {
if (operationChangeEvent.isFromClient()) {
apply();
}
});
operationChangeRegistrations.put(filterComponent, operationChangeRegistration);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ class GenericFilterApplyAfterBaseChangeTest extends FlowuiTestSpecification {

when: "the base is replaced again and the user changes the operation of a condition, which applies the group through its listener"
groupFilter.dataLoader.setCondition(PropertyCondition.greater("amount", 0))
number.setOperation(PropertyFilter.Operation.CONTAINS)
number.setOperationInternal(PropertyFilter.Operation.CONTAINS, true)

then:
hasPropertyConditionOn(groupFilter.dataLoader.condition, "amount")
Expand Down Expand Up @@ -202,7 +202,7 @@ class GenericFilterApplyAfterBaseChangeTest extends FlowuiTestSpecification {
when: "a child operation change applies the delegated root group"
PropertyFilter<?> number = filter.getConfiguration("c1").rootLogicalFilterComponent.filterComponents
.find { it instanceof PropertyFilter } as PropertyFilter
number.setOperation(PropertyFilter.Operation.CONTAINS)
number.setOperationInternal(PropertyFilter.Operation.CONTAINS, true)

then: "the application did not replace the base — same loader condition object"
filter.dataLoader.condition.is(composedByGenericFilter)
Expand Down Expand Up @@ -258,7 +258,7 @@ class GenericFilterApplyAfterBaseChangeTest extends FlowuiTestSpecification {
filter.dataLoader.setCondition(PropertyCondition.greater("total", 0))

when: "the user changes the condition operation, which applies the delegated root group"
number.setOperation(PropertyFilter.Operation.CONTAINS)
number.setOperationInternal(PropertyFilter.Operation.CONTAINS, true)

then: "one load, by the new base AND the shown configuration"
loads == 1
Expand Down Expand Up @@ -305,7 +305,7 @@ class GenericFilterApplyAfterBaseChangeTest extends FlowuiTestSpecification {
groupFilter.dataLoader.setCondition(PropertyCondition.greater("total", 0))

when: "the user changes the operation of the nested group's condition, which applies the nested (delegated) group"
date.setOperation(PropertyFilter.Operation.LESS)
date.setOperationInternal(PropertyFilter.Operation.LESS, true)

then: "the loader condition combines the new base with the owning group's output"
hasPropertyConditionOn(groupFilter.dataLoader.condition, "total")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -511,9 +511,9 @@ class GenericFilterReNavigationTest extends FlowuiTestSpecification {
binder.updateState(QueryParameters.empty())
}

and: "the user changes the baseline operation once"
and: "the user changes the baseline operation once (client-driven: the group applies only on isFromClient)"
loadCount.set(0)
nameFilter.setOperation(PropertyFilter.Operation.CONTAINS)
nameFilter.setOperationInternal(PropertyFilter.Operation.CONTAINS, true)

then: "the loader is loaded exactly once — not once per accumulated (leaked) listener"
loadCount.get() == 1
Expand All @@ -537,9 +537,9 @@ class GenericFilterReNavigationTest extends FlowuiTestSpecification {
rootGroup.add(nameFilter)
}

and: "the user changes the baseline operation once"
and: "the user changes the baseline operation once (a client-driven gesture)"
loadCount.set(0)
nameFilter.setOperation(PropertyFilter.Operation.CONTAINS)
nameFilter.setOperationInternal(PropertyFilter.Operation.CONTAINS, true)

then: "the loader is loaded exactly once — removeAll detached the stale listeners"
loadCount.get() == 1
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* Copyright 2026 Haulmont.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package facet.url_query_parameters

import com.vaadin.flow.router.QueryParameters
import facet.url_query_parameters.view.GenericFilterEditableOpConfigTestView
import io.jmix.flowui.component.propertyfilter.PropertyFilter
import io.jmix.flowui.facet.UrlQueryParametersFacet
import io.jmix.flowui.facet.urlqueryparameters.GenericFilterUrlQueryParametersBinder
import io.jmix.flowui.model.CollectionLoader
import org.springframework.boot.test.context.SpringBootTest
import test_support.spec.FlowuiTestSpecification

/**
* Restoring the filter state from URL query parameters must not fire a data load of its own: the
* load belongs to the navigation itself. The binder applies the operation from the URL to the
* configuration's condition programmatically; the group's operation-change listener applies the
* filter on a client-driven change only, so no intermediate load happens mid-restore.
*/
@SpringBootTest
class GenericFilterUrlRestoreLoadTest extends FlowuiTestSpecification {

@Override
void setup() {
registerViewBasePackages("facet.url_query_parameters", "io.jmix.flowui.app")
}

def "restoring a configuration with a changed operation fires no load of its own"() {
given: "a view with a design-time configuration whose condition operation is editable"
def view = navigateToView(GenericFilterEditableOpConfigTestView)
def binder = getBinder(view.urlQueryParameters)
int loads = 0
(view.ownersFilter.dataLoader as CollectionLoader).addPostLoadListener { loads++ }

when: "the URL selects the configuration and carries a different operation for its condition"
binder.updateState(new QueryParameters([
(binder.configurationParam): List.of("byName"),
(binder.conditionParam) : List.of("property:name_not-equal_Bob")]))

then: "the operation and the value are applied to the configuration's own condition"
def component = view.ownersFilter.currentConfiguration.rootLogicalFilterComponent.filterComponents
.first() as PropertyFilter<?>
component.operation == PropertyFilter.Operation.NOT_EQUAL
component.value == "Bob"

and: "the restore composed the loader condition without loading"
loads == 0
}

private static GenericFilterUrlQueryParametersBinder getBinder(UrlQueryParametersFacet facet) {
return facet.binders
.findAll { it instanceof GenericFilterUrlQueryParametersBinder }
.first() as GenericFilterUrlQueryParametersBinder
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2026 Haulmont.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package facet.url_query_parameters.view;

import com.vaadin.flow.router.Route;
import io.jmix.flowui.component.genericfilter.GenericFilter;
import io.jmix.flowui.facet.UrlQueryParametersFacet;
import io.jmix.flowui.view.StandardView;
import io.jmix.flowui.view.ViewComponent;
import io.jmix.flowui.view.ViewController;
import io.jmix.flowui.view.ViewDescriptor;

@Route("GenericFilterEditableOpConfigTestView")
@ViewController
@ViewDescriptor("generic-filter-editable-op-config-test-view.xml")
public class GenericFilterEditableOpConfigTestView extends StandardView {

@ViewComponent
public GenericFilter ownersFilter;

@ViewComponent("urlQueryParameters")
public UrlQueryParametersFacet urlQueryParameters;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!--
~ Copyright 2026 Haulmont.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->

<view xmlns="http://jmix.io/schema/flowui/view">
<data>
<collection id="ownersDc" class="test_support.entity.petclinic.Owner">
<fetchPlan extends="_base"/>
<loader id="ownersDl">
<query><![CDATA[select e from pc_Owner e]]></query>
</loader>
</collection>
</data>
<facets>
<urlQueryParameters id="urlQueryParameters">
<genericFilter component="ownersFilter"/>
</urlQueryParameters>
</facets>
<layout>
<genericFilter id="ownersFilter" dataLoader="ownersDl">
<configurations>
<configuration id="byName" name="By name">
<propertyFilter property="name" operation="EQUAL" operationEditable="true"/>
</configuration>
</configurations>
</genericFilter>
</layout>
</view>