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: + *

*/ 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/DesignTimeConfigurationBuilder.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/DesignTimeConfigurationBuilder.java new file mode 100644 index 0000000000..3bdf1615cf --- /dev/null +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/DesignTimeConfigurationBuilder.java @@ -0,0 +1,220 @@ +/* + * 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.DesignTimeConfiguration; +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 DesignTimeConfiguration}. + *

+ * Encapsulates all steps that would otherwise need to be performed manually: + *

    + *
  • Creating and configuring the root {@link LogicalFilterComponent}
  • + *
  • Adding filter components to the root
  • + *
  • Calling {@code setFilterComponentDefaultValue} for each component that has a value
  • + *
  • Registering the configuration via {@link GenericFilter#addConfiguration(Configuration)}
  • + *
  • Optionally activating it via {@link GenericFilter#setCurrentConfiguration(Configuration)}
  • + *
+ *

+ * 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 entries = new ArrayList<>(); + + DesignTimeConfigurationBuilder(GenericFilter filter, UiComponents uiComponents) { + this.filter = filter; + this.uiComponents = uiComponents; + } + + /** + * Sets the configuration id. Required. + * + * @param id unique configuration identifier within this filter + */ + public DesignTimeConfigurationBuilder 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 DesignTimeConfigurationBuilder 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 DesignTimeConfigurationBuilder operation(LogicalFilterComponent.Operation operation) { + checkNotNullArgument(operation, "operation must not be null"); + this.operation = operation; + return this; + } + + /** + * Adds a filter component to the configuration using the component's current value + * (if any) as the default. + * + * @param filterComponent filter component to add + */ + public DesignTimeConfigurationBuilder add(FilterComponent filterComponent) { + checkNotNullArgument(filterComponent, "filterComponent must not be null"); + entries.add(new ComponentEntry(filterComponent, null, false)); + return this; + } + + /** + * Adds a filter component to the configuration, overriding its default value. + *

+ * 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: + *

    + *
  • Adds each filter component to the configuration's root
  • + *
  • Calls {@code setFilterComponentDefaultValue} for every component that carries a + * value (either via the overload with an explicit default or because the component + * already has a non-null value at the time {@code add()} was called)
  • + *
  • Calls {@link GenericFilter#addConfiguration(Configuration)}
  • + *
  • If {@link #asDefault()} was called, calls + * {@link GenericFilter#setCurrentConfiguration(Configuration)}
  • + *
+ * + * @return the newly created and registered {@link DesignTimeConfiguration} + * @throws IllegalStateException if {@code id} was not set + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + public DesignTimeConfiguration buildAndRegister() { + if (id == null) { + throw new IllegalStateException( + "DesignTimeConfigurationBuilder: 'id' is required — call .id(\"...\") before .buildAndRegister()"); + } + + DesignTimeConfiguration config = filter.addConfiguration(id, name, operation); + LogicalFilterComponent root = config.getRootLogicalFilterComponent(); + + for (ComponentEntry entry : entries) { + FilterComponent fc = entry.filterComponent; + + if (entry.overrideDefault) { + // 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) { + try { + ((SingleFilterComponentBase) sfc).setValue(entry.defaultValue); + } catch (RuntimeException ignored) { + // component not fully initialised; default stored in config below + } + } + } + + root.add(fc); + + // 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) { + String paramName = sfc.getParameterName(); + Object valueToStore = entry.overrideDefault ? entry.defaultValue : sfc.getValue(); + if (paramName != null && valueToStore != null) { + config.setFilterComponentDefaultValue(paramName, valueToStore); + } + } + } + + if (makeDefault) { + filter.setCurrentConfiguration(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/FilterComponentBuilder.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/FilterComponentBuilder.java new file mode 100644 index 0000000000..da3727ef6d --- /dev/null +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/FilterComponentBuilder.java @@ -0,0 +1,407 @@ +/* + * 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.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.lang.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import static io.jmix.core.common.util.Preconditions.checkNotNullArgument; + +/** + * A factory for creating filter components ({@link PropertyFilter}, {@link JpqlFilter}, + * {@link GroupFilter}) that are pre-configured for use inside a specific {@link GenericFilter}. + *

+ * All builders returned by this factory automatically perform the initialisation steps + * that the XML loader ({@code GenericFilterLoader}) does implicitly: + *

    + *
  • {@code setConditionModificationDelegated(true)}
  • + *
  • {@code setDataLoader(filter.getDataLoader())}
  • + *
  • Correct ordering of {@code property → operation → value} for {@link PropertyFilter}
  • + *
+ *

+ * 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 the value type inferred from the entity property + * @return a new {@link PropertyFilterBuilder} + */ + public PropertyFilterBuilder propertyFilter() { + return new PropertyFilterBuilder<>(filter, uiComponents); + } + + /** + * Returns a builder for a {@link JpqlFilter} without a query parameter + * (rendered as a checkbox; parameter class is {@code Void}). + * + * @return a new {@link JpqlFilterBuilder} with {@code parameterClass = Void.class} + */ + public JpqlFilterBuilder jpqlFilter() { + return new JpqlFilterBuilder<>(filter, uiComponents, Void.class); + } + + /** + * Returns a builder for a {@link JpqlFilter} with a typed query parameter. + * + * @param parameterClass the Java class of the query parameter + * @param the value type + * @return a new {@link JpqlFilterBuilder} + */ + public JpqlFilterBuilder jpqlFilter(Class 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: + *

    + *
  1. {@code setConditionModificationDelegated(true)}
  2. + *
  3. {@code setDataLoader(...)}
  4. + *
  5. {@code setProperty(...)}
  6. + *
  7. {@code setOperation(...)}
  8. + *
  9. {@code setValue(...)} (only if a default value was provided)
  10. + *
+ * + * @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: + *

    + *
  1. {@code setConditionModificationDelegated(true)}
  2. + *
  3. {@code setDataLoader(...)}
  4. + *
  5. {@code setParameterName(...)} (if provided)
  6. + *
  7. {@code setParameterClass(...)}
  8. + *
  9. {@code setCondition(where, join)}
  10. + *
  11. {@code setValue(...)} (only if a default value was provided)
  12. + *
+ * + * @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/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/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/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/MutableConfiguration.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/MutableConfiguration.java new file mode 100644 index 0000000000..df3a95adcd --- /dev/null +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/genericfilter/MutableConfiguration.java @@ -0,0 +1,117 @@ +/* + * 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
+ * }
+ * + *

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: + *

    + *
  • 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) { + // 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. + *

+ * 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); 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 { 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: + *

    + *
  1. {@code setConditionModificationDelegated(true)}
  2. + *
  3. {@code setDataLoader(…)} (skipped when the filter has no loader)
  4. + *
  5. {@code setProperty(…)}
  6. + *
  7. {@code setOperation(…)}
  8. + *
+ */ + 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() + } +}