diff --git a/.github/workflows/service-ci-build.yml b/.github/workflows/service-ci-build.yml index ef121541e..247061025 100644 --- a/.github/workflows/service-ci-build.yml +++ b/.github/workflows/service-ci-build.yml @@ -31,6 +31,8 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_PROJECT_KEY : ${{ secrets.SONAR_PROJECT_KEY }} steps: + - name: Remove unused sonar images + run: docker image prune -af - name: Set up JDK 17 uses: actions/setup-java@v3 with: diff --git a/.github/workflows/ui-ci-build.yml b/.github/workflows/ui-ci-build.yml index 5b1db345c..157a8fd7f 100644 --- a/.github/workflows/ui-ci-build.yml +++ b/.github/workflows/ui-ci-build.yml @@ -39,9 +39,6 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} steps: - - name: Setup chrome - uses: browser-actions/setup-chrome@v1 - - name: Execute Tests run: | cd ./marketplace-ui @@ -65,3 +62,12 @@ jobs: env: SONAR_TOKEN: ${{ env.SONAR_TOKEN }} SONAR_HOST_URL: ${{ env.SONAR_HOST_URL }} + + clean-up: + name: Remove unused docker images + needs: analysis + runs-on: self-hosted + + steps: + - name: Remove unused sonar images + run: docker image prune -af diff --git a/marketplace-build/.env b/marketplace-build/.env index 75cd6f6b7..ed05498de 100644 --- a/marketplace-build/.env +++ b/marketplace-build/.env @@ -9,4 +9,5 @@ MARKET_GITHUB_TOKEN= MARKET_GITHUB_OAUTH_APP_CLIENT_ID= MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET= MARKET_JWT_SECRET_KEY= -MARKET_CORS_ALLOWED_ORIGIN=* \ No newline at end of file +MARKET_CORS_ALLOWED_ORIGIN=* +MARKET_MONGO_LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/marketplace-build/config/nginx/dev/nginx.conf b/marketplace-build/config/nginx/dev/nginx.conf new file mode 100644 index 000000000..8eb6752b6 --- /dev/null +++ b/marketplace-build/config/nginx/dev/nginx.conf @@ -0,0 +1,28 @@ +events {} + +http { + include /etc/nginx/mime.types; + + server { + listen 80; + server_name marketplace; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /marketplace-service { + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-NginX-Proxy true; + proxy_pass http://service:8080/marketplace-service; + proxy_ssl_session_reuse off; + proxy_set_header Host $http_host; + proxy_cache_bypass $http_upgrade; + proxy_redirect off; + } + } +} \ No newline at end of file diff --git a/marketplace-build/dev/.env b/marketplace-build/dev/.env index a476ec9f3..1a037765b 100644 --- a/marketplace-build/dev/.env +++ b/marketplace-build/dev/.env @@ -9,4 +9,5 @@ MARKET_GITHUB_TOKEN= MARKET_GITHUB_OAUTH_APP_CLIENT_ID= MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET= MARKET_JWT_SECRET_KEY= -MARKET_CORS_ALLOWED_ORIGIN=* \ No newline at end of file +MARKET_CORS_ALLOWED_ORIGIN=* +MARKET_MONGO_LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/marketplace-build/dev/docker-compose.yml b/marketplace-build/dev/docker-compose.yml index 706eeab07..d7c8d3cd2 100644 --- a/marketplace-build/dev/docker-compose.yml +++ b/marketplace-build/dev/docker-compose.yml @@ -12,7 +12,7 @@ services: - BUILD_ENV=${BUILD_ENV} restart: always volumes: - - ../../marketplace-build/config/nginx/nginx.conf:/etc/nginx/nginx.conf + - ../../marketplace-build/config/nginx/dev/nginx.conf:/etc/nginx/nginx.conf ports: - "4200:80" depends_on: @@ -22,7 +22,7 @@ services: container_name: marketplace-service restart: always volumes: - - /home/axonivy/marketplace/data/market-installations.json:/home/data/market-installation.json + - /home/axonivy/marketplace/data/market-installations.json:/data/market-installation.json environment: - MONGODB_HOST=${SERVICE_MONGODB_HOST} - MONGODB_DATABASE=${SERVICE_MONGODB_DATABASE} @@ -34,6 +34,7 @@ services: - MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET=${MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET} - MARKET_JWT_SECRET_KEY=${MARKET_JWT_SECRET_KEY} - MARKET_CORS_ALLOWED_ORIGIN=${MARKET_CORS_ALLOWED_ORIGIN} + - MARKET_MONGO_LOG_LEVEL=${MARKET_MONGO_LOG_LEVEL} build: context: ../../marketplace-service dockerfile: Dockerfile diff --git a/marketplace-build/docker-compose.yml b/marketplace-build/docker-compose.yml index 230d269b6..d85991b79 100644 --- a/marketplace-build/docker-compose.yml +++ b/marketplace-build/docker-compose.yml @@ -37,7 +37,7 @@ services: container_name: marketplace-service restart: always volumes: - - /home/axonivy/marketplace/data/market-installations.json:/home/data/market-installation.json + - /home/axonivy/marketplace/data/market-installations.json:/data/market-installation.json environment: - MONGODB_HOST=${SERVICE_MONGODB_HOST} - MONGODB_DATABASE=${SERVICE_MONGODB_DATABASE} @@ -49,6 +49,7 @@ services: - MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET=${MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET} - MARKET_JWT_SECRET_KEY=${MARKET_JWT_SECRET_KEY} - MARKET_CORS_ALLOWED_ORIGIN=${MARKET_CORS_ALLOWED_ORIGIN} + - MARKET_MONGO_LOG_LEVEL=${MARKET_MONGO_LOG_LEVEL} build: context: ../marketplace-service dockerfile: Dockerfile diff --git a/marketplace-build/release/.env b/marketplace-build/release/.env index 2b5f160d4..9af06af38 100644 --- a/marketplace-build/release/.env +++ b/marketplace-build/release/.env @@ -10,4 +10,5 @@ MARKET_GITHUB_TOKEN= MARKET_GITHUB_OAUTH_APP_CLIENT_ID= MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET= MARKET_JWT_SECRET_KEY= -MARKET_CORS_ALLOWED_ORIGIN=* \ No newline at end of file +MARKET_CORS_ALLOWED_ORIGIN=* +MARKET_MONGO_LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/marketplace-build/release/docker-compose.yml b/marketplace-build/release/docker-compose.yml index afb6b5825..06f69540e 100644 --- a/marketplace-build/release/docker-compose.yml +++ b/marketplace-build/release/docker-compose.yml @@ -29,7 +29,7 @@ services: expose: - 8080 volumes: - - /home/axonivy/marketplace/data/market-installations.json:/home/data/market-installation.json + - /home/axonivy/marketplace/data/market-installations.json:/data/market-installation.json environment: - MONGODB_HOST=${SERVICE_MONGODB_HOST} - MONGODB_DATABASE=${SERVICE_MONGODB_DATABASE} @@ -41,5 +41,6 @@ services: - MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET=${MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET} - MARKET_JWT_SECRET_KEY=${MARKET_JWT_SECRET_KEY} - MARKET_CORS_ALLOWED_ORIGIN=${MARKET_CORS_ALLOWED_ORIGIN} + - MARKET_MONGO_LOG_LEVEL=${MARKET_MONGO_LOG_LEVEL} volumes: mongodata: \ No newline at end of file diff --git a/marketplace-service/pom.xml b/marketplace-service/pom.xml index 1a19d1657..8bdb6e625 100644 --- a/marketplace-service/pom.xml +++ b/marketplace-service/pom.xml @@ -36,7 +36,6 @@ org.springframework.boot spring-boot-starter-tomcat - provided org.springframework.boot diff --git a/marketplace-service/src/main/java/com/axonivy/market/assembler/ProductDetailModelAssembler.java b/marketplace-service/src/main/java/com/axonivy/market/assembler/ProductDetailModelAssembler.java index 8817c5929..9acbe6fc5 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/assembler/ProductDetailModelAssembler.java +++ b/marketplace-service/src/main/java/com/axonivy/market/assembler/ProductDetailModelAssembler.java @@ -1,25 +1,24 @@ package com.axonivy.market.assembler; -import com.axonivy.market.constants.RequestMappingConstants; -import com.axonivy.market.controller.ProductDetailsController; -import com.axonivy.market.entity.Product; -import com.axonivy.market.model.ProductDetailModel; -import com.axonivy.market.util.VersionUtils; -import lombok.extern.log4j.Log4j2; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +import java.util.Optional; + import org.apache.commons.lang3.StringUtils; import org.springframework.hateoas.Link; import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.util.CollectionUtils; -import java.util.Optional; - -import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; -import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; +import com.axonivy.market.constants.RequestMappingConstants; +import com.axonivy.market.controller.ProductDetailsController; +import com.axonivy.market.entity.Product; +import com.axonivy.market.model.ProductDetailModel; +import com.axonivy.market.util.ImageUtils; +import com.axonivy.market.util.VersionUtils; @Component -@Log4j2 public class ProductDetailModelAssembler extends RepresentationModelAssemblerSupport { private final ProductModelAssembler productModelAssembler; @@ -80,6 +79,7 @@ private void createDetailResource(ProductDetailModel model, Product product) { model.setContactUs(product.getContactUs()); model.setCost(product.getCost()); model.setInstallationCount(product.getInstallationCount()); - model.setProductModuleContent(product.getProductModuleContent()); + model.setProductModuleContent(ImageUtils.mappingImageForProductModuleContent(product.getProductModuleContent())); } + } diff --git a/marketplace-service/src/main/java/com/axonivy/market/assembler/ProductModelAssembler.java b/marketplace-service/src/main/java/com/axonivy/market/assembler/ProductModelAssembler.java index a50d82d1a..9e51bb92a 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/assembler/ProductModelAssembler.java +++ b/marketplace-service/src/main/java/com/axonivy/market/assembler/ProductModelAssembler.java @@ -1,8 +1,10 @@ package com.axonivy.market.assembler; +import com.axonivy.market.controller.ImageController; import com.axonivy.market.controller.ProductDetailsController; import com.axonivy.market.entity.Product; import com.axonivy.market.model.ProductModel; +import org.springframework.hateoas.Link; import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport; import org.springframework.stereotype.Component; @@ -29,7 +31,9 @@ public ProductModel createResource(ProductModel model, Product product) { model.setShortDescriptions(product.getShortDescriptions()); model.setType(product.getType()); model.setTags(product.getTags()); - model.setLogoUrl(product.getLogoUrl()); + + Link logoLink = linkTo(methodOn(ImageController.class).findImageById(product.getLogoId())).withSelfRel(); + model.setLogoUrl(logoLink.getHref()); return model; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/comparator/MavenVersionComparator.java b/marketplace-service/src/main/java/com/axonivy/market/comparator/MavenVersionComparator.java new file mode 100644 index 000000000..d7df9ed85 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/comparator/MavenVersionComparator.java @@ -0,0 +1,122 @@ +package com.axonivy.market.comparator; + +import com.axonivy.market.constants.CommonConstants; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; +import org.kohsuke.github.GHTag; +import org.springframework.util.CollectionUtils; + +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.axonivy.market.constants.CommonConstants.DASH_SEPARATOR; +import static com.axonivy.market.constants.MavenConstants.SNAPSHOT_VERSION; +import static org.apache.commons.lang3.StringUtils.EMPTY; + +public class MavenVersionComparator { + + private static final String MAIN_VERSION_REGEX = "\\."; + private static final int GREATER_THAN = 1; + private static final int EQUAL = 0; + private static final int LESS_THAN = -1; + + private MavenVersionComparator() { + } + + public static GHTag findHighestTag(List ghTags) { + if (CollectionUtils.isEmpty(ghTags)) { + return null; + } + String highestVersion = findHighestMavenVersion(ghTags.stream().map(GHTag::getName).toList()); + return ghTags.stream().filter(tag -> tag.getName().equals(highestVersion)).findAny().orElse(null); + } + + public static String findHighestMavenVersion(List versions) { + if (CollectionUtils.isEmpty(versions)) { + return null; + } + + String highestVersion = versions.get(0); + for (var version : versions) { + if (compare(version, highestVersion) > EQUAL) { + highestVersion = version; + } + } + return highestVersion; + } + + private static int compare(String version, String otherVersion) { + version = stripLeadingChars(version); + otherVersion = stripLeadingChars(otherVersion); + String[] versionParts = createMainAndQualifierArray(version); + String[] otherVersionParts = createMainAndQualifierArray(otherVersion); + + // Compare main version parts + int mainComparison = compareMainVersion(versionParts[0], otherVersionParts[0]); + if (mainComparison != EQUAL) { + return mainComparison; + } + + // Compare qualifiers + String qualifier1 = getQualifierPart(versionParts); + String qualifier2 = getQualifierPart(otherVersionParts); + // Consider versions without a qualifier higher than those with qualifiers + if (qualifier1.isEmpty() && !qualifier2.isEmpty()) { + return GREATER_THAN; + } + if (!qualifier1.isEmpty() && qualifier2.isEmpty()) { + return LESS_THAN; + } + return compareQualifier(qualifier1, qualifier2); + } + + private static String stripLeadingChars(String version) { + Pattern pattern = Pattern.compile(CommonConstants.DIGIT_REGEX); + Matcher matcher = pattern.matcher(version); + if (matcher.find()) { + return matcher.group(1); + } + return version; + } + + private static int compareMainVersion(String mainVersion, String otherMainVersion) { + String[] parts1 = mainVersion.split(MAIN_VERSION_REGEX); + String[] parts2 = otherMainVersion.split(MAIN_VERSION_REGEX); + + int length = Math.max(parts1.length, parts2.length); + for (int i = 0; i < length; i++) { + int num1 = parseToNumber(parts1, i); + int num2 = parseToNumber(parts2, i); + if (num1 != num2) { + return num1 - num2; + } + } + return EQUAL; + } + + private static String getQualifierPart(String[] versionParts) { + return versionParts.length > 1 ? versionParts[1] : EMPTY; + } + + private static String[] createMainAndQualifierArray(String version) { + return StringUtils.defaultIfBlank(version, EMPTY).split(DASH_SEPARATOR, 2); + } + + private static int parseToNumber(String[] versionParts, int index) { + if (index < versionParts.length && NumberUtils.isDigits(versionParts[index])) { + return NumberUtils.toInt(versionParts[index]); + } + return 0; + } + + private static int compareQualifier(String qualifier1, String qualifier2) { + if (SNAPSHOT_VERSION.equals(qualifier1) && !SNAPSHOT_VERSION.equals(qualifier2)) { + return LESS_THAN; + } + if (!SNAPSHOT_VERSION.equals(qualifier1) && SNAPSHOT_VERSION.equals(qualifier2)) { + return GREATER_THAN; + } + return qualifier1.compareTo(qualifier2); + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/config/AsyncConfig.java b/marketplace-service/src/main/java/com/axonivy/market/config/AsyncConfig.java new file mode 100644 index 000000000..43dcfcdd0 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package com.axonivy.market.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +public class AsyncConfig implements AsyncConfigurer { + + private static final String THREAD_NAME_PREFIX = "AC-Thread-"; + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(25); + executor.setThreadNamePrefix(THREAD_NAME_PREFIX); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/marketplace-service/src/main/java/com/axonivy/market/config/MarketApiDocumentConfig.java b/marketplace-service/src/main/java/com/axonivy/market/config/MarketApiDocumentConfig.java index 0fb1dc852..67e0dfbed 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/config/MarketApiDocumentConfig.java +++ b/marketplace-service/src/main/java/com/axonivy/market/config/MarketApiDocumentConfig.java @@ -9,6 +9,9 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.Arrays; +import java.util.List; + import static com.axonivy.market.constants.CommonConstants.REQUESTED_BY; @Configuration @@ -20,16 +23,20 @@ public class MarketApiDocumentConfig { @Bean public GroupedOpenApi buildMarketCustomHeader() { - return GroupedOpenApi.builder().group(DEFAULT_DOC_GROUP).addOpenApiCustomizer(customMarketHeaders()) - .pathsToMatch(PATH_PATTERN).build(); + return GroupedOpenApi.builder().group(DEFAULT_DOC_GROUP) + .addOpenApiCustomizer(customMarketHeaders()) + .pathsToMatch(PATH_PATTERN).build(); } private OpenApiCustomizer customMarketHeaders() { return openApi -> openApi.getPaths().values().forEach((PathItem pathItem) -> { - for (Operation operation : pathItem.readOperations()) { - Parameter headerParameter = new Parameter().in(HEADER_PARAM).schema(new StringSchema()).name(REQUESTED_BY) - .description(DEFAULT_PARAM).required(true); - operation.addParametersItem(headerParameter); + List operations = Arrays.asList(pathItem.getPut(), pathItem.getPost(), pathItem.getPatch(), pathItem.getDelete()); + for (Operation operation : operations) { + if (operation != null) { + Parameter headerParameter = new Parameter().in(HEADER_PARAM).schema(new StringSchema()) + .name(REQUESTED_BY).description(DEFAULT_PARAM).required(true); + operation.addParametersItem(headerParameter); + } } }); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/config/MarketHeaderInterceptor.java b/marketplace-service/src/main/java/com/axonivy/market/config/MarketHeaderInterceptor.java index 83b281062..f47d860da 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/config/MarketHeaderInterceptor.java +++ b/marketplace-service/src/main/java/com/axonivy/market/config/MarketHeaderInterceptor.java @@ -5,22 +5,21 @@ import io.swagger.v3.oas.models.PathItem.HttpMethod; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import org.springframework.beans.factory.annotation.Value; +import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; @Component public class MarketHeaderInterceptor implements HandlerInterceptor { - @Value("${request.header}") - private String requestHeader; - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { if (HttpMethod.OPTIONS.name().equalsIgnoreCase(request.getMethod())) { return true; } - if (!requestHeader.equals(request.getHeader(CommonConstants.REQUESTED_BY))) { + if (!HttpMethod.GET.name().equalsIgnoreCase(request.getMethod()) + && StringUtils.isBlank(request.getHeader(CommonConstants.REQUESTED_BY))) { throw new MissingHeaderException(); } return true; diff --git a/marketplace-service/src/main/java/com/axonivy/market/config/SchedulingConfig.java b/marketplace-service/src/main/java/com/axonivy/market/config/SchedulingConfig.java new file mode 100644 index 000000000..275717f54 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/config/SchedulingConfig.java @@ -0,0 +1,20 @@ +package com.axonivy.market.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +public class SchedulingConfig { + + private static final String THREAD_NAME_PREFIX = "SC-Thread-"; + + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + taskScheduler.setPoolSize(10); + taskScheduler.setThreadNamePrefix(THREAD_NAME_PREFIX); + taskScheduler.initialize(); + return taskScheduler; + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/config/WebConfig.java b/marketplace-service/src/main/java/com/axonivy/market/config/WebConfig.java index 38d1e80f9..d50fe342c 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/config/WebConfig.java +++ b/marketplace-service/src/main/java/com/axonivy/market/config/WebConfig.java @@ -10,8 +10,8 @@ public class WebConfig implements WebMvcConfigurer { private static final String ALL_MAPPINGS = "/**"; - private static final String[] EXCLUDE_PATHS = { "/", "/swagger-ui/**", "/api-docs/**", - "/api/product-details/productjsoncontent/**" , }; + private static final String[] EXCLUDE_PATHS = { "/", "/swagger-ui/**", "/api-docs/**", "/api/product-details/**/json", + "/api/image/**" }; private static final String[] ALLOWED_HEADERS = { "Accept-Language", "Content-Type", "Authorization", "X-Requested-By", "x-requested-with", "X-Forwarded-Host", "x-xsrf-token", "x-authorization" }; private static final String[] ALLOWED_METHODS = { "GET", "POST", "PUT", "DELETE", "OPTIONS" }; diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java index 07d963b75..68a5f236a 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/CommonConstants.java @@ -6,11 +6,15 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class CommonConstants { public static final String REQUESTED_BY = "X-Requested-By"; - public static final String LOGO_FILE = "logo.png"; public static final String SLASH = "/"; public static final String DOT_SEPARATOR = "."; public static final String PLUS = "+"; public static final String DASH_SEPARATOR = "-"; public static final String SPACE_SEPARATOR = " "; public static final String BEARER = "Bearer"; + public static final String DIGIT_REGEX = "([0-9]+.*)"; + public static final String IMAGE_ID_PREFIX = "imageId-"; + public static final String ID_WITH_NUMBER_PATTERN = "%s-%s"; + public static final String ERROR = "error"; + public static final String MESSAGE = "message"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/EntityConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/EntityConstants.java index fc813d23a..2d0fee374 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/EntityConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/EntityConstants.java @@ -7,10 +7,12 @@ public class EntityConstants { public static final String USER = "User"; public static final String PRODUCT = "Product"; + public static final String PRODUCT_DESIGNER_INSTALLATION = "ProductDesignerInstallation"; public static final String MAVEN_ARTIFACT_VERSION = "MavenArtifactVersion"; public static final String GH_REPO_META = "GitHubRepoMeta"; public static final String FEEDBACK = "Feedback"; public static final String PRODUCT_CUSTOM_SORT = "ProductCustomSort"; public static final String PRODUCT_JSON_CONTENT = "ProductJsonContent"; public static final String PRODUCT_MODULE_CONTENT = "ProductModuleContent"; + public static final String IMAGE = "Image"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/ErrorMessageConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/ErrorMessageConstants.java index 4ad9f831a..431d7311e 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/ErrorMessageConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/ErrorMessageConstants.java @@ -6,4 +6,5 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ErrorMessageConstants { public static final String INVALID_MISSING_HEADER_ERROR_MESSAGE = "Invalid or missing header"; + public static final String CURRENT_CLIENT_ID_MISMATCH_MESSAGE = " Client ID mismatch (Request ID: %s, Server ID: %s)"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/MavenConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/MavenConstants.java index 71cdb72ee..67ec50e5a 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/MavenConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/MavenConstants.java @@ -4,7 +4,8 @@ public class MavenConstants { private MavenConstants() { } - public static final String SNAPSHOT_RELEASE_POSTFIX = "-SNAPSHOT"; + public static final String SNAPSHOT_VERSION = "SNAPSHOT"; + public static final String SNAPSHOT_RELEASE_POSTFIX = "-" + SNAPSHOT_VERSION; public static final String SPRINT_RELEASE_POSTFIX = "-m"; public static final String PRODUCT_ARTIFACT_POSTFIX = "-product"; public static final String METADATA_URL_FORMAT = "%s/%s/%s/maven-metadata.xml"; diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/MongoDBConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/MongoDBConstants.java index df9c3c1e0..917709dd9 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/MongoDBConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/MongoDBConstants.java @@ -6,15 +6,11 @@ private MongoDBConstants() { } public static final String ID ="_id"; - public static final String PRODUCT_MODULE_CONTENT ="productModuleContent"; - public static final String PRODUCT_MODULE_CONTENT_QUERY ="$productModuleContents"; - public static final String INPUT ="input"; - public static final String AS ="as"; - public static final String CONDITION ="cond"; - public static final String EQUAL ="$eq"; - public static final String PRODUCT_MODULE_CONTENT_TAG ="$$productModuleContent.tag"; public static final String PRODUCT_COLLECTION ="Product"; public static final String INSTALLATION_COUNT = "InstallationCount"; public static final String SYNCHRONIZED_INSTALLATION_COUNT ="SynchronizedInstallationCount"; + public static final String PRODUCT_ID = "productId"; + public static final String DESIGNER_VERSION = "designerVersion"; + public static final String TAG = "tag"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/ProductJsonConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/ProductJsonConstants.java index c802ab60c..fd63733dd 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/ProductJsonConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/ProductJsonConstants.java @@ -2,6 +2,7 @@ public class ProductJsonConstants { public static final String PRODUCT_JSON_FILE = "product.json"; + public static final String LOGO_FILE = "logo.png"; public static final String DATA = "data"; public static final String REPOSITORIES = "repositories"; public static final String URL = "url"; diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java index 6890a6424..8d3124988 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestMappingConstants.java @@ -10,6 +10,7 @@ public class RequestMappingConstants { public static final String SYNC = "sync"; public static final String PRODUCT = API + "/product"; public static final String PRODUCT_DETAILS = API + "/product-details"; + public static final String PRODUCT_DESIGNER_INSTALLATION = API + "/product-designer-installation"; public static final String FEEDBACK = API + "/feedback"; public static final String SWAGGER_URL = "/swagger-ui/index.html"; public static final String GIT_HUB_LOGIN = "/github/login"; @@ -21,7 +22,10 @@ public class RequestMappingConstants { public static final String PRODUCT_BY_ID = "/product/{id}"; public static final String PRODUCT_RATING_BY_ID = "/product/{id}/rating"; public static final String INSTALLATION_COUNT_BY_ID = "/installationcount/{id}"; - public static final String PRODUCT_JSON_CONTENT_BY_PRODUCT_ID_AND_VERSION = "/productjsoncontent/{productId}/{version}"; + public static final String PRODUCT_JSON_CONTENT_BY_PRODUCT_ID_AND_VERSION = "/{id}/{version}/json"; public static final String VERSIONS_IN_DESIGNER = "/{id}/designerversions"; + public static final String DESIGNER_INSTALLATION_BY_PRODUCT_ID_AND_DESIGNER_VERSION = "/installation/{productId}/designer/{designerVersion}"; + public static final String DESIGNER_INSTALLATION_BY_ID = "/installation/{id}/designer"; public static final String CUSTOM_SORT = "custom-sort"; + public static final String IMAGE = API + "/image"; } \ No newline at end of file diff --git a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestParamConstants.java b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestParamConstants.java index 5a5097edb..b1fff6169 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/constants/RequestParamConstants.java +++ b/marketplace-service/src/main/java/com/axonivy/market/constants/RequestParamConstants.java @@ -6,7 +6,6 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class RequestParamConstants { public static final String ID = "id"; - public static final String TAG = "tag"; public static final String TYPE = "type"; public static final String KEYWORD = "keyword"; public static final String LANGUAGE = "language"; @@ -17,5 +16,4 @@ public class RequestParamConstants { public static final String SHOW_DEV_VERSION = "isShowDevVersion"; public static final String DESIGNER_VERSION = "designerVersion"; public static final String VERSION = "version"; - public static final String PRODUCT_ID = "productId"; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/ImageController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/ImageController.java new file mode 100644 index 000000000..5264d7e40 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/ImageController.java @@ -0,0 +1,54 @@ +package com.axonivy.market.controller; + +import com.axonivy.market.entity.Image; +import com.axonivy.market.service.ImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.axonivy.market.constants.RequestMappingConstants.BY_ID; +import static com.axonivy.market.constants.RequestMappingConstants.IMAGE; +import static com.axonivy.market.constants.RequestParamConstants.ID; + +@RestController +@RequestMapping(IMAGE) +@Tag(name = "Image Controllers", description = "API collection to get image's detail.") +public class ImageController { + private final ImageService imageService; + + public ImageController(ImageService imageService) { + this.imageService = imageService; + } + + @GetMapping(BY_ID) + @Operation(summary = "Get the image content by id", description = "Collect the byte[] of image with contentType in header is PNG") + @ApiResponse(responseCode = "200", description = "Image found and returned", content = @Content(mediaType = MediaType.IMAGE_PNG_VALUE, schema = @Schema(implementation = Image.class))) + @ApiResponse(responseCode = "404", description = "Image not found") + @ApiResponse(responseCode = "204", description = "No content (image empty)") + public ResponseEntity findImageById( + @PathVariable(ID) @Parameter(description = "The image id", example = "66e7efc8a24f36158df06fc7", in = ParameterIn.PATH) String id) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.IMAGE_PNG); + byte[] imageData = imageService.readImage(id); + if (imageData == null) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + + if (imageData.length == 0) { + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + return new ResponseEntity<>(imageData, headers, HttpStatus.OK); + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/OAuth2Controller.java b/marketplace-service/src/main/java/com/axonivy/market/controller/OAuth2Controller.java index 928f2e203..afc4aba6d 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/controller/OAuth2Controller.java +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/OAuth2Controller.java @@ -4,8 +4,11 @@ import static com.axonivy.market.constants.RequestMappingConstants.GIT_HUB_LOGIN; import java.util.Collections; +import java.util.HashMap; import java.util.Map; +import com.axonivy.market.constants.CommonConstants; +import com.axonivy.market.exceptions.model.Oauth2ExchangeCodeException; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; @@ -54,12 +57,16 @@ public ResponseEntity> gitHubLogin(@RequestBody Oauth2Author try { GitHubAccessTokenResponse tokenResponse = gitHubService.getAccessToken(oauth2AuthorizationCode.getCode(), gitHubProperty); accessToken = tokenResponse.getAccessToken(); + User user = gitHubService.getAndUpdateUser(accessToken); + String jwtToken = jwtService.generateToken(user); + return new ResponseEntity<>(Collections.singletonMap(GitHubConstants.Json.TOKEN, jwtToken), HttpStatus.OK); + } catch (Oauth2ExchangeCodeException e) { + Map errorResponse = new HashMap<>(); + errorResponse.put(CommonConstants.ERROR, e.getError()); + errorResponse.put(CommonConstants.MESSAGE, e.getErrorDescription()); + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); } catch (Exception e) { - return new ResponseEntity<>(Map.of(e.getClass().getName(), e.getMessage()), HttpStatus.BAD_REQUEST); + return new ResponseEntity<>(Map.of(CommonConstants.MESSAGE, e.getMessage()), HttpStatus.BAD_REQUEST); } - - User user = gitHubService.getAndUpdateUser(accessToken); - String jwtToken = jwtService.generateToken(user); - return new ResponseEntity<>(Collections.singletonMap(GitHubConstants.Json.TOKEN, jwtToken), HttpStatus.OK); } } \ No newline at end of file diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductDesignerInstallationController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductDesignerInstallationController.java new file mode 100644 index 000000000..301cb7439 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductDesignerInstallationController.java @@ -0,0 +1,38 @@ +package com.axonivy.market.controller; + +import com.axonivy.market.model.DesignerInstallation; +import com.axonivy.market.service.ProductDesignerInstallationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static com.axonivy.market.constants.RequestMappingConstants.DESIGNER_INSTALLATION_BY_ID; +import static com.axonivy.market.constants.RequestMappingConstants.PRODUCT_DESIGNER_INSTALLATION; +import static com.axonivy.market.constants.RequestParamConstants.ID; + +@RestController +@RequestMapping(PRODUCT_DESIGNER_INSTALLATION) +@Tag(name = "Product Designer Installation Controllers", description = "API collection to get designer installation count.") +public class ProductDesignerInstallationController { + private final ProductDesignerInstallationService productDesignerInstallationService; + + public ProductDesignerInstallationController(ProductDesignerInstallationService productDesignerInstallationService) { + this.productDesignerInstallationService = productDesignerInstallationService; + } + + @GetMapping(DESIGNER_INSTALLATION_BY_ID) + @Operation(summary = "Get designer installation count by product id.", description = "get designer installation count by product id") + public ResponseEntity> getProductDesignerInstallationByProductId(@PathVariable(ID) @Parameter(description = "Product id (from meta.json)", example = "adobe-acrobat-connector", in = ParameterIn.PATH) String productId) { + List models = productDesignerInstallationService.findByProductId(productId); + return new ResponseEntity<>(models, HttpStatus.OK); + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductDetailsController.java b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductDetailsController.java index 7c4efc906..5b68520bd 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/controller/ProductDetailsController.java +++ b/marketplace-service/src/main/java/com/axonivy/market/controller/ProductDetailsController.java @@ -1,18 +1,17 @@ package com.axonivy.market.controller; -import static com.axonivy.market.constants.RequestMappingConstants.PRODUCT_JSON_CONTENT_BY_PRODUCT_ID_AND_VERSION; -import static com.axonivy.market.constants.RequestMappingConstants.VERSIONS_IN_DESIGNER; import static com.axonivy.market.constants.RequestParamConstants.DESIGNER_VERSION; import static com.axonivy.market.constants.RequestParamConstants.ID; -import static com.axonivy.market.constants.RequestParamConstants.PRODUCT_ID; import static com.axonivy.market.constants.RequestParamConstants.SHOW_DEV_VERSION; import static com.axonivy.market.constants.RequestParamConstants.VERSION; +import static com.axonivy.market.constants.RequestMappingConstants.BEST_MATCH_BY_ID_AND_VERSION; import static com.axonivy.market.constants.RequestMappingConstants.BY_ID; import static com.axonivy.market.constants.RequestMappingConstants.BY_ID_AND_VERSION; import static com.axonivy.market.constants.RequestMappingConstants.INSTALLATION_COUNT_BY_ID; import static com.axonivy.market.constants.RequestMappingConstants.PRODUCT_DETAILS; +import static com.axonivy.market.constants.RequestMappingConstants.PRODUCT_JSON_CONTENT_BY_PRODUCT_ID_AND_VERSION; import static com.axonivy.market.constants.RequestMappingConstants.VERSIONS_BY_ID; -import static com.axonivy.market.constants.RequestMappingConstants.BEST_MATCH_BY_ID_AND_VERSION; +import static com.axonivy.market.constants.RequestMappingConstants.VERSIONS_IN_DESIGNER; import java.util.List; import java.util.Map; @@ -22,7 +21,6 @@ import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; @@ -46,7 +44,8 @@ public class ProductDetailsController { private final ProductService productService; private final ProductDetailModelAssembler detailModelAssembler; - public ProductDetailsController(VersionService versionService, ProductService productService, ProductDetailModelAssembler detailModelAssembler) { + public ProductDetailsController(VersionService versionService, ProductService productService, + ProductDetailModelAssembler detailModelAssembler) { this.versionService = versionService; this.productService = productService; this.detailModelAssembler = detailModelAssembler; @@ -70,12 +69,12 @@ public ResponseEntity findBestMatchProductDetailsByVersion( return new ResponseEntity<>(detailModelAssembler.toModel(productDetail, version, BEST_MATCH_BY_ID_AND_VERSION), HttpStatus.OK); } - @CrossOrigin(originPatterns = "*") @PutMapping(INSTALLATION_COUNT_BY_ID) @Operation(summary = "Update installation count of product", description = "By default, increase installation count when click download product files by users") public ResponseEntity syncInstallationCount( - @PathVariable(ID) @Parameter(description = "Product id (from meta.json)", example = "approval-decision-utils", in = ParameterIn.PATH) String productId) { - int result = productService.updateInstallationCountForProduct(productId); + @PathVariable(ID) @Parameter(description = "Product id (from meta.json)", example = "approval-decision-utils", in = ParameterIn.PATH) String productId, + @RequestParam(name = DESIGNER_VERSION, required = false) @Parameter(in = ParameterIn.QUERY, example = "v10.0.20") String designerVersion) { + int result = productService.updateInstallationCountForProduct(productId, designerVersion); return new ResponseEntity<>(result, HttpStatus.OK); } @@ -99,7 +98,7 @@ public ResponseEntity> findProductVersionsById( @GetMapping(PRODUCT_JSON_CONTENT_BY_PRODUCT_ID_AND_VERSION) @Operation(summary = "Get product json content for designer to install", description = "When we click install in designer, this API will send content of product json for installing in Ivy designer") - public ResponseEntity> findProductJsonContent(@PathVariable(PRODUCT_ID) String productId, + public ResponseEntity> findProductJsonContent(@PathVariable(ID) String productId, @PathVariable(VERSION) String version) { Map productJsonContent = versionService.getProductJsonContentByIdAndVersion(productId, version); return new ResponseEntity<>(productJsonContent, HttpStatus.OK); @@ -111,5 +110,4 @@ public ResponseEntity> findVersionsForDesigner(@PathVar List versionList = versionService.getVersionsForDesigner(id); return new ResponseEntity<>(versionList, HttpStatus.OK); } - } diff --git a/marketplace-service/src/main/java/com/axonivy/market/entity/GitHubRepoMeta.java b/marketplace-service/src/main/java/com/axonivy/market/entity/GitHubRepoMeta.java index d2ef46fbf..2f07cdfbb 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/entity/GitHubRepoMeta.java +++ b/marketplace-service/src/main/java/com/axonivy/market/entity/GitHubRepoMeta.java @@ -3,8 +3,11 @@ import lombok.Getter; import lombok.Setter; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.mongodb.core.mapping.Document; +import java.util.Date; + import static com.axonivy.market.constants.EntityConstants.GH_REPO_META; @Getter @@ -16,4 +19,6 @@ public class GitHubRepoMeta { private String repoName; private Long lastChange; private String lastSHA1; + @LastModifiedDate + private Date updatedAt; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/entity/Image.java b/marketplace-service/src/main/java/com/axonivy/market/entity/Image.java new file mode 100644 index 000000000..85e6842ef --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/entity/Image.java @@ -0,0 +1,32 @@ +package com.axonivy.market.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.bson.types.Binary; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import static com.axonivy.market.constants.EntityConstants.IMAGE; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Document(IMAGE) +public class Image { + @Id + private String id; + @Schema(description = "Product id", example = "jira-connector") + private String productId; + @Schema(description = "The download url from github", example = "https://raw.githubusercontent.comamazon-comprehend/logo.png") + private String imageUrl; + @Schema(description = "The image content as binary type", example = "Binary(Buffer.from(\"89504e470d0a1a0a0000000d\", \"hex\"), 0)") + private Binary imageData; + @Schema(description = "The SHA from github", example = "93b1e2f1595d3a85e51b01") + private String sha; +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/entity/Product.java b/marketplace-service/src/main/java/com/axonivy/market/entity/Product.java index e11d7fba8..00db4d4e4 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/entity/Product.java +++ b/marketplace-service/src/main/java/com/axonivy/market/entity/Product.java @@ -1,5 +1,6 @@ package com.axonivy.market.entity; +import static com.axonivy.market.constants.EntityConstants.PRODUCT; import com.axonivy.market.github.model.MavenArtifact; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.AllArgsConstructor; @@ -10,8 +11,8 @@ import org.apache.commons.lang3.builder.EqualsBuilder; import org.apache.commons.lang3.builder.HashCodeBuilder; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.annotation.Transient; -import org.springframework.data.mongodb.core.mapping.DBRef; import org.springframework.data.mongodb.core.mapping.Document; import java.io.Serial; @@ -20,8 +21,6 @@ import java.util.List; import java.util.Map; -import static com.axonivy.market.constants.EntityConstants.PRODUCT; - @Getter @Setter @AllArgsConstructor @@ -66,6 +65,9 @@ public class Product implements Serializable { private List releasedVersions; @Transient private String metaProductJsonUrl; + private String logoId; + @LastModifiedDate + private Date updatedAt; @Override public int hashCode() { diff --git a/marketplace-service/src/main/java/com/axonivy/market/entity/ProductDesignerInstallation.java b/marketplace-service/src/main/java/com/axonivy/market/entity/ProductDesignerInstallation.java new file mode 100644 index 000000000..127a92ead --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/entity/ProductDesignerInstallation.java @@ -0,0 +1,41 @@ +package com.axonivy.market.entity; + +import lombok.*; +import org.apache.commons.lang3.builder.EqualsBuilder; +import org.apache.commons.lang3.builder.HashCodeBuilder; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.io.Serial; +import java.io.Serializable; + +import static com.axonivy.market.constants.EntityConstants.PRODUCT_DESIGNER_INSTALLATION; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +@Builder +@Document(PRODUCT_DESIGNER_INSTALLATION) +public class ProductDesignerInstallation implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + @Id + private String id; + private String productId; + private String designerVersion; + private int installationCount; + + @Override + public int hashCode() { + return new HashCodeBuilder().append(productId).hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == null || this.getClass() != obj.getClass()) { + return false; + } + return new EqualsBuilder().append(productId, ((ProductDesignerInstallation) obj).getProductId()).isEquals(); + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/entity/ProductJsonContent.java b/marketplace-service/src/main/java/com/axonivy/market/entity/ProductJsonContent.java index 0d8b2b79c..a6a48fb6f 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/entity/ProductJsonContent.java +++ b/marketplace-service/src/main/java/com/axonivy/market/entity/ProductJsonContent.java @@ -7,8 +7,11 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.mongodb.core.mapping.Document; +import java.util.Date; + import static com.axonivy.market.constants.EntityConstants.PRODUCT_JSON_CONTENT; @Getter @@ -25,4 +28,6 @@ public class ProductJsonContent { private String productId; private String name; private String content; -} + @LastModifiedDate + private Date updatedAt; +} \ No newline at end of file diff --git a/marketplace-service/src/main/java/com/axonivy/market/entity/ProductModuleContent.java b/marketplace-service/src/main/java/com/axonivy/market/entity/ProductModuleContent.java index 4001571d1..08762f4ed 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/entity/ProductModuleContent.java +++ b/marketplace-service/src/main/java/com/axonivy/market/entity/ProductModuleContent.java @@ -7,10 +7,12 @@ import lombok.NoArgsConstructor; import lombok.Setter; import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.mongodb.core.mapping.Document; import java.io.Serial; import java.io.Serializable; +import java.util.Date; import java.util.Map; import static com.axonivy.market.constants.EntityConstants.PRODUCT_MODULE_CONTENT; @@ -46,4 +48,6 @@ public class ProductModuleContent implements Serializable { private String artifactId; @Schema(description = "Artifact file type", example = "iar") private String type; + @LastModifiedDate + private Date updatedAt; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/enums/ErrorCode.java b/marketplace-service/src/main/java/com/axonivy/market/enums/ErrorCode.java index c12d7ed44..fa939c58c 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/enums/ErrorCode.java +++ b/marketplace-service/src/main/java/com/axonivy/market/enums/ErrorCode.java @@ -18,7 +18,8 @@ public enum ErrorCode { GH_FILE_STATUS_INVALID("0201", "GIT_HUB_FILE_STATUS_INVALID"), GH_FILE_TYPE_INVALID("0202", "GIT_HUB_FILE_TYPE_INVALID"), USER_NOT_FOUND("2103", "USER_NOT_FOUND"), GITHUB_USER_NOT_FOUND("2204", "GITHUB_USER_NOT_FOUND"), GITHUB_USER_UNAUTHORIZED("2205", "GITHUB_USER_UNAUTHORIZED"), - FEEDBACK_NOT_FOUND("3103", "FEEDBACK_NOT_FOUND"), ARGUMENT_BAD_REQUEST("4000", "ARGUMENT_BAD_REQUEST"); + FEEDBACK_NOT_FOUND("3103", "FEEDBACK_NOT_FOUND"), NO_FEEDBACK_OF_USER_FOR_PRODUCT("3103", "NO_FEEDBACK_OF_USER_FOR_PRODUCT"), + ARGUMENT_BAD_REQUEST("4000", "ARGUMENT_BAD_REQUEST"); String code; String helpText; diff --git a/marketplace-service/src/main/java/com/axonivy/market/exceptions/ExceptionHandlers.java b/marketplace-service/src/main/java/com/axonivy/market/exceptions/ExceptionHandlers.java index 3ec95b44d..24738b39a 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/exceptions/ExceptionHandlers.java +++ b/marketplace-service/src/main/java/com/axonivy/market/exceptions/ExceptionHandlers.java @@ -3,6 +3,7 @@ import com.axonivy.market.enums.ErrorCode; import com.axonivy.market.exceptions.model.InvalidParamException; import com.axonivy.market.exceptions.model.MissingHeaderException; +import com.axonivy.market.exceptions.model.NoContentException; import com.axonivy.market.exceptions.model.NotFoundException; import com.axonivy.market.exceptions.model.Oauth2ExchangeCodeException; import com.axonivy.market.exceptions.model.UnauthorizedException; @@ -59,6 +60,14 @@ public ResponseEntity handleNotFoundException(NotFoundException notFound return new ResponseEntity<>(errorMessage, HttpStatus.NOT_FOUND); } + @ExceptionHandler(NoContentException.class) + public ResponseEntity handleNoContentException(NoContentException noContentException) { + var errorMessage = new Message(); + errorMessage.setHelpCode(noContentException.getCode()); + errorMessage.setMessageDetails(noContentException.getMessage()); + return new ResponseEntity<>(errorMessage, HttpStatus.NO_CONTENT); + } + @ExceptionHandler(InvalidParamException.class) public ResponseEntity handleInvalidException(InvalidParamException invalidDataException) { var errorMessage = new Message(); diff --git a/marketplace-service/src/main/java/com/axonivy/market/exceptions/model/NoContentException.java b/marketplace-service/src/main/java/com/axonivy/market/exceptions/model/NoContentException.java new file mode 100644 index 000000000..9d8a74fc6 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/exceptions/model/NoContentException.java @@ -0,0 +1,27 @@ +package com.axonivy.market.exceptions.model; + +import com.axonivy.market.enums.ErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +import java.io.Serial; + +@Getter +@Setter +@AllArgsConstructor +public class NoContentException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + private static final String SEPARATOR = "-"; + + private final String code; + private final String message; + + public NoContentException(ErrorCode errorCode, String additionalMessage) { + this.code = errorCode.getCode(); + this.message = errorCode.getHelpText() + SEPARATOR + additionalMessage; + } + +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/exceptions/model/NotFoundException.java b/marketplace-service/src/main/java/com/axonivy/market/exceptions/model/NotFoundException.java index e84dd9c01..11f9c2cf5 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/exceptions/model/NotFoundException.java +++ b/marketplace-service/src/main/java/com/axonivy/market/exceptions/model/NotFoundException.java @@ -1,5 +1,6 @@ package com.axonivy.market.exceptions.model; +import com.axonivy.market.constants.CommonConstants; import com.axonivy.market.enums.ErrorCode; import lombok.AllArgsConstructor; import lombok.Getter; @@ -14,7 +15,6 @@ public class NotFoundException extends RuntimeException { @Serial private static final long serialVersionUID = 1L; - private static final String SEPARATOR = "-"; private final String code; private final String message; @@ -26,7 +26,7 @@ public NotFoundException(ErrorCode errorCode) { public NotFoundException(ErrorCode errorCode, String additionalMessage) { this.code = errorCode.getCode(); - this.message = errorCode.getHelpText() + SEPARATOR + additionalMessage; + this.message = errorCode.getHelpText() + CommonConstants.DASH_SEPARATOR + additionalMessage; } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/factory/ProductFactory.java b/marketplace-service/src/main/java/com/axonivy/market/factory/ProductFactory.java index e6f5b7580..a1ee2fdce 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/factory/ProductFactory.java +++ b/marketplace-service/src/main/java/com/axonivy/market/factory/ProductFactory.java @@ -1,8 +1,15 @@ package com.axonivy.market.factory; +import static com.axonivy.market.constants.CommonConstants.SLASH; +import static com.axonivy.market.constants.MetaConstants.DEFAULT_VENDOR_NAME; +import static com.axonivy.market.constants.MetaConstants.DEFAULT_VENDOR_URL; +import static com.axonivy.market.constants.MetaConstants.META_FILE; +import static org.apache.commons.lang3.StringUtils.EMPTY; +import com.axonivy.market.constants.CommonConstants; import com.axonivy.market.entity.Product; +import com.axonivy.market.entity.ProductJsonContent; +import com.axonivy.market.entity.ProductModuleContent; import com.axonivy.market.github.model.Meta; -import com.axonivy.market.github.util.GitHubUtils; import com.axonivy.market.model.DisplayValue; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AccessLevel; @@ -12,20 +19,12 @@ import org.apache.commons.lang3.StringUtils; import org.kohsuke.github.GHContent; import org.springframework.util.CollectionUtils; - import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import static com.axonivy.market.constants.CommonConstants.LOGO_FILE; -import static com.axonivy.market.constants.CommonConstants.SLASH; -import static com.axonivy.market.constants.MetaConstants.DEFAULT_VENDOR_NAME; -import static com.axonivy.market.constants.MetaConstants.DEFAULT_VENDOR_URL; -import static com.axonivy.market.constants.MetaConstants.META_FILE; -import static org.apache.commons.lang3.StringUtils.EMPTY; - @Log4j2 @NoArgsConstructor(access = AccessLevel.PRIVATE) public class ProductFactory { @@ -40,9 +39,6 @@ public static Product mappingByGHContent(Product product, GHContent content) { if (StringUtils.endsWith(contentName, META_FILE)) { mappingByMetaJSONFile(product, content); } - if (StringUtils.endsWith(contentName, LOGO_FILE)) { - product.setLogoUrl(GitHubUtils.getDownloadUrl(content)); - } return product; } @@ -78,6 +74,14 @@ public static Product mappingByMetaJSONFile(Product product, GHContent ghContent return product; } + public static void transferComputedPersistedDataToProduct(Product persisted, Product product) { + product.setCustomOrder(persisted.getCustomOrder()); + product.setNewestReleaseVersion(persisted.getNewestReleaseVersion()); + product.setReleasedVersions(persisted.getReleasedVersions()); + product.setInstallationCount(persisted.getInstallationCount()); + product.setSynchronizedInstallationCount(persisted.getSynchronizedInstallationCount()); + } + private static Map mappingMultilingualismValueByMetaJSONFile(List list) { Map value = new HashMap<>(); if (!CollectionUtils.isEmpty(list)) { @@ -112,4 +116,16 @@ public static void extractSourceUrl(Product product, Meta meta) { private static Meta jsonDecode(GHContent ghContent) throws IOException { return MAPPER.readValue(ghContent.read().readAllBytes(), Meta.class); } + + public static void mappingIdForProductModuleContent(ProductModuleContent content) { + if (StringUtils.isNotBlank(content.getProductId()) && StringUtils.isNotBlank(content.getTag())) { + content.setId(String.format(CommonConstants.ID_WITH_NUMBER_PATTERN, content.getProductId(), content.getTag())); + } + } + + public static void mappingIdForProductJsonContent(ProductJsonContent content) { + if (StringUtils.isNotBlank(content.getProductId()) && StringUtils.isNotBlank(content.getVersion())) { + content.setId(String.format(CommonConstants.ID_WITH_NUMBER_PATTERN, content.getProductId(), content.getVersion())); + } + } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyProductRepoService.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyProductRepoService.java index 2f26df9c6..debe27c42 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyProductRepoService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/GHAxonIvyProductRepoService.java @@ -19,4 +19,6 @@ public interface GHAxonIvyProductRepoService { ProductModuleContent getReadmeAndProductContentsFromTag(Product product, GHRepository ghRepository, String tag); List convertProductJsonToMavenProductInfo(GHContent content) throws IOException; + + void extractReadMeFileFromContents(Product product, List contents, ProductModuleContent productModuleContent); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java index e1843ae25..2d0e673f0 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GHAxonIvyProductRepoServiceImpl.java @@ -5,16 +5,19 @@ import com.axonivy.market.constants.MavenConstants; import com.axonivy.market.constants.ProductJsonConstants; import com.axonivy.market.constants.ReadmeConstants; +import com.axonivy.market.entity.Image; import com.axonivy.market.entity.Product; import com.axonivy.market.entity.ProductModuleContent; import com.axonivy.market.entity.ProductJsonContent; import com.axonivy.market.enums.Language; import com.axonivy.market.enums.NonStandardProduct; +import com.axonivy.market.factory.ProductFactory; import com.axonivy.market.github.model.MavenArtifact; import com.axonivy.market.github.service.GHAxonIvyProductRepoService; import com.axonivy.market.github.service.GitHubService; import com.axonivy.market.github.util.GitHubUtils; import com.axonivy.market.repository.ProductJsonContentRepository; +import com.axonivy.market.service.ImageService; import com.axonivy.market.util.VersionUtils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -30,6 +33,7 @@ import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; +import javax.swing.text.html.Option; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -38,9 +42,11 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; +import static com.axonivy.market.constants.CommonConstants.IMAGE_ID_PREFIX; import static com.axonivy.market.constants.ProductJsonConstants.EN_LANGUAGE; import static com.axonivy.market.constants.ProductJsonConstants.VERSION_VALUE; @@ -49,10 +55,11 @@ public class GHAxonIvyProductRepoServiceImpl implements GHAxonIvyProductRepoService { private GHOrganization organization; private final GitHubService gitHubService; - + private final ImageService imageService; private final ProductJsonContentRepository productJsonContentRepository; private String repoUrl; private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String HASH = "#"; public static final String DEMO_SETUP_TITLE = "(?i)## Demo|## Setup"; public static final String IMAGE_EXTENSION = "(.*?).(jpeg|jpg|png|gif)"; public static final String README_IMAGE_FORMAT = "\\(([^)]*?%s[^)]*?)\\)"; @@ -61,9 +68,10 @@ public class GHAxonIvyProductRepoServiceImpl implements GHAxonIvyProductRepoServ public static final String DEMO = "demo"; public static final String SETUP = "setup"; - public GHAxonIvyProductRepoServiceImpl(GitHubService gitHubService, + public GHAxonIvyProductRepoServiceImpl(GitHubService gitHubService, ImageService imageService, ProductJsonContentRepository productJsonContentRepository) { this.gitHubService = gitHubService; + this.imageService = imageService; this.productJsonContentRepository = productJsonContentRepository; } @@ -167,10 +175,21 @@ public ProductModuleContent getReadmeAndProductContentsFromTag(Product product, List contents = getProductFolderContents(product, ghRepository, tag); productModuleContent.setProductId(product.getId()); productModuleContent.setTag(tag); + ProductFactory.mappingIdForProductModuleContent(productModuleContent); updateDependencyContentsFromProductJson(productModuleContent, contents , product); + extractReadMeFileFromContents(product, contents, productModuleContent); + } catch (Exception e) { + log.error("Cannot get product.json content {}", e.getMessage()); + return null; + } + return productModuleContent; + } + + public void extractReadMeFileFromContents(Product product, List contents, ProductModuleContent productModuleContent) { + try { List readmeFiles = contents.stream().filter(GHContent::isFile) .filter(content -> content.getName().startsWith(ReadmeConstants.README_FILE_NAME)).toList(); - Map> moduleContents = new HashMap<>(); + Map> moduleContents = new HashMap<>(); if (!CollectionUtils.isEmpty(readmeFiles)) { for (GHContent readmeFile : readmeFiles) { String readmeContents = new String(readmeFile.read().readAllBytes()); @@ -185,10 +204,8 @@ public ProductModuleContent getReadmeAndProductContentsFromTag(Product product, productModuleContent.setSetup(replaceEmptyContentsWithEnContent(moduleContents.get(SETUP))); } } catch (Exception e) { - log.error("Cannot get product.json and README file's content {}", e.getMessage()); - return null; + log.error("Cannot get README file's content {}", e.getMessage()); } - return productModuleContent; } /** @@ -229,13 +246,12 @@ private void updateDependencyContentsFromProductJson(ProductModuleContent produc productModuleContent.setName(artifact.getName()); } String currentVersion = VersionUtils.convertTagToVersion(productModuleContent.getTag()); - boolean isProductJsonContentExists = productJsonContentRepository.existsByProductIdAndVersion(product.getId(), - currentVersion); String content = extractProductJsonContent(productJsonFile, productModuleContent.getTag()); - if (ObjectUtils.isNotEmpty(content) && !isProductJsonContentExists) { + if (ObjectUtils.isNotEmpty(content)) { ProductJsonContent jsonContent = new ProductJsonContent(); jsonContent.setVersion(currentVersion); jsonContent.setProductId(product.getId()); + ProductFactory.mappingIdForProductJsonContent(jsonContent); jsonContent.setName(product.getNames().get(EN_LANGUAGE)); jsonContent.setContent(content.replace(VERSION_VALUE, currentVersion)); productJsonContentRepository.save(jsonContent); @@ -258,37 +274,42 @@ private static GHContent getProductJsonFile(List contents) { .filter(content -> ProductJsonConstants.PRODUCT_JSON_FILE.equals(content.getName())).findFirst().orElse(null); } - public String updateImagesWithDownloadUrl(Product product, List contents, String readmeContents) - throws IOException { - Map imageUrls = new HashMap<>(); - List productImages = contents.stream().filter(GHContent::isFile) + public String updateImagesWithDownloadUrl(Product product, List contents, String readmeContents) { + + List imagesAtRootFolder = contents.stream().filter(GHContent::isFile) .filter(content -> content.getName().toLowerCase().matches(IMAGE_EXTENSION)).toList(); - if (!CollectionUtils.isEmpty(productImages)) { - for (GHContent productImage : productImages) { - imageUrls.put(productImage.getName(), productImage.getDownloadUrl()); - } - } else { - getImagesFromImageFolder(product, contents, imageUrls); - } + + List allContentOfImages = ObjectUtils.isNotEmpty(imagesAtRootFolder) + ? imagesAtRootFolder + : getImagesFromImageFolder(product, contents); + + Map imageUrls = new HashMap<>(); + + allContentOfImages.forEach(content -> Optional.of(imageService.mappingImageFromGHContent(product, content, false)) + .ifPresent(image -> imageUrls.put(content.getName(), IMAGE_ID_PREFIX.concat(image.getId())))); + for (Map.Entry entry : imageUrls.entrySet()) { String imageUrlPattern = String.format(README_IMAGE_FORMAT, Pattern.quote(entry.getKey())); readmeContents = readmeContents.replaceAll(imageUrlPattern, String.format(IMAGE_DOWNLOAD_URL_FORMAT, entry.getValue())); - } + return readmeContents; } - private void getImagesFromImageFolder(Product product, List contents, Map imageUrls) - throws IOException { + private List getImagesFromImageFolder(Product product, List contents) { + List images = new ArrayList<>(); String imageFolderPath = GitHubUtils.getNonStandardImageFolder(product.getId()); - GHContent imageFolder = contents.stream().filter(GHContent::isDirectory) - .filter(content -> imageFolderPath.equals(content.getName())).findFirst().orElse(null); - if (Objects.nonNull(imageFolder)) { - for (GHContent imageContent : imageFolder.listDirectoryContent().toList()) { - imageUrls.put(imageContent.getName(), imageContent.getDownloadUrl()); - } - } + contents.stream().filter(GHContent::isDirectory) + .filter(content -> imageFolderPath.equals(content.getName())) + .findFirst().ifPresent(imageFolder -> { + try { + images.addAll(imageFolder.listDirectoryContent().toList()); + } catch (IOException e) { + log.error(e.getMessage()); + } + }); + return images; } // Cover some cases including when demo and setup parts switch positions or @@ -353,10 +374,16 @@ private boolean hasImageDirectives(String readmeContents) { } private String removeFirstLine(String text) { + String result; if (text.isBlank()) { - return Strings.EMPTY; + result = Strings.EMPTY; + } else if (text.startsWith(HASH)) { + int index = text.indexOf(StringUtils.LF); + result = index != StringUtils.INDEX_NOT_FOUND ? text.substring(index + 1).trim() : Strings.EMPTY; + } else { + result = text; } - int index = text.indexOf(StringUtils.LF); - return index != -1 ? text.substring(index + 1).trim() : Strings.EMPTY; + + return result; } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java index 045d745f2..4ff0bc3e8 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/service/impl/GitHubServiceImpl.java @@ -1,19 +1,26 @@ package com.axonivy.market.github.service.impl; -import static org.apache.commons.lang3.StringUtils.EMPTY; - -import java.io.IOException; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; - +import com.axonivy.market.constants.CommonConstants; +import com.axonivy.market.constants.ErrorMessageConstants; +import com.axonivy.market.constants.GitHubConstants; +import com.axonivy.market.entity.User; +import com.axonivy.market.enums.ErrorCode; +import com.axonivy.market.exceptions.model.MissingHeaderException; +import com.axonivy.market.exceptions.model.NotFoundException; +import com.axonivy.market.exceptions.model.Oauth2ExchangeCodeException; +import com.axonivy.market.exceptions.model.UnauthorizedException; +import com.axonivy.market.github.model.GitHubAccessTokenResponse; +import com.axonivy.market.github.model.GitHubProperty; +import com.axonivy.market.github.service.GitHubService; +import com.axonivy.market.github.util.GitHubUtils; +import com.axonivy.market.repository.UserRepository; +import lombok.extern.log4j.Log4j2; import org.kohsuke.github.GHContent; import org.kohsuke.github.GHOrganization; import org.kohsuke.github.GHRepository; +import org.kohsuke.github.GHTag; import org.kohsuke.github.GitHub; import org.kohsuke.github.GitHubBuilder; -import org.kohsuke.github.GHTag; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; @@ -28,19 +35,15 @@ import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; -import com.axonivy.market.constants.GitHubConstants; -import com.axonivy.market.entity.User; -import com.axonivy.market.enums.ErrorCode; -import com.axonivy.market.exceptions.model.MissingHeaderException; -import com.axonivy.market.exceptions.model.NotFoundException; -import com.axonivy.market.exceptions.model.Oauth2ExchangeCodeException; -import com.axonivy.market.exceptions.model.UnauthorizedException; -import com.axonivy.market.github.model.GitHubAccessTokenResponse; -import com.axonivy.market.github.model.GitHubProperty; -import com.axonivy.market.github.service.GitHubService; -import com.axonivy.market.github.util.GitHubUtils; -import com.axonivy.market.repository.UserRepository; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.apache.commons.lang3.StringUtils.EMPTY; +@Log4j2 @Service public class GitHubServiceImpl implements GitHubService { @@ -57,9 +60,8 @@ public GitHubServiceImpl(RestTemplateBuilder restTemplateBuilder, UserRepository @Override public GitHub getGitHub() throws IOException { - return new GitHubBuilder() - .withOAuthToken(Optional.ofNullable(gitHubProperty).map(GitHubProperty::getToken).orElse(EMPTY).trim()) - .build(); + return new GitHubBuilder().withOAuthToken( + Optional.ofNullable(gitHubProperty).map(GitHubProperty::getToken).orElse(EMPTY).trim()).build(); } @Override @@ -109,6 +111,8 @@ public GitHubAccessTokenResponse getAccessToken(String code, GitHubProperty gitH GitHubAccessTokenResponse response = responseEntity.getBody(); if (response != null && response.getError() != null && !response.getError().isBlank()) { + log.error(String.format(ErrorMessageConstants.CURRENT_CLIENT_ID_MISMATCH_MESSAGE, code, + gitHubProperty.getOauth2ClientId())); throw new Oauth2ExchangeCodeException(response.getError(), response.getErrorDescription()); } @@ -175,8 +179,8 @@ public List> getUserOrganizations(String accessToken) throws return response.getBody(); } catch (HttpClientErrorException exception) { throw new UnauthorizedException(ErrorCode.GITHUB_USER_UNAUTHORIZED.getCode(), - ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText() + "-" + GitHubUtils.extractMessageFromExceptionMessage( - exception.getMessage())); + ErrorCode.GITHUB_USER_UNAUTHORIZED.getHelpText() + CommonConstants.DASH_SEPARATOR + + GitHubUtils.extractMessageFromExceptionMessage(exception.getMessage())); } } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java index 7c982cb1f..6d72dab73 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java +++ b/marketplace-service/src/main/java/com/axonivy/market/github/util/GitHubUtils.java @@ -15,6 +15,8 @@ import java.util.List; import java.util.stream.Collectors; +import static com.axonivy.market.constants.MetaConstants.META_FILE; + @Log4j2 @NoArgsConstructor(access = AccessLevel.PRIVATE) public class GitHubUtils { @@ -37,7 +39,7 @@ public static String getDownloadUrl(GHContent content) { } catch (IOException e) { log.error("Cannot get DownloadURl from GHContent: ", e); } - return ""; + return StringUtils.EMPTY; } public static List mapPagedIteratorToList(PagedIterable paged) { @@ -79,15 +81,23 @@ public static String extractMessageFromExceptionMessage(String exceptionMessage) return json.substring(startIndex, endIndex); } } - return ""; + return StringUtils.EMPTY; } - private static String extractJson(String text) { + public static String extractJson(String text) { int start = text.indexOf("{"); int end = text.lastIndexOf("}") + 1; if (start != -1 && end != -1) { return text.substring(start, end); } - return ""; + return StringUtils.EMPTY; + } + + public static int sortMetaJsonFirst(String fileName1, String fileName2) { + if (fileName1.endsWith(META_FILE)) + return -1; + if (fileName2.endsWith(META_FILE)) + return 1; + return fileName1.compareTo(fileName2); } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/model/DesignerInstallation.java b/marketplace-service/src/main/java/com/axonivy/market/model/DesignerInstallation.java new file mode 100644 index 000000000..398ad26af --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/model/DesignerInstallation.java @@ -0,0 +1,17 @@ +package com.axonivy.market.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class DesignerInstallation { + @Schema(description = "Ivy designer version", example = "11.4.0") + private String designerVersion; + private int numberOfDownloads; +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/CustomProductModuleContentRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/CustomProductModuleContentRepository.java new file mode 100644 index 000000000..af4652af8 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/CustomProductModuleContentRepository.java @@ -0,0 +1,8 @@ +package com.axonivy.market.repository; + +import java.util.List; + +public interface CustomProductModuleContentRepository { + + List findTagsByProductId(String id); +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/CustomProductRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/CustomProductRepository.java index 3cd980656..3fc59e926 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/CustomProductRepository.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/CustomProductRepository.java @@ -14,4 +14,6 @@ public interface CustomProductRepository { int updateInitialCount(String productId, int initialCount); int increaseInstallationCount(String productId); + + void increaseInstallationCountForProductByDesignerVersion(String productId, String designerVersion); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/CustomRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/CustomRepository.java new file mode 100644 index 000000000..1de688ba5 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/CustomRepository.java @@ -0,0 +1,25 @@ +package com.axonivy.market.repository; + +import com.axonivy.market.constants.MongoDBConstants; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.aggregation.AggregationOperation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; + +public class CustomRepository { + protected AggregationOperation createIdMatchOperation(String id) { + return createFieldMatchOperation(MongoDBConstants.ID, id); + } + + protected Query createQueryById(String id) { + return new Query(Criteria.where(MongoDBConstants.ID).is(id)); + } + + protected AggregationOperation createProjectAggregationBySingleFieldName(String fieldName) { + return Aggregation.project(fieldName); + } + + protected AggregationOperation createFieldMatchOperation(String fieldName, String id) { + return Aggregation.match(Criteria.where(fieldName).is(id)); + } +} \ No newline at end of file diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/ImageRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/ImageRepository.java new file mode 100644 index 000000000..4949a1d2c --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/ImageRepository.java @@ -0,0 +1,12 @@ +package com.axonivy.market.repository; + +import com.axonivy.market.entity.Image; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ImageRepository extends MongoRepository { + Image findByProductIdAndSha(String productId, String sha); + + void deleteAllByProductId(String productId); +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductDesignerInstallationRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductDesignerInstallationRepository.java new file mode 100644 index 000000000..2b5789521 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductDesignerInstallationRepository.java @@ -0,0 +1,14 @@ +package com.axonivy.market.repository; + +import com.axonivy.market.entity.ProductDesignerInstallation; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ProductDesignerInstallationRepository extends MongoRepository { + + List findByProductId(String productId, Sort sort); +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductJsonContentRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductJsonContentRepository.java index d803d824b..9d8dbfc7a 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductJsonContentRepository.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductJsonContentRepository.java @@ -8,7 +8,4 @@ public interface ProductJsonContentRepository extends MongoRepository { ProductJsonContent findByProductIdAndVersion(String productId , String version); - - boolean existsByProductIdAndVersion(String productId , String version); - } diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductModuleContentRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductModuleContentRepository.java index 75bdc53db..123a30c1c 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductModuleContentRepository.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductModuleContentRepository.java @@ -5,6 +5,6 @@ import org.springframework.stereotype.Repository; @Repository -public interface ProductModuleContentRepository extends MongoRepository { - ProductModuleContent findByTagAndProductId(String tag, String productId); +public interface ProductModuleContentRepository extends MongoRepository, CustomProductModuleContentRepository { + ProductModuleContent findByTagAndProductId(String tag, String productId); } diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductRepository.java b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductRepository.java index c138cadf4..946a87bc0 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/ProductRepository.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/ProductRepository.java @@ -10,4 +10,6 @@ public interface ProductRepository extends MongoRepository, Pro Product findByLogoUrl(String logoUrl); + Product findByLogoId(String logoId); + } diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/impl/CustomProductModuleContentRepositoryImpl.java b/marketplace-service/src/main/java/com/axonivy/market/repository/impl/CustomProductModuleContentRepositoryImpl.java new file mode 100644 index 000000000..933b6a366 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/impl/CustomProductModuleContentRepositoryImpl.java @@ -0,0 +1,34 @@ +package com.axonivy.market.repository.impl; + +import com.axonivy.market.constants.EntityConstants; +import com.axonivy.market.constants.MongoDBConstants; +import com.axonivy.market.entity.ProductModuleContent; +import com.axonivy.market.repository.CustomProductModuleContentRepository; +import com.axonivy.market.repository.CustomRepository; +import lombok.Builder; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; + +import java.util.List; + +@Builder +public class CustomProductModuleContentRepositoryImpl extends CustomRepository implements CustomProductModuleContentRepository { + + private final MongoTemplate mongoTemplate; + + public CustomProductModuleContentRepositoryImpl(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @Override + public List findTagsByProductId(String id) { + Aggregation aggregation = Aggregation.newAggregation(createFieldMatchOperation(MongoDBConstants.PRODUCT_ID, id), + createProjectAggregationBySingleFieldName(MongoDBConstants.TAG)); + return queryProductModuleContentsByAggregation(aggregation).stream().map(ProductModuleContent::getTag).toList(); + } + + public List queryProductModuleContentsByAggregation(Aggregation aggregation) { + return mongoTemplate.aggregate(aggregation, EntityConstants.PRODUCT_MODULE_CONTENT, ProductModuleContent.class) + .getMappedResults(); + } +} \ No newline at end of file diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/impl/CustomProductRepositoryImpl.java b/marketplace-service/src/main/java/com/axonivy/market/repository/impl/CustomProductRepositoryImpl.java index 34a538b15..a91d50200 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/impl/CustomProductRepositoryImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/impl/CustomProductRepositoryImpl.java @@ -1,15 +1,17 @@ package com.axonivy.market.repository.impl; +import com.axonivy.market.constants.EntityConstants; import com.axonivy.market.constants.MongoDBConstants; import com.axonivy.market.entity.Product; import com.axonivy.market.entity.ProductModuleContent; +import com.axonivy.market.entity.ProductDesignerInstallation; import com.axonivy.market.repository.CustomProductRepository; +import com.axonivy.market.repository.CustomRepository; import com.axonivy.market.repository.ProductModuleContentRepository; import lombok.Builder; import org.springframework.data.mongodb.core.FindAndModifyOptions; import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.aggregation.Aggregation; -import org.springframework.data.mongodb.core.aggregation.AggregationOperation; import org.springframework.data.mongodb.core.aggregation.AggregationResults; import org.springframework.data.mongodb.core.query.Criteria; import org.springframework.data.mongodb.core.query.Query; @@ -21,7 +23,7 @@ import java.util.Optional; @Builder -public class CustomProductRepositoryImpl implements CustomProductRepository { +public class CustomProductRepositoryImpl extends CustomRepository implements CustomProductRepository { private final MongoTemplate mongoTemplate; private final ProductModuleContentRepository contentRepository; @@ -30,12 +32,9 @@ public CustomProductRepositoryImpl(MongoTemplate mongoTemplate, ProductModuleCon this.contentRepository = contentRepository; } - private AggregationOperation createIdMatchOperation(String id) { - return Aggregation.match(Criteria.where(MongoDBConstants.ID).is(id)); - } public Product queryProductByAggregation(Aggregation aggregation) { - return Optional.of(mongoTemplate.aggregate(aggregation, MongoDBConstants.PRODUCT_COLLECTION, Product.class)) + return Optional.of(mongoTemplate.aggregate(aggregation, EntityConstants.PRODUCT, Product.class)) .map(AggregationResults::getUniqueMappedResult).orElse(null); } @@ -89,7 +88,16 @@ public int increaseInstallationCount(String productId) { return updatedProduct != null ? updatedProduct.getInstallationCount() : 0; } - private Query createQueryById(String id) { - return new Query(Criteria.where(MongoDBConstants.ID).is(id)); + + @Override + public void increaseInstallationCountForProductByDesignerVersion(String productId, String designerVersion) { + Update update = new Update().inc(MongoDBConstants.INSTALLATION_COUNT, 1); + mongoTemplate.upsert(createQueryByProductIdAndDesignerVersion(productId, designerVersion), + update, ProductDesignerInstallation.class); + } + + private Query createQueryByProductIdAndDesignerVersion(String productId, String designerVersion) { + return new Query(Criteria.where(MongoDBConstants.PRODUCT_ID).is(productId) + .andOperator(Criteria.where(MongoDBConstants.DESIGNER_VERSION).is(designerVersion))); } } diff --git a/marketplace-service/src/main/java/com/axonivy/market/repository/impl/ProductSearchRepositoryImpl.java b/marketplace-service/src/main/java/com/axonivy/market/repository/impl/ProductSearchRepositoryImpl.java index 538f8f447..68a86856f 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/repository/impl/ProductSearchRepositoryImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/repository/impl/ProductSearchRepositoryImpl.java @@ -34,7 +34,6 @@ public ProductSearchRepositoryImpl(MongoTemplate mongoTemplate) { this.mongoTemplate = mongoTemplate; } - @Override public Page searchByCriteria(ProductSearchCriteria searchCriteria, Pageable pageable) { return getResultAsPageable(pageable, buildCriteriaSearch(searchCriteria)); diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/ImageService.java b/marketplace-service/src/main/java/com/axonivy/market/service/ImageService.java new file mode 100644 index 000000000..f87dbf830 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/service/ImageService.java @@ -0,0 +1,15 @@ +package com.axonivy.market.service; + +import org.bson.types.Binary; +import org.kohsuke.github.GHContent; + +import com.axonivy.market.entity.Image; +import com.axonivy.market.entity.Product; + +public interface ImageService { + Binary getImageBinary(GHContent ghContent); + + Image mappingImageFromGHContent(Product product, GHContent ghContent, boolean isLogo); + + byte[] readImage(String id); +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/ProductDesignerInstallationService.java b/marketplace-service/src/main/java/com/axonivy/market/service/ProductDesignerInstallationService.java new file mode 100644 index 000000000..046a7be7f --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/service/ProductDesignerInstallationService.java @@ -0,0 +1,9 @@ +package com.axonivy.market.service; + +import com.axonivy.market.model.DesignerInstallation; + +import java.util.List; + +public interface ProductDesignerInstallationService { + List findByProductId(String productId); +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java b/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java index f63495616..44c27771f 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/ProductService.java @@ -11,7 +11,7 @@ public interface ProductService { boolean syncLatestDataFromMarketRepo(); - int updateInstallationCountForProduct(String key); + int updateInstallationCountForProduct(String key, String designerVersion); Product fetchProductDetail(String id); diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/impl/FeedbackServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/service/impl/FeedbackServiceImpl.java index b4148918c..3d648c144 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/impl/FeedbackServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/impl/FeedbackServiceImpl.java @@ -2,6 +2,7 @@ import com.axonivy.market.entity.Feedback; import com.axonivy.market.enums.ErrorCode; +import com.axonivy.market.exceptions.model.NoContentException; import com.axonivy.market.exceptions.model.NotFoundException; import com.axonivy.market.model.FeedbackModelRequest; import com.axonivy.market.model.ProductRating; @@ -9,6 +10,7 @@ import com.axonivy.market.repository.ProductRepository; import com.axonivy.market.repository.UserRepository; import com.axonivy.market.service.FeedbackService; +import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -45,14 +47,16 @@ public Feedback findFeedback(String id) throws NotFoundException { } @Override - public Feedback findFeedbackByUserIdAndProductId(String userId, String productId) throws NotFoundException { - validateUserExists(userId); + public Feedback findFeedbackByUserIdAndProductId(String userId, String productId) throws NotFoundException, NoContentException { + if (StringUtils.isNotBlank(userId)) { + validateUserExists(userId); + } validateProductExists(productId); Feedback existingUserFeedback = feedbackRepository.findByUserIdAndProductId(userId, productId); if (existingUserFeedback == null) { - throw new NotFoundException(ErrorCode.FEEDBACK_NOT_FOUND, - String.format("Not found feedback with user id '%s' and product id '%s'", userId, productId)); + throw new NoContentException(ErrorCode.NO_FEEDBACK_OF_USER_FOR_PRODUCT, + String.format("No feedback with user id '%s' and product id '%s'", userId, productId)); } return existingUserFeedback; } diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ImageServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ImageServiceImpl.java new file mode 100644 index 000000000..1b589872f --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ImageServiceImpl.java @@ -0,0 +1,65 @@ +package com.axonivy.market.service.impl; + +import com.axonivy.market.entity.Image; +import com.axonivy.market.entity.Product; +import com.axonivy.market.github.util.GitHubUtils; +import com.axonivy.market.repository.ImageRepository; +import com.axonivy.market.service.ImageService; +import lombok.extern.log4j.Log4j2; +import org.apache.commons.io.IOUtils; +import org.apache.commons.lang3.ObjectUtils; +import org.bson.types.Binary; +import org.kohsuke.github.GHContent; +import org.springframework.stereotype.Service; + +import java.io.InputStream; + +@Service +@Log4j2 +public class ImageServiceImpl implements ImageService { + + private final ImageRepository imageRepository; + + public ImageServiceImpl(ImageRepository imageRepository) { + this.imageRepository = imageRepository; + } + + @Override + public Binary getImageBinary(GHContent ghContent) { + try { + InputStream contentStream = ghContent.read(); + byte[] sourceBytes = IOUtils.toByteArray(contentStream); + return new Binary(sourceBytes); + } catch (Exception exception) { + log.error("Cannot get content of product logo {} ", ghContent.getName()); + return null; + } + } + + @Override + public Image mappingImageFromGHContent(Product product, GHContent ghContent, boolean isLogo) { + if (ObjectUtils.isEmpty(ghContent)) { + log.info("There is missing for image content for product {}" , product.getId()); + return null; + } + + if (!isLogo) { + Image existsImage = imageRepository.findByProductIdAndSha(product.getId(), ghContent.getSha()); + if (ObjectUtils.isNotEmpty(existsImage)) { + return existsImage; + } + } + Image image = new Image(); + String currentImageUrl = GitHubUtils.getDownloadUrl(ghContent); + image.setProductId(product.getId()); + image.setImageUrl(currentImageUrl); + image.setImageData(getImageBinary(ghContent)); + image.setSha(ghContent.getSha()); + return imageRepository.save(image); + } + + @Override + public byte[] readImage(String id) { + return imageRepository.findById(id).map(Image::getImageData).map(Binary::getData).orElse(null); + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductDesignerInstallationServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductDesignerInstallationServiceImpl.java new file mode 100644 index 000000000..c95241e08 --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductDesignerInstallationServiceImpl.java @@ -0,0 +1,35 @@ +package com.axonivy.market.service.impl; + +import com.axonivy.market.constants.MongoDBConstants; +import com.axonivy.market.entity.ProductDesignerInstallation; +import com.axonivy.market.model.DesignerInstallation; +import com.axonivy.market.repository.ProductDesignerInstallationRepository; +import com.axonivy.market.service.ProductDesignerInstallationService; +import lombok.extern.log4j.Log4j2; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Log4j2 +@Service +public class ProductDesignerInstallationServiceImpl implements ProductDesignerInstallationService { + private final ProductDesignerInstallationRepository productDesignerInstallationRepository; + + public ProductDesignerInstallationServiceImpl(ProductDesignerInstallationRepository productDesignerInstallationRepository) { + this.productDesignerInstallationRepository = productDesignerInstallationRepository; + } + + @Override + public List findByProductId(String productId) { + List designerInstallations = new ArrayList<>(); + List productDesignerInstallations = + productDesignerInstallationRepository.findByProductId(productId, Sort.by(Sort.Direction.DESC, MongoDBConstants.DESIGNER_VERSION)); + for (ProductDesignerInstallation productDesignerInstallation : productDesignerInstallations) { + DesignerInstallation designerInstallation = new DesignerInstallation(productDesignerInstallation.getDesignerVersion(), productDesignerInstallation.getInstallationCount()); + designerInstallations.add(designerInstallation); + } + return designerInstallations; + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java index dbeea37d2..56d424b01 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java +++ b/marketplace-service/src/main/java/com/axonivy/market/service/impl/ProductServiceImpl.java @@ -1,5 +1,6 @@ package com.axonivy.market.service.impl; +import com.axonivy.market.comparator.MavenVersionComparator; import com.axonivy.market.constants.CommonConstants; import com.axonivy.market.constants.GitHubConstants; import com.axonivy.market.constants.ProductJsonConstants; @@ -22,9 +23,11 @@ import com.axonivy.market.github.util.GitHubUtils; import com.axonivy.market.model.ProductCustomSortRequest; import com.axonivy.market.repository.GitHubRepoMetaRepository; +import com.axonivy.market.repository.ImageRepository; import com.axonivy.market.repository.ProductCustomSortRepository; import com.axonivy.market.repository.ProductModuleContentRepository; import com.axonivy.market.repository.ProductRepository; +import com.axonivy.market.service.ImageService; import com.axonivy.market.service.ProductService; import com.axonivy.market.util.VersionUtils; import com.fasterxml.jackson.core.type.TypeReference; @@ -56,13 +59,15 @@ import java.nio.file.Paths; import java.security.SecureRandom; import java.util.ArrayList; -import java.util.Comparator; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Predicate; +import static com.axonivy.market.constants.ProductJsonConstants.LOGO_FILE; import static com.axonivy.market.enums.DocumentField.MARKET_DIRECTORY; import static com.axonivy.market.enums.DocumentField.SHORT_DESCRIPTIONS; import static java.util.Optional.ofNullable; @@ -79,7 +84,9 @@ public class ProductServiceImpl implements ProductService { private final GitHubRepoMetaRepository gitHubRepoMetaRepository; private final GitHubService gitHubService; private final ProductCustomSortRepository productCustomSortRepository; + private final ImageRepository imageRepository; + private final ImageService imageService; private final MongoTemplate mongoTemplate; private GHCommit lastGHCommit; @@ -93,14 +100,15 @@ public class ProductServiceImpl implements ProductService { private String marketRepoBranch; public static final String NON_NUMERIC_CHAR = "[^0-9.]"; + private static final String INITIAL_VERSION = "1.0"; private final SecureRandom random = new SecureRandom(); public ProductServiceImpl(ProductRepository productRepository, ProductModuleContentRepository productModuleContentRepository, - GHAxonIvyMarketRepoService axonIvyMarketRepoService, - GHAxonIvyProductRepoService axonIvyProductRepoService, GitHubRepoMetaRepository gitHubRepoMetaRepository, - GitHubService gitHubService, ProductCustomSortRepository productCustomSortRepository, - MongoTemplate mongoTemplate) { + GHAxonIvyMarketRepoService axonIvyMarketRepoService, GHAxonIvyProductRepoService axonIvyProductRepoService, + GitHubRepoMetaRepository gitHubRepoMetaRepository, GitHubService gitHubService, + ProductCustomSortRepository productCustomSortRepository, ImageRepository imageRepository1, + ImageService imageService, MongoTemplate mongoTemplate) { this.productRepository = productRepository; this.productModuleContentRepository = productModuleContentRepository; this.axonIvyMarketRepoService = axonIvyMarketRepoService; @@ -108,6 +116,8 @@ public ProductServiceImpl(ProductRepository productRepository, this.gitHubRepoMetaRepository = gitHubRepoMetaRepository; this.gitHubService = gitHubService; this.productCustomSortRepository = productCustomSortRepository; + this.imageRepository = imageRepository1; + this.imageService = imageService; this.mongoTemplate = mongoTemplate; } @@ -144,11 +154,17 @@ public boolean syncLatestDataFromMarketRepo() { } @Override - public int updateInstallationCountForProduct(String key) { + public int updateInstallationCountForProduct(String key, String designerVersion) { Product product= productRepository.getProductById(key); if (Objects.isNull(product)){ return 0; } + + log.info("Increase installation count for product {} By Designer Version {}", key, designerVersion); + if (StringUtils.isNotBlank(designerVersion)) { + productRepository.increaseInstallationCountForProductByDesignerVersion(key, designerVersion); + } + log.info("updating installation count for product {}", key); if (BooleanUtils.isTrue(product.getSynchronizedInstallationCount())) { return productRepository.increaseInstallationCount(key); @@ -193,7 +209,7 @@ private void syncRepoMetaDataStatus() { private void updateLatestChangeToProductsFromGithubRepo() { var fromSHA1 = marketRepoMeta.getLastSHA1(); - var toSHA1 = ofNullable(lastGHCommit).map(GHCommit::getSHA1).orElse(""); + var toSHA1 = ofNullable(lastGHCommit).map(GHCommit::getSHA1).orElse(EMPTY); log.warn("**ProductService: synchronize products from SHA1 {} to SHA1 {}", fromSHA1, toSHA1); List gitHubFileChanges = axonIvyMarketRepoService.fetchMarketItemsBySHA1Range(fromSHA1, toSHA1); Map> groupGitHubFiles = new HashMap<>(); @@ -201,6 +217,7 @@ private void updateLatestChangeToProductsFromGithubRepo() { String filePath = file.getFileName(); var parentPath = filePath.replace(FileType.META.getFileName(), EMPTY).replace(FileType.LOGO.getFileName(), EMPTY); var files = groupGitHubFiles.getOrDefault(parentPath, new ArrayList<>()); + files.sort((file1, file2) -> GitHubUtils.sortMetaJsonFirst(file1.getFileName(), file2.getFileName())); files.add(file); groupGitHubFiles.putIfAbsent(parentPath, files); } @@ -219,6 +236,7 @@ private void updateLatestChangeToProductsFromGithubRepo() { ProductFactory.mappingByGHContent(product, fileContent); if (FileType.META == file.getType()) { + transferComputedDataFromDB(product); modifyProductByMetaContent(file, product); } else { modifyProductLogo(ghFileEntity.getKey(), file, product, fileContent); @@ -227,6 +245,10 @@ private void updateLatestChangeToProductsFromGithubRepo() { }); } + private static Predicate filterNonPersistGhTagName(List currentTags) { + return tag -> !currentTags.contains(tag.getName()); + } + private void modifyProductLogo(String parentPath, GitHubFile file, Product product, GHContent fileContent) { Product result; switch (file.getStatus()) { @@ -236,13 +258,17 @@ private void modifyProductLogo(String parentPath, GitHubFile file, Product produ searchCriteria.setFields(List.of(MARKET_DIRECTORY)); result = productRepository.findByCriteria(searchCriteria); if (result != null) { - result.setLogoUrl(GitHubUtils.getDownloadUrl(fileContent)); - productRepository.save(result); + Optional.ofNullable(imageService.mappingImageFromGHContent(result, fileContent, true)).ifPresent(image -> { + imageRepository.deleteById(result.getLogoId()); + result.setLogoId(image.getId()); + productRepository.save(result); + }); } break; case REMOVED: - result = productRepository.findByLogoUrl(product.getLogoUrl()); + result = productRepository.findByLogoId(product.getLogoId()); if (result != null) { + imageRepository.deleteAllByProductId(result.getId()); productRepository.deleteById(result.getId()); } break; @@ -251,19 +277,6 @@ private void modifyProductLogo(String parentPath, GitHubFile file, Product produ } } - private void modifyProductByMetaContent(GitHubFile file, Product product) { - switch (file.getStatus()) { - case MODIFIED, ADDED: - productRepository.save(product); - break; - case REMOVED: - productRepository.deleteById(product.getId()); - break; - default: - break; - } - } - private Pageable refinePagination(String language, Pageable pageable) { PageRequest pageRequest = (PageRequest) pageable; if (pageable != null) { @@ -312,6 +325,19 @@ private boolean isLastGithubCommitCovered() { return isLastCommitCovered; } + private void modifyProductByMetaContent(GitHubFile file, Product product) { + switch (file.getStatus()) { + case MODIFIED, ADDED: + productRepository.save(product); + break; + case REMOVED: + productRepository.deleteById(product.getId()); + break; + default: + break; + } + } + private void updateLatestReleaseTagContentsFromProductRepo() { List products = productRepository.findAll(); if (ObjectUtils.isEmpty(products)) { @@ -326,48 +352,73 @@ private void updateLatestReleaseTagContentsFromProductRepo() { } } + private void updateProductContentForNonStandardProduct(Map.Entry> ghContentEntity, Product product) { + ProductModuleContent initialContent = new ProductModuleContent(); + initialContent.setTag(INITIAL_VERSION); + initialContent.setProductId(product.getId()); + ProductFactory.mappingIdForProductModuleContent(initialContent); + product.setReleasedVersions(List.of(INITIAL_VERSION)); + product.setNewestReleaseVersion(INITIAL_VERSION); + axonIvyProductRepoService.extractReadMeFileFromContents(product, ghContentEntity.getValue(), initialContent); + productModuleContentRepository.save(initialContent); + } + + private void getProductContents(Product product) { + try { + GHRepository productRepo = gitHubService.getRepository(product.getRepositoryName()); + updateProductFromReleaseTags(product, productRepo); + } catch (IOException e) { + log.error("Cannot find product repository {} {}", product.getRepositoryName(), e); + } + } + private void syncProductsFromGitHubRepo() { log.warn("**ProductService: synchronize products from scratch based on the Market repo"); var gitHubContentMap = axonIvyMarketRepoService.fetchAllMarketItems(); - gitHubContentMap.entrySet().forEach(ghContentEntity -> { + for (Map.Entry> ghContentEntity : gitHubContentMap.entrySet()) { Product product = new Product(); + //update the meta.json first + ghContentEntity.getValue() + .sort((file1, file2) -> GitHubUtils.sortMetaJsonFirst(file1.getName(), file2.getName())); for (var content : ghContentEntity.getValue()) { ProductFactory.mappingByGHContent(product, content); + mappingLogoFromGHContent(product, content); + } + if (productRepository.findById(product.getId()).isPresent()) { + return; } if (StringUtils.isNotBlank(product.getRepositoryName())) { updateProductCompatibility(product); getProductContents(product); + } else { + updateProductContentForNonStandardProduct(ghContentEntity, product); } + transferComputedDataFromDB(product); productRepository.save(product); - }); + } } - private void getProductContents(Product product) { - try { - GHRepository productRepo = gitHubService.getRepository(product.getRepositoryName()); - updateProductFromReleaseTags(product, productRepo); - } catch (IOException e) { - log.error("Cannot find product repository {} {}", product.getRepositoryName(), e); + private void mappingLogoFromGHContent(Product product, GHContent ghContent) { + if (StringUtils.endsWith(ghContent.getName(), LOGO_FILE)) { + Optional.ofNullable(imageService.mappingImageFromGHContent(product, ghContent, true)) + .ifPresent(image -> product.setLogoId(image.getId())); } } private void updateProductFromReleaseTags(Product product, GHRepository productRepo) { List productModuleContents = new ArrayList<>(); List ghTags = getProductReleaseTags(product); - GHTag lastTag = CollectionUtils.firstElement(ghTags); - + GHTag lastTag = MavenVersionComparator.findHighestTag(ghTags); if (lastTag == null || lastTag.getName().equals(product.getNewestReleaseVersion())) { return; } - - getPublishedDateFromLatestTag(product, - lastTag); + product.setNewestPublishedDate(getPublishedDateFromLatestTag(lastTag)); product.setNewestReleaseVersion(lastTag.getName()); - - if (!CollectionUtils.isEmpty(product.getReleasedVersions())) { - List currentTags = VersionUtils.getReleaseTagsFromProduct(product); - ghTags = ghTags.stream().filter(t -> !currentTags.contains(t.getName())).toList(); + List currentTags = VersionUtils.getReleaseTagsFromProduct(product); + if (CollectionUtils.isEmpty(currentTags)) { + currentTags = productModuleContentRepository.findTagsByProductId(product.getId()); } + ghTags = ghTags.stream().filter(filterNonPersistGhTagName(currentTags)).toList(); for (GHTag ghTag : ghTags) { ProductModuleContent productModuleContent = @@ -386,35 +437,33 @@ private void updateProductFromReleaseTags(Product product, GHRepository productR } } - private void getPublishedDateFromLatestTag(Product product, GHTag lastTag) { + private Date getPublishedDateFromLatestTag(GHTag lastTag) { try { - product.setNewestPublishedDate(lastTag.getCommit().getCommitDate()); - } catch (IOException e) { + return lastTag.getCommit().getCommitDate(); + } catch (Exception e) { log.error("Fail to get commit date ", e); } + return null; } private void updateProductCompatibility(Product product) { if (StringUtils.isNotBlank(product.getCompatibility())) { return; } - String oldestTag = - getProductReleaseTags(product).stream().map(tag -> tag.getName().replaceAll(NON_NUMERIC_CHAR, Strings.EMPTY)) - .distinct().sorted(Comparator.reverseOrder()).reduce((tag1, tag2) -> tag2).orElse(null); - if (oldestTag != null) { - String compatibility = getCompatibilityFromOldestTag(oldestTag); + String oldestVersion = VersionUtils.getOldestVersion(getProductReleaseTags(product)); + if (oldestVersion != null) { + String compatibility = getCompatibilityFromOldestTag(oldestVersion); product.setCompatibility(compatibility); } } private List getProductReleaseTags(Product product) { - List tags = new ArrayList<>(); try { - tags = gitHubService.getRepositoryTags(product.getRepositoryName()); + return gitHubService.getRepositoryTags(product.getRepositoryName()); } catch (IOException e) { log.error("Cannot get tag list of product ", e); } - return tags; + return List.of(); } // Cover 3 cases after removing non-numeric characters (8, 11.1 and 10.0.2) @@ -498,6 +547,7 @@ public List refineOrderedListOfProductsInCustomSort(List ordere } Product product = productOptional.get(); product.setCustomOrder(descendingOrder--); + productRepository.save(product); productEntries.add(product); } @@ -508,4 +558,11 @@ public void removeFieldFromAllProductDocuments(String fieldName) { Update update = new Update().unset(fieldName); mongoTemplate.updateMulti(new Query(), update, Product.class); } + + public void transferComputedDataFromDB(Product product) { + productRepository.findById(product.getId()).ifPresent(persistedData -> + ProductFactory.transferComputedPersistedDataToProduct(persistedData, product) + ); + } + } diff --git a/marketplace-service/src/main/java/com/axonivy/market/util/ImageUtils.java b/marketplace-service/src/main/java/com/axonivy/market/util/ImageUtils.java new file mode 100644 index 000000000..ddab4a2fe --- /dev/null +++ b/marketplace-service/src/main/java/com/axonivy/market/util/ImageUtils.java @@ -0,0 +1,60 @@ +package com.axonivy.market.util; + +import static com.axonivy.market.constants.CommonConstants.IMAGE_ID_PREFIX; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import com.axonivy.market.controller.ImageController; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.logging.log4j.util.Strings; +import org.springframework.hateoas.Link; +import com.axonivy.market.entity.ProductModuleContent; + +public class ImageUtils { + public static final String IMAGE_ID_FORMAT_PATTERN = "imageId-\\w+"; + private static final Pattern PATTERN = Pattern.compile(IMAGE_ID_FORMAT_PATTERN); + + private ImageUtils() { + } + + public static ProductModuleContent mappingImageForProductModuleContent(ProductModuleContent productModuleContent) { + if (ObjectUtils.isEmpty(productModuleContent)) { + return null; + } + mappingImageUrl(productModuleContent.getDescription()); + mappingImageUrl(productModuleContent.getDemo()); + mappingImageUrl(productModuleContent.getSetup()); + return productModuleContent; + } + + private static void mappingImageUrl(Map content) { + if (ObjectUtils.isEmpty(content)) { + return; + } + content.forEach((key, value) -> { + List imageIds = extractAllImageIds(value); + for (String imageId : imageIds) { + String rawId = imageId.replace(IMAGE_ID_PREFIX, Strings.EMPTY); + Link link = linkTo(methodOn(ImageController.class).findImageById(rawId)).withSelfRel(); + value = value.replace(imageId, link.getHref()); + } + content.put(key, value); + }); + } + + private static List extractAllImageIds(String content) { + List result = new ArrayList<>(); + Matcher matcher = PATTERN.matcher(content); + while (matcher.find()) { + var foundImgTag = matcher.group(); + result.add(foundImgTag); + } + return result; + } +} diff --git a/marketplace-service/src/main/java/com/axonivy/market/util/VersionUtils.java b/marketplace-service/src/main/java/com/axonivy/market/util/VersionUtils.java index dc580a9c4..ea4bb7963 100644 --- a/marketplace-service/src/main/java/com/axonivy/market/util/VersionUtils.java +++ b/marketplace-service/src/main/java/com/axonivy/market/util/VersionUtils.java @@ -8,15 +8,18 @@ import com.axonivy.market.enums.NonStandardProduct; import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.util.Strings; +import org.kohsuke.github.GHTag; import org.springframework.util.CollectionUtils; import java.util.ArrayList; import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.stream.Stream; public class VersionUtils { + public static final String NON_NUMERIC_CHAR = "[^0-9.]"; + private VersionUtils() { } public static List getVersionsToDisplay(List versions, Boolean isShowDevVersion, String designerVersion) { @@ -112,8 +115,17 @@ public static String convertVersionToTag(String productId, String version) { return GitHubConstants.STANDARD_TAG_PREFIX.concat(version); } + public static String getOldestVersion(List tags) { + String result = StringUtils.EMPTY; + if (!CollectionUtils.isEmpty(tags)) { + List releasedTags = tags.stream().map(tag -> tag.getName().replaceAll(NON_NUMERIC_CHAR, Strings.EMPTY)) + .distinct().sorted(new LatestVersionComparator()).toList(); + return CollectionUtils.lastElement(releasedTags); + } + return result; + } public static List getReleaseTagsFromProduct(Product product) { - if (Objects.isNull(product)) { + if (Objects.isNull(product) || CollectionUtils.isEmpty(product.getReleasedVersions())) { return new ArrayList<>(); } return product.getReleasedVersions().stream().map(version -> convertVersionToTag(product.getId(), version)).toList(); diff --git a/marketplace-service/src/main/resources/application.properties b/marketplace-service/src/main/resources/application.properties index c8ea1172a..9dc5a98cd 100644 --- a/marketplace-service/src/main/resources/application.properties +++ b/marketplace-service/src/main/resources/application.properties @@ -3,18 +3,17 @@ spring.data.mongodb.uri=mongodb://${MONGODB_USERNAME}:${MONGODB_PASSWORD}@${MONG spring.data.mongodb.database=${MONGODB_DATABASE} server.port=8080 logging.level.org.springframework.web=warn -request.header=ivy server.forward-headers-strategy=framework springdoc.api-docs.path=/api-docs springdoc.swagger-ui.path=/swagger-ui.html market.cors.allowed.origin.maxAge=3600 market.cors.allowed.origin.patterns=${MARKET_CORS_ALLOWED_ORIGIN} -synchronized.installation.counts.path=/home/data/market-installation.json +synchronized.installation.counts.path=/data/market-installation.json market.github.market.branch=${MARKET_GITHUB_MARKET_BRANCH} market.github.token=${MARKET_GITHUB_TOKEN} market.github.oauth2-clientId=${MARKET_GITHUB_OAUTH_APP_CLIENT_ID} market.github.oauth2-clientSecret=${MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET} jwt.secret=${MARKET_JWT_SECRET_KEY} jwt.expiration=365 -logging.level.org.springframework.data.mongodb.core.MongoTemplate=DEBUG +logging.level.org.springframework.data.mongodb.core.MongoTemplate=${MARKET_MONGO_LOG_LEVEL} spring.jackson.serialization.indent_output=true \ No newline at end of file diff --git a/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java b/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java index d53fddf43..35ca031e0 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java +++ b/marketplace-service/src/test/java/com/axonivy/market/BaseSetup.java @@ -2,8 +2,10 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; +import com.axonivy.market.entity.ProductDesignerInstallation; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -41,4 +43,20 @@ protected Page createPageProductsMock() { mockProducts.add(mockProduct); return new PageImpl<>(mockProducts); } + + protected List createProductDesignerInstallationsMock() { + var mockProductDesignerInstallations = new ArrayList(); + ProductDesignerInstallation mockProductDesignerInstallation = new ProductDesignerInstallation(); + mockProductDesignerInstallation.setProductId(SAMPLE_PRODUCT_ID); + mockProductDesignerInstallation.setDesignerVersion("10.0.22"); + mockProductDesignerInstallation.setInstallationCount(50); + mockProductDesignerInstallations.add(mockProductDesignerInstallation); + + mockProductDesignerInstallation = new ProductDesignerInstallation(); + mockProductDesignerInstallation.setProductId(SAMPLE_PRODUCT_ID); + mockProductDesignerInstallation.setDesignerVersion("11.4.0"); + mockProductDesignerInstallation.setInstallationCount(30); + mockProductDesignerInstallations.add(mockProductDesignerInstallation); + return mockProductDesignerInstallations; + } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/assembler/ProductDetailModelAssemblerTest.java b/marketplace-service/src/test/java/com/axonivy/market/assembler/ProductDetailModelAssemblerTest.java index 366c4962e..75c2566b5 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/assembler/ProductDetailModelAssemblerTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/assembler/ProductDetailModelAssemblerTest.java @@ -54,6 +54,6 @@ void testToModelWithRequestPathAndVersion() { void testToModelWithRequestPathAndBestMatchVersion() { ProductDetailModel model = productDetailModelAssembler.toModel(mockProduct, VERSION, RequestMappingConstants.BEST_MATCH_BY_ID_AND_VERSION); Assertions.assertTrue(model.getLink(SELF_RELATION).get().getHref().endsWith("/api/product-details/portal/10.0.19/bestmatch")); - Assertions.assertTrue(model.getMetaProductJsonUrl().endsWith("/api/product-details/productjsoncontent/portal/10.0.8")); + Assertions.assertTrue(model.getMetaProductJsonUrl().endsWith("/api/product-details/portal/10.0.8/json")); } } \ No newline at end of file diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/ImageControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/ImageControllerTest.java new file mode 100644 index 000000000..295d8aeff --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/ImageControllerTest.java @@ -0,0 +1,40 @@ +package com.axonivy.market.controller; + +import com.axonivy.market.service.ImageService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ImageControllerTest { + + @Mock + private ImageService imageService; + + @InjectMocks + private ImageController imageController; + + @Test + void test_getImageFromId() { + byte[] mockImageData = "image data".getBytes(); + when(imageService.readImage("66e2b14868f2f95b2f95549a")).thenReturn(mockImageData); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.IMAGE_PNG); + ResponseEntity expectedResult = new ResponseEntity<>(mockImageData, headers, HttpStatus.OK); + + ResponseEntity result = imageController.findImageById("66e2b14868f2f95b2f95549a"); + + assertEquals(expectedResult, result); + } + +} diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/OAuth2ControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/OAuth2ControllerTest.java index cc4ae1bf8..5e3c04f55 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/controller/OAuth2ControllerTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/OAuth2ControllerTest.java @@ -1,26 +1,28 @@ package com.axonivy.market.controller; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; - -import java.util.Map; - +import com.axonivy.market.constants.CommonConstants; +import com.axonivy.market.entity.User; +import com.axonivy.market.exceptions.model.MissingHeaderException; +import com.axonivy.market.exceptions.model.Oauth2ExchangeCodeException; +import com.axonivy.market.github.model.GitHubAccessTokenResponse; +import com.axonivy.market.github.service.GitHubService; +import com.axonivy.market.model.Oauth2AuthorizationCode; +import com.axonivy.market.service.JwtService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import com.axonivy.market.entity.User; -import com.axonivy.market.exceptions.model.MissingHeaderException; -import com.axonivy.market.exceptions.model.Oauth2ExchangeCodeException; -import com.axonivy.market.github.model.GitHubAccessTokenResponse; -import com.axonivy.market.github.service.GitHubService; -import com.axonivy.market.model.Oauth2AuthorizationCode; -import com.axonivy.market.service.JwtService; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class OAuth2ControllerTest { @@ -43,7 +45,7 @@ void setup() { } @Test - void testGitHubLogin() throws Oauth2ExchangeCodeException, MissingHeaderException { + void testGitHubLogin_Success() throws Oauth2ExchangeCodeException, MissingHeaderException { String accessToken = "sampleAccessToken"; User user = createUserMock(); String jwtToken = "sampleJwtToken"; @@ -58,6 +60,42 @@ void testGitHubLogin() throws Oauth2ExchangeCodeException, MissingHeaderExceptio assertEquals(Map.of("token", jwtToken), response.getBody()); } + @Test + void testGitHubLogin_Oauth2ExchangeCodeException() throws Oauth2ExchangeCodeException, MissingHeaderException { + when(gitHubService.getAccessToken(any(), any())).thenThrow( + new Oauth2ExchangeCodeException("invalid_grant", "Invalid authorization code")); + + ResponseEntity> response = oAuth2Controller.gitHubLogin(oauth2AuthorizationCode); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + Map body = (Map) response.getBody(); + assertEquals("invalid_grant", body.get(CommonConstants.ERROR)); + assertEquals("Invalid authorization code", body.get(CommonConstants.MESSAGE)); + } + + @Test + void testGitHubLogin_GeneralException() throws Oauth2ExchangeCodeException, MissingHeaderException { + when(gitHubService.getAccessToken(any(), any())).thenThrow(new RuntimeException("Unexpected error")); + + ResponseEntity> response = oAuth2Controller.gitHubLogin(oauth2AuthorizationCode); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + Map body = (Map) response.getBody(); + assertTrue(body.containsKey(CommonConstants.MESSAGE)); + assertEquals("Unexpected error", body.get(CommonConstants.MESSAGE)); + } + + @Test + void testGitHubLogin_EmptyAuthorizationCode() { + oauth2AuthorizationCode.setCode(null); + + ResponseEntity> response = oAuth2Controller.gitHubLogin(oauth2AuthorizationCode); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + Map body = (Map) response.getBody(); + assertTrue(body.containsKey(CommonConstants.MESSAGE)); + } + private User createUserMock() { User user = new User(); user.setId("userId"); @@ -73,4 +111,4 @@ private GitHubAccessTokenResponse createGitHubAccessTokenResponseMock() { gitHubAccessTokenResponse.setAccessToken("sampleAccessToken"); return gitHubAccessTokenResponse; } -} \ No newline at end of file +} diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/ProductDesignerInstallationControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/ProductDesignerInstallationControllerTest.java new file mode 100644 index 000000000..b8fd3f553 --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/ProductDesignerInstallationControllerTest.java @@ -0,0 +1,39 @@ +package com.axonivy.market.controller; + +import com.axonivy.market.model.DesignerInstallation; +import com.axonivy.market.service.ProductDesignerInstallationService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Objects; + +@ExtendWith(MockitoExtension.class) +class ProductDesignerInstallationControllerTest { + public static final String DESIGNER_VERSION = "11.4.0"; + + @Mock + ProductDesignerInstallationService productDesignerInstallationService; + + @InjectMocks + private ProductDesignerInstallationController productDesignerInstallationController; + + @Test + void testGetProductDesignerInstallationByProductId() { + List models = List.of(new DesignerInstallation(DESIGNER_VERSION, 5)); + Mockito.when(productDesignerInstallationService.findByProductId(Mockito.anyString())).thenReturn(models); + ResponseEntity> result = productDesignerInstallationController.getProductDesignerInstallationByProductId("portal"); + Assertions.assertEquals(HttpStatus.OK, result.getStatusCode()); + Assertions.assertEquals(1, Objects.requireNonNull(result.getBody()).size()); + Assertions.assertEquals(DESIGNER_VERSION, result.getBody().get(0).getDesignerVersion()); + Assertions.assertEquals(5, result.getBody().get(0).getNumberOfDownloads()); + Assertions.assertEquals(models, result.getBody()); + } +} diff --git a/marketplace-service/src/test/java/com/axonivy/market/controller/ProductDetailsControllerTest.java b/marketplace-service/src/test/java/com/axonivy/market/controller/ProductDetailsControllerTest.java index 766a1611f..42ae3db76 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/controller/ProductDetailsControllerTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/controller/ProductDetailsControllerTest.java @@ -14,6 +14,8 @@ import com.axonivy.market.constants.RequestMappingConstants; import com.axonivy.market.entity.ProductJsonContent; import com.axonivy.market.model.VersionAndUrlModel; +import com.axonivy.market.service.ImageService; +import com.axonivy.market.service.ProductDesignerInstallationService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -38,9 +40,15 @@ class ProductDetailsControllerTest { @Mock private ProductService productService; + @Mock + private ImageService imageService; + @Mock VersionService versionService; + @Mock + ProductDesignerInstallationService productDesignerInstallationService; + @Mock private ProductDetailModelAssembler detailModelAssembler; @@ -116,9 +124,9 @@ void testFindProductVersionsById() { @Test void testSyncInstallationCount() { - when(productService.updateInstallationCountForProduct("google-maps-connector")).thenReturn(1); + when(productService.updateInstallationCountForProduct("google-maps-connector", "10.0.20")).thenReturn(1); - var result = productDetailsController.syncInstallationCount("google-maps-connector"); + var result = productDetailsController.syncInstallationCount("google-maps-connector", "10.0.20"); assertEquals(1, result.getBody()); } diff --git a/marketplace-service/src/test/java/com/axonivy/market/factory/ProductFactoryTest.java b/marketplace-service/src/test/java/com/axonivy/market/factory/ProductFactoryTest.java index 8aaa42318..24aa148c7 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/factory/ProductFactoryTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/factory/ProductFactoryTest.java @@ -1,6 +1,6 @@ package com.axonivy.market.factory; -import com.axonivy.market.constants.CommonConstants; +import com.axonivy.market.constants.ProductJsonConstants; import com.axonivy.market.entity.Product; import com.axonivy.market.github.model.Meta; import org.junit.jupiter.api.Assertions; @@ -11,6 +11,7 @@ import java.io.IOException; import java.io.InputStream; +import java.util.List; import static com.axonivy.market.constants.CommonConstants.SLASH; import static com.axonivy.market.constants.MetaConstants.META_FILE; @@ -41,15 +42,14 @@ void testMappingByGHContent() throws IOException { } @Test - void testMappingLogo() throws IOException { + void testMappingLogo() { Product product = new Product(); GHContent content = mock(GHContent.class); - when(content.getName()).thenReturn(CommonConstants.LOGO_FILE); + when(content.getName()).thenReturn(ProductJsonConstants.LOGO_FILE); var result = ProductFactory.mappingByGHContent(product, content); assertNotEquals(null, result); - when(content.getName()).thenReturn(CommonConstants.LOGO_FILE); - when(content.getDownloadUrl()).thenReturn(DUMMY_LOGO_URL); + when(content.getName()).thenReturn(ProductJsonConstants.LOGO_FILE); result = ProductFactory.mappingByGHContent(product, content); assertNotEquals(null, result); } @@ -74,4 +74,20 @@ void testExtractSourceUrl() { Assertions.assertEquals(sourceUrl, product.getRepositoryName()); Assertions.assertEquals(sourceUrl, product.getSourceUrl()); } -} + + @Test + void testTransferComputedData() { + String initialVersion ="10.0.2"; + Product product = new Product(); + Product persistedData = new Product(); + persistedData.setCustomOrder(1); + persistedData.setReleasedVersions(List.of(initialVersion)); + persistedData.setNewestReleaseVersion(initialVersion); + + ProductFactory.transferComputedPersistedDataToProduct(persistedData,product); + assertEquals(1,product.getCustomOrder()); + assertEquals(initialVersion, product.getNewestReleaseVersion()); + assertEquals(1, product.getReleasedVersions().size()); + assertEquals(initialVersion, product.getReleasedVersions().get(0)); + } +} \ No newline at end of file diff --git a/marketplace-service/src/test/java/com/axonivy/market/handler/ExceptionHandlersTest.java b/marketplace-service/src/test/java/com/axonivy/market/handler/ExceptionHandlersTest.java index ad5db1617..d440e0215 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/handler/ExceptionHandlersTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/handler/ExceptionHandlersTest.java @@ -3,7 +3,10 @@ import com.axonivy.market.exceptions.ExceptionHandlers; import com.axonivy.market.exceptions.model.InvalidParamException; import com.axonivy.market.exceptions.model.MissingHeaderException; +import com.axonivy.market.exceptions.model.NoContentException; import com.axonivy.market.exceptions.model.NotFoundException; +import com.axonivy.market.exceptions.model.Oauth2ExchangeCodeException; +import com.axonivy.market.exceptions.model.UnauthorizedException; import com.axonivy.market.model.Message; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -47,10 +50,31 @@ void testHandleNotFoundException() { assertEquals(HttpStatus.NOT_FOUND, responseEntity.getStatusCode()); } + @Test + void testHandleNoContentException() { + var noContentException = mock(NoContentException.class); + var responseEntity = exceptionHandlers.handleNoContentException(noContentException); + assertEquals(HttpStatus.NO_CONTENT, responseEntity.getStatusCode()); + } + @Test void testHandleInvalidException() { var invalidParamException = mock(InvalidParamException.class); var responseEntity = exceptionHandlers.handleInvalidException(invalidParamException); assertEquals(HttpStatus.BAD_REQUEST, responseEntity.getStatusCode()); } + + @Test + void testHandleOauth2ExchangeCodeException() { + var oauth2ExchangeCodeException = mock(Oauth2ExchangeCodeException.class); + var responseEntity = exceptionHandlers.handleOauth2ExchangeCodeException(oauth2ExchangeCodeException); + assertEquals(HttpStatus.BAD_REQUEST, responseEntity.getStatusCode()); + } + + @Test + void testHandleUnauthorizedException() { + var unauthorizedException = mock(UnauthorizedException.class); + var responseEntity = exceptionHandlers.handleUnauthorizedException(unauthorizedException); + assertEquals(HttpStatus.UNAUTHORIZED, responseEntity.getStatusCode()); + } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/repository/impl/CustomProductRepositoryImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/repository/impl/CustomProductRepositoryImplTest.java index 4d9fb26db..3938e5d54 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/repository/impl/CustomProductRepositoryImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/repository/impl/CustomProductRepositoryImplTest.java @@ -3,6 +3,7 @@ import com.axonivy.market.BaseSetup; import com.axonivy.market.constants.MongoDBConstants; import com.axonivy.market.entity.Product; +import com.axonivy.market.entity.ProductDesignerInstallation; import com.axonivy.market.repository.ProductModuleContentRepository; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -132,4 +133,10 @@ void testUpdateInitialCount() { repo.updateInitialCount(ID, initialCount); verify(mongoTemplate).updateFirst(any(Query.class), eq(new Update().inc("InstallationCount", initialCount).set("SynchronizedInstallationCount", true)), eq(Product.class)); } + + @Test + void testIncreaseInstallationCountForProductByDesignerVersion() { + repo.increaseInstallationCountForProductByDesignerVersion("portal", "10.0.20"); + verify(mongoTemplate).upsert(any(Query.class), any(Update.class), eq(ProductDesignerInstallation.class)); + } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/FeedbackServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/FeedbackServiceImplTest.java index b2f6cfded..45be07c10 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/FeedbackServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/FeedbackServiceImplTest.java @@ -4,6 +4,7 @@ import com.axonivy.market.entity.Product; import com.axonivy.market.entity.User; import com.axonivy.market.enums.ErrorCode; +import com.axonivy.market.exceptions.model.NoContentException; import com.axonivy.market.exceptions.model.NotFoundException; import com.axonivy.market.model.FeedbackModel; import com.axonivy.market.model.FeedbackModelRequest; @@ -133,7 +134,7 @@ void testFindFeedback_NotFound() { } @Test - void testFindFeedbackByUserIdAndProductId() throws NotFoundException { + void testFindFeedbackByUserIdAndProductId() { String productId = "product1"; when(userRepository.findById(userId)).thenReturn(Optional.of(new User())); @@ -150,21 +151,30 @@ void testFindFeedbackByUserIdAndProductId() throws NotFoundException { } @Test - void testFindFeedbackByUserIdAndProductId_NotFound() { + void testFindFeedbackByUserIdAndProductId_NoContent() { String productId = "product1"; - - when(userRepository.findById(userId)).thenReturn(Optional.of(new User())); + userId = ""; when(productRepository.findById(productId)).thenReturn(Optional.of(new Product())); when(feedbackRepository.findByUserIdAndProductId(userId, productId)).thenReturn(null); - NotFoundException exception = assertThrows(NotFoundException.class, + NoContentException exception = assertThrows(NoContentException.class, () -> feedbackService.findFeedbackByUserIdAndProductId(userId, productId)); - assertEquals(ErrorCode.FEEDBACK_NOT_FOUND.getCode(), exception.getCode()); - verify(userRepository, times(1)).findById(userId); + assertEquals(ErrorCode.NO_FEEDBACK_OF_USER_FOR_PRODUCT.getCode(), exception.getCode()); verify(productRepository, times(1)).findById(productId); verify(feedbackRepository, times(1)).findByUserIdAndProductId(userId, productId); } + @Test + void testFindFeedbackByUserIdAndProductId_NotFound() { + userId = "notFoundUser"; + + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + NotFoundException exception = assertThrows(NotFoundException.class, + () -> feedbackService.findFeedbackByUserIdAndProductId(userId, "product")); + assertEquals(ErrorCode.USER_NOT_FOUND.getCode(), exception.getCode()); + verify(userRepository, times(1)).findById(userId); + } + @Test void testUpsertFeedback_Insert() throws NotFoundException { String productId = "product1"; diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyProductRepoServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyProductRepoServiceImplTest.java index 8a58b28a3..b4ebfce5e 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyProductRepoServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/GHAxonIvyProductRepoServiceImplTest.java @@ -1,16 +1,26 @@ package com.axonivy.market.service.impl; -import com.axonivy.market.constants.CommonConstants; -import com.axonivy.market.constants.ProductJsonConstants; -import com.axonivy.market.constants.ReadmeConstants; -import com.axonivy.market.entity.Product; -import com.axonivy.market.entity.ProductJsonContent; -import com.axonivy.market.enums.Language; -import com.axonivy.market.github.model.MavenArtifact; -import com.axonivy.market.github.service.GitHubService; -import com.axonivy.market.github.service.impl.GHAxonIvyProductRepoServiceImpl; -import com.axonivy.market.repository.ProductJsonContentRepository; -import com.fasterxml.jackson.databind.JsonNode; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -26,25 +36,19 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import com.axonivy.market.constants.CommonConstants; +import com.axonivy.market.constants.ProductJsonConstants; +import com.axonivy.market.constants.ReadmeConstants; +import com.axonivy.market.entity.Image; +import com.axonivy.market.entity.Product; +import com.axonivy.market.entity.ProductJsonContent; +import com.axonivy.market.enums.Language; +import com.axonivy.market.github.model.MavenArtifact; +import com.axonivy.market.github.service.GitHubService; +import com.axonivy.market.github.service.impl.GHAxonIvyProductRepoServiceImpl; +import com.axonivy.market.repository.ProductJsonContentRepository; +import com.axonivy.market.service.ImageService; +import com.fasterxml.jackson.databind.JsonNode; @ExtendWith(MockitoExtension.class) class GHAxonIvyProductRepoServiceImplTest { @@ -75,6 +79,9 @@ class GHAxonIvyProductRepoServiceImplTest { @Mock GHContent content = new GHContent(); + @Mock + ImageService imageService; + @Mock ProductJsonContentRepository productJsonContentRepository; @@ -210,12 +217,18 @@ void testGetOrganization() throws IOException { @Test void testGetReadmeAndProductContentsFromTag() throws IOException { String readmeContentWithImage = "#Product-name\n Test README\n## Demo\nDemo content\n## Setup\nSetup content (image.png)"; + testGetReadmeAndProductContentsFromTagWithReadmeText(readmeContentWithImage); + String readmeContentWithoutHashProductName = "Test README\n## Demo\nDemo content\n## Setup\nSetup content (image.png)"; + testGetReadmeAndProductContentsFromTagWithReadmeText(readmeContentWithoutHashProductName); + } + private void testGetReadmeAndProductContentsFromTagWithReadmeText(String readmeContentWithImage) throws IOException { GHContent mockContent = createMockProductFolderWithProductJson(); getReadmeInputStream(readmeContentWithImage, mockContent); InputStream inputStream = getMockInputStream(); Mockito.when(axonivyProductRepoServiceImpl.extractedContentStream(any())).thenReturn(inputStream); + Mockito.when(imageService.mappingImageFromGHContent(any(),any(),anyBoolean())).thenReturn(mockImage()); var result = axonivyProductRepoServiceImpl.getReadmeAndProductContentsFromTag(createMockProduct(), ghRepository, RELEASE_TAG); @@ -226,7 +239,17 @@ void testGetReadmeAndProductContentsFromTag() throws IOException { assertEquals("iar", result.getType()); assertEquals("Test README", result.getDescription().get(Language.EN.getValue())); assertEquals("Demo content", result.getDemo().get(Language.EN.getValue())); - assertEquals("Setup content (https://raw.githubusercontent.com/image.png)", result.getSetup().get(Language.EN.getValue())); + assertEquals("Setup content (imageId-66e2b14868f2f95b2f95549a)", result.getSetup().get(Language.EN.getValue())); + } + + public static Image mockImage() { + Image image = new Image(); + image.setId("66e2b14868f2f95b2f95549a"); + image.setSha("914d9b6956db7a1404622f14265e435f36db81fa"); + image.setProductId("amazon-comprehend"); + image.setImageUrl( + "https://raw.githubusercontent.com/amazon-comprehend-connector-product/images/comprehend-demo-sentiment.png"); + return image; } @Test @@ -236,8 +259,7 @@ void testGetReadmeAndProductContentFromTag_ImageFromFolder() throws IOException GHContent mockImageFile = mock(GHContent.class); when(mockImageFile.getName()).thenReturn(ReadmeConstants.IMAGES, IMAGE_NAME); when(mockImageFile.isDirectory()).thenReturn(true); - when(mockImageFile.getDownloadUrl()).thenReturn(IMAGE_DOWNLOAD_URL); - + Mockito.when(imageService.mappingImageFromGHContent(any(),any() ,anyBoolean())).thenReturn(mockImage()); PagedIterable pagedIterable = Mockito.mock(String.valueOf(GHContent.class)); when(mockImageFile.listDirectoryContent()).thenReturn(pagedIterable); when(pagedIterable.toList()).thenReturn(List.of(mockImageFile)); @@ -246,7 +268,7 @@ void testGetReadmeAndProductContentFromTag_ImageFromFolder() throws IOException List.of(mockImageFile), readmeContentWithImageFolder); assertEquals( - "#Product-name\n Test README\n## Demo\nDemo content\n## Setup\nSetup content (https://raw.githubusercontent.com/image.png)", + "#Product-name\n Test README\n## Demo\nDemo content\n## Setup\nSetup content (imageId-66e2b14868f2f95b2f95549a)", updatedReadme); } @@ -400,11 +422,10 @@ private GHContent createMockProductFolderWithProductJson() throws IOException { return mockContent; } - private static GHContent createMockProductJson() throws IOException { + private static GHContent createMockProductJson() { GHContent mockProductJson = mock(GHContent.class); when(mockProductJson.isFile()).thenReturn(true); when(mockProductJson.getName()).thenReturn(ProductJsonConstants.PRODUCT_JSON_FILE, IMAGE_NAME); - when(mockProductJson.getDownloadUrl()).thenReturn(IMAGE_DOWNLOAD_URL); return mockProductJson; } diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ImageServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ImageServiceImplTest.java new file mode 100644 index 000000000..9f95c35fa --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ImageServiceImplTest.java @@ -0,0 +1,72 @@ +package com.axonivy.market.service.impl; + +import static com.axonivy.market.constants.CommonConstants.SLASH; +import static com.axonivy.market.constants.MetaConstants.META_FILE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.io.InputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.kohsuke.github.GHContent; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.axonivy.market.entity.Image; +import com.axonivy.market.entity.Product; +import com.axonivy.market.repository.ImageRepository; + +@ExtendWith(MockitoExtension.class) +class ImageServiceImplTest { + @InjectMocks + private ImageServiceImpl imageService; + + @Mock + private ImageRepository imageRepository; + + @Captor + ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Image.class); + + @Test + void testMappingImageFromGHContent() throws IOException { + GHContent content = mock(GHContent.class); + when(content.getSha()).thenReturn("914d9b6956db7a1404622f14265e435f36db81fa"); + when(content.getDownloadUrl()).thenReturn("https://raw.githubusercontent.com/images/comprehend-demo-sentiment.png"); + + InputStream inputStream = this.getClass().getResourceAsStream(SLASH.concat(META_FILE)); + when(content.read()).thenReturn(inputStream); + + imageService.mappingImageFromGHContent(mockProduct(), content , true); + + Image expectedImage = new Image(); + expectedImage.setProductId("google-maps-connector"); + expectedImage.setSha("914d9b6956db7a1404622f14265e435f36db81fa"); + expectedImage.setImageUrl("https://raw.githubusercontent.com/images/comprehend-demo-sentiment.png"); + + verify(imageRepository).save(argumentCaptor.capture()); + + assertEquals(argumentCaptor.getValue().getProductId(),expectedImage.getProductId()); + assertEquals(argumentCaptor.getValue().getSha(),expectedImage.getSha()); + assertEquals(argumentCaptor.getValue().getImageUrl(),expectedImage.getImageUrl()); + } + + @Test + void testMappingImageFromGHContent_noGhContent() { + var result = imageService.mappingImageFromGHContent(mockProduct(), null , true); + assertNull(result); + } + + private Product mockProduct() { + return Product.builder().id("google-maps-connector") + .language("English") + .build(); + } +} diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductDesignerInstallationServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductDesignerInstallationServiceImplTest.java new file mode 100644 index 000000000..6e8998fef --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductDesignerInstallationServiceImplTest.java @@ -0,0 +1,44 @@ +package com.axonivy.market.service.impl; + +import com.axonivy.market.BaseSetup; +import com.axonivy.market.entity.ProductDesignerInstallation; +import com.axonivy.market.model.DesignerInstallation; +import com.axonivy.market.repository.ProductDesignerInstallationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProductDesignerInstallationServiceImplTest extends BaseSetup { + private List mockResultReturn; + @Mock + private ProductDesignerInstallationRepository productDesignerInstallationRepository; + + @InjectMocks + private ProductDesignerInstallationServiceImpl productDesignerInstallationServiceImpl; + + @BeforeEach + public void setup() { + mockResultReturn = createProductDesignerInstallationsMock(); + } + + @Test + void testFindByProductId() { + when(productDesignerInstallationRepository.findByProductId(any(), any())).thenReturn(this.mockResultReturn); + List results = productDesignerInstallationServiceImpl.findByProductId(BaseSetup.SAMPLE_PRODUCT_ID); + assertEquals(2,results.size()); + assertEquals("10.0.22", results.get(0).getDesignerVersion()); + assertEquals(50, results.get(0).getNumberOfDownloads()); + assertEquals("11.4.0", results.get(1).getDesignerVersion()); + assertEquals(30, results.get(1).getNumberOfDownloads()); + } +} diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java index 127eaa577..2f466ab8b 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/ProductServiceImplTest.java @@ -1,6 +1,6 @@ package com.axonivy.market.service.impl; -import static com.axonivy.market.constants.CommonConstants.LOGO_FILE; +import static com.axonivy.market.constants.ProductJsonConstants.LOGO_FILE; import static com.axonivy.market.constants.CommonConstants.SLASH; import static com.axonivy.market.constants.MetaConstants.META_FILE; import static com.axonivy.market.enums.DocumentField.SHORT_DESCRIPTIONS; @@ -11,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; @@ -34,6 +35,7 @@ import java.util.UUID; import com.axonivy.market.criteria.ProductSearchCriteria; +import com.axonivy.market.service.ImageService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -45,6 +47,7 @@ import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -88,6 +91,7 @@ class ProductServiceImplTest extends BaseSetup { private static final String SHA1_SAMPLE = "35baa89091b2452b77705da227f1a964ecabc6c8"; public static final String RELEASE_TAG = "v10.0.2"; private static final String INSTALLATION_FILE_PATH = "src/test/resources/installationCount.json"; + private static final String EMPTY_SOURCE_URL_META_JSON_FILE = "/emptySourceUrlMeta.json"; private String keyword; private String language; @@ -121,11 +125,17 @@ class ProductServiceImplTest extends BaseSetup { ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(Product.class); @Captor - ArgumentCaptor> argumentCaptorProductModuleContent; + ArgumentCaptor> argumentCaptorProductModuleContents; + + @Captor + ArgumentCaptor argumentCaptorProductModuleContent; @Mock private GHAxonIvyProductRepoService ghAxonIvyProductRepoService; + @Mock + private ImageService imageService; + @Captor ArgumentCaptor> productListArgumentCaptor; @@ -142,13 +152,17 @@ public void setup() { @Test void testUpdateInstallationCountForProduct() { - int result = productService.updateInstallationCountForProduct(null); + String designerVersion = "10.0.20"; + int result = productService.updateInstallationCountForProduct(null, designerVersion); assertEquals(0, result); Product product = mockProduct(); when(productRepository.getProductById(product.getId())).thenReturn(product); when(productRepository.increaseInstallationCount(product.getId())).thenReturn(31); - result = productService.updateInstallationCountForProduct(product.getId()); + result = productService.updateInstallationCountForProduct(product.getId(), designerVersion); + assertEquals(31, result); + + result = productService.updateInstallationCountForProduct(product.getId(), ""); assertEquals(31, result); } @@ -265,7 +279,6 @@ void testSyncProductsAsUpdateLogoFromGitHub() throws IOException { mockGitHubFile.setStatus(FileStatus.REMOVED); when(marketRepoService.fetchMarketItemsBySHA1Range(any(), any())).thenReturn(List.of(mockGitHubFile)); when(gitHubService.getGHContent(any(), anyString(), any())).thenReturn(mockGHContent); - when(productRepository.findByLogoUrl(any())).thenReturn(new Product()); // Executes result = productService.syncLatestDataFromMarketRepo(); @@ -324,18 +337,48 @@ void testSyncProductsFirstTime() throws IOException { var mockContent = mockGHContentAsMetaJSON(); InputStream inputStream = this.getClass().getResourceAsStream(SLASH.concat(META_FILE)); when(mockContent.read()).thenReturn(inputStream); + + var mockContentLogo = mockGHContentAsLogo(); + List mockMetaJsonAndLogoList = new ArrayList<>(List.of(mockContent, mockContentLogo)); + Map> mockGHContentMap = new HashMap<>(); - mockGHContentMap.put(SAMPLE_PRODUCT_ID, List.of(mockContent)); + mockGHContentMap.put(SAMPLE_PRODUCT_ID, mockMetaJsonAndLogoList); when(marketRepoService.fetchAllMarketItems()).thenReturn(mockGHContentMap); when(productModuleContentRepository.saveAll(anyList())).thenReturn(List.of(mockReadmeProductContent())); + Mockito.when(imageService.mappingImageFromGHContent(any(), any(), anyBoolean())) + .thenReturn(GHAxonIvyProductRepoServiceImplTest.mockImage()); // Executes productService.syncLatestDataFromMarketRepo(); - verify(productModuleContentRepository).saveAll(argumentCaptorProductModuleContent.capture()); + verify(productModuleContentRepository).saveAll(argumentCaptorProductModuleContents.capture()); verify(productRepository).save(argumentCaptor.capture()); - assertThat(argumentCaptorProductModuleContent.getValue()).usingRecursiveComparison() - .isEqualTo(List.of(mockReadmeProductContent())); + assertEquals(1, argumentCaptorProductModuleContents.getValue().size()); + assertThat(argumentCaptorProductModuleContents.getValue().get(0).getId()) + .isEqualTo(mockReadmeProductContent().getId()); + } + + @Test + void testSyncProductsFirstTimeWithOutSourceUrl() throws IOException { + var mockCommit = mockGHCommitHasSHA1(SHA1_SAMPLE); + when(marketRepoService.getLastCommit(anyLong())).thenReturn(mockCommit); + when(repoMetaRepository.findByRepoName(anyString())).thenReturn(null); + + var mockContent = mockGHContentAsMetaJSON(); + InputStream inputStream = this.getClass().getResourceAsStream(EMPTY_SOURCE_URL_META_JSON_FILE); + when(mockContent.read()).thenReturn(inputStream); + + var mockContentLogo = mockGHContentAsLogo(); + + Map> mockGHContentMap = new HashMap<>(); + List mockMetaJsonAndLogoList = new ArrayList<>(List.of(mockContent, mockContentLogo)); + mockGHContentMap.put(SAMPLE_PRODUCT_ID, mockMetaJsonAndLogoList); + when(marketRepoService.fetchAllMarketItems()).thenReturn(mockGHContentMap); + Mockito.when(imageService.mappingImageFromGHContent(any(),any(),anyBoolean())).thenReturn(GHAxonIvyProductRepoServiceImplTest.mockImage()); + // Executes + productService.syncLatestDataFromMarketRepo(); + verify(productModuleContentRepository).save(argumentCaptorProductModuleContent.capture()); + assertEquals("1.0", argumentCaptorProductModuleContent.getValue().getTag()); } @Test @@ -352,10 +395,10 @@ void testSyncProductsSecondTime() throws IOException { GHTag mockTag = mock(GHTag.class); when(mockTag.getName()).thenReturn("v10.0.2"); - when(mockTag.getCommit()).thenReturn(mockGHCommit); GHTag mockTag2 = mock(GHTag.class); when(mockTag2.getName()).thenReturn("v10.0.3"); + when(mockTag2.getCommit()).thenReturn(mockGHCommit); when(mockGHCommit.getCommitDate()).thenReturn(new Date()); when(gitHubService.getRepositoryTags(anyString())).thenReturn(Arrays.asList(mockTag, mockTag2)); @@ -371,10 +414,10 @@ void testSyncProductsSecondTime() throws IOException { // Executes productService.syncLatestDataFromMarketRepo(); - verify(productModuleContentRepository).saveAll(argumentCaptorProductModuleContent.capture()); + verify(productModuleContentRepository).saveAll(argumentCaptorProductModuleContents.capture()); verify(productRepository).save(argumentCaptor.capture()); - assertThat(argumentCaptor.getValue().getProductModuleContent()).usingRecursiveComparison() - .isEqualTo(mockReadmeProductContent()); + assertThat(argumentCaptor.getValue().getProductModuleContent().getId()) + .isEqualTo(mockReadmeProductContent().getId()); } @Test @@ -395,7 +438,7 @@ void testSyncNullProductModuleContent() { var mockCommit = mockGHCommitHasSHA1(SHA1_SAMPLE); when(marketRepoService.getLastCommit(anyLong())).thenReturn(mockCommit); when(repoMetaRepository.findByRepoName(anyString())).thenReturn(null); - + Map> mockGHContentMap = new HashMap<>(); mockGHContentMap.put(SAMPLE_PRODUCT_ID, new ArrayList<>()); when(marketRepoService.fetchAllMarketItems()).thenReturn(mockGHContentMap); @@ -573,9 +616,15 @@ private GHContent mockGHContentAsMetaJSON() { return mockGHContent; } + private GHContent mockGHContentAsLogo() { + var mockGHContent = mock(GHContent.class); + when(mockGHContent.getName()).thenReturn("logo.png"); + return mockGHContent; + } + private ProductModuleContent mockReadmeProductContent() { ProductModuleContent productModuleContent = new ProductModuleContent(); - productModuleContent.setId("123"); + productModuleContent.setId("amazon-comprehendv-10.0.2"); productModuleContent.setTag("v10.0.2"); productModuleContent.setName("Amazon Comprehend"); Map description = new HashMap<>(); diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/SchedulingTasksTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/SchedulingTasksTest.java index 04cf00ce3..0e2b26bcd 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/SchedulingTasksTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/SchedulingTasksTest.java @@ -14,7 +14,7 @@ @SpringBootTest(properties = { "MONGODB_USERNAME=user", "MONGODB_PASSWORD=password", "MONGODB_HOST=mongoHost", "MONGODB_DATABASE=product", "MARKET_GITHUB_OAUTH_APP_CLIENT_ID=clientId", "MARKET_GITHUB_OAUTH_APP_CLIENT_SECRET=clientSecret", "MARKET_JWT_SECRET_KEY=jwtSecret", - "MARKET_CORS_ALLOWED_ORIGIN=*", "MARKET_GITHUB_MARKET_BRANCH=master" }) + "MARKET_CORS_ALLOWED_ORIGIN=*", "MARKET_GITHUB_MARKET_BRANCH=master", "MARKET_MONGO_LOG_LEVEL=DEBUG"}) class SchedulingTasksTest { @SpyBean diff --git a/marketplace-service/src/test/java/com/axonivy/market/service/impl/VersionServiceImplTest.java b/marketplace-service/src/test/java/com/axonivy/market/service/impl/VersionServiceImplTest.java index e33f77ddf..a45e845fa 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/service/impl/VersionServiceImplTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/service/impl/VersionServiceImplTest.java @@ -425,11 +425,10 @@ void testGetVersionsForDesigner() { Assertions.assertEquals(result.stream().map(VersionAndUrlModel::getVersion).toList(), List.of("11.3.0", "11.1.1", "11.1.0", "10.0.2")); - Assertions.assertEquals("/api/product-details/productjsoncontent/11.3.0/11.3.0", result.get(0).getUrl()); - Assertions.assertEquals("/api/product-details/productjsoncontent/11.3.0/11.1.1", result.get(1).getUrl()); - Assertions.assertEquals("/api/product-details/productjsoncontent/11.3.0/11.1.0", result.get(2).getUrl()); - Assertions.assertEquals("/api/product-details/productjsoncontent/11.3.0/10.0.2", result.get(3).getUrl()); - + Assertions.assertEquals("/api/product-details/11.3.0/11.3.0/json", result.get(0).getUrl()); + Assertions.assertEquals("/api/product-details/11.3.0/11.1.1/json", result.get(1).getUrl()); + Assertions.assertEquals("/api/product-details/11.3.0/11.1.0/json", result.get(2).getUrl()); + Assertions.assertEquals("/api/product-details/11.3.0/10.0.2/json", result.get(3).getUrl()); } @Test diff --git a/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java b/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java index 2f97404dd..376d90d6d 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/util/GitHubUtilsTest.java @@ -8,6 +8,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; +import static com.axonivy.market.constants.MetaConstants.META_FILE; +import static com.axonivy.market.constants.ProductJsonConstants.LOGO_FILE; + @ExtendWith(MockitoExtension.class) class GitHubUtilsTest { private static final String JIRA_CONNECTOR = "Jira Connector"; @@ -90,4 +93,52 @@ void testGetNonStandardImageFolder() { result = GitHubUtils.getNonStandardImageFolder(JIRA_CONNECTOR); Assertions.assertEquals("images", result); } + + @Test + void testSortMetaJsonFirst() { + int result = GitHubUtils.sortMetaJsonFirst(META_FILE, LOGO_FILE); + Assertions.assertEquals(-1, result); + + result = GitHubUtils.sortMetaJsonFirst(LOGO_FILE, META_FILE); + Assertions.assertEquals(1, result); + + result = GitHubUtils.sortMetaJsonFirst(LOGO_FILE, LOGO_FILE); + Assertions.assertEquals(0, result); + } + + @Test + void testExtractJson() { + // Test case: valid JSON inside a string + String exceptionMessage = "Error occurred: {\"message\":\"An error occurred\"}"; + String json = GitHubUtils.extractJson(exceptionMessage); + Assertions.assertEquals("{\"message\":\"An error occurred\"}", json); + + // Test case: no JSON in string + exceptionMessage = "Error occurred: no json here"; + json = GitHubUtils.extractJson(exceptionMessage); + Assertions.assertEquals(StringUtils.EMPTY, json); + + // Test case: empty string + exceptionMessage = ""; + json = GitHubUtils.extractJson(exceptionMessage); + Assertions.assertEquals(StringUtils.EMPTY, json); + } + + @Test + void testExtractMessageFromExceptionMessage() { + // Test case: valid message extraction + String exceptionMessage = "Some error occurred: {\"message\":\"Invalid input data\"}"; + String extractedMessage = GitHubUtils.extractMessageFromExceptionMessage(exceptionMessage); + Assertions.assertEquals("Invalid input data", extractedMessage); + + // Test case: no message key + exceptionMessage = "Some error occurred: {\"error\":\"Something went wrong\"}"; + extractedMessage = GitHubUtils.extractMessageFromExceptionMessage(exceptionMessage); + Assertions.assertEquals(StringUtils.EMPTY, extractedMessage); + + // Test case: empty exception message + exceptionMessage = ""; + extractedMessage = GitHubUtils.extractMessageFromExceptionMessage(exceptionMessage); + Assertions.assertEquals(StringUtils.EMPTY, extractedMessage); + } } diff --git a/marketplace-service/src/test/java/com/axonivy/market/util/ImageUtilsTest.java b/marketplace-service/src/test/java/com/axonivy/market/util/ImageUtilsTest.java new file mode 100644 index 000000000..07e7a218e --- /dev/null +++ b/marketplace-service/src/test/java/com/axonivy/market/util/ImageUtilsTest.java @@ -0,0 +1,41 @@ +package com.axonivy.market.util; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.axonivy.market.entity.ProductModuleContent; + +@ExtendWith(MockitoExtension.class) +class ImageUtilsTest { + + @Test + void testMappingImageForProductModuleContent() { + String expectedValue = "Login or create a new account.[demo-process](/api/image/66e2b13c68f2f95b2f95548c)"; + var result = ImageUtils.mappingImageForProductModuleContent(mockProductModuleContent()); + Assertions.assertEquals(expectedValue, result.getDescription().get("en")); + Assertions.assertEquals(expectedValue, result.getSetup().get("de")); + } + + private ProductModuleContent mockProductModuleContent(){ + ProductModuleContent productModuleContent = new ProductModuleContent(); + productModuleContent.setDescription(mockDescriptionForProductModuleContent()); + productModuleContent.setDemo(null); + productModuleContent.setSetup(mockDescriptionForProductModuleContent()); + + return productModuleContent; + } + + private Map mockDescriptionForProductModuleContent(){ + Map mutableMap = new HashMap<>(); + mutableMap.put("en", "Login or create a new account.[demo-process](imageId-66e2b13c68f2f95b2f95548c)"); + mutableMap.put("de", "Login or create a new account.[demo-process](imageId-66e2b13c68f2f95b2f95548c)"); + return mutableMap; + + } + +} diff --git a/marketplace-service/src/test/java/com/axonivy/market/util/VersionUtilsTest.java b/marketplace-service/src/test/java/com/axonivy/market/util/VersionUtilsTest.java index f02ed171b..a1686746f 100644 --- a/marketplace-service/src/test/java/com/axonivy/market/util/VersionUtilsTest.java +++ b/marketplace-service/src/test/java/com/axonivy/market/util/VersionUtilsTest.java @@ -5,10 +5,13 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.kohsuke.github.GHTag; import org.mockito.InjectMocks; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; @ExtendWith(MockitoExtension.class) @@ -148,4 +151,32 @@ void testConvertTagsToVersions() { Assertions.assertEquals("10.0.2", results.get(1)); } + @Test + void testGetOldestVersionWithEmptyTags() { + List tags = List.of(); + + String oldestTag = VersionUtils.getOldestVersion(tags); + + Assertions.assertEquals(StringUtils.EMPTY, oldestTag); + } + + @Test + void testGetOldestVersionWithNullTags() { + String oldestTag = VersionUtils.getOldestVersion(null); + + Assertions.assertEquals(StringUtils.EMPTY, oldestTag); + } + + @Test + void testGetOldestVersionWithNonNumericCharacters() { + GHTag tag1 = Mockito.mock(GHTag.class); + GHTag tag2 = Mockito.mock(GHTag.class); + Mockito.when(tag1.getName()).thenReturn("v1.0"); + Mockito.when(tag2.getName()).thenReturn("2.1"); + List tags = Arrays.asList(tag1, tag2); + + String oldestTag = VersionUtils.getOldestVersion(tags); + + Assertions.assertEquals("1.0", oldestTag); // Assuming the replacement of non-numeric characters works correctly + } } diff --git a/marketplace-service/src/test/resources/emptySourceUrlMeta.json b/marketplace-service/src/test/resources/emptySourceUrlMeta.json new file mode 100644 index 000000000..1ca345367 --- /dev/null +++ b/marketplace-service/src/test/resources/emptySourceUrlMeta.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://json-schema.axonivy.com/market/10.0.2/meta.json", + "id": "employee-onboarding", + "version": "1.0", + "name": "Employee Onboarding", + "names": [ + { + "locale": "en", + "value": "Employee Onboarding" + }, + { + "locale": "de", + "value": "Mitarbeiter Onboarding" + } + ], + "description": "This solution helps HR managers to accelerate time-to-market for employee onboarding.", + "descriptions": [ + { + "locale": "en", + "value": "This solution helps HR managers to accelerate time-to-market for employee onboarding." + }, + { + "locale": "de", + "value": "HR-Manager können mit dieser Lösung die Time-to-Market für die Einführung neuer Mitarbeiter effektiv reduzieren." + } + ], + "type": "solution", + "cost": "paid", + "language": "EN", + "industry": "Cross-Industry", + "tags": [ + "hr" + ], + "contactUs": true +} \ No newline at end of file diff --git a/marketplace-ui/src/app/app.component.html b/marketplace-ui/src/app/app.component.html index 4bce97dd2..7ee11ade9 100644 --- a/marketplace-ui/src/app/app.component.html +++ b/marketplace-ui/src/app/app.component.html @@ -1,25 +1,32 @@ -@if(!routingQueryParamService.isDesignerEnv()){ - - - - - -} - - - - - -@if(!routingQueryParamService.isDesignerEnv()){ - -} + + @if (!routingQueryParamService.isDesignerEnv()) { + + + + + + } + + + + + + @if (!routingQueryParamService.isDesignerEnv()) { + + } -@if (loadingService.isLoading()) { - - + @if (loadingService.isLoading()) { + + + + } -} \ No newline at end of file diff --git a/marketplace-ui/src/app/app.component.scss b/marketplace-ui/src/app/app.component.scss index bc547d095..d3196b6f5 100644 --- a/marketplace-ui/src/app/app.component.scss +++ b/marketplace-ui/src/app/app.component.scss @@ -42,3 +42,20 @@ footer { width: 4rem; height: 4rem; } + +.header-mobile { + z-index: 1; + position: absolute; + background: var(--info-dropdown-bg); + top: 0; + left: 0; + width: 100vw; + padding-bottom: 3rem; +} + +.header-mobile-container { + position: relative; + width: 100vw; + height: 100vh; + overflow: hidden; +} diff --git a/marketplace-ui/src/app/app.component.spec.ts b/marketplace-ui/src/app/app.component.spec.ts index 2a9174723..776d58bf3 100644 --- a/marketplace-ui/src/app/app.component.spec.ts +++ b/marketplace-ui/src/app/app.component.spec.ts @@ -4,9 +4,11 @@ import { FooterComponent } from './shared/components/footer/footer.component'; import { HeaderComponent } from './shared/components/header/header.component'; import { LoadingService } from './core/services/loading/loading.service'; import { RoutingQueryParamService } from './shared/services/routing.query.param.service'; -import { ActivatedRoute, RouterOutlet, NavigationStart } from '@angular/router'; +import { ActivatedRoute, RouterOutlet, NavigationStart, RouterModule, Router, NavigationError, Event } from '@angular/router'; import { of, Subject } from 'rxjs'; import { TranslateService, TranslateModule } from '@ngx-translate/core'; +import { By } from '@angular/platform-browser'; +import { ERROR_PAGE_PATH } from './shared/constants/common.constant'; describe('AppComponent', () => { let component: AppComponent; @@ -14,9 +16,13 @@ describe('AppComponent', () => { let routingQueryParamService: jasmine.SpyObj; let activatedRoute: ActivatedRoute; let navigationStartSubject: Subject; + let appElement: HTMLElement; + let router: Router; + let routerEventsSubject: Subject; beforeEach(async () => { navigationStartSubject = new Subject(); + routerEventsSubject = new Subject(); const loadingServiceSpy = jasmine.createSpyObj('LoadingService', [ 'isLoading' ]); @@ -30,13 +36,19 @@ describe('AppComponent', () => { ] ); + const routerMock = { + events: routerEventsSubject.asObservable(), + navigate: jasmine.createSpy('navigate'), + }; + await TestBed.configureTestingModule({ imports: [ AppComponent, RouterOutlet, HeaderComponent, FooterComponent, - TranslateModule.forRoot() + TranslateModule.forRoot(), + RouterModule.forRoot([]) ], providers: [ { provide: LoadingService, useValue: loadingServiceSpy }, @@ -50,7 +62,8 @@ describe('AppComponent', () => { queryParams: of({}) } }, - TranslateService + TranslateService, + { provide: Router, useValue: routerMock } ] }).compileComponents(); @@ -59,11 +72,17 @@ describe('AppComponent', () => { routingQueryParamService = TestBed.inject( RoutingQueryParamService ) as jasmine.SpyObj; - activatedRoute = TestBed.inject(ActivatedRoute); routingQueryParamService.getNavigationStartEvent.and.returnValue( navigationStartSubject.asObservable() ); + appElement = fixture.debugElement.query( + By.css('.app-container') + ).nativeElement; + + activatedRoute = TestBed.inject(ActivatedRoute); + router = TestBed.inject(Router); + fixture.detectChanges(); }); it('should create the app', () => { @@ -104,4 +123,36 @@ describe('AppComponent', () => { routingQueryParamService.checkCookieForDesignerVersion ).not.toHaveBeenCalled(); }); + + it('should hide scrollbar when burger menu is opened', () => { + component.isMobileMenuCollapsed = false; + fixture.detectChanges(); + + const headerElement = fixture.debugElement.query(By.css('.header-mobile')); + expect(headerElement).toBeTruthy(); + + expect(appElement.classList.contains('header-mobile-container')).toBeTrue(); + + const headerComputedStyle = window.getComputedStyle(appElement); + expect(headerComputedStyle.overflow).toBe('hidden'); + }); + + it('should reset header style when burger menu is closed', () => { + component.isMobileMenuCollapsed = true; + fixture.detectChanges(); + + const headerElement = fixture.debugElement.query(By.css('.header-mobile')); + expect(headerElement).toBeNull(); + + expect( + appElement.classList.contains('header-mobile-container') + ).toBeFalse(); + }); + + it('should redirect to "/error-page" on NavigationError', () => { + // Simulate a NavigationError event + const navigationError = new NavigationError(1, '/a-trust/test-url', 'Error message'); + routerEventsSubject.next(navigationError); + expect(router.navigate).toHaveBeenCalledWith([ERROR_PAGE_PATH]); + }); }); diff --git a/marketplace-ui/src/app/app.component.ts b/marketplace-ui/src/app/app.component.ts index 25ab92bc5..2d15456b4 100644 --- a/marketplace-ui/src/app/app.component.ts +++ b/marketplace-ui/src/app/app.component.ts @@ -1,14 +1,22 @@ -import { Component, inject } from '@angular/core'; -import { RouterOutlet, ActivatedRoute } from '@angular/router'; import { FooterComponent } from './shared/components/footer/footer.component'; import { HeaderComponent } from './shared/components/header/header.component'; import { LoadingService } from './core/services/loading/loading.service'; import { RoutingQueryParamService } from './shared/services/routing.query.param.service'; +import { CommonModule } from '@angular/common'; +import { ERROR_PAGE_PATH } from './shared/constants/common.constant'; +import { Component, inject } from '@angular/core'; +import { + ActivatedRoute, + NavigationError, + Router, + RouterOutlet, + Event +} from '@angular/router'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet, HeaderComponent, FooterComponent], + imports: [RouterOutlet, HeaderComponent, FooterComponent, CommonModule], templateUrl: './app.component.html', styleUrl: './app.component.scss' }) @@ -16,10 +24,17 @@ export class AppComponent { loadingService = inject(LoadingService); routingQueryParamService = inject(RoutingQueryParamService); route = inject(ActivatedRoute); + isMobileMenuCollapsed: boolean = true; - constructor() {} + constructor(private readonly router: Router) {} ngOnInit(): void { + this.router.events.subscribe((event: Event) => { + if (event instanceof NavigationError) { + this.router.navigate([ERROR_PAGE_PATH]); + } + }); + this.routingQueryParamService.getNavigationStartEvent().subscribe(() => { if (!this.routingQueryParamService.isDesignerEnv()) { this.route.queryParams.subscribe(params => { diff --git a/marketplace-ui/src/app/app.routes.ts b/marketplace-ui/src/app/app.routes.ts index 76ebff774..08f5524ec 100644 --- a/marketplace-ui/src/app/app.routes.ts +++ b/marketplace-ui/src/app/app.routes.ts @@ -1,7 +1,12 @@ import { Routes } from '@angular/router'; import { GithubCallbackComponent } from './auth/github-callback/github-callback.component'; +import { ErrorPageComponentComponent } from './shared/components/error-page-component/error-page-component.component'; export const routes: Routes = [ + { + path: 'error-page', + component: ErrorPageComponentComponent + }, { path: '', loadChildren: () => import('./modules/home/home.routes').then(m => m.routes) diff --git a/marketplace-ui/src/app/core/interceptors/api.interceptor.spec.ts b/marketplace-ui/src/app/core/interceptors/api.interceptor.spec.ts new file mode 100644 index 000000000..6870339a5 --- /dev/null +++ b/marketplace-ui/src/app/core/interceptors/api.interceptor.spec.ts @@ -0,0 +1,66 @@ +import { HttpClient, HttpHeaders, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { HttpTestingController } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { ProductComponent } from '../../modules/product/product.component'; +import { DESIGNER_COOKIE_VARIABLE } from '../../shared/constants/common.constant'; +import { apiInterceptor } from './api.interceptor'; + +describe('AuthInterceptor', () => { + let productComponent: ProductComponent; + let fixture: ComponentFixture; + let httpClient: HttpClient; + let httpTestingController: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ProductComponent, TranslateModule.forRoot()], + providers: [ + provideHttpClient(withInterceptors([apiInterceptor])), + HttpTestingController, + { + provide: ActivatedRoute, + useValue: { + queryParams: of({ + [DESIGNER_COOKIE_VARIABLE.restClientParamName]: true + }) + } + } + ] + }); + + httpClient = TestBed.inject(HttpClient); + httpTestingController = TestBed.inject(HttpTestingController); + + fixture = TestBed.createComponent(ProductComponent); + productComponent = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should throw error', () => { + const headers = new HttpHeaders({ + 'X-Requested-By': 'ivy' + }); + httpClient.get('product', { headers }).subscribe({ + next() { + fail('Expected an error, but got a response'); + }, + error(e) { + expect(e.status).not.toBe(200); + } + }); + }); + + it('should throw error with the url contains i18n', () => { + httpClient.get('assets/i18n').subscribe({ + next() { + fail('Expected an error, but got a response'); + }, + error(e) { + expect(e.status).not.toBe(200); + } + }); + }); +}); diff --git a/marketplace-ui/src/app/core/interceptors/api.interceptor.ts b/marketplace-ui/src/app/core/interceptors/api.interceptor.ts index e8ec8fa2b..19475e3d5 100644 --- a/marketplace-ui/src/app/core/interceptors/api.interceptor.ts +++ b/marketplace-ui/src/app/core/interceptors/api.interceptor.ts @@ -6,10 +6,12 @@ import { import { environment } from '../../../environments/environment'; import { LoadingService } from '../services/loading/loading.service'; import { inject } from '@angular/core'; -import { finalize } from 'rxjs'; +import { catchError, finalize, throwError } from 'rxjs'; +import { Router } from '@angular/router'; +import { ERROR_CODES, ERROR_PAGE_PATH } from '../../shared/constants/common.constant'; export const REQUEST_BY = 'X-Requested-By'; -export const IVY = 'ivy'; +export const IVY = 'marketplace-website'; /** This is option for exclude loading api * @Example return httpClient.get('apiEndPoint', { context: new HttpContext().set(SkipLoading, true) }) @@ -17,6 +19,7 @@ export const IVY = 'ivy'; export const SkipLoading = new HttpContextToken(() => false); export const apiInterceptor: HttpInterceptorFn = (req, next) => { + const router = inject(Router); const loadingService = inject(LoadingService); if (req.url.includes('i18n')) { @@ -40,6 +43,12 @@ export const apiInterceptor: HttpInterceptorFn = (req, next) => { loadingService.show(); return next(cloneReq).pipe( + catchError(error => { + if (ERROR_CODES.includes(error.status)) { + router.navigate([ERROR_PAGE_PATH]); + } + return throwError(() => new Error(error.message)); + }), finalize(() => { loadingService.hide(); }) diff --git a/marketplace-ui/src/app/core/services/language/language.service.ts b/marketplace-ui/src/app/core/services/language/language.service.ts index 8d4ce476c..218be4c58 100644 --- a/marketplace-ui/src/app/core/services/language/language.service.ts +++ b/marketplace-ui/src/app/core/services/language/language.service.ts @@ -7,7 +7,7 @@ const DATA_LANGUAGE = 'data-language'; @Injectable({ providedIn: 'root' }) export class LanguageService { private readonly language = signal(Language.EN); - selectedLanguage = computed(() => this.language() ?? Language.EN) + selectedLanguage = computed(() => this.language() ?? Language.EN); constructor(@Inject(DOCUMENT) private readonly document: Document) { const localStorage = this.document.defaultView?.localStorage; diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html index d61a45eb4..a32508c3d 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-information-tab/product-detail-information-tab.component.html @@ -17,15 +17,17 @@ - - - {{ 'common.product.detail.information.value.compatibility' | translate }} - - - {{ productDetail.compatibility }} - - + @if(productDetail.compatibility) { + + + + {{ 'common.product.detail.information.value.compatibility' | translate }} + + + {{ productDetail.compatibility }} + + + } @@ -48,13 +50,6 @@ {{ productDetail.type }} - - - {{ 'common.product.detail.information.value.industry' | translate }} - - {{ productDetail.industry }} - - {{ 'common.product.detail.information.value.tag' | translate }} @@ -63,24 +58,28 @@ - - - {{ 'common.product.detail.information.value.source' | translate }} - - - - github.com - - - - - - - {{ 'common.product.detail.information.value.status' | translate }} - - - + @if(productDetail.sourceUrl) { + + + + {{ 'common.product.detail.information.value.source' | translate }} + + + + github.com + + + + } + @if(productDetail.sourceUrl) { + + + + {{ 'common.product.detail.information.value.status' | translate }} + + + + } diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.html b/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.html index 15e185a93..eac66489e 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.html +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.html @@ -1,20 +1,12 @@ - - <!-- {{ productModuleContent.name }} --> - - <dependency> - - <groupId>{{ productModuleContent.groupId }}</groupId> - - <artifactId>{{ - productModuleContent.artifactId - }}</artifactId> - - <version>{{ - selectedVersion.replaceAll('Version ', '') - }}</version> - - <type>{{ productModuleContent.type }}</type> - - </dependency> - \ No newline at end of file + + + <!-- {{ getProductName() }} --> + <dependency> + <groupId>{{ productDetail.productModuleContent.groupId }}</groupId> + <artifactId>{{productDetail.productModuleContent.artifactId}}</artifactId> + <version>{{ selectedVersion.replaceAll('Version ', '') }}</version> + <type>{{ productDetail.productModuleContent.type }}</type> + </dependency> + + \ No newline at end of file diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.scss b/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.scss index e69de29bb..0c04bfd6e 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.scss +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.scss @@ -0,0 +1,3 @@ +pre { + border: 1px solid #c7d426; +} \ No newline at end of file diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.spec.ts index 5fc90702b..6130b7de4 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.spec.ts @@ -15,7 +15,7 @@ describe('ProductDetailMavenContentComponent', () => { fixture = TestBed.createComponent(ProductDetailMavenContentComponent); component = fixture.componentInstance; - component.productModuleContent = MOCK_PRODUCT_DETAIL.productModuleContent; + component.productDetail = MOCK_PRODUCT_DETAIL; component.selectedVersion = '1.0.0'; fixture.detectChanges(); }); diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.ts index a645e68e9..bf944286c 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-maven-content/product-detail-maven-content.component.ts @@ -1,7 +1,8 @@ import { Component, inject, Input } from '@angular/core'; import { TranslateModule } from '@ngx-translate/core'; -import { ProductModuleContent } from '../../../../shared/models/product-module-content.model'; import { LanguageService } from '../../../../core/services/language/language.service'; +import { Language } from '../../../../shared/enums/language.enum'; +import { ProductDetail } from '../../../../shared/models/product-detail.model'; @Component({ selector: 'app-product-detail-maven-content', @@ -12,9 +13,13 @@ import { LanguageService } from '../../../../core/services/language/language.ser }) export class ProductDetailMavenContentComponent { @Input() - productModuleContent!: ProductModuleContent; + productDetail!: ProductDetail; @Input() selectedVersion!: string; languageService = inject(LanguageService); + + getProductName() { + return this.productDetail.names[Language.EN]; + } } diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.html b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.html index 069281404..29048377e 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.html +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.html @@ -1,100 +1,117 @@ -@if (isDesignerEnvironment()) { - - - - +@switch (actionType) { + @case ('standard') { + + {{ 'common.product.detail.install.buttonLabel' | translate }} + + + {{ 'common.product.detail.download.buttonLabel' | translate }} + + } + @case ('designerEnv') { + + + + + {{ 'common.product.detail.install.buttonLabelInDesigner' | translate }} + + + } + @case ('customSolution') { - {{ 'common.product.detail.install.buttonLabelInDesigner' | translate }} + {{ 'common.product.detail.contactUs.label' | translate }} - -} @else { - - {{ 'common.product.detail.install.buttonLabel' | translate }} - - - {{ 'common.product.detail.download.buttonLabel' | translate }} - + } } @if (isDropDownDisplayed()) { - - - - - - {{ - 'common.product.detail.download.artifactSelector.label' | translate - }} - + + + + + + {{ + 'common.product.detail.download.artifactSelector.label' | translate + }} + - - - - + + + + - - - - {{ - 'common.product.detail.download.versionSelector.label' | translate - }} - + + + + {{ + 'common.product.detail.download.versionSelector.label' | translate + }} + - - - + + + + - - - @if (isDevVersionsDisplayed()) { - {{ 'common.product.detail.download.hideDevVersions' | translate }} - } @else { - {{ 'common.product.detail.download.showDevVersions' | translate }} - } - - - - {{ 'common.product.detail.download.buttonLabel' | translate }} - - - - + + @if (isDevVersionsDisplayed()) { + {{ 'common.product.detail.download.hideDevVersions' | translate }} + } @else { + {{ 'common.product.detail.download.showDevVersions' | translate }} + } + + + + {{ 'common.product.detail.download.buttonLabel' | translate }} + + + + } diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.scss b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.scss index 724c5a230..753ca8452 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.scss +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.scss @@ -74,7 +74,8 @@ border: 0.5px solid var(--ivy-secondary-border-color); } -::ng-deep .btn { +.btn, +::ng-deep .install-designer-button { padding: 12px 32px; gap: 10px; font-weight: 500; @@ -175,19 +176,27 @@ line-height: 16.8px; } } + .up-arrow { - margin-left: 55%; + margin: 0; + top: -10px; + width: 20px; + height: 20px; + left: 50%; z-index: 1001; - margin-top: -3.1rem; @media (max-width: 767px) { - margin-left: 80%; + top: -8px; + width: 16px; + height: 16px; } - transform: rotate(45deg); - top: 2.45rem; - width: 20px; - height: 20px; - margin-bottom: -0.75rem; + transform: translateX(-50%) rotate(45deg); border-right-color: transparent; border-bottom-color: transparent; background-color: var(--bs-body-bg); } + +.btn_contact-us { + @media (max-width: 991px) { + width: 100%; + } +} diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.spec.ts index c96440d10..14f47fd83 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.spec.ts @@ -12,11 +12,10 @@ class MockElementRef implements ElementRef { contains: jasmine.createSpy('contains') }; } -describe('ProductVersionActionComponent', () => { +describe('ProductDetailVersionActionComponent', () => { let component: ProductDetailVersionActionComponent; let fixture: ComponentFixture; let productServiceMock: any; - let elementRef: MockElementRef; beforeEach(() => { productServiceMock = jasmine.createSpyObj('ProductService', [ @@ -78,15 +77,12 @@ describe('ProductVersionActionComponent', () => { component.sanitizeDataBeforeFetching(); expect(component.versions().length).toBe(0); expect(component.artifacts().length).toBe(0); - expect(component.selectedVersion()).toEqual(''); - expect(component.selectedArtifact).toEqual(''); }); it('should call sendRequestToProductDetailVersionAPI and update versions and versionMap', () => { const { mockArtifact1, mockArtifact2 } = mockApiWithExpectedResponse(); - + component.selectedVersion.set('Version 1.0'); component.getVersionWithArtifact(); - expect( productServiceMock.sendRequestToProductDetailVersionAPI ).toHaveBeenCalledWith( @@ -166,28 +162,52 @@ describe('ProductVersionActionComponent', () => { const mockWindowOpen = jasmine.createSpy('windowOpen').and.returnValue({ blur: jasmine.createSpy('blur') }); - const mockWindowFocus = spyOn(window, 'focus'); - - // Mock window.open spyOn(window, 'open').and.callFake(mockWindowOpen); spyOn(component, 'onUpdateInstallationCount'); - // Set the artifact URL component.selectedArtifact = 'http://example.com/artifact'; - // Call the method component.downloadArtifact(); - // Check if window.open was called with the correct URL and target expect(window.open).toHaveBeenCalledWith( 'http://example.com/artifact', '_blank' ); - - // Check if newTab.blur() was called expect(mockWindowOpen().blur).toHaveBeenCalled(); expect(component.onUpdateInstallationCount).toHaveBeenCalledOnceWith(); - // Check if window.focus() was called expect(mockWindowFocus).toHaveBeenCalled(); }); + + it('should open a new tab with the correct URL and blur it', () => { + const productId = 'octopus'; + component.productId = productId; + const newTabMock: Partial = { + blur: jasmine.createSpy('blur') + }; + spyOn(window, 'open').and.returnValue(newTabMock as Window); + spyOn(window, 'focus'); + component.onNavigateToContactPage(); + + expect(window.open).toHaveBeenCalledWith( + `https://www.axonivy.com/marketplace/contact/?market_solutions=${productId}`, + '_blank' + ); + expect(newTabMock.blur).toHaveBeenCalled(); + expect(window.focus).toHaveBeenCalled(); + }); + + it('should not call blur if newTab is null', () => { + const productId = 'octopus'; + component.productId = productId; + spyOn(window, 'open').and.returnValue(null); + spyOn(window, 'focus'); + + component.onNavigateToContactPage(); + + expect(window.open).toHaveBeenCalledWith( + `https://www.axonivy.com/marketplace/contact/?market_solutions=${productId}`, + '_blank' + ); + expect(window.focus).toHaveBeenCalled(); + }); }); diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.ts index 2304f195b..a6a59488a 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail-version-action/product-detail-version-action.component.ts @@ -1,11 +1,18 @@ import { AfterViewInit, - Component, computed, - ElementRef, EventEmitter, + ChangeDetectorRef, + Component, + computed, + ElementRef, + EventEmitter, + HostListener, inject, Input, - model, Output, Signal, + model, + Output, + Signal, signal, + ViewChild, WritableSignal } from '@angular/core'; import { ThemeService } from '../../../../core/services/theme/theme.service'; @@ -14,20 +21,24 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ProductService } from '../../product.service'; import { Tooltip } from 'bootstrap'; -import { ProductDetailService } from '../product-detail.service'; -import { RoutingQueryParamService } from '../../../../shared/services/routing.query.param.service'; import { CommonDropdownComponent } from '../../../../shared/components/common-dropdown/common-dropdown.component'; import { LanguageService } from '../../../../core/services/language/language.service'; import { ItemDropdown } from '../../../../shared/models/item-dropdown.model'; -import { ProductDetail } from '../../../../shared/models/product-detail.model'; import { environment } from '../../../../../environments/environment'; import { VERSION } from '../../../../shared/constants/common.constant'; +import { ProductDetailActionType } from '../../../../shared/enums/product-detail-action-type'; +import { RoutingQueryParamService } from '../../../../shared/services/routing.query.param.service'; +import { ProductDetail } from '../../../../shared/models/product-detail.model'; -const delayTimeBeforeHideMessage = 2000; @Component({ selector: 'app-product-version-action', standalone: true, - imports: [CommonModule, TranslateModule, FormsModule, CommonDropdownComponent], + imports: [ + CommonModule, + TranslateModule, + FormsModule, + CommonDropdownComponent + ], templateUrl: './product-detail-version-action.component.html', styleUrl: './product-detail-version-action.component.scss' }) @@ -35,14 +46,18 @@ export class ProductDetailVersionActionComponent implements AfterViewInit { protected readonly environment = environment; @Output() installationCount = new EventEmitter(); @Input() productId!: string; + @Input() actionType!: ProductDetailActionType; + + @ViewChild('artifactDownloadButton') artifactDownloadButton!: ElementRef; + @ViewChild('artifactDownloadDialog') artifactDownloadDialog!: ElementRef; @Input() product!: ProductDetail; selectedVersion = model(''); versions: WritableSignal = signal([]); - versionDropdown : Signal = computed(() => { + versionDropdown: Signal = computed(() => { return this.versions().map(version => ({ value: version, - label: version, + label: version })); }); metaDataJsonUrl = model(''); @@ -51,19 +66,17 @@ export class ProductDetailVersionActionComponent implements AfterViewInit { artifacts: WritableSignal = signal([]); isDevVersionsDisplayed = signal(false); isDropDownDisplayed = signal(false); - isDesignerEnvironment = signal(false); - isInvalidInstallationEnvironment = signal(false); designerVersion = ''; selectedArtifact: string | undefined = ''; - selectedArtifactName:string | undefined = ''; + selectedArtifactName: string | undefined = ''; versionMap: Map = new Map(); - routingQueryParamService = inject(RoutingQueryParamService); themeService = inject(ThemeService); productService = inject(ProductService); - productDetailService = inject(ProductDetailService); elementRef = inject(ElementRef); languageService = inject(LanguageService); + routingQueryParamService = inject(RoutingQueryParamService); + changeDetectorRef = inject(ChangeDetectorRef); ngAfterViewInit() { const tooltipTriggerList = [].slice.call( @@ -72,25 +85,17 @@ export class ProductDetailVersionActionComponent implements AfterViewInit { tooltipTriggerList.forEach( tooltipTriggerEl => new Tooltip(tooltipTriggerEl) ); - this.isDesignerEnvironment.set( - this.routingQueryParamService.isDesignerEnv() - ); - } - getInstallationTooltipText() { - return `Please open the - Axon Ivy Market - inside your - Axon Ivy Designer - (minimum version 9.2.0)`; - } - - onSelectVersion(version : string) { + onSelectVersion(version: string) { this.selectedVersion.set(version); this.artifacts.set(this.versionMap.get(this.selectedVersion()) ?? []); + this.updateSelectedArtifact(); + } + + private updateSelectedArtifact() { this.artifacts().forEach(artifact => { - if(artifact.name) { + if (artifact.name) { artifact.label = artifact.name; } }); @@ -120,6 +125,42 @@ export class ProductDetailVersionActionComponent implements AfterViewInit { this.getVersionWithArtifact(); } this.isDropDownDisplayed.set(!this.isDropDownDisplayed()); + this.changeDetectorRef.detectChanges(); + this.reLocaleDialog(); + } + + @HostListener('window:resize', ['$event']) + onResize() { + this.reLocaleDialog(); + } + + reLocaleDialog() { + const buttonPosition = this.getElementPosition(this.artifactDownloadButton); + const dialogPosition = this.getElementPosition(this.artifactDownloadDialog); + if (buttonPosition && dialogPosition) { + const dialogElement = this.artifactDownloadDialog.nativeElement; + + dialogElement.style.position = 'absolute'; + dialogElement.style.top = `${buttonPosition.y + buttonPosition.height}px`; + + // Align the dialog to the center of the button + const dialogWidth = dialogElement.offsetWidth; + const buttonCenterX = buttonPosition.x + buttonPosition.width / 2; + dialogElement.style.left = `${buttonCenterX - dialogWidth / 2}px`; + } + } + + getElementPosition(element: ElementRef) { + if (element?.nativeElement) { + const rect = element.nativeElement.getBoundingClientRect(); + return { + x: rect.left + window.scrollX, + y: rect.top + window.scrollY, + width: rect.width, + height: rect.height + }; + } + return null; } getVersionWithArtifact() { @@ -143,32 +184,39 @@ export class ProductDetailVersionActionComponent implements AfterViewInit { } }); if (this.versions().length !== 0) { - this.selectedVersion.set(this.versions()[0]); + this.artifacts.set(this.versionMap.get(this.selectedVersion()) ?? []); + this.updateSelectedArtifact(); } }); } - getVersionInDesigner(): void { if (this.versions().length === 0) { - this.productService.sendRequestToGetProductVersionsForDesigner(this.productId - ).subscribe(data => { - const versionMap = data.map(dataVersionAndUrl => dataVersionAndUrl.version).map(version => VERSION.displayPrefix.concat(version)); - data.forEach(dataVersionAndUrl => { - const currentVersion = VERSION.displayPrefix.concat(dataVersionAndUrl.version); - const versionAndUrl: ItemDropdown = { value: currentVersion, label: currentVersion, metaDataJsonUrl: dataVersionAndUrl.url }; - this.versionDropdownInDesigner.push(versionAndUrl); + this.productService + .sendRequestToGetProductVersionsForDesigner(this.productId) + .subscribe(data => { + const versionMap = data + .map(dataVersionAndUrl => dataVersionAndUrl.version) + .map(version => VERSION.displayPrefix.concat(version)); + data.forEach(dataVersionAndUrl => { + const currentVersion = VERSION.displayPrefix.concat( + dataVersionAndUrl.version + ); + const versionAndUrl: ItemDropdown = { + value: currentVersion, + label: currentVersion, + metaDataJsonUrl: dataVersionAndUrl.url + }; + this.versionDropdownInDesigner.push(versionAndUrl); + }); + this.versions.set(versionMap); }); - this.versions.set(versionMap); - }); } } sanitizeDataBeforeFetching() { this.versions.set([]); this.artifacts.set([]); - this.selectedArtifact = ''; - this.selectedVersion.set(''); } downloadArtifact() { @@ -182,14 +230,25 @@ export class ProductDetailVersionActionComponent implements AfterViewInit { onUpdateInstallationCount() { this.productService - .sendRequestToUpdateInstallationCount(this.productId) + .sendRequestToUpdateInstallationCount( + this.productId, + this.routingQueryParamService.getDesignerVersionFromCookie() + ) .subscribe((data: number) => this.installationCount.emit(data)); } onUpdateInstallationCountForDesigner() { - if (this.isDesignerEnvironment()) { - this.onUpdateInstallationCount(); - } + this.onUpdateInstallationCount(); } + onNavigateToContactPage() { + const newTab = window.open( + `https://www.axonivy.com/marketplace/contact/?market_solutions=${this.productId}`, + '_blank' + ); + if (newTab) { + newTab.blur(); + } + window.focus(); + } } diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.html b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.html index 434725085..b20928040 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.html +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.html @@ -45,8 +45,11 @@ [isShowRateLink]="false" [isShowTotalRatingNumber]="false" /> - + @if(productDetailActionType() !== 'customSolution') { + + } [(selectedVersion)]="selectedVersion!" [(metaDataJsonUrl)]="metaProductJsonUrl!" [productId]="productDetail().id" - [product]="productDetail()" + [actionType]="productDetailActionType()" (selectedVersionChange)=" loadDetailTabs($event) "> @@ -86,28 +89,25 @@ class="tab-group d-flex flex-column justify-content-center align-items-start p-0 col-12 col-xl-8"> - @for (tab of detailTabs; track $index) { - @if (tab.value | hasValueTab: productModuleContent()) { - - - {{ tab.label | translate }} - - - } + @for (displayedTab of displayedTabsSignal(); track $index) { + + + {{ displayedTab.label | translate }} + + } @@ -119,8 +119,8 @@ class="dropdown-container position-relative d-flex align-items-center w-100"> @@ -154,58 +154,29 @@ @if (!isEmptyProductContent()) { - - - - - - - - - - - - + @for (displayedTab of displayedTabsSignal(); track $index) { + + @if (displayedTab.value === 'dependency') { + + + } @else { + + } + + } } diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.scss b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.scss index 55dd43e2e..33086bbe7 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.scss +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.scss @@ -38,6 +38,10 @@ .detail-body { gap: 4rem; + + .tab-title { + padding: 0.5rem; + } } .readme-content ::ng-deep { @@ -205,6 +209,7 @@ .product-title { line-height: 79.2px; + white-space: break-spaces; } hr { diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts index 031072a7b..407437495 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.spec.ts @@ -27,6 +27,9 @@ import { ProductDetailComponent } from './product-detail.component'; import { ProductModuleContent } from '../../../shared/models/product-module-content.model'; import { RoutingQueryParamService } from '../../../shared/services/routing.query.param.service'; import { MockProductService } from '../../../shared/mocks/mock-services'; +import { ProductDetailActionType } from '../../../shared/enums/product-detail-action-type'; +import { LanguageService } from '../../../core/services/language/language.service'; +import { Language } from '../../../shared/enums/language.enum'; const products = MOCK_PRODUCTS._embedded.products; declare const viewport: Viewport; @@ -34,12 +37,18 @@ describe('ProductDetailComponent', () => { let component: ProductDetailComponent; let fixture: ComponentFixture; let routingQueryParamService: jasmine.SpyObj; + let languageService: jasmine.SpyObj; beforeEach(async () => { const routingQueryParamServiceSpy = jasmine.createSpyObj( 'RoutingQueryParamService', ['getDesignerVersionFromCookie', 'isDesignerEnv'] ); + + const languageServiceSpy = jasmine.createSpyObj( + 'LanguageService', + ['selectedLanguage'] + ); await TestBed.configureTestingModule({ imports: [ @@ -63,6 +72,10 @@ describe('ProductDetailComponent', () => { { provide: RoutingQueryParamService, useValue: routingQueryParamServiceSpy + }, + { + provide: LanguageService, + useValue: languageServiceSpy } ] }) @@ -76,6 +89,10 @@ describe('ProductDetailComponent', () => { routingQueryParamService = TestBed.inject( RoutingQueryParamService ) as jasmine.SpyObj; + + languageService = TestBed.inject( + LanguageService + ) as jasmine.SpyObj; }); beforeEach(() => { @@ -91,7 +108,7 @@ describe('ProductDetailComponent', () => { }); it('version should display in number', () => { - expect(component.selectedVersion).toEqual('10.0.0'); + expect(component.selectedVersion).toEqual('Version 10.0.0'); }); it('should get corresponding version from cookie', () => { @@ -157,8 +174,415 @@ describe('ProductDetailComponent', () => { fixture.detectChanges(); const description = fixture.debugElement.query(By.css('#description')); expect(description).toBeFalsy(); + } + ) + + it('should return true for description when in EN language it is not null and not undefined and not empty', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + description: { en: 'Test description' } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('description')).toBeTrue(); + }); + + it('should return true for description when in DE language it is not null and not undefined and not empty', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + description: { de: 'Test description' } + }; + + const selectedLanguage = Language.DE; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('description')).toBeTrue(); + }); + + it('should return true for description when in DE language it is empty but in EN language it has value', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + description: { en: 'Test description', de: '' } + }; + + const selectedLanguage = Language.DE; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('description')).toBeTrue(); + }); + + it('should return true for description when in DE language it is undefined but in EN language it has value', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + description: { en: 'Test description'} + }; + + const selectedLanguage = Language.DE; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('description')).toBeTrue(); + }); + + it('should return false for description when it is null', () => { + const mockContentWithNullDescription: ProductModuleContent = + MOCK_PRODUCT_MODULE_CONTENT; + component.productModuleContent.set(mockContentWithNullDescription); + expect(component.getContent('description')).toBeFalse(); + }); + + it('should return false for description when in EN language it is an empty string', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + description: { en: '', de: 'Test description' } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('description')).toBeFalse(); + }); + + it('should return false for description when in EN language it is undefined', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + description: { de: "Test description" } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('description')).toBeFalse(); + }); + + it('should return false for description when in both DE and EN language it is an empty string', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + description: { en: '', de: '' } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('description')).toBeFalse(); + }); + + it('should return false for description when in both DE and EN language it is an undefined', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + description: {} + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('description')).toBeFalse(); + }); + + it('should return true for setup when in EN language it is not null and not undefined and not empty', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + setup: { en: 'Test setup' } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('setup')).toBeTrue(); + }); + + it('should return true for setup when in DE language it is not null and not undefined and not empty', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + setup: { de: 'Test setup' } + }; + + const selectedLanguage = Language.DE; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('setup')).toBeTrue(); + }); + + it('should return true for setup when in DE language it is empty but in EN language it has value', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + setup: { en: 'Test setup', de: '' } + }; + + const selectedLanguage = Language.DE; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('setup')).toBeTrue(); + }); + + it('should return true for setup when in DE language it is undefined but in EN language it has value', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + setup: { en: 'Test setup'} + }; + + const selectedLanguage = Language.DE; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('setup')).toBeTrue(); + }); + + it('should return false for setup when it is null', () => { + const mockContentWithNullSetup: ProductModuleContent = + MOCK_PRODUCT_MODULE_CONTENT; + component.productModuleContent.set(mockContentWithNullSetup); + expect(component.getContent('setup')).toBeFalse(); + }); + + it('should return false for setup when in EN language it is an empty string', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + setup: { en: '', de: "Test setup" } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('setup')).toBeFalse(); + }); + + it('should return false for setup when in EN language it is undefined', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + setup: { de: "Test setup" } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('setup')).toBeFalse(); + }); + + it('should return false for setup when in both DE and EN language it is an empty string', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + setup: { en: '', de: '' } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('setup')).toBeFalse(); + }); + + it('should return false for setup when in both DE and EN language it is an undefined', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + setup: {} + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('setup')).toBeFalse(); + }); + + it('should return true for demo when in EN language it is not null and not undefined and not empty', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + demo: { en: 'Test demo' } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('demo')).toBeTrue(); + }); + + it('should return true for demo when in DE language it is not null and not undefined and not empty', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + demo: { de: 'Test demo' } + }; + + const selectedLanguage = Language.DE; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('demo')).toBeTrue(); + }); + + it('should return true for demo when in DE language it is empty but in EN language it has value', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + demo: { en: 'Test demo', de: '' } + }; + + const selectedLanguage = Language.DE; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('demo')).toBeTrue(); + }); + + it('should return true for demo when in DE language it is undefined but in EN language it has value', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + demo: { en: 'Test demo'} + }; + + const selectedLanguage = Language.DE; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('demo')).toBeTrue(); + }); + + it('should return false for demo when it is null', () => { + const mockContentWithNullDemo: ProductModuleContent = + MOCK_PRODUCT_MODULE_CONTENT; + component.productModuleContent.set(mockContentWithNullDemo); + expect(component.getContent('demo')).toBeFalse(); + }); + + it('should return false for demo when in EN language it is an empty string', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + demo: { en: '', de: 'Test demo' } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('demo')).toBeFalse(); + }); + + it('should return false for demo when in EN language it is undefined', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + demo: { de: "Test demo" } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('demo')).toBeFalse(); + }); + + it('should return false for demo when in both DE and EN language it is an empty string', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + demo: { en: '', de: '' } + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('demo')).toBeFalse(); + }); + + it('should return false for demo when in both DE and EN language it is undefined', () => { + const mockContent: ProductModuleContent = { + ...MOCK_PRODUCT_MODULE_CONTENT, + demo: {} + }; + + const selectedLanguage = Language.EN; + + languageService.selectedLanguage.and.returnValue( + selectedLanguage + ); + + component.productModuleContent.set(mockContent); + expect(component.getContent('demo')).toBeFalse(); }); + it('should display dropdown horizontally on small viewport', () => { viewport.set(540); const tabGroup = fixture.debugElement.query(By.css('.row-tab')); @@ -279,4 +703,39 @@ describe('ProductDetailComponent', () => { component.handleProductContentVersion(); expect(component.selectedVersion).toEqual('Version 10.0.11'); }); + + it('should return DESIGNER_ENV as acction type in Designer Env', () => { + routingQueryParamService.isDesignerEnv.and.returnValue(true); + + component.updateProductDetailActionType({ sourceUrl: 'some-url'} as any); + expect(component.productDetailActionType()).toBe( + ProductDetailActionType.DESIGNER_ENV + ); + }); + + it('should return CUSTOM_SOLUTION as acction type when productDetail.sourceUrl is undefined', () => { + routingQueryParamService.isDesignerEnv.and.returnValue(false); + + component.updateProductDetailActionType({ sourceUrl: undefined } as any); + + expect(component.productDetailActionType()).toBe( + ProductDetailActionType.CUSTOM_SOLUTION + ); + fixture.detectChanges(); + let installationCount = fixture.debugElement.query( + By.css('#app-product-installation-count-action') + ); + expect(installationCount).toBeFalsy(); + + }); + + it('should return STANDARD as acction type when when productDetail.sourceUrl is defined', () => { + routingQueryParamService.isDesignerEnv.and.returnValue(false); + + component.updateProductDetailActionType({ sourceUrl: 'some-url' } as any); + + expect(component.productDetailActionType()).toBe( + ProductDetailActionType.STANDARD + ); + }); }); diff --git a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.ts b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.ts index 3fed5e594..46737a8dc 100644 --- a/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.ts +++ b/marketplace-ui/src/app/modules/product/product-detail/product-detail.component.ts @@ -3,7 +3,9 @@ import { Component, ElementRef, HostListener, + Signal, WritableSignal, + computed, inject, signal } from '@angular/core'; @@ -23,8 +25,8 @@ import { import { ItemDropdown } from '../../../shared/models/item-dropdown.model'; import { ProductDetail } from '../../../shared/models/product-detail.model'; import { ProductModuleContent } from '../../../shared/models/product-module-content.model'; -import { HasValueTabPipe } from '../../../shared/pipes/has-value-tab.pipe'; import { ProductTypeIconPipe } from '../../../shared/pipes/icon.pipe'; +import { MissingReadmeContentPipe } from '../../../shared/pipes/missing-readme-content.pipe'; import { MultilingualismPipe } from '../../../shared/pipes/multilingualism.pipe'; import { ProductTypePipe } from '../../../shared/pipes/product-type.pipe'; import { AppModalService } from '../../../shared/services/app-modal.service'; @@ -34,12 +36,14 @@ import { ProductService } from '../product.service'; import { ProductDetailFeedbackComponent } from './product-detail-feedback/product-detail-feedback.component'; import { ProductFeedbackService } from './product-detail-feedback/product-feedbacks-panel/product-feedback.service'; import { ProductStarRatingService } from './product-detail-feedback/product-star-rating-panel/product-star-rating.service'; +import { ProductDetailActionType } from '../../../shared/enums/product-detail-action-type'; import { ProductDetailInformationTabComponent } from './product-detail-information-tab/product-detail-information-tab.component'; import { ProductDetailMavenContentComponent } from './product-detail-maven-content/product-detail-maven-content.component'; import { ProductDetailVersionActionComponent } from './product-detail-version-action/product-detail-version-action.component'; import { ProductDetailService } from './product-detail.service'; import { ProductInstallationCountActionComponent } from './product-installation-count-action/product-installation-count-action.component'; import { ProductStarRatingNumberComponent } from './product-star-rating-number/product-star-rating-number.component'; +import { DisplayValue } from '../../../shared/models/display-value.model'; export interface DetailTab { activeClass: string; @@ -67,7 +71,7 @@ const DEFAULT_ACTIVE_TAB = 'description'; ProductDetailFeedbackComponent, ProductInstallationCountActionComponent, ProductTypeIconPipe, - HasValueTabPipe, + MissingReadmeContentPipe, CommonDropdownComponent ], providers: [ProductService, MarkdownService], @@ -94,14 +98,14 @@ export class ProductDetailComponent { productModuleContent: WritableSignal = signal( {} as ProductModuleContent ); + productDetailActionType = signal(ProductDetailActionType.STANDARD); detailContent!: DetailTab; detailTabs = PRODUCT_DETAIL_TABS; - activeTab = DEFAULT_ACTIVE_TAB; - selectedTabLabel: string = CommonUtils.getLabel( - PRODUCT_DETAIL_TABS[0].value, - PRODUCT_DETAIL_TABS - ); - detailTabsForDropdown = PRODUCT_DETAIL_TABS; + activeTab = ''; + displayedTabsSignal: Signal = computed(() => { + this.languageService.selectedLanguage(); + return this.getDisplayedTabsSignal(); + }); isDropdownOpen: WritableSignal = signal(false); isTabDropdownShown: WritableSignal = signal(false); selectedVersion = ''; @@ -109,6 +113,7 @@ export class ProductDetailComponent { showPopup!: boolean; isMobileMode = signal(false); installationCount = 0; + @HostListener('window:popstate', ['$event']) onPopState() { this.activeTab = window.location.hash.split('#tab-')[1]; @@ -126,28 +131,27 @@ export class ProductDetailComponent { } ngOnInit(): void { + this.router.navigate([], { + relativeTo: this.route, + replaceUrl: true + }); + const productId = this.route.snapshot.params['id']; this.productDetailService.productId.set(productId); if (productId) { this.getProductById(productId).subscribe(productDetail => { this.productDetail.set(productDetail); this.productModuleContent.set(productDetail.productModuleContent); - this.selectedVersion = VERSION.displayPrefix.concat(this.convertTagToVersion((productDetail.productModuleContent.tag))); this.metaProductJsonUrl = productDetail.metaProductJsonUrl; - this.detailTabsForDropdown = this.getNotEmptyTabs(); this.productDetailService.productNames.set(productDetail.names); - localStorage.removeItem(STORAGE_ITEM); this.installationCount = productDetail.installationCount; this.handleProductContentVersion(); + this.updateProductDetailActionType(productDetail); }); + this.productFeedbackService.initFeedbacks(); this.productStarRatingService.fetchData(); } - - const savedTab = localStorage.getItem(STORAGE_ITEM); - if (savedTab) { - this.activeTab = savedTab; - } this.updateDropdownSelection(); } @@ -155,11 +159,18 @@ export class ProductDetailComponent { if (this.isEmptyProductContent()) { return; } - this.selectedVersion = this.convertTagToVersion( - this.productModuleContent().tag + this.selectedVersion = VERSION.displayPrefix.concat( + this.convertTagToVersion(this.productModuleContent().tag) ); - if (this.routingQueryParamService.isDesignerEnv()) { - this.selectedVersion = VERSION.displayPrefix.concat(this.selectedVersion); + } + + updateProductDetailActionType(productDetail: ProductDetail) { + if (productDetail?.sourceUrl === undefined) { + this.productDetailActionType.set(ProductDetailActionType.CUSTOM_SOLUTION); + } else if (this.routingQueryParamService.isDesignerEnv()) { + this.productDetailActionType.set(ProductDetailActionType.DESIGNER_ENV); + } else { + this.productDetailActionType.set(ProductDetailActionType.STANDARD) } } @@ -194,6 +205,38 @@ export class ProductDetailComponent { }); } + getContent(value: string): boolean { + const content = this.productModuleContent(); + + if (Object.keys(content).length === 0) { + return false; + } + + const conditions: { [key: string]: boolean } = { + description: + content.description !== null && + CommonUtils.isContentDisplayedBasedOnLanguage( + content.description, + this.languageService.selectedLanguage() + ), + demo: + content.demo !== null && + CommonUtils.isContentDisplayedBasedOnLanguage( + content.demo, + this.languageService.selectedLanguage() + ), + setup: + content.setup !== null && + CommonUtils.isContentDisplayedBasedOnLanguage( + content.setup, + this.languageService.selectedLanguage() + ), + dependency: content.isDependency + }; + + return conditions[value] ?? false; + } + isEmptyProductContent(): boolean { const content = this.productModuleContent(); return !content || Object.keys(content).length === 0; @@ -213,11 +256,14 @@ export class ProductDetailComponent { onTabChange(event: string) { this.setActiveTab(event); - this.selectedTabLabel = CommonUtils.getLabel(event, PRODUCT_DETAIL_TABS); this.isTabDropdownShown.update(value => !value); this.onTabDropdownShown(); } + getSelectedTabLabel() { + return CommonUtils.getLabel(this.activeTab, PRODUCT_DETAIL_TABS); + } + updateDropdownSelection() { const dropdown = document.getElementById( 'tab-group-dropdown' @@ -238,7 +284,12 @@ export class ProductDetailComponent { } this.updateDropdownSelection(); - localStorage.setItem(STORAGE_ITEM, tab); + const savedTab = { + productId: this.productDetail().id, + savedActiveTab: this.activeTab + }; + + localStorage.setItem(STORAGE_ITEM, JSON.stringify(savedTab)); } onShowInfoContent() { @@ -296,19 +347,27 @@ export class ProductDetailComponent { }); } - getNotEmptyTabs(): ItemDropdown[] { - return this.detailTabsForDropdown.filter(tab => - HasValueTabPipe.prototype.transform( - tab.value, - this.productModuleContent() - ) - ); - } - convertTagToVersion(tag: string): string { if (tag !== '' && tag.startsWith(VERSION.tagPrefix)) { return tag.substring(1); } return tag; } + + getDisplayedTabsSignal() { + const displayedTabs: ItemDropdown[] = []; + for (const detailTab of this.detailTabs) { + if (this.getContent(detailTab.value)) { + displayedTabs.push(detailTab); + this.activeTab = displayedTabs[0].value; + } + } + return displayedTabs; + } + + getProductModuleContentValue(key: ItemDropdown): DisplayValue | null { + type tabName = 'description' | 'demo' | 'setup'; + const value = key.value as tabName; + return this.productModuleContent()[value]; + } } diff --git a/marketplace-ui/src/app/modules/product/product.service.spec.ts b/marketplace-ui/src/app/modules/product/product.service.spec.ts index f9d3ff539..212ca073a 100644 --- a/marketplace-ui/src/app/modules/product/product.service.spec.ts +++ b/marketplace-ui/src/app/modules/product/product.service.spec.ts @@ -215,12 +215,13 @@ describe('ProductService', () => { it('sendRequestToUpdateInstallationCount', () => { const productId = "google-maps-connector"; + const designerVersion = "10.0.0"; - service.sendRequestToUpdateInstallationCount(productId).subscribe(response => { + service.sendRequestToUpdateInstallationCount(productId, designerVersion).subscribe(response => { expect(response).toBe(3); }); - const req = httpMock.expectOne(`api/product-details/installationcount/${productId}`); + const req = httpMock.expectOne(`api/product-details/installationcount/${productId}?designerVersion=${designerVersion}`); expect(req.request.method).toBe('PUT'); expect(req.request.headers.get('X-Requested-By')).toBe('ivy'); req.flush(3); @@ -241,4 +242,5 @@ describe('ProductService', () => { expect(req.request.headers.get('X-Requested-By')).toBe('ivy'); req.flush([{ version: '10.0.2' }, {version: '10.0.1'}, {version: '10.0.0'}]); }); + }); diff --git a/marketplace-ui/src/app/modules/product/product.service.ts b/marketplace-ui/src/app/modules/product/product.service.ts index f28ec3343..9ecbebff4 100644 --- a/marketplace-ui/src/app/modules/product/product.service.ts +++ b/marketplace-ui/src/app/modules/product/product.service.ts @@ -76,14 +76,15 @@ export class ProductService { ); } - sendRequestToUpdateInstallationCount(productId: string) { + sendRequestToUpdateInstallationCount(productId: string, designerVersion: string) { const url = 'api/product-details/installationcount/' + productId; - return this.httpClient.put(url, null, { headers: { 'X-Requested-By': 'ivy' } }); + const headers = { 'X-Requested-By': 'ivy' }; + const params = new HttpParams().append('designerVersion', designerVersion); + return this.httpClient.put(url, null, { headers, params }); } sendRequestToGetProductVersionsForDesigner(productId: string) { const url = `api/product-details/${productId}/designerversions`; return this.httpClient.get(url, { headers: { 'X-Requested-By': 'ivy' } }); } - } diff --git a/marketplace-ui/src/app/shared/components/common-dropdown/common-dropdown.component.html b/marketplace-ui/src/app/shared/components/common-dropdown/common-dropdown.component.html index 67c354a3f..2799c25f8 100644 --- a/marketplace-ui/src/app/shared/components/common-dropdown/common-dropdown.component.html +++ b/marketplace-ui/src/app/shared/components/common-dropdown/common-dropdown.component.html @@ -9,8 +9,8 @@ @for (item of items; track $index) { + [attr.metaDataJsonUrl]="item.metaDataJsonUrl" + [ngClass]="{'active': (item | activeItemPipe : selectedItem)}"> {{ item.label | translate }} } diff --git a/marketplace-ui/src/app/shared/components/common-dropdown/common-dropdown.component.ts b/marketplace-ui/src/app/shared/components/common-dropdown/common-dropdown.component.ts index 33ee74450..e3ac74e20 100644 --- a/marketplace-ui/src/app/shared/components/common-dropdown/common-dropdown.component.ts +++ b/marketplace-ui/src/app/shared/components/common-dropdown/common-dropdown.component.ts @@ -2,14 +2,15 @@ import { Component, ElementRef, EventEmitter, HostListener, inject, Input, Outpu import { NgClass } from '@angular/common'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ItemDropdown } from '../../models/item-dropdown.model'; - +import { ActiveDropDownItemPipe } from '../../pipes/active-dropdown-item.pipe'; @Component({ selector: 'app-common-dropdown', standalone: true, imports: [ NgClass, - TranslateModule - ], + TranslateModule, + ActiveDropDownItemPipe +], templateUrl: './common-dropdown.component.html', styleUrl: './common-dropdown.component.scss' }) diff --git a/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.html b/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.html new file mode 100644 index 000000000..9de60b3d1 --- /dev/null +++ b/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.html @@ -0,0 +1,27 @@ + + + {{ 'common.error.code' | translate }} + {{ 'common.error.oops' | translate }} + + {{ 'common.error.description' | translate }} + + + + {{ 'common.error.buttonLabel' | translate }} + + + + + + + + diff --git a/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.scss b/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.scss new file mode 100644 index 000000000..0085d3d45 --- /dev/null +++ b/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.scss @@ -0,0 +1,54 @@ +.error-page-container { + margin-top: 5rem; + &.mobile-mode { + .error-code-404, .oops, .description, .button-container { + text-align: center; + } + + .error-description { + text-align: center; + } + } + + .error-code-404 { + font-size: 14px; + font-weight: 600; + line-height: 16.8px; + letter-spacing: 0.18em; + text-align: left; + color: var(--text-error-code-color); + } + + .oops { + font-size: 72px; + font-weight: 600; + line-height: 79.2px; + text-align: left; + color: var(--text-error-oops-color); + } + + .description { + font-size: 18px; + font-weight: 400; + line-height: 25.2px; + text-align: left; + } + + .flexible-gap, + .module-gap { + gap: 1.5rem; + } + + .button-container { + margin-bottom: 1.5rem; + } + + .error-description { + color: var(--ivy-text-secondary-color); + font-family: Inter; + font-size: 18px; + font-weight: 400; + line-height: 25.2px; + text-align: left; + } +} diff --git a/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.spec.ts b/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.spec.ts new file mode 100644 index 000000000..a1d35370e --- /dev/null +++ b/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.spec.ts @@ -0,0 +1,82 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ErrorPageComponentComponent } from './error-page-component.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { Viewport } from 'karma-viewport/dist/adapter/viewport'; +import { By } from '@angular/platform-browser'; +import { Router } from '@angular/router'; +declare const viewport: Viewport; + +describe('ErrorPageComponentComponent', () => { + let component: ErrorPageComponentComponent; + let fixture: ComponentFixture; + let router: Router; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ErrorPageComponentComponent, TranslateModule.forRoot()] + }).compileComponents(); + + fixture = TestBed.createComponent(ErrorPageComponentComponent); + component = fixture.componentInstance; + router = TestBed.inject(Router); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call checkMediaSize on window resize', () => { + spyOn(component, 'checkMediaSize'); + component.onResize(); + expect(component.checkMediaSize).toHaveBeenCalled(); + }); + + it('should display image with the light mode on small and large viewport', () => { + component.themeService.isDarkMode.set(false); + viewport.set(1920); + component.onResize(); + fixture.detectChanges(); + let imgElement = fixture.debugElement.query(By.css('img')); + expect(imgElement.attributes['src']).toBe('/assets/images/misc/robot.png'); + + viewport.set(540); + component.onResize(); + fixture.detectChanges(); + expect(imgElement.attributes['src']).toBe( + '/assets/images/misc/robot-mobile.png' + ); + }); + + it('should display image with the dark mode on small and large viewport', () => { + component.themeService.isDarkMode.set(true); + viewport.set(1920); + component.onResize(); + fixture.detectChanges(); + let imgElement = fixture.debugElement.query(By.css('img')); + expect(imgElement.attributes['src']).toBe( + '/assets/images/misc/robot-black.png' + ); + + viewport.set(540); + component.onResize(); + fixture.detectChanges(); + expect(imgElement.attributes['src']).toBe( + '/assets/images/misc/robot-mobile-black.png' + ); + }); + + it('should back to the home page', () => { + spyOn(component, 'backToHomePage'); + let buttonElement = fixture.debugElement.query(By.css('button')); + buttonElement.nativeElement.click(); + expect(component.backToHomePage).toHaveBeenCalled(); + }); + + it('should redirect to the home page', () => { + const navigateSpy = spyOn(router, 'navigate'); + component.backToHomePage(); + expect(navigateSpy).toHaveBeenCalledWith(['/']); + }); +}); diff --git a/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.ts b/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.ts new file mode 100644 index 000000000..f97495db8 --- /dev/null +++ b/marketplace-ui/src/app/shared/components/error-page-component/error-page-component.component.ts @@ -0,0 +1,53 @@ +import { Component, HostListener, inject, signal } from '@angular/core'; +import { ThemeService } from '../../../core/services/theme/theme.service'; +import { LanguageService } from '../../../core/services/language/language.service'; +import { CommonModule } from '@angular/common'; +import { TranslateModule } from '@ngx-translate/core'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-error-page-component', + standalone: true, + imports: [CommonModule, TranslateModule], + templateUrl: './error-page-component.component.html', + styleUrl: './error-page-component.component.scss' +}) +export class ErrorPageComponentComponent { + themeService = inject(ThemeService); + languageService = inject(LanguageService); + isMobileMode = signal(false); + + constructor(private readonly router: Router) { + this.checkMediaSize(); + } + + backToHomePage() { + this.router.navigate(['/']); + } + + @HostListener('window:resize', ['$event']) + onResize() { + this.checkMediaSize(); + } + + checkMediaSize() { + const mediaQuery = window.matchMedia('(max-width: 767px)'); + this.isMobileMode.set(mediaQuery.matches); + } + + getImageSrcInLightMode(): string { + if (this.isMobileMode()) { + return '/assets/images/misc/robot-mobile.png'; + } + + return '/assets/images/misc/robot.png'; + } + + getImageSrcInDarkMode(): string { + if (this.isMobileMode()) { + return '/assets/images/misc/robot-mobile-black.png'; + } + + return '/assets/images/misc/robot-black.png'; + } +} diff --git a/marketplace-ui/src/app/shared/components/footer/footer.component.html b/marketplace-ui/src/app/shared/components/footer/footer.component.html index 31982d2c2..0b75bf587 100644 --- a/marketplace-ui/src/app/shared/components/footer/footer.component.html +++ b/marketplace-ui/src/app/shared/components/footer/footer.component.html @@ -17,7 +17,7 @@ - {{ 'common.footer.downloadLatestLTSVersion' | translate }} + {{ 'common.downloadLatestLTSVersion' | translate }} {{ 'common.footer.downloadLatestDevVersion' | translate }} diff --git a/marketplace-ui/src/app/shared/components/header/header.component.scss b/marketplace-ui/src/app/shared/components/header/header.component.scss index f05d6844e..dcf1610f0 100644 --- a/marketplace-ui/src/app/shared/components/header/header.component.scss +++ b/marketplace-ui/src/app/shared/components/header/header.component.scss @@ -33,23 +33,6 @@ .navbar-brand { padding: 14.42px 0; } - - .nav-link { - height: 45px; - padding: 10px 0px 10px 15px; - gap: 0px; - border-radius: 5px; - opacity: 0px; - } - - .nav-link.active { - height: 45px; - padding: 10px 15px 10px -15px; - gap: 0px; - border-radius: 5px; - opacity: 0px; - background-color: var(--ivy-secondary-background); - } } @media all and (max-width: 1200px) { diff --git a/marketplace-ui/src/app/shared/components/header/header.component.ts b/marketplace-ui/src/app/shared/components/header/header.component.ts index 1a3824362..2812dbb8e 100644 --- a/marketplace-ui/src/app/shared/components/header/header.component.ts +++ b/marketplace-ui/src/app/shared/components/header/header.component.ts @@ -1,5 +1,11 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, signal, WritableSignal } from '@angular/core'; +import { + Component, + EventEmitter, + inject, + Output, + model +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { LanguageService } from '../../../core/services/language/language.service'; @@ -25,9 +31,10 @@ import { ThemeSelectionComponent } from './theme-selection/theme-selection.compo styleUrls: ['./header.component.scss', '../../../app.component.scss'] }) export class HeaderComponent { + selectedNav = '/'; - isMobileMenuCollapsed: WritableSignal = signal(true); + isMobileMenuCollapsed = model(true); themeService = inject(ThemeService); translateService = inject(TranslateService); diff --git a/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.html b/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.html index 0b2fb3815..6b35cc449 100644 --- a/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.html +++ b/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.html @@ -14,14 +14,23 @@ @for (item of navItems; track $index) { - - - {{ item.label | translate }} - - - + + + {{ item.label | translate }} + + + } - \ No newline at end of file + diff --git a/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.scss b/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.scss index ae3aca955..96d552ce7 100644 --- a/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.scss +++ b/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.scss @@ -11,8 +11,10 @@ .nav-link { height: 22px; line-height: 22.4px; - font-weight: 400; - font-size: 16px; +} + +.nav-link.active { + font-weight: bold; } .active-line { @@ -48,6 +50,9 @@ gap: 0px; border-radius: 5px; opacity: 0px; + line-height: 140%; + font-weight: 600; + font-size: 1.125rem; } .nav-link.active { @@ -56,6 +61,6 @@ gap: 0px; border-radius: 5px; opacity: 0px; - background-color: var(--ivy-secondary-background); + background-color: var(--ivy-secondary-bg); } } diff --git a/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.spec.ts b/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.spec.ts index 94ded576a..93176dd08 100644 --- a/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.spec.ts +++ b/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.spec.ts @@ -25,6 +25,12 @@ describe('NavigationComponent', () => { expect(component).toBeTruthy(); }); + it('should call checkMediaSize on window resize', () => { + spyOn(component, 'checkMediaSize'); + component.onResize(); + expect(component.checkMediaSize).toHaveBeenCalled(); + }); + it('mobile search should display in small screen', () => { viewport.set(540); diff --git a/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.ts b/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.ts index a8db15d56..d0d99def3 100644 --- a/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.ts +++ b/marketplace-ui/src/app/shared/components/header/navigation/navigation.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, Input } from '@angular/core'; +import { Component, HostListener, inject, Input, signal } from '@angular/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { NAV_ITEMS } from '../../../constants/common.constant'; import { NavItem } from '../../../models/nav-item.model'; @@ -17,4 +17,19 @@ export class NavigationComponent { translateService = inject(TranslateService); languageService = inject(LanguageService); + isMobileMode = signal(false); + + constructor() { + this.checkMediaSize(); + } + + @HostListener('window:resize', ['$event']) + onResize() { + this.checkMediaSize(); + } + + checkMediaSize() { + const mediaQuery = window.matchMedia('(max-width: 992px)'); + this.isMobileMode.set(mediaQuery.matches); + } } diff --git a/marketplace-ui/src/app/shared/components/header/search-bar/search-bar.component.html b/marketplace-ui/src/app/shared/components/header/search-bar/search-bar.component.html index fad70609a..1869c47e5 100644 --- a/marketplace-ui/src/app/shared/components/header/search-bar/search-bar.component.html +++ b/marketplace-ui/src/app/shared/components/header/search-bar/search-bar.component.html @@ -58,15 +58,21 @@ } - - - - {{ 'common.header.download' | translate }} - - - - - + + + + {{ 'common.header.download' | translate }} + + + + + + {{ 'common.downloadLatestLTSVersion' | translate }} + + } - \ No newline at end of file + diff --git a/marketplace-ui/src/app/shared/components/header/search-bar/search-bar.component.spec.ts b/marketplace-ui/src/app/shared/components/header/search-bar/search-bar.component.spec.ts index 11c8cb238..e74dbaa13 100644 --- a/marketplace-ui/src/app/shared/components/header/search-bar/search-bar.component.spec.ts +++ b/marketplace-ui/src/app/shared/components/header/search-bar/search-bar.component.spec.ts @@ -71,4 +71,42 @@ describe('SearchBarComponent', () => { 'none' ); }); + + it('should display the correct text in large screen', () => { + viewport.set(1920); + fixture.detectChanges(); + + const largeScreenButton = fixture.debugElement.query( + By.css('.d-none.d-lg-block') + ).nativeElement; + const smallScreenButton = fixture.debugElement.query( + By.css('.d-lg-none') + ).nativeElement; + + expect(getComputedStyle(largeScreenButton).display).not.toBe('none'); + expect(getComputedStyle(smallScreenButton).display).toBe('none'); + + expect(largeScreenButton.textContent.trim()).toContain( + 'common.header.download' + ); + }); + + it('should display the correct text in smaller screen', () => { + viewport.set(430); + fixture.detectChanges(); + + const smallScreenButton = fixture.debugElement.query( + By.css('.d-lg-none') + ).nativeElement; + const largeScreenButton = fixture.debugElement.query( + By.css('.d-none.d-lg-block') + ).nativeElement; + + expect(getComputedStyle(smallScreenButton).display).not.toBe('none'); + expect(getComputedStyle(largeScreenButton).display).toBe('none'); + + expect(smallScreenButton.textContent.trim()).toContain( + 'common.downloadLatestLTSVersion' + ); + }); }); diff --git a/marketplace-ui/src/app/shared/constants/common.constant.ts b/marketplace-ui/src/app/shared/constants/common.constant.ts index c37d19e69..ac0d39c6b 100644 --- a/marketplace-ui/src/app/shared/constants/common.constant.ts +++ b/marketplace-ui/src/app/shared/constants/common.constant.ts @@ -195,3 +195,13 @@ export const VERSION = { tagPrefix: 'v', displayPrefix: 'Version ' }; + +export const ERROR_PAGE_PATH = '/error-page'; +export const NOT_FOUND_ERROR_CODE = 404; +export const INTERNAL_SERVER_ERROR_CODE = 500; +export const UNDEFINED_ERROR_CODE = 0; +export const ERROR_CODES = [ + UNDEFINED_ERROR_CODE, + NOT_FOUND_ERROR_CODE, + INTERNAL_SERVER_ERROR_CODE +]; \ No newline at end of file diff --git a/marketplace-ui/src/app/shared/enums/product-detail-action-type.ts b/marketplace-ui/src/app/shared/enums/product-detail-action-type.ts new file mode 100644 index 000000000..96d7adbe0 --- /dev/null +++ b/marketplace-ui/src/app/shared/enums/product-detail-action-type.ts @@ -0,0 +1,5 @@ +export enum ProductDetailActionType { + DESIGNER_ENV = 'designerEnv', + STANDARD = 'standard', + CUSTOM_SOLUTION = 'customSolution' +} diff --git a/marketplace-ui/src/app/shared/mocks/mock-data.ts b/marketplace-ui/src/app/shared/mocks/mock-data.ts index 7e97b73fe..582f1dd97 100644 --- a/marketplace-ui/src/app/shared/mocks/mock-data.ts +++ b/marketplace-ui/src/app/shared/mocks/mock-data.ts @@ -211,7 +211,8 @@ export const MOCK_PRODUCT_MODULE_CONTENT: ProductModuleContent = { name: 'Jira Connector', groupId: 'com.axonivy.connector.jira', artifactId: 'jira-connector', - type: 'iar' + type: 'iar', + productId: 'jira-connector' }; export const MOCK_PRODUCT_DETAIL_BY_VERSION: ProductDetail = { @@ -255,7 +256,8 @@ export const MOCK_PRODUCT_DETAIL_BY_VERSION: ProductDetail = { name: 'cron job', groupId: 'com.axonivy.utils.cronjob', artifactId: 'cronjob', - type: 'iar' + type: 'iar', + productId: 'cronjob' }, installationCount: 0, _links: { @@ -307,7 +309,8 @@ export const MOCK_PRODUCT_DETAIL: ProductDetail = { name: 'Jira Connector', groupId: 'com.axonivy.connector.jira', artifactId: 'jira-connector', - type: 'iar' + type: 'iar', + productId: 'jira-connector', }, _links: { self: { diff --git a/marketplace-ui/src/app/shared/models/product-module-content.model.ts b/marketplace-ui/src/app/shared/models/product-module-content.model.ts index c96314614..584405043 100644 --- a/marketplace-ui/src/app/shared/models/product-module-content.model.ts +++ b/marketplace-ui/src/app/shared/models/product-module-content.model.ts @@ -10,4 +10,5 @@ export interface ProductModuleContent { groupId: string; artifactId: string; type: string; + productId: string; } diff --git a/marketplace-ui/src/app/shared/pipes/active-dropdown-item.pipe.ts b/marketplace-ui/src/app/shared/pipes/active-dropdown-item.pipe.ts new file mode 100644 index 000000000..bd091e6bc --- /dev/null +++ b/marketplace-ui/src/app/shared/pipes/active-dropdown-item.pipe.ts @@ -0,0 +1,15 @@ +import { inject, Pipe, PipeTransform } from "@angular/core"; +import { ItemDropdown } from "../models/item-dropdown.model"; +import { TranslateService } from "@ngx-translate/core"; + +@Pipe({ + standalone: true, + name: 'activeItemPipe' +}) +export class ActiveDropDownItemPipe implements PipeTransform { + translateService = inject(TranslateService); + + transform(value: ItemDropdown, selectedItem: Object | undefined): boolean { + return this.translateService.instant(value.label) === selectedItem; + } +} \ No newline at end of file diff --git a/marketplace-ui/src/app/shared/pipes/has-value-tab.pipe.ts b/marketplace-ui/src/app/shared/pipes/has-value-tab.pipe.ts deleted file mode 100644 index e9be1a086..000000000 --- a/marketplace-ui/src/app/shared/pipes/has-value-tab.pipe.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Pipe, PipeTransform } from "@angular/core"; -import { ProductModuleContent } from "../models/product-module-content.model"; - -@Pipe({ - standalone: true, - name: 'hasValueTab' -}) -export class HasValueTabPipe implements PipeTransform { - transform(value: string, productModuleContent: ProductModuleContent): boolean { - const conditions: { [key: string]: boolean } = { - description: productModuleContent?.description !== null, - demo: productModuleContent?.demo !== null, - setup: productModuleContent?.setup !== null , - dependency: productModuleContent?.isDependency - }; - - return conditions[value] ?? false; - } -} \ No newline at end of file diff --git a/marketplace-ui/src/app/shared/pipes/missing-readme-content.pipe.ts b/marketplace-ui/src/app/shared/pipes/missing-readme-content.pipe.ts new file mode 100644 index 000000000..72f8d166d --- /dev/null +++ b/marketplace-ui/src/app/shared/pipes/missing-readme-content.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from "@angular/core"; +import { ProductModuleContent } from "../models/product-module-content.model"; + +@Pipe({ + standalone: true, + name: 'missingReadmeContent' +}) +export class MissingReadmeContentPipe implements PipeTransform { + transform(productModuleContent: ProductModuleContent): boolean { + return ( + !productModuleContent || Object.keys(productModuleContent).length === 0 + ); + } +} \ No newline at end of file diff --git a/marketplace-ui/src/app/shared/pipes/multilingualism.pipe.ts b/marketplace-ui/src/app/shared/pipes/multilingualism.pipe.ts index 154c344fd..f001c231d 100644 --- a/marketplace-ui/src/app/shared/pipes/multilingualism.pipe.ts +++ b/marketplace-ui/src/app/shared/pipes/multilingualism.pipe.ts @@ -7,15 +7,14 @@ import { DisplayValue } from '../models/display-value.model'; name: 'multilingualism' }) export class MultilingualismPipe implements PipeTransform { - transform(value: DisplayValue, language: Language, _args?: []): string { + transform(value: DisplayValue | null, language: Language, _args?: []): string { let displayValue = ''; - if (value !== undefined) { + if (value) { displayValue = value[language]; if (displayValue === undefined || displayValue === '') { displayValue = value[Language.EN]; } } - return displayValue; } } diff --git a/marketplace-ui/src/app/shared/utils/common.utils.ts b/marketplace-ui/src/app/shared/utils/common.utils.ts index 2d6d2931b..267061d8e 100644 --- a/marketplace-ui/src/app/shared/utils/common.utils.ts +++ b/marketplace-ui/src/app/shared/utils/common.utils.ts @@ -1,10 +1,25 @@ +import { Language } from '../enums/language.enum'; import { ItemDropdown } from '../models/item-dropdown.model'; +import { DisplayValue } from './../models/display-value.model'; export class CommonUtils { - static getLabel(value: string, options: ItemDropdown[]): string { const currentLabel = options.find((option: ItemDropdown) => option.value === value)?.label; return currentLabel ?? options[0].label; } + static isContentDisplayedBasedOnLanguage( + value: DisplayValue, + language: Language + ) { + if ( + language === Language.DE && + value[language] !== '' && + value[language] !== undefined + ) { + return true; + } + + return value[Language.EN] !== '' && value[Language.EN] !== undefined; + } } diff --git a/marketplace-ui/src/assets/i18n/de.yaml b/marketplace-ui/src/assets/i18n/de.yaml index 8fb0bee34..7a844258d 100644 --- a/marketplace-ui/src/assets/i18n/de.yaml +++ b/marketplace-ui/src/assets/i18n/de.yaml @@ -23,6 +23,11 @@ common: util: Nützliches demos: Demos solution: Lösungen + error: + code: 'ERROR CODE: 404' + oops: 'Oops!' + description: 'Unsere Webseite macht eine kleine Pause. Offenbar finden wir die von Dir angefragte Seite nicht.' + buttonLabel: Zurück zur Startseite sort: label: Sortierung value: @@ -41,11 +46,11 @@ common: download: Download footer: newVersionsInfo: Neue Version verfügbar für Linux, Windows und MacOS Betriebssysteme. - downloadLatestLTSVersion: Download V10.0.18 - downloadLatestDevVersion: Download V11.2.1 + downloadLatestDevVersion: Download V11.3.1 ivyCompanyInfo: © 2023 Axon Ivy Inc privacyPolicy: Datenschutzrichtlinie termsOfService: Nutzungsbedingungen + downloadLatestLTSVersion: Download V10.0.23 product: detail: backToMainPage: Zurück zur Übersicht @@ -67,7 +72,6 @@ common: compatibility: Kompatibilität cost: Kosten language: Sprache - industry: Branche tag: Tags source: Quelle status: Status @@ -84,6 +88,8 @@ common: label: Zielplattform artifactSelector: label: Artefakt + contactUs: + label: Kontakt feedback: label: Rückmeldung successMessage: Ihr Feedback wurde erfolgreich übermittelt! @@ -102,4 +108,4 @@ common: noFeedbackMessage1: Deine Meinung ist gefragt. noFeedbackMessage2: '' rateFeedbackBtnLabel: Bewerte diesen Konnektor - reviewLabelNoYet: Noch keine Bewertungen \ No newline at end of file + reviewLabelNoYet: Noch keine Bewertungen diff --git a/marketplace-ui/src/assets/i18n/en.yaml b/marketplace-ui/src/assets/i18n/en.yaml index 2efd857d6..6d2b425ed 100644 --- a/marketplace-ui/src/assets/i18n/en.yaml +++ b/marketplace-ui/src/assets/i18n/en.yaml @@ -27,6 +27,11 @@ common: util: Utilities demos: Demos solution: Solutions + error: + code: 'ERROR CODE: 404' + oops: 'Oops!' + description: 'Our website is taking a break. It seems that we cannot find the page you requested.' + buttonLabel: Back to homepage sort: label: Sort by value: @@ -45,11 +50,11 @@ common: download: Download footer: newVersionsInfo: New version release available for Linux, Window and MacOS operating systems. - downloadLatestLTSVersion: Download V10.0.18 - downloadLatestDevVersion: Download V11.2.1 + downloadLatestDevVersion: Download V11.3.1 ivyCompanyInfo: © 2023 Axon Ivy Inc. privacyPolicy: Privacy Policy termsOfService: Terms of Service + downloadLatestLTSVersion: Download V10.0.23 product: detail: backToMainPage: Back to overview @@ -71,12 +76,11 @@ common: compatibility: Compatibility cost: Cost language: Language - industry: Industry tag: Tags source: Source status: Status moreInformation: More Information - contactUs: Contact Us + contactUs: Contact us install: buttonLabel: Install Now buttonLabelInDesigner: Install @@ -88,6 +92,8 @@ common: label: Choose target platform artifactSelector: label: Choose artifact + contactUs: + label: Contact us feedback: label: Feedback successMessage: Your feedback has been successfully submitted! diff --git a/marketplace-ui/src/assets/images/misc/robot-black.png b/marketplace-ui/src/assets/images/misc/robot-black.png new file mode 100644 index 000000000..409970b56 Binary files /dev/null and b/marketplace-ui/src/assets/images/misc/robot-black.png differ diff --git a/marketplace-ui/src/assets/images/misc/robot-mobile-black.png b/marketplace-ui/src/assets/images/misc/robot-mobile-black.png new file mode 100644 index 000000000..b28ea8d63 Binary files /dev/null and b/marketplace-ui/src/assets/images/misc/robot-mobile-black.png differ diff --git a/marketplace-ui/src/assets/images/misc/robot-mobile.png b/marketplace-ui/src/assets/images/misc/robot-mobile.png new file mode 100644 index 000000000..3d81d117b Binary files /dev/null and b/marketplace-ui/src/assets/images/misc/robot-mobile.png differ diff --git a/marketplace-ui/src/assets/images/misc/robot.png b/marketplace-ui/src/assets/images/misc/robot.png new file mode 100644 index 000000000..66279ab7b Binary files /dev/null and b/marketplace-ui/src/assets/images/misc/robot.png differ diff --git a/marketplace-ui/src/assets/scss/custom-style.scss b/marketplace-ui/src/assets/scss/custom-style.scss index b9a1c1633..c1d859f50 100644 --- a/marketplace-ui/src/assets/scss/custom-style.scss +++ b/marketplace-ui/src/assets/scss/custom-style.scss @@ -78,6 +78,8 @@ p { --star-color: #b0b0b0; --star-filled-color: #{$ivyPrimaryTextColorLight}; --text-no-rating-color: #757575; + --text-error-code-color: #757575; + --text-error-oops-color: #{$ivyPrimaryTextColorLight}; .bg-primary { background-color: #{$ivyPrimaryColorLight} !important; @@ -180,6 +182,9 @@ p { --star-filled-color: #{$white}; --text-no-rating-color: #A3A3A3; + --text-error-code-color: #A3A3A3; + --text-error-oops-color: #ffffff; + a { color: #{$white}; } diff --git a/marketplace-ui/src/environments/environment.ts b/marketplace-ui/src/environments/environment.ts index 6135e3642..45d094887 100644 --- a/marketplace-ui/src/environments/environment.ts +++ b/marketplace-ui/src/environments/environment.ts @@ -1,7 +1,7 @@ export const environment = { production: true, apiUrl: '/marketplace-service', - githubClientId: 'Iv23livu9HbsC4Q24eSC', + githubClientId: 'Iv23licGsRvdWBGRYJfT', githubAuthCallbackPath: '/auth/github/callback', dayInMiliseconds: 86400000 };
- <!-- {{ productModuleContent.name }} --> - - <dependency> - - <groupId>{{ productModuleContent.groupId }}</groupId> - - <artifactId>{{ - productModuleContent.artifactId - }}</artifactId> - - <version>{{ - selectedVersion.replaceAll('Version ', '') - }}</version> - - <type>{{ productModuleContent.type }}</type> - - </dependency> -
+ + <!-- {{ getProductName() }} --> + <dependency> + <groupId>{{ productDetail.productModuleContent.groupId }}</groupId> + <artifactId>{{productDetail.productModuleContent.artifactId}}</artifactId> + <version>{{ selectedVersion.replaceAll('Version ', '') }}</version> + <type>{{ productDetail.productModuleContent.type }}</type> + </dependency> + +
+ <!-- {{ getProductName() }} --> + <dependency> + <groupId>{{ productDetail.productModuleContent.groupId }}</groupId> + <artifactId>{{productDetail.productModuleContent.artifactId}}</artifactId> + <version>{{ selectedVersion.replaceAll('Version ', '') }}</version> + <type>{{ productDetail.productModuleContent.type }}</type> + </dependency> +
Please open the - Axon Ivy Market - inside your - Axon Ivy Designer - (minimum version 9.2.0)