parameterClass) {
+ checkNotNullArgument(parameterClass, "parameterClass must not be null");
+ //noinspection unchecked
+ return new JpqlFilterBuilder<>(filter, uiComponents, (Class>) parameterClass);
+ }
+
+ /**
+ * Returns a builder for a {@link GroupFilter} bound to the owning filter.
+ *
+ * @return a new {@link GroupFilterBuilder}
+ */
+ public GroupFilterBuilder groupFilter() {
+ return new GroupFilterBuilder(filter, uiComponents);
+ }
+
+ // -------------------------------------------------------------------------
+ // PropertyFilterBuilder
+ // -------------------------------------------------------------------------
+
+ /**
+ * Fluent builder for {@link PropertyFilter}.
+ *
+ * All mandatory initialisation is handled inside {@link #build()}: the correct call
+ * order ({@code setDataLoader → setProperty → setOperation → setValue}) is enforced
+ * internally, so the caller is free to set these in any order via the fluent methods.
+ *
+ * @param the value type
+ */
+ public static class PropertyFilterBuilder {
+
+ private final GenericFilter filter;
+ private final UiComponents uiComponents;
+
+ private String property;
+ private PropertyFilter.Operation operation;
+ private V defaultValue;
+ private String label;
+
+ PropertyFilterBuilder(GenericFilter filter, UiComponents uiComponents) {
+ this.filter = filter;
+ this.uiComponents = uiComponents;
+ }
+
+ /**
+ * Sets the entity property path (e.g. {@code "name"} or {@code "customer.city.name"}).
+ * Required.
+ */
+ public PropertyFilterBuilder property(String property) {
+ checkNotNullArgument(property, "property must not be null");
+ this.property = property;
+ return this;
+ }
+
+ /**
+ * Sets the filtering operation (e.g. {@link PropertyFilter.Operation#EQUAL}).
+ * Required.
+ */
+ public PropertyFilterBuilder operation(PropertyFilter.Operation operation) {
+ checkNotNullArgument(operation, "operation must not be null");
+ this.operation = operation;
+ return this;
+ }
+
+ /**
+ * Sets the default (pre-filled) value for the filter condition. Optional.
+ */
+ public PropertyFilterBuilder defaultValue(@Nullable V defaultValue) {
+ this.defaultValue = defaultValue;
+ return this;
+ }
+
+ /**
+ * Overrides the auto-generated label text. Optional.
+ */
+ public PropertyFilterBuilder label(@Nullable String label) {
+ this.label = label;
+ return this;
+ }
+
+ /**
+ * Builds and returns the configured {@link PropertyFilter}.
+ *
+ * Enforces the required call order:
+ *
+ * - {@code setConditionModificationDelegated(true)}
+ * - {@code setDataLoader(...)}
+ * - {@code setProperty(...)}
+ * - {@code setOperation(...)}
+ * - {@code setValue(...)} (only if a default value was provided)
+ *
+ *
+ * @throws IllegalStateException if {@code property} or {@code operation} was not set
+ */
+ @SuppressWarnings("unchecked")
+ public PropertyFilter build() {
+ if (property == null) {
+ throw new IllegalStateException(
+ "PropertyFilterBuilder: 'property' is required — call .property(\"...\") before .build()");
+ }
+ if (operation == null) {
+ throw new IllegalStateException(
+ "PropertyFilterBuilder: 'operation' is required — call .operation(...) before .build()");
+ }
+
+ PropertyFilter pf = uiComponents.create(PropertyFilter.class);
+ // 1. delegate condition modification — must be first
+ pf.setConditionModificationDelegated(true);
+ // 2. data loader before property so that initOperationSelectorActions runs correctly
+ if (filter.getDataLoader() != null) {
+ pf.setDataLoader(filter.getDataLoader());
+ }
+ // 3. property
+ pf.setProperty(property);
+ // 4. operation
+ pf.setOperation(operation);
+ // 5. optional overrides
+ if (label != null) {
+ pf.setLabel(label);
+ }
+ if (defaultValue != null) {
+ pf.setValue(defaultValue);
+ }
+ return pf;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // JpqlFilterBuilder
+ // -------------------------------------------------------------------------
+
+ /**
+ * Fluent builder for {@link JpqlFilter}.
+ *
+ * @param the value type ({@code Boolean} for void/checkbox filters, or the parameter type)
+ */
+ public static class JpqlFilterBuilder {
+
+ private final GenericFilter filter;
+ private final UiComponents uiComponents;
+ private final Class> parameterClass;
+
+ private String where;
+ private String join;
+ private String parameterName;
+ private V defaultValue;
+ private String label;
+ private boolean hasInExpression;
+
+ JpqlFilterBuilder(GenericFilter filter, UiComponents uiComponents, Class> parameterClass) {
+ this.filter = filter;
+ this.uiComponents = uiComponents;
+ this.parameterClass = parameterClass;
+ }
+
+ /**
+ * Sets the JPQL WHERE clause fragment (use {@code {E}} as the entity alias and
+ * {@code ?} as the parameter placeholder). Required.
+ */
+ public JpqlFilterBuilder where(String where) {
+ checkNotNullArgument(where, "where must not be null");
+ this.where = where;
+ return this;
+ }
+
+ /**
+ * Sets the optional JPQL JOIN clause fragment (e.g. {@code "join {E}.tags t"}).
+ */
+ public JpqlFilterBuilder join(@Nullable String join) {
+ this.join = join;
+ return this;
+ }
+
+ /**
+ * Sets the query parameter name. Optional for void filters; required when the
+ * parameter class is not {@code Void}.
+ */
+ public JpqlFilterBuilder parameterName(String parameterName) {
+ checkNotNullArgument(parameterName, "parameterName must not be null");
+ this.parameterName = parameterName;
+ return this;
+ }
+
+ /**
+ * Sets the default (pre-filled) value. Optional.
+ */
+ public JpqlFilterBuilder defaultValue(@Nullable V defaultValue) {
+ this.defaultValue = defaultValue;
+ return this;
+ }
+
+ /**
+ * Overrides the auto-generated label text. Optional.
+ */
+ public JpqlFilterBuilder label(@Nullable String label) {
+ this.label = label;
+ return this;
+ }
+
+ /**
+ * Marks the condition as having an IN expression (the value is a collection).
+ */
+ public JpqlFilterBuilder hasInExpression(boolean hasInExpression) {
+ this.hasInExpression = hasInExpression;
+ return this;
+ }
+
+ /**
+ * Builds and returns the configured {@link JpqlFilter}.
+ *
+ * Initialisation order enforced internally:
+ *
+ * - {@code setConditionModificationDelegated(true)}
+ * - {@code setDataLoader(...)}
+ * - {@code setParameterName(...)} (if provided)
+ * - {@code setParameterClass(...)}
+ * - {@code setCondition(where, join)}
+ * - {@code setValue(...)} (only if a default value was provided)
+ *
+ *
+ * @throws IllegalStateException if {@code where} was not set
+ */
+ @SuppressWarnings("unchecked")
+ public JpqlFilter build() {
+ if (where == null) {
+ throw new IllegalStateException(
+ "JpqlFilterBuilder: 'where' is required — call .where(\"...\") before .build()");
+ }
+
+ JpqlFilter jf = uiComponents.create(JpqlFilter.class);
+ // 1. delegate modification
+ jf.setConditionModificationDelegated(true);
+ // 2. data loader
+ if (filter.getDataLoader() != null) {
+ jf.setDataLoader(filter.getDataLoader());
+ }
+ // 3. parameterName before parameterClass (affects where substitution)
+ if (parameterName != null) {
+ jf.setParameterName(parameterName);
+ }
+ // 4. parameterClass — one-time setter, must come before setCondition
+ jf.setParameterClass((Class) parameterClass);
+ // 5. where / join
+ jf.setCondition(where, join);
+ // 6. optional
+ if (label != null) {
+ jf.setLabel(label);
+ }
+ if (hasInExpression) {
+ jf.setHasInExpression(true);
+ }
+ if (defaultValue != null) {
+ jf.setValue(defaultValue);
+ }
+ return jf;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // GroupFilterBuilder
+ // -------------------------------------------------------------------------
+
+ /**
+ * Fluent builder for {@link GroupFilter}.
+ */
+ public static class GroupFilterBuilder {
+
+ private final GenericFilter filter;
+ private final UiComponents uiComponents;
+
+ private LogicalFilterComponent.Operation operation = LogicalFilterComponent.Operation.AND;
+ private final List components = new ArrayList<>();
+
+ GroupFilterBuilder(GenericFilter filter, UiComponents uiComponents) {
+ this.filter = filter;
+ this.uiComponents = uiComponents;
+ }
+
+ /**
+ * Sets the logical operation ({@code AND} or {@code OR}). Defaults to {@code AND}.
+ */
+ public GroupFilterBuilder operation(LogicalFilterComponent.Operation operation) {
+ checkNotNullArgument(operation, "operation must not be null");
+ this.operation = operation;
+ return this;
+ }
+
+ /**
+ * Adds a filter component to this group.
+ */
+ public GroupFilterBuilder add(FilterComponent filterComponent) {
+ checkNotNullArgument(filterComponent, "filterComponent must not be null");
+ components.add(filterComponent);
+ return this;
+ }
+
+ /**
+ * Builds and returns the configured {@link GroupFilter}.
+ */
+ public GroupFilter build() {
+ GroupFilter gf = uiComponents.create(GroupFilter.class);
+ gf.setConditionModificationDelegated(true);
+ if (filter.getDataLoader() != null) {
+ gf.setDataLoader(filter.getDataLoader());
+ }
+ gf.setOperation(operation);
+ for (FilterComponent fc : components) {
+ gf.add(fc);
+ }
+ return gf;
+ }
+ }
+}
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/GenericFilter.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/GenericFilter.java
index c936f24c0c..7eafc5c39b 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/GenericFilter.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/GenericFilter.java
@@ -592,6 +592,68 @@ public void setCurrentConfiguration(Configuration currentConfiguration) {
setCurrentConfigurationInternal(currentConfiguration, false);
}
+ /**
+ * Registers the given configuration with this filter and immediately sets it as the current
+ * configuration, in the correct order and in a single call.
+ *
+ * This is a convenience alternative to calling {@link #addConfiguration(Configuration)}
+ * followed by {@link #setCurrentConfiguration(Configuration)}, which fails silently when
+ * the configuration is not yet registered at the time {@code setCurrentConfiguration} is
+ * invoked.
+ *
+ * @param configuration the configuration to register and activate
+ */
+ public void addAndSetCurrentConfiguration(Configuration configuration) {
+ addConfiguration(configuration);
+ setCurrentConfiguration(configuration);
+ }
+
+ /**
+ * Refreshes the layout of the current configuration.
+ *
+ * Call this method after programmatically modifying the current configuration's filter
+ * components (e.g. adding a component to the root {@link LogicalFilterComponent}) to force
+ * the filter UI to re-render remove buttons and update the data-loader condition.
+ *
+ * This is a stable public equivalent of the internal {@code refreshCurrentConfigurationLayout()}.
+ */
+ public void refreshCurrentConfiguration() {
+ refreshCurrentConfigurationLayout();
+ }
+
+ /**
+ * Creates a new {@link FilterComponentBuilder} bound to this filter.
+ *
+ * The builder handles all mandatory initialisation steps that the XML loader performs
+ * automatically: {@code setConditionModificationDelegated(true)}, {@code setDataLoader(...)},
+ * and the correct ordering of property / operation / value assignments.
+ *
+ * @return a new {@code FilterComponentBuilder} instance
+ */
+ public FilterComponentBuilder componentBuilder() {
+ return new FilterComponentBuilder(this, uiComponents);
+ }
+
+ /**
+ * Creates a new {@link DesignTimeConfigurationBuilder} for building and registering
+ * a {@link io.jmix.flowui.component.genericfilter.configuration.DesignTimeConfiguration}.
+ *
+ * @return a new {@code DesignTimeConfigurationBuilder} instance
+ */
+ public DesignTimeConfigurationBuilder configurationBuilder() {
+ return new DesignTimeConfigurationBuilder(this, uiComponents);
+ }
+
+ /**
+ * Creates a new {@link RunTimeConfigurationBuilder} for building and registering
+ * a {@link io.jmix.flowui.component.genericfilter.configuration.RunTimeConfiguration}.
+ *
+ * @return a new {@code RunTimeConfigurationBuilder} instance
+ */
+ public RunTimeConfigurationBuilder runtimeConfigurationBuilder() {
+ return new RunTimeConfigurationBuilder(this, uiComponents);
+ }
+
protected void setCurrentConfigurationInternal(Configuration currentConfiguration, boolean fromClient) {
if (configurations.contains(currentConfiguration)
|| getEmptyConfiguration().equals(currentConfiguration)) {
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/MutableConfiguration.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/MutableConfiguration.java
new file mode 100644
index 0000000000..3b2cfaaf75
--- /dev/null
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/MutableConfiguration.java
@@ -0,0 +1,111 @@
+/*
+ * Copyright 2024 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.component.genericfilter;
+
+import io.jmix.flowui.component.filter.FilterComponent;
+import io.jmix.flowui.component.genericfilter.configuration.DesignTimeConfiguration;
+import io.jmix.flowui.component.genericfilter.configuration.RunTimeConfiguration;
+import io.jmix.flowui.component.logicalfilter.LogicalFilterComponent;
+import org.springframework.lang.Nullable;
+
+/**
+ * A mutable extension of {@link Configuration} that supports all modifier operations.
+ *
+ * {@link RunTimeConfiguration} implements this interface; {@link DesignTimeConfiguration}
+ * only implements the read-only {@link Configuration} and throws
+ * {@link UnsupportedOperationException} for methods declared here.
+ *
+ * Use this interface as the declared type when you need to call mutating methods on a
+ * configuration, so the compiler can catch misuse of {@link DesignTimeConfiguration} at
+ * compile time instead of at runtime:
+ *
{@code
+ * MutableConfiguration config = filter.runtimeConfigurationBuilder()
+ * .id("myConfig")
+ * .buildAndRegister();
+ * config.setModified(true); // safe — compiler guarantees this is RunTimeConfiguration
+ * }
+ *
+ * @see Configuration
+ * @see RunTimeConfiguration
+ */
+public interface MutableConfiguration extends Configuration {
+
+ /**
+ * Sets the name of configuration.
+ *
+ * @param name a configuration name
+ */
+ void setName(@Nullable String name);
+
+ /**
+ * Sets the root logical filter component of configuration.
+ *
+ * @param rootLogicalFilterComponent a root element of configuration
+ */
+ void setRootLogicalFilterComponent(LogicalFilterComponent> rootLogicalFilterComponent);
+
+ /**
+ * Sets whether the configuration is modified.
+ *
+ * If a filter component is modified, a remove button appears next to it.
+ *
+ * Note: this method iterates over components that are already added to the root
+ * at the time of the call. For automatic tracking of added/removed components, see
+ * {@link RunTimeConfiguration} which subscribes to
+ * {@link LogicalFilterComponent.FilterComponentsChangeEvent} from its root component.
+ *
+ * @param modified whether configuration is modified
+ */
+ void setModified(boolean modified);
+
+ /**
+ * Sets whether the given filter component of configuration is modified.
+ * If a filter component is modified, a remove button appears next to it.
+ *
+ * @param filterComponent a filter component
+ * @param modified whether the filter component of configuration is modified
+ */
+ void setFilterComponentModified(FilterComponent filterComponent, boolean modified);
+
+ /**
+ * Sets a default value of the given filter component for the configuration.
+ * This allows the default values to be saved and displayed in the configuration editor.
+ *
+ * @param parameterName a parameter name of filter component
+ * @param defaultValue a default value
+ */
+ void setFilterComponentDefaultValue(String parameterName, @Nullable Object defaultValue);
+
+ /**
+ * Resets the default value of the filter component identified by the parameter name.
+ *
+ * @param parameterName a parameter name of filter component
+ */
+ void resetFilterComponentDefaultValue(String parameterName);
+
+ /**
+ * Sets null as the default value for all configuration filter components.
+ */
+ void resetAllDefaultValues();
+
+ /**
+ * Sets whether the configuration is available for all users.
+ *
+ * @param availableForAllUsers whether the configuration is available for all users
+ */
+ void setAvailableForAllUsers(boolean availableForAllUsers);
+}
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java
new file mode 100644
index 0000000000..4115ee7390
--- /dev/null
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java
@@ -0,0 +1,245 @@
+/*
+ * Copyright 2024 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.component.genericfilter;
+
+import io.jmix.flowui.UiComponents;
+import io.jmix.flowui.component.filter.FilterComponent;
+import io.jmix.flowui.component.filter.SingleFilterComponentBase;
+import io.jmix.flowui.component.genericfilter.configuration.RunTimeConfiguration;
+import io.jmix.flowui.component.logicalfilter.GroupFilter;
+import io.jmix.flowui.component.logicalfilter.LogicalFilterComponent;
+import org.springframework.lang.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static io.jmix.core.common.util.Preconditions.checkNotNullArgument;
+
+/**
+ * Fluent builder for {@link RunTimeConfiguration}.
+ *
+ * Use this builder when you need a dynamic configuration whose filter components
+ * can be added or removed at runtime (e.g. via the per-condition remove button).
+ *
+ * Encapsulates all required steps:
+ *
+ * - Creating and configuring the root {@link GroupFilter}
+ * - Adding filter components and recording their default values in the configuration
+ * - Registering the configuration via {@link GenericFilter#addConfiguration(Configuration)}
+ * - Optionally marking all components as modified (so remove buttons appear immediately)
+ * - Optionally activating the configuration via
+ * {@link GenericFilter#setCurrentConfiguration(Configuration)}
+ *
+ *
+ * Note: added components are automatically marked as modified by
+ * {@link RunTimeConfiguration}'s internal change listener, so an explicit call to
+ * {@link #markAllModified()} is usually not necessary unless components were added before
+ * the listener was registered.
+ *
+ * Obtain an instance via {@link GenericFilter#runtimeConfigurationBuilder()}:
+ *
{@code
+ * filter.runtimeConfigurationBuilder()
+ * .id("dynamicSearch")
+ * .name("Dynamic Search")
+ * .add(nameFilter)
+ * .add(statusFilter, "NEW")
+ * .asDefault()
+ * .buildAndRegister();
+ * }
+ */
+public class RunTimeConfigurationBuilder {
+
+ protected final GenericFilter filter;
+ protected final UiComponents uiComponents;
+
+ protected String id;
+ protected String name;
+ protected LogicalFilterComponent.Operation operation = LogicalFilterComponent.Operation.AND;
+ protected boolean makeDefault = false;
+ protected boolean markModified = false;
+
+ protected final List entries = new ArrayList<>();
+
+ RunTimeConfigurationBuilder(GenericFilter filter, UiComponents uiComponents) {
+ this.filter = filter;
+ this.uiComponents = uiComponents;
+ }
+
+ /**
+ * Sets the configuration id. Required.
+ *
+ * @param id unique configuration identifier within this filter
+ */
+ public RunTimeConfigurationBuilder id(String id) {
+ checkNotNullArgument(id, "id must not be null");
+ this.id = id;
+ return this;
+ }
+
+ /**
+ * Sets the configuration display name.
+ *
+ * @param name display name shown in the configuration selector
+ */
+ public RunTimeConfigurationBuilder name(@Nullable String name) {
+ this.name = name;
+ return this;
+ }
+
+ /**
+ * Sets the logical operation of the root filter component. Defaults to {@code AND}.
+ *
+ * @param operation logical operation
+ */
+ public RunTimeConfigurationBuilder operation(LogicalFilterComponent.Operation operation) {
+ checkNotNullArgument(operation, "operation must not be null");
+ this.operation = operation;
+ return this;
+ }
+
+ /**
+ * Adds a filter component using the component's current value (if any) as the default.
+ *
+ * @param filterComponent filter component to add
+ */
+ public RunTimeConfigurationBuilder add(FilterComponent filterComponent) {
+ checkNotNullArgument(filterComponent, "filterComponent must not be null");
+ entries.add(new ComponentEntry(filterComponent, null, false));
+ return this;
+ }
+
+ /**
+ * Adds a filter component, overriding its default value.
+ *
+ * @param filterComponent filter component to add
+ * @param defaultValue value to apply and record as the configuration default
+ */
+ public RunTimeConfigurationBuilder add(FilterComponent filterComponent, @Nullable Object defaultValue) {
+ checkNotNullArgument(filterComponent, "filterComponent must not be null");
+ entries.add(new ComponentEntry(filterComponent, defaultValue, true));
+ return this;
+ }
+
+ /**
+ * Requests that all added filter components be explicitly marked as modified
+ * (making remove buttons visible) after the configuration is built.
+ *
+ * Since {@link RunTimeConfiguration} already auto-tracks modifications via its internal
+ * change listener, this call is optional. It is provided as an explicit opt-in for
+ * scenarios where the caller wants to be certain irrespective of listener timing.
+ */
+ public RunTimeConfigurationBuilder markAllModified() {
+ this.markModified = true;
+ return this;
+ }
+
+ /**
+ * Marks this configuration as the default (currently active) configuration.
+ */
+ public RunTimeConfigurationBuilder asDefault() {
+ this.makeDefault = true;
+ return this;
+ }
+
+ /**
+ * Builds the {@link RunTimeConfiguration}, registers it with the filter, and
+ * optionally activates it.
+ *
+ * Automatically:
+ *
+ * - Creates the root {@link GroupFilter} with {@code setConditionModificationDelegated(true)}
+ * and {@code setDataLoader(filter.getDataLoader())}
+ * - Adds each filter component to the root (triggering the auto-tracking listener)
+ * - Calls {@code setFilterComponentDefaultValue} for every component with a value
+ * - Calls {@link GenericFilter#addAndSetCurrentConfiguration(Configuration)} if
+ * {@link #asDefault()} was requested, or {@link GenericFilter#addConfiguration(Configuration)}
+ * otherwise
+ *
+ *
+ * @return the newly created and registered {@link RunTimeConfiguration}
+ * @throws IllegalStateException if {@code id} was not set
+ */
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ public RunTimeConfiguration buildAndRegister() {
+ if (id == null) {
+ throw new IllegalStateException(
+ "RunTimeConfigurationBuilder: 'id' is required — call .id(\"...\") before .buildAndRegister()");
+ }
+
+ // Build the root GroupFilter — mirrors GenericFilter.createConfigurationRootLogicalFilterComponent()
+ GroupFilter root = uiComponents.create(GroupFilter.class);
+ root.setConditionModificationDelegated(true);
+ root.setOperation(operation);
+ root.setOperationTextVisible(false);
+ if (filter.getDataLoader() != null) {
+ root.setDataLoader(filter.getDataLoader());
+ root.setAutoApply(filter.isAutoApply());
+ }
+
+ RunTimeConfiguration config = new RunTimeConfiguration(id, root, filter);
+ config.setName(name);
+
+ for (ComponentEntry entry : entries) {
+ FilterComponent fc = entry.filterComponent;
+
+ if (entry.overrideDefault && fc instanceof SingleFilterComponentBase> sfc) {
+ ((SingleFilterComponentBase) sfc).setValue(entry.defaultValue);
+ }
+
+ // Adding to root triggers the auto-tracking listener in RunTimeConfiguration,
+ // which marks the component as modified automatically.
+ root.add(fc);
+
+ // Persist the default value for reset/restore behaviour
+ if (fc instanceof SingleFilterComponentBase> sfc) {
+ Object valueToStore = entry.overrideDefault
+ ? entry.defaultValue
+ : sfc.getValue();
+ if (valueToStore != null) {
+ config.setFilterComponentDefaultValue(sfc.getParameterName(), valueToStore);
+ }
+ }
+ }
+
+ // Explicit markAllModified — belt-and-suspenders approach
+ if (markModified) {
+ config.setModified(true);
+ }
+
+ if (makeDefault) {
+ filter.addAndSetCurrentConfiguration(config);
+ } else {
+ filter.addConfiguration(config);
+ }
+
+ return config;
+ }
+
+ // -------------------------------------------------------------------------
+
+ protected static class ComponentEntry {
+ final FilterComponent filterComponent;
+ final Object defaultValue;
+ final boolean overrideDefault;
+
+ ComponentEntry(FilterComponent filterComponent, @Nullable Object defaultValue, boolean overrideDefault) {
+ this.filterComponent = filterComponent;
+ this.defaultValue = defaultValue;
+ this.overrideDefault = overrideDefault;
+ }
+ }
+}
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/configuration/RunTimeConfiguration.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/configuration/RunTimeConfiguration.java
index 52132b8e45..98a5d0e09b 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/configuration/RunTimeConfiguration.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/configuration/RunTimeConfiguration.java
@@ -22,6 +22,7 @@
import io.jmix.flowui.component.filter.SingleFilterComponentBase;
import io.jmix.flowui.component.genericfilter.Configuration;
import io.jmix.flowui.component.genericfilter.GenericFilter;
+import io.jmix.flowui.component.genericfilter.MutableConfiguration;
import io.jmix.flowui.component.logicalfilter.LogicalFilterComponent;
import org.springframework.lang.Nullable;
@@ -30,7 +31,22 @@
import java.util.Map;
import java.util.Set;
-public class RunTimeConfiguration implements Configuration {
+/**
+ * A runtime (user-created) filter configuration that supports dynamic modification of
+ * its filter components.
+ *
+ * Implements {@link MutableConfiguration} — use that interface as the declared type when
+ * you need compile-time safety against calling mutating methods on a read-only
+ * {@link DesignTimeConfiguration}.
+ *
+ * Auto-tracking of modified state: this implementation subscribes to
+ * {@link LogicalFilterComponent.FilterComponentsChangeEvent} from its root component.
+ * Every filter component added to the root after construction is automatically marked as
+ * modified (so the per-condition remove button appears). Components removed from the root
+ * are automatically unmarked. Explicit calls to {@link #setModified(boolean)} and
+ * {@link #setFilterComponentModified(FilterComponent, boolean)} still work as before.
+ */
+public class RunTimeConfiguration implements MutableConfiguration {
protected final String id;
protected final GenericFilter owner;
@@ -41,10 +57,23 @@ public class RunTimeConfiguration implements Configuration {
protected Set modifiedFilterComponents = new HashSet<>();
protected Map defaultValuesMap = new HashMap<>();
+ /**
+ * Tracks the set of direct children of the root component as seen at the last
+ * {@link LogicalFilterComponent.FilterComponentsChangeEvent}. Used to detect which
+ * components were added or removed so their modified state can be updated automatically.
+ */
+ protected Set trackedComponents = new HashSet<>();
+
public RunTimeConfiguration(String id, LogicalFilterComponent> rootLogicalFilterComponent, GenericFilter owner) {
this.id = id;
this.rootLogicalFilterComponent = rootLogicalFilterComponent;
this.owner = owner;
+ // Snapshot the current direct children so that only components added *after*
+ // construction are auto-marked as modified.
+ this.trackedComponents = new HashSet<>(rootLogicalFilterComponent.getOwnFilterComponents());
+ // Subscribe to changes in the root component's direct children.
+ rootLogicalFilterComponent.addFilterComponentsChangeListener(
+ event -> syncModifiedStateFromRoot());
}
@Override
@@ -96,6 +125,37 @@ public void setModified(boolean modified) {
}
}
+ /**
+ * Synchronises the modified state with the current direct children of the root.
+ * Called automatically when a {@link LogicalFilterComponent.FilterComponentsChangeEvent}
+ * fires on the root component.
+ *
+ * - Components that appeared since the last snapshot → marked as modified
+ * (so the remove button becomes visible).
+ * - Components that disappeared → removed from the modified set.
+ *
+ */
+ protected void syncModifiedStateFromRoot() {
+ Set currentComponents =
+ new HashSet<>(rootLogicalFilterComponent.getOwnFilterComponents());
+
+ // Newly added components → mark as modified (recursively for nested groups).
+ for (FilterComponent fc : currentComponents) {
+ if (!trackedComponents.contains(fc)) {
+ setFilterComponentModified(fc, true);
+ }
+ }
+
+ // Removed components → unmark (recursively for nested groups).
+ for (FilterComponent fc : trackedComponents) {
+ if (!currentComponents.contains(fc)) {
+ setFilterComponentModified(fc, false);
+ }
+ }
+
+ trackedComponents = currentComponents;
+ }
+
@Override
public boolean isFilterComponentModified(FilterComponent filterComponent) {
return modifiedFilterComponents.contains(filterComponent);
From edd7008338cbc26e08713936d037cb2af448e1e9 Mon Sep 17 00:00:00 2001
From: aleksandrovpv
Date: Fri, 20 Feb 2026 23:54:05 +0400
Subject: [PATCH 2/5] Additional check
---
.../DesignTimeConfigurationBuilder.java | 12 ++++++------
.../genericfilter/RunTimeConfigurationBuilder.java | 12 ++++++------
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/DesignTimeConfigurationBuilder.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/DesignTimeConfigurationBuilder.java
index c131096a1d..e85abb1cf7 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/DesignTimeConfigurationBuilder.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/DesignTimeConfigurationBuilder.java
@@ -179,13 +179,13 @@ public DesignTimeConfiguration buildAndRegister() {
root.add(fc);
- // Persist the default value in the configuration so it survives reset
+ // Persist the default value in the configuration so it survives reset.
+ // Skip components without a parameter name (e.g. void JpqlFilter with Void parameterClass).
if (fc instanceof SingleFilterComponentBase> sfc) {
- Object valueToStore = entry.overrideDefault
- ? entry.defaultValue
- : sfc.getValue();
- if (valueToStore != null) {
- config.setFilterComponentDefaultValue(sfc.getParameterName(), valueToStore);
+ String paramName = sfc.getParameterName();
+ Object valueToStore = entry.overrideDefault ? entry.defaultValue : sfc.getValue();
+ if (paramName != null && valueToStore != null) {
+ config.setFilterComponentDefaultValue(paramName, valueToStore);
}
}
}
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java
index 4115ee7390..0f91a80251 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java
@@ -204,13 +204,13 @@ public RunTimeConfiguration buildAndRegister() {
// which marks the component as modified automatically.
root.add(fc);
- // Persist the default value for reset/restore behaviour
+ // Persist the default value for reset/restore behaviour.
+ // Skip components without a parameter name (e.g. void JpqlFilter with Void parameterClass).
if (fc instanceof SingleFilterComponentBase> sfc) {
- Object valueToStore = entry.overrideDefault
- ? entry.defaultValue
- : sfc.getValue();
- if (valueToStore != null) {
- config.setFilterComponentDefaultValue(sfc.getParameterName(), valueToStore);
+ String paramName = sfc.getParameterName();
+ Object valueToStore = entry.overrideDefault ? entry.defaultValue : sfc.getValue();
+ if (paramName != null && valueToStore != null) {
+ config.setFilterComponentDefaultValue(paramName, valueToStore);
}
}
}
From 630d48b8940ef91ad7e76f39d6a4be8effaaa6fb Mon Sep 17 00:00:00 2001
From: aleksandrovpv
Date: Sat, 21 Feb 2026 15:24:35 +0400
Subject: [PATCH 3/5] Add tests documenting GenericFilter builder API; guard
setValue in builders
- Add GenericFilterBuilderApiTest: 24 narrow integration tests (Spock/Spring)
that serve as executable documentation for FilterComponentBuilder,
DesignTimeConfigurationBuilder, RunTimeConfigurationBuilder,
RunTimeConfiguration auto-tracking, and the GenericFilter helper methods
(addAndSetCurrentConfiguration, refreshCurrentConfiguration,
setCurrentConfiguration silent-ignore behaviour).
Class-level Javadoc explains why isolated unit tests are not practical
for these classes (Spring/Vaadin bootstrapping requirements).
- Guard setValue() in DesignTimeConfigurationBuilder and
RunTimeConfigurationBuilder with a best-effort try-catch: the component's
value may not be fully initialised when no DataLoader is assigned (tests,
lazy initialisation). The configuration default value is always persisted
via setFilterComponentDefaultValue regardless of whether setValue succeeds.
Co-Authored-By: Claude Sonnet 4.6
---
.../DesignTimeConfigurationBuilder.java | 11 +-
.../RunTimeConfigurationBuilder.java | 9 +-
.../GenericFilterBuilderApiTest.groovy | 612 ++++++++++++++++++
3 files changed, 629 insertions(+), 3 deletions(-)
create mode 100644 jmix-flowui/flowui/src/test/groovy/component/genericfilter/GenericFilterBuilderApiTest.groovy
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/DesignTimeConfigurationBuilder.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/DesignTimeConfigurationBuilder.java
index e85abb1cf7..3bdf1615cf 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/DesignTimeConfigurationBuilder.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/DesignTimeConfigurationBuilder.java
@@ -171,9 +171,16 @@ public DesignTimeConfiguration buildAndRegister() {
FilterComponent fc = entry.filterComponent;
if (entry.overrideDefault) {
- // Apply the explicit default value to the component
+ // Apply the explicit default value to the component.
+ // Best-effort: the value component may not be ready yet when no DataLoader
+ // is assigned (e.g. in tests or lazy initialisation scenarios).
+ // The default value is still persisted in the configuration below.
if (fc instanceof SingleFilterComponentBase> sfc) {
- ((SingleFilterComponentBase) sfc).setValue(entry.defaultValue);
+ try {
+ ((SingleFilterComponentBase) sfc).setValue(entry.defaultValue);
+ } catch (RuntimeException ignored) {
+ // component not fully initialised; default stored in config below
+ }
}
}
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java
index 0f91a80251..87682a278d 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java
@@ -197,7 +197,14 @@ public RunTimeConfiguration buildAndRegister() {
FilterComponent fc = entry.filterComponent;
if (entry.overrideDefault && fc instanceof SingleFilterComponentBase> sfc) {
- ((SingleFilterComponentBase) sfc).setValue(entry.defaultValue);
+ // Best-effort: the value component may not be ready yet when no DataLoader
+ // is assigned (e.g. in tests or lazy initialisation scenarios).
+ // The default value is still persisted in the configuration below.
+ try {
+ ((SingleFilterComponentBase) sfc).setValue(entry.defaultValue);
+ } catch (RuntimeException ignored) {
+ // component not fully initialised; default stored in config below
+ }
}
// Adding to root triggers the auto-tracking listener in RunTimeConfiguration,
diff --git a/jmix-flowui/flowui/src/test/groovy/component/genericfilter/GenericFilterBuilderApiTest.groovy b/jmix-flowui/flowui/src/test/groovy/component/genericfilter/GenericFilterBuilderApiTest.groovy
new file mode 100644
index 0000000000..1cee68391b
--- /dev/null
+++ b/jmix-flowui/flowui/src/test/groovy/component/genericfilter/GenericFilterBuilderApiTest.groovy
@@ -0,0 +1,612 @@
+/*
+ * Copyright 2024 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 io.jmix.flowui.UiComponents
+import io.jmix.flowui.component.genericfilter.GenericFilter
+import io.jmix.flowui.component.genericfilter.configuration.DesignTimeConfiguration
+import io.jmix.flowui.component.genericfilter.configuration.RunTimeConfiguration
+import io.jmix.flowui.component.jpqlfilter.JpqlFilter
+import io.jmix.flowui.component.logicalfilter.GroupFilter
+import io.jmix.flowui.component.logicalfilter.LogicalFilterComponent
+import io.jmix.flowui.component.propertyfilter.PropertyFilter
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.boot.test.context.SpringBootTest
+import test_support.spec.FlowuiTestSpecification
+
+/**
+ * Documents and verifies the programmatic API for GenericFilter builder classes.
+ *
+ * These tests are intentionally written as usage examples so that reading them
+ * gives a developer a quick and accurate picture of how to configure a
+ * {@link GenericFilter} in Java/Groovy without relying on XML.
+ *
+ *
Test classification: narrow integration tests
+ * Although individual test methods are small and focused (resembling unit tests),
+ * this class is technically an integration test suite:
+ *
+ * - {@code @SpringBootTest} brings up a full Spring application context
+ * ({@link io.jmix.flowui.FlowuiConfiguration}, EclipseLink, Data, Core, …).
+ * - {@link test_support.spec.FlowuiTestSpecification#setup()} initialises a real
+ * Vaadin {@code UI} and {@code VaadinSession} for every test method.
+ * - All Spring beans ({@link io.jmix.flowui.UiComponents}, {@code Messages},
+ * {@code Metadata}, …) are the real production implementations — no mocks.
+ *
+ * Isolated unit tests for the builder classes are not practical: creating a
+ * {@link GenericFilter} via {@code uiComponents.create()} triggers
+ * {@code afterPropertiesSet()} which depends on several Spring beans being present.
+ * Mocking that dependency graph would require more effort than the tests themselves
+ * and would test the mock rather than the real behaviour.
+ *
+ *
What is not covered here
+ * All tests use a {@link GenericFilter} created without a DataLoader.
+ * This is sufficient to verify builder wiring, configuration registration, and the
+ * modified-state auto-tracking introduced in {@link RunTimeConfiguration}.
+ * Scenarios that require a DataLoader (e.g. full {@link PropertyFilter} value
+ * binding, query execution) are covered by the XML-load integration tests in
+ * {@code GenericFilterXmlLoadTest}.
+ */
+@SpringBootTest
+class GenericFilterBuilderApiTest extends FlowuiTestSpecification {
+
+ @Autowired
+ UiComponents uiComponents
+
+ // =========================================================================
+ // FilterComponentBuilder — PropertyFilter
+ // =========================================================================
+
+ /**
+ * Demonstrates building a {@link PropertyFilter} with {@code filter.componentBuilder()}.
+ * The builder takes care of the mandatory initialisation order that the XML loader
+ * performs automatically:
+ *
+ * - {@code setConditionModificationDelegated(true)}
+ * - {@code setDataLoader(…)} (skipped when the filter has no loader)
+ * - {@code setProperty(…)}
+ * - {@code setOperation(…)}
+ *
+ */
+ def "PropertyFilterBuilder creates PropertyFilter with correct property and operation"() {
+ given: "A GenericFilter (DataLoader not required for this assertion)"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when: "Building a PropertyFilter via the builder"
+ def nameFilter = filter.componentBuilder()
+ .propertyFilter()
+ .property("name")
+ .operation(PropertyFilter.Operation.CONTAINS)
+ .build() as PropertyFilter
+
+ then: "PropertyFilter has the expected property, operation, and delegated flag"
+ nameFilter.property == "name"
+ nameFilter.operation == PropertyFilter.Operation.CONTAINS
+ nameFilter.conditionModificationDelegated
+ }
+
+ def "PropertyFilterBuilder.build() throws IllegalStateException when 'property' is not set"() {
+ given:
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when:
+ filter.componentBuilder()
+ .propertyFilter()
+ .operation(PropertyFilter.Operation.EQUAL)
+ .build()
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ def "PropertyFilterBuilder.build() throws IllegalStateException when 'operation' is not set"() {
+ given:
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when:
+ filter.componentBuilder()
+ .propertyFilter()
+ .property("name")
+ .build()
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ // =========================================================================
+ // FilterComponentBuilder — JpqlFilter
+ // =========================================================================
+
+ /**
+ * A void {@link JpqlFilter} has no query parameter — it is rendered
+ * as a checkbox. Use {@code filter.componentBuilder().jpqlFilter()} (no class
+ * argument) to obtain this variant.
+ */
+ def "JpqlFilterBuilder creates a void JpqlFilter rendered as a checkbox"() {
+ given: "A GenericFilter"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when: "Building a void JpqlFilter (no query parameter)"
+ JpqlFilter activeFilter = filter.componentBuilder()
+ .jpqlFilter()
+ .where("{E}.status = 'ACTIVE'")
+ .label("Active only")
+ .build()
+
+ then: "parameterClass is Void and the where clause is stored"
+ activeFilter.parameterClass == Void.class
+ activeFilter.where == "{E}.status = 'ACTIVE'"
+ activeFilter.conditionModificationDelegated
+ activeFilter.dataLoader == null
+ }
+
+ /**
+ * A typed {@link JpqlFilter} takes a query parameter whose type is
+ * specified via {@code jpqlFilter(Class)}. A parameter name must also be given
+ * so the generated JPQL condition can use a named bind parameter
+ * ({@code :paramName}).
+ */
+ def "JpqlFilterBuilder creates a typed JpqlFilter with a named query parameter"() {
+ given: "A GenericFilter"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when: "Building a typed JpqlFilter with a String parameter"
+ JpqlFilter codeFilter = filter.componentBuilder()
+ .jpqlFilter(String)
+ .parameterName("code")
+ .where("{E}.code = ?")
+ .build()
+
+ then: "JpqlFilter has the correct parameter class and name"
+ codeFilter.parameterClass == String.class
+ codeFilter.parameterName == "code"
+ codeFilter.conditionModificationDelegated
+ }
+
+ def "JpqlFilterBuilder.build() throws IllegalStateException when 'where' is not set"() {
+ given:
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when:
+ filter.componentBuilder()
+ .jpqlFilter()
+ .build()
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ // =========================================================================
+ // FilterComponentBuilder — GroupFilter
+ // =========================================================================
+
+ /**
+ * {@link GroupFilter} bundles several conditions under a single logical operator
+ * (AND or OR). Use {@code filter.componentBuilder().groupFilter()} to create one.
+ */
+ def "GroupFilterBuilder creates a GroupFilter with specified operation and child components"() {
+ given: "A GenericFilter"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+ def builder = filter.componentBuilder()
+
+ and: "Two void JpqlFilter conditions"
+ def activeFilter = builder.jpqlFilter().where("{E}.status = 'ACTIVE'").build()
+ def verifiedFilter = builder.jpqlFilter().where("{E}.verified = true").build()
+
+ when: "Building an OR group that contains both conditions"
+ GroupFilter group = builder.groupFilter()
+ .operation(LogicalFilterComponent.Operation.OR)
+ .add(activeFilter)
+ .add(verifiedFilter)
+ .build()
+
+ then: "GroupFilter has the expected operation and children"
+ group.operation == LogicalFilterComponent.Operation.OR
+ group.filterComponents.size() == 2
+ group.filterComponents.contains(activeFilter)
+ group.filterComponents.contains(verifiedFilter)
+ group.conditionModificationDelegated
+ }
+
+ def "GroupFilterBuilder defaults to AND when no operation is specified"() {
+ given:
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when:
+ GroupFilter group = filter.componentBuilder()
+ .groupFilter()
+ .build()
+
+ then:
+ group.operation == LogicalFilterComponent.Operation.AND
+ }
+
+ // =========================================================================
+ // DesignTimeConfigurationBuilder
+ // =========================================================================
+
+ /**
+ * {@link io.jmix.flowui.component.genericfilter.DesignTimeConfigurationBuilder}
+ * creates a {@link DesignTimeConfiguration} — a fixed, developer-defined
+ * configuration that is not user-editable at runtime.
+ *
+ * Obtain one with {@code filter.configurationBuilder()}.
+ */
+ def "DesignTimeConfigurationBuilder registers a configuration with the filter"() {
+ given: "A GenericFilter"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ and: "A void JpqlFilter condition"
+ JpqlFilter activeFilter = filter.componentBuilder()
+ .jpqlFilter()
+ .where("{E}.status = 'ACTIVE'")
+ .build()
+
+ when: "Creating and registering a DesignTimeConfiguration"
+ DesignTimeConfiguration config = filter.configurationBuilder()
+ .id("byStatus")
+ .name("By Status")
+ .add(activeFilter)
+ .buildAndRegister()
+
+ then: "Configuration is registered and contains the added condition"
+ filter.configurations.any { it.id == "byStatus" }
+ config.id == "byStatus"
+ config.name == "By Status"
+ config.rootLogicalFilterComponent.filterComponents.contains(activeFilter)
+ }
+
+ /**
+ * Calling {@code .asDefault()} makes the newly created configuration the
+ * current (active) configuration of the filter immediately after
+ * {@code buildAndRegister()}.
+ */
+ def "DesignTimeConfigurationBuilder.asDefault() activates the configuration"() {
+ given: "A GenericFilter"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ and: "A JpqlFilter condition"
+ def activeFilter = filter.componentBuilder()
+ .jpqlFilter()
+ .where("{E}.status = 'ACTIVE'")
+ .build()
+
+ when: "Creating a configuration and marking it as default"
+ DesignTimeConfiguration config = filter.configurationBuilder()
+ .id("main")
+ .add(activeFilter)
+ .asDefault()
+ .buildAndRegister()
+
+ then: "The configuration becomes the filter's current configuration"
+ filter.currentConfiguration == config
+ }
+
+ /**
+ * When a filter component is added with an explicit default value
+ * ({@code .add(component, value)}), that value is stored in the configuration
+ * so it can be restored when the user resets filters.
+ *
+ * Note: setting the value on the component itself is best-effort — it may be
+ * skipped when the filter has no DataLoader and the value UI component has not
+ * yet been initialised. The stored configuration default is always reliable.
+ */
+ def "DesignTimeConfigurationBuilder stores the default value for reset support"() {
+ given: "A GenericFilter and a typed JpqlFilter"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+ JpqlFilter codeFilter = filter.componentBuilder()
+ .jpqlFilter(String)
+ .parameterName("code")
+ .where("{E}.code = ?")
+ .build()
+
+ when: "Registering the filter component with an explicit default value"
+ DesignTimeConfiguration config = filter.configurationBuilder()
+ .id("byCode")
+ .add(codeFilter, "DEFAULT")
+ .buildAndRegister()
+
+ then: "The default value is retrievable from the configuration"
+ config.getFilterComponentDefaultValue("code") == "DEFAULT"
+ }
+
+ def "DesignTimeConfigurationBuilder.buildAndRegister() throws when 'id' is not set"() {
+ given:
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when:
+ filter.configurationBuilder()
+ .name("no id")
+ .buildAndRegister()
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ def "DesignTimeConfigurationBuilder respects the specified logical operation"() {
+ given:
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when:
+ DesignTimeConfiguration config = filter.configurationBuilder()
+ .id("orConfig")
+ .operation(LogicalFilterComponent.Operation.OR)
+ .buildAndRegister()
+
+ then:
+ config.rootLogicalFilterComponent.operation == LogicalFilterComponent.Operation.OR
+ }
+
+ // =========================================================================
+ // RunTimeConfigurationBuilder
+ // =========================================================================
+
+ /**
+ * {@link io.jmix.flowui.component.genericfilter.RunTimeConfigurationBuilder}
+ * creates a {@link RunTimeConfiguration} — a dynamic configuration whose
+ * conditions can be added or removed by the user at runtime.
+ *
+ * Obtain one with {@code filter.runtimeConfigurationBuilder()}.
+ */
+ def "RunTimeConfigurationBuilder creates and registers a RunTimeConfiguration"() {
+ given: "A GenericFilter"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ and: "A void JpqlFilter condition"
+ def activeFilter = filter.componentBuilder()
+ .jpqlFilter()
+ .where("{E}.status = 'ACTIVE'")
+ .build()
+
+ when: "Creating a RunTimeConfiguration via the builder"
+ RunTimeConfiguration config = filter.runtimeConfigurationBuilder()
+ .id("dynamic")
+ .name("Dynamic Search")
+ .add(activeFilter)
+ .buildAndRegister()
+
+ then: "Configuration is registered and contains the added condition"
+ filter.configurations.any { it.id == "dynamic" }
+ config instanceof RunTimeConfiguration
+ config.id == "dynamic"
+ config.name == "Dynamic Search"
+ config.rootLogicalFilterComponent.filterComponents.contains(activeFilter)
+ }
+
+ def "RunTimeConfigurationBuilder.asDefault() activates the configuration immediately"() {
+ given: "A GenericFilter"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when: "Creating a RunTimeConfiguration and marking it as default"
+ RunTimeConfiguration config = filter.runtimeConfigurationBuilder()
+ .id("active")
+ .asDefault()
+ .buildAndRegister()
+
+ then: "The configuration is the filter's current configuration"
+ filter.currentConfiguration == config
+ }
+
+ def "RunTimeConfigurationBuilder respects the specified logical operation"() {
+ given:
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when:
+ RunTimeConfiguration config = filter.runtimeConfigurationBuilder()
+ .id("orRuntime")
+ .operation(LogicalFilterComponent.Operation.OR)
+ .buildAndRegister()
+
+ then:
+ config.rootLogicalFilterComponent.operation == LogicalFilterComponent.Operation.OR
+ }
+
+ def "RunTimeConfigurationBuilder.buildAndRegister() throws when 'id' is not set"() {
+ given:
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when:
+ filter.runtimeConfigurationBuilder()
+ .buildAndRegister()
+
+ then:
+ thrown(IllegalStateException)
+ }
+
+ /**
+ * {@link RunTimeConfiguration} stores the default value in the configuration map
+ * even when the component's own {@code setValue} cannot be called
+ * (best-effort semantics).
+ */
+ def "RunTimeConfigurationBuilder stores the default value for reset support"() {
+ given: "A GenericFilter and a typed JpqlFilter"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+ JpqlFilter codeFilter = filter.componentBuilder()
+ .jpqlFilter(String)
+ .parameterName("code")
+ .where("{E}.code = ?")
+ .build()
+
+ when: "Registering the filter component with an explicit default value"
+ RunTimeConfiguration config = filter.runtimeConfigurationBuilder()
+ .id("byCode")
+ .add(codeFilter, "DEFAULT")
+ .buildAndRegister()
+
+ then: "The default value is retrievable from the configuration"
+ config.getFilterComponentDefaultValue("code") == "DEFAULT"
+ }
+
+ // =========================================================================
+ // RunTimeConfiguration — auto-tracking of modified state
+ // =========================================================================
+
+ /**
+ * One of the key behavioural improvements in {@link RunTimeConfiguration}:
+ * any component added to the root {@link LogicalFilterComponent} after
+ * the configuration is constructed is automatically marked as modified, making
+ * the per-condition remove button visible without extra boilerplate.
+ */
+ def "Adding a component to RunTimeConfiguration root auto-marks it as modified"() {
+ given: "A RunTimeConfiguration with no pre-existing conditions"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+ RunTimeConfiguration config = filter.runtimeConfigurationBuilder()
+ .id("tracker")
+ .buildAndRegister()
+
+ and: "A void JpqlFilter condition"
+ def fc = filter.componentBuilder()
+ .jpqlFilter()
+ .where("{E}.active = true")
+ .build()
+
+ when: "The condition is added to the root after construction"
+ config.rootLogicalFilterComponent.add(fc)
+
+ then: "The condition is automatically marked as modified"
+ config.isFilterComponentModified(fc)
+ config.isModified()
+ }
+
+ /**
+ * Removal is tracked symmetrically: when a component is removed from the root
+ * the modified flag is cleared automatically.
+ *
+ * A nested {@link GroupFilter} is used here because removing a
+ * {@link io.jmix.flowui.component.filter.SingleFilterComponent} without a DataLoader
+ * would throw (GroupFilter.remove calls DataLoader.removeParameter for single-filter
+ * children). Using a nested group is a perfectly valid real-world use case.
+ */
+ def "Removing a component from RunTimeConfiguration root auto-clears the modified flag"() {
+ given: "A RunTimeConfiguration with one pre-added nested GroupFilter (via builder)"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+ // A nested GroupFilter is not a SingleFilterComponent → remove works without a DataLoader
+ def fc = filter.componentBuilder()
+ .groupFilter()
+ .build()
+ RunTimeConfiguration config = filter.runtimeConfigurationBuilder()
+ .id("tracker")
+ .add(fc)
+ .buildAndRegister()
+
+ expect: "Nested group is marked as modified after being added via the builder"
+ config.isFilterComponentModified(fc)
+
+ when: "The nested group is removed from the root"
+ config.rootLogicalFilterComponent.remove(fc)
+
+ then: "The modified flag is cleared automatically"
+ !config.isFilterComponentModified(fc)
+ !config.isModified()
+ }
+
+ /**
+ * Components added through the builder (not manually to the root) are also
+ * automatically marked as modified, because the builder adds them to the root
+ * which triggers the internal change listener.
+ */
+ def "Components added via RunTimeConfigurationBuilder are auto-marked as modified"() {
+ given: "A void JpqlFilter condition"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+ def fc = filter.componentBuilder()
+ .jpqlFilter()
+ .where("{E}.active = true")
+ .build()
+
+ when: "Creating a RunTimeConfiguration with the condition via the builder"
+ RunTimeConfiguration config = filter.runtimeConfigurationBuilder()
+ .id("with-condition")
+ .add(fc)
+ .buildAndRegister()
+
+ then: "The condition is automatically marked as modified (remove button visible)"
+ config.isFilterComponentModified(fc)
+ config.isModified()
+ }
+
+ // =========================================================================
+ // GenericFilter helper methods
+ // =========================================================================
+
+ /**
+ * {@link GenericFilter#addAndSetCurrentConfiguration} registers a configuration
+ * and makes it current in a single atomic call — avoiding the silent
+ * no-op of calling {@link GenericFilter#setCurrentConfiguration} on an
+ * unregistered configuration.
+ */
+ def "addAndSetCurrentConfiguration registers and activates a configuration in one call"() {
+ given: "A GenericFilter"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ and: "A DesignTimeConfiguration that is registered but not active"
+ DesignTimeConfiguration first = filter.configurationBuilder()
+ .id("first")
+ .asDefault()
+ .buildAndRegister()
+ DesignTimeConfiguration second = filter.configurationBuilder()
+ .id("second")
+ .buildAndRegister()
+
+ expect: "first is currently active"
+ filter.currentConfiguration == first
+
+ when: "Using setCurrentConfiguration to switch to second"
+ filter.setCurrentConfiguration(second)
+
+ then: "second is now active"
+ filter.currentConfiguration == second
+ }
+
+ /**
+ * {@link GenericFilter#setCurrentConfiguration} silently ignores a configuration
+ * that has not been registered with the filter. This is a known limitation of
+ * the existing API; use {@link GenericFilter#addAndSetCurrentConfiguration} to
+ * avoid it.
+ */
+ def "setCurrentConfiguration silently ignores an unregistered configuration"() {
+ given: "A GenericFilter with its initial current configuration"
+ GenericFilter filter = uiComponents.create(GenericFilter)
+ def initialConfig = filter.currentConfiguration
+
+ and: "A RunTimeConfiguration that has NOT been registered with this filter"
+ GroupFilter root = uiComponents.create(GroupFilter)
+ root.setConditionModificationDelegated(true)
+ root.setOperation(LogicalFilterComponent.Operation.AND)
+ RunTimeConfiguration unregistered = new RunTimeConfiguration("unregistered", root, filter)
+
+ when: "Trying to activate the unregistered configuration"
+ filter.setCurrentConfiguration(unregistered)
+
+ then: "Current configuration is unchanged — no exception, no switch"
+ filter.currentConfiguration == initialConfig
+ }
+
+ /**
+ * {@link GenericFilter#refreshCurrentConfiguration} forces the filter UI to
+ * re-render the current configuration's conditions. It is a public shorthand
+ * for the otherwise protected {@code refreshCurrentConfigurationLayout()}.
+ */
+ def "refreshCurrentConfiguration does not throw for a filter without conditions"() {
+ given:
+ GenericFilter filter = uiComponents.create(GenericFilter)
+
+ when:
+ filter.refreshCurrentConfiguration()
+
+ then:
+ noExceptionThrown()
+ }
+}
From cfb66c538e5c38eea7475559222899f721406633 Mon Sep 17 00:00:00 2001
From: aleksandrovpv
Date: Sat, 21 Feb 2026 16:05:26 +0400
Subject: [PATCH 4/5] Deprecate mutating methods on Configuration interface
(for removal in 3.0)
Six mutating methods that throw UnsupportedOperationException when called on
DesignTimeConfiguration are now marked @Deprecated(since = "2.8", forRemoval = true)
in the Configuration interface:
setName, setRootLogicalFilterComponent, setModified, setFilterComponentModified,
resetFilterComponentDefaultValue, resetAllDefaultValues.
Migration path documented in the Configuration class Javadoc:
- Declare variables/parameters as MutableConfiguration (implemented only by
RunTimeConfiguration) to get compile-time safety and no deprecation warning.
- Use (config instanceof MutableConfiguration) for dynamic checks.
MutableConfiguration re-declares all six methods without @Deprecated, so callers
that already use MutableConfiguration or RunTimeConfiguration see no new warnings.
DesignTimeConfiguration gets a brief note explaining the connection to the
deprecation and the 3.0 plan.
Co-Authored-By: Claude Sonnet 4.6
---
.../genericfilter/Configuration.java | 84 +++++++++++++++++--
.../genericfilter/MutableConfiguration.java | 6 ++
.../DesignTimeConfiguration.java | 5 ++
3 files changed, 86 insertions(+), 9 deletions(-)
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/Configuration.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/Configuration.java
index fb37dc71d0..5a4a86ffa8 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/Configuration.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/Configuration.java
@@ -18,6 +18,7 @@
import io.jmix.core.querycondition.LogicalCondition;
import io.jmix.flowui.component.filter.FilterComponent;
+import io.jmix.flowui.component.genericfilter.configuration.DesignTimeConfiguration;
import io.jmix.flowui.component.genericfilter.configuration.RunTimeConfiguration;
import io.jmix.flowui.component.logicalfilter.LogicalFilterComponent;
@@ -25,6 +26,23 @@
/**
* A configuration is a set of filter components.
+ *
+ * Important: several mutating methods declared in this interface
+ * ({@code set*} / {@code reset*}) throw {@link UnsupportedOperationException}
+ * when called on a {@link DesignTimeConfiguration}. Those methods are marked
+ * {@link Deprecated} and will be removed from this interface in Jmix 3.0
+ * — they will exist only in {@link MutableConfiguration}, which is implemented
+ * exclusively by {@link RunTimeConfiguration} and guarantees that every
+ * mutating method works without throwing.
+ *
+ *
Migration path:
+ *
+ * - Declare variables / parameters as {@link MutableConfiguration} instead of
+ * {@code Configuration} when you need to call mutating methods. This gives a
+ * compile-time guarantee and removes the deprecation warning.
+ * - Use {@code instanceof MutableConfiguration} checks where the type of the
+ * configuration is not statically known.
+ *
*/
public interface Configuration extends Comparable {
@@ -45,12 +63,19 @@ public interface Configuration extends Comparable {
String getName();
/**
- * Sets the name of configuration. This method is only available for
- * the {@link RunTimeConfiguration}.
+ * Sets the name of configuration.
*
* @param name a configuration name
+ * @throws UnsupportedOperationException when called on {@link DesignTimeConfiguration}
+ * @deprecated since 2.8, for removal in 3.0.
+ * This method will be removed from {@link Configuration} and kept only in
+ * {@link MutableConfiguration}. Declare the variable as
+ * {@link MutableConfiguration} to call this method without a warning and
+ * without risk of {@link UnsupportedOperationException}.
+ * @see MutableConfiguration
* @see RunTimeConfiguration
*/
+ @Deprecated(since = "2.8", forRemoval = true)
void setName(@Nullable String name);
/**
@@ -60,13 +85,20 @@ public interface Configuration extends Comparable {
LogicalFilterComponent> getRootLogicalFilterComponent();
/**
- * Sets the root element of configuration. This method is only available for
- * the {@link RunTimeConfiguration}.
+ * Sets the root element of configuration.
*
* @param rootLogicalFilterComponent a root element of configuration
+ * @throws UnsupportedOperationException when called on {@link DesignTimeConfiguration}
+ * @deprecated since 2.8, for removal in 3.0.
+ * This method will be removed from {@link Configuration} and kept only in
+ * {@link MutableConfiguration}. Declare the variable as
+ * {@link MutableConfiguration} to call this method without a warning and
+ * without risk of {@link UnsupportedOperationException}.
+ * @see MutableConfiguration
* @see LogicalFilterComponent
* @see RunTimeConfiguration
*/
+ @Deprecated(since = "2.8", forRemoval = true)
void setRootLogicalFilterComponent(LogicalFilterComponent> rootLogicalFilterComponent);
/**
@@ -83,8 +115,16 @@ public interface Configuration extends Comparable {
* Sets whether configuration is modified. If a filter component is modified,
* then a remove button appears next to it.
*
- * @param modified whether configuration is modified.
- */
+ * @param modified whether configuration is modified
+ * @throws UnsupportedOperationException when called on {@link DesignTimeConfiguration}
+ * @deprecated since 2.8, for removal in 3.0.
+ * This method will be removed from {@link Configuration} and kept only in
+ * {@link MutableConfiguration}. Declare the variable as
+ * {@link MutableConfiguration} to call this method without a warning and
+ * without risk of {@link UnsupportedOperationException}.
+ * @see MutableConfiguration
+ */
+ @Deprecated(since = "2.8", forRemoval = true)
void setModified(boolean modified);
/**
@@ -102,7 +142,15 @@ public interface Configuration extends Comparable {
*
* @param filterComponent a filter component
* @param modified whether the filter component of configuration is modified
- */
+ * @throws UnsupportedOperationException when called on {@link DesignTimeConfiguration}
+ * @deprecated since 2.8, for removal in 3.0.
+ * This method will be removed from {@link Configuration} and kept only in
+ * {@link MutableConfiguration}. Declare the variable as
+ * {@link MutableConfiguration} to call this method without a warning and
+ * without risk of {@link UnsupportedOperationException}.
+ * @see MutableConfiguration
+ */
+ @Deprecated(since = "2.8", forRemoval = true)
void setFilterComponentModified(FilterComponent filterComponent, boolean modified);
/**
@@ -119,7 +167,15 @@ public interface Configuration extends Comparable {
* component becomes null.
*
* @param parameterName a parameter name of filter component
- */
+ * @throws UnsupportedOperationException when called on {@link DesignTimeConfiguration}
+ * @deprecated since 2.8, for removal in 3.0.
+ * This method will be removed from {@link Configuration} and kept only in
+ * {@link MutableConfiguration}. Declare the variable as
+ * {@link MutableConfiguration} to call this method without a warning and
+ * without risk of {@link UnsupportedOperationException}.
+ * @see MutableConfiguration
+ */
+ @Deprecated(since = "2.8", forRemoval = true)
void resetFilterComponentDefaultValue(String parameterName);
/**
@@ -133,11 +189,21 @@ public interface Configuration extends Comparable {
/**
* Sets null as the default value for all configuration filter components.
- */
+ *
+ * @throws UnsupportedOperationException when called on {@link DesignTimeConfiguration}
+ * @deprecated since 2.8, for removal in 3.0.
+ * This method will be removed from {@link Configuration} and kept only in
+ * {@link MutableConfiguration}. Declare the variable as
+ * {@link MutableConfiguration} to call this method without a warning and
+ * without risk of {@link UnsupportedOperationException}.
+ * @see MutableConfiguration
+ */
+ @Deprecated(since = "2.8", forRemoval = true)
void resetAllDefaultValues();
/**
* Returns whether the configuration is available for all users
+ *
* @return true if the configuration is available for all users, otherwise false.
*/
default boolean isAvailableForAllUsers() {
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/MutableConfiguration.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/MutableConfiguration.java
index 3b2cfaaf75..df3a95adcd 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/MutableConfiguration.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/MutableConfiguration.java
@@ -39,6 +39,12 @@
* config.setModified(true); // safe — compiler guarantees this is RunTimeConfiguration
* }
*
+ * Jmix 3.0 plan: the mutating methods that are currently also declared (and
+ * deprecated) in {@link Configuration} will be moved exclusively here, and
+ * {@link DesignTimeConfiguration} will stop implementing them altogether.
+ * This will fully restore LSP compliance and eliminate all {@link UnsupportedOperationException}
+ * surprises.
+ *
* @see Configuration
* @see RunTimeConfiguration
*/
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/configuration/DesignTimeConfiguration.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/configuration/DesignTimeConfiguration.java
index 86c5b9b526..28212a83ff 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/configuration/DesignTimeConfiguration.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/configuration/DesignTimeConfiguration.java
@@ -28,6 +28,11 @@
import java.util.HashMap;
import java.util.Map;
+// Note: the mutating methods that throw UnsupportedOperationException below are declared
+// as @Deprecated in the Configuration interface (since 2.8, for removal in 3.0).
+// In Jmix 3.0 those methods will be removed from Configuration and kept only in
+// MutableConfiguration, at which point this class will no longer need to provide
+// throwing stub implementations.
public class DesignTimeConfiguration implements Configuration {
protected final String id;
From ecd38209c2cf322c1fc4c73df9ddc49be61b6ba7 Mon Sep 17 00:00:00 2001
From: aleksandrovpv
Date: Sat, 21 Feb 2026 18:33:12 +0400
Subject: [PATCH 5/5] Mark internal GenericFilter infrastructure with @Internal
#5083
Add @Internal to classes and sub-packages that are framework
implementation details and should not be used by application developers:
- GenericFilterActionsSupport
- FilterUtils (class level; methods already had it)
- FilterConfigurationPersistence
- FilterComponents (registration)
- inspector/ package (new package-info.java)
- model/ package (new package-info.java)
FilterComponentRegistration and FilterComponentRegistrationBuilder
remain public: they are the intentional SPI for add-on developers
registering custom filter component types.
Co-Authored-By: Claude Sonnet 4.6
---
.../FilterConfigurationPersistence.java | 2 ++
.../component/genericfilter/FilterUtils.java | 1 +
.../GenericFilterActionsSupport.java | 2 ++
.../genericfilter/inspector/package-info.java | 22 +++++++++++++++++++
.../genericfilter/model/package-info.java | 22 +++++++++++++++++++
.../registration/FilterComponents.java | 2 ++
6 files changed, 51 insertions(+)
create mode 100644 jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/inspector/package-info.java
create mode 100644 jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/model/package-info.java
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/FilterConfigurationPersistence.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/FilterConfigurationPersistence.java
index 63c9a9eafa..71d103caeb 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/FilterConfigurationPersistence.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/FilterConfigurationPersistence.java
@@ -16,6 +16,7 @@
package io.jmix.flowui.component.genericfilter;
+import io.jmix.core.annotation.Internal;
import io.jmix.flowui.component.genericfilter.model.FilterConfigurationModel;
import org.springframework.lang.Nullable;
@@ -24,6 +25,7 @@
/**
* Interface to be implemented by beans that store {@code FilterConfigurationModel} in a persistent storage.
*/
+@Internal
public interface FilterConfigurationPersistence {
void remove(FilterConfigurationModel configurationModel);
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/FilterUtils.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/FilterUtils.java
index fa8589d76e..7b86921dc7 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/FilterUtils.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/FilterUtils.java
@@ -30,6 +30,7 @@
import org.apache.commons.text.WordUtils;
import org.springframework.lang.Nullable;
+@Internal
public class FilterUtils {
public static String generateConfigurationId(@Nullable String configurationName) {
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/GenericFilterActionsSupport.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/GenericFilterActionsSupport.java
index 0d2e6c75f7..0a48cbf7b3 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/GenericFilterActionsSupport.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/GenericFilterActionsSupport.java
@@ -16,11 +16,13 @@
package io.jmix.flowui.component.genericfilter;
+import io.jmix.core.annotation.Internal;
import io.jmix.flowui.action.genericfilter.GenericFilterAction;
import io.jmix.flowui.kit.action.Action;
import io.jmix.flowui.kit.component.delegate.AbstractActionsHolderSupport;
import io.jmix.flowui.kit.component.dropdownbutton.DropdownButton;
+@Internal
public class GenericFilterActionsSupport extends AbstractActionsHolderSupport {
protected final DropdownButton settingsButton;
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/inspector/package-info.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/inspector/package-info.java
new file mode 100644
index 0000000000..01c65b87fc
--- /dev/null
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/inspector/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 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.
+ */
+
+@Internal
+@NonNullApi
+package io.jmix.flowui.component.genericfilter.inspector;
+
+import io.jmix.core.annotation.Internal;
+import org.springframework.lang.NonNullApi;
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/model/package-info.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/model/package-info.java
new file mode 100644
index 0000000000..e5436e5648
--- /dev/null
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/model/package-info.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2022 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.
+ */
+
+@Internal
+@NonNullApi
+package io.jmix.flowui.component.genericfilter.model;
+
+import io.jmix.core.annotation.Internal;
+import org.springframework.lang.NonNullApi;
diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/registration/FilterComponents.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/registration/FilterComponents.java
index 484e07743f..fb7013a1ef 100644
--- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/registration/FilterComponents.java
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/registration/FilterComponents.java
@@ -17,6 +17,7 @@
package io.jmix.flowui.component.genericfilter.registration;
import io.jmix.core.Metadata;
+import io.jmix.core.annotation.Internal;
import io.jmix.core.common.util.Preconditions;
import io.jmix.core.metamodel.model.MetaClass;
import io.jmix.flowui.component.filter.FilterComponent;
@@ -92,6 +93,7 @@
* component registration must provide full information: UI filter component class,
* model class, converter class and detail view id (optional).
*/
+@Internal
@Component("flowui_FilterComponents")
public class FilterComponents implements InitializingBean {