diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiComponentProperties.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiComponentProperties.java index 33c5a835d3..432c542989 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiComponentProperties.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/UiComponentProperties.java @@ -134,6 +134,16 @@ public class UiComponentProperties { */ boolean filterConfigurationUniqueNamesEnabled; + /** + * Whether a standalone filter component ({@link io.jmix.flowui.component.propertyfilter.PropertyFilter}, + * {@link io.jmix.flowui.component.jpqlfilter.JpqlFilter}, a {@code DataGrid} header filter) participates + * in loading as a condition contributor of its data loader instead of appending its condition into the + * loader's condition tree. With contributors the loader's condition stays with the application, the + * contribution cannot be lost when another party replaces or rebuilds the loader condition, and it is + * not visible in {@code DataLoader#getCondition()} - only in {@code DataLoader#getEffectiveCondition()}. + */ + boolean standaloneFilterContributesCondition; + /** * Whether error message should be shown below the field or not. */ @@ -195,6 +205,7 @@ public UiComponentProperties( @DefaultValue("false") boolean filterShowConfigurationIdField, @DefaultValue("true") boolean filterShowNonJpaProperties, @DefaultValue("true") boolean filterConfigurationUniqueNamesEnabled, + @DefaultValue("false") boolean standaloneFilterContributesCondition, @DefaultValue("true") boolean showErrorMessageBelowField, @DefaultValue("true") boolean immediateRequiredValidationEnabled, @DefaultValue("true") boolean defaultTrimEnabled, @@ -231,6 +242,7 @@ public UiComponentProperties( this.filterShowConfigurationIdField = filterShowConfigurationIdField; this.filterShowNonJpaProperties = filterShowNonJpaProperties; this.filterConfigurationUniqueNamesEnabled = filterConfigurationUniqueNamesEnabled; + this.standaloneFilterContributesCondition = standaloneFilterContributesCondition; this.showErrorMessageBelowField = showErrorMessageBelowField; this.immediateRequiredValidationEnabled = immediateRequiredValidationEnabled; @@ -383,6 +395,13 @@ public boolean isFilterConfigurationUniqueNamesEnabled() { return filterConfigurationUniqueNamesEnabled; } + /** + * @see #standaloneFilterContributesCondition + */ + public boolean isStandaloneFilterContributesCondition() { + return standaloneFilterContributesCondition; + } + /** * @see #showErrorMessageBelowField */ diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/filter/SingleFilterComponentBase.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/filter/SingleFilterComponentBase.java index 0c5b442465..852fe58448 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/filter/SingleFilterComponentBase.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/filter/SingleFilterComponentBase.java @@ -26,6 +26,7 @@ import com.vaadin.flow.dom.PropertyChangeEvent; import com.vaadin.flow.shared.Registration; import io.jmix.core.annotation.Internal; +import io.jmix.core.common.event.Subscription; import io.jmix.core.querycondition.Condition; import io.jmix.core.querycondition.LogicalCondition; import io.jmix.flowui.UiComponentProperties; @@ -61,6 +62,9 @@ public abstract class SingleFilterComponentBase extends CustomField protected DataLoader dataLoader; protected boolean autoApply; protected Condition queryCondition; + protected boolean contributesCondition; + @Nullable + protected Subscription conditionContributorSubscription; @Internal protected boolean conditionModificationDelegated = false; @@ -93,7 +97,9 @@ protected void autowireDependencies() { } protected void initComponent() { - this.autoApply = applicationContext.getBean(UiComponentProperties.class).isFilterAutoApply(); + UiComponentProperties componentProperties = applicationContext.getBean(UiComponentProperties.class); + this.autoApply = componentProperties.isFilterAutoApply(); + this.contributesCondition = componentProperties.isStandaloneFilterContributesCondition(); root = createRootComponent(); initRootComponent(root); @@ -156,7 +162,14 @@ public void setDataLoader(DataLoader dataLoader) { this.dataLoader = dataLoader; if (!isConditionModificationDelegated()) { - updateDataLoaderCondition(); + if (contributesCondition) { + // The contributor mode: the loader polls the filter for its current condition on + // every load, the loader's condition slot stays with the application, and the + // contribution cannot be lost when another party replaces or rebuilds the slot. + conditionContributorSubscription = dataLoader.addConditionContributor(this::getQueryCondition); + } else { + updateDataLoaderCondition(); + } } } @@ -195,6 +208,13 @@ public boolean isConditionModificationDelegated() { @Override public void setConditionModificationDelegated(boolean conditionModificationDelegated) { this.conditionModificationDelegated = conditionModificationDelegated; + + if (conditionModificationDelegated && conditionContributorSubscription != null) { + // The owner takes over condition management: a contribution of its own would now + // duplicate the owner's composition, which includes this filter's condition. + conditionContributorSubscription.remove(); + conditionContributorSubscription = null; + } } @Override diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/ConditionContributor.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/ConditionContributor.java new file mode 100644 index 0000000000..1468484d85 --- /dev/null +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/ConditionContributor.java @@ -0,0 +1,47 @@ +/* + * 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 io.jmix.flowui.model; + +import io.jmix.core.querycondition.Condition; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Supplies a condition to a {@link DataLoader} the contributor is registered on. The loader polls + * every registered contributor when it builds a load context, so the query condition is composed + * of the loader's own condition and the current contribution of each contributor at the moment of + * loading. The loader takes a copy of the returned condition: a contribution never becomes a + * shared mutable node of the loader's condition tree, and editing a previously returned condition + * takes effect on the next load. + * + *

Contributors let several independent parties filter one loader without competing for the + * single {@link DataLoader#setCondition(Condition)} slot: the slot stays with the application, + * each contributor owns its contribution. + * + * @see DataLoader#addConditionContributor(ConditionContributor) + */ +@NullMarked +@FunctionalInterface +public interface ConditionContributor { + + /** + * Returns the current contribution of this contributor, or {@code null} if it currently + * contributes nothing. + */ + @Nullable + Condition getCondition(); +} diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/DataLoader.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/DataLoader.java index c19a9b3eaa..93f85dcb49 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/DataLoader.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/DataLoader.java @@ -16,6 +16,7 @@ package io.jmix.flowui.model; +import io.jmix.core.common.event.Subscription; import io.jmix.core.querycondition.Condition; import io.jmix.flowui.monitoring.DataLoaderMonitoringInfo; @@ -79,6 +80,33 @@ public interface DataLoader { */ void setCondition(@Nullable Condition condition); + /** + * Registers a contributor whose current condition is combined with the condition of this + * loader on every load. The resulting query condition is the conjunction of + * {@link #getCondition()} and the non-null contributions; the loader copies each contribution, + * so a contributor keeps sole ownership of its condition instance. + * + * @param conditionContributor the contributor to register + * @return a subscription that unregisters the contributor + * @throws UnsupportedOperationException if this implementation does not support condition + * contributors + */ + default Subscription addConditionContributor(ConditionContributor conditionContributor) { + throw new UnsupportedOperationException( + getClass().getName() + " does not support condition contributors"); + } + + /** + * Returns the condition the next load would use: the conjunction of {@link #getCondition()} + * and the current contributions of the registered condition contributors. With no registered + * contributors it is the same as {@link #getCondition()}. The returned condition is composed + * for reading; modifying it has no effect on this loader. + */ + @Nullable + default Condition getEffectiveCondition() { + return getCondition(); + } + /** * Returns the map of query parameters. */ diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/CollectionLoaderImpl.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/CollectionLoaderImpl.java index 27ddfedd12..58870011a6 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/CollectionLoaderImpl.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/CollectionLoaderImpl.java @@ -73,6 +73,7 @@ public class CollectionLoaderImpl implements CollectionLoader { protected CollectionContainer container; protected String query; protected Condition condition; + protected List conditionContributors = new ArrayList<>(); protected Map parameters = new HashMap<>(); protected int firstResult = 0; protected int maxResults = Integer.MAX_VALUE; @@ -165,12 +166,13 @@ public LoadContext createLoadContext() { LoadContext.Query query = loadContext.setQueryString(queryString); - query.setCondition(condition); + Condition effectiveCondition = getEffectiveCondition(); + query.setCondition(effectiveCondition); query.setSort(sort); query.setParameters(parameters); query.setCacheable(cacheable); - query.setDistinct(canLeadToDuplicateResultsRecursive(condition)); + query.setDistinct(canLeadToDuplicateResultsRecursive(effectiveCondition)); if (firstResult > 0) query.setFirstResult(firstResult); @@ -287,6 +289,19 @@ public void setCondition(@Nullable Condition condition) { this.condition = condition; } + @Override + public Subscription addConditionContributor(ConditionContributor conditionContributor) { + Preconditions.checkNotNullArgument(conditionContributor); + conditionContributors.add(conditionContributor); + return () -> conditionContributors.remove(conditionContributor); + } + + @Nullable + @Override + public Condition getEffectiveCondition() { + return DataLoadersHelper.composeEffectiveCondition(condition, conditionContributors); + } + @Override public Map getParameters() { return Collections.unmodifiableMap(parameters); diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/DataLoadersHelper.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/DataLoadersHelper.java index e9608eb10c..1b101b6785 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/DataLoadersHelper.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/DataLoadersHelper.java @@ -17,6 +17,9 @@ package io.jmix.flowui.model.impl; import com.google.common.base.Strings; +import io.jmix.core.querycondition.Condition; +import io.jmix.core.querycondition.LogicalCondition; +import io.jmix.flowui.model.ConditionContributor; import io.jmix.flowui.model.DataLoader; import io.jmix.flowui.model.HasLoader; import io.jmix.flowui.model.InstanceContainer; @@ -35,6 +38,40 @@ public class DataLoadersHelper { public static final Pattern PARAM_PATTERN = Pattern.compile(":([\\w$]+)"); + /** + * Composes the effective condition of a loader: the conjunction of the loader's own condition + * and the current contributions of the registered contributors. Both the loader's condition + * and the contributions enter the composed tree as copies, so it shares no nodes with anyone. + * With no non-null contributions the loader's own condition is returned as is - the same + * instance, so the behavior of a loader without contributors is untouched. + * + * @param condition the loader's own condition + * @param contributors registered contributors, polled in registration order + * @return the composed condition, or {@code null} if there is nothing to compose + */ + @Nullable + public static Condition composeEffectiveCondition(@Nullable Condition condition, + List contributors) { + List contributions = new ArrayList<>(contributors.size()); + for (ConditionContributor contributor : contributors) { + Condition contribution = contributor.getCondition(); + if (contribution != null) { + contributions.add(contribution.copy()); + } + } + + if (contributions.isEmpty()) { + return condition; + } + + LogicalCondition effective = LogicalCondition.and(); + if (condition != null) { + effective.add(condition.copy()); + } + contributions.forEach(effective::add); + return effective; + } + /** * Returns the loader of master entity instance. * diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/InstanceLoaderImpl.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/InstanceLoaderImpl.java index 69b6b58d2b..7500b4c73e 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/InstanceLoaderImpl.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/InstanceLoaderImpl.java @@ -60,6 +60,7 @@ public class InstanceLoaderImpl implements InstanceLoader { protected InstanceContainer container; protected String query; protected Condition condition; + protected List conditionContributors = new ArrayList<>(); protected Map parameters = new HashMap<>(); protected Object entityId; protected FetchPlan fetchPlan; @@ -156,7 +157,8 @@ public LoadContext createLoadContext() { } else { String queryString = QueryUtils.applyQueryStringProcessors(queryStringProcessors, this.query, entityClass); LoadContext.Query query = loadContext.setQueryString(queryString); - query.setCondition(condition); + Condition effectiveCondition = getEffectiveCondition(); + query.setCondition(effectiveCondition); query.setParameters(parameters); } @@ -224,6 +226,19 @@ public void setCondition(@Nullable Condition condition) { this.condition = condition; } + @Override + public Subscription addConditionContributor(ConditionContributor conditionContributor) { + Preconditions.checkNotNullArgument(conditionContributor); + conditionContributors.add(conditionContributor); + return () -> conditionContributors.remove(conditionContributor); + } + + @Nullable + @Override + public Condition getEffectiveCondition() { + return DataLoadersHelper.composeEffectiveCondition(condition, conditionContributors); + } + @Override public Map getParameters() { return Collections.unmodifiableMap(parameters); diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/KeyValueCollectionLoaderImpl.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/KeyValueCollectionLoaderImpl.java index 634dae6bcf..a68be92e48 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/KeyValueCollectionLoaderImpl.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/KeyValueCollectionLoaderImpl.java @@ -56,6 +56,7 @@ public class KeyValueCollectionLoaderImpl implements KeyValueCollectionLoader { protected KeyValueCollectionContainer container; protected String query; protected Condition condition; + protected List conditionContributors = new ArrayList<>(); protected Map parameters = new HashMap<>(); protected int firstResult = 0; protected int maxResults = Integer.MAX_VALUE; @@ -136,7 +137,8 @@ public ValueLoadContext createLoadContext() { ValueLoadContext.Query query = loadContext.setQueryString(this.query); - query.setCondition(condition); + Condition effectiveCondition = getEffectiveCondition(); + query.setCondition(effectiveCondition); query.setSort(sort); query.setParameters(parameters); @@ -198,6 +200,19 @@ public void setCondition(@Nullable Condition condition) { this.condition = condition; } + @Override + public Subscription addConditionContributor(ConditionContributor conditionContributor) { + Preconditions.checkNotNullArgument(conditionContributor); + conditionContributors.add(conditionContributor); + return () -> conditionContributors.remove(conditionContributor); + } + + @Nullable + @Override + public Condition getEffectiveCondition() { + return DataLoadersHelper.composeEffectiveCondition(condition, conditionContributors); + } + @Override public Map getParameters() { return Collections.unmodifiableMap(parameters); diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/KeyValueInstanceLoaderImpl.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/KeyValueInstanceLoaderImpl.java index 57e4af31bf..e04adbefe6 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/KeyValueInstanceLoaderImpl.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/model/impl/KeyValueInstanceLoaderImpl.java @@ -34,6 +34,7 @@ import org.jspecify.annotations.Nullable; import java.io.Serializable; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -56,6 +57,7 @@ public class KeyValueInstanceLoaderImpl implements KeyValueInstanceLoader { protected KeyValueContainer container; protected String query; protected Condition condition; + protected List conditionContributors = new ArrayList<>(); protected Map parameters = new HashMap<>(); protected Map hints = new HashMap<>(); protected String storeName = Stores.MAIN; @@ -125,7 +127,8 @@ public ValueLoadContext createLoadContext() { ValueLoadContext.Query query = loadContext.setQueryString(this.query); - query.setCondition(condition); + Condition effectiveCondition = getEffectiveCondition(); + query.setCondition(effectiveCondition); query.setParameters(parameters); query.setMaxResults(1); @@ -181,6 +184,19 @@ public void setCondition(@Nullable Condition condition) { this.condition = condition; } + @Override + public Subscription addConditionContributor(ConditionContributor conditionContributor) { + Preconditions.checkNotNullArgument(conditionContributor); + conditionContributors.add(conditionContributor); + return () -> conditionContributors.remove(conditionContributor); + } + + @Nullable + @Override + public Condition getEffectiveCondition() { + return DataLoadersHelper.composeEffectiveCondition(condition, conditionContributors); + } + @Override public Map getParameters() { return Collections.unmodifiableMap(parameters); diff --git a/jmix-flowui/flowui/src/test/groovy/component/genericfilter/StandaloneFilterConditionContributorTest.groovy b/jmix-flowui/flowui/src/test/groovy/component/genericfilter/StandaloneFilterConditionContributorTest.groovy new file mode 100644 index 0000000000..cbb7c3132d --- /dev/null +++ b/jmix-flowui/flowui/src/test/groovy/component/genericfilter/StandaloneFilterConditionContributorTest.groovy @@ -0,0 +1,79 @@ +/* + * 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 component.genericfilter + +import component.genericfilter.view.GfStandaloneContributorTestView +import io.jmix.flowui.component.logicalfilter.LogicalFilterComponent +import io.jmix.flowui.model.CollectionLoader +import org.springframework.boot.test.context.SpringBootTest +import test_support.spec.FlowuiTestSpecification + +import static component.genericfilter.TestFilterConditions.hasPropertyConditionOn + +/** + * With {@code jmix.ui.component.standalone-filter-contributes-condition = true} a standalone + * filter component participates in loading as a condition contributor of its data loader: the + * loader's condition slot stays with the other parties, and the contribution survives a rebuild + * of the slot by a composing filter component. + */ +@SpringBootTest(properties = ["jmix.ui.component.standalone-filter-contributes-condition = true"]) +class StandaloneFilterConditionContributorTest extends FlowuiTestSpecification { + + void setup() { + registerViewBasePackages("component.genericfilter.view") + } + + def "standalone filter leaves the loader condition slot untouched and contributes at load"() { + when: "the view opens: a standalone GroupFilter and a standalone PropertyFilter on one loader" + GfStandaloneContributorTestView view = navigateToView(GfStandaloneContributorTestView) + CollectionLoader loader = view.groupFilter.dataLoader as CollectionLoader + + then: "the loader condition slot is not touched by the standalone filter" + !hasPropertyConditionOn(loader.condition, "amount") + + and: "the load itself sees the standalone contribution" + hasPropertyConditionOn(loader.createLoadContext().getQuery().getCondition(), "amount") + } + + def "contribution survives a rebuild of the loader condition by the composing filter"() { + given: "the standalone PropertyFilter contributes to the shared loader" + GfStandaloneContributorTestView view = navigateToView(GfStandaloneContributorTestView) + CollectionLoader loader = view.groupFilter.dataLoader as CollectionLoader + + when: "a structural change makes the group rebuild the loader condition from scratch" + view.groupFilter.setOperation(LogicalFilterComponent.Operation.OR) + + then: "the standalone contribution is still applied on load" + hasPropertyConditionOn(loader.createLoadContext().getQuery().getCondition(), "amount") + + and: "without being written into the rebuilt slot" + !hasPropertyConditionOn(loader.condition, "amount") + } + + def "delegating condition modification withdraws the contribution"() { + given: "the standalone PropertyFilter contributes to the shared loader" + GfStandaloneContributorTestView view = navigateToView(GfStandaloneContributorTestView) + CollectionLoader loader = view.standaloneFilter.dataLoader as CollectionLoader + assert hasPropertyConditionOn(loader.createLoadContext().getQuery().getCondition(), "amount") + + when: "an owner takes over condition management of the filter" + view.standaloneFilter.setConditionModificationDelegated(true) + + then: "the filter no longer contributes on its own" + !hasPropertyConditionOn(loader.createLoadContext().getQuery().getCondition(), "amount") + } +} diff --git a/jmix-flowui/flowui/src/test/groovy/component/genericfilter/StandaloneFilterLegacyConditionTest.groovy b/jmix-flowui/flowui/src/test/groovy/component/genericfilter/StandaloneFilterLegacyConditionTest.groovy new file mode 100644 index 0000000000..ab4a023c01 --- /dev/null +++ b/jmix-flowui/flowui/src/test/groovy/component/genericfilter/StandaloneFilterLegacyConditionTest.groovy @@ -0,0 +1,49 @@ +/* + * 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 component.genericfilter + +import component.genericfilter.view.GfStandaloneContributorTestView +import io.jmix.flowui.model.CollectionLoader +import org.springframework.boot.test.context.SpringBootTest +import test_support.spec.FlowuiTestSpecification + +import static component.genericfilter.TestFilterConditions.hasPropertyConditionOn + +/** + * Pins the default behavior of a standalone filter component: with + * {@code jmix.ui.component.standalone-filter-contributes-condition} off (the default) the filter + * appends its condition into the loader's condition tree, exactly as before contributors existed. + */ +@SpringBootTest +class StandaloneFilterLegacyConditionTest extends FlowuiTestSpecification { + + void setup() { + registerViewBasePackages("component.genericfilter.view") + } + + def "by default a standalone filter appends its condition into the loader condition tree"() { + when: "the view opens: a standalone GroupFilter and a standalone PropertyFilter on one loader" + GfStandaloneContributorTestView view = navigateToView(GfStandaloneContributorTestView) + CollectionLoader loader = view.standaloneFilter.dataLoader as CollectionLoader + + then: "the standalone filter's condition sits in the loader condition slot" + hasPropertyConditionOn(loader.condition, "amount") + + and: "the load context takes the slot as is - the very same condition instance" + loader.createLoadContext().getQuery().getCondition().is(loader.condition) + } +} diff --git a/jmix-flowui/flowui/src/test/groovy/data_components/DataLoaderConditionContributorTest.groovy b/jmix-flowui/flowui/src/test/groovy/data_components/DataLoaderConditionContributorTest.groovy new file mode 100644 index 0000000000..022becac2f --- /dev/null +++ b/jmix-flowui/flowui/src/test/groovy/data_components/DataLoaderConditionContributorTest.groovy @@ -0,0 +1,175 @@ +/* + * 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 data_components + +import io.jmix.core.DataManager +import io.jmix.core.common.event.Subscription +import io.jmix.core.querycondition.Condition +import io.jmix.core.querycondition.LogicalCondition +import io.jmix.core.querycondition.PropertyCondition +import io.jmix.flowui.model.CollectionContainer +import io.jmix.flowui.model.CollectionLoader +import io.jmix.flowui.model.DataComponents +import org.springframework.beans.factory.annotation.Autowired +import test_support.entity.Foo +import test_support.spec.DataContextSpec + +class DataLoaderConditionContributorTest extends DataContextSpec { + + @Autowired + DataManager dataManager + @Autowired + DataComponents factory + + private CollectionLoader createLoader() { + CollectionLoader loader = factory.createCollectionLoader() + CollectionContainer container = factory.createCollectionContainer(Foo) + loader.setContainer(container) + loader.setQuery('select e from test_Foo e') + return loader + } + + def "load context condition is the same instance when no contributors are registered"() { + CollectionLoader loader = createLoader() + Condition base = PropertyCondition.equal("name", "foo") + loader.setCondition(base) + + expect: "the pre-contributors behavior is untouched: the very same condition object is used" + loader.createLoadContext().getQuery().getCondition().is(base) + loader.getEffectiveCondition().is(base) + } + + def "contribution is combined with the loader condition on load"() { + CollectionLoader loader = createLoader() + loader.setCondition(PropertyCondition.equal("name", "base")) + loader.addConditionContributor { PropertyCondition.contains("name", "extra") } + + when: + Condition effective = loader.createLoadContext().getQuery().getCondition() + + then: "the query condition is AND[base, contribution]" + effective instanceof LogicalCondition + (effective as LogicalCondition).getType() == LogicalCondition.Type.AND + describe(effective) == "AND[name equal base, name contains extra]" + + and: "the loader condition slot is untouched" + describe(loader.getCondition()) == "name equal base" + } + + def "contribution works without a loader condition"() { + CollectionLoader loader = createLoader() + loader.addConditionContributor { PropertyCondition.contains("name", "extra") } + + expect: + describe(loader.createLoadContext().getQuery().getCondition()) == "AND[name contains extra]" + } + + def "a null contribution does not participate"() { + CollectionLoader loader = createLoader() + Condition base = PropertyCondition.equal("name", "base") + loader.setCondition(base) + loader.addConditionContributor { null } + + expect: "with nothing contributed the loader condition is used as is - the same instance" + loader.createLoadContext().getQuery().getCondition().is(base) + } + + def "contributor is polled on every load"() { + CollectionLoader loader = createLoader() + PropertyCondition contribution = PropertyCondition.contains("name", "first") + loader.addConditionContributor { contribution } + + when: + Condition first = loader.createLoadContext().getQuery().getCondition() + contribution.setParameterValue("second") + Condition second = loader.createLoadContext().getQuery().getCondition() + + then: + describe(first) == "AND[name contains first]" + describe(second) == "AND[name contains second]" + } + + def "composed condition holds copies, not the live nodes"() { + CollectionLoader loader = createLoader() + PropertyCondition base = PropertyCondition.equal("name", "base") + PropertyCondition contribution = PropertyCondition.contains("name", "extra") + loader.setCondition(base) + loader.addConditionContributor { contribution } + + when: "the owners edit their conditions after a load context is built" + Condition effective = loader.createLoadContext().getQuery().getCondition() + base.setParameterValue("changed") + contribution.setParameterValue("changed") + + then: "the built context is not affected" + describe(effective) == "AND[name equal base, name contains extra]" + } + + def "subscription unregisters the contributor"() { + CollectionLoader loader = createLoader() + Condition base = PropertyCondition.equal("name", "base") + loader.setCondition(base) + Subscription subscription = loader.addConditionContributor { PropertyCondition.contains("name", "extra") } + + when: + subscription.remove() + + then: + loader.createLoadContext().getQuery().getCondition().is(base) + } + + def "contributions of all registered contributors are combined in registration order"() { + CollectionLoader loader = createLoader() + loader.addConditionContributor { PropertyCondition.contains("name", "one") } + loader.addConditionContributor { PropertyCondition.contains("name", "two") } + + expect: + describe(loader.createLoadContext().getQuery().getCondition()) == + "AND[name contains one, name contains two]" + } + + def "loaded data respects the contribution"() { + CollectionLoader loader = createLoader() + Foo one = new Foo(name: "one") + Foo two = new Foo(name: "two") + dataManager.save(one, two) + loader.addConditionContributor { PropertyCondition.equal("name", "two") } + + when: + loader.load() + + then: + loader.getContainer().getItems() == [two] + + cleanup: + deleteRecord(one, two) + } + + private static String describe(Condition condition) { + if (condition == null) { + return "(none)" + } + if (condition instanceof LogicalCondition) { + return condition.getType().toString() + + "[" + condition.getConditions().collect { describe(it) }.join(", ") + "]" + } + if (condition instanceof PropertyCondition) { + return "${condition.getProperty()} ${condition.getOperation()} ${condition.getParameterValue()}" + } + return condition.toString() + } +} diff --git a/jmix-flowui/flowui/src/test/java/component/genericfilter/view/GfStandaloneContributorTestView.java b/jmix-flowui/flowui/src/test/java/component/genericfilter/view/GfStandaloneContributorTestView.java new file mode 100644 index 0000000000..867263e022 --- /dev/null +++ b/jmix-flowui/flowui/src/test/java/component/genericfilter/view/GfStandaloneContributorTestView.java @@ -0,0 +1,39 @@ +/* + * 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 component.genericfilter.view; + +import com.vaadin.flow.router.Route; +import io.jmix.flowui.component.logicalfilter.GroupFilter; +import io.jmix.flowui.component.propertyfilter.PropertyFilter; +import io.jmix.flowui.view.*; + +import java.math.BigDecimal; + +/** + * A standalone {@code PropertyFilter} sharing a data loader with a standalone {@code GroupFilter} + * (a composing filter component). + */ +@Route(value = "gf-standalone-contributor-view") +@ViewController("GfStandaloneContributorTestView") +@ViewDescriptor("gf-standalone-contributor-view.xml") +public class GfStandaloneContributorTestView extends StandardView { + + @ViewComponent + public GroupFilter groupFilter; + @ViewComponent + public PropertyFilter standaloneFilter; +} diff --git a/jmix-flowui/flowui/src/test/resources/component/genericfilter/view/gf-standalone-contributor-view.xml b/jmix-flowui/flowui/src/test/resources/component/genericfilter/view/gf-standalone-contributor-view.xml new file mode 100644 index 0000000000..1f98e0c1e3 --- /dev/null +++ b/jmix-flowui/flowui/src/test/resources/component/genericfilter/view/gf-standalone-contributor-view.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + +