diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/RsqlValidationOracle.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/RsqlValidationOracle.java new file mode 100644 index 000000000..bb4b16817 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/RsqlValidationOracle.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.rsql; + +/** + * An interface declaration which validates an RSQL based query syntax and + * allows providing suggestions e.g. in case of syntax errors or current cursor + * position. + */ +@FunctionalInterface +public interface RsqlValidationOracle { + + /** + * Parses and validates an given RSQL based query syntax and provides + * suggestion based on syntax error and cursor positioning. + * + * @param rsqlQuery + * an RSQL based query string to parse. + * @param cursorPosition + * the position of the cursor to retrieve suggestions at the + * position. {@code -1} indicates for no cursor suggestion + * @return a validation oracle context providing information about syntax + * errors and possible suggestions for fixing the syntax error or at + * the cursor position to replace tokens + */ + ValidationOracleContext suggest(final String rsqlQuery, final int cursorPosition); + +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/SuggestToken.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/SuggestToken.java new file mode 100644 index 000000000..55f009adf --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/SuggestToken.java @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.rsql; + +/** + * A suggestion which contains the start and the end character position of the + * suggested token of the suggestion of the token and the actual suggestion. + */ +public class SuggestToken { + + private final int start; + private final int end; + private final String suggestion; + private final String tokenImageName; + + /** + * Constructor. + * + * @param start + * the character position of the start of the token + * @param end + * the character position of the end of the token + * @param the + * token image name which is currently parsed, e.g. entered image + * name is {@code na} which the suggestion can be based on and + * e.g. filter the suggestions. + * @param tokenImageName + * the entered name of the token, e.g. could be the beginning of + * the suggestion like 'na' or 'name' + * @param suggestion + * the token suggestion + */ + public SuggestToken(final int start, final int end, final String tokenImageName, final String suggestion) { + this.start = start; + this.end = end; + this.tokenImageName = tokenImageName; + this.suggestion = suggestion; + } + + public int getStart() { + return start; + } + + public int getEnd() { + return end; + } + + public String getSuggestion() { + return suggestion; + } + + public String getTokenImageName() { + return tokenImageName; + } + + @Override + public String toString() { + return "SuggestToken [start=" + start + ", end=" + end + ", suggestion=" + suggestion + "]"; + } +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/SuggestionContext.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/SuggestionContext.java new file mode 100644 index 000000000..2dd6531a6 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/SuggestionContext.java @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.rsql; + +import java.util.ArrayList; +import java.util.List; + +/** + * The context which holds suggestions for the current cursor position. + */ +public class SuggestionContext { + + private String rsqlQuery; + private int cursorPosition; + private List suggestions = new ArrayList<>(); + + /** + * Default constructor. + */ + public SuggestionContext() { + // nothing to initialize + } + + /** + * Constructor. + * + * @param rsqlQuery + * the original RSQL based query the suggestions based on + * @param cursorPosition + * the current cursor position + * @param suggestions + * the suggestions for the current cursor position + */ + public SuggestionContext(final String rsqlQuery, final int cursorPosition, final List suggestions) { + this.rsqlQuery = rsqlQuery; + this.cursorPosition = cursorPosition; + this.suggestions = suggestions; + } + + public List getSuggestions() { + return suggestions; + } + + public int getCursorPosition() { + return cursorPosition; + } + + public String getRsqlQuery() { + return rsqlQuery; + } + + public void setRsqlQuery(final String rsqlQuery) { + this.rsqlQuery = rsqlQuery; + } + + public void setCursorPosition(final int cursorPosition) { + this.cursorPosition = cursorPosition; + } + + public void setSuggestions(final List suggestions) { + this.suggestions = suggestions; + } +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/SyntaxErrorContext.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/SyntaxErrorContext.java new file mode 100644 index 000000000..107ff755a --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/SyntaxErrorContext.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.rsql; + +/** + * An syntax error context object which holds the character position of the + * syntax error and message. + */ +public class SyntaxErrorContext { + + private int characterPosition = -1; + private String errorMessage; + + /** + * Default constructor. + */ + public SyntaxErrorContext() { + // nothing to initialize + } + + /** + * Constructor. + * + * @param characterPosition + * the position of the character within the RSQL query string the + * error occurs. + * @param errorMessage + * the error message with further information + */ + public SyntaxErrorContext(final int characterPosition, final String errorMessage) { + this.characterPosition = characterPosition; + this.errorMessage = errorMessage; + } + + public int getCharacterPosition() { + return characterPosition; + } + + public void setCharacterPosition(final int characterPosition) { + this.characterPosition = characterPosition; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(final String errorMessage) { + this.errorMessage = errorMessage; + } +} diff --git a/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/ValidationOracleContext.java b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/ValidationOracleContext.java new file mode 100644 index 000000000..a6a71cf35 --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-api/src/main/java/org/eclipse/hawkbit/repository/rsql/ValidationOracleContext.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.rsql; + +/** + * A context object which contains information about validation and suggestions + * of a parsed RSQL query. + */ +public class ValidationOracleContext { + + private boolean syntaxError; + + private SuggestionContext suggestionContext; + + private SyntaxErrorContext syntaxErrorContext; + + public boolean isSyntaxError() { + return syntaxError; + } + + public SuggestionContext getSuggestionContext() { + return suggestionContext; + } + + public SyntaxErrorContext getSyntaxErrorContext() { + return syntaxErrorContext; + } + + public void setSyntaxError(final boolean syntaxError) { + this.syntaxError = syntaxError; + } + + public void setSuggestionContext(final SuggestionContext suggestionContext) { + this.suggestionContext = suggestionContext; + } + + public void setSyntaxErrorContext(final SyntaxErrorContext syntaxErrorContext) { + this.syntaxErrorContext = syntaxErrorContext; + } +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/RepositoryApplicationConfiguration.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/RepositoryApplicationConfiguration.java index a62de9a06..c38612095 100644 --- a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/RepositoryApplicationConfiguration.java +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/RepositoryApplicationConfiguration.java @@ -52,6 +52,8 @@ import org.eclipse.hawkbit.repository.jpa.model.helper.SystemManagementHolder; import org.eclipse.hawkbit.repository.jpa.model.helper.SystemSecurityContextHolder; import org.eclipse.hawkbit.repository.jpa.model.helper.TenantAwareHolder; import org.eclipse.hawkbit.repository.jpa.model.helper.TenantConfigurationManagementHolder; +import org.eclipse.hawkbit.repository.jpa.rsql.RsqlParserValidationOracle; +import org.eclipse.hawkbit.repository.rsql.RsqlValidationOracle; import org.eclipse.hawkbit.security.SecurityTokenGenerator; import org.eclipse.hawkbit.security.SystemSecurityContext; import org.eclipse.hawkbit.tenancy.TenantAware; @@ -92,6 +94,12 @@ public class RepositoryApplicationConfiguration extends JpaBaseConfiguration { @Autowired private EventBus eventBus; + @Bean + @ConditionalOnMissingBean + public RsqlValidationOracle rsqlValidationOracle() { + return new RsqlParserValidationOracle(); + } + /** * @return the {@link SystemSecurityContext} singleton bean which make it * accessible in beans which cannot access the service directly, diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/ParseExceptionWrapper.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/ParseExceptionWrapper.java new file mode 100644 index 000000000..08978eaaa --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/ParseExceptionWrapper.java @@ -0,0 +1,187 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.jpa.rsql; + +import java.lang.reflect.Field; +import java.util.Arrays; + +import com.google.common.base.Throwables; + +import cz.jirutka.rsql.parser.ParseException; + +/** + * A {@link ParseException} wrapper which allows to access the parsing + * information from the exception using reflection due there is no other access + * of this information. See issue for requesting feature + * {@link https://github.com/jirutka/rsql-parser/issues/22} + */ +public class ParseExceptionWrapper { + + private static final String FIELD_EXPECTED_TOKEN_SEQ = "expectedTokenSequences"; + private static final String FIELD_CURRENT_TOKEN = "currentToken"; + + private final ParseException parseException; + private final Class parseExceptionClass; + private Field expectedTokenSequenceField; + private Field currentTokenField; + + /** + * Constructor. + * + * @param parseException + * the original parsing exception object to access its field + * using reflection + */ + public ParseExceptionWrapper(final ParseException parseException) { + this.parseException = parseException; + parseExceptionClass = parseException.getClass(); + + try { + expectedTokenSequenceField = getAccessibleField(parseExceptionClass, FIELD_EXPECTED_TOKEN_SEQ); + } catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) { + expectedTokenSequenceField = null; + } + + try { + currentTokenField = getAccessibleField(parseExceptionClass, FIELD_CURRENT_TOKEN); + } catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) { + currentTokenField = null; + } + } + + public int[][] getExpectedTokenSequence() { + if (expectedTokenSequenceField == null) { + return new int[0][0]; + } + return (int[][]) getValue(expectedTokenSequenceField, parseException); + } + + public TokenWrapper getCurrentToken() { + if (currentTokenField == null) { + return null; + } + return new TokenWrapper(getValue(currentTokenField, parseException)); + } + + @Override + public String toString() { + return "ParseExceptionWrapper [getExpectedTokenSequence()=" + Arrays.toString(getExpectedTokenSequence()) + + ", getCurrentToken()=" + getCurrentToken() + "]"; + } + + private static Field getAccessibleField(final Class clazz, final String field) throws NoSuchFieldException { + final Field declaredField = clazz.getDeclaredField(field); + declaredField.setAccessible(true); + return declaredField; + } + + private static Object getValue(final Field field, final Object instance) { + try { + return field.get(instance); + } catch (IllegalArgumentException | IllegalAccessException e) { + throw Throwables.propagate(e); + } + } + + /** + * A {@link TokenWrapper} which wraps the + * {@code cz.jirutka.rsql.parser.Token} class of the {@link ParseException} + * which otherwise is not accessible. + */ + public static final class TokenWrapper { + + private static final String FIELD_NEXT = "next"; + private static final String FIELD_KIND = "kind"; + private static final String FIELD_IMAGE = "image"; + private static final String FIELD_BEGIN_COL = "beginColumn"; + private static final String FIELD_END_COL = "endColumn"; + + private final Object tokenInstance; + + private Field nextTokenField; + private Field kindTokenField; + private Field imageTokenField; + private Field beginColumnTokenField; + private Field endColumnTokenField; + + private TokenWrapper(final Object tokenField) { + this.tokenInstance = tokenField; + + try { + nextTokenField = getAccessibleField(tokenField.getClass(), FIELD_NEXT); + } catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) { + nextTokenField = null; + } + try { + kindTokenField = getAccessibleField(tokenField.getClass(), FIELD_KIND); + } catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) { + kindTokenField = null; + } + + try { + imageTokenField = getAccessibleField(tokenField.getClass(), FIELD_IMAGE); + } catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) { + imageTokenField = null; + } + + try { + beginColumnTokenField = getAccessibleField(tokenField.getClass(), FIELD_BEGIN_COL); + } catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) { + beginColumnTokenField = null; + } + + try { + endColumnTokenField = getAccessibleField(tokenField.getClass(), FIELD_END_COL); + } catch (@SuppressWarnings("squid:S1166") final NoSuchFieldException e) { + endColumnTokenField = null; + } + } + + public TokenWrapper getNext() { + final Object nextToken = getValue(nextTokenField, tokenInstance); + return nextToken != null ? new TokenWrapper(nextToken) : null; + + } + + public int getKind() { + if (kindTokenField == null) { + return 0; + } + return (int) getValue(kindTokenField, tokenInstance); + } + + public String getImage() { + if (imageTokenField == null) { + return null; + } + return (String) getValue(imageTokenField, tokenInstance); + } + + public int getBeginColumn() { + if (beginColumnTokenField == null) { + return 0; + } + return (int) getValue(beginColumnTokenField, tokenInstance); + } + + public int getEndColumn() { + if (endColumnTokenField == null) { + return 0; + } + return (int) getValue(endColumnTokenField, tokenInstance); + } + + @Override + public String toString() { + return "TokenWrapper [tokenInstance=" + tokenInstance + ", getNext()=" + getNext() + ", getKind()=" + + getKind() + ", getImage()=" + getImage() + ", getBeginColumn()=" + getBeginColumn() + + ", getEndColumn()=" + getEndColumn() + "]"; + } + } +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RsqlParserValidationOracle.java b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RsqlParserValidationOracle.java new file mode 100644 index 000000000..8eb21400f --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/main/java/org/eclipse/hawkbit/repository/jpa/rsql/RsqlParserValidationOracle.java @@ -0,0 +1,301 @@ +package org.eclipse.hawkbit.repository.jpa.rsql; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.hawkbit.repository.TargetFields; +import org.eclipse.hawkbit.repository.TargetManagement; +import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException; +import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException; +import org.eclipse.hawkbit.repository.jpa.rsql.ParseExceptionWrapper.TokenWrapper; +import org.eclipse.hawkbit.repository.rsql.RsqlValidationOracle; +import org.eclipse.hawkbit.repository.rsql.SuggestToken; +import org.eclipse.hawkbit.repository.rsql.SuggestionContext; +import org.eclipse.hawkbit.repository.rsql.SyntaxErrorContext; +import org.eclipse.hawkbit.repository.rsql.ValidationOracleContext; +import org.eclipse.persistence.exceptions.ConversionException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.orm.jpa.JpaSystemException; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Lists; +import com.google.common.collect.Multimap; + +import cz.jirutka.rsql.parser.ParseException; +import cz.jirutka.rsql.parser.RSQLParserException; + +/** + * An implementation of {@link RsqlValidationOracle} which retrieves the + * exception using the {@link ParseException} to retrieve the suggestions. + * + * The suggestion only works when there are syntax errors existing because the + * information about current and next tokens in the RSQL syntax are from the + * {@link ParseException}. + * + * There is a feature request on the GitHub project + * {@link https://github.com/jirutka/rsql-parser/issues/22}. + */ +public class RsqlParserValidationOracle implements RsqlValidationOracle { + + private static final Logger LOGGER = LoggerFactory.getLogger(RsqlParserValidationOracle.class); + + @Autowired + private TargetManagement targetManagement; + + @Override + public ValidationOracleContext suggest(final String rsqlQuery, final int cursorPosition) { + + final List expectedTokens = new ArrayList<>(); + final ValidationOracleContext context = new ValidationOracleContext(); + context.setSyntaxError(true); + final SuggestionContext suggestionContext = new SuggestionContext(); + context.setSuggestionContext(suggestionContext); + final SyntaxErrorContext errorContext = new SyntaxErrorContext(); + context.setSyntaxErrorContext(errorContext); + + try { + targetManagement.findTargetsAll(rsqlQuery, new PageRequest(0, 1)); + context.setSyntaxError(false); + suggestionContext.getSuggestions().addAll(getLogicalOperatorSuggestion(rsqlQuery)); + } catch (final RSQLParameterSyntaxException | RSQLParserException ex) { + setExceptionDetails(new Exception(ex.getCause().getCause()), expectedTokens); + errorContext.setErrorMessage(getCustomMessage(ex.getCause().getMessage(), expectedTokens)); + suggestionContext.setSuggestions(expectedTokens); + LOGGER.trace("Syntax exception on parsing :", ex); + } catch (final RSQLParameterUnsupportedFieldException | IllegalArgumentException ex) { + errorContext.setErrorMessage(getCustomMessage(ex.getMessage(), null)); + LOGGER.trace("Illegal argument on parsing :", ex); + } catch (@SuppressWarnings("squid:S1166") final ConversionException | JpaSystemException e) { + // noop + } + return context; + } + + private static Collection getLogicalOperatorSuggestion(final String rsqlQuery) { + final int currentQueryLength = rsqlQuery.length(); + // only return and/or suggestion when there is a space at the end + if (rsqlQuery.endsWith(" ")) { + final Collection tokenImages = TokenDescription.getTokenImage(TokenDescription.LOGICAL_OP); + final List logicalOps = new ArrayList<>(tokenImages.size()); + for (final String tokenImage : tokenImages) { + logicalOps.add(new SuggestToken(currentQueryLength, currentQueryLength + tokenImage.length(), null, + tokenImage)); + } + return logicalOps; + } + return Collections.emptyList(); + } + + private static void setExceptionDetails(final Exception ex, final List expectedTokens) { + expectedTokens.addAll(getNextTokens(ex)); + } + + private static List getNextTokens(final Exception e) { + final ParseException parseException = findParseException(e); + if (parseException != null) { + final List listTokens = new ArrayList<>(); + final ParseExceptionWrapper parseExceptionWrapper = new ParseExceptionWrapper(parseException); + final int[][] expectedTokenSequence = parseExceptionWrapper.getExpectedTokenSequence(); + final TokenWrapper currentToken = parseExceptionWrapper.getCurrentToken(); + final TokenWrapper nextToken = currentToken.getNext(); + final int currentTokenKind = currentToken.getKind(); + final String currentTokenImageName = currentToken.getImage(); + final int nextTokenBeginColumn = nextToken.getBeginColumn(); + final int currentTokenEndColumn = currentToken.getEndColumn(); + + // token == 5 is the field token, reverse engineering. + if (currentTokenKind == 5) { + final Optional> handleFieldTokenSuggestion = handleFieldTokenSuggestion( + currentTokenImageName, nextTokenBeginColumn, currentTokenEndColumn); + if (handleFieldTokenSuggestion.isPresent()) { + return handleFieldTokenSuggestion.get(); + } + } + + for (final int[] is : expectedTokenSequence) { + for (final int i : is) { + final Collection tokenImage = TokenDescription.getTokenImage(i); + if (tokenImage != null && !tokenImage.isEmpty()) { + tokenImage.forEach(image -> listTokens.add(new SuggestToken(currentTokenEndColumn + 1, + nextTokenBeginColumn + image.length(), null, image))); + } + } + } + return listTokens; + } + return Collections.emptyList(); + + } + + private static Optional> handleFieldTokenSuggestion(final String currentTokenImageName, + final int nextTokenBeginColumn, final int currentTokenEndColumn) { + final boolean containsDot = currentTokenImageName.indexOf('.') != -1; + if (shouldSuggestTopLevelFieldNames(currentTokenImageName, containsDot)) { + return Optional + .of(FieldNameDescription.toTopSuggestToken(nextTokenBeginColumn - currentTokenImageName.length(), + nextTokenBeginColumn + currentTokenImageName.length(), currentTokenImageName)); + } else if (shouldSuggestDotToken(currentTokenImageName, containsDot)) { + return Optional.of( + Lists.newArrayList(new SuggestToken(currentTokenEndColumn, nextTokenBeginColumn + 1, null, "."))); + } else if (shouldSuggestSubTokenFieldNames(currentTokenImageName, containsDot)) { + return handleSubtokenSuggestion(currentTokenImageName, nextTokenBeginColumn); + } + return Optional.empty(); + } + + private static boolean shouldSuggestSubTokenFieldNames(final String currentTokenImageName, + final boolean containsDot) { + return containsDot && !FieldNameDescription.containsValue(currentTokenImageName); + } + + private static boolean shouldSuggestDotToken(final String currentTokenImageName, final boolean containsDot) { + return !containsDot && FieldNameDescription.hasSubEntries(currentTokenImageName); + } + + private static boolean shouldSuggestTopLevelFieldNames(final String currentTokenImageName, + final boolean containsDot) { + return !containsDot && !FieldNameDescription.containsValue(currentTokenImageName) + && !FieldNameDescription.hasSubEntries(currentTokenImageName); + } + + private static Optional> handleSubtokenSuggestion(final String currentTokenImageName, + final int nextTokenBeginColumn) { + final String[] split = currentTokenImageName.split("\\."); + for (final String string : split) { + if (FieldNameDescription.containsValue(string)) { + final String subTokenImage = split.length > 1 ? split[1] : null; + final int subTokenBegin = nextTokenBeginColumn + currentTokenImageName.indexOf('.') + 1; + return Optional.of(FieldNameDescription.toSubSuggestToken(subTokenBegin, subTokenBegin + 1, string, + subTokenImage)); + } + } + return Optional.empty(); + } + + private static ParseException findParseException(final Throwable e) { + if (e != null && e instanceof ParseException) { + return (ParseException) e; + } else if (e != null && e.getCause() != null) { + return findParseException(e.getCause()); + } + return null; + } + + private static String getCustomMessage(final String message, final List expectedTokens) { + String builder = message; + if (message.contains(":")) { + builder = message.substring(message.indexOf(':') + 1, message.length()); + if (builder.indexOf("Was expecting") != -1) { + builder = builder.substring(0, builder.lastIndexOf("Was expecting")); + } + if (null != expectedTokens && !expectedTokens.isEmpty()) { + final StringBuilder tokens = new StringBuilder(); + expectedTokens.stream().forEach(value -> tokens.append(value.getSuggestion() + ",")); + builder = builder.concat("Was expecting :" + tokens.toString().substring(0, tokens.length() - 1)); + } + builder = builder.replace('\r', ' '); + builder = builder.replace('\n', ' '); + builder = builder.replaceAll(">", " "); + builder = builder.replaceAll("<", " "); + } + return builder; + } + + private static final class TokenDescription { + + private static final Multimap TOKEN_MAP = ArrayListMultimap.create(); + + private static final int LOGICAL_OP = 8; + private static final int COMPARATOR = 12; + + static { + TOKEN_MAP.put(LOGICAL_OP, "and"); + TOKEN_MAP.put(LOGICAL_OP, "or"); + TOKEN_MAP.put(COMPARATOR, "=="); + TOKEN_MAP.put(COMPARATOR, "!="); + TOKEN_MAP.put(COMPARATOR, "=ge="); + TOKEN_MAP.put(COMPARATOR, "=le="); + TOKEN_MAP.put(COMPARATOR, "=gt="); + TOKEN_MAP.put(COMPARATOR, "=lt="); + TOKEN_MAP.put(COMPARATOR, "=in="); + TOKEN_MAP.put(COMPARATOR, "=out="); + } + + private TokenDescription() { + + } + + private static Collection getTokenImage(final int tokenIndex) { + return TOKEN_MAP.get(tokenIndex); + } + + } + + private static final class FieldNameDescription { + + private static final Set FIELD_NAMES = Arrays.stream(TargetFields.values()) + .map(field -> field.toString().toLowerCase()).collect(Collectors.toSet()); + + private static final Map> SUB_NAMES = Arrays.stream(TargetFields.values()).collect( + Collectors.toMap(field -> field.toString().toLowerCase(), field -> field.getSubEntityAttributes())); + + private FieldNameDescription() { + + } + + private static boolean hasSubEntries(final String tokenImageName) { + final String tmpTokenName; + if (tokenImageName.contains(".")) { + final String[] split = tokenImageName.split("\\."); + if (split.length > 0) { + tmpTokenName = split[0]; + } else { + return false; + } + } else { + tmpTokenName = tokenImageName; + } + return Arrays.stream(TargetFields.values()).filter(field -> field.toString().equalsIgnoreCase(tmpTokenName)) + .map(field -> field.getSubEntityAttributes()).flatMap(subentities -> subentities.stream()) + .count() > 0; + } + + private static List toTopSuggestToken(final int beginToken, final int endToken, + final String tokenImageName) { + return FIELD_NAMES.stream() + .map(field -> new SuggestToken(beginToken, endToken, tokenImageName, field.toLowerCase())) + .collect(Collectors.toList()); + } + + private static List toSubSuggestToken(final int beginToken, final int endToken, + final String topToken, final String tokenImageName) { + return Arrays.stream(TargetFields.values()).filter(field -> field.toString().equalsIgnoreCase(topToken)) + .map(field -> field.getSubEntityAttributes()).flatMap(list -> list.stream()) + .map(subentity -> new SuggestToken(beginToken, endToken, tokenImageName, subentity)) + .collect(Collectors.toList()); + } + + private static boolean containsValue(final String imageName) { + if (imageName.contains(".")) { + final String[] split = imageName.split("\\."); + if (split.length > 1 && FIELD_NAMES.contains(split[0].toLowerCase())) { + return SUB_NAMES.get(split[0].toLowerCase()).stream() + .filter(subname -> new String(split[0] + "." + subname).equalsIgnoreCase(imageName)) + .count() > 0; + } + } + return FIELD_NAMES.stream().filter(value -> value.equalsIgnoreCase(imageName)).count() > 0; + } + } + +} diff --git a/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RsqlParserValidationOracleTest.java b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RsqlParserValidationOracleTest.java new file mode 100644 index 000000000..c1b43eecd --- /dev/null +++ b/hawkbit-repository/hawkbit-repository-jpa/src/test/java/org/eclipse/hawkbit/repository/jpa/rsql/RsqlParserValidationOracleTest.java @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.repository.jpa.rsql; + +import static org.fest.assertions.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.eclipse.hawkbit.repository.TargetFields; +import org.eclipse.hawkbit.repository.jpa.AbstractJpaIntegrationTest; +import org.eclipse.hawkbit.repository.rsql.RsqlValidationOracle; +import org.eclipse.hawkbit.repository.rsql.ValidationOracleContext; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import ru.yandex.qatools.allure.annotations.Description; +import ru.yandex.qatools.allure.annotations.Features; +import ru.yandex.qatools.allure.annotations.Stories; + +@Features("Component Tests - Repository") +@Stories("RSQL filter suggestion") +public class RsqlParserValidationOracleTest extends AbstractJpaIntegrationTest { + + @Autowired + private RsqlValidationOracle rsqlValidationOracle; + + private static final String[] OP_SUGGESTIONS = new String[] { "==", "!=", "=ge=", "=le=", "=gt=", "=lt=", "=in=", + "=out=" }; + private static final String[] FIELD_SUGGESTIONS = Arrays.stream(TargetFields.values()) + .map(field -> field.name().toLowerCase()).toArray(size -> new String[size]); + private static final String[] AND_OR_SUGGESTIONS = new String[] { "and", "or" }; + private static final String[] NAME_VERSION_SUGGESTIONS = new String[] { "name", "version" }; + + @Test + @Description("Verifies that suggestions contains all possible field names") + public void suggestionContainsAllFieldNames() { + final String rsqlQuery = "na"; + final List currentSuggestions = getSuggestions(rsqlQuery); + assertThat(currentSuggestions).containsOnly(FIELD_SUGGESTIONS); + } + + @Test + @Description("Verifies that suggestions only contains the allowed operators") + public void suggestionContainsOnlyOperators() { + final String rsqlQuery = "name"; + final List currentSuggestions = getSuggestions(rsqlQuery); + assertThat(currentSuggestions).containsOnly(OP_SUGGESTIONS); + } + + @Test + @Description("Verifies that suggestions only contains operator to combine RSQL filters (and, or)") + public void suggestionContainsOnlyAndOrOperator() { + final String rsqlQuery = "name==a "; + final List currentSuggestions = getSuggestions(rsqlQuery); + assertThat(currentSuggestions).containsOnly(AND_OR_SUGGESTIONS); + } + + @Test + @Description("Verifies that sub suggestions are shown") + public void suggestionContainsSubFieldSuggestions() { + final String rsqlQuery = "assignedds."; + final List currentSuggestions = getSuggestions(rsqlQuery); + assertThat(currentSuggestions).containsOnly(NAME_VERSION_SUGGESTIONS); + } + + private List getSuggestions(final String rsqlQuery) { + final ValidationOracleContext suggest = rsqlValidationOracle.suggest(rsqlQuery, -1); + final List currentSuggestions = suggest.getSuggestionContext().getSuggestions().stream() + .map(suggestion -> suggestion.getSuggestion()).collect(Collectors.toList()); + return currentSuggestions; + } + +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/AppWidgetSet.gwt.xml b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/AppWidgetSet.gwt.xml index c6ee9b5b5..2141eb6b1 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/AppWidgetSet.gwt.xml +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/AppWidgetSet.gwt.xml @@ -1,44 +1,33 @@ - + - + - - + + - + - + - + - + - + - + - + + - + + - + diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/AutoCompleteTextFieldComponent.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/AutoCompleteTextFieldComponent.java new file mode 100644 index 000000000..e3d766391 --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/AutoCompleteTextFieldComponent.java @@ -0,0 +1,255 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ui.filtermanagement; + +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.Executor; + +import javax.annotation.PostConstruct; + +import org.eclipse.hawkbit.repository.rsql.RsqlValidationOracle; +import org.eclipse.hawkbit.ui.common.builder.TextFieldBuilder; +import org.eclipse.hawkbit.ui.filtermanagement.event.CustomFilterUIEvent; +import org.eclipse.hawkbit.ui.filtermanagement.state.FilterManagementUIState; +import org.eclipse.hawkbit.ui.utils.SPUIComponentIdProvider; +import org.eclipse.hawkbit.ui.utils.SPUILabelDefinitions; +import org.eclipse.hawkbit.ui.utils.SPUIStyleDefinitions; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.vaadin.spring.events.EventBus; + +import com.vaadin.event.ShortcutListener; +import com.vaadin.server.FontAwesome; +import com.vaadin.shared.ui.label.ContentMode; +import com.vaadin.spring.annotation.SpringComponent; +import com.vaadin.spring.annotation.ViewScope; +import com.vaadin.ui.AbstractTextField.TextChangeEventMode; +import com.vaadin.ui.Alignment; +import com.vaadin.ui.HorizontalLayout; +import com.vaadin.ui.Label; +import com.vaadin.ui.TextField; +import com.vaadin.ui.UI; + +/** + * An textfield with the {@link TextFieldSuggestionBox} extension which shows + * suggestions in a suggestion-pop-up window while typing. + */ +@SpringComponent +@ViewScope +public class AutoCompleteTextFieldComponent extends HorizontalLayout { + + private static final long serialVersionUID = 1L; + + @Autowired + private FilterManagementUIState filterManagementUIState; + + @Autowired + private transient EventBus.SessionEventBus eventBus; + + @Autowired + private transient RsqlValidationOracle rsqlValidationOracle; + + @Autowired + @Qualifier("uiExecutor") + private transient Executor executor; + + private transient List listeners = new LinkedList<>(); + + private final Label validationIcon; + private final TextField queryTextField; + + /** + * Constructor. + */ + public AutoCompleteTextFieldComponent() { + + queryTextField = createSearchField(); + validationIcon = createStatusIcon(); + + setSizeUndefined(); + setSpacing(true); + addStyleName("custom-search-layout"); + addComponents(validationIcon, queryTextField); + setComponentAlignment(validationIcon, Alignment.TOP_CENTER); + } + + /** + * Called by the spring-framework when this bean has be post-constructed. + */ + @PostConstruct + public void postConstruct() { + new TextFieldSuggestionBox(rsqlValidationOracle, this).extend(queryTextField); + } + + /** + * Clears the textfield and resets the validation icon. + */ + public void clear() { + queryTextField.clear(); + validationIcon.setValue(FontAwesome.CHECK_CIRCLE.getHtml()); + validationIcon.setStyleName("hide-status-label"); + } + + @Override + public void focus() { + queryTextField.focus(); + } + + /** + * Adds the given listener + * + * @param textChangeListener + * the listener to be called in case of text changed + */ + public void addTextChangeListener(final FilterQueryChangeListener textChangeListener) { + listeners.add(textChangeListener); + } + + public void setValue(final String textValue) { + queryTextField.setValue(textValue); + } + + public String getValue() { + return queryTextField.getValue(); + } + + /** + * Called when the filter-query has been changed in the textfield, e.g. from + * client-side. + * + * @param currentText + * the current text of the textfield which has been changed + * @param valid + * {@code boolean} if the current text is RSQL syntax valid + * otherwise {@code false} + * @param validationMessage + * a message shown in case of syntax errors as tooltip + */ + public void onQueryFilterChange(final String currentText, final boolean valid, final String validationMessage) { + if (valid) { + showValidationSuccesIcon(currentText); + } else { + showValidationFailureIcon(validationMessage); + } + listeners.forEach(listener -> listener.queryChanged(valid, currentText)); + } + + /** + * Shows the validation success icon in the textfield + * + * @param text + * the text to store in the UI state object + */ + public void showValidationSuccesIcon(final String text) { + validationIcon.setValue(FontAwesome.CHECK_CIRCLE.getHtml()); + validationIcon.setStyleName(SPUIStyleDefinitions.SUCCESS_ICON); + filterManagementUIState.setFilterQueryValue(text); + filterManagementUIState.setIsFilterByInvalidFilterQuery(Boolean.FALSE); + } + + /** + * Shows the validation error icon in the textfield + * + * @param validationMessage + * the validation message which should be added to the error-icon + * tooltip + */ + public void showValidationFailureIcon(final String validationMessage) { + validationIcon.setValue(FontAwesome.TIMES_CIRCLE.getHtml()); + validationIcon.setStyleName(SPUIStyleDefinitions.ERROR_ICON); + validationIcon.setDescription(validationMessage); + filterManagementUIState.setFilterQueryValue(null); + filterManagementUIState.setIsFilterByInvalidFilterQuery(Boolean.TRUE); + } + + public boolean isValidationError() { + return validationIcon.getStyleName().equals(SPUIStyleDefinitions.ERROR_ICON); + } + + private TextField createSearchField() { + final TextField textField = new TextFieldBuilder().immediate(true).id("custom.query.text.Id") + .maxLengthAllowed(SPUILabelDefinitions.TARGET_FILTER_QUERY_TEXT_FIELD_LENGTH).buildTextComponent(); + textField.addStyleName("target-filter-textfield"); + textField.setWidth(900.0F, Unit.PIXELS); + textField.setTextChangeEventMode(TextChangeEventMode.EAGER); + textField.setImmediate(true); + textField.setTextChangeTimeout(100); + textField.addShortcutListener(new EnterShortCutListener()); + return textField; + } + + private static Label createStatusIcon() { + final Label statusIcon = new Label(); + statusIcon.setImmediate(true); + statusIcon.setContentMode(ContentMode.HTML); + statusIcon.setSizeFull(); + setInitialStatusIconStyle(statusIcon); + statusIcon.setId(SPUIComponentIdProvider.VALIDATION_STATUS_ICON_ID); + return statusIcon; + } + + private static void setInitialStatusIconStyle(final Label statusIcon) { + statusIcon.setValue(FontAwesome.CHECK_CIRCLE.getHtml()); + statusIcon.setStyleName("hide-status-label"); + } + + class StatusCircledAsync implements Runnable { + private final UI current; + + public StatusCircledAsync(final UI current) { + this.current = current; + } + + @Override + public void run() { + UI.setCurrent(current); + eventBus.publish(this, CustomFilterUIEvent.FILTER_TARGET_BY_QUERY); + } + } + + private final class EnterShortCutListener extends ShortcutListener { + + private static final long serialVersionUID = 1L; + + public EnterShortCutListener() { + super("Enter", KeyCode.ENTER, new int[0]); + } + + @Override + public void handleAction(final Object sender, final Object target) { + if (!isValidationError()) { + showValidationInProgress(); + executor.execute(new StatusCircledAsync(UI.getCurrent())); + } + } + + private void showValidationInProgress() { + validationIcon.setValue(null); + validationIcon.addStyleName("show-status-label"); + validationIcon.setStyleName(SPUIStyleDefinitions.TARGET_FILTER_SEARCH_PROGRESS_INDICATOR_STYLE); + } + } + + /** + * Change listener on the textfield. + */ + @FunctionalInterface + public interface FilterQueryChangeListener { + /** + * Called when the text has been changed and validated. + * + * @param valid + * indicates if the entered query text is valid + * @param query + * the entered query text + */ + void queryChanged(final boolean valid, final String query); + } +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/CreateOrUpdateFilterHeader.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/CreateOrUpdateFilterHeader.java index b6f186188..34607da2b 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/CreateOrUpdateFilterHeader.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/CreateOrUpdateFilterHeader.java @@ -8,7 +8,7 @@ */ package org.eclipse.hawkbit.ui.filtermanagement; -import java.util.concurrent.Executor; +import java.util.Optional; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; @@ -23,6 +23,7 @@ import org.eclipse.hawkbit.ui.common.builder.TextFieldBuilder; import org.eclipse.hawkbit.ui.components.SPUIButton; import org.eclipse.hawkbit.ui.components.SPUIComponentProvider; import org.eclipse.hawkbit.ui.decorators.SPUIButtonStyleSmallNoBorder; +import org.eclipse.hawkbit.ui.filtermanagement.AutoCompleteTextFieldComponent.FilterQueryChangeListener; import org.eclipse.hawkbit.ui.filtermanagement.event.CustomFilterUIEvent; import org.eclipse.hawkbit.ui.filtermanagement.state.FilterManagementUIState; import org.eclipse.hawkbit.ui.utils.I18N; @@ -31,7 +32,6 @@ import org.eclipse.hawkbit.ui.utils.SPUILabelDefinitions; import org.eclipse.hawkbit.ui.utils.SPUIStyleDefinitions; import org.eclipse.hawkbit.ui.utils.UINotification; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.vaadin.spring.events.EventBus; import org.vaadin.spring.events.EventScope; import org.vaadin.spring.events.annotation.EventBusListenerMethod; @@ -40,16 +40,11 @@ import com.google.common.base.Strings; import com.vaadin.event.FieldEvents.BlurEvent; import com.vaadin.event.FieldEvents.BlurListener; import com.vaadin.event.FieldEvents.TextChangeEvent; -import com.vaadin.event.FieldEvents.TextChangeListener; import com.vaadin.event.LayoutEvents.LayoutClickEvent; import com.vaadin.event.LayoutEvents.LayoutClickListener; -import com.vaadin.event.ShortcutAction.KeyCode; import com.vaadin.server.FontAwesome; -import com.vaadin.shared.ui.label.ContentMode; import com.vaadin.spring.annotation.SpringComponent; import com.vaadin.spring.annotation.ViewScope; -import com.vaadin.ui.AbstractField; -import com.vaadin.ui.AbstractTextField.TextChangeEventMode; import com.vaadin.ui.Alignment; import com.vaadin.ui.Button; import com.vaadin.ui.Button.ClickEvent; @@ -57,7 +52,6 @@ import com.vaadin.ui.HorizontalLayout; import com.vaadin.ui.Label; import com.vaadin.ui.Link; import com.vaadin.ui.TextField; -import com.vaadin.ui.UI; import com.vaadin.ui.VerticalLayout; import com.vaadin.ui.themes.ValoTheme; @@ -94,11 +88,10 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button private transient UiProperties uiProperties; @Autowired - @Qualifier("uiExecutor") - private transient Executor executor; + private transient EntityFactory entityFactory; @Autowired - private transient EntityFactory entityFactory; + private AutoCompleteTextFieldComponent queryTextField; private HorizontalLayout breadcrumbLayout; @@ -108,8 +101,6 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button private Label headerCaption; - private TextField queryTextField; - private TextField nameTextField; private Label nameLabel; @@ -120,10 +111,6 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button private Link helpLink; - private Label validationIcon; - - private HorizontalLayout searchLayout; - private String oldFilterName; private String oldFilterQuery; @@ -136,8 +123,6 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button private LayoutClickListener nameLayoutClickListner; - private boolean validationFailed = false; - /** * Initialize the Campaign Status History Header. */ @@ -170,8 +155,6 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button } else if (custFUIEvent == CustomFilterUIEvent.CREATE_NEW_FILTER_CLICK) { setUpCaptionLayout(true); resetComponents(); - } else if (custFUIEvent == CustomFilterUIEvent.UPDATE_TARGET_FILTER_SEARCH_ICON) { - UI.getCurrent().access(() -> updateStatusIconAfterTablePopulated()); } } @@ -183,38 +166,22 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button oldFilterQuery = filterManagementUIState.getTfQuery().get().getQuery(); } breadcrumbName.setValue(nameLabel.getValue()); - showValidationSuccesIcon(); + queryTextField.showValidationSuccesIcon(filterManagementUIState.getFilterQueryValue()); titleFilterIconsLayout.addStyleName(SPUIStyleDefinitions.TARGET_FILTER_CAPTION_LAYOUT); headerCaption.setVisible(false); setUpCaptionLayout(false); } private void resetComponents() { + queryTextField.clear(); + queryTextField.focus(); headerCaption.setVisible(true); breadcrumbName.setValue(headerCaption.getValue()); nameLabel.setValue(""); - queryTextField.setValue(""); - setInitialStatusIconStyle(validationIcon); - validationFailed = false; saveButton.setEnabled(false); titleFilterIconsLayout.removeStyleName(SPUIStyleDefinitions.TARGET_FILTER_CAPTION_LAYOUT); } - private Label createStatusIcon() { - final Label statusIcon = new Label(); - statusIcon.setImmediate(true); - statusIcon.setContentMode(ContentMode.HTML); - statusIcon.setSizeFull(); - setInitialStatusIconStyle(statusIcon); - statusIcon.setId(SPUIComponentIdProvider.VALIDATION_STATUS_ICON_ID); - return statusIcon; - } - - private void setInitialStatusIconStyle(final Label statusIcon) { - statusIcon.setValue(FontAwesome.CHECK_CIRCLE.getHtml()); - statusIcon.setStyleName("hide-status-label"); - } - private void createComponents() { breadcrumbButton = createBreadcrumbButton(); @@ -227,10 +194,6 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button nameTextField = createNameTextField(); nameTextField.setWidth(380, Unit.PIXELS); - queryTextField = createSearchField(); - addSearchLisenter(); - - validationIcon = createStatusIcon(); saveButton = createSaveButton(); helpLink = SPUIComponentProvider.getHelpLink(uiProperties.getLinks().getDocumentation().getTargetfilterView()); @@ -282,6 +245,14 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button } } }; + + queryTextField.addTextChangeListener(new FilterQueryChangeListener() { + @Override + public void queryChanged(final boolean valid, final String query) { + enableDisableSaveButton(!valid, query); + } + }); + } private void onFilterNameChange(final TextChangeEvent event) { @@ -318,15 +289,6 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button titleFilterLayout.setComponentAlignment(titleFilterIconsLayout, Alignment.TOP_LEFT); titleFilterLayout.setComponentAlignment(closeIcon, Alignment.TOP_RIGHT); - validationIcon = createStatusIcon(); - - searchLayout = new HorizontalLayout(); - searchLayout.setSizeUndefined(); - searchLayout.setSpacing(false); - searchLayout.addComponents(validationIcon, queryTextField); - searchLayout.addStyleName("custom-search-layout"); - searchLayout.setComponentAlignment(validationIcon, Alignment.TOP_CENTER); - final HorizontalLayout iconLayout = new HorizontalLayout(); iconLayout.setSizeUndefined(); iconLayout.setSpacing(false); @@ -335,7 +297,7 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button final HorizontalLayout queryLayout = new HorizontalLayout(); queryLayout.setSizeUndefined(); queryLayout.setSpacing(true); - queryLayout.addComponents(searchLayout, iconLayout); + queryLayout.addComponents(queryTextField, iconLayout); addComponent(breadcrumbLayout); addComponent(titleFilterLayout); @@ -358,58 +320,6 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button } } - private void addSearchLisenter() { - queryTextField.addTextChangeListener(new TextChangeListener() { - private static final long serialVersionUID = -6668604418942689391L; - - @Override - public void textChange(final TextChangeEvent event) { - validationIcon.addStyleName("show-status-label"); - showValidationInProgress(); - onQueryChange(event.getText()); - executor.execute(new StatusCircledAsync(UI.getCurrent())); - } - - }); - } - - class StatusCircledAsync implements Runnable { - private final UI current; - - public StatusCircledAsync(final UI current) { - this.current = current; - } - - @Override - public void run() { - UI.setCurrent(current); - eventBus.publish(this, CustomFilterUIEvent.FILTER_TARGET_BY_QUERY); - } - } - - private void onQueryChange(final String input) { - if (!Strings.isNullOrEmpty(input)) { - final ValidationResult validationResult = FilterQueryValidation.getExpectedTokens(input); - if (!validationResult.getIsValidationFailed()) { - filterManagementUIState.setFilterQueryValue(input); - filterManagementUIState.setIsFilterByInvalidFilterQuery(Boolean.FALSE); - validationFailed = false; - } else { - validationFailed = true; - filterManagementUIState.setFilterQueryValue(null); - filterManagementUIState.setIsFilterByInvalidFilterQuery(Boolean.TRUE); - validationIcon.setDescription(validationResult.getMessage()); - showValidationFailureIcon(); - } - enableDisableSaveButton(validationFailed, input); - } else { - setInitialStatusIconStyle(validationIcon); - filterManagementUIState.setFilterQueryValue(null); - filterManagementUIState.setIsFilterByInvalidFilterQuery(Boolean.TRUE); - } - queryTextField.setValue(input); - } - private void enableDisableSaveButton(final boolean validationFailed, final String query) { if (validationFailed || (isNameAndQueryEmpty(nameTextField.getValue(), query) || (query.equals(oldFilterQuery) && nameTextField.getValue().equals(oldFilterName)))) { @@ -428,21 +338,6 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button return false; } - private void showValidationSuccesIcon() { - validationIcon.setValue(FontAwesome.CHECK_CIRCLE.getHtml()); - validationIcon.setStyleName(SPUIStyleDefinitions.SUCCESS_ICON); - } - - private void showValidationFailureIcon() { - validationIcon.setValue(FontAwesome.TIMES_CIRCLE.getHtml()); - validationIcon.setStyleName(SPUIStyleDefinitions.ERROR_ICON); - } - - private void showValidationInProgress() { - validationIcon.setValue(null); - validationIcon.setStyleName(SPUIStyleDefinitions.TARGET_FILTER_SEARCH_PROGRESS_INDICATOR_STYLE); - } - private SPUIButton createSearchResetIcon() { final SPUIButton button = (SPUIButton) SPUIComponentProvider.getButton("create.custom.filter.close.Id", "", "", null, false, FontAwesome.TIMES, SPUIButtonStyleSmallNoBorder.class); @@ -450,18 +345,6 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button return button; } - private static TextField createSearchField() { - final TextField textField = new TextFieldBuilder().immediate(true).id("custom.query.text.Id") - .maxLengthAllowed(SPUILabelDefinitions.TARGET_FILTER_QUERY_TEXT_FIELD_LENGTH).buildTextComponent(); - textField.addStyleName("target-filter-textfield"); - textField.setWidth(900.0F, Unit.PIXELS); - textField.setTextChangeEventMode(TextChangeEventMode.LAZY); - textField.setTextChangeTimeout(1000); - - textField.addShortcutListener(new AbstractField.FocusShortcut(textField, KeyCode.ENTER)); - return textField; - } - private void closeFilterLayout() { filterManagementUIState.setFilterQueryValue(null); filterManagementUIState.setCreateFilterBtnClicked(false); @@ -508,7 +391,11 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button } private void updateCustomFilter() { - final TargetFilterQuery targetFilterQuery = filterManagementUIState.getTfQuery().get(); + final Optional tfQuery = filterManagementUIState.getTfQuery(); + if (!tfQuery.isPresent()) { + return; + } + final TargetFilterQuery targetFilterQuery = tfQuery.get(); targetFilterQuery.setName(nameTextField.getValue()); targetFilterQuery.setQuery(queryTextField.getValue()); final TargetFilterQuery updatedTargetFilter = targetFilterQueryManagement @@ -545,13 +432,6 @@ public class CreateOrUpdateFilterHeader extends VerticalLayout implements Button return true; } - private void updateStatusIconAfterTablePopulated() { - queryTextField.focus(); - if (!validationFailed && !Strings.isNullOrEmpty(queryTextField.getValue())) { - showValidationSuccesIcon(); - } - } - private void showCustomFiltersView() { eventBus.publish(this, CustomFilterUIEvent.SHOW_FILTER_MANAGEMENT); } diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterManagementView.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterManagementView.java index 40e6306b0..88f7c003d 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterManagementView.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterManagementView.java @@ -94,8 +94,7 @@ public class FilterManagementView extends VerticalLayout implements View { void onEvent(final CustomFilterUIEvent custFilterUIEvent) { if (custFilterUIEvent == CustomFilterUIEvent.TARGET_FILTER_DETAIL_VIEW) { viewTargetFilterDetailLayout(); - } else if (custFilterUIEvent == CustomFilterUIEvent.CREATE_NEW_FILTER_CLICK - || custFilterUIEvent == CustomFilterUIEvent.FILTER_TARGET_BY_QUERY) { + } else if (custFilterUIEvent == CustomFilterUIEvent.CREATE_NEW_FILTER_CLICK) { this.getUI().access(() -> viewCreateTargetFilterLayout()); } else if (custFilterUIEvent == CustomFilterUIEvent.EXIT_CREATE_OR_UPDATE_FILTRER_VIEW || custFilterUIEvent == CustomFilterUIEvent.SHOW_FILTER_MANAGEMENT) { diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterQueryValidation.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterQueryValidation.java deleted file mode 100644 index 6d1f1a7ab..000000000 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/FilterQueryValidation.java +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Copyright (c) 2015 Bosch Software Innovations GmbH and others. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.eclipse.hawkbit.ui.filtermanagement; - -import java.lang.reflect.Field; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -import org.eclipse.hawkbit.repository.TargetFields; -import org.eclipse.hawkbit.repository.TargetManagement; -import org.eclipse.hawkbit.repository.exception.RSQLParameterSyntaxException; -import org.eclipse.hawkbit.repository.exception.RSQLParameterUnsupportedFieldException; -import org.eclipse.hawkbit.ui.utils.SpringContextHelper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.data.domain.PageRequest; - -import cz.jirutka.rsql.parser.ParseException; -import cz.jirutka.rsql.parser.RSQLParserException; - -/** - * - * Validates the target filter query. - * - */ -public final class FilterQueryValidation { - - private static final Logger LOGGER = LoggerFactory.getLogger(FilterQueryValidation.class); - - private FilterQueryValidation() { - - } - - /** - * method for get ExpectedTokens. - * - * @param input - * @param entityManager - * @return - */ - public static ValidationResult getExpectedTokens(final String input) { - - final TokenDescription tokenDesc = new TokenDescription(); - final ValidationResult result = new ValidationResult(); - final List expectedTokens = new ArrayList<>(); - try { - - final TargetManagement management = SpringContextHelper.getBean(TargetManagement.class); - management.findTargetsAll(input, new PageRequest(0, 100)); - } catch (final RSQLParameterSyntaxException ex) { - setExceptionDetails(new Exception(ex.getCause().getCause()), expectedTokens, result, tokenDesc); - result.setMessage(getCustomMessage(ex.getCause().getMessage(), result.getExpectedTokens())); - result.setIsValidationFailed(Boolean.TRUE); - LOGGER.trace("Syntax exception on parsing :", ex); - } catch (final RSQLParserException ex) { - setExceptionDetails(ex, expectedTokens, result, tokenDesc); - result.setMessage(getCustomMessage(ex.getMessage(), result.getExpectedTokens())); - result.setIsValidationFailed(Boolean.TRUE); - LOGGER.trace("Exception on parsing :", ex); - } catch (final IllegalArgumentException ex) { - result.setMessage(getCustomMessage(ex.getMessage(), null)); - result.setIsValidationFailed(Boolean.TRUE); - LOGGER.trace("Illegal argument on parsing :", ex); - } catch (final RSQLParameterUnsupportedFieldException ex) { - result.setMessage(getCustomMessage(ex.getMessage(), null)); - result.setIsValidationFailed(Boolean.TRUE); - LOGGER.trace("Unsupported field on parsing :", ex); - } - return result; - - } - - private static void setExceptionDetails(final Exception ex, final List expectedTokens, - final ValidationResult result, final TokenDescription tokenDesc) { - for (final Integer node : getNextTokens(ex)) { - if (node != 12) { - expectedTokens.add(tokenDesc.getTokenImage()[node]); - } - } - final List customExpectTokenList = processExpectedTokens(getNextTokens(ex)); - if (!customExpectTokenList.isEmpty()) { - result.setExpectedTokens(customExpectTokenList); - } else { - result.setExpectedTokens(expectedTokens); - } - } - - /** - * method for process ExpectedTokens. - * - * @param expectedTokens - * @return - */ - // Exception squid:S2095 - see - // https://jira.sonarsource.com/browse/SONARJAVA-1478 - @SuppressWarnings({ "squid:S2095" }) - public static List processExpectedTokens(final List expectedTokens) { - final List expectToken = new ArrayList<>(); - if (expectedTokens.size() == 2 && expectedTokens.contains(9) && expectedTokens.contains(4)) { - final List expectedFieldList = Arrays.stream(TargetFields.values()).map(v -> v.name().toLowerCase()) - .collect(Collectors.toList()); - expectToken.addAll(expectedFieldList); - expectToken.add("assignedds.name"); - expectToken.add("assignedds.version"); - } - return expectToken; - } - - /** - * Method To Get Next Token. - * - * @param e - * . - * @return list. - */ - public static List getNextTokens(final Exception e) { - Throwable parseException = e.getCause(); - final List listTokens = new ArrayList<>(); - if (parseException != null) { - do { - if (parseException instanceof ParseException) { - try { - Field declaredField; - declaredField = parseException.getClass().getDeclaredField("expectedTokenSequences"); - int[][] tokens; - tokens = (int[][]) declaredField.get(parseException); - for (final int[] is : tokens) { - for (final int i : is) { - listTokens.add(i); - } - } - return listTokens; - } catch (SecurityException | NoSuchFieldException | IllegalArgumentException - | IllegalAccessException ex) { - LOGGER.info("Exception on parsing :", ex); - } - - } else { - return listTokens; - } - } while ((parseException = parseException.getCause()) != null); - } - return Collections.emptyList(); - } - - /** - * To Get Custom Message. - * - * @param message - * @param expectedTokens - * @return String. - */ - public static String getCustomMessage(final String message, final List expectedTokens) { - String builder = message; - if (message.contains(":")) { - builder = message.substring(message.indexOf(':') + 1, message.length()); - if (builder.indexOf("Was expecting") != -1) { - builder = builder.substring(0, builder.lastIndexOf("Was expecting")); - } - if (null != expectedTokens && !expectedTokens.isEmpty()) { - final StringBuilder tokens = new StringBuilder(); - expectedTokens.stream().forEach(value -> tokens.append(value + ",")); - builder = builder.concat("Was expecting :" + tokens.toString().substring(0, tokens.length() - 1)); - } - builder = builder.replace('\r', ' '); - builder = builder.replace('\n', ' '); - builder = builder.replaceAll(">", " "); - builder = builder.replaceAll("<", " "); - } - return builder; - } - -} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterHeader.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterHeader.java index ada3e600a..a58b40a98 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterHeader.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterHeader.java @@ -114,6 +114,7 @@ public class TargetFilterHeader extends VerticalLayout { } private void addNewFilter() { + filterManagementUIState.setTfQuery(null); filterManagementUIState.setFilterQueryValue(null); filterManagementUIState.setCreateFilterBtnClicked(true); eventBus.publish(this, CustomFilterUIEvent.CREATE_NEW_FILTER_CLICK); diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterTable.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterTable.java index 5ee3be084..010e37bde 100644 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterTable.java +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TargetFilterTable.java @@ -225,7 +225,6 @@ public class TargetFilterTable extends Table { final String targetFilterName = (String) ((Button) event.getComponent()).getData(); final TargetFilterQuery targetFilterQuery = targetFilterQueryManagement .findTargetFilterQueryByName(targetFilterName); - filterManagementUIState.setTfQuery(targetFilterQuery); filterManagementUIState.setFilterQueryValue(targetFilterQuery.getQuery()); filterManagementUIState.setEditViewDisplayed(true); diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TextFieldSuggestionBox.gwt.xml b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TextFieldSuggestionBox.gwt.xml new file mode 100644 index 000000000..43f76950d --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TextFieldSuggestionBox.gwt.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TextFieldSuggestionBox.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TextFieldSuggestionBox.java new file mode 100644 index 000000000..3c3b63bee --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TextFieldSuggestionBox.java @@ -0,0 +1,83 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ui.filtermanagement; + +import java.util.stream.Collectors; + +import org.eclipse.hawkbit.repository.rsql.RsqlValidationOracle; +import org.eclipse.hawkbit.repository.rsql.SuggestionContext; +import org.eclipse.hawkbit.repository.rsql.ValidationOracleContext; +import org.eclipse.hawkbit.ui.filtermanagement.client.SuggestTokenDto; +import org.eclipse.hawkbit.ui.filtermanagement.client.SuggestionContextDto; +import org.eclipse.hawkbit.ui.filtermanagement.client.TextFieldSuggestionBoxClientRpc; +import org.eclipse.hawkbit.ui.filtermanagement.client.TextFieldSuggestionBoxServerRpc; + +import com.vaadin.server.AbstractExtension; +import com.vaadin.ui.TextField; + +/** + * Extension for the AutoCompleteTexfield. + * + */ +public class TextFieldSuggestionBox extends AbstractExtension implements TextFieldSuggestionBoxServerRpc { + + private static final long serialVersionUID = 1L; + private final transient RsqlValidationOracle rsqlValidationOracle; + private final AutoCompleteTextFieldComponent autoCompleteTextFieldComponent; + + /** + * Constructor. + * + * @param autoCompleteTextFieldComponent + * @param rsqlValidationOracle + * the suggestion oracle where to retrieve the suggestions from + */ + public TextFieldSuggestionBox(final RsqlValidationOracle rsqlValidationOracle, + final AutoCompleteTextFieldComponent autoCompleteTextFieldComponent) { + this.rsqlValidationOracle = rsqlValidationOracle; + this.autoCompleteTextFieldComponent = autoCompleteTextFieldComponent; + + registerRpc(this, TextFieldSuggestionBoxServerRpc.class); + } + + /** + * Add this extension to the target connector. This method is protected to + * allow subclasses to require a more specific type of target. + * + * @param target + * the connector to attach this extension to + */ + public void extend(final TextField target) { + super.extend(target); + } + + @Override + public void suggest(final String text, final int cursor) { + final ValidationOracleContext suggest = rsqlValidationOracle.suggest(text, cursor); + updateValidationIcon(suggest, text); + getRpcProxy(TextFieldSuggestionBoxClientRpc.class).showSuggestions(mapToDto(suggest.getSuggestionContext())); + } + + private static SuggestionContextDto mapToDto(final SuggestionContext suggestionContext) { + return new SuggestionContextDto(suggestionContext.getCursorPosition(), + suggestionContext.getSuggestions().stream() + .filter(suggestion -> suggestion.getTokenImageName() == null + || suggestion.getSuggestion().contains(suggestion.getTokenImageName())) + .map(suggestion -> new SuggestTokenDto(suggestion.getStart(), suggestion.getEnd(), + suggestion.getSuggestion())) + .collect(Collectors.toList())); + + } + + private void updateValidationIcon(final ValidationOracleContext suggest, final String text) { + final String errorMessage = (suggest.getSyntaxErrorContext() != null) + ? suggest.getSyntaxErrorContext().getErrorMessage() : null; + autoCompleteTextFieldComponent.onQueryFilterChange(text, !suggest.isSyntaxError(), errorMessage); + } +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TokenDescription.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TokenDescription.java deleted file mode 100644 index 81f222cee..000000000 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/TokenDescription.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) 2015 Bosch Software Innovations GmbH and others. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.eclipse.hawkbit.ui.filtermanagement; - -import java.util.Arrays; - -/** - * - * Available token details. - * - * - * - */ -public class TokenDescription { - - /** Literal token values. */ - private static final String[] TOKEN_IMAGE = { "", "\" \"", "\"\\t\"", "", "", - "", "", "", "", "\"(\"", "\")\"", "<==>|", ">=|<=", }; - - public String[] getTokenImage() { - return Arrays.copyOf(TOKEN_IMAGE, TOKEN_IMAGE.length); - } - -} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/ValidationResult.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/ValidationResult.java deleted file mode 100644 index 68d80fc62..000000000 --- a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/ValidationResult.java +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright (c) 2015 Bosch Software Innovations GmbH and others. - * - * All rights reserved. This program and the accompanying materials - * are made available under the terms of the Eclipse Public License v1.0 - * which accompanies this distribution, and is available at - * http://www.eclipse.org/legal/epl-v10.html - */ -package org.eclipse.hawkbit.ui.filtermanagement; - -import java.util.ArrayList; -import java.util.List; - -/** - * Query validation result with expected token on error. - * - * - * - */ -public class ValidationResult { - - private List expectedTokens = new ArrayList<>(); - - private String message; - - private Boolean isValidationFailed = Boolean.FALSE; - - /** - * @return the isValidationFailed - */ - public Boolean getIsValidationFailed() { - return isValidationFailed; - } - - /** - * @param isValidationFailed - * the isValidationFailed to set - */ - public void setIsValidationFailed(final Boolean isValidationFailed) { - this.isValidationFailed = isValidationFailed; - } - - /** - * @return the expectedTokens - */ - public List getExpectedTokens() { - return expectedTokens; - } - - /** - * @param expectedTokens - * the expectedTokens to set - */ - public void setExpectedTokens(final List expectedTokens) { - this.expectedTokens = expectedTokens; - } - - /** - * @return the message - */ - public String getMessage() { - return message; - } - - /** - * @param message - * the message to set - */ - public void setMessage(final String message) { - this.message = message; - } - -} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/AutoCompleteTextFieldConnector.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/AutoCompleteTextFieldConnector.java new file mode 100644 index 000000000..949b2a39d --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/AutoCompleteTextFieldConnector.java @@ -0,0 +1,137 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ui.filtermanagement.client; + +import java.util.List; + +import org.eclipse.hawkbit.ui.filtermanagement.TextFieldSuggestionBox; + +import com.google.gwt.event.dom.client.KeyCodes; +import com.google.gwt.event.dom.client.KeyUpEvent; +import com.google.gwt.event.dom.client.KeyUpHandler; +import com.google.gwt.user.client.ui.MenuItem; +import com.vaadin.client.ComponentConnector; +import com.vaadin.client.ServerConnector; +import com.vaadin.client.extensions.AbstractExtensionConnector; +import com.vaadin.client.ui.VOverlay; +import com.vaadin.client.ui.VTextField; +import com.vaadin.shared.ui.Connect; + +/** + * Connector for the AutoCompleteTextField which automatically listens to + * key-events to show pop-up panel with entered suggestions based on the + * {@link TextFieldSuggestionBoxServerRpc} call. + * + */ +@SuppressWarnings({ "deprecation", "squid:CallToDeprecatedMethod" }) +// need to use VOverlay because otherwise it's not in the correct theme +// widget @see com.vaadin.client.ui.VOverlay.getOverlayContainer() +@Connect(TextFieldSuggestionBox.class) +public class AutoCompleteTextFieldConnector extends AbstractExtensionConnector { + + private static final long serialVersionUID = 1L; + + private final transient SuggestionsSelectList select = new SuggestionsSelectList(); + private transient VTextField textFieldWidget; + + private final TextFieldSuggestionBoxServerRpc rpc = getRpcProxy(TextFieldSuggestionBoxServerRpc.class); + + private final transient VOverlay panel = new VOverlay(true, false, true); + + @Override + protected void init() { + super.init(); + + registerRpc(TextFieldSuggestionBoxClientRpc.class, new TextFieldSuggestionBoxClientRpc() { + private static final long serialVersionUID = 1L; + + @Override + public void showSuggestions(final SuggestionContextDto suggestContext) { + select.clearItems(); + if (suggestContext != null) { + final List suggestions = suggestContext.getSuggestions(); + if (suggestions != null && !suggestions.isEmpty()) { + select.addItems(suggestions, textFieldWidget, panel, rpc); + panel.showRelativeTo(textFieldWidget); + select.moveSelectionDown(); + return; + } + } + panel.hide(); + } + }); + } + + @Override + protected void extend(final ServerConnector target) { + textFieldWidget = (VTextField) ((ComponentConnector) target).getWidget(); + textFieldWidget.setImmediate(true); + textFieldWidget.textChangeEventMode = "EAGER"; + panel.setWidget(select); + panel.setStyleName("suggestion-popup"); + panel.setOwner(textFieldWidget); + + textFieldWidget.addKeyUpHandler(new KeyUpHandler() { + @Override + public void onKeyUp(final KeyUpEvent event) { + if (panel.isAttached()) { + handlePanelEventDelegation(event); + } else { + doAskForSuggestion(); + } + } + }); + } + + private void handlePanelEventDelegation(final KeyUpEvent event) { + switch (event.getNativeKeyCode()) { + case KeyCodes.KEY_DOWN: + arrowKeyDown(event); + break; + case KeyCodes.KEY_UP: + arrorKeyUp(event); + break; + case KeyCodes.KEY_ESCAPE: + escapeKey(); + break; + case KeyCodes.KEY_ENTER: + enterKey(); + break; + default: + doAskForSuggestion(); + } + } + + private void escapeKey() { + panel.hide(); + } + + private void enterKey() { + final MenuItem item = select.getSelectedItem(); + if (item != null) { + item.getScheduledCommand().execute(); + } + } + + private void arrorKeyUp(final KeyUpEvent event) { + select.moveSelectionUp(); + event.preventDefault(); + event.stopPropagation(); + } + + private void arrowKeyDown(final KeyUpEvent event) { + select.moveSelectionDown(); + event.preventDefault(); + event.stopPropagation(); + } + + private void doAskForSuggestion() { + rpc.suggest(textFieldWidget.getValue(), textFieldWidget.getCursorPos()); + } +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/SuggestTokenDto.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/SuggestTokenDto.java new file mode 100644 index 000000000..8291fa0bf --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/SuggestTokenDto.java @@ -0,0 +1,68 @@ +package org.eclipse.hawkbit.ui.filtermanagement.client; + +import java.io.Serializable; + +/** + * A suggestion which contains the start and the end character position of the + * suggested token of the suggestion of the token and the actual suggestion. + */ +public class SuggestTokenDto implements Serializable { + + private static final long serialVersionUID = 1L; + + private int start; + private int end; + private String suggestion; + + /** + * Default constructor. + */ + public SuggestTokenDto() { + // necessary for java serialization with GWT. + } + + /** + * Constructor. + * + * @param start + * the character position of the start of the token + * @param end + * the character position of the end of the token + * @param suggestion + * the token suggestion + */ + public SuggestTokenDto(final int start, final int end, final String suggestion) { + this.start = start; + this.end = end; + this.suggestion = suggestion; + } + + public int getStart() { + return start; + } + + public int getEnd() { + return end; + } + + public String getSuggestion() { + return suggestion; + } + + public void setStart(final int start) { + this.start = start; + } + + public void setEnd(final int end) { + this.end = end; + } + + public void setSuggestion(final String suggestion) { + this.suggestion = suggestion; + } + + @Override + public String toString() { + return "SuggestTokenDto [start=" + start + ", end=" + end + ", suggestion=" + suggestion + "]"; + } +} \ No newline at end of file diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/SuggestionContextDto.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/SuggestionContextDto.java new file mode 100644 index 000000000..ec5326d28 --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/SuggestionContextDto.java @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ui.filtermanagement.client; + +import java.io.Serializable; +import java.util.List; + +public class SuggestionContextDto implements Serializable { + + private static final long serialVersionUID = 1L; + + private int cursorPosition; + private List suggestions; + + /** + * Default constructor. + */ + public SuggestionContextDto() { + // necessary for java serialization with GWT. + } + + /** + * Constructor. + * + * @param rsqlQuery + * the original RSQL based query the suggestions based on + * @param cursorPosition + * the current cursor position + * @param suggestions + * the suggestions for the current cursor position + */ + public SuggestionContextDto(final int cursorPosition, final List suggestions) { + this.cursorPosition = cursorPosition; + this.suggestions = suggestions; + } + + public List getSuggestions() { + return suggestions; + } + + public int getCursorPosition() { + return cursorPosition; + } + + public void setCursorPosition(final int cursorPosition) { + this.cursorPosition = cursorPosition; + } + + public void setSuggestions(final List suggestions) { + this.suggestions = suggestions; + } + + @Override + public String toString() { + return "SuggestionContextDto [cursorPosition=" + cursorPosition + ", suggestions=" + suggestions + "]"; + } +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/SuggestionsSelectList.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/SuggestionsSelectList.java new file mode 100644 index 000000000..e51c1c8fe --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/SuggestionsSelectList.java @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ui.filtermanagement.client; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.google.gwt.aria.client.Roles; +import com.google.gwt.core.client.Scheduler.ScheduledCommand; +import com.google.gwt.user.client.ui.MenuBar; +import com.google.gwt.user.client.ui.MenuItem; +import com.google.gwt.user.client.ui.PopupPanel; +import com.vaadin.client.WidgetUtil; +import com.vaadin.client.ui.VTextField; + +/** + * The suggestion list within the suggestion pop-up panel. + */ +public class SuggestionsSelectList extends MenuBar { + + public static final String CLASSNAME = "autocomplete"; + private final Map tokenMap = new HashMap<>(); + + /** + * Constructor. + */ + public SuggestionsSelectList() { + super(true); + setFocusOnHoverEnabled(false); + } + + /** + * Adds suggestions to the suggestion menu bar. + * + * @param suggestions + * the suggestions to be added + * @param textFieldWidget + * the text field which the suggestion is attached to to bring + * back the focus after selection + * @param popupPanel + * pop-up panel where the menu bar is shown to hide it after + * selection + * @param suggestionServerRpc + * server RPC to ask for new suggestion after a selection + */ + public void addItems(final List suggestions, final VTextField textFieldWidget, + final PopupPanel popupPanel, final TextFieldSuggestionBoxServerRpc suggestionServerRpc) { + for (int index = 0; index < suggestions.size(); index++) { + final SuggestTokenDto suggestToken = suggestions.get(index); + final MenuItem mi = new MenuItem(suggestToken.getSuggestion(), true, new ScheduledCommand() { + @Override + public void execute() { + final String tmpSuggestion = suggestToken.getSuggestion(); + final TokenStartEnd tokenStartEnd = tokenMap.get(tmpSuggestion); + final String text = textFieldWidget.getValue(); + final StringBuilder builder = new StringBuilder(text); + builder.replace(tokenStartEnd.getStart(), tokenStartEnd.getEnd() + 1, tmpSuggestion); + textFieldWidget.setValue(builder.toString(), true); + popupPanel.hide(); + textFieldWidget.setFocus(true); + suggestionServerRpc.suggest(builder.toString(), textFieldWidget.getCursorPos()); + } + }); + tokenMap.put(suggestToken.getSuggestion(), + new TokenStartEnd(suggestToken.getStart(), suggestToken.getEnd())); + Roles.getListitemRole().set(mi.getElement()); + WidgetUtil.sinkOnloadForImages(mi.getElement()); + addItem(mi); + } + } + + @Override + public void setStyleName(final String style) { + super.setStyleName(style + "-" + CLASSNAME); + } + + @Override + public MenuItem getSelectedItem() { + return super.getSelectedItem(); + } + + /** + * Suggestion Token start and end index. + * + */ + public static final class TokenStartEnd { + final int start; + final int end; + + /** + * Constructor. + * + * @param start + * @param end + */ + public TokenStartEnd(final int start, final int end) { + this.start = start; + this.end = end; + } + + public int getStart() { + return start; + } + + public int getEnd() { + return end; + } + } +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/TextFieldSuggestionBoxClientRpc.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/TextFieldSuggestionBoxClientRpc.java new file mode 100644 index 000000000..d4385db6f --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/TextFieldSuggestionBoxClientRpc.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ui.filtermanagement.client; + +import com.vaadin.shared.communication.ClientRpc; + +/** + * Client RPC for the AutocompleteTextField. The Client RPC interface is used to + * make server to client calls in Vaadin. Only void methods are allowed in + * ClientRpc calls. + * + */ +@FunctionalInterface +public interface TextFieldSuggestionBoxClientRpc extends ClientRpc { + + /** + * Notifies the client about showing the given suggestions in the suggestion + * box. + * + * @param suggestionContext + * the suggestion context which contains all informations about + * showing suggestions + */ + void showSuggestions(final SuggestionContextDto suggestionContext); + +} diff --git a/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/TextFieldSuggestionBoxServerRpc.java b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/TextFieldSuggestionBoxServerRpc.java new file mode 100644 index 000000000..51388a0a3 --- /dev/null +++ b/hawkbit-ui/src/main/java/org/eclipse/hawkbit/ui/filtermanagement/client/TextFieldSuggestionBoxServerRpc.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2015 Bosch Software Innovations GmbH and others. + * + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + */ +package org.eclipse.hawkbit.ui.filtermanagement.client; + +import com.vaadin.shared.communication.ServerRpc; + +/** + * Server RPC for the AutoCompleteTextField. The Server RPC interface is used to + * make client to server calls in Vaadin. Only void methods are allowed in + * ServerRpc calls. + */ +@FunctionalInterface +public interface TextFieldSuggestionBoxServerRpc extends ServerRpc { + + /** + * Parses the given RSQL based query and try finding suggestions at the + * current given cursor position. When suggestions are possible the + * {@link TextFieldSuggestionBoxClientRpc#showSuggestions(org.eclipse.hawkbit.rsql.SuggestionContext)} + * is called as a callback mechanism back to the client. + * + * @param text + * the current entered text e.g. in a text field to retrieve + * suggestion for + * @param cursor + * the current cursor position + */ + void suggest(final String text, final int cursor); +} diff --git a/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/target-filter-query.scss b/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/target-filter-query.scss index 33ae2dd85..102f6a3c9 100644 --- a/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/target-filter-query.scss +++ b/hawkbit-ui/src/main/resources/VAADIN/themes/hawkbit/customstyles/target-filter-query.scss @@ -8,6 +8,39 @@ */ @mixin target-filter-query { +.gwt-MenuBar-autocomplete { + cursor: default; + } + + .gwt-MenuBar-autocomplete .gwt-MenuItem{ + border-radius: 3px !important; + cursor: pointer !important; + font-weight: 400 !important; + line-height: 27px !important; + padding: 0 20px 0 10px !important; + position: relative !important; + white-space: nowrap !important; + } + +.gwt-MenuBar-autocomplete .gwt-MenuItem-selected { + background-color: #197de1 !important; + background-image: linear-gradient(to bottom, #1b87e3 2%, #166ed5 98%) !important; + color: #ecf2f8 !important; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.05) !important; +} +.suggestion-popup{ + backface-visibility: hidden; + background-color: white; + border-radius: 4px; + box-shadow: 0 4px 10px 0 rgba(0, 0, 0, 0.1), 0 3px 5px 0 rgba(0, 0, 0, 0.05), 0 0 0 1px rgba(0, 0, 0, 0.09); + box-sizing: border-box; + color: #474747; + padding: 4px; + position: relative; + z-index: 1; +} + + .caption-header-layout{ padding-left:10px; } @@ -23,15 +56,17 @@ .target-filter-textfield, .target-filter-textfield:focus{ border:none !important; box-shadow: none !important; - height:26px !important; + height: 26px !important; } .error-icon{ + margin-left: 5px; color:$success-icon-color !important; padding-left:2px !important; } .success-icon{ + margin-left: 5px; color:$error-icon-color !important; padding-left:2px !important; } @@ -42,10 +77,12 @@ } .target-filter-spinner{ - @include valo-spinner( - $size: $v-font-size--small, - $color: $bosch-color-light-blue - ); + margin-top: 5px; + margin-left: 5px; + @include valo-spinner( + $size:16px, + $speed:500ms + ); } .hide-status-label { diff --git a/pom.xml b/pom.xml index 31a175bd6..6bfa81534 100644 --- a/pom.xml +++ b/pom.xml @@ -105,7 +105,7 @@ 3.4 4.1 20141113 - 2.0.0 + 2.1.0