From 744b8ae91b3f00a0864eb967993849a18b547c6a Mon Sep 17 00:00:00 2001 From: Fabrizzio Araya <37148755+fabrizzio-dotCMS@users.noreply.github.com> Date: Mon, 25 Sep 2023 15:16:49 -0600 Subject: [PATCH 01/17] fix(cli): fix passing token (#26237) --- .../api/client/AuthenticationParamContextImpl.java | 14 +++++--------- .../dotcms/api/provider/DotCMSClientHeaders.java | 4 +--- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/client/AuthenticationParamContextImpl.java b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/client/AuthenticationParamContextImpl.java index 363f53c7c6e2..05c58eb92b05 100644 --- a/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/client/AuthenticationParamContextImpl.java +++ b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/client/AuthenticationParamContextImpl.java @@ -1,7 +1,7 @@ package com.dotcms.api.client; import io.quarkus.arc.DefaultBean; -import java.lang.ref.WeakReference; +import java.util.Arrays; import java.util.Optional; import javax.enterprise.context.ApplicationScoped; @@ -12,22 +12,18 @@ @ApplicationScoped public class AuthenticationParamContextImpl implements AuthenticationParam { - WeakReference token; + char[] token; @Override public void setToken(final char[] token) { - this.token = new WeakReference<>(token); + this.token = Arrays.copyOf(token, token.length); } public Optional getToken() { - if (null == token || null == token.get()) { + if (null == token || 0 == token.length) { return Optional.empty(); } - try { - return Optional.ofNullable(token.get()); - } finally { - token.clear(); - } + return Optional.of(token); } } diff --git a/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/provider/DotCMSClientHeaders.java b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/provider/DotCMSClientHeaders.java index d934a0779354..79137aee4dfd 100644 --- a/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/provider/DotCMSClientHeaders.java +++ b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/api/provider/DotCMSClientHeaders.java @@ -25,9 +25,7 @@ public MultivaluedMap update(MultivaluedMap mm1, MultivaluedMap mm2) { authenticationContext.getToken().ifPresentOrElse(token -> mm2.add("Authorization", "Bearer " + new String(token)), - () -> { - logger.error("Unable to get a valid token from the authentication context."); - } + () -> logger.error("Unable to get a valid token from the authentication context.") ); return mm2; From 89c62c9b03ed6e9161bd69f98c21626005a4155f Mon Sep 17 00:00:00 2001 From: Jonathan Gamba Date: Mon, 25 Sep 2023 15:37:07 -0600 Subject: [PATCH 02/17] feat(cli): Implement sync feature for languages (#26239) Refs: #26002 --- .../api/v2/languages/LanguagesResource.java | 2 +- .../push/AbstractPushAnalysisResult.java | 22 ++ .../model/push/AbstractPushOptions.java | 21 ++ .../com/dotcms/model/push/PushAction.java | 5 + .../api/client/push/ContentComparator.java | 50 +++ .../api/client/push/ContentFetcher.java | 19 ++ .../dotcms/api/client/push/FormatStatus.java | 152 +++++++++ .../dotcms/api/client/push/MapperService.java | 48 +++ .../api/client/push/MapperServiceImpl.java | 123 +++++++ .../api/client/push/PushAnalysisService.java | 27 ++ .../client/push/PushAnalysisServiceImpl.java | 221 ++++++++++++ .../dotcms/api/client/push/PushHandler.java | 57 ++++ .../dotcms/api/client/push/PushService.java | 31 ++ .../api/client/push/PushServiceImpl.java | 318 ++++++++++++++++++ .../push/exception/MappingException.java | 30 ++ .../client/push/exception/PushException.java | 18 + .../push/language/LanguageComparator.java | 104 ++++++ .../client/push/language/LanguageFetcher.java | 25 ++ .../push/language/LanguagePushHandler.java | 104 ++++++ .../client/push/task/ProcessResultTask.java | 148 ++++++++ .../dotcms/api/client/push/task/PushTask.java | 95 ++++++ .../com/dotcms/cli/command/PushCommand.java | 18 +- .../command/files/AbstractFilesCommand.java | 17 +- .../dotcms/cli/command/files/FilesPush.java | 15 +- .../files}/FilesPushMixin.java | 2 +- .../cli/command/language/LanguagePush.java | 173 ++++++---- .../command/language/LanguagesPushMixin.java | 13 + .../dotcms/cli/common/FormatOptionMixin.java | 6 +- .../java/com/dotcms/cli/common/PushMixin.java | 10 +- .../dotcms/cli/command/PushCommandTest.java | 2 +- .../LanguageCommandIntegrationTest.java | 282 ++++++++++++++-- 31 files changed, 2005 insertions(+), 153 deletions(-) create mode 100644 tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/AbstractPushAnalysisResult.java create mode 100644 tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/AbstractPushOptions.java create mode 100644 tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/PushAction.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/ContentComparator.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/ContentFetcher.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/FormatStatus.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/MapperService.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/MapperServiceImpl.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushAnalysisService.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushAnalysisServiceImpl.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushHandler.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushService.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushServiceImpl.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/exception/MappingException.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/exception/PushException.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguageComparator.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguageFetcher.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguagePushHandler.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/task/ProcessResultTask.java create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/task/PushTask.java rename tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/{common => command/files}/FilesPushMixin.java (95%) create mode 100644 tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/language/LanguagesPushMixin.java diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v2/languages/LanguagesResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v2/languages/LanguagesResource.java index 26054c43efc8..f4b43c7090d1 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v2/languages/LanguagesResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v2/languages/LanguagesResource.java @@ -233,8 +233,8 @@ public Response getFromLanguageTag ( if(null == language){ return Response.status(Status.NOT_FOUND).build(); } - return Response.ok(new ResponseEntityView(language)).build(); // 200 + return Response.ok(new ResponseEntityView(new LanguageView(language))).build(); } private Locale validateLanguageTag(final String languageTag)throws DoesNotExistException { diff --git a/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/AbstractPushAnalysisResult.java b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/AbstractPushAnalysisResult.java new file mode 100644 index 000000000000..17dd82058766 --- /dev/null +++ b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/AbstractPushAnalysisResult.java @@ -0,0 +1,22 @@ +package com.dotcms.model.push; + +import com.dotcms.model.annotation.ValueType; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import java.io.File; +import java.util.Optional; +import org.immutables.value.Value; + +@ValueType +@Value.Immutable +@JsonDeserialize(as = PushAnalysisResult.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public interface AbstractPushAnalysisResult { + + PushAction action(); + + Optional serverContent(); + + Optional localFile(); + +} diff --git a/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/AbstractPushOptions.java b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/AbstractPushOptions.java new file mode 100644 index 000000000000..f485a2f1a4a8 --- /dev/null +++ b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/AbstractPushOptions.java @@ -0,0 +1,21 @@ +package com.dotcms.model.push; + +import com.dotcms.model.annotation.ValueType; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.immutables.value.Value; + +@ValueType +@Value.Immutable +@JsonDeserialize(as = PushOptions.class) +@JsonIgnoreProperties(ignoreUnknown = true) +public interface AbstractPushOptions { + + boolean allowRemove(); + + boolean failFast(); + + boolean dryRun(); + + int maxRetryAttempts(); +} diff --git a/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/PushAction.java b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/PushAction.java new file mode 100644 index 000000000000..d4f2c315daf7 --- /dev/null +++ b/tools/dotcms-cli/api-data-model/src/main/java/com/dotcms/model/push/PushAction.java @@ -0,0 +1,5 @@ +package com.dotcms.model.push; + +public enum PushAction { + ADD, UPDATE, REMOVE, NO_ACTION +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/ContentComparator.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/ContentComparator.java new file mode 100644 index 000000000000..ab8df4e8ea3e --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/ContentComparator.java @@ -0,0 +1,50 @@ +package com.dotcms.api.client.push; + +import java.util.List; +import java.util.Optional; + +/** + * Interface for comparing content of type T. + * + * @param the type of content to be compared + */ +public interface ContentComparator { + + + /** + * Retrieves the type parameter of the class. + * + * @return the type parameter of the class + */ + Class type(); + + /** + * Finds matching server content based on local content and a list of server contents. + * + * @param localContent the local content to compare against server contents + * @param serverContents the list of server contents to search for matches + * @return an Optional containing the matching server content if found, otherwise an empty + * Optional. + */ + Optional findMatchingServerContent(T localContent, List serverContents); + + /** + * Checks if the given server content is contained within the list of local contents. + * + * @param serverContent the server content to check for containment + * @param localContents the list of local contents to search for containment + * @return an Optional containing the matching local content if found, or an empty Optional if + * not found. + */ + Optional localContains(T serverContent, List localContents); + + /** + * Checks if the given local content and server content are equal. + * + * @param localContent the local content to compare + * @param serverContent the server content to compare + * @return true if the local content is equal to the server content, false otherwise + */ + boolean contentEquals(T localContent, T serverContent); + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/ContentFetcher.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/ContentFetcher.java new file mode 100644 index 000000000000..f63f774d6e2a --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/ContentFetcher.java @@ -0,0 +1,19 @@ +package com.dotcms.api.client.push; + +import java.util.List; + +/** + * The ContentFetcher interface provides a contract for classes that can fetch content of type T. + * + * @param The type of content to fetch. + */ +public interface ContentFetcher { + + /** + * Fetches a list of elements of type T. + * + * @return The fetched list of elements. + */ + List fetch(); + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/FormatStatus.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/FormatStatus.java new file mode 100644 index 000000000000..5eadec10dc01 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/FormatStatus.java @@ -0,0 +1,152 @@ +package com.dotcms.api.client.push; + +import static com.dotcms.model.push.PushAction.NO_ACTION; + +import com.dotcms.api.client.push.exception.PushException; +import com.dotcms.model.push.PushAnalysisResult; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; +import javax.enterprise.context.Dependent; +import javax.inject.Inject; +import org.jboss.logging.Logger; + +/** + * The {@code FormatStatus} class is responsible for formatting the status of a push operation. It + * provides methods for formatting the results of a push analysis into a user-friendly format. + * + *

This class is meant to be used in conjunction with a {@link PushHandler} which provides + * additional functionality for handling the push operation. + * + * @see PushAnalysisResult + * @see PushHandler + */ +@Dependent +public class FormatStatus { + + private final String COLOR_NEW = "green"; + private final String COLOR_MODIFIED = "cyan"; + private final String COLOR_DELETED = "red"; + + private final String REGULAR_FORMAT = "%s"; + private final String PUSH_NEW_FORMAT = "@|bold," + COLOR_NEW + " %s \u2795|@"; + private final String PUSH_MODIFIED_FORMAT = "@|bold," + COLOR_MODIFIED + " %s \u270E|@"; + private final String PUSH_DELETE_FORMAT = "@|bold," + COLOR_DELETED + " %s \u2716|@"; + + @Inject + Logger logger; + + /** + * Formats the push analysis results using the specified push handler. + * + * @param results the list of push analysis results + * @param pushHandler the push handler to use for formatting + * @param ignoreNoAction indicates whether to ignore results with no action + * @return a StringBuilder containing the formatted push analysis results + */ + public StringBuilder format(final List> results, + PushHandler pushHandler, final boolean ignoreNoAction) { + + var outputBuilder = new StringBuilder(); + + outputBuilder.append(String.format(" %s:", pushHandler.title())).append("\n"); + + List> filteredResults = results; + if (ignoreNoAction) { + filteredResults = results.stream() + .filter(result -> result.action() != NO_ACTION) + .collect(Collectors.toList()); + } + + Iterator> iterator = filteredResults.iterator(); + while (iterator.hasNext()) { + + PushAnalysisResult result = iterator.next(); + boolean isLast = !iterator.hasNext(); + outputBuilder.append(formatResult(" ", result, pushHandler, isLast)); + } + + return outputBuilder; + } + + /** + * Formats a single push analysis result using the specified prefix, result, push handler, and + * lastElement indicator. + * + * @param prefix the prefix to use for indentation + * @param result the push analysis result to format + * @param pushHandler the push handler to use for formatting + * @param lastElement indicates whether the result is the last element in the list + * @param z the type of the push analysis result + * @return a StringBuilder containing the formatted push analysis result + */ + private StringBuilder formatResult(final String prefix, + final PushAnalysisResult result, final PushHandler pushHandler, + final boolean lastElement) { + + var outputBuilder = new StringBuilder(); + + String contentFormat; + String contentName; + switch (result.action()) { + case ADD: + + contentFormat = PUSH_NEW_FORMAT; + + if (result.localFile().isPresent()) { + contentName = result.localFile().get().getName(); + } else { + var message = "Local file is missing for add action"; + logger.error(message); + throw new PushException(message); + } + break; + case UPDATE: + + contentFormat = PUSH_MODIFIED_FORMAT; + + if (result.localFile().isPresent()) { + contentName = result.localFile().get().getName(); + } else { + var message = "Local file is missing for update action"; + logger.error(message); + throw new PushException(message); + } + break; + case REMOVE: + + contentFormat = PUSH_DELETE_FORMAT; + + if (result.serverContent().isPresent()) { + contentName = pushHandler.contentSimpleDisplay(result.serverContent().get()); + } else { + var message = "Server content is missing for remove action"; + logger.error(message); + throw new PushException(message); + } + break; + case NO_ACTION: + + contentFormat = REGULAR_FORMAT; + + if (result.localFile().isPresent()) { + contentName = result.localFile().get().getName(); + } else { + var message = "Local file is missing"; + logger.error(message); + throw new PushException(message); + } + break; + default: + throw new PushException("Unknown action: " + result.action()); + } + + outputBuilder.append(prefix). + append(lastElement ? "└── " : "├── "). + append(String.format(contentFormat, contentName)). + append("\n"); + + return outputBuilder; + } + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/MapperService.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/MapperService.java new file mode 100644 index 000000000000..26b4ffba818d --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/MapperService.java @@ -0,0 +1,48 @@ +package com.dotcms.api.client.push; + +import com.dotcms.api.client.push.exception.MappingException; +import com.dotcms.cli.common.InputOutputFormat; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.File; + +/** + * MapperService is an interface that provides a method to map a File to an object of a given + * class. + */ +public interface MapperService { + + /** + * Returns an instance of ObjectMapper based on the input file format. + * + * @param file the file to be processed + * @return an instance of ObjectMapper for processing the given file + */ + ObjectMapper objectMapper(final File file); + + /** + * Returns an instance of ObjectMapper based on the specified input/output format. + * + * @param inputOutputFormat The input/output format for which the ObjectMapper will be + * returned. + * @return An instance of ObjectMapper for the given input/output format. + */ + ObjectMapper objectMapper(final InputOutputFormat inputOutputFormat); + + /** + * Returns an instance of ObjectMapper with default input/output format YAML. + * + * @return An instance of ObjectMapper with default input/output format YAML. + */ + ObjectMapper objectMapper(); + + /** + * Maps the contents of a file to an instance of the given class. + * + * @param file the file to read and map + * @param clazz the class to map the file contents to + * @return the mapped instance of the given class + * @throws MappingException if there is an error during the mapping process + */ + T map(final File file, Class clazz) throws MappingException; + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/MapperServiceImpl.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/MapperServiceImpl.java new file mode 100644 index 000000000000..a34b68356c91 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/MapperServiceImpl.java @@ -0,0 +1,123 @@ +package com.dotcms.api.client.push; + +import com.dotcms.api.client.push.exception.MappingException; +import com.dotcms.api.provider.ClientObjectMapper; +import com.dotcms.api.provider.YAMLMapperSupplier; +import com.dotcms.cli.common.InputOutputFormat; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.quarkus.arc.DefaultBean; +import java.io.File; +import java.io.IOException; +import javax.enterprise.context.Dependent; +import javax.enterprise.context.control.ActivateRequestContext; +import javax.inject.Inject; +import org.jboss.logging.Logger; + +/** + * The {@code MapperServiceImpl} class implements the {@code MapperService} interface and provides + * methods for mapping data from a file to an object using different formats. + *

+ * This class is annotated with {@code @DefaultBean} and {@code @Dependent} to indicate that it is + * the default implementation of the {@code MapperService} interface and that it belongs to the + * dependent scope. + */ +@DefaultBean +@Dependent +public class MapperServiceImpl implements MapperService { + + @Inject + Logger logger; + + /** + * Returns an instance of ObjectMapper based on the input file format. + * + * @param file The file from which the input will be read. + * @return An instance of ObjectMapper for the given input file format. + */ + @ActivateRequestContext + public ObjectMapper objectMapper(final File file) { + + if (null == file) { + var message = "Trying to obtain ObjectMapper with null file"; + logger.error(message); + throw new MappingException(message); + } + + InputOutputFormat inputOutputFormat; + if (isJSONFile(file)) { + inputOutputFormat = InputOutputFormat.JSON; + } else { + inputOutputFormat = InputOutputFormat.YML; + } + + return objectMapper(inputOutputFormat); + } + + /** + * Returns an instance of ObjectMapper based on the specified input/output format. + * + * @param inputOutputFormat The input/output format for which the ObjectMapper will be + * returned. + * @return An instance of ObjectMapper for the given input/output format. + */ + @ActivateRequestContext + public ObjectMapper objectMapper(final InputOutputFormat inputOutputFormat) { + + if (inputOutputFormat == InputOutputFormat.JSON) { + return new ClientObjectMapper().getContext(null); + } + + return new YAMLMapperSupplier().get(); + } + + /** + * Returns an instance of ObjectMapper with default input/output format YAML. + * + * @return An instance of ObjectMapper with default input/output format YAML. + */ + @ActivateRequestContext + public ObjectMapper objectMapper() { + return objectMapper(InputOutputFormat.YAML); + } + + /** + * Maps the given file to an object of the specified class. + * + * @param file The file to be mapped. + * @param clazz The class of the object to be mapped to. + * @return The mapped object. + * @throws MappingException If there is an error mapping the file. + */ + @ActivateRequestContext + public T map(final File file, Class clazz) throws MappingException { + + if (null == file) { + var message = String.format("Trying to map empty file for type [%s]", clazz.getName()); + logger.error(message); + throw new MappingException(message); + } + + try { + ObjectMapper objectMapper = objectMapper(file); + return objectMapper.readValue(file, clazz); + } catch (IOException e) { + + var message = String.format("Error mapping file [%s] for type [%s]", + file.getAbsolutePath(), clazz.getName()); + logger.error(message, e); + + throw new MappingException(message, e); + } + } + + /** + * Checks if the given file is a JSON file. + * + * @param file The file to be checked. + * @return True if the file is a JSON file, false otherwise. + */ + private boolean isJSONFile(final File file) { + return file.getName().toLowerCase().endsWith(".json"); + } + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushAnalysisService.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushAnalysisService.java new file mode 100644 index 000000000000..2452306308d1 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushAnalysisService.java @@ -0,0 +1,27 @@ +package com.dotcms.api.client.push; + +import com.dotcms.model.push.PushAnalysisResult; +import java.io.File; +import java.util.List; + +/** + * Service interface for performing push analysis on a local file or folder. + */ +public interface PushAnalysisService { + + /** + * Analyzes a local file or folder and generates a list of push analysis results. + * + * @param localFileOrFolder the local file or folder to analyze + * @param allowRemove whether to allow removals + * @param provider the content fetcher used to retrieve content + * @param comparator the content comparator used to compare content + * @return a list of push analysis results + */ + List> analyze(File localFileOrFolder, + boolean allowRemove, + ContentFetcher provider, + ContentComparator comparator); + +} + diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushAnalysisServiceImpl.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushAnalysisServiceImpl.java new file mode 100644 index 000000000000..401940ecc736 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushAnalysisServiceImpl.java @@ -0,0 +1,221 @@ +package com.dotcms.api.client.push; + +import com.dotcms.model.push.PushAction; +import com.dotcms.model.push.PushAnalysisResult; +import io.quarkus.arc.DefaultBean; +import java.io.File; +import java.io.FileFilter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import javax.enterprise.context.Dependent; +import javax.enterprise.context.control.ActivateRequestContext; +import javax.inject.Inject; +import org.jboss.logging.Logger; + +/** + * This class provides an implementation of the PushAnalysisService interface. It analyzes local + * files against server files to find updates, additions, and removals. The analysis results are + * returned as a list of PushAnalysisResult objects. + */ +@DefaultBean +@Dependent +public class PushAnalysisServiceImpl implements PushAnalysisService { + + @Inject + MapperService mapperService; + + @Inject + Logger logger; + + /** + * Analyzes the local files against server files to find updates, additions, and removals based + * on the provided local files, server files, and content comparator. The analysis is performed + * by comparing the content of the local files with the content of the server files using the + * specified content comparator. + * + * @param localFile the local file or directory to be analyzed + * @param allowRemove whether to allow removals + * @param provider the content fetcher that provides the server files content + * @param comparator the content comparator used to compare the content of local and server + * files + * @return a list of push analysis results which include updates, additions, and removals found + * during the analysis + */ + @ActivateRequestContext + public List> analyze(final File localFile, + final boolean allowRemove, + final ContentFetcher provider, + final ContentComparator comparator) { + + List localContents = readLocalContents(localFile); + List serverContents = provider.fetch(); + + // Checking local files against server files to find updates and additions + List> results = new ArrayList<>( + checkLocal(localContents, serverContents, comparator) + ); + + if (allowRemove) { + + // We don't need to check the server against local files if we are dealing with a single file + if (localFile.isDirectory()) { + // Checking server files against local files to find removals + results.addAll(checkServerForRemovals(localContents, serverContents, comparator)); + } + } + + return results; + } + + /** + * This method analyzes local files and server contents using a content comparator to determine + * the appropriate actions to perform. It returns a list of PushAnalysisResult objects that + * represent the analysis results. + * + * @param localFiles a list of local files to be analyzed + * @param serverContents a list of server contents to be compared against + * @param comparator a content comparator used to compare local content with server + * contents + * @return a list of PushAnalysisResult objects representing the analysis results + */ + private List> checkLocal(List localFiles, + List serverContents, ContentComparator comparator) { + + List> results = new ArrayList<>(); + + for (File localFile : localFiles) { + + var localContent = map(localFile, comparator.type()); + + var matchingServerContent = comparator.findMatchingServerContent( + localContent, + serverContents + ); + if (matchingServerContent.isPresent()) { + + var action = PushAction.NO_ACTION; + if (!comparator.contentEquals(localContent, matchingServerContent.get())) { + action = PushAction.UPDATE; + } + + results.add( + PushAnalysisResult.builder(). + action(action). + localFile(localFile). + serverContent(matchingServerContent). + build() + ); + } else { + results.add( + PushAnalysisResult.builder(). + action(PushAction.ADD). + localFile(localFile). + build() + ); + } + } + + return results; + } + + /** + * This method analyzes the server contents and compares them with the local files using a + * content comparator to determine the appropriate removal actions. It returns a list of + * PushAnalysisResult objects that represent the analysis results. + * + * @param localFiles a list of local files to be compared against the server contents + * @param serverContents a list of server contents to be analyzed + * @param comparator a content comparator used to compare server content with local files + * @return a list of PushAnalysisResult objects representing the analysis results + */ + private List> checkServerForRemovals(List localFiles, + List serverContents, ContentComparator comparator) { + + if (serverContents.isEmpty()) { + return Collections.emptyList(); + } + + // Convert List to List + List localContents = map(localFiles, comparator.type()); + + List> removals = new ArrayList<>(); + + for (T serverContent : serverContents) { + + var local = comparator.localContains(serverContent, localContents); + if (local.isEmpty()) { + removals.add( + PushAnalysisResult.builder(). + action(PushAction.REMOVE). + serverContent(serverContent). + build() + ); + } + } + + return removals; + } + + /** + * This method reads the contents of a local file or directory and returns a list of File + * objects representing the evaluated files. + * + * @param localFile the local file or directory to be read + * @return a list of File objects representing the evaluated files + */ + private List readLocalContents(File localFile) { + + if (localFile.isFile()) { + return List.of(localFile); + } else if (localFile.isDirectory()) { + var foundFiles = localFile.listFiles(new HiddenFileFilter()); + if (foundFiles != null) { + return List.of(foundFiles); + } + } + + return new ArrayList<>(); + } + + /** + * This method maps a given local file to the specified class using the mapper service and + * returns the mapped object. + * + * @param localFile the local file to be mapped + * @param clazz the class to map the local file onto + * @return the mapped object of type T + */ + private T map(File localFile, Class clazz) { + return mapperService.map(localFile, clazz); + } + + /** + * This method maps a list of local files to the specified class using the mapper service and + * returns a list of the mapped objects. + * + * @param localFiles the list of local files to be mapped + * @param clazz the class to map the local files onto + * @return a list of the mapped objects of type T + */ + private List map(List localFiles, Class clazz) { + + return localFiles.stream() + .map(file -> map(file, clazz)) + .collect(Collectors.toList()); + } + + /** + * FileFilter implementation to allow hidden files and folders and filter out system specific + * elements. + */ + static class HiddenFileFilter implements FileFilter { + + @Override + public boolean accept(File file) { + return !file.getName().equalsIgnoreCase(".DS_Store"); + } + } + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushHandler.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushHandler.java new file mode 100644 index 000000000000..fc76b8d2226a --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushHandler.java @@ -0,0 +1,57 @@ +package com.dotcms.api.client.push; + +import java.io.File; + +/** + * This interface represents a push handler, which is responsible for handling push operations for a + * specific type. + */ +public interface PushHandler { + + /** + * Returns the type parameter of the class. + * + * @return the type parameter + */ + Class type(); + + /** + * Returns a title we can use for this type on the console. + * + * @return the title as a String + */ + String title(); + + /** + * Generates a simple String representation of a content to use on the console. + * + * @param content the content to be displayed + * @return a string representation of the content + */ + String contentSimpleDisplay(T content); + + /** + * Creates a T content in the server. + * + * @param localFile the local file representing the content to be added + * @param mappedLocalFile the mapped local file as a T + */ + void add(File localFile, T mappedLocalFile); + + /** + * Updates the server content with the local T content. + * + * @param localFile the local file representing the content to be updated + * @param mappedLocalFile the mapped local file as a T + * @param serverContent the existing server content to be updated + */ + void edit(File localFile, T mappedLocalFile, T serverContent); + + /** + * Removes the given serverContent from the server. + * + * @param serverContent the server content to be removed + */ + void remove(T serverContent); + +} \ No newline at end of file diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushService.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushService.java new file mode 100644 index 000000000000..91b069a1cf37 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushService.java @@ -0,0 +1,31 @@ +package com.dotcms.api.client.push; + +import com.dotcms.cli.common.OutputOptionMixin; +import com.dotcms.model.push.PushOptions; +import java.io.File; + +/** + * Represents a service for pushing content from a local file or folder to a remote destination. + */ +public interface PushService { + + /** + * Pushes the local file or folder to a remote location using the provided options and + * handlers. + * + * @param localFileOrFolder the local file or folder to be pushed + * @param options the options for the push operation + * @param output the output options for the push operation + * @param provider the content fetcher for retrieving the content to be pushed + * @param comparator the comparator for comparing the content to be pushed with the + * remote content + * @param pushHandler the push handler for handling the push operations + */ + void push(File localFileOrFolder, + PushOptions options, + OutputOptionMixin output, + ContentFetcher provider, + ContentComparator comparator, + PushHandler pushHandler); + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushServiceImpl.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushServiceImpl.java new file mode 100644 index 000000000000..5afb509ec8fd --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/PushServiceImpl.java @@ -0,0 +1,318 @@ +package com.dotcms.api.client.push; + +import static com.dotcms.cli.command.files.TreePrinter.COLOR_DELETED; +import static com.dotcms.cli.command.files.TreePrinter.COLOR_MODIFIED; +import static com.dotcms.cli.command.files.TreePrinter.COLOR_NEW; + +import com.dotcms.api.client.push.exception.PushException; +import com.dotcms.api.client.push.task.PushTask; +import com.dotcms.cli.common.ConsoleLoadingAnimation; +import com.dotcms.cli.common.ConsoleProgressBar; +import com.dotcms.cli.common.OutputOptionMixin; +import com.dotcms.model.push.PushAnalysisResult; +import com.dotcms.model.push.PushOptions; +import io.quarkus.arc.DefaultBean; +import java.io.File; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ForkJoinPool; +import javax.enterprise.context.Dependent; +import javax.enterprise.context.control.ActivateRequestContext; +import javax.inject.Inject; +import org.apache.commons.lang3.tuple.Pair; +import org.jboss.logging.Logger; + +/** + * Implementation of the PushService interface for performing push operations. + */ +@DefaultBean +@Dependent +public class PushServiceImpl implements PushService { + + @Inject + PushAnalysisService pushAnalysisService; + + @Inject + MapperService mapperService; + + @Inject + FormatStatus formatStatus; + + @Inject + Logger logger; + + /** + * Analyzes and pushes the changes to a remote repository. + * + * @param localFileOrFolder The local file or folder to push. + * @param options The push options. + * @param output The output option mixin. + * @param provider The content fetcher provider. + * @param comparator The content comparator. + * @param pushHandler The push handler. + */ + @ActivateRequestContext + @Override + public void push(final File localFileOrFolder, final PushOptions options, + final OutputOptionMixin output, final ContentFetcher provider, + final ContentComparator comparator, final PushHandler pushHandler) { + + // --- + // Analyzing what push operations need to be performed + var results = analyze(localFileOrFolder, options, output, provider, comparator); + var analysisResults = results.getLeft(); + var summary = results.getRight(); + + var outputBuilder = new StringBuilder(); + + outputBuilder.append("\r\n"). + append(" ──────\n"); + + if (summary.total > 0 && summary.total != summary.noActions) { + + // Sorting analysisResults by action + analysisResults.sort(Comparator.comparing(PushAnalysisResult::action)); + + outputBuilder.append(String.format(" Push Data: " + + "@|bold [%d]|@ %s to push: " + + "(@|bold," + COLOR_NEW + " %d|@ New " + + "- @|bold," + COLOR_MODIFIED + " %d|@ Modified)", + (summary.total - summary.noActions), + pushHandler.title(), + summary.adds, + summary.updates)); + + if (options.allowRemove()) { + outputBuilder.append( + String.format(" - @|bold," + COLOR_DELETED + " %d|@ to Delete%n%n", + summary.removes)); + } else { + outputBuilder.append(String.format("%n%n")); + } + + if (options.dryRun()) { + outputBuilder.append(formatStatus.format(analysisResults, pushHandler, true)); + } + + output.info(outputBuilder.toString()); + + // --- + // Pushing the changes + if (!options.dryRun()) { + processPush(analysisResults, summary, options, output, pushHandler); + } + + } else { + outputBuilder.append( + String.format(" No changes in %s to push%n%n", pushHandler.title())); + output.info(outputBuilder.toString()); + } + + } + + /** + * Analyzes the push data for a local file or folder. + * + * @param localFileOrFolder the local file or folder to analyze + * @param options the push options + * @param output the output option mixin to use for displaying progress + * @param provider the content fetcher used to fetch content for analysis + * @param comparator the content comparator used to compare content for analysis + * @return a pair containing the list of push analysis results and the push analysis summary + * @throws PushException if an error occurs during the analysis + */ + private Pair>, PushAnalysisSummary> analyze( + final File localFileOrFolder, + final PushOptions options, + final OutputOptionMixin output, + final ContentFetcher provider, + final ContentComparator comparator) { + + CompletableFuture>> + pushAnalysisServiceFuture = CompletableFuture.supplyAsync( + () -> { + // Analyzing what push operations need to be performed + return pushAnalysisService.analyze( + localFileOrFolder, + options.allowRemove(), + provider, + comparator + ); + }); + + // ConsoleLoadingAnimation instance to handle the waiting "animation" + ConsoleLoadingAnimation consoleLoadingAnimation = new ConsoleLoadingAnimation( + output, + pushAnalysisServiceFuture + ); + + CompletableFuture animationFuture = CompletableFuture.runAsync( + consoleLoadingAnimation + ); + + final List> analysisResults; + + try { + // Waits for the completion of both the push analysis service and console loading animation tasks. + // This line blocks the current thread until both CompletableFuture instances + // (pushAnalysisServiceFuture and animationFuture) have completed. + CompletableFuture.allOf(pushAnalysisServiceFuture, animationFuture).join(); + analysisResults = pushAnalysisServiceFuture.get(); + } catch (InterruptedException | ExecutionException e) { + var errorMessage = String.format( + "Error occurred while analysing push data for [%s]: [%s].", + localFileOrFolder.getAbsolutePath(), e.getMessage()); + logger.error(errorMessage, e); + throw new PushException(errorMessage, e); + } + + var summary = new PushAnalysisSummary<>(analysisResults); + + return Pair.of(analysisResults, summary); + } + + /** + * Processes the push operation based on the given analysis results, summary, options, output, + * and push handler. The method handles retrying the push process, displaying progress bar, and + * catching any exceptions. + * + * @param analysisResults the list of push analysis results + * @param summary the push analysis summary + * @param options the push options + * @param output the output option mixin + * @param pushHandler the push handler for handling the push operations + */ + private void processPush(List> analysisResults, + PushAnalysisSummary summary, + final PushOptions options, + final OutputOptionMixin output, + final PushHandler pushHandler) { + + var retryAttempts = 0; + var failed = false; + + do { + + if (retryAttempts > 0) { + output.info(String.format("%n↺ Retrying push process [%d of %d]...", retryAttempts, + options.maxRetryAttempts())); + } + + // ConsoleProgressBar instance to handle the push progress bar + ConsoleProgressBar progressBar = new ConsoleProgressBar(output); + // Calculating the total number of steps + progressBar.setTotalSteps( + summary.total + ); + + CompletableFuture> pushFuture = CompletableFuture.supplyAsync( + () -> { + var forkJoinPool = ForkJoinPool.commonPool(); + var task = new PushTask<>( + analysisResults, + options.allowRemove(), + options.failFast(), + pushHandler, + mapperService, + logger, + progressBar + ); + return forkJoinPool.invoke(task); + }); + progressBar.setFuture(pushFuture); + + CompletableFuture animationFuture = CompletableFuture.runAsync( + progressBar + ); + + try { + + // Waits for the completion of both the push process and console progress bar animation tasks. + // This line blocks the current thread until both CompletableFuture instances + // (pushFuture and animationFuture) have completed. + CompletableFuture.allOf(pushFuture, animationFuture).join(); + + var errors = pushFuture.get(); + if (!errors.isEmpty()) { + + failed = true; + output.info(String.format( + "%n%nFound [@|bold,red %s|@] errors during the push process:", + errors.size())); + long count = errors.stream().filter(PushException.class::isInstance).count(); + int c = 0; + for (var error : errors) { + c++; + output.handleCommandException(error, + String.format("%s %n", error.getMessage()), c == count); + } + } + + } catch (InterruptedException | ExecutionException e) { + + var errorMessage = String.format("Error occurred while pushing contents: [%s].", + e.getMessage()); + logger.error(errorMessage, e); + throw new PushException(errorMessage, e); + } catch (Exception e) {// Fail fast + + failed = true; + if (retryAttempts + 1 <= options.maxRetryAttempts()) { + output.info("\n\nFound errors during the push process:"); + output.error(e.getMessage()); + } else { + throw e; + } + } + } while (failed && retryAttempts++ < options.maxRetryAttempts()); + } + + /** + * The PushAnalysisSummary class represents a summary of push analysis results. It counts the + * number of adds, updates, removes, no actions, and total actions. + */ + static class PushAnalysisSummary { + + private int adds; + private int updates; + private int removes; + private int noActions; + private int total; + + public PushAnalysisSummary(List> results) { + if (results != null) { + for (PushAnalysisResult result : results) { + switch (result.action()) { + case ADD: + adds++; + break; + case UPDATE: + updates++; + break; + case REMOVE: + removes++; + break; + case NO_ACTION: + noActions++; + break; + } + } + + total = results.size(); + } + } + + @Override + public String toString() { + return "PushAnalysisSummary{" + + "adds=" + adds + + ", updates=" + updates + + ", removes=" + removes + + ", noActions=" + noActions + + ", total=" + total + + '}'; + } + } +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/exception/MappingException.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/exception/MappingException.java new file mode 100644 index 000000000000..c670553ced27 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/exception/MappingException.java @@ -0,0 +1,30 @@ +package com.dotcms.api.client.push.exception; + +/** + * {@code MappingException} is a specialized {@code RuntimeException} that is thrown when there is a + * problem with mapping an object to a specific type. + * + *

{@code MappingException} provides constructors to create an instance with a custom error + * message or with a custom error message and a cause.

+ * + *

Example usage:

+ *
{@code
+ * try {
+ *     // Mapping code here
+ * } catch (MappingException ex) {
+ *     // Handle mapping exception
+ * }
+ * }
+ * + * @see RuntimeException + */ +public class MappingException extends RuntimeException { + + public MappingException(String message) { + super(message); + } + + public MappingException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/exception/PushException.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/exception/PushException.java new file mode 100644 index 000000000000..513c6781ca31 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/exception/PushException.java @@ -0,0 +1,18 @@ +package com.dotcms.api.client.push.exception; + +/** + * Represents an exception that is thrown when a push operation fails. + *

+ * This class extends the RuntimeException class, making it an unchecked exception. + *

+ */ +public class PushException extends RuntimeException { + + public PushException(String message) { + super(message); + } + + public PushException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguageComparator.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguageComparator.java new file mode 100644 index 000000000000..5d52f1f6ae34 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguageComparator.java @@ -0,0 +1,104 @@ +package com.dotcms.api.client.push.language; + +import com.dotcms.api.client.push.ContentComparator; +import com.dotcms.model.language.Language; +import java.util.List; +import java.util.Optional; +import javax.enterprise.context.Dependent; +import javax.enterprise.context.control.ActivateRequestContext; + +@Dependent +public class LanguageComparator implements ContentComparator { + + @Override + public Class type() { + return Language.class; + } + + @ActivateRequestContext + @Override + public Optional findMatchingServerContent(Language localLanguage, + List serverContents) { + + // Compare by id first. + var result = findById(localLanguage.id(), serverContents); + + if (result.isEmpty()) { + + // If not found by id, compare by ISO code. + result = findByISOCode(localLanguage.isoCode(), serverContents); + } + + return result; + } + + @ActivateRequestContext + @Override + public Optional localContains(Language serverContent, List localLanguages) { + + // Compare by id first. + var result = findById(serverContent.id(), localLanguages); + + if (result.isEmpty()) { + + // If not found by id, compare by ISO code. + result = findByISOCode(serverContent.isoCode(), localLanguages); + } + + return result; + } + + @ActivateRequestContext + @Override + public boolean contentEquals(Language localLanguage, Language serverContent) { + + // Validation to make sure the equals method works as expected + if (localLanguage.defaultLanguage().isEmpty()) { + localLanguage = localLanguage.withDefaultLanguage(false); + } + + // Comparing the local and server content in order to determine if we need to update or not the content + return localLanguage.equals(serverContent); + } + + /** + * Finds a Language object in the given list based on the specified id. + * + * @param id the id of the Language object to be found + * @param languages the list of Language objects to search in + * @return an Optional containing the found Language object, or an empty Optional if no match is + * found + */ + private Optional findById(Optional id, List languages) { + + if (id.isPresent()) { + for (var language : languages) { + if (language.id().isPresent() && language.id().get().equals(id.get())) { + return Optional.of(language); + } + } + } + + return Optional.empty(); + } + + /** + * Finds a Language object in the given list based on the specified ISO code. + * + * @param isoCode the ISO code of the Language object to be found + * @param languages the list of Language objects to search in + * @return an Optional containing the found Language object, or an empty Optional if no match is + * found + */ + private Optional findByISOCode(String isoCode, List languages) { + + for (var language : languages) { + if (language.isoCode().equalsIgnoreCase(isoCode)) { + return Optional.of(language); + } + } + + return Optional.empty(); + } + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguageFetcher.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguageFetcher.java new file mode 100644 index 000000000000..397428254d69 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguageFetcher.java @@ -0,0 +1,25 @@ +package com.dotcms.api.client.push.language; + +import com.dotcms.api.LanguageAPI; +import com.dotcms.api.client.RestClientFactory; +import com.dotcms.api.client.push.ContentFetcher; +import com.dotcms.model.language.Language; +import java.util.List; +import javax.enterprise.context.Dependent; +import javax.enterprise.context.control.ActivateRequestContext; +import javax.inject.Inject; + +@Dependent +public class LanguageFetcher implements ContentFetcher { + + @Inject + protected RestClientFactory clientFactory; + + @ActivateRequestContext + @Override + public List fetch() { + var languageAPI = clientFactory.getClient(LanguageAPI.class); + return languageAPI.list().entity(); + } + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguagePushHandler.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguagePushHandler.java new file mode 100644 index 000000000000..5dda6e0272b9 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/language/LanguagePushHandler.java @@ -0,0 +1,104 @@ +package com.dotcms.api.client.push.language; + +import com.dotcms.api.LanguageAPI; +import com.dotcms.api.client.RestClientFactory; +import com.dotcms.api.client.push.PushHandler; +import com.dotcms.model.language.Language; +import java.io.File; +import java.util.Optional; +import javax.enterprise.context.Dependent; +import javax.enterprise.context.control.ActivateRequestContext; +import javax.inject.Inject; + +@Dependent +public class LanguagePushHandler implements PushHandler { + + @Inject + protected RestClientFactory clientFactory; + + @Override + public Class type() { + return Language.class; + } + + @Override + public String title() { + return "Languages"; + } + + @Override + public String contentSimpleDisplay(Language language) { + + if (language.id().isPresent()) { + return String.format( + "id: [%s] code: [%s]", + language.id().get(), + language.isoCode() + ); + } else { + return String.format( + "code: [%s]", + language.isoCode() + ); + } + } + + @ActivateRequestContext + @Override + public void add(File localFile, Language localLanguage) { + + // Check if the language is missing some required values and trying to set them + localLanguage = setMissingValues(localLanguage); + + final LanguageAPI languageAPI = clientFactory.getClient(LanguageAPI.class); + languageAPI.create( + Language.builder().from(localLanguage).id(Optional.empty()).build() + ); + } + + @ActivateRequestContext + @Override + public void edit(File localFile, Language localLanguage, Language serverLanguage) { + + // Check if the language is missing some required values and trying to set them + localLanguage = setMissingValues(localLanguage); + + final LanguageAPI languageAPI = clientFactory.getClient(LanguageAPI.class); + languageAPI.update( + localLanguage.id().map(String::valueOf).orElseThrow(() -> + new RuntimeException("Missing language ID") + ), Language.builder().from(localLanguage).id(Optional.empty()).build() + ); + } + + @ActivateRequestContext + @Override + public void remove(Language serverLanguage) { + + final LanguageAPI languageAPI = clientFactory.getClient(LanguageAPI.class); + languageAPI.delete( + serverLanguage.id().map(String::valueOf).orElseThrow(() -> + new RuntimeException("Missing language ID") + ) + ); + } + + private Language setMissingValues(Language localLanguage) { + + final String isoCode = localLanguage.isoCode(); + if (localLanguage.languageCode().isEmpty()) { + localLanguage = localLanguage.withLanguageCode(isoCode.split("-")[0]); + } + + if (localLanguage.countryCode().isEmpty()) { + if (isoCode.split("-").length > 1) { + localLanguage = localLanguage.withCountryCode(isoCode.split("-")[1]); + } else { + localLanguage = localLanguage.withCountryCode(""); + } + } + + return localLanguage; + } + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/task/ProcessResultTask.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/task/ProcessResultTask.java new file mode 100644 index 000000000000..d9ba7aaaebdc --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/task/ProcessResultTask.java @@ -0,0 +1,148 @@ +package com.dotcms.api.client.push.task; + +import com.dotcms.api.client.push.MapperService; +import com.dotcms.api.client.push.PushHandler; +import com.dotcms.api.client.push.exception.PushException; +import com.dotcms.model.push.PushAnalysisResult; +import java.util.concurrent.RecursiveAction; +import org.jboss.logging.Logger; + +/** + * Represents a task for processing a {@link PushAnalysisResult}. + *

+ * This task extends {@link RecursiveAction} and is used to perform the necessary actions based on + * the result of the push analysis. + * + * @param the type of content being pushed + */ +public class ProcessResultTask extends RecursiveAction { + + private final PushAnalysisResult result; + + private final PushHandler pushHandler; + + private final MapperService mapperService; + + final boolean allowRemove; + + private final Logger logger; + + public ProcessResultTask(final PushAnalysisResult result, final boolean allowRemove, + final PushHandler pushHandler, final MapperService mapperService, + final Logger logger) { + + this.result = result; + this.allowRemove = allowRemove; + this.pushHandler = pushHandler; + this.mapperService = mapperService; + this.logger = logger; + } + + /** + * This method is responsible for performing the push operation based on the given result. It + * handles different actions such as adding, updating, removing, or no action required. + * + * @throws PushException if there is an error while performing the push operation + */ + @Override + protected void compute() { + + try { + + T localContent = null; + if (result.localFile().isPresent()) { + localContent = this.mapperService.map(result.localFile().get(), + this.pushHandler.type()); + } + + switch (result.action()) { + case ADD: + + logger.debug(String.format("Pushing file [%s] for [%s] operation", + result.localFile().get().getAbsolutePath(), result.action())); + + if (result.localFile().isPresent()) { + this.pushHandler.add(result.localFile().get(), localContent); + } else { + var message = "Local file is missing for add action"; + logger.error(message); + throw new PushException(message); + } + break; + case UPDATE: + + logger.debug(String.format("Pushing file [%s] for [%s] operation", + result.localFile().get().getAbsolutePath(), result.action())); + + if (result.localFile().isPresent() && result.serverContent().isPresent()) { + this.pushHandler.edit(result.localFile().get(), localContent, + result.serverContent().get()); + } else { + String message = "Local file or server content is missing for update action"; + logger.error(message); + throw new PushException(message); + } + break; + case REMOVE: + + if (this.allowRemove) { + logger.debug( + String.format("Pushing [%s] operation for [%s]", + result.action(), + this.pushHandler.contentSimpleDisplay( + result.serverContent().get()) + ) + ); + + if (result.serverContent().isPresent()) { + + logger.debug( + String.format("Pushing [%s] operation for [%s]", + result.action(), + this.pushHandler.contentSimpleDisplay( + result.serverContent().get()) + ) + ); + + this.pushHandler.remove(result.serverContent().get()); + } else { + var message = "Server content is missing for remove action"; + logger.error(message); + throw new PushException(message); + } + } + break; + case NO_ACTION: + + if (result.localFile().isPresent()) { + logger.debug(String.format("File [%s] requires no action", + result.localFile().get().getAbsolutePath())); + } + + // Do nothing for now + break; + default: + logger.error("Unknown action: " + result.action()); + break; + } + } catch (Exception e) { + + var message = String.format("Error pushing content for operation [%s]", + result.action()); + if (result.localFile().isPresent()) { + message = String.format("Error pushing file [%s] for operation [%s] - [%s]", + result.localFile().get().getAbsolutePath(), result.action(), + e.getMessage()); + } else if (result.serverContent().isPresent()) { + message = String.format("Error pushing [%s] for operation [%s] - [%s]", + this.pushHandler.contentSimpleDisplay(result.serverContent().get()), + result.action(), e.getMessage()); + } + + logger.error(message, e); + throw new PushException(message, e); + } + + } +} + diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/task/PushTask.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/task/PushTask.java new file mode 100644 index 000000000000..4f3d56252db2 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/api/client/push/task/PushTask.java @@ -0,0 +1,95 @@ +package com.dotcms.api.client.push.task; + +import com.dotcms.api.client.push.MapperService; +import com.dotcms.api.client.push.PushHandler; +import com.dotcms.cli.common.ConsoleProgressBar; +import com.dotcms.model.push.PushAnalysisResult; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.RecursiveAction; +import java.util.concurrent.RecursiveTask; +import org.jboss.logging.Logger; + +/** + * Represents a task for pushing analysis results using a specified push handler. This class extends + * the `RecursiveTask` class from the `java.util.concurrent` package. + * + * @param the type of analysis result + */ +public class PushTask extends RecursiveTask> { + + private final List> analysisResults; + + private final PushHandler pushHandler; + + private final boolean allowRemove; + + private final boolean failFast; + + private final MapperService mapperService; + + private final ConsoleProgressBar progressBar; + + private Logger logger; + + public PushTask( + final List> analysisResults, + final boolean allowRemove, + final boolean failFast, + final PushHandler pushHandler, + final MapperService mapperService, + final Logger logger, + final ConsoleProgressBar progressBar) { + + this.analysisResults = analysisResults; + this.allowRemove = allowRemove; + this.failFast = failFast; + this.pushHandler = pushHandler; + this.mapperService = mapperService; + this.logger = logger; + this.progressBar = progressBar; + } + + /** + * Computes the analysis results and returns a list of exceptions. + * + * @return a list of exceptions encountered during the computation + */ + @Override + protected List compute() { + + var errors = new ArrayList(); + + List tasks = new ArrayList<>(); + + for (var result : analysisResults) { + var task = new ProcessResultTask<>( + result, + allowRemove, + pushHandler, + mapperService, + logger + ); + tasks.add(task); + task.fork(); + } + + // Join all tasks + for (RecursiveAction task : tasks) { + try { + task.join(); + } catch (Exception e) { + if (failFast) { + throw e; + } else { + errors.add(e); + } + } finally { + progressBar.incrementStep(); + } + } + + return errors; + } + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/PushCommand.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/PushCommand.java index f78f09bd1551..d7a902bb31c1 100644 --- a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/PushCommand.java +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/PushCommand.java @@ -5,9 +5,7 @@ import com.dotcms.cli.common.OutputOptionMixin; import com.dotcms.cli.common.PushMixin; import com.dotcms.common.WorkspaceManager; -import java.io.File; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.concurrent.Callable; import javax.enterprise.context.control.ActivateRequestContext; @@ -104,26 +102,16 @@ CommandLine createCommandLine(DotPush command) { /** * Checks if the provided file is a valid workspace. * - * @param fromFile the file representing the workspace directory. If null, the current directory - * is used. + * @param path represents the workspace directory. * @throws IllegalArgumentException if no valid workspace is found at the specified path. */ - void checkValidWorkspace(final File fromFile) { - - String fromPath; - if (fromFile == null) { - // If the workspace is not specified, we use the current directory - fromPath = Paths.get("").toAbsolutePath().normalize().toString(); - } else { - fromPath = fromFile.getAbsolutePath(); - } + void checkValidWorkspace(final Path path) { - final Path path = Paths.get(fromPath); final var workspace = workspaceManager.findWorkspace(path); if (workspace.isEmpty()) { throw new IllegalArgumentException( - String.format("No valid workspace found at path: [%s]", fromPath)); + String.format("No valid workspace found at path: [%s]", path.toAbsolutePath())); } } diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/AbstractFilesCommand.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/AbstractFilesCommand.java index 374eb77cbc57..7603c11ce923 100644 --- a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/AbstractFilesCommand.java +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/AbstractFilesCommand.java @@ -8,7 +8,6 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.HashSet; import java.util.Set; import javax.inject.Inject; @@ -67,23 +66,13 @@ protected File getOrCreateWorkspaceFilesDirectory(final Path workspacePath) thro } /** - * Returns the directory where the workspace is. * - * @param fromFile the file object representing a directory within the workspace, or null if not specified + * @param path represents a directory within the workspace * @return the workspace files directory * @throws IllegalArgumentException if a valid workspace is not found from the provided path */ - protected File getWorkspaceDirectory(final File fromFile) { - - String fromPath; - if (fromFile == null) { - // If the workspace is not specified, we use the current directory - fromPath = Paths.get("").toAbsolutePath().normalize().toString(); - } else { - fromPath = fromFile.getAbsolutePath(); - } + protected File getWorkspaceDirectory(final Path path) { - final Path path = Paths.get(fromPath); final var workspace = workspaceManager.findWorkspace(path); if (workspace.isPresent()) { @@ -91,7 +80,7 @@ protected File getWorkspaceDirectory(final File fromFile) { } throw new IllegalArgumentException( - String.format("No valid workspace found at path: [%s]", fromPath)); + String.format("No valid workspace found at path: [%s]", path.toAbsolutePath())); } diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/FilesPush.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/FilesPush.java index 55b5db0a73c8..5a341f9b906a 100644 --- a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/FilesPush.java +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/FilesPush.java @@ -9,11 +9,9 @@ import com.dotcms.cli.command.DotCommand; import com.dotcms.cli.command.DotPush; import com.dotcms.cli.common.ConsoleLoadingAnimation; -import com.dotcms.cli.common.FilesPushMixin; import com.dotcms.cli.common.OutputOptionMixin; import com.dotcms.cli.common.PushMixin; import com.dotcms.common.AssetsUtils; -import java.nio.file.Paths; import java.util.List; import java.util.Optional; import java.util.concurrent.Callable; @@ -61,18 +59,14 @@ public Integer call() throws Exception { } // Getting the workspace - var workspace = getWorkspaceDirectory(pushMixin.path); - - // If the source is not specified, we use the current directory - if (pushMixin.path == null) { - pushMixin.path = Paths.get("").toAbsolutePath().normalize().toFile(); - } + var workspace = getWorkspaceDirectory(pushMixin.path()); CompletableFuture, AssetsUtils.LocalPathStructure, TreeNode>>> folderTraversalFuture = CompletableFuture.supplyAsync( () -> { // Service to handle the traversal of the folder - return pushService.traverseLocalFolders(output, workspace, pushMixin.path, + return pushService.traverseLocalFolders(output, workspace, + pushMixin.path().toFile(), filesPushMixin.removeAssets, filesPushMixin.removeFolders, true, true); }); @@ -95,7 +89,8 @@ public Integer call() throws Exception { if (result == null) { output.error(String.format( - "Error occurred while pushing folder info: [%s].", pushMixin.path)); + "Error occurred while pushing folder info: [%s].", + pushMixin.path().toAbsolutePath())); return CommandLine.ExitCode.SOFTWARE; } diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/FilesPushMixin.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/FilesPushMixin.java similarity index 95% rename from tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/FilesPushMixin.java rename to tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/FilesPushMixin.java index 30cae5c03acc..56ef8dc11f78 100644 --- a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/FilesPushMixin.java +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/files/FilesPushMixin.java @@ -1,4 +1,4 @@ -package com.dotcms.cli.common; +package com.dotcms.cli.command.files; import picocli.CommandLine; diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/language/LanguagePush.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/language/LanguagePush.java index d0257240fdad..b245b0b154de 100644 --- a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/language/LanguagePush.java +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/language/LanguagePush.java @@ -1,14 +1,20 @@ package com.dotcms.cli.command.language; import com.dotcms.api.LanguageAPI; +import com.dotcms.api.client.push.MapperService; +import com.dotcms.api.client.push.PushService; +import com.dotcms.api.client.push.language.LanguageComparator; +import com.dotcms.api.client.push.language.LanguageFetcher; +import com.dotcms.api.client.push.language.LanguagePushHandler; import com.dotcms.cli.command.DotCommand; -import com.dotcms.cli.common.FormatOptionMixin; +import com.dotcms.cli.command.DotPush; import com.dotcms.cli.common.OutputOptionMixin; -import com.dotcms.cli.common.WorkspaceMixin; +import com.dotcms.cli.common.PushMixin; import com.dotcms.common.WorkspaceManager; import com.dotcms.model.ResponseEntityView; import com.dotcms.model.config.Workspace; import com.dotcms.model.language.Language; +import com.dotcms.model.push.PushOptions; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.File; import java.io.IOException; @@ -21,38 +27,58 @@ import picocli.CommandLine; import picocli.CommandLine.ExitCode; +/** + * The LanguagePush class represents a command that allows pushing languages to the server. It + * provides functionality to push a language file or folder path, or create a new language by + * providing a language ISO code. + */ @ActivateRequestContext @CommandLine.Command( name = LanguagePush.NAME, header = "@|bold,blue Push a language|@", description = { - " Save or update a language given a Language object (in JSON or YML format) or iso (e.g.: en-us)", - " Push a language given a Language object (in JSON or YML format) or iso (e.g.: en-us)", - " If no file is specified, a new language will be created using the iso provided.", + "This command enables the pushing of languages to the server. It accommodates the " + + "specification of either a language file or a folder path. In addition to " + + "these options, it also facilitates the creation of a new language through " + + "the provision of a language iso code (e.g.: en-us).", "" // empty string to add a new line } ) -/** - * Command to push a language given a Language object (in JSON or YML format) or iso code (e.g.: en-us) - * @author nollymar - */ -public class LanguagePush extends AbstractLanguageCommand implements Callable, DotCommand { +public class LanguagePush extends AbstractLanguageCommand implements Callable, DotCommand, + DotPush { + static final String NAME = "push"; - @CommandLine.Mixin(name = "format") - FormatOptionMixin formatOption; + static final String LANGUAGES_PUSH_MIXIN = "languagesPushMixin"; + + @CommandLine.Mixin + PushMixin pushMixin; - @CommandLine.Mixin(name = "workspace") - WorkspaceMixin workspaceMixin; + @CommandLine.Mixin(name = LANGUAGES_PUSH_MIXIN) + LanguagesPushMixin languagesPushMixin; + + @CommandLine.Option(names = {"--byIso"}, description = + "Code to be used to create a new language. " + + "Used when no file is specified. For example: en-us") + String languageIso; @Inject WorkspaceManager workspaceManager; - @CommandLine.Option(names = {"--byIso"}, description = "Code to be used to create a new language. Used when no file is specified. For example: en-us") - String languageIso; + @Inject + PushService pushService; + + @Inject + LanguageFetcher languageProvider; - @CommandLine.Option(names = {"-f", "--file"}, description = "The json/yml formatted content-type descriptor file to be pushed. ") - File file; + @Inject + LanguageComparator languageComparator; + + @Inject + LanguagePushHandler languagePushHandler; + + @Inject + MapperService mapperService; @CommandLine.Spec CommandLine.Model.CommandSpec spec; @@ -60,78 +86,73 @@ public class LanguagePush extends AbstractLanguageCommand implements Callable workspace = workspaceManager.findWorkspace( + this.getPushMixin().path() + ); + if (workspace.isEmpty()) { + throw new IllegalArgumentException( + String.format("No valid workspace found at path: [%s]", + this.getPushMixin().path.toPath())); + } - ResponseEntityView responseEntityView; - if (null != inputFile) { - final Optional workspace = workspaceManager.findWorkspace(workspaceMixin.workspace()); - if(workspace.isPresent() && !inputFile.isAbsolute()){ + File inputFile = this.getPushMixin().path().toFile(); + if (!inputFile.isAbsolute()) { inputFile = Path.of(workspace.get().languages().toString(), inputFile.getName()).toFile(); } if (!inputFile.exists() || !inputFile.canRead()) { throw new IOException(String.format( - "Unable to read the input file [%s] check that it does exist and that you have read permissions on it.", - inputFile) + "Unable to access the path [%s] check that it does exist and that you have " + + "read permissions on it.", inputFile) ); } - final Language language = objectMapper.readValue(inputFile, Language.class); - responseEntityView = pushLanguageByFile(languageAPI, language); - - } else { - responseEntityView = pushLanguageByIsoCode(languageAPI); - } - - final Language response = responseEntityView.entity(); - output.info(objectMapper.writeValueAsString(response)); - - return CommandLine.ExitCode.OK; - - } - - - - private ResponseEntityView pushLanguageByFile(final LanguageAPI languageAPI, final Language language) { - - final String languageId = language.id().map(String::valueOf).orElse(""); - final ResponseEntityView responseEntityView; + // To make sure that if the user is passing a directory we use the languages folder + if (inputFile.isDirectory()) { + inputFile = workspace.get().languages().toFile(); + } - final String isoCode = language.isoCode(); - language.withLanguageCode(isoCode.split("-")[0]); + // Execute the push + pushService.push( + inputFile, + PushOptions.builder(). + failFast(pushMixin.failFast). + allowRemove(languagesPushMixin.removeLanguages). + maxRetryAttempts(pushMixin.retryAttempts). + dryRun(pushMixin.dryRun). + build(), + output, + languageProvider, + languageComparator, + languagePushHandler + ); - if (isoCode.split("-").length > 1) { - language.withCountryCode(isoCode.split("-")[1]); } else { - language.withCountryCode(""); - } - output.info(String.format("Attempting to save language with code @|bold,green [%s]|@",language.languageCode().get())); + final LanguageAPI languageAPI = clientFactory.getClient(LanguageAPI.class); - if (StringUtils.isNotBlank(languageId)){ - output.info(String.format("The id @|bold,green [%s]|@ provided in the language file will be used for look-up.", languageId)); - responseEntityView = languageAPI.update( - languageId, Language.builder().from(language).id(Optional.empty()).build()); - } else { - output.info("The language file @|bold did not|@ provide a language id. "); - responseEntityView = languageAPI.create(Language.builder().from(language).id(Optional.empty()).build()); - } - - output.info(String.format("Language with code @|bold,green [%s]|@ successfully pushed.",language.languageCode().get())); + var responseEntityView = pushLanguageByIsoCode(languageAPI); + final Language response = responseEntityView.entity(); - return responseEntityView; + final ObjectMapper objectMapper = mapperService.objectMapper(); + output.info(objectMapper.writeValueAsString(response)); + } + return CommandLine.ExitCode.OK; } private ResponseEntityView pushLanguageByIsoCode(final LanguageAPI languageAPI) { @@ -155,4 +176,14 @@ public OutputOptionMixin getOutput() { return output; } + @Override + public PushMixin getPushMixin() { + return pushMixin; + } + + @Override + public Optional getCustomMixinName() { + return Optional.of(LANGUAGES_PUSH_MIXIN); + } + } diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/language/LanguagesPushMixin.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/language/LanguagesPushMixin.java new file mode 100644 index 000000000000..91ae1ef66bd0 --- /dev/null +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/command/language/LanguagesPushMixin.java @@ -0,0 +1,13 @@ +package com.dotcms.cli.command.language; + +import picocli.CommandLine; + +public class LanguagesPushMixin { + + @CommandLine.Option(names = {"-rl", "--removeLanguages"}, defaultValue = "false", + description = + "When this option is enabled, the push process allows the deletion of languages in the remote server. " + + "By default, this option is disabled, and languages will not be removed on the remote server.") + public boolean removeLanguages; + +} diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/FormatOptionMixin.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/FormatOptionMixin.java index 5ed8211c3533..0216798841b5 100644 --- a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/FormatOptionMixin.java +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/FormatOptionMixin.java @@ -11,12 +11,7 @@ public class FormatOptionMixin { @CommandLine.Option(names = {"-fmt", "--format"}, description = "Enum values: ${COMPLETION-CANDIDATES}") InputOutputFormat inputOutputFormat = InputOutputFormat.defaultFormat(); - private ObjectMapper objectMapper; - public ObjectMapper objectMapper(final File file) { - if (null != objectMapper) { - return objectMapper; - } if (null != file){ if (isJSONFile(file)){ @@ -26,6 +21,7 @@ public ObjectMapper objectMapper(final File file) { } } + ObjectMapper objectMapper; if (inputOutputFormat == InputOutputFormat.JSON) { objectMapper = new ClientObjectMapper().getContext(null); } else { diff --git a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/PushMixin.java b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/PushMixin.java index 091439ff8965..4703b6f90089 100644 --- a/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/PushMixin.java +++ b/tools/dotcms-cli/cli/src/main/java/com/dotcms/cli/common/PushMixin.java @@ -1,6 +1,7 @@ package com.dotcms.cli.common; import java.io.File; +import java.nio.file.Path; import picocli.CommandLine; /** @@ -42,12 +43,15 @@ public class PushMixin { public boolean noValidateUnmatchedArguments; /** - * Returns the path of the file. (Useful for mocking) + * Returns the path of the file. If no path is provided, it will return current working directory. * * @return The path of the file. */ - public File path() { - return path; + public Path path() { + if (null == path) { + return Path.of(""); + } + return path.toPath(); } } diff --git a/tools/dotcms-cli/cli/src/test/java/com/dotcms/cli/command/PushCommandTest.java b/tools/dotcms-cli/cli/src/test/java/com/dotcms/cli/command/PushCommandTest.java index d6b5256da694..93e72d6e23c9 100644 --- a/tools/dotcms-cli/cli/src/test/java/com/dotcms/cli/command/PushCommandTest.java +++ b/tools/dotcms-cli/cli/src/test/java/com/dotcms/cli/command/PushCommandTest.java @@ -185,7 +185,7 @@ void testAllPushCommandsAreCalled() throws Exception { when(parseResult.expandedArgs()). thenReturn(new ArrayList<>()); - when(pushMixin.path()).thenReturn(tempFolder.toFile()); + when(pushMixin.path()).thenReturn(tempFolder.toAbsolutePath()); pushCommand.workspaceManager = workspaceManager; pushCommand.call(); diff --git a/tools/dotcms-cli/cli/src/test/java/com/dotcms/cli/command/language/LanguageCommandIntegrationTest.java b/tools/dotcms-cli/cli/src/test/java/com/dotcms/cli/command/language/LanguageCommandIntegrationTest.java index 4d1c7ca7f87e..28c49975f5ee 100644 --- a/tools/dotcms-cli/cli/src/test/java/com/dotcms/cli/command/language/LanguageCommandIntegrationTest.java +++ b/tools/dotcms-cli/cli/src/test/java/com/dotcms/cli/command/language/LanguageCommandIntegrationTest.java @@ -1,6 +1,8 @@ package com.dotcms.cli.command.language; import com.dotcms.api.AuthenticationContext; +import com.dotcms.api.LanguageAPI; +import com.dotcms.api.client.RestClientFactory; import com.dotcms.api.provider.ClientObjectMapper; import com.dotcms.api.provider.YAMLMapperSupplier; import com.dotcms.cli.command.CommandTest; @@ -11,14 +13,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.quarkus.test.junit.QuarkusTest; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import picocli.CommandLine; -import picocli.CommandLine.ExitCode; - -import javax.inject.Inject; -import java.io.File; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; @@ -29,14 +23,26 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.UUID; import java.util.stream.Stream; +import javax.inject.Inject; +import javax.ws.rs.NotFoundException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import picocli.CommandLine; +import picocli.CommandLine.ExitCode; @QuarkusTest class LanguageCommandIntegrationTest extends CommandTest { + @Inject AuthenticationContext authenticationContext; + @Inject WorkspaceManager workspaceManager; + @Inject + RestClientFactory clientFactory; + @BeforeEach public void setupTest() throws IOException { resetServiceProfiles(); @@ -125,8 +131,8 @@ void Test_Command_Language_Pull_By_IsoCode_Checking_JSON_DotCMS_Type() throws IO Assertions.assertTrue(json.contains("\"dotCMSObjectType\" : \"Language\"")); // And now pushing the language back to dotCMS to make sure the structure is still correct - status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, "-f", - languageFilePath.toAbsolutePath().toString()); + status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, + languageFilePath.toAbsolutePath().toString(), "-ff"); Assertions.assertEquals(CommandLine.ExitCode.OK, status); } finally { deleteTempDirectory(tempFolder); @@ -166,9 +172,8 @@ void Test_Command_Language_Pull_By_IsoCode_Checking_YAML_DotCMS_Type() throws IO Assertions.assertTrue(json.contains("dotCMSObjectType: \"Language\"")); // And now pushing the language back to dotCMS to make sure the structure is still correct - status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, "-f", - languageFilePath.toAbsolutePath().toString(), "-fmt", - InputOutputFormat.YAML.toString()); + status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, + languageFilePath.toAbsolutePath().toString(), "-ff"); Assertions.assertEquals(CommandLine.ExitCode.OK, status); } finally { deleteTempDirectory(tempFolder); @@ -223,10 +228,26 @@ void Test_Command_Language_Push_byIsoCodeWithoutCountry() { final StringWriter writer = new StringWriter(); try (PrintWriter out = new PrintWriter(writer)) { commandLine.setOut(out); - final int status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, "--byIso", "fr"); + final int status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, + "--byIso", "fr"); Assertions.assertEquals(CommandLine.ExitCode.OK, status); - final String output = writer.toString(); - Assertions.assertTrue(output.contains("French")); + + // Checking we pushed the language correctly + var foundLanguage = clientFactory.getClient(LanguageAPI.class). + getFromLanguageIsoCode("fr"); + Assertions.assertNotNull(foundLanguage); + Assertions.assertNotNull(foundLanguage.entity()); + Assertions.assertTrue(foundLanguage.entity().language().isPresent()); + Assertions.assertEquals("French", foundLanguage.entity().language().get()); + + // Cleaning up + try { + clientFactory.getClient(LanguageAPI.class).delete( + String.valueOf(foundLanguage.entity().id().get()) + ); + } catch (Exception e) { + // Ignoring + } } } @@ -238,6 +259,11 @@ void Test_Command_Language_Push_byIsoCodeWithoutCountry() { */ @Test void Test_Command_Language_Push_byFile_JSON() throws IOException { + + // Create a temporal folder for the workspace + var tempFolder = createTempFolder(); + final Workspace workspace = workspaceManager.getOrCreate(tempFolder); + final CommandLine commandLine = createCommand(); final StringWriter writer = new StringWriter(); try (PrintWriter out = new PrintWriter(writer)) { @@ -245,13 +271,29 @@ void Test_Command_Language_Push_byFile_JSON() throws IOException { final Language language = Language.builder().isoCode("it-it").languageCode("it-IT") .countryCode("IT").language("Italian").country("Italy").build(); final ObjectMapper mapper = new ClientObjectMapper().getContext(null); - final File targetFile = File.createTempFile("language", ".json"); - mapper.writeValue(targetFile, language); + final var targetFilePath = Path.of(workspace.languages().toString(), "language.json"); + mapper.writeValue(targetFilePath.toFile(), language); commandLine.setOut(out); - final int status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, "-f", targetFile.getAbsolutePath()); + final int status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, + targetFilePath.toAbsolutePath().toString(), "-ff"); Assertions.assertEquals(CommandLine.ExitCode.OK, status); - final String output = writer.toString(); - Assertions.assertTrue(output.contains("Italian")); + + // Checking we pushed the language correctly + var foundLanguage = clientFactory.getClient(LanguageAPI.class). + getFromLanguageIsoCode("it-IT"); + Assertions.assertNotNull(foundLanguage); + Assertions.assertNotNull(foundLanguage.entity()); + Assertions.assertTrue(foundLanguage.entity().language().isPresent()); + Assertions.assertEquals("Italian", foundLanguage.entity().language().get()); + + // Cleaning up + try { + clientFactory.getClient(LanguageAPI.class).delete( + String.valueOf(foundLanguage.entity().id().get()) + ); + } catch (Exception e) { + // Ignoring + } } } @@ -263,27 +305,42 @@ void Test_Command_Language_Push_byFile_JSON() throws IOException { */ @Test void Test_Command_Language_Push_byFile_YAML() throws IOException { + + // Create a temporal folder for the workspace + var tempFolder = createTempFolder(); + final Workspace workspace = workspaceManager.getOrCreate(tempFolder); + final CommandLine commandLine = createCommand(); final StringWriter writer = new StringWriter(); try (PrintWriter out = new PrintWriter(writer)) { + //Create a YAML file with the language to push final Language language = Language.builder().isoCode("it-it").languageCode("it-IT") .countryCode("IT").language("Italian").country("Italy").build(); final ObjectMapper mapper = new YAMLMapperSupplier().get(); - final File targetFile = File.createTempFile("language", ".yml"); - mapper.writeValue(targetFile, language); + final var targetFilePath = Path.of(workspace.languages().toString(), "language.yml"); + mapper.writeValue(targetFilePath.toFile(), language); commandLine.setOut(out); - int status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, "-f", targetFile.getAbsolutePath(), "-fmt", - InputOutputFormat.YAML.toString()); + int status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, + targetFilePath.toAbsolutePath().toString(), "-ff"); Assertions.assertEquals(CommandLine.ExitCode.OK, status); - String output = writer.toString(); - Assertions.assertTrue(output.contains("Italian")); - //The push command should work without specifying the format - status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, "-f", targetFile.getAbsolutePath()); - Assertions.assertEquals(CommandLine.ExitCode.OK, status); - output = writer.toString(); - Assertions.assertTrue(output.contains("Italian")); + // Checking we pushed the language correctly + var foundLanguage = clientFactory.getClient(LanguageAPI.class). + getFromLanguageIsoCode("it-IT"); + Assertions.assertNotNull(foundLanguage); + Assertions.assertNotNull(foundLanguage.entity()); + Assertions.assertTrue(foundLanguage.entity().language().isPresent()); + Assertions.assertEquals("Italian", foundLanguage.entity().language().get()); + + // Cleaning up + try { + clientFactory.getClient(LanguageAPI.class).delete( + String.valueOf(foundLanguage.entity().id().get()) + ); + } catch (Exception e) { + // Ignoring + } } } @@ -383,6 +440,167 @@ void Test_Pull_Same_Language_Multiple_Times() throws IOException { } } + /** + * This tests will test the functionality of the language push command when pushing a folder, + * checking that the languages are properly add, updated and removed on the remote server. + */ + @Test + void Test_Command_Language_Folder_Push() throws IOException { + + // Create a temporal folder for the workspace + var tempFolder = createTempFolder(); + final Workspace workspace = workspaceManager.getOrCreate(tempFolder); + + final CommandLine commandLine = createCommand(); + final StringWriter writer = new StringWriter(); + try (PrintWriter out = new PrintWriter(writer)) { + + // Pulling the en us language + int status = commandLine.execute(LanguageCommand.NAME, LanguagePull.NAME, "en-US", + "-fmt", InputOutputFormat.YAML.toString(), "--workspace", + workspace.root().toString()); + Assertions.assertEquals(CommandLine.ExitCode.OK, status); + + // Make sure the language it is really there + final var languageUSPath = Path.of(workspace.languages().toString(), "en-us.yml"); + var json = Files.readString(languageUSPath); + Assertions.assertTrue(json.contains("countryCode: \"US\"")); + + //Now, create a couple of file with a new languages to push + final Language italian = Language.builder(). + isoCode("it-it"). + languageCode("it-IT"). + countryCode("IT"). + language("Italian"). + country("Italy"). + build(); + var mapper = new ClientObjectMapper().getContext(null); + var targetItalianFilePath = Path.of(workspace.languages().toString(), "it-it.json"); + mapper.writeValue(targetItalianFilePath.toFile(), italian); + + // --- + //Create a couple of file with a new languages to push + final Language french = Language.builder(). + isoCode("fr"). + language("French"). + build(); + var targetFrenchFilePath = Path.of(workspace.languages().toString(), "fr.json"); + mapper.writeValue(targetFrenchFilePath.toFile(), french); + + // --- + // Pushing the languages + commandLine.setOut(out); + status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, + workspace.languages().toString(), "-ff"); + Assertions.assertEquals(CommandLine.ExitCode.OK, status); + + // --- + // Checking we pushed the languages correctly + // Italian + var italianResponse = clientFactory.getClient(LanguageAPI.class). + getFromLanguageIsoCode("it-IT"); + Assertions.assertNotNull(italianResponse); + Assertions.assertNotNull(italianResponse.entity()); + Assertions.assertTrue(italianResponse.entity().language().isPresent()); + Assertions.assertEquals("Italian", italianResponse.entity().language().get()); + + // French + var frenchResponse = clientFactory.getClient(LanguageAPI.class). + getFromLanguageIsoCode("fr"); + Assertions.assertNotNull(frenchResponse); + Assertions.assertNotNull(frenchResponse.entity()); + Assertions.assertTrue(frenchResponse.entity().language().isPresent()); + Assertions.assertEquals("French", frenchResponse.entity().language().get()); + + // --- + // Pulling italian, we need the file with the updated id + status = commandLine.execute(LanguageCommand.NAME, LanguagePull.NAME, "it-IT", + "-fmt", InputOutputFormat.JSON.toString().toUpperCase(), + "--workspace", workspace.root().toString()); + Assertions.assertEquals(CommandLine.ExitCode.OK, status); + + // --- + // Now we remove a file and test the removal is working properly + Files.delete(targetFrenchFilePath); + + // Editing the italian file to validate the update works + final Language updatedItalian = Language.builder(). + isoCode("it-va"). + id(italianResponse.entity().id().get()). + languageCode("it-va"). + countryCode("VA"). + language("Italian"). + country("Vatican City"). + build(); + mapper = new ClientObjectMapper().getContext(null); + targetItalianFilePath = Path.of(workspace.languages().toString(), "it-it.json"); + mapper.writeValue(targetItalianFilePath.toFile(), updatedItalian); + + // Pushing the languages with the remove language flag + status = commandLine.execute(LanguageCommand.NAME, LanguagePush.NAME, + workspace.languages().toString(), "-ff", "-rl"); + Assertions.assertEquals(CommandLine.ExitCode.OK, status); + + // --- + // Make sure Italian-VA is there and Italian-IT and French is not + + // Italian-Vatican city - Should be there + italianResponse = clientFactory.getClient(LanguageAPI.class). + getFromLanguageIsoCode("it-VA"); + Assertions.assertNotNull(italianResponse); + Assertions.assertNotNull(italianResponse.entity()); + Assertions.assertTrue(italianResponse.entity().language().isPresent()); + Assertions.assertEquals("Italian", italianResponse.entity().language().get()); + Assertions.assertEquals("Vatican City", italianResponse.entity().country().get()); + + var allLanguages = clientFactory.getClient(LanguageAPI.class).list(); + Assertions.assertNotNull(allLanguages); + Assertions.assertEquals(2, allLanguages.entity().size()); + Assertions.assertTrue( + allLanguages.entity().stream() + .anyMatch(l -> l.isoCode().equalsIgnoreCase("it-VA")) + ); + Assertions.assertTrue( + allLanguages.entity().stream() + .anyMatch(l -> l.isoCode().equalsIgnoreCase("en-US")) + ); + + } finally { + + // Cleaning up + try { + var foundItalian = clientFactory.getClient(LanguageAPI.class). + getFromLanguageIsoCode("it-IT"); + clientFactory.getClient(LanguageAPI.class).delete( + String.valueOf(foundItalian.entity().id().get()) + ); + } catch (Exception e) { + // Ignoring + } + + // Cleaning up + try { + var foundItalian = clientFactory.getClient(LanguageAPI.class). + getFromLanguageIsoCode("it-VA"); + clientFactory.getClient(LanguageAPI.class).delete( + String.valueOf(foundItalian.entity().id().get()) + ); + } catch (Exception e) { + // Ignoring + } + + try { + var foundFrench = clientFactory.getClient(LanguageAPI.class). + getFromLanguageIsoCode("fr"); + clientFactory.getClient(LanguageAPI.class).delete( + String.valueOf(foundFrench.entity().id().get()) + ); + } catch (Exception e) { + // Ignoring + } + } + } + /** * Creates a temporary folder with a random name. * From fb6523a36080e1fb24e98d2486bb49f1815cebbf Mon Sep 17 00:00:00 2001 From: erickgonzalez Date: Tue, 26 Sep 2023 07:23:37 -0600 Subject: [PATCH 03/17] chore: remove persona icon (#26241) ref: #26159 Co-authored-by: Will Ezell --- dotCMS/src/main/webapp/html/js/tag.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/dotCMS/src/main/webapp/html/js/tag.js b/dotCMS/src/main/webapp/html/js/tag.js index 0d7a2ddb1f49..5d0c572c7737 100644 --- a/dotCMS/src/main/webapp/html/js/tag.js +++ b/dotCMS/src/main/webapp/html/js/tag.js @@ -334,11 +334,7 @@ function showTagsForSearch(result) { let tagName = tag.tagName; tagName = RTrim(tagName); tagName = LTrim(tagName); - if (tag.persona) { - personasTags += "" + tagName + ""; - } else { - tags += "" + tagName + ""; - } + tags += "" + tagName + ""; } From 3603500aa07c54299bffff2cd12efdecb5523aff Mon Sep 17 00:00:00 2001 From: erickgonzalez Date: Tue, 26 Sep 2023 08:52:24 -0600 Subject: [PATCH 04/17] Issue 26158 (#26240) * test: create test Ref: #26158 * chore: remove append of :persona Ref: #26158 --------- Co-authored-by: Will Ezell --- .../SaveContentActionletWithTagsTest.java | 38 +++++++++++++++++++ .../portlets/contentlet/model/Contentlet.java | 8 +--- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/actionlet/SaveContentActionletWithTagsTest.java b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/actionlet/SaveContentActionletWithTagsTest.java index cd422cc31166..e081ad86e610 100644 --- a/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/actionlet/SaveContentActionletWithTagsTest.java +++ b/dotCMS/src/integration-test/java/com/dotmarketing/portlets/workflows/actionlet/SaveContentActionletWithTagsTest.java @@ -6,11 +6,13 @@ import com.dotcms.contenttype.model.type.ContentType; import com.dotcms.contenttype.model.type.ContentTypeBuilder; import com.dotcms.contenttype.transform.contenttype.StructureTransformer; +import com.dotcms.datagen.TagDataGen; import com.dotcms.util.IntegrationTestInitService; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.exception.DotDataException; import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.business.HostAPI; import com.dotmarketing.portlets.contentlet.model.Contentlet; import com.dotmarketing.portlets.contentlet.model.ContentletDependencies; import com.dotmarketing.portlets.folders.business.FolderAPI; @@ -19,6 +21,7 @@ import com.dotmarketing.portlets.workflows.business.SystemWorkflowConstants; import com.dotmarketing.portlets.workflows.business.WorkflowAPI; import com.dotmarketing.portlets.workflows.model.WorkflowScheme; +import com.dotmarketing.tag.model.Tag; import com.dotmarketing.tag.model.TagInode; import org.junit.AfterClass; import org.junit.Assert; @@ -96,6 +99,41 @@ public static void cleanup() cleanupDebug(SaveContentActionletTest.class); } // cleanup + + /** + * Method to test: {@link Contentlet#setTags()} + * Given Scenario: Contentlet with persona tag is getting appended :persona + * Expected Result: tag should not have :persona, should be the same as the tag name. + */ + @Test + public void test_TagsShouldNotIncludePersona() throws DotDataException, DotSecurityException { + //Create persona Tag + final String tagName = "personaTag" + System.currentTimeMillis(); + final Tag tag = new TagDataGen().name(tagName).site(APILocator.getHostAPI().findSystemHost()).persona(true).nextPersisted(); + + //Add persona Tag to a contentlet + final Contentlet contentlet = new Contentlet(); + contentlet.setContentType(customContentType); + contentlet.setProperty("title", tag.getTagName()); + contentlet.setProperty("txt", tag.getTagName()); + contentlet.setProperty("tag", tag.getTagName()); + + final Contentlet contentletSaved = + workflowAPI.fireContentWorkflow(contentlet, + new ContentletDependencies.Builder() + .modUser(APILocator.systemUser()) + .workflowActionId(SystemWorkflowConstants.WORKFLOW_SAVE_ACTION_ID) + .build()); + + Assert.assertNotNull(contentletSaved); + Assert.assertEquals(tag.getTagName(), contentletSaved.getStringProperty("title")); + Assert.assertEquals(tag.getTagName(), contentletSaved.getStringProperty("txt")); + contentletSaved.setTags(); + //Check that tag do not include :persona + Assert.assertEquals(tag.getTagName(), contentletSaved.getStringProperty("tag")); + + } + @Test public void test_Save_Contentlet_Actionlet_Tags () throws DotSecurityException, DotDataException { diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/model/Contentlet.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/model/Contentlet.java index 5203d0c2d150..99a4518e806a 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/model/Contentlet.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/model/Contentlet.java @@ -1509,12 +1509,8 @@ public void setTags() throws DotDataException { if (contentletTagsBuilder.length() > 0) { contentletTagsBuilder.append(","); } - if (relatedTag.isPersona()) { - contentletTagsBuilder.append(relatedTag.getTagName()) - .append(":persona"); - } else { - contentletTagsBuilder.append(relatedTag.getTagName()); - } + + contentletTagsBuilder.append(relatedTag.getTagName()); contentletTagsMap.put(fieldVarName, contentletTagsBuilder); } else { From 1a6f81325cc36adc4ad2523afcbe4498d2671d67 Mon Sep 17 00:00:00 2001 From: Humberto Morera <31667212+hmoreras@users.noreply.github.com> Date: Tue, 26 Sep 2023 09:30:06 -0600 Subject: [PATCH 05/17] fix(experiments): Prevent editing of Experiment/variants when page locked by another user Prevent editing of Experiment/variants when page locked by another user Refs: #26187 --- .../src/lib/dot-experiments-constants.ts | 4 + ...riments-configuration-goals.component.html | 41 ++++---- ...ents-configuration-goals.component.spec.ts | 19 +++- ...periments-configuration-goals.component.ts | 1 + ...ts-configuration-scheduling.component.html | 18 ++-- ...configuration-scheduling.component.spec.ts | 2 +- ...ents-configuration-scheduling.component.ts | 1 + ...nts-configuration-targeting.component.html | 12 +-- ...ments-configuration-targeting.component.ts | 1 + ...ration-traffic-allocation-add.component.ts | 15 ++- ...nfiguration-traffic-split-add.component.ts | 21 ++--- ...ments-configuration-traffic.component.html | 15 ++- ...ts-configuration-traffic.component.spec.ts | 2 +- ...riments-configuration-traffic.component.ts | 19 ++-- ...nfiguration-variants-add.component.spec.ts | 3 +- ...ents-configuration-variants.component.html | 23 +++-- ...s-configuration-variants.component.spec.ts | 4 +- ...t-experiments-configuration.component.html | 5 +- ...xperiments-configuration.component.spec.ts | 15 ++- ...ot-experiments-configuration-store.spec.ts | 48 ++++++++++ .../dot-experiments-configuration-store.ts | 93 +++++++++++++++---- .../WEB-INF/messages/Language.properties | 1 + 22 files changed, 240 insertions(+), 123 deletions(-) diff --git a/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts b/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts index 5f09713e11ba..f1dbff7f1519 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-experiments-constants.ts @@ -295,3 +295,7 @@ export enum HealthStatusTypes { } export const RUNNING_UNTIL_DATE_FORMAT = 'EEE, LLL dd'; + +export const EXP_CONFIG_ERROR_LABEL_CANT_EDIT = 'experiment.configure.edit.only.draft.status'; + +export const EXP_CONFIG_ERROR_LABEL_PAGE_BLOCKED = 'experiment.configure.edit.page.blocked'; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.html index b84eeb11ee02..498a3a8f30ca 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.html @@ -7,8 +7,7 @@

[ngClass]="{ isDone: vm.goals }" [size]="16" data-testId="goal-title-step-done" - name="check_circle" - > + name="check_circle"> {{ 'experiments.configure.goals.name' | dm }} @@ -27,13 +26,11 @@

*ngIf="vm.goals.primary.conditions.length; else titleTpl" [data]="vm.goals.primary.conditions" [isEmpty]="false" - [title]="titleTpl" - > + [title]="titleTpl">
+ data-testId="goal-header-parameter"> {{ vm.goals.primary.type === GOAL_TYPES.URL_PARAMETER ? ('experiments.goal.conditions.query.parameter' | dm) @@ -67,8 +64,7 @@

+ data-testId="goal-value"> {{ vm.goals.primary.type === GOAL_TYPES.URL_PARAMETER ? row.value.value @@ -81,20 +77,16 @@

-
+
+ pButton>
@@ -110,20 +102,19 @@

+ tooltipPosition="bottom"> + pButton>
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.spec.ts index 38a6a839ec69..76b2b29586df 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.spec.ts @@ -11,6 +11,7 @@ import { ActivatedRoute } from '@angular/router'; import { ConfirmationService, MessageService } from 'primeng/api'; import { Card } from 'primeng/card'; +import { Tooltip } from 'primeng/tooltip'; import { DotMessageService } from '@dotcms/data-access'; import { @@ -46,11 +47,15 @@ const messageServiceMock = new MockDotMessageService({ const EXPERIMENT_MOCK = getExperimentMock(0); const EXPERIMENT_MOCK_WITH_GOAL = getExperimentMock(2); -function getVmMock(goals = GoalsMock): { +function getVmMock( + goals = GoalsMock, + disabledTooltipLabel = null +): { experimentId: string; goals: Goals; status: StepStatus; isExperimentADraft: boolean; + disabledTooltipLabel: null | string; } { return { experimentId: EXPERIMENT_MOCK.id, @@ -60,7 +65,8 @@ function getVmMock(goals = GoalsMock): { isOpen: false, experimentStep: null }, - isExperimentADraft: true + isExperimentADraft: true, + disabledTooltipLabel }; } @@ -135,6 +141,15 @@ describe('DotExperimentsConfigurationGoalsComponent', () => { expect(spectator.query(DotExperimentsDetailsTableComponent)).toExist(); }); + test('should disable the button of add goal if there is an error', () => { + spectator.component.vm$ = of(getVmMock(null, 'error')); + spectator.detectComponentChanges(); + + const addButton = spectator.query(byTestId('goals-add-button')) as HTMLButtonElement; + expect(addButton.disabled).toBe(true); + expect(spectator.query(Tooltip).disabled).toEqual(false); + }); + test('should call openSelectGoalSidebar if you click the add goal button', () => { jest.spyOn(spectator.component, 'openSelectGoalSidebar'); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.ts index 48bb913e1b0a..95bed68861ce 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goals/dot-experiments-configuration-goals.component.ts @@ -59,6 +59,7 @@ export class DotExperimentsConfigurationGoalsComponent { goals: Goals | null; status: StepStatus; isExperimentADraft: boolean; + disabledTooltipLabel: null | string; }> = this.dotExperimentsConfigurationStore.goalsStepVm$.pipe( tap(({ status }) => this.handleSidebar(status)) ); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html index f228c9c61142..5ddd5f49c2c7 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html @@ -6,8 +6,7 @@

+ name="check_circle"> {{ 'experiments.configure.scheduling.name' | dm }}

@@ -22,24 +21,21 @@

+ tooltipPosition="bottom"> diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.spec.ts index abba16ba76b4..a5dddef20cfd 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.spec.ts @@ -103,7 +103,7 @@ describe('DotExperimentsConfigurationSchedulingComponent', () => { expect(spectator.query(Tooltip).disabled).toEqual(true); }); - it('should disable button and show tooltip when experiment is nos on draft', () => { + it('should disable button and show tooltip when there is an error', () => { dotExperimentsService.getById.mockReturnValue( of({ ...EXPERIMENT_MOCK, diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.ts index d39a62885479..349fe9e1b040 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.ts @@ -44,6 +44,7 @@ export class DotExperimentsConfigurationSchedulingComponent { scheduling: RangeOfDateAndTime; status: StepStatus; isExperimentADraft: boolean; + disabledTooltipLabel: string | null; }> = this.dotExperimentsConfigurationStore.schedulingStepVm$.pipe( tap(({ status }) => this.handleSidebar(status)) ); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.html index da6628df6772..fda41581c64f 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.html @@ -9,19 +9,17 @@

+ tooltipPosition="bottom"> + pButton>
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.ts index e02c754a9580..faf6978630ac 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.ts @@ -27,6 +27,7 @@ export class DotExperimentsConfigurationTargetingComponent { experimentId: string; status: StepStatus; isExperimentADraft: boolean; + disabledTooltipLabel: string | null; }> = this.dotExperimentsConfigurationStore.targetStepVm$.pipe( tap(({ status }) => this.handleSidebar(status)) ); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-allocation-add/dot-experiments-configuration-traffic-allocation-add.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-allocation-add/dot-experiments-configuration-traffic-allocation-add.component.ts index 17c6c8ff0da6..0dda7f9c4749 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-allocation-add/dot-experiments-configuration-traffic-allocation-add.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-allocation-add/dot-experiments-configuration-traffic-allocation-add.component.ts @@ -18,12 +18,15 @@ import { SliderModule } from 'primeng/slider'; import { take } from 'rxjs/operators'; import { DotFieldValidationMessageModule } from '@components/_common/dot-field-validation-message/dot-file-validation-message.module'; -import { ComponentStatus, StepStatus, TrafficProportion } from '@dotcms/dotcms-models'; +import { ComponentStatus } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { DotSidebarDirective } from '@portlets/shared/directives/dot-sidebar.directive'; import { DotSidebarHeaderComponent } from '@shared/dot-sidebar-header/dot-sidebar-header.component'; -import { DotExperimentsConfigurationStore } from '../../store/dot-experiments-configuration-store'; +import { + ConfigurationTrafficStepViewModel, + DotExperimentsConfigurationStore +} from '../../store/dot-experiments-configuration-store'; @Component({ selector: 'dot-experiments-configuration-traffic-allocation-add', @@ -52,12 +55,8 @@ export class DotExperimentsConfigurationTrafficAllocationAddComponent implements trafficAllocation: string; stepStatus = ComponentStatus; - vm$: Observable<{ - experimentId: string; - trafficProportion: TrafficProportion; - trafficAllocation: number; - status: StepStatus; - }> = this.dotExperimentsConfigurationStore.trafficStepVm$; + vm$: Observable = + this.dotExperimentsConfigurationStore.trafficStepVm$; constructor( private readonly dotExperimentsConfigurationStore: DotExperimentsConfigurationStore diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.ts index e63168420169..e97d67650456 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.ts @@ -23,18 +23,15 @@ import { SidebarModule } from 'primeng/sidebar'; import { take } from 'rxjs/operators'; import { DotFieldValidationMessageModule } from '@components/_common/dot-field-validation-message/dot-file-validation-message.module'; -import { - ComponentStatus, - StepStatus, - TrafficProportion, - TrafficProportionTypes, - Variant -} from '@dotcms/dotcms-models'; +import { ComponentStatus, TrafficProportionTypes, Variant } from '@dotcms/dotcms-models'; import { DotMessagePipe } from '@dotcms/ui'; import { DotSidebarDirective } from '@portlets/shared/directives/dot-sidebar.directive'; import { DotSidebarHeaderComponent } from '@shared/dot-sidebar-header/dot-sidebar-header.component'; -import { DotExperimentsConfigurationStore } from '../../store/dot-experiments-configuration-store'; +import { + ConfigurationTrafficStepViewModel, + DotExperimentsConfigurationStore +} from '../../store/dot-experiments-configuration-store'; @Component({ selector: 'dot-experiments-configuration-traffic-split-add', @@ -64,12 +61,8 @@ export class DotExperimentsConfigurationTrafficSplitAddComponent implements OnIn splitEvenly = TrafficProportionTypes.SPLIT_EVENLY; customPercentages = TrafficProportionTypes.CUSTOM_PERCENTAGES; - vm$: Observable<{ - experimentId: string; - trafficProportion: TrafficProportion; - trafficAllocation: number; - status: StepStatus; - }> = this.dotExperimentsConfigurationStore.trafficStepVm$; + vm$: Observable = + this.dotExperimentsConfigurationStore.trafficStepVm$; constructor( private readonly dotExperimentsConfigurationStore: DotExperimentsConfigurationStore, diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html index 01594eb0fee5..a8cb9520156c 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html @@ -7,8 +7,7 @@

+ name="check_circle"> {{ 'experiments.configure.traffic.name' | dm }}

@@ -21,18 +20,16 @@

+ tooltipPosition="bottom"> diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.spec.ts index 694d79d3df39..295cf8819bdf 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.spec.ts @@ -113,7 +113,7 @@ describe('DotExperimentsConfigurationTrafficComponent', () => { expect(spectator.query(Tooltip).disabled).toEqual(true); }); - it('should disable button and show tooltip when experiment is nos on draft', () => { + it('should disable button and show tooltip when experiment has an error label', () => { dotExperimentsService.getById.mockReturnValue( of({ ...EXPERIMENT_MOCK, diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.ts index ba73933fa288..807fdff30fca 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.ts @@ -13,13 +13,15 @@ import { ComponentStatus, ExperimentSteps, StepStatus, - TrafficProportion, TrafficProportionTypes } from '@dotcms/dotcms-models'; import { DotIconModule, DotMessagePipe } from '@dotcms/ui'; import { DotDynamicDirective } from '@portlets/shared/directives/dot-dynamic.directive'; -import { DotExperimentsConfigurationStore } from '../../store/dot-experiments-configuration-store'; +import { + ConfigurationTrafficStepViewModel, + DotExperimentsConfigurationStore +} from '../../store/dot-experiments-configuration-store'; import { DotExperimentsConfigurationTrafficAllocationAddComponent } from '../dot-experiments-configuration-traffic-allocation-add/dot-experiments-configuration-traffic-allocation-add.component'; import { DotExperimentsConfigurationTrafficSplitAddComponent } from '../dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component'; @@ -41,15 +43,10 @@ import { DotExperimentsConfigurationTrafficSplitAddComponent } from '../dot-expe changeDetection: ChangeDetectionStrategy.OnPush }) export class DotExperimentsConfigurationTrafficComponent { - vm$: Observable<{ - experimentId: string; - trafficProportion: TrafficProportion; - trafficAllocation: number; - status: StepStatus; - isExperimentADraft: boolean; - }> = this.dotExperimentsConfigurationStore.trafficStepVm$.pipe( - tap(({ status }) => this.handleSidebar(status)) - ); + vm$: Observable = + this.dotExperimentsConfigurationStore.trafficStepVm$.pipe( + tap(({ status }) => this.handleSidebar(status)) + ); splitEvenly = TrafficProportionTypes.SPLIT_EVENLY; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.spec.ts index 03a34c240ad8..41839d313bc6 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.spec.ts @@ -90,7 +90,8 @@ describe('DotExperimentsConfigurationVariantsAddComponent', () => { }, isExperimentADraft: true, canLockPage: true, - pageSate: null + pageSate: null, + disabledTooltipLabel: null }); spectator.detectChanges(); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.html index 22148d2c18cb..1a08e8473220 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.html @@ -31,7 +31,7 @@

{{ variant.name }} @@ -48,13 +48,13 @@

|
+ type="submit"> diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.ts index 45c493150b59..2d61c745e64b 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.ts @@ -23,7 +23,7 @@ import { MAX_INPUT_DESCRIPTIVE_LENGTH, StepStatus } from '@dotcms/dotcms-models'; -import { DotAutofocusDirective, DotMessagePipe } from '@dotcms/ui'; +import { DotAutofocusDirective, DotMessagePipe, DotTrimInputDirective } from '@dotcms/ui'; import { DotDropdownDirective } from '@portlets/shared/directives/dot-dropdown.directive'; import { DotSidebarDirective, @@ -59,7 +59,8 @@ import { DotExperimentsConfigurationStore } from '../../store/dot-experiments-co DropdownModule, DotExperimentsGoalConfigurationReachPageComponent, DotExperimentsGoalConfigurationUrlParameterComponentComponent, - DotExperimentsGoalsComingSoonComponent + DotExperimentsGoalsComingSoonComponent, + DotTrimInputDirective ], templateUrl: './dot-experiments-configuration-goal-select.component.html', styleUrls: ['./dot-experiments-configuration-goal-select.component.scss'], diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.html index e00566d4dcbc..15b60bbd0394 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.html @@ -2,8 +2,7 @@ + dotTitle="{{ 'experiments.configure.variants.add' | dm }}">
+ novalidate>
+ type="text" /> + [field]="form.controls.name">
@@ -49,7 +46,6 @@ form="new-variant-form" label="{{ 'experiments.action.add' | dm }}" pButton - type="submit" - > + type="submit"> diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.html index 618659589ed8..db47a41399e2 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.html @@ -2,8 +2,7 @@ + dotTitle="{{ 'experiments.create.form.sidebar.header' | dm }}">
+ novalidate>
@@ -48,12 +45,10 @@ name="description" pInputTextarea placeholder="{{ 'experiments.create.form.description.placeholder' | dm }}" - rows="6" - > + rows="6"> + [field]="form.controls.description">
@@ -68,7 +63,6 @@ form="new-experiment-form" label="{{ 'experiments.action.add' | dm }}" pButton - type="submit" - > + type="submit"> diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts index 97d682acfbaf..a9e99cf07f54 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts @@ -11,7 +11,12 @@ import { SidebarModule } from 'primeng/sidebar'; import { DotFieldValidationMessageModule } from '@components/_common/dot-field-validation-message/dot-file-validation-message.module'; import { DotExperiment, MAX_INPUT_TITLE_LENGTH } from '@dotcms/dotcms-models'; -import { DotAutofocusDirective, DotFieldRequiredDirective, DotMessagePipe } from '@dotcms/ui'; +import { + DotAutofocusDirective, + DotFieldRequiredDirective, + DotMessagePipe, + DotTrimInputDirective +} from '@dotcms/ui'; import { DotSidebarDirective } from '@portlets/shared/directives/dot-sidebar.directive'; import { DotSidebarHeaderComponent } from '@shared/dot-sidebar-header/dot-sidebar-header.component'; import { DotValidators } from '@shared/validators/dotValidators'; @@ -44,7 +49,8 @@ interface CreateForm { InputTextModule, SidebarModule, ButtonModule, - DotFieldRequiredDirective + DotFieldRequiredDirective, + DotTrimInputDirective ], templateUrl: './dot-experiments-create.component.html', styleUrls: ['./dot-experiments-create.component.scss'], diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html index cc3279f3897b..d883d995b734 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html @@ -10,8 +10,7 @@ [class]="inplaceSizes[inputSize].button" data-testId="text-input-button" icon="pi pi-pencil" - pButton - > + pButton>
@@ -31,17 +30,16 @@ (keydown.escape)="deactivateInplace()" data-testId="inplace-input" dotAutofocus + dotTrimInput formControlName="text" - pInputText - /> + pInputText /> + data-testId="variant-inplace-button">

+ type="button"> diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.scss b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.scss index bdb70727b9e5..4c9437e14dff 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.scss +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.scss @@ -17,16 +17,14 @@ } &::ng-deep { - .p-disabled, - .p-component { - width: 100%; - + .p-disabled { &:disabled { opacity: 0.8; } } p-inplace .p-inplace { + width: 100%; .p-inplace-display { padding: 0; white-space: pre-wrap; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.spec.ts index 854853bb829c..80d1c36c45ff 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.spec.ts @@ -21,7 +21,7 @@ const LONG_TEXT = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed condimentum eros sit amet malesuada mattis. Morbi ac congue lectus, ut vestibulum velit. Ut sed ornare metus. Proin a orci lacus. Aenean odio lacus, fringilla eu ipsum non, pellentesque sagittis purus. Integer non.'; const NEW_EXPERIMENT_DESCRIPTION = 'new experiment description'; -describe('DotExperimentsExperimentSummaryComponent', () => { +describe('DotExperimentsInlineEditTextComponent', () => { let spectator: Spectator; const createComponent = createComponentFactory({ component: DotExperimentsInlineEditTextComponent, @@ -176,6 +176,17 @@ describe('DotExperimentsExperimentSummaryComponent', () => { expect(deactivate).toHaveBeenCalled(); }); + it('should deactivate the textControl if isLoading input has `currentValue = true` ', () => { + spectator.dispatchMouseEvent(byTestId('text-input'), 'click'); + + const input = spectator.query(byTestId('inplace-input')) as HTMLInputElement; + + expect(input.disabled).toBe(false); + + spectator.setInput('isLoading', true); + expect(input.disabled).toBe(true); + }); + it('should show `dot-field-validation-message` message error by default', () => { spectator.setInput('text', SHORT_TEXT); spectator.setInput('required', true); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.ts index 0510fa508091..a55eac57f558 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.ts @@ -23,7 +23,7 @@ import { InputTextModule } from 'primeng/inputtext'; import { DotFieldValidationMessageModule } from '@components/_common/dot-field-validation-message/dot-file-validation-message.module'; import { MAX_INPUT_DESCRIPTIVE_LENGTH } from '@dotcms/dotcms-models'; -import { DotAutofocusDirective, DotMessagePipe } from '@dotcms/ui'; +import { DotAutofocusDirective, DotMessagePipe, DotTrimInputDirective } from '@dotcms/ui'; import { DotValidators } from '@shared/validators/dotValidators'; type InplaceInputSize = 'small' | 'large'; @@ -50,7 +50,8 @@ const InplaceInputSizeMapPrimeNg: Record; + +const Template: Story = ( + args: DotExperimentsInlineEditTextComponent +) => ({ + props: { ...args, textChanged: action('textChanged') } +}); + +export const Default = Template.bind({}); diff --git a/core-web/libs/ui/src/index.ts b/core-web/libs/ui/src/index.ts index 603a72dbadc4..af0778d59de8 100644 --- a/core-web/libs/ui/src/index.ts +++ b/core-web/libs/ui/src/index.ts @@ -12,3 +12,4 @@ export * from './lib/components/dot-empty-container/dot-empty-container.componen export * from './lib/dot-tab-buttons/dot-tab-buttons.component'; export * from './lib/dot-remove-confirm-popup/dot-remove-confirm-popup.directive'; export * from './lib/directives/dot-autofocus/dot-autofocus.directive'; +export * from './lib/directives/dot-trim-input/dot-trim-input.directive'; diff --git a/core-web/libs/ui/src/lib/directives/dot-trim-input/dot-trim-input.directive.spec.ts b/core-web/libs/ui/src/lib/directives/dot-trim-input/dot-trim-input.directive.spec.ts new file mode 100644 index 000000000000..0466ac448ce0 --- /dev/null +++ b/core-web/libs/ui/src/lib/directives/dot-trim-input/dot-trim-input.directive.spec.ts @@ -0,0 +1,43 @@ +import { byTestId, createComponentFactory, Spectator } from '@ngneat/spectator'; + +import { Component } from '@angular/core'; +import { FormsModule, NgControl } from '@angular/forms'; + +import { DotTrimInputDirective } from '@dotcms/ui'; + +const STRING_WITH_SPACES = ' Test Value '; + +@Component({ + template: `` +}) +export class DotTrimInputHostMockComponent { + name = STRING_WITH_SPACES; +} + +describe('DotTrimInputDirective', () => { + let spectator: Spectator; + const createComponent = createComponentFactory({ + component: DotTrimInputHostMockComponent, + imports: [FormsModule, DotTrimInputDirective], + providers: [NgControl] + }); + + beforeEach(() => { + spectator = createComponent(); + }); + + it('should trim the input value on blur', async () => { + const input = spectator.query(byTestId('input-to-trim')) as HTMLInputElement; + const expectedValue = STRING_WITH_SPACES.trim(); + + await spectator.fixture.whenStable(); + + expect(spectator.query(byTestId('input-to-trim'))).toExist(); + expect(input.value).toBe(STRING_WITH_SPACES); + + spectator.dispatchFakeEvent(input, 'blur'); + spectator.detectComponentChanges(); + + expect(input.value).toBe(expectedValue); + }); +}); diff --git a/core-web/libs/ui/src/lib/directives/dot-trim-input/dot-trim-input.directive.ts b/core-web/libs/ui/src/lib/directives/dot-trim-input/dot-trim-input.directive.ts new file mode 100644 index 000000000000..18866c6f6ffb --- /dev/null +++ b/core-web/libs/ui/src/lib/directives/dot-trim-input/dot-trim-input.directive.ts @@ -0,0 +1,27 @@ +import { AfterViewInit, Directive, ElementRef, HostListener, Optional, Self } from '@angular/core'; +import { NgControl } from '@angular/forms'; + +/** + * Directive for trimming the input value on blur. + */ +@Directive({ + selector: '[dotTrimInput]', + standalone: true +}) +export class DotTrimInputDirective implements AfterViewInit { + constructor( + @Optional() @Self() private readonly ngControl: NgControl, + private readonly el: ElementRef + ) {} + + @HostListener('blur') + onBlur() { + this.ngControl.control.setValue(this.ngControl.value.trim()); + } + + ngAfterViewInit(): void { + if (this.el.nativeElement.tagName.toLowerCase() !== 'input') { + console.warn('DotTrimInputDirective is for use with Inputs'); + } + } +}