diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/logicalfilter/GroupFilter.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/logicalfilter/GroupFilter.java index ccc9a7c7bf..8a76ed9e0b 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/logicalfilter/GroupFilter.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/component/logicalfilter/GroupFilter.java @@ -283,8 +283,15 @@ public void add(FilterComponent filterComponent) { if (filterComponent instanceof PropertyFilter) { // Keep the registration so remove() can detach it; otherwise re-adding a component // (e.g. on a filter re-navigation restore) would accumulate stale apply() listeners. + // Apply on the user's gesture only: a programmatic operation change (e.g. the URL binder + // restoring the filter state) must not fire a load of its own, consistently with the + // value path, which is gated by isFromClient in SingleFilterComponentBase. Registration operationChangeRegistration = ((PropertyFilter) filterComponent) - .addOperationChangeListener(operationChangeEvent -> apply()); + .addOperationChangeListener(operationChangeEvent -> { + if (operationChangeEvent.isFromClient()) { + apply(); + } + }); operationChangeRegistrations.put(filterComponent, operationChangeRegistration); } diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/DataGridFilterUrlQueryParametersBinder.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/DataGridFilterUrlQueryParametersBinder.java index b707ddeaf8..a33b231d97 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/DataGridFilterUrlQueryParametersBinder.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/DataGridFilterUrlQueryParametersBinder.java @@ -193,23 +193,11 @@ protected void applyPropertyFilterParameters(List params) { @SuppressWarnings({"rawtypes", "unchecked"}) protected void applyPropertyFilterParameter(String parameterString) { - int separatorIndex = parameterString.indexOf(SEPARATOR); + List tokens = filterUrlQueryParametersSupport.splitParameter(parameterString, 3); - if (separatorIndex == -1) { - throw new IllegalStateException("Can't parse property condition: " + parameterString); - } - - String keyString = parameterString.substring(0, separatorIndex); - - parameterString = parameterString.substring(separatorIndex + 1); - separatorIndex = parameterString.indexOf(SEPARATOR); - if (separatorIndex == -1) { - throw new IllegalStateException("Can't parse property condition: " + parameterString); - } - - String propertyString = parameterString.substring(0, separatorIndex); + String keyString = tokens.get(0); String property = urlParamSerializer.deserialize(String.class, - filterUrlQueryParametersSupport.restoreSeparatorValue(propertyString)); + filterUrlQueryParametersSupport.restoreSeparatorValue(tokens.get(1))); DataGridColumn column = (DataGridColumn) grid.getColumnByKey(keyString); if (column == null) { @@ -219,22 +207,15 @@ protected void applyPropertyFilterParameter(String parameterString) { throw new IllegalStateException("Column must be filterable"); } - parameterString = parameterString.substring(separatorIndex + 1); - separatorIndex = parameterString.indexOf(SEPARATOR); - if (separatorIndex == -1) { - throw new IllegalStateException("Can't parse property condition: " + parameterString); - } - - String operationString = parameterString.substring(0, separatorIndex); PropertyFilter.Operation operation = urlParamSerializer .deserialize(PropertyFilter.Operation.class, - filterUrlQueryParametersSupport.restoreSeparatorValue(operationString)); + filterUrlQueryParametersSupport.restoreSeparatorValue(tokens.get(2))); DataGridHeaderFilter headerFilter = (DataGridHeaderFilter) column.getHeaderComponent(); PropertyFilter propertyFilter = headerFilter.getPropertyFilter(); propertyFilter.setOperation(operation); - String valueString = parameterString.substring(separatorIndex + 1); + String valueString = tokens.get(3); if (!Strings.isNullOrEmpty(valueString)) { try { Object parsedValue = filterUrlQueryParametersSupport diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/FilterUrlQueryParametersSupport.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/FilterUrlQueryParametersSupport.java index ad784c6160..49e38340d1 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/FilterUrlQueryParametersSupport.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/FilterUrlQueryParametersSupport.java @@ -30,9 +30,11 @@ import org.jspecify.annotations.Nullable; import org.springframework.stereotype.Component; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Date; +import java.util.List; import java.util.Objects; /** @@ -130,6 +132,33 @@ public Object parseCollectionValue(String property, String collectionValueString .toList(); } + /** + * Splits a serialized parameter string into {@code headTokens} structural tokens separated by + * {@link #SEPARATOR}, plus the remainder — everything past the last structural separator. The + * remainder may itself contain separator characters (e.g. inside a serialized value) and may be + * empty. The returned list always has {@code headTokens + 1} elements. + * + * @param parameterString the serialized parameter string + * @param headTokens the number of structural tokens before the remainder + * @return the structural tokens followed by the remainder + * @throws IllegalStateException if the string has fewer than {@code headTokens} separators + */ + public List splitParameter(String parameterString, int headTokens) { + List tokens = new ArrayList<>(headTokens + 1); + String rest = parameterString; + for (int i = 0; i < headTokens; i++) { + int separatorIndex = rest.indexOf(SEPARATOR); + if (separatorIndex == -1) { + throw new IllegalStateException("Can't parse parameter: " + parameterString); + } + tokens.add(rest.substring(0, separatorIndex)); + rest = rest.substring(separatorIndex + 1); + } + tokens.add(rest); + + return tokens; + } + public Object getSerializableValue(@Nullable Object value) { if (value == null) { return ""; diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/GenericFilterUrlQueryParametersBinder.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/GenericFilterUrlQueryParametersBinder.java index 37562b19db..c7cfcc2b58 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/GenericFilterUrlQueryParametersBinder.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/GenericFilterUrlQueryParametersBinder.java @@ -23,6 +23,7 @@ import com.vaadin.flow.router.QueryParameters; import com.vaadin.flow.shared.Registration; import io.jmix.core.AccessManager; +import io.jmix.core.Metadata; import io.jmix.core.MetadataTools; import io.jmix.core.accesscontext.EntityAttributeContext; import io.jmix.core.annotation.Internal; @@ -41,10 +42,15 @@ import io.jmix.flowui.component.genericfilter.FilterUtils; import io.jmix.flowui.component.genericfilter.GenericFilter; import io.jmix.flowui.component.genericfilter.configuration.RunTimeConfiguration; +import io.jmix.flowui.component.genericfilter.converter.FilterConverter; +import io.jmix.flowui.component.genericfilter.registration.FilterComponents; import io.jmix.flowui.component.logicalfilter.LogicalFilterComponent; import io.jmix.flowui.component.logicalfilter.LogicalFilterComponent.FilterComponentsChangeEvent; import io.jmix.flowui.component.propertyfilter.PropertyFilter; +import io.jmix.flowui.component.propertyfilter.PropertyFilterSupport; import io.jmix.flowui.component.propertyfilter.SingleFilterSupport; +import io.jmix.flowui.entity.filter.FilterValueComponent; +import io.jmix.flowui.entity.filter.PropertyFilterCondition; import io.jmix.flowui.facet.UrlQueryParametersFacet.UrlQueryParametersChangeEvent; import io.jmix.flowui.model.CollectionLoader; import io.jmix.flowui.model.DataLoader; @@ -87,7 +93,10 @@ public class GenericFilterUrlQueryParametersBinder extends AbstractUrlQueryParam protected UiComponents uiComponents; protected SingleFilterComponentStateSupport singleFilterComponentStateSupport; protected SingleFilterSupport singleFilterSupport; + protected PropertyFilterSupport propertyFilterSupport; protected MetadataTools metadataTools; + protected Metadata metadata; + protected FilterComponents filterComponents; protected FilterUrlQueryParametersSupport filterUrlQueryParametersSupport; protected AccessManager accessManager; @@ -113,6 +122,9 @@ protected void autowireDependencies() { singleFilterComponentStateSupport = applicationContext.getBean(SingleFilterComponentStateSupport.class); filterUrlQueryParametersSupport = applicationContext.getBean(FilterUrlQueryParametersSupport.class); accessManager = applicationContext.getBean(AccessManager.class); + metadata = applicationContext.getBean(Metadata.class); + filterComponents = applicationContext.getBean(FilterComponents.class); + propertyFilterSupport = applicationContext.getBean(PropertyFilterSupport.class); } protected void initComponent(GenericFilter filter) { @@ -373,12 +385,12 @@ public void updateState(QueryParameters queryParameters) { LogicalFilterComponent rootLogicalFilterComponent = currentConfiguration.getRootLogicalFilterComponent(); - List conditions = deserializeConditions(conditionParams, - rootLogicalFilterComponent.getDataLoader()); - conditions.forEach(filterComponent -> { + DataLoader dataLoader = rootLogicalFilterComponent.getDataLoader(); + for (ParsedPropertyCondition condition : deserializeConditionModels(conditionParams, dataLoader)) { + FilterComponent filterComponent = createPropertyFilter(condition, dataLoader); rootLogicalFilterComponent.add(filterComponent); currentConfiguration.setFilterComponentModified(filterComponent, true); - }); + } FilterUtils.setCurrentConfiguration(filter, currentConfiguration, true); } @@ -388,69 +400,226 @@ protected String deserializeConfigurationId(String configurationParam) { return urlParamSerializer.deserialize(String.class, configurationParam); } - protected List deserializeConditions(List conditionParams, DataLoader dataLoader) { - List conditions = new ArrayList<>(conditionParams.size()); + protected List deserializeConditionModels(List conditionParams, + DataLoader dataLoader) { + List conditions = new ArrayList<>(conditionParams.size()); for (String conditionString : conditionParams) { - FilterComponent filterComponent = parseCondition(conditionString, dataLoader); - if (isPermitted(dataLoader, filterComponent)) { - conditions.add(filterComponent); + ParsedPropertyCondition condition; + try { + condition = parseConditionModel(conditionString); + } catch (RuntimeException e) { + // A URL is external input: a malformed condition (hand-edited, truncated, or from + // another version) degrades to a skipped condition, never to a failed navigation. + log.warn("A URL condition '{}' is skipped: {}", conditionString, e.toString()); + continue; + } + if (isConditionPermitted(dataLoader, condition)) { + conditions.add(condition); } } return conditions; } - protected boolean isPermitted(DataLoader dataLoader, FilterComponent filterComponent) { - if (filterComponent instanceof PropertyFilter propertyFilter && propertyFilter.getProperty() != null) { - MetaClass entityMetaClass = dataLoader.getContainer().getEntityMetaClass(); - MetaPropertyPath propertyPath = getMetadataTools().resolveMetaPropertyPathOrNull(entityMetaClass, propertyFilter.getProperty()); + protected ParsedPropertyCondition parseConditionModel(String conditionString) { + if (conditionString.startsWith(PROPERTY_CONDITION_PREFIX)) { + String propertyConditionString = conditionString.substring(PROPERTY_CONDITION_PREFIX.length()); + return parsePropertyConditionModel(propertyConditionString); + } - Predicate propertyFiltersPredicate = filter.getPropertyFiltersPredicate(); - if (propertyFiltersPredicate != null && !propertyFiltersPredicate.test(propertyPath)) { - return false; - } + throw new IllegalStateException("Unknown condition type: " + conditionString); + } - EntityAttributeContext context = new EntityAttributeContext(propertyPath); - accessManager.applyRegisteredConstraints(context); - if (!context.canView()) { - return false; - } + protected ParsedPropertyCondition parsePropertyConditionModel(String conditionString) { + List tokens = filterUrlQueryParametersSupport.splitParameter(conditionString, 2); + + String property = urlParamSerializer.deserialize(String.class, + filterUrlQueryParametersSupport.restoreSeparatorValue(tokens.get(0))); + PropertyFilter.Operation operation = urlParamSerializer + .deserialize(PropertyFilter.Operation.class, + filterUrlQueryParametersSupport.restoreSeparatorValue(tokens.get(1))); + + return new ParsedPropertyCondition(property, operation, Strings.emptyToNull(tokens.get(2))); + } + + protected boolean isConditionPermitted(DataLoader dataLoader, ParsedPropertyCondition condition) { + MetaClass entityMetaClass = dataLoader.getContainer().getEntityMetaClass(); + MetaPropertyPath propertyPath = getMetadataTools() + .resolveMetaPropertyPathOrNull(entityMetaClass, condition.property()); + if (propertyPath == null) { + log.warn("A URL condition on '{}' is skipped: the attribute does not exist in entity '{}'", + condition.property(), entityMetaClass.getName()); + return false; + } - return propertyPath == null || - !propertyPath.getMetaProperty().getAnnotatedElement().isAnnotationPresent(SystemLevel.class); + Predicate propertyFiltersPredicate = filter.getPropertyFiltersPredicate(); + if (propertyFiltersPredicate != null && !propertyFiltersPredicate.test(propertyPath)) { + return false; + } + + EntityAttributeContext context = new EntityAttributeContext(propertyPath); + accessManager.applyRegisteredConstraints(context); + if (!context.canView()) { + return false; + } + + if (propertyPath.getMetaProperty().getAnnotatedElement().isAnnotationPresent(SystemLevel.class)) { + return false; + } + + // The operation compatibility used to be checked only by PropertyFilter.setOperation while + // the component was being built, where a failure escaped into the navigation. Validate it + // here on the model, so an incompatible operation degrades to a skipped condition. + if (!propertyFilterSupport.getAvailableOperations(propertyPath).contains(condition.operation())) { + log.warn("A URL condition on '{}' is skipped: the operation '{}' is not available for the attribute", + condition.property(), condition.operation()); + return false; + } + + return true; + } + + @Nullable + protected Object parseConditionValue(ParsedPropertyCondition condition, DataLoader dataLoader) { + if (condition.valueString() == null) { + return null; + } + try { + return filterUrlQueryParametersSupport.parseValue(dataLoader.getContainer().getEntityMetaClass(), + condition.property(), condition.operation().getType(), condition.valueString()); + } catch (RuntimeException e) { + log.warn("Cannot parse the value of a URL condition on '{}': {}", condition.property(), e.toString()); + return null; + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + protected PropertyFilter createPropertyFilter(ParsedPropertyCondition condition, DataLoader dataLoader) { + PropertyFilterCondition model = metadata.create(PropertyFilterCondition.class); + model.setProperty(condition.property()); + model.setOperation(condition.operation()); + model.setParameterName(PropertyConditionUtils.generateParameterName(condition.property())); + model.setValueComponent(metadata.create(FilterValueComponent.class)); + + FilterConverter converter = + (FilterConverter) filterComponents.getConverterByModelClass(PropertyFilterCondition.class, filter); + PropertyFilter propertyFilter = converter.convertToComponent(model); + + Object value = parseConditionValue(condition, dataLoader); + if (value != null) { + propertyFilter.setValue(value); + } + + return propertyFilter; + } + + /** + * @deprecated the URL restore no longer builds a filter component per condition; conditions are + * parsed into {@link ParsedPropertyCondition} models and validated by + * {@link #deserializeConditionModels(List, DataLoader)} first, and a component is created by + * {@link #createPropertyFilter(ParsedPropertyCondition, DataLoader)} only for the surviving ones. + * This method now delegates to that flow. + */ + @Deprecated(since = "3.1", forRemoval = true) + protected List deserializeConditions(List conditionParams, DataLoader dataLoader) { + List models = deserializeConditionModels(conditionParams, dataLoader); + List conditions = new ArrayList<>(models.size()); + for (ParsedPropertyCondition condition : models) { + conditions.add(createPropertyFilter(condition, dataLoader)); + } + + return conditions; + } + + /** + * @deprecated use {@link #isConditionPermitted(DataLoader, ParsedPropertyCondition)}, which this + * method delegates to, instead. Unlike before, an attribute that does not exist in the entity is + * reported as not permitted rather than tolerated. + */ + @Deprecated(since = "3.1", forRemoval = true) + protected boolean isPermitted(DataLoader dataLoader, FilterComponent filterComponent) { + if (filterComponent instanceof PropertyFilter propertyFilter && propertyFilter.getProperty() != null) { + return isConditionPermitted(dataLoader, new ParsedPropertyCondition( + propertyFilter.getProperty(), propertyFilter.getOperation(), null)); } return true; } protected void updateConfigurationConditions(Configuration currentConfiguration, List conditionParams) { LogicalFilterComponent rootLogicalFilterComponent = currentConfiguration.getRootLogicalFilterComponent(); + DataLoader dataLoader = rootLogicalFilterComponent.getDataLoader(); - List conditions = deserializeConditions(conditionParams, - rootLogicalFilterComponent.getDataLoader()); + List conditions = deserializeConditionModels(conditionParams, dataLoader); List configurationComponents = rootLogicalFilterComponent.getFilterComponents(); - for (FilterComponent filterComponent : conditions) { + for (ParsedPropertyCondition condition : conditions) { FilterComponent usedFilterComponent = null; for (int i = 0; i < configurationComponents.size() && usedFilterComponent == null; ++i) { FilterComponent configurationComponent = configurationComponents.get(i); - usedFilterComponent = updateFilterComponent(configurationComponent, filterComponent); + usedFilterComponent = applyConditionToComponent(configurationComponent, condition, dataLoader); } if (usedFilterComponent != null) { configurationComponents.remove(usedFilterComponent); - } - - if (currentConfiguration instanceof RunTimeConfiguration && usedFilterComponent == null) { + } else if (currentConfiguration instanceof RunTimeConfiguration) { + FilterComponent filterComponent = createPropertyFilter(condition, dataLoader); currentConfiguration.setFilterComponentModified(filterComponent, true); rootLogicalFilterComponent.add(filterComponent); } else { - log.debug("Can't add filterComponent to Design-Time Configuration"); + log.warn("A URL condition on '{}' is skipped: the design-time configuration '{}' has no" + + " matching condition and cannot be extended", condition.property(), currentConfiguration.getId()); } } } + @Nullable + protected FilterComponent applyConditionToComponent(FilterComponent configurationComponent, + ParsedPropertyCondition condition, + DataLoader dataLoader) { + if (configurationComponent instanceof PropertyFilter configurationPropertyFilter) { + return applyPropertyCondition(configurationPropertyFilter, condition, dataLoader); + } + + return null; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Nullable + protected FilterComponent applyPropertyCondition(PropertyFilter configurationComponent, + ParsedPropertyCondition condition, + DataLoader dataLoader) { + if (!Objects.equals(configurationComponent.getProperty(), condition.property())) { + return null; + } + + // The component may narrow the attribute's operations further via setOperationsList + // (an empty list means no restriction); an operation outside that list is not applied + // instead of failing the restore. + List operationsList = configurationComponent.getOperationsList(); + if (configurationComponent.isOperationEditable() + && (operationsList.isEmpty() || operationsList.contains(condition.operation()))) { + configurationComponent.setOperation(condition.operation()); + } + + if (Objects.equals(configurationComponent.getOperation(), condition.operation())) { + Object value = parseConditionValue(condition, dataLoader); + if (value != null) { + UiComponentUtils.setValue(configurationComponent, value); + } + + return configurationComponent; + } + + return null; + } + + /** + * @deprecated use {@link #applyConditionToComponent(FilterComponent, ParsedPropertyCondition, DataLoader)} + * instead + */ + @Deprecated(since = "3.1", forRemoval = true) @Nullable protected FilterComponent updateFilterComponent(FilterComponent configurationComponent, FilterComponent filterComponent) { @@ -471,6 +640,10 @@ protected FilterComponent updateFilterComponent(FilterComponent configurationCom return null; } + /** + * @deprecated use {@link #applyPropertyCondition(PropertyFilter, ParsedPropertyCondition, DataLoader)} instead + */ + @Deprecated(since = "3.1", forRemoval = true) @SuppressWarnings({"rawtypes", "unchecked"}) @Nullable protected FilterComponent updatePropertyCondition(PropertyFilter configurationComponent, @@ -492,64 +665,32 @@ protected FilterComponent updatePropertyCondition(PropertyFilter configurationCo return null; } + /** + * @deprecated use {@link #parseConditionModel(String)} and + * {@link #createPropertyFilter(ParsedPropertyCondition, DataLoader)}, which this method delegates + * to, instead + */ + @Deprecated(since = "3.1", forRemoval = true) protected FilterComponent parseCondition(String conditionString, DataLoader dataLoader) { - if (conditionString.startsWith(PROPERTY_CONDITION_PREFIX)) { - String propertyConditionString = conditionString.substring(PROPERTY_CONDITION_PREFIX.length()); - return parsePropertyCondition(propertyConditionString, dataLoader); - } - - throw new IllegalStateException("Unknown condition type: " + conditionString); + return createPropertyFilter(parseConditionModel(conditionString), dataLoader); } - @SuppressWarnings({"rawtypes", "unchecked"}) + /** + * @deprecated use {@link #parsePropertyConditionModel(String)} and + * {@link #createPropertyFilter(ParsedPropertyCondition, DataLoader)} instead; this method now + * delegates to them, so the component is created through the converter route and its condition + * modification is delegated to the filter + */ + @Deprecated(since = "3.1", forRemoval = true) protected PropertyFilter parsePropertyCondition(String conditionString, DataLoader dataLoader) { - int separatorIndex = conditionString.indexOf(SEPARATOR); - if (separatorIndex == -1) { - throw new IllegalStateException("Can't parse property condition: " + conditionString); - } - - String propertyString = conditionString.substring(0, separatorIndex); - String property = urlParamSerializer.deserialize(String.class, - filterUrlQueryParametersSupport.restoreSeparatorValue(propertyString)); - - conditionString = conditionString.substring(separatorIndex + 1); - separatorIndex = conditionString.indexOf(SEPARATOR); - if (separatorIndex == -1) { - throw new IllegalStateException("Can't parse property condition: " + conditionString); - } - - String operationString = conditionString.substring(0, separatorIndex); - PropertyFilter.Operation operation = urlParamSerializer - .deserialize(PropertyFilter.Operation.class, - filterUrlQueryParametersSupport.restoreSeparatorValue(operationString)); - - PropertyFilter propertyFilter = uiComponents.create(PropertyFilter.class); - propertyFilter.setProperty(property); - propertyFilter.setOperation(operation); - // TODO: gg, change when configurations and custom conditions will be implemented - propertyFilter.setOperationEditable(true); - - propertyFilter.setParameterName(PropertyConditionUtils.generateParameterName(property)); - propertyFilter.setDataLoader(dataLoader); - - propertyFilter.setValueComponent(generatePropertyFilterValueComponent(propertyFilter)); - - String valueString = conditionString.substring(separatorIndex + 1); - if (!Strings.isNullOrEmpty(valueString)) { - try { - Object parsedValue = filterUrlQueryParametersSupport - .parseValue(dataLoader.getContainer().getEntityMetaClass(), - property, operation.getType(), valueString); - propertyFilter.setValue(parsedValue); - } catch (Exception e) { - log.info("Cannot parse URL parameter. {}", e.toString()); - propertyFilter.setValue(null); - } - } - - return propertyFilter; + return createPropertyFilter(parsePropertyConditionModel(conditionString), dataLoader); } + /** + * @deprecated the value component is generated by the {@code PropertyFilterConverter} used in + * {@link #createPropertyFilter(ParsedPropertyCondition, DataLoader)} + */ + @Deprecated(since = "3.1", forRemoval = true) protected HasValueAndElement generatePropertyFilterValueComponent(PropertyFilter propertyFilter) { MetaClass metaClass = propertyFilter.getDataLoader().getContainer().getEntityMetaClass(); return getSingleFilterSupport().generateValueComponent(metaClass, @@ -625,6 +766,7 @@ protected MetadataTools getMetadataTools() { return metadataTools; } + protected SingleFilterSupport getSingleFilterSupport() { if (singleFilterSupport == null) { singleFilterSupport = applicationContext.getBean(SingleFilterSupport.class); @@ -660,6 +802,22 @@ protected record InitialState(Configuration configuration, Map defaultValues) { } + /** + * A URL property condition parsed into a plain description: the attribute path, the operation and + * the raw (still serialized) value. Permission checks and matching against the configuration run + * on this model; a filter component is created, and the value is deserialized (which may load a + * referenced entity), only for the conditions that survive them. + * + * @param property the entity attribute path + * @param operation the condition operation + * @param valueString the serialized condition value, or {@code null} if the URL carries none + */ + @Internal + protected record ParsedPropertyCondition(String property, + PropertyFilter.Operation operation, + @Nullable String valueString) { + } + /** * A node of the captured configuration structure: a filter component, its initial {@code modified} * flag, and, for a nested {@link LogicalFilterComponent}, its own ordered child nodes. diff --git a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/PropertyFilterUrlQueryParametersBinder.java b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/PropertyFilterUrlQueryParametersBinder.java index ee0793a4b1..1353a97c8f 100644 --- a/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/PropertyFilterUrlQueryParametersBinder.java +++ b/jmix-flowui/flowui/src/main/java/io/jmix/flowui/facet/urlqueryparameters/PropertyFilterUrlQueryParametersBinder.java @@ -128,14 +128,9 @@ public void updateState(QueryParameters queryParameters) { if (parameters.containsKey(getParameter())) { String serializedSettings = parameters.get(getParameter()).get(0); - int separatorIndex = serializedSettings.indexOf(SEPARATOR); - if (separatorIndex == -1) { - throw new IllegalStateException("Can't parse property filter settings: " + serializedSettings); - } - - String operationString = serializedSettings.substring(0, separatorIndex); + List tokens = filterUrlQueryParametersSupport.splitParameter(serializedSettings, 1); Operation operation = urlParamSerializer.deserialize(Operation.class, - filterUrlQueryParametersSupport.restoreSeparatorValue(operationString)); + filterUrlQueryParametersSupport.restoreSeparatorValue(tokens.get(0))); if (filter.isOperationEditable()) { filter.setOperation(operation); @@ -144,7 +139,7 @@ public void updateState(QueryParameters queryParameters) { return; } - String valueString = serializedSettings.substring(separatorIndex + 1); + String valueString = tokens.get(1); if (!Strings.isNullOrEmpty(valueString)) { MetaClass entityMetaClass = filter.getDataLoader().getContainer().getEntityMetaClass(); try { diff --git a/jmix-flowui/flowui/src/test/groovy/facet/url_query_parameters/FilterUrlQueryParametersSupportTest.groovy b/jmix-flowui/flowui/src/test/groovy/facet/url_query_parameters/FilterUrlQueryParametersSupportTest.groovy new file mode 100644 index 0000000000..18fc59c7cb --- /dev/null +++ b/jmix-flowui/flowui/src/test/groovy/facet/url_query_parameters/FilterUrlQueryParametersSupportTest.groovy @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package facet.url_query_parameters + +import io.jmix.flowui.facet.urlqueryparameters.FilterUrlQueryParametersSupport +import spock.lang.Specification + +/** + * Unit test for the shared parameter tokenizer used by the generic filter, property filter and + * data grid filter URL binders: {@code headTokens} structural tokens plus the remainder, which may + * itself contain separator characters and may be empty. + */ +class FilterUrlQueryParametersSupportTest extends Specification { + + def support = new FilterUrlQueryParametersSupport(null, null, null) + + def "splits a parameter into head tokens and the remainder"() { + expect: + support.splitParameter(parameter, headTokens) == expected + + where: + parameter | headTokens | expected + "name_contains_John" | 2 | ["name", "contains", "John"] + "name_contains_a_b_c" | 2 | ["name", "contains", "a_b_c"] + "name_equal_" | 2 | ["name", "equal", ""] + "operation_value" | 1 | ["operation", "value"] + "key_property_operation_va_lue" | 3 | ["key", "property", "operation", "va_lue"] + "whole" | 0 | ["whole"] + } + + def "a parameter with fewer separators than head tokens is rejected"() { + when: + support.splitParameter("name_contains", 2) + + then: + def e = thrown(IllegalStateException) + e.message.contains("name_contains") + } +} diff --git a/jmix-flowui/flowui/src/test/groovy/facet/url_query_parameters/GenericFilterReNavigationTest.groovy b/jmix-flowui/flowui/src/test/groovy/facet/url_query_parameters/GenericFilterReNavigationTest.groovy index 7cf3247738..997c0266f1 100644 --- a/jmix-flowui/flowui/src/test/groovy/facet/url_query_parameters/GenericFilterReNavigationTest.groovy +++ b/jmix-flowui/flowui/src/test/groovy/facet/url_query_parameters/GenericFilterReNavigationTest.groovy @@ -510,9 +510,9 @@ class GenericFilterReNavigationTest extends FlowuiTestSpecification { binder.updateState(QueryParameters.empty()) } - and: "the user changes the baseline operation once" + and: "the user changes the baseline operation once (client-driven: the group applies only on isFromClient)" loadCount.set(0) - nameFilter.setOperation(PropertyFilter.Operation.CONTAINS) + nameFilter.setOperationInternal(PropertyFilter.Operation.CONTAINS, true) then: "the loader is loaded exactly once — not once per accumulated (leaked) listener" loadCount.get() == 1 @@ -536,9 +536,9 @@ class GenericFilterReNavigationTest extends FlowuiTestSpecification { rootGroup.add(nameFilter) } - and: "the user changes the baseline operation once" + and: "the user changes the baseline operation once (a client-driven gesture)" loadCount.set(0) - nameFilter.setOperation(PropertyFilter.Operation.CONTAINS) + nameFilter.setOperationInternal(PropertyFilter.Operation.CONTAINS, true) then: "the loader is loaded exactly once — removeAll detached the stale listeners" loadCount.get() == 1 diff --git a/jmix-flowui/flowui/src/test/groovy/facet/url_query_parameters/GenericFilterUrlConditionModelTest.groovy b/jmix-flowui/flowui/src/test/groovy/facet/url_query_parameters/GenericFilterUrlConditionModelTest.groovy new file mode 100644 index 0000000000..7b54cb6ed1 --- /dev/null +++ b/jmix-flowui/flowui/src/test/groovy/facet/url_query_parameters/GenericFilterUrlConditionModelTest.groovy @@ -0,0 +1,282 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package facet.url_query_parameters + +import com.vaadin.flow.router.QueryParameters +import facet.url_query_parameters.view.GenericFilterConfigsTestView +import facet.url_query_parameters.view.GenericFilterEditableOpConfigTestView +import facet.url_query_parameters.view.GenericFilterUrlQueryParamsTestView +import io.jmix.core.querycondition.Condition +import io.jmix.core.querycondition.LogicalCondition +import io.jmix.core.querycondition.PropertyCondition +import io.jmix.flowui.component.propertyfilter.PropertyFilter +import io.jmix.flowui.facet.UrlQueryParametersFacet +import io.jmix.flowui.facet.urlqueryparameters.GenericFilterUrlQueryParametersBinder +import io.jmix.flowui.model.CollectionLoader +import org.springframework.boot.test.context.SpringBootTest +import test_support.spec.FlowuiTestSpecification + +/** + * The URL restore parses conditions into plain models first, validates them there, and creates a + * filter component (through the converter route) only for the conditions that survive. This spec + * pins the behaviors that the model-first flow guarantees: an unknown attribute degrades to a + * skipped condition instead of a failed navigation, a restored condition never leaks into the data + * loader condition outside the filter's own composition, and applying the URL state costs a single + * data load. + */ +@SpringBootTest +class GenericFilterUrlConditionModelTest extends FlowuiTestSpecification { + + @Override + void setup() { + registerViewBasePackages("facet.url_query_parameters", "io.jmix.flowui.app") + } + + def "a URL condition on an unknown attribute is skipped and does not fail the restore"() { + given: "a view with a generic filter" + def view = navigateToView(GenericFilterUrlQueryParamsTestView) + def binder = getBinder(view.urlQueryParameters) + + when: "the URL carries a condition on an attribute that does not exist" + binder.updateState(QueryParameters.simple( + [(binder.conditionParam): "property:nonexistent_contains_x"])) + + then: "the restore succeeds and the condition is not applied" + noExceptionThrown() + view.ownersFilter.currentConfiguration.rootLogicalFilterComponent.filterComponents.isEmpty() + } + + def "a restored condition appears in the loader condition exactly once"() { + given: + def view = navigateToView(GenericFilterUrlQueryParamsTestView) + def binder = getBinder(view.urlQueryParameters) + + when: "the URL carries a condition that is added to the current configuration" + binder.updateState(QueryParameters.simple( + [(binder.conditionParam): "property:name_contains_John"])) + + then: "the filter shows the condition" + def components = view.ownersFilter.currentConfiguration.rootLogicalFilterComponent.filterComponents + components.size() == 1 + (components.first() as PropertyFilter).value == "John" + + and: "the loader condition contains it exactly once - only through the filter's composition" + countPropertyConditions(view.ownersFilter.dataLoader.condition, "name") == 1 + } + + def "a condition rejected by the property filters predicate leaves no trace in the loader condition"() { + given: "a filter that does not allow filtering by 'name'" + def view = navigateToView(GenericFilterUrlQueryParamsTestView) + view.ownersFilter.setPropertyFiltersPredicate { mpp -> !"name".equals(mpp.toPathString()) } + def binder = getBinder(view.urlQueryParameters) + + when: + binder.updateState(QueryParameters.simple( + [(binder.conditionParam): "property:name_contains_John"])) + + then: "no component is added and nothing on 'name' reaches the loader condition" + view.ownersFilter.currentConfiguration.rootLogicalFilterComponent.filterComponents.isEmpty() + countPropertyConditions(view.ownersFilter.dataLoader.condition, "name") == 0 + } + + def "restoring a configuration with a changed operation fires no load of its own"() { + given: "a view with a design-time configuration whose condition operation is editable" + def view = navigateToView(GenericFilterEditableOpConfigTestView) + def binder = getBinder(view.urlQueryParameters) + int loads = 0 + (view.ownersFilter.dataLoader as CollectionLoader).addPostLoadListener { loads++ } + + when: "the URL selects the configuration and carries a different operation for its condition" + binder.updateState(new QueryParameters([ + (binder.configurationParam): List.of("byName"), + (binder.conditionParam) : List.of("property:name_not-equal_Bob")])) + + then: "the operation and the value are applied to the configuration's own condition" + def component = view.ownersFilter.currentConfiguration.rootLogicalFilterComponent.filterComponents + .first() as PropertyFilter + component.operation == PropertyFilter.Operation.NOT_EQUAL + component.value == "Bob" + + and: "the restore composed the loader condition without loading - the load belongs to the navigation itself" + loads == 0 + countPropertyConditions(view.ownersFilter.dataLoader.condition, "name") == 1 + } + + def "a malformed condition string is skipped and does not fail the restore"() { + given: + def view = navigateToView(GenericFilterUrlQueryParamsTestView) + def binder = getBinder(view.urlQueryParameters) + + when: "the URL carries a hand-edited condition without separators and one with an unknown operation" + binder.updateState(QueryParameters.simple([(binder.conditionParam): conditionString])) + + then: "the restore succeeds and the condition is not applied" + noExceptionThrown() + view.ownersFilter.currentConfiguration.rootLogicalFilterComponent.filterComponents.isEmpty() + + where: + conditionString << ["property:name", "property:name_garbage_x", "garbage", + "property:_contains_x", "property:__", "property:a.b.c_equal_x", + "property:name..weird_equal_x"] + } + + def "a LIST condition value from the URL is restored as a collection"() { + given: + def view = navigateToView(GenericFilterUrlQueryParamsTestView) + def binder = getBinder(view.urlQueryParameters) + + when: + binder.updateState(QueryParameters.simple( + [(binder.conditionParam): "property:name_in-list_John,Jane"])) + + then: + def component = view.ownersFilter.currentConfiguration.rootLogicalFilterComponent.filterComponents + .first() as PropertyFilter + component.operation == PropertyFilter.Operation.IN_LIST + component.value == ["John", "Jane"] + } + + def "a runtime configuration takes a matched operation change and a new condition from the URL without loading"() { + given: "a view with a runtime configuration built by the programmatic API" + def view = navigateToView(GenericFilterConfigsTestView) + def binder = getBinder(view.urlQueryParameters) + int loads = 0 + (view.ownersFilter.dataLoader as CollectionLoader).addPostLoadListener { loads++ } + + when: "the URL selects it, changes the operation of its own condition and adds a new one" + binder.updateState(new QueryParameters([ + (binder.configurationParam): List.of("active"), + (binder.conditionParam) : List.of( + "property:name_not-equal_Bob", + "property:email_contains_gmail")])) + + then: "the existing condition is updated in place and the new one is added as modified" + def components = view.ownersFilter.currentConfiguration.rootLogicalFilterComponent.filterComponents + components.size() == 2 + with(components.find { (it as PropertyFilter).property == "name" } as PropertyFilter) { + operation == PropertyFilter.Operation.NOT_EQUAL + value == "Bob" + } + with(components.find { (it as PropertyFilter).property == "email" } as PropertyFilter) { + operation == PropertyFilter.Operation.CONTAINS + value == "gmail" + } + view.ownersFilter.currentConfiguration.isFilterComponentModified( + components.find { (it as PropertyFilter).property == "email" }) + + and: "the restore fired no load of its own" + loads == 0 + } + + def "an unparsable condition value degrades to a condition without a value"() { + given: + def view = navigateToView(GenericFilterUrlQueryParamsTestView) + def binder = getBinder(view.urlQueryParameters) + + when: "the URL carries a resolvable attribute with a value that cannot be deserialized" + binder.updateState(QueryParameters.simple([(binder.conditionParam): conditionString])) + + then: "the condition is present but empty, and the restore succeeds" + noExceptionThrown() + def components = view.ownersFilter.currentConfiguration.rootLogicalFilterComponent.filterComponents + components.size() == 1 + (components.first() as PropertyFilter).value == null + + where: "a malformed UUID and an unparsable value of an embedded attribute" + conditionString << ["property:id_equal_not-a-uuid", "property:address_equal_x"] + } + + def "a condition with an operation not available for the attribute is skipped"() { + given: + def view = navigateToView(GenericFilterUrlQueryParamsTestView) + def binder = getBinder(view.urlQueryParameters) + + when: "the URL carries a string operation on a UUID attribute" + binder.updateState(QueryParameters.simple( + [(binder.conditionParam): "property:id_contains_x"])) + + then: "the restore succeeds and the condition is not applied" + noExceptionThrown() + view.ownersFilter.currentConfiguration.rootLogicalFilterComponent.filterComponents.isEmpty() + } + + def "an operation outside the component's operations list is not applied and does not fail the restore"() { + given: "a design-time configuration whose condition allows only EQUAL" + def view = navigateToView(GenericFilterEditableOpConfigTestView) + def binder = getBinder(view.urlQueryParameters) + def component = view.ownersFilter.getConfiguration("byName").rootLogicalFilterComponent.filterComponents + .first() as PropertyFilter + component.setOperationsList(List.of(PropertyFilter.Operation.EQUAL)) + + when: "the URL carries an operation outside that list" + binder.updateState(new QueryParameters([ + (binder.configurationParam): List.of("byName"), + (binder.conditionParam) : List.of("property:name_not-equal_Bob")])) + + then: "the restore succeeds and the component keeps its own operation" + noExceptionThrown() + component.operation == PropertyFilter.Operation.EQUAL + } + + @SuppressWarnings('GrDeprecatedAPIUsage') + def "deprecated component-based methods delegate to the model flow"() { + given: + def view = navigateToView(GenericFilterUrlQueryParamsTestView) + def binder = getBinder(view.urlQueryParameters) + def dataLoader = view.ownersFilter.dataLoader + + when: "the deprecated parser is called directly" + def component = binder.parsePropertyCondition("name_contains_a_b", dataLoader) as PropertyFilter + + then: "it returns a component built by the converter route, with delegated condition modification" + component.property == "name" + component.operation == PropertyFilter.Operation.CONTAINS + component.value == "a_b" + component.conditionModificationDelegated + + when: "the deprecated deserialization gets a valid condition and one on an unknown attribute" + def components = binder.deserializeConditions( + List.of("property:name_contains_x", "property:nonexistent_contains_x"), dataLoader) + + then: "only the valid condition yields a component" + components.size() == 1 + (components.first() as PropertyFilter).property == "name" + + and: "the deprecated permission check accepts the built component" + binder.isPermitted(dataLoader, components.first()) + } + + private static int countPropertyConditions(Condition condition, String property) { + if (condition instanceof LogicalCondition) { + int count = 0 + for (Condition nested : condition.conditions) { + count += countPropertyConditions(nested, property) + } + return count + } + if (condition instanceof PropertyCondition) { + return property == condition.property ? 1 : 0 + } + return 0 + } + + private static GenericFilterUrlQueryParametersBinder getBinder(UrlQueryParametersFacet facet) { + return facet.binders + .findAll { it instanceof GenericFilterUrlQueryParametersBinder } + .first() as GenericFilterUrlQueryParametersBinder + } +} diff --git a/jmix-flowui/flowui/src/test/java/facet/url_query_parameters/view/GenericFilterEditableOpConfigTestView.java b/jmix-flowui/flowui/src/test/java/facet/url_query_parameters/view/GenericFilterEditableOpConfigTestView.java new file mode 100644 index 0000000000..92f7060947 --- /dev/null +++ b/jmix-flowui/flowui/src/test/java/facet/url_query_parameters/view/GenericFilterEditableOpConfigTestView.java @@ -0,0 +1,37 @@ +/* + * Copyright 2026 Haulmont. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package facet.url_query_parameters.view; + +import com.vaadin.flow.router.Route; +import io.jmix.flowui.component.genericfilter.GenericFilter; +import io.jmix.flowui.facet.UrlQueryParametersFacet; +import io.jmix.flowui.view.StandardView; +import io.jmix.flowui.view.ViewComponent; +import io.jmix.flowui.view.ViewController; +import io.jmix.flowui.view.ViewDescriptor; + +@Route("GenericFilterEditableOpConfigTestView") +@ViewController +@ViewDescriptor("generic-filter-editable-op-config-test-view.xml") +public class GenericFilterEditableOpConfigTestView extends StandardView { + + @ViewComponent + public GenericFilter ownersFilter; + + @ViewComponent("urlQueryParameters") + public UrlQueryParametersFacet urlQueryParameters; +} diff --git a/jmix-flowui/flowui/src/test/resources/facet/url_query_parameters/view/generic-filter-editable-op-config-test-view.xml b/jmix-flowui/flowui/src/test/resources/facet/url_query_parameters/view/generic-filter-editable-op-config-test-view.xml new file mode 100644 index 0000000000..ce0a3e8f1d --- /dev/null +++ b/jmix-flowui/flowui/src/test/resources/facet/url_query_parameters/view/generic-filter-editable-op-config-test-view.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +