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 @@ -64,7 +64,7 @@ public static Result recompose(@Nullable Condition currentLoaderCondition,
UnaryOperator<Condition> copy) {
Condition base = baseCondition;
if (!baseConditionInitialized
|| (lastConditionSetByFilter != null && currentLoaderCondition != lastConditionSetByFilter)) {
|| isReplacedExternally(currentLoaderCondition, lastConditionSetByFilter)) {
base = currentLoaderCondition != null ? copy.apply(currentLoaderCondition) : null;
}

Expand All @@ -82,4 +82,19 @@ public static Result recompose(@Nullable Condition currentLoaderCondition,

return new Result(base, loaderCondition);
}

/**
* Returns whether the loader condition object differs from the one the filter set last,
* i.e. the application has replaced it since the filter's last contribution. The comparison
* is by identity: the filter always contributes a freshly composed object, so a different
* reference means an external write.
*
* @param currentLoaderCondition the loader's current condition
* @param lastConditionSetByFilter the condition object the filter set last, if any
* @return {@code true} if the loader condition was replaced externally
*/
public static boolean isReplacedExternally(@Nullable Condition currentLoaderCondition,
@Nullable Condition lastConditionSetByFilter) {
return lastConditionSetByFilter != null && currentLoaderCondition != lastConditionSetByFilter;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public abstract class SingleFilterComponentBase<V> extends CustomField<V>

@Internal
protected boolean conditionModificationDelegated = false;
protected Runnable loaderConditionRecomposeDelegate;

protected HorizontalLayout root;

Expand Down Expand Up @@ -197,6 +198,20 @@ public void setConditionModificationDelegated(boolean conditionModificationDeleg
this.conditionModificationDelegated = conditionModificationDelegated;
}

/**
* Sets the owner's recomposition callback for a condition whose modification is delegated:
* {@link #apply()} invokes it before triggering the data loader directly, so the owning filter
* can recompose a loader condition the application has replaced since the last composition.
* Maintained by the owning logical filter component when this condition is added to or
* removed from it.
*
* @param loaderConditionRecomposeDelegate the owner's recomposition callback, or {@code null}
*/
@Internal
public void setLoaderConditionRecomposeDelegate(@Nullable Runnable loaderConditionRecomposeDelegate) {
this.loaderConditionRecomposeDelegate = loaderConditionRecomposeDelegate;
}

@Override
public Condition getQueryCondition() {
return queryCondition;
Expand All @@ -208,6 +223,14 @@ public void apply() {
// So if we have several such conditions we get redundant data loading.
// To avoid this problem FilterComponent skips data loading if it's not attached to the UI.
if (isAttached() && dataLoader != null) {
// A delegated condition loads directly, bypassing the owning filter's composition;
// if the application replaced the loader condition since the owner composed it last,
// this condition's query condition is no longer part of it. Let the owner recompose
// first - like the other apply entry points, regardless of autoApply - so a load
// never uses the replaced base alone.
if (loaderConditionRecomposeDelegate != null) {
loaderConditionRecomposeDelegate.run();
}
setupLoaderFirstResult();
if (autoApply) {
dataLoader.load();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import io.jmix.core.Messages;
import io.jmix.core.Metadata;
import io.jmix.core.annotation.Experimental;
import io.jmix.core.annotation.Internal;
import io.jmix.core.metamodel.model.MetaPropertyPath;
import io.jmix.core.querycondition.Condition;
import io.jmix.core.querycondition.LogicalCondition;
Expand Down Expand Up @@ -318,6 +319,9 @@ protected void updateApplyButtonText(boolean autoApply) {
}

protected void onApplyButtonClick(ClickEvent<MenuItem> clickEvent) {
// Same recomposition rule as apply(). Unlike apply(), the button always loads and keeps
// the current page.
recomposeLoaderConditionIfOutdated();
getDataLoader().load();
}

Expand Down Expand Up @@ -465,11 +469,36 @@ protected void updateCurrentConfigurationAutoApply(boolean autoApply) {
*/
public void apply() {
if (dataLoader != null) {
recomposeLoaderConditionIfOutdated();
setupLoaderFirstResult();
if (isAutoApply()) dataLoader.load();
}
}

/**
* Recomposes the data loader condition as "base AND the shown configuration" if the
* application has replaced the loader condition since the filter's last contribution
* (a new base condition); an untouched loader condition is left as is, so applications
* that never replace it see exactly the previous behavior. A configuration's root component
* receives this method as its recomposition delegate when the configuration is activated;
* nested components reach it through their owning group's chain, so their direct loads never
* use a replaced base alone.
*/
protected void recomposeLoaderConditionIfOutdated() {
if (isLoaderConditionOutdated()) {
updateDataLoaderCondition();
}
}

/**
* Returns whether the loader condition was replaced by the application since this filter
* composed it last, so the composition no longer contains the shown configuration.
*/
protected boolean isLoaderConditionOutdated() {
return dataLoader != null
&& BaseConditionSupport.isReplacedExternally(dataLoader.getCondition(), lastConditionSetByFilter);
}

protected void setupLoaderFirstResult() {
if (dataLoader instanceof BaseCollectionLoader) {
((BaseCollectionLoader) dataLoader).setFirstResult(0);
Expand Down Expand Up @@ -707,6 +736,15 @@ protected void refreshCurrentConfigurationLayout() {
}

LogicalFilterComponent<?> rootComponent = getCurrentConfiguration().getRootLogicalFilterComponent();

// The adoption point every configuration passes through on activation, whoever built its
// root - the filter's own factory, a configuration converter, or application code
// registering a hand-built configuration: from here on the root forwards recomposition
// requests to this filter.
if (rootComponent instanceof GroupFilter rootGroupFilter) {
rootGroupFilter.setLoaderConditionRecomposeDelegate(this::recomposeLoaderConditionIfOutdated);
}

boolean isAnyFilterComponentVisible = rootComponent.getFilterComponents().stream()
.anyMatch(filterComponent -> ((Component) filterComponent).isVisible());
if (isAnyFilterComponentVisible) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,7 @@ public RunTimeConfiguration buildAndRegister() {
"RunTimeConfigurationBuilder: the filter has no DataLoader; set it before building a configuration");
}

// Build the root GroupFilter — mirrors GenericFilter.createConfigurationRootLogicalFilterComponent()
GroupFilter root = uiComponents.create(GroupFilter.class);
root.setConditionModificationDelegated(true);
root.setOperation(operation);
root.setOperationTextVisible(false);
root.setAutoApply(filter.isAutoApply());
root.setDataLoader(filter.getDataLoader());
LogicalFilterComponent<?> root = filter.createConfigurationRootLogicalFilterComponent(operation);

RunTimeConfiguration config = new RunTimeConfiguration(id, root, filter);
config.setName(name);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ public class GroupFilter extends Composite<VerticalLayout>

@Internal
protected boolean conditionModificationDelegated = false;
protected Runnable loaderConditionRecomposeDelegate;

protected List<ResponsiveStep> responsiveSteps;
protected Div summaryComponent;
Expand Down Expand Up @@ -242,11 +243,66 @@ public void setAutoApply(boolean autoApply) {

@Override
public void apply() {
if (dataLoader != null && autoApply) {
dataLoader.load();
if (dataLoader != null) {
// Compose "base AND own conditions" before loading if the application replaced the
// loader condition since the last contribution: a standalone group recomposes itself,
// a delegated group asks its owner through the delegate the owner has set.
recomposeLoaderConditionIfOutdated();
if (autoApply) {
dataLoader.load();
}
Comment thread
KremnevDmitry marked this conversation as resolved.
}
}

/**
* Recomposes the loader condition if the application has replaced it since the last
* contribution; an untouched loader condition is left as is. A group with a recomposition
* delegate forwards the request to it instead of composing itself; child components of this
* group receive this method as their delegate, so a request from any nesting level reaches
* the outermost owner.
*/
protected void recomposeLoaderConditionIfOutdated() {
if (loaderConditionRecomposeDelegate != null) {
loaderConditionRecomposeDelegate.run();
} else if (!isConditionModificationDelegated() && isLoaderConditionOutdated()) {
updateDataLoaderCondition();
}
}

/**
* Sets the recomposition callback this group forwards to instead of composing the loader
* condition itself: for a configuration's root group the owning filter sets it at creation,
* for a nested group the owning group sets it on add and clears it on removal.
*
* @param loaderConditionRecomposeDelegate the owner's recomposition callback, or {@code null}
*/
@Internal
public void setLoaderConditionRecomposeDelegate(@Nullable Runnable loaderConditionRecomposeDelegate) {
this.loaderConditionRecomposeDelegate = loaderConditionRecomposeDelegate;
}

/**
* Sets or clears the recomposition delegate on a child component of this group, so the
* child's direct load can first let the group's chain recompose an outdated loader condition.
*/
protected void setLoaderConditionRecomposeDelegateOn(FilterComponent filterComponent,
@Nullable Runnable delegate) {
if (filterComponent instanceof SingleFilterComponentBase<?> singleFilterComponent) {
singleFilterComponent.setLoaderConditionRecomposeDelegate(delegate);
} else if (filterComponent instanceof GroupFilter groupFilter) {
groupFilter.setLoaderConditionRecomposeDelegate(delegate);
}
}

/**
* Returns whether the loader condition was replaced by the application since this group
* composed it last, so the composition no longer contains this group's conditions.
*/
protected boolean isLoaderConditionOutdated() {
return dataLoader != null
&& BaseConditionSupport.isReplacedExternally(dataLoader.getCondition(), lastConditionSetByFilter);
}

@Override
public LogicalCondition getQueryCondition() {
updateQueryCondition();
Expand All @@ -270,6 +326,7 @@ public void add(FilterComponent filterComponent) {

filterComponent.setConditionModificationDelegated(true);
filterComponent.setAutoApply(isAutoApply());
setLoaderConditionRecomposeDelegateOn(filterComponent, this::recomposeLoaderConditionIfOutdated);
getQueryCondition().add(filterComponent.getQueryCondition());

if (ownFilterComponentsOrder == null) {
Expand Down Expand Up @@ -320,6 +377,7 @@ public void remove(FilterComponent filterComponent) {
if (operationChangeRegistration != null) {
operationChangeRegistration.remove();
}
setLoaderConditionRecomposeDelegateOn(filterComponent, null);

FormLayout.FormItem formItem = null;
if (filterComponent instanceof SingleFilterComponent) {
Expand Down Expand Up @@ -350,6 +408,11 @@ public void remove(FilterComponent filterComponent) {

@Override
public void removeAll() {
if (ownFilterComponentsOrder != null) {
for (FilterComponent filterComponent : ownFilterComponentsOrder) {
setLoaderConditionRecomposeDelegateOn(filterComponent, null);
}
}
ownFilterComponentsOrder = null;

operationChangeRegistrations.values().forEach(Registration::remove);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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 facet.url_query_parameters.view.DataGridFilterUrlQueryParamsTestView
import io.jmix.flowui.component.grid.DataGridColumn
import io.jmix.flowui.component.grid.headerfilter.DataGridHeaderFilter
import io.jmix.flowui.model.CollectionLoader
import org.springframework.boot.test.context.SpringBootTest
import test_support.spec.FlowuiTestSpecification

/**
* Pins that {@code DataGridHeaderFilter} is not affected by the loader condition recomposition
* introduced for delegated filter conditions: its inner {@code PropertyFilter} is neither delegated
* nor given a recomposition delegate, and {@code DataGridHeaderFilter.apply()} performs exactly one
* load without replacing the loader condition object.
*/
@SpringBootTest
class DataGridHeaderFilterApplyTest extends FlowuiTestSpecification {

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

def "a header filter is outside the recomposition wiring and its apply() loads exactly once"() {
given: "a data grid with a filterable column"
def view = navigateToView(DataGridFilterUrlQueryParamsTestView)
def column = view.ownersTable.getColumnByKey("name") as DataGridColumn<?>
def headerFilter = column.getHeaderComponent() as DataGridHeaderFilter

expect: "the inner property filter is neither delegated nor wired to an owner"
!headerFilter.propertyFilter.conditionModificationDelegated
headerFilter.propertyFilter.loaderConditionRecomposeDelegate == null

when: "the user applies the header filter"
def loader = headerFilter.propertyFilter.dataLoader as CollectionLoader
def conditionBefore = loader.condition
int loads = 0
loader.addPostLoadListener { loads++ }
headerFilter.propertyFilter.setValue("John")
headerFilter.apply()

then: "exactly one load, and the loader condition object is not replaced by any recomposition"
loads == 1
loader.condition.is(conditionBefore)
}
}
Loading