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: + *
+ * Encapsulates all steps that would otherwise need to be performed manually: + *
+ * Obtain an instance via {@link GenericFilter#configurationBuilder()}: + *
{@code
+ * filter.configurationBuilder()
+ * .id("byStatus")
+ * .name("Search by Status")
+ * .operation(LogicalFilterComponent.Operation.AND)
+ * .add(statusFilter)
+ * .add(nameFilter, "Acme") // override default value for this component
+ * .asDefault()
+ * .buildAndRegister();
+ * }
+ */
+public class DesignTimeConfigurationBuilder {
+
+ 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 final List+ * This is equivalent to calling {@code component.setValue(defaultValue)} followed by + * {@code config.setFilterComponentDefaultValue(parameterName, defaultValue)}. + * + * @param filterComponent filter component to add + * @param defaultValue value to set as both the component's current value and the + * configuration's default + */ + public DesignTimeConfigurationBuilder add(FilterComponent filterComponent, @Nullable Object defaultValue) { + checkNotNullArgument(filterComponent, "filterComponent must not be null"); + entries.add(new ComponentEntry(filterComponent, defaultValue, true)); + return this; + } + + /** + * Marks this configuration as the default (currently active) configuration. + * After {@link #buildAndRegister()} the configuration will be set as the current + * configuration of the filter. + */ + public DesignTimeConfigurationBuilder asDefault() { + this.makeDefault = true; + return this; + } + + /** + * Builds the {@link DesignTimeConfiguration}, registers it with the filter, and + * optionally activates it. + *
+ * Automatically: + *
+ * All builders returned by this factory automatically perform the initialisation steps + * that the XML loader ({@code GenericFilterLoader}) does implicitly: + *
+ * Obtain an instance via {@link GenericFilter#componentBuilder()}: + *
{@code
+ * FilterComponentBuilder builder = filter.componentBuilder();
+ *
+ * PropertyFilter nameFilter = builder.propertyFilter()
+ * .property("name")
+ * .operation(PropertyFilter.Operation.CONTAINS)
+ * .defaultValue("Acme")
+ * .build();
+ *
+ * JpqlFilter activeFilter = builder.jpqlFilter()
+ * .where("{E}.status = 'ACTIVE'")
+ * .build();
+ * }
+ */
+public class FilterComponentBuilder {
+
+ protected final GenericFilter filter;
+ protected final UiComponents uiComponents;
+
+ FilterComponentBuilder(GenericFilter filter, UiComponents uiComponents) {
+ this.filter = filter;
+ this.uiComponents = uiComponents;
+ }
+
+ /**
+ * Returns a builder for a {@link PropertyFilter} bound to the owning filter.
+ *
+ * @param
+ * 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
+ * Enforces the required call order:
+ *
+ * Initialisation order enforced internally:
+ *
+ * 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/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
+ * {@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:
+ * 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
+ */
+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..87682a278d
--- /dev/null
+++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/RunTimeConfigurationBuilder.java
@@ -0,0 +1,252 @@
+/*
+ * 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:
+ *
+ * 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()}:
+ *
+ * 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:
+ *
+ * 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 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.
+ *
+ * Although individual test methods are small and focused (resembling unit tests),
+ * this class is technically an integration test suite:
+ * 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.
+ *
+ * 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:
+ *
+ * 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
+ * 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
+ * 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
+ * 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()
+ }
+}
+ *
+ *
+ * @throws IllegalStateException if {@code property} or {@code operation} was not set
+ */
+ @SuppressWarnings("unchecked")
+ public PropertyFilter
+ *
+ *
+ * @throws IllegalStateException if {@code where} was not set
+ */
+ @SuppressWarnings("unchecked")
+ public JpqlFilter{@code
+ * MutableConfiguration config = filter.runtimeConfigurationBuilder()
+ * .id("myConfig")
+ * .buildAndRegister();
+ * config.setModified(true); // safe — compiler guarantees this is RunTimeConfiguration
+ * }
+ *
+ *
+ *
+ * {@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
+ *
+ *
+ * @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) {
+ // 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,
+ // which marks the component as modified automatically.
+ root.add(fc);
+
+ // 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) {
+ String paramName = sfc.getParameterName();
+ Object valueToStore = entry.overrideDefault ? entry.defaultValue : sfc.getValue();
+ if (paramName != null && valueToStore != null) {
+ config.setFilterComponentDefaultValue(paramName, 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/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;
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.
+ *
+ *
+ */
+ protected void syncModifiedStateFromRoot() {
+ SetTest classification: narrow integration tests
+ *
+ *
+ * What is not covered here
+ *
+ *
+ */
+ 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