This is an automated email from the ASF dual-hosted git repository.

marat pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-karavan.git


The following commit(s) were added to refs/heads/main by this push:
     new 37a0d298 Introduce validation framework (#1041)
37a0d298 is described below

commit 37a0d298105ef07ff4372c7a446644264067e14b
Author: Mario Volf <[email protected]>
AuthorDate: Wed Jan 3 18:11:18 2024 +0100

    Introduce validation framework (#1041)
    
    * mvolf - web - Implement copy project functionality
    
    * mvolf - web - Implement copy project functionality
    
    * #973 - Copy project
    
    * #973 - Copy project
    
    * #973 - Don't copy project if existing project-id is used.
    
    * #973 - Fixed code formatting
    
    * #973 - Handle duplicate project id on project create and copy. Some 
additional small modifications and fixes.
    
    * Introduced validation framework and used it for create and copy project 
actions
    
    ---------
    
    Co-authored-by: mvolf <[email protected]>
---
 karavan-space/package-lock.json                    |   2 +-
 karavan-web/karavan-app/pom.xml                    |   4 +
 .../apache/camel/karavan/api/ProjectResource.java  |  27 +---
 .../camel/karavan/infinispan/model/Project.java    |   5 +
 .../camel/karavan/service/ProjectService.java      |  25 ++-
 .../apache/camel/karavan/shared/error/Error.java   |  78 ++++++++++
 .../camel/karavan/shared/error/ErrorResponse.java  |  59 ++++++++
 .../exception/ExceptionToResponseMapper.java       |  66 ++++++++
 .../shared/exception/ProjectExistsException.java   |   7 -
 .../shared/exception/ValidationException.java      |  40 +++++
 .../karavan/shared/validation/SimpleValidator.java |  33 ++++
 .../karavan/shared/validation/ValidationError.java |  59 ++++++++
 .../shared/validation/ValidationResult.java        |  45 ++++++
 .../camel/karavan/shared/validation/Validator.java |  23 +++
 .../validation/project/ProjectModifyValidator.java |  40 +++++
 .../karavan-app/src/main/webui/package-lock.json   |  75 +++++++++
 .../karavan-app/src/main/webui/package.json        |   6 +-
 .../src/main/webui/src/api/KaravanApi.tsx          |  35 +----
 .../src/main/webui/src/api/ProjectService.ts       |   6 +-
 .../main/webui/src/projects/CreateProjectModal.tsx | 164 ++++++++++++++------
 .../main/webui/src/services/CreateServiceModal.tsx | 167 +++++++++++++++------
 .../src/main/webui/src/shared/error/Error.ts       |  11 ++
 .../main/webui/src/shared/error/ErrorResponse.ts   |  15 ++
 .../webui/src/shared/error/ProjectExistsError.ts   |   6 -
 .../src/shared/error/UseResponseErrorHandler.ts    |  43 ++++++
 25 files changed, 863 insertions(+), 178 deletions(-)

diff --git a/karavan-space/package-lock.json b/karavan-space/package-lock.json
index 902d4ddc..71287714 100644
--- a/karavan-space/package-lock.json
+++ b/karavan-space/package-lock.json
@@ -45,7 +45,7 @@
       }
     },
     "../karavan-core": {
-      "version": "4.1.1",
+      "version": "4.3.0",
       "license": "Apache-2.0",
       "dependencies": {
         "@types/js-yaml": "^4.0.7",
diff --git a/karavan-web/karavan-app/pom.xml b/karavan-web/karavan-app/pom.xml
index 59c20d87..4481d289 100644
--- a/karavan-web/karavan-app/pom.xml
+++ b/karavan-web/karavan-app/pom.xml
@@ -146,6 +146,10 @@
             <groupId>io.quarkus</groupId>
             <artifactId>quarkus-scheduler</artifactId>
         </dependency>
+        <dependency>
+            <groupId>io.quarkus</groupId>
+            <artifactId>quarkus-hibernate-validator</artifactId>
+        </dependency>
         <dependency>
             <groupId>io.micrometer</groupId>
             <artifactId>micrometer-registry-prometheus</artifactId>
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/api/ProjectResource.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/api/ProjectResource.java
index 29541520..d9d95f66 100644
--- 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/api/ProjectResource.java
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/api/ProjectResource.java
@@ -30,7 +30,6 @@ import org.apache.camel.karavan.infinispan.model.Project;
 import org.apache.camel.karavan.kubernetes.KubernetesService;
 import org.apache.camel.karavan.service.ConfigService;
 import org.apache.camel.karavan.service.ProjectService;
-import org.apache.camel.karavan.shared.exception.ProjectExistsException;
 import org.jboss.logging.Logger;
 
 import java.net.URLDecoder;
@@ -82,17 +81,8 @@ public class ProjectResource {
     @POST
     @Produces(MediaType.APPLICATION_JSON)
     @Consumes(MediaType.APPLICATION_JSON)
-    public Response save(Project project) throws Exception {
-        try {
-            Project createdProject = projectService.save(project);
-            return Response.ok().entity(createdProject).build();
-        } catch (ProjectExistsException exception) {
-            LOGGER.error(exception.getMessage());
-            return 
Response.status(Response.Status.CONFLICT).entity(exception.getMessage()).build();
-        } catch (Exception exception) {
-            LOGGER.error(exception.getMessage());
-            return 
Response.serverError().entity(exception.getMessage()).build();
-        }
+    public Project save(Project project) {
+        return projectService.save(project);
     }
 
     @DELETE
@@ -180,16 +170,7 @@ public class ProjectResource {
     @Produces(MediaType.APPLICATION_JSON)
     @Consumes(MediaType.APPLICATION_JSON)
     @Path("/copy/{sourceProject}")
-    public Response copy(@PathParam("sourceProject") String sourceProject, 
Project project) throws Exception {
-        try {
-            Project copiedProject = projectService.copy(sourceProject, 
project);
-            return Response.ok().entity(copiedProject).build();
-        } catch (ProjectExistsException exception) {
-            LOGGER.error(exception.getMessage());
-            return 
Response.status(Response.Status.CONFLICT).entity(exception.getMessage()).build();
-        } catch (Exception exception) {
-            LOGGER.error(exception.getMessage());
-            return 
Response.serverError().entity(exception.getMessage()).build();
-        }
+    public Project copy(@PathParam("sourceProject") String sourceProject, 
Project project) {
+        return projectService.copy(sourceProject, project);
     }
 }
\ No newline at end of file
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/infinispan/model/Project.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/infinispan/model/Project.java
index 6c4beac4..87311fac 100644
--- 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/infinispan/model/Project.java
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/infinispan/model/Project.java
@@ -23,6 +23,8 @@ import org.infinispan.protostream.annotations.ProtoField;
 
 import java.time.Instant;
 
+import jakarta.validation.constraints.NotBlank;
+
 public class Project {
     public static final String CACHE = "projects";
 
@@ -36,10 +38,13 @@ public class Project {
     }
 
     @ProtoField(number = 1)
+    @NotBlank
     String projectId;
     @ProtoField(number = 2)
+    @NotBlank
     String name;
     @ProtoField(number = 3)
+    @NotBlank
     String description;
     @ProtoField(number = 4)
     String lastCommit;
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/service/ProjectService.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/service/ProjectService.java
index 75d0f004..64522eb6 100644
--- 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/service/ProjectService.java
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/service/ProjectService.java
@@ -36,7 +36,7 @@ import org.apache.camel.karavan.kubernetes.KubernetesService;
 import org.apache.camel.karavan.registry.RegistryService;
 import org.apache.camel.karavan.shared.Constants;
 import org.apache.camel.karavan.shared.Property;
-import org.apache.camel.karavan.shared.exception.ProjectExistsException;
+import org.apache.camel.karavan.validation.project.ProjectModifyValidator;
 import org.apache.commons.lang3.StringUtils;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.microprofile.config.inject.ConfigProperty;
@@ -62,6 +62,10 @@ public class ProjectService implements HealthCheck {
     @ConfigProperty(name = "karavan.environment")
     String environment;
 
+
+    @Inject
+    ProjectModifyValidator projectModifyValidator;
+
     @Inject
     InfinispanService infinispanService;
 
@@ -167,11 +171,8 @@ public class ProjectService implements HealthCheck {
         }
     }
 
-    public Project save(Project project) throws Exception {
-        boolean projectExists = 
infinispanService.getProject(project.getProjectId()) != null;
-        if(projectExists) {
-            throw new ProjectExistsException("Project with project id [" + 
project.getProjectId() + "] already exists");
-        }
+    public Project save(Project project) {
+        projectModifyValidator.validate(project).failOnError();
 
         infinispanService.saveProject(project);
 
@@ -188,11 +189,9 @@ public class ProjectService implements HealthCheck {
         return project;
     }
 
-    public Project copy(String sourceProjectId, Project project) throws 
Exception {
-        boolean projectExists = 
infinispanService.getProject(project.getProjectId()) != null;
-        if(projectExists) {
-            throw new ProjectExistsException("Project with project id [" + 
project.getProjectId() + "] already exists");
-        }
+    public Project copy(String sourceProjectId, Project project) {
+        projectModifyValidator.validate(project).failOnError();
+
         Project sourceProject = infinispanService.getProject(sourceProjectId);
 
         // Save project
@@ -208,7 +207,7 @@ public class ProjectService implements HealthCheck {
                         e -> {
                             ProjectFile file = e.getValue();
                             file.setProjectId(project.getProjectId());
-                            if(Objects.equals(file.getName(), 
APPLICATION_PROPERTIES_FILENAME)) {
+                            if (Objects.equals(file.getName(), 
APPLICATION_PROPERTIES_FILENAME)) {
                                 modifyPropertyFileOnProjectCopy(file, 
sourceProject, project);
                             }
                             return file;
@@ -219,7 +218,7 @@ public class ProjectService implements HealthCheck {
         if (!ConfigService.inKubernetes()) {
             ProjectFile projectCompose = 
codeService.createInitialProjectCompose(project);
             infinispanService.saveProjectFile(projectCompose);
-        } else if (kubernetesService.isOpenshift()){
+        } else if (kubernetesService.isOpenshift()) {
             ProjectFile projectCompose = 
codeService.createInitialDeployment(project);
             infinispanService.saveProjectFile(projectCompose);
         }
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/error/Error.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/error/Error.java
new file mode 100644
index 00000000..dee5372e
--- /dev/null
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/error/Error.java
@@ -0,0 +1,78 @@
+package org.apache.camel.karavan.shared.error;
+
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class Error {
+    private String field;
+    private String message;
+    private String code;
+
+    public Error(String message) {
+        this.message = message;
+    }
+
+    public Error(String field, String message) {
+        this.field = field;
+        this.message = message;
+    }
+
+    public Error(String field, String message, String code) {
+        this.field = field;
+        this.message = message;
+        this.code = code;
+    }
+
+    public String getField() {
+        return field;
+    }
+
+    public void setField(String field) {
+        this.field = field;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public void setMessage(String message) {
+        this.message = message;
+    }
+
+    public String getCode() {
+        return code;
+    }
+
+    public void setCode(String code) {
+        this.code = code;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        Error error = (Error) o;
+        return Objects.equals(field, error.field) && Objects.equals(message, 
error.message)
+                && Objects.equals(code, error.code);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(field, message, code);
+    }
+
+    @Override
+    public String toString() {
+        return "Error{" +
+                "field='" + field + '\'' +
+                ", message='" + message + '\'' +
+                ", code='" + code + '\'' +
+                '}';
+    }
+}
\ No newline at end of file
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/error/ErrorResponse.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/error/ErrorResponse.java
new file mode 100644
index 00000000..9749bdf8
--- /dev/null
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/error/ErrorResponse.java
@@ -0,0 +1,59 @@
+package org.apache.camel.karavan.shared.error;
+
+import java.util.Collection;
+import java.util.Date;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ErrorResponse {
+    private final Date timestamp;
+    private final int status;
+    private final String error;
+    private final String message;
+    private Collection<Error> errors;
+
+    public ErrorResponse(
+            int httpStatus,
+            String reasonPhrase,
+            String message
+    ) {
+        this.timestamp = new Date();
+        this.status = httpStatus;
+        this.error = reasonPhrase;
+        this.message = message;
+    }
+
+    public ErrorResponse(
+            int httpStatus,
+            String reasonPhrase,
+            String message,
+            Collection<Error> errors
+    ) {
+        this.timestamp = new Date();
+        this.status = httpStatus;
+        this.error = reasonPhrase;
+        this.message = message;
+        this.errors = errors;
+    }
+
+    public Date getTimestamp() {
+        return timestamp;
+    }
+
+    public int getStatus() {
+        return status;
+    }
+
+    public String getError() {
+        return error;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public Collection<Error> getErrors() {
+        return errors;
+    }
+}
\ No newline at end of file
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/exception/ExceptionToResponseMapper.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/exception/ExceptionToResponseMapper.java
new file mode 100644
index 00000000..b26b49fc
--- /dev/null
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/exception/ExceptionToResponseMapper.java
@@ -0,0 +1,66 @@
+package org.apache.camel.karavan.shared.exception;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.camel.karavan.shared.error.Error;
+import org.apache.camel.karavan.shared.error.ErrorResponse;
+import org.jboss.logging.Logger;
+import org.jboss.resteasy.reactive.RestResponse;
+import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
+
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+public class ExceptionToResponseMapper {
+    private static final Logger LOGGER = 
Logger.getLogger(ExceptionToResponseMapper.class.getName());
+
+    @ServerExceptionMapper
+    public RestResponse<Object> validationException(ValidationException 
exception) {
+        List<Error> errors = (exception).getErrors()
+                .stream()
+                .map(fieldError -> new Error(fieldError.getField(), 
fieldError.getMessage()))
+                .toList();
+
+        return logAndBuildResponse(
+                exception,
+                Response.Status.BAD_REQUEST.getStatusCode(),
+                Response.Status.BAD_REQUEST.getReasonPhrase(),
+                errors
+        );
+    }
+
+    private RestResponse<Object> logAndBuildResponse(
+            Throwable exception,
+            int status,
+            String reasonPhrase,
+            Collection<Error> errors
+    ) {
+        LOGGER.error("Error occurred", exception);
+
+        String cause = (exception.getCause() != null) ? 
exception.getCause().getMessage() : null;
+        String message = (cause != null) ? exception.getMessage() + ", caused 
by: " + cause : exception.getMessage();
+
+        if (message == null) {
+            message = exception.getClass().toString();
+        }
+
+        // Hide errors array if there are no errors and leave just error 
message
+        if (errors != null && errors.isEmpty()) {
+            errors = null;
+        }
+
+        ErrorResponse responseBody = new ErrorResponse(
+                status,
+                reasonPhrase,
+                message,
+                errors
+        );
+
+        return RestResponse.ResponseBuilder
+                .create(status)
+                .entity(responseBody)
+                .type(MediaType.APPLICATION_JSON)
+                .build();
+    }
+}
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/exception/ProjectExistsException.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/exception/ProjectExistsException.java
deleted file mode 100644
index 478028bb..00000000
--- 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/exception/ProjectExistsException.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.apache.camel.karavan.shared.exception;
-
-public class ProjectExistsException extends RuntimeException {
-    public ProjectExistsException(String message) {
-        super(message);
-    }
-}
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/exception/ValidationException.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/exception/ValidationException.java
new file mode 100644
index 00000000..c56fb075
--- /dev/null
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/exception/ValidationException.java
@@ -0,0 +1,40 @@
+package org.apache.camel.karavan.shared.exception;
+
+import java.util.Collection;
+import java.util.List;
+
+import org.apache.camel.karavan.shared.validation.ValidationError;
+
+public class ValidationException extends RuntimeException {
+    private final transient Collection<ValidationError> errors;
+
+    public ValidationException(String message, Collection<ValidationError> 
errors) {
+        super(message);
+        this.errors = errors;
+    }
+
+    public ValidationException(String message, ValidationError error) {
+        super(message);
+        this.errors = List.of(error);
+    }
+
+    public ValidationException(String message) {
+        super(message);
+        this.errors = List.of();
+    }
+
+    public ValidationError getFirstErrorOrNull() {
+        return errors.stream().findFirst().orElse(null);
+    }
+
+    public Collection<ValidationError> getErrors() {
+        return errors;
+    }
+
+    @Override
+    public String toString() {
+        return "ValidationException{" +
+                "errors=" + errors +
+                '}';
+    }
+}
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/SimpleValidator.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/SimpleValidator.java
new file mode 100644
index 00000000..b9d095cd
--- /dev/null
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/SimpleValidator.java
@@ -0,0 +1,33 @@
+package org.apache.camel.karavan.shared.validation;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.validation.ConstraintViolation;
+import jakarta.validation.Validator;
+
+@ApplicationScoped
+public class SimpleValidator {
+    private final Validator validator;
+
+    public SimpleValidator(Validator validator) {
+        this.validator = validator;
+    }
+
+    public <T> void validate(T object, List<ValidationError> errors) {
+        Set<ConstraintViolation<T>> violations = validator.validate(object);
+
+        violations
+                .forEach(violation -> errors.add(new 
ValidationError(violation.getPropertyPath().toString(), 
violation.getMessage())));
+    }
+
+    public <T> ValidationResult validate(T object) {
+        List<ValidationError> errors = new ArrayList<>();
+
+        validate(object, errors);
+
+        return new ValidationResult(errors);
+    }
+}
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/ValidationError.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/ValidationError.java
new file mode 100644
index 00000000..7c9e3b39
--- /dev/null
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/ValidationError.java
@@ -0,0 +1,59 @@
+package org.apache.camel.karavan.shared.validation;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Objects;
+
+public class ValidationError implements Serializable {
+    @Serial
+    private static final long serialVersionUID = 1905122041950251207L;
+
+    private String field;
+    private String message;
+
+    public ValidationError(String field, String message) {
+        this.field = field;
+        this.message = message;
+    }
+
+    public String getField() {
+        return field;
+    }
+
+    public void setField(String field) {
+        this.field = field;
+    }
+
+    public String getMessage() {
+        return message;
+    }
+
+    public void setMessage(String message) {
+        this.message = message;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (this == o) {
+            return true;
+        }
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        ValidationError that = (ValidationError) o;
+        return Objects.equals(field, that.field) && Objects.equals(message, 
that.message);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(field, message);
+    }
+
+    @Override
+    public String toString() {
+        return "ValidationError{" +
+                "field='" + field + '\'' +
+                ", message='" + message + '\'' +
+                '}';
+    }
+}
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/ValidationResult.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/ValidationResult.java
new file mode 100644
index 00000000..e9563634
--- /dev/null
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/ValidationResult.java
@@ -0,0 +1,45 @@
+package org.apache.camel.karavan.shared.validation;
+
+import java.util.List;
+
+import org.apache.camel.karavan.shared.exception.ValidationException;
+
+public class ValidationResult {
+    private List<ValidationError> errors;
+
+    ValidationResult(List<ValidationError> errors) {
+        this.errors = errors;
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    public static class Builder {
+        private List<ValidationError> errors;
+
+        Builder() {}
+
+        public Builder errors(List<ValidationError> errors) {
+            this.errors = errors;
+            return this;
+        }
+
+        public ValidationResult build() {
+            return new ValidationResult(errors);
+        }
+
+        @Override
+        public String toString() {
+            return "Builder{" +
+                    "errors=" + errors +
+                    '}';
+        }
+    }
+
+    public void failOnError() {
+        if (!errors.isEmpty()) {
+            throw new ValidationException("Object failed validation", errors);
+        }
+    }
+}
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/Validator.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/Validator.java
new file mode 100644
index 00000000..c48fbfc5
--- /dev/null
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/shared/validation/Validator.java
@@ -0,0 +1,23 @@
+package org.apache.camel.karavan.shared.validation;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public abstract class Validator<T> {
+    public ValidationResult validate(T object) {
+        List<ValidationError> errors = new ArrayList<>();
+
+        validationRules(object, errors);
+
+        return ValidationResult.builder()
+                .errors(errors)
+                .build();
+    }
+
+    protected abstract void validationRules(T object, List<ValidationError> 
errors);
+
+    public static class ValidationErrors {
+        private ValidationErrors() {
+        }
+    }
+}
diff --git 
a/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/validation/project/ProjectModifyValidator.java
 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/validation/project/ProjectModifyValidator.java
new file mode 100644
index 00000000..3ba08dac
--- /dev/null
+++ 
b/karavan-web/karavan-app/src/main/java/org/apache/camel/karavan/validation/project/ProjectModifyValidator.java
@@ -0,0 +1,40 @@
+package org.apache.camel.karavan.validation.project;
+
+import java.util.List;
+
+import org.apache.camel.karavan.infinispan.InfinispanService;
+import org.apache.camel.karavan.infinispan.model.Project;
+import org.apache.camel.karavan.shared.validation.SimpleValidator;
+import org.apache.camel.karavan.shared.validation.ValidationError;
+import org.apache.camel.karavan.shared.validation.Validator;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+@ApplicationScoped
+public class ProjectModifyValidator extends Validator<Project> {
+    private static final List<String> FORBIDDEN_PROJECT_ID_VALUES = 
List.of("templates", "kamelets");
+
+    private final SimpleValidator simpleValidator;
+    private final InfinispanService infinispanService;
+
+    public ProjectModifyValidator(SimpleValidator simpleValidator, 
InfinispanService infinispanService) {
+        this.simpleValidator = simpleValidator;
+        this.infinispanService = infinispanService;
+    }
+
+
+    @Override
+    protected void validationRules(Project value, List<ValidationError> 
errors) {
+        simpleValidator.validate(value, errors);
+
+        boolean projectIdExists = 
infinispanService.getProject(value.getProjectId()) != null;
+
+        if(projectIdExists) {
+            errors.add(new ValidationError("projectId", "Project ID already 
exists"));
+        }
+
+        if(FORBIDDEN_PROJECT_ID_VALUES.contains(value.getProjectId())) {
+            errors.add(new ValidationError("projectId", "'templates' or 
'kamelets' can't be used as project ID"));
+        }
+    }
+}
diff --git a/karavan-web/karavan-app/src/main/webui/package-lock.json 
b/karavan-web/karavan-app/src/main/webui/package-lock.json
index 67918528..67f8f5ab 100644
--- a/karavan-web/karavan-app/src/main/webui/package-lock.json
+++ b/karavan-web/karavan-app/src/main/webui/package-lock.json
@@ -8,6 +8,8 @@
       "name": "karavan",
       "version": "4.3.1",
       "dependencies": {
+        "@hookform/error-message": "^2.0.1",
+        "@hookform/resolvers": "^2.9.10",
         "@microsoft/fetch-event-source": "^2.0.1",
         "@monaco-editor/react": "4.6.0",
         "@patternfly/patternfly": "^5.1.0",
@@ -29,9 +31,11 @@
         "keycloak-js": "23.0.1",
         "react": "18.2.0",
         "react-dom": "18.2.0",
+        "react-hook-form": "^7.49.1",
         "react-router-dom": "^6.15.0",
         "rxjs": "7.8.1",
         "uuid": "9.0.1",
+        "yup": "^1.3.2",
         "zustand": "^4.4.3"
       },
       "devDependencies": {
@@ -2530,6 +2534,24 @@
         "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
       }
     },
+    "node_modules/@hookform/error-message": {
+      "version": "2.0.1",
+      "resolved": 
"https://registry.npmjs.org/@hookform/error-message/-/error-message-2.0.1.tgz";,
+      "integrity": 
"sha512-U410sAr92xgxT1idlu9WWOVjndxLdgPUHEB8Schr27C9eh7/xUnITWpCMF93s+lGiG++D4JnbSnrb5A21AdSNg==",
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0",
+        "react-hook-form": "^7.0.0"
+      }
+    },
+    "node_modules/@hookform/resolvers": {
+      "version": "2.9.11",
+      "resolved": 
"https://registry.npmjs.org/@hookform/resolvers/-/resolvers-2.9.11.tgz";,
+      "integrity": 
"sha512-bA3aZ79UgcHj7tFV7RlgThzwSSHZgvfbt2wprldRkYBcMopdMvHyO17Wwp/twcJasNFischFfS7oz8Katz8DdQ==",
+      "peerDependencies": {
+        "react-hook-form": "^7.0.0"
+      }
+    },
     "node_modules/@humanwhocodes/config-array": {
       "version": "0.11.13",
       "resolved": 
"https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz";,
@@ -17725,6 +17747,11 @@
         "react-is": "^16.13.1"
       }
     },
+    "node_modules/property-expr": {
+      "version": "2.0.6",
+      "resolved": 
"https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz";,
+      "integrity": 
"sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA=="
+    },
     "node_modules/property-information": {
       "version": "6.4.0",
       "resolved": 
"https://registry.npmjs.org/property-information/-/property-information-6.4.0.tgz";,
@@ -18079,6 +18106,22 @@
       "resolved": 
"https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz";,
       "integrity": 
"sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
     },
+    "node_modules/react-hook-form": {
+      "version": "7.49.2",
+      "resolved": 
"https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.49.2.tgz";,
+      "integrity": 
"sha512-TZcnSc17+LPPVpMRIDNVITY6w20deMdNi6iehTFLV1x8SqThXGwu93HjlUVU09pzFgZH7qZOvLMM7UYf2ShAHA==",
+      "engines": {
+        "node": ">=18",
+        "pnpm": "8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/react-hook-form";
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17 || ^18"
+      }
+    },
     "node_modules/react-is": {
       "version": "16.13.1",
       "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz";,
@@ -20442,6 +20485,11 @@
       "integrity": 
"sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
       "dev": true
     },
+    "node_modules/tiny-case": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz";,
+      "integrity": 
"sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q=="
+    },
     "node_modules/tmpl": {
       "version": "1.0.5",
       "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz";,
@@ -20478,6 +20526,11 @@
         "node": ">=0.6"
       }
     },
+    "node_modules/toposort": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz";,
+      "integrity": 
"sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg=="
+    },
     "node_modules/tough-cookie": {
       "version": "4.1.3",
       "resolved": 
"https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz";,
@@ -22571,6 +22624,28 @@
         "url": "https://github.com/sponsors/sindresorhus";
       }
     },
+    "node_modules/yup": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/yup/-/yup-1.3.3.tgz";,
+      "integrity": 
"sha512-v8QwZSsHH2K3/G9WSkp6mZKO+hugKT1EmnMqLNUcfu51HU9MDyhlETT/JgtzprnrnQHPWsjc6MUDMBp/l9fNnw==",
+      "dependencies": {
+        "property-expr": "^2.0.5",
+        "tiny-case": "^1.0.3",
+        "toposort": "^2.0.2",
+        "type-fest": "^2.19.0"
+      }
+    },
+    "node_modules/yup/node_modules/type-fest": {
+      "version": "2.19.0",
+      "resolved": 
"https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz";,
+      "integrity": 
"sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
+      "engines": {
+        "node": ">=12.20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus";
+      }
+    },
     "node_modules/zustand": {
       "version": "4.4.7",
       "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.7.tgz";,
diff --git a/karavan-web/karavan-app/src/main/webui/package.json 
b/karavan-web/karavan-app/src/main/webui/package.json
index 92130f83..3b8aa31d 100644
--- a/karavan-web/karavan-app/src/main/webui/package.json
+++ b/karavan-web/karavan-app/src/main/webui/package.json
@@ -40,6 +40,8 @@
     "@types/js-yaml": "4.0.7",
     "@types/node": "18.16.3",
     "@types/uuid": "9.0.1",
+    "@hookform/error-message": "^2.0.1",
+    "@hookform/resolvers": "^2.9.10",
     "@uiw/react-markdown-preview": "^5.0.3",
     "axios": "1.6.2",
     "buffer": "6.0.3",
@@ -50,10 +52,12 @@
     "keycloak-js": "23.0.1",
     "react": "18.2.0",
     "react-dom": "18.2.0",
+    "react-hook-form": "^7.49.1",
     "react-router-dom": "^6.15.0",
     "rxjs": "7.8.1",
     "uuid": "9.0.1",
-    "zustand": "^4.4.3"
+    "zustand": "^4.4.3",
+    "yup": "^1.3.2"
   },
   "devDependencies": {
     "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
diff --git a/karavan-web/karavan-app/src/main/webui/src/api/KaravanApi.tsx 
b/karavan-web/karavan-app/src/main/webui/src/api/KaravanApi.tsx
index e5d54d05..95ad0698 100644
--- a/karavan-web/karavan-app/src/main/webui/src/api/KaravanApi.tsx
+++ b/karavan-web/karavan-app/src/main/webui/src/api/KaravanApi.tsx
@@ -28,7 +28,6 @@ import {Buffer} from 'buffer';
 import {SsoApi} from "./SsoApi";
 import {EventStreamContentType, fetchEventSource} from 
"@microsoft/fetch-event-source";
 import {ProjectEventBus} from "./ProjectEventBus";
-import {ProjectExistsError} from "../shared/error/ProjectExistsError";
 
 axios.defaults.headers.common['Accept'] = 'application/json';
 axios.defaults.headers.common['Content-Type'] = 'application/json';
@@ -220,38 +219,12 @@ export class KaravanApi {
         });
     }
 
-    static async postProject(project: Project): Promise<[Error | null, Project 
| null]> {
-        return instance.post('/api/project', project)
-            .then(res => {
-                if(res.status === 200 || res.status === 201) {
-                    return [null, res.data as Project] as [null, Project]
-                } else {
-                    return [Error("Error while creating project"), null] as 
[Error, null]
-                }
-            }).catch(err => {
-                if(err.response?.status === 409) {
-                    return [new ProjectExistsError("Project with id " + 
project.projectId + " already exists."), null] as [Error, null]
-                } else {
-                    return [err as Error, null] as [Error, null];
-                }
-            });
+    static async postProject(project: Project) {
+        return instance.post('/api/project', project);
     }
 
-    static async copyProject(sourceProject: string, project: Project): 
Promise<[Error | null, Project | null]> {
-        return instance.post('/api/project/copy/' + sourceProject, project)
-            .then(res => {
-                if(res.status === 200 || res.status === 201) {
-                    return [null, res.data as Project] as [null, Project]
-                } else {
-                    return [Error("Error while copying project"), null] as 
[Error, null]
-                }
-            }).catch(err => {
-                if(err.response?.status === 409) {
-                    return [new ProjectExistsError("Project with id " + 
project.projectId + " already exists."), null] as [Error, null]
-                } else {
-                    return [err as Error, null] as [Error, null];
-                }
-            });
+    static async copyProject(sourceProject: string, project: Project) {
+        return instance.post('/api/project/copy/' + sourceProject, project);
     }
 
     static async deleteProject(project: Project, deleteContainers: boolean, 
after: (res: AxiosResponse<any>) => void) {
diff --git a/karavan-web/karavan-app/src/main/webui/src/api/ProjectService.ts 
b/karavan-web/karavan-app/src/main/webui/src/api/ProjectService.ts
index 6f567937..4cefba6a 100644
--- a/karavan-web/karavan-app/src/main/webui/src/api/ProjectService.ts
+++ b/karavan-web/karavan-app/src/main/webui/src/api/ProjectService.ts
@@ -237,11 +237,13 @@ export class ProjectService {
     }
 
     public static async createProject(project: Project) {
-        return KaravanApi.postProject(project);
+        const result = await KaravanApi.postProject(project);
+        return result.data;
     }
 
     public static async copyProject(sourceProject: string, project: Project) {
-        return KaravanApi.copyProject(sourceProject, project);
+        const result = await KaravanApi.copyProject(sourceProject, project);
+        return result.data;
     }
 
     public static createFile(file: ProjectFile) {
diff --git 
a/karavan-web/karavan-app/src/main/webui/src/projects/CreateProjectModal.tsx 
b/karavan-web/karavan-app/src/main/webui/src/projects/CreateProjectModal.tsx
index b92a2218..2e1d670d 100644
--- a/karavan-web/karavan-app/src/main/webui/src/projects/CreateProjectModal.tsx
+++ b/karavan-web/karavan-app/src/main/webui/src/projects/CreateProjectModal.tsx
@@ -17,58 +17,110 @@
 
 import React, {useState} from 'react';
 import {
-    Button, Form, FormGroup, FormHelperText, HelperText, HelperTextItem,
+    Alert,
+    Button,
+    Divider,
+    Form,
+    FormGroup,
+    Grid,
     Modal,
-    ModalVariant, TextInput, Text
+    ModalVariant,
+    Text,
+    TextInput
 } from '@patternfly/react-core';
 import '../designer/karavan.css';
 import {useProjectStore} from "../api/ProjectStore";
 import {ProjectService} from "../api/ProjectService";
 import {Project} from "../api/ProjectModels";
 import {CamelUi} from "../designer/utils/CamelUi";
-import {shallow} from "zustand/shallow";
 import {isEmpty} from "../util/StringUtils";
 import {EventBus} from "../designer/utils/EventBus";
-import {ProjectExistsError} from "../shared/error/ProjectExistsError";
+import {useResponseErrorHandler} from 
"../shared/error/UseResponseErrorHandler";
+import {useForm} from "react-hook-form";
+import * as yup from 'yup';
+import {yupResolver} from '@hookform/resolvers/yup';
+import {AxiosError} from "axios";
 
 export function CreateProjectModal () {
 
-    const [operation, project, setOperation] = useProjectStore((s) => 
[s.operation, s.project, s.setOperation], shallow)
+    const formValidationSchema = yup.object().shape({
+        name: yup
+            .string()
+            .required("Project name is required"),
+        description: yup
+            .string()
+            .required("Project description is required"),
+        projectId: yup
+            .string()
+            .required("Project ID is required")
+            .notOneOf(['templates', 'kamelets'], "'templates' or 'kamelets' 
can't be used as project ID")
+    });
+
+    const defaultFormValues = {
+        name: "",
+        description: "",
+        projectId: ""
+    };
+
+    const responseToFormErrorFields = new Map<string, string>([
+        ["projectId", "projectId"],
+        ["name", "name"],
+        ["description", "description"]
+    ]);
+
+    const {
+        register,
+        setError,
+        handleSubmit,
+        formState: { errors },
+        reset
+    } = useForm({
+        resolver: yupResolver(formValidationSchema),
+        mode: "onChange",
+        defaultValues: defaultFormValues
+    });
+
+    const {project, operation, setOperation} = useProjectStore();
     const [name, setName] = useState('');
     const [description, setDescription] = useState('');
     const [projectId, setProjectId] = useState('');
-    const [isValidationError, setIsValidationError] = useState(false);
+    const [globalErrors, registerResponseErrors, resetGlobalErrors] = 
useResponseErrorHandler(
+        responseToFormErrorFields,
+        setError
+    );
 
-    function cleanValues() {
-        setName("");
-        setDescription("");
-        setProjectId("");
+    function resetForm() {
+        resetGlobalErrors();
+        reset(defaultFormValues);
     }
 
     function closeModal() {
-        setOperation('none');
-        cleanValues();
+        setOperation("none");
+        resetForm();
     }
 
-    async function handleFormSubmit() {
-        setIsValidationError(false);
-        const [ err, createdProject ] = operation !== 'copy' ?
-            await ProjectService.createProject(new Project({name: name, 
description: description, projectId: projectId})) :
-            await ProjectService.copyProject(project?.projectId, new 
Project({name: name, description: description, projectId: projectId}));
-
-        if (createdProject !== null) {
-            EventBus.sendAlert( 'Success', 'Project created', 'success');
-            ProjectService.refreshProjectData(project.projectId);
-            ProjectService.refreshProjects();
-            setOperation('none');
-            cleanValues();
-        } else if (err !== null && err instanceof ProjectExistsError) {
-            setIsValidationError(true);
-        } else {
-            operation !== 'copy' ?
-                EventBus.sendAlert( 'Warning', 'Error when creating project:' 
+ err?.message, 'warning') :
-                EventBus.sendAlert( 'Warning', 'Error when copying project:' + 
err?.message, 'warning');
-        }
+    function handleFormSubmit() {
+        const action = operation !== "copy" ?
+            ProjectService.createProject(new Project({name: name, description: 
description, projectId: projectId})) :
+            ProjectService.copyProject(project?.projectId, new Project({name: 
name, description: description, projectId: projectId}))
+
+        return action
+            .then(() => handleOnFormSubmitSuccess())
+            .catch((error) => handleOnFormSubmitFailure(error));
+    }
+
+    function handleOnFormSubmitSuccess () {
+        const message = operation !== "copy" ? "Project successfully created." 
: "Project successfully copied.";
+
+        EventBus.sendAlert( "Success", message, "success");
+        ProjectService.refreshProjectData(projectId);
+        ProjectService.refreshProjects();
+        setOperation("none");
+        resetForm();
+    }
+
+    function handleOnFormSubmitFailure(error: AxiosError) {
+        registerResponseErrors(error);
     }
 
     function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void {
@@ -77,7 +129,6 @@ export function CreateProjectModal () {
         }
     }
 
-    const isReady = projectId && name && description && !['templates', 
'kamelets'].includes(projectId);
     return (
         <Modal
             title={operation !== 'copy' ? "Create new project" : "Copy project 
from " + project?.projectId}
@@ -86,37 +137,56 @@ export function CreateProjectModal () {
             onClose={closeModal}
             onKeyDown={onKeyDown}
             actions={[
-                <Button key="confirm" variant="primary" isDisabled={!isReady}
-                        onClick={handleFormSubmit}>Save</Button>,
+                <Button key="confirm" variant="primary" 
onClick={handleSubmit(handleFormSubmit)}>Save</Button>,
                 <Button key="cancel" variant="secondary" 
onClick={closeModal}>Cancel</Button>
             ]}
             className="new-project"
         >
             <Form isHorizontal={true} autoComplete="off">
                 <FormGroup label="Name" fieldId="name" isRequired>
-                    <TextInput className="text-field" type="text" id="name" 
name="name"
+                    <TextInput className="text-field" type="text" id="name"
                                value={name}
-                               onChange={(_, e) => setName(e)}/>
+                               validated={!!errors.name ? 'error' : 'default'}
+                               {...register('name')}
+                               onChange={(e, v) => {
+                                   setName(v);
+                                   register('name').onChange(e);
+                               }}
+                    />
+                    {!!errors.name && <Text  style={{ color: 'red', fontStyle: 
'italic'}}>{errors?.name?.message}</Text>}
                 </FormGroup>
                 <FormGroup label="Description" fieldId="description" 
isRequired>
-                    <TextInput className="text-field" type="text" 
id="description" name="description"
+                    <TextInput className="text-field" type="text" 
id="description"
                                value={description}
-                               onChange={(_, e) => setDescription(e)}/>
+                               validated={!!errors.description ? 'error' : 
'default'}
+                               {...register('description')}
+                               onChange={(e, v) => {
+                                   setDescription(v);
+                                   register('description').onChange(e);
+                               }}
+                    />
+                    {!!errors.description && <Text  style={{ color: 'red', 
fontStyle: 'italic'}}>{errors?.description?.message}</Text>}
                 </FormGroup>
                 <FormGroup label="Project ID" fieldId="projectId" isRequired>
-                    <TextInput className="text-field" type="text" 
id="projectId" name="projectId"
+                    <TextInput className="text-field" type="text" 
id="projectId"
                                value={projectId}
                                onFocus={e => setProjectId(projectId === '' ? 
CamelUi.nameFromTitle(name) : projectId)}
-                               onChange={(_, e) => 
setProjectId(CamelUi.nameFromTitle(e))}
-                               validated={isValidationError ? 'error' : 
'default'}
+                               validated={!!errors.projectId ? 'error' : 
'default'}
+                               {...register('projectId')}
+                               onChange={(e, v) => {
+                                   setProjectId(CamelUi.nameFromTitle(v));
+                                   register('projectId').onChange(e);
+                               }}
                     />
-                    {isValidationError && <Text  style={{ color: 'red', 
fontStyle: 'italic'}}>Project ID must be unique</Text>}
-                    <FormHelperText>
-                        <HelperText>
-                            <HelperTextItem>Unique project 
name</HelperTextItem>
-                        </HelperText>
-                    </FormHelperText>
+                    {!!errors.projectId && <Text  style={{ color: 'red', 
fontStyle: 'italic'}}>{errors?.projectId?.message}</Text>}
                 </FormGroup>
+                <Grid>
+                    {globalErrors &&
+                        globalErrors.map((error) => (
+                            <Alert title={error} key={error} 
variant="danger"></Alert>
+                        ))}
+                    <Divider role="presentation" />
+                </Grid>
             </Form>
         </Modal>
     )
diff --git 
a/karavan-web/karavan-app/src/main/webui/src/services/CreateServiceModal.tsx 
b/karavan-web/karavan-app/src/main/webui/src/services/CreateServiceModal.tsx
index 38977138..22f7b67a 100644
--- a/karavan-web/karavan-app/src/main/webui/src/services/CreateServiceModal.tsx
+++ b/karavan-web/karavan-app/src/main/webui/src/services/CreateServiceModal.tsx
@@ -17,56 +17,111 @@
 
 import React, {useState} from 'react';
 import {
-    Button, Form, FormGroup, FormHelperText, HelperText, HelperTextItem,
+    Alert,
+    Button,
+    Divider,
+    Form,
+    FormGroup,
+    Grid,
     Modal,
-    ModalVariant, TextInput, Text
+    ModalVariant,
+    Text,
+    TextInput
 } from '@patternfly/react-core';
 import '../designer/karavan.css';
-import {useAppConfigStore, useProjectStore} from "../api/ProjectStore";
+import {useProjectStore} from "../api/ProjectStore";
 import {ProjectService} from "../api/ProjectService";
 import {Project} from "../api/ProjectModels";
 import {CamelUi} from "../designer/utils/CamelUi";
 import {EventBus} from "../designer/utils/EventBus";
-import {ProjectExistsError} from "../shared/error/ProjectExistsError";
+import {useResponseErrorHandler} from 
"../shared/error/UseResponseErrorHandler";
+import {useForm} from "react-hook-form";
+import * as yup from 'yup';
+import {yupResolver} from '@hookform/resolvers/yup';
+import {AxiosError} from "axios";
 
 
 export function CreateServiceModal () {
 
-    const {project, operation} = useProjectStore();
+    const formValidationSchema = yup.object().shape({
+        name: yup
+            .string()
+            .required("Project name is required"),
+        description: yup
+            .string()
+            .required("Project description is required"),
+        projectId: yup
+            .string()
+            .required("Project ID is required")
+            .notOneOf(['templates', 'kamelets'], "'templates' or 'kamelets' 
can't be used as project ID")
+    });
+
+    const defaultFormValues = {
+        name: "",
+        description: "",
+        projectId: ""
+    };
+
+    const responseToFormErrorFields = new Map<string, string>([
+        ["projectId", "projectId"],
+        ["name", "name"],
+        ["description", "description"]
+    ]);
+
+    const {
+        register,
+        setError,
+        handleSubmit,
+        formState: { errors },
+        reset
+    } = useForm({
+        resolver: yupResolver(formValidationSchema),
+        mode: "onChange",
+        defaultValues: defaultFormValues
+    });
+
+    const {project, operation, setOperation} = useProjectStore();
     const [name, setName] = useState('');
     const [description, setDescription] = useState('');
     const [runtime, setRuntime] = useState('');
     const [projectId, setProjectId] = useState('');
-    const {config} = useAppConfigStore();
-    const [isValidationError, setIsValidationError] = useState(false);
-
-    function cleanValues()  {
-        setName("");
-        setDescription("");
-        setRuntime("");
-        setProjectId("");
+    const [globalErrors, registerResponseErrors, resetGlobalErrors] = 
useResponseErrorHandler(
+        responseToFormErrorFields,
+        setError
+    );
+
+    function resetForm() {
+        resetGlobalErrors();
+        reset(defaultFormValues);
     }
 
-    function closeModal () {
-        useProjectStore.setState({operation: "none"});
-        cleanValues();
+    function closeModal() {
+        setOperation("none");
+        resetForm();
     }
 
-    async function handleFormSubmit () {
-        setIsValidationError(false);
-        const [ err, createdProject ] = await ProjectService.createProject(new 
Project({name: name, description: description, projectId: projectId}));
-
-        if (createdProject !== null) {
-            EventBus.sendAlert( 'Success', 'Project created', 'success');
-            ProjectService.refreshProjectData(project.projectId);
-            ProjectService.refreshProjects();
-            useProjectStore.setState({operation: "none"});
-            cleanValues();
-        } else if (err !== null && err instanceof ProjectExistsError) {
-            setIsValidationError(true);
-        } else {
-            EventBus.sendAlert( 'Warning', 'Error when creating project:' + 
err?.message, 'warning');
-        }
+    function handleFormSubmit() {
+        const action = operation !== "copy" ?
+            ProjectService.createProject(new Project({name: name, description: 
description, projectId: projectId})) :
+            ProjectService.copyProject(project?.projectId, new Project({name: 
name, description: description, projectId: projectId}))
+
+        return action
+            .then(() => handleOnFormSubmitSuccess())
+            .catch((error) => handleOnFormSubmitFailure(error));
+    }
+
+    function handleOnFormSubmitSuccess () {
+        const message = operation !== "copy" ? "Project successfully created." 
: "Project successfully copied.";
+
+        EventBus.sendAlert( "Success", message, "success");
+        ProjectService.refreshProjectData(projectId);
+        ProjectService.refreshProjects();
+        setOperation("none");
+        resetForm();
+    }
+
+    function handleOnFormSubmitFailure(error: AxiosError) {
+        registerResponseErrors(error);
     }
 
     function onKeyDown (event: React.KeyboardEvent<HTMLDivElement>): void {
@@ -75,7 +130,6 @@ export function CreateServiceModal () {
         }
     }
 
-    const isReady = projectId && name && description && !['templates', 
'kamelets'].includes(projectId);
     return (
         <Modal
             title={operation !== 'copy' ? "Create new project" : "Copy project 
from " + project?.projectId}
@@ -84,37 +138,56 @@ export function CreateServiceModal () {
             onClose={closeModal}
             onKeyDown={onKeyDown}
             actions={[
-                <Button key="confirm" variant="primary" isDisabled={!isReady}
-                        onClick={handleFormSubmit}>Save</Button>,
+                <Button key="confirm" variant="primary" 
onClick={handleSubmit(handleFormSubmit)}>Save</Button>,
                 <Button key="cancel" variant="secondary" 
onClick={closeModal}>Cancel</Button>
             ]}
             className="new-project"
         >
             <Form isHorizontal={true} autoComplete="off">
                 <FormGroup label="Name" fieldId="name" isRequired>
-                    <TextInput className="text-field" type="text" id="name" 
name="name"
+                    <TextInput className="text-field" type="text" id="name"
                                value={name}
-                               onChange={(_, e) => setName(e)}/>
+                               validated={!!errors.name ? 'error' : 'default'}
+                               {...register('name')}
+                               onChange={(e, v) => {
+                                   setName(v);
+                                   register('name').onChange(e);
+                               }}
+                    />
+                    {!!errors.name && <Text  style={{ color: 'red', fontStyle: 
'italic'}}>{errors?.name?.message}</Text>}
                 </FormGroup>
                 <FormGroup label="Description" fieldId="description" 
isRequired>
-                    <TextInput className="text-field" type="text" 
id="description" name="description"
+                    <TextInput className="text-field" type="text" 
id="description"
                                value={description}
-                               onChange={(_, e) => setDescription(e)}/>
+                               validated={!!errors.description ? 'error' : 
'default'}
+                               {...register('description')}
+                               onChange={(e, v) => {
+                                   setDescription(v);
+                                   register('description').onChange(e);
+                               }}
+                    />
+                    {!!errors.description && <Text  style={{ color: 'red', 
fontStyle: 'italic'}}>{errors?.description?.message}</Text>}
                 </FormGroup>
                 <FormGroup label="Project ID" fieldId="projectId" isRequired>
-                    <TextInput className="text-field" type="text" 
id="projectId" name="projectId"
+                    <TextInput className="text-field" type="text" 
id="projectId"
                                value={projectId}
                                onFocus={e => setProjectId(projectId === '' ? 
CamelUi.nameFromTitle(name) : projectId)}
-                               onChange={(_, e) => 
setProjectId(CamelUi.nameFromTitle(e))}
-                               validated={isValidationError ? 'error' : 
'default'}
+                               validated={!!errors.projectId ? 'error' : 
'default'}
+                               {...register('projectId')}
+                               onChange={(e, v) => {
+                                   setProjectId(CamelUi.nameFromTitle(v));
+                                   register('projectId').onChange(e);
+                               }}
                     />
-                    {isValidationError && <Text  style={{ color: 'red', 
fontStyle: 'italic'}}>Project ID must be unique</Text>}
-                    <FormHelperText>
-                        <HelperText>
-                            <HelperTextItem>Unique service 
name</HelperTextItem>
-                        </HelperText>
-                    </FormHelperText>
+                    {!!errors.projectId && <Text  style={{ color: 'red', 
fontStyle: 'italic'}}>{errors?.projectId?.message}</Text>}
                 </FormGroup>
+                <Grid>
+                    {globalErrors &&
+                        globalErrors.map((error) => (
+                            <Alert title={error} key={error} 
variant="danger"></Alert>
+                        ))}
+                    <Divider role="presentation" />
+                </Grid>
             </Form>
         </Modal>
     )
diff --git a/karavan-web/karavan-app/src/main/webui/src/shared/error/Error.ts 
b/karavan-web/karavan-app/src/main/webui/src/shared/error/Error.ts
new file mode 100644
index 00000000..b0e0dac3
--- /dev/null
+++ b/karavan-web/karavan-app/src/main/webui/src/shared/error/Error.ts
@@ -0,0 +1,11 @@
+export class Error {
+    field: string = '';
+    message: string = '';
+    code: string = '';
+
+    constructor(field: string, message: string, code: string) {
+        this.field = field;
+        this.message = message;
+        this.code = code;
+    }
+}
\ No newline at end of file
diff --git 
a/karavan-web/karavan-app/src/main/webui/src/shared/error/ErrorResponse.ts 
b/karavan-web/karavan-app/src/main/webui/src/shared/error/ErrorResponse.ts
new file mode 100644
index 00000000..70728e36
--- /dev/null
+++ b/karavan-web/karavan-app/src/main/webui/src/shared/error/ErrorResponse.ts
@@ -0,0 +1,15 @@
+import {Error} from "./Error";
+
+export class ErrorResponse {
+    status: number = 0;
+    error: string = '';
+    message: string = '';
+    errors: Array<Error> = [];
+
+    constructor(status: number, error: string, message: string, errors: 
Array<Error>) {
+        this.status = status;
+        this.error = error;
+        this.message = message;
+        this.errors = errors;
+    }
+}
\ No newline at end of file
diff --git 
a/karavan-web/karavan-app/src/main/webui/src/shared/error/ProjectExistsError.ts 
b/karavan-web/karavan-app/src/main/webui/src/shared/error/ProjectExistsError.ts
deleted file mode 100644
index 6adee70b..00000000
--- 
a/karavan-web/karavan-app/src/main/webui/src/shared/error/ProjectExistsError.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export class ProjectExistsError extends Error {
-    constructor(message: string) {
-        super(message);
-        this.name = 'ProjectExistsError';
-    }
-}
\ No newline at end of file
diff --git 
a/karavan-web/karavan-app/src/main/webui/src/shared/error/UseResponseErrorHandler.ts
 
b/karavan-web/karavan-app/src/main/webui/src/shared/error/UseResponseErrorHandler.ts
new file mode 100644
index 00000000..7e8c557a
--- /dev/null
+++ 
b/karavan-web/karavan-app/src/main/webui/src/shared/error/UseResponseErrorHandler.ts
@@ -0,0 +1,43 @@
+import {useState} from 'react';
+import {ErrorResponse} from "./ErrorResponse";
+import {Error} from "./Error";
+import {AxiosError} from "axios";
+
+export function useResponseErrorHandler(errorResponseToFormFields: Map<string, 
string>, setError: any) {
+    const [globalErrors, setGlobalErrors] = useState<string[]>([]);
+
+    function registerResponseErrors(axiosError: AxiosError) {
+        const errorResponse: ErrorResponse = axiosError.response?.data as 
ErrorResponse;
+        // Register field errors if field errors were returned
+        if (errorResponse.errors) {
+            // Reset global error
+            setGlobalErrors([]);
+
+            // Register all field errors
+            errorResponse.errors?.forEach((error: Error) => {
+                // If field name was found in mapping table, register it to 
the form error
+                if (errorResponseToFormFields.get(error.field)) {
+                    setError(errorResponseToFormFields.get(error.field), {
+                        type: 'custom',
+                        message: error.message
+                    });
+                }
+                // If field name was not found in mapping table, register it 
to the global error
+                else {
+                    // Make copy as state shouldn't be mutated
+                    setGlobalErrors(prevGlobalErrors => [...prevGlobalErrors, 
error.message]);
+                }
+            });
+        }
+        // Register global error if no field errors were returned
+        else {
+            setGlobalErrors([errorResponse.message]);
+        }
+    }
+
+    function resetGlobalErrors() {
+        setGlobalErrors([]);
+    }
+
+    return [globalErrors, registerResponseErrors, resetGlobalErrors] as const;
+}

Reply via email to