diff --git a/.semgrepignore b/.semgrepignore index af2e78047..ded413c74 100644 --- a/.semgrepignore +++ b/.semgrepignore @@ -7,6 +7,7 @@ example/order/src/main/scala/org/finos/vuu/provider/simulation/SimulatedBigInstr vuu/src/main/scala/org/finos/vuu/provider/simulation/SimulatedBigInstrumentsProvider.scala vuu-ui/packages/vuu-data/src/array-data-source/group-utils.ts vuu-ui/packages/vuu-datagrid-extras/src/column-expression-input/column-language-parser/walkExpressionTree.ts +vuu-ui/packages/vuu-layout/src/layout-persistence/RemoteLayoutPersistenceManager.ts vuu-ui/packages/vuu-popups/src/menu/useContextMenu.tsx vuu-ui/packages/vuu-table-extras/src/cell-edit-validators/PatternValidator.ts vuu-ui/packages/vuu-ui-controls/src/list/Highlighter.tsx diff --git a/layout-server/pom.xml b/layout-server/pom.xml new file mode 100644 index 000000000..9ae3cf8e5 --- /dev/null +++ b/layout-server/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.16 + + + org.finos.vuu + layout-server + 0.0.1-SNAPSHOT + layout-server + A remote server to persist layouts for the Vuu client + + 11 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + runtime + + + org.springdoc + springdoc-openapi-ui + 1.6.12 + + + org.projectlombok + lombok + + + org.modelmapper + modelmapper + 3.1.0 + + + org.jetbrains + annotations + 13.0 + compile + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/CorsConfig.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/CorsConfig.java new file mode 100644 index 000000000..5637db23f --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/CorsConfig.java @@ -0,0 +1,20 @@ +package org.finos.vuu.layoutserver; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOrigins( + "http://127.0.0.1:5173", + "https://127.0.0.1:5173", + "http://127.0.0.1:8443/", + "https://127.0.0.1:8443/" + ) + .allowedMethods("GET", "POST", "PUT", "DELETE"); + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/LayoutServerApplication.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/LayoutServerApplication.java new file mode 100644 index 000000000..f0a1d10c7 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/LayoutServerApplication.java @@ -0,0 +1,13 @@ +package org.finos.vuu.layoutserver; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class LayoutServerApplication { + + public static void main(String[] args) { + SpringApplication.run(LayoutServerApplication.class, args); + } + +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/config/MappingConfig.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/config/MappingConfig.java new file mode 100644 index 000000000..546d38ca7 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/config/MappingConfig.java @@ -0,0 +1,23 @@ +package org.finos.vuu.layoutserver.config; + +import lombok.RequiredArgsConstructor; +import org.finos.vuu.layoutserver.dto.request.LayoutRequestDto; +import org.finos.vuu.layoutserver.model.Layout; +import org.modelmapper.ModelMapper; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@RequiredArgsConstructor +@Configuration +public class MappingConfig { + + @Bean + public ModelMapper modelMapper() { + ModelMapper mapper = new ModelMapper(); + + mapper.typeMap(LayoutRequestDto.class, Layout.class) + .addMappings(m -> m.skip(Layout::setId)); + + return mapper; + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutController.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutController.java new file mode 100644 index 000000000..7db9388d5 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutController.java @@ -0,0 +1,62 @@ +package org.finos.vuu.layoutserver.controller; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import org.finos.vuu.layoutserver.dto.response.ApplicationLayoutDto; +import org.finos.vuu.layoutserver.service.ApplicationLayoutService; +import org.modelmapper.ModelMapper; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/application-layouts") +public class ApplicationLayoutController { + + private final ApplicationLayoutService service; + private final ModelMapper mapper; + + /** + * Gets the persisted application layout for the requesting user. If the requesting user does not have an + * application layout persisted, a default layout with a null username is returned instead. No more than one + * application layout can be persisted for a given user. + * + * @return the application layout + */ + @ResponseStatus(HttpStatus.OK) + @GetMapping + public ApplicationLayoutDto getApplicationLayout(@RequestHeader("username") String username) { + return mapper.map(service.getApplicationLayout(username), ApplicationLayoutDto.class); + } + + /** + * Creates or updates the unique application layout for the requesting user. + * + * @param layoutDefinition JSON representation of the application layout to be created + * @param username the user making the request + */ + @ResponseStatus(HttpStatus.CREATED) + @PutMapping + public void persistApplicationLayout(@RequestHeader("username") String username, @RequestBody ObjectNode layoutDefinition) { + service.persistApplicationLayout(username, layoutDefinition); + } + + /** + * Deletes the application layout for the requesting user. A 404 will be returned if there is no existing + * application layout. + * + * @param username the user making the request + */ + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping + public void deleteApplicationLayout(@RequestHeader("username") String username) { + service.deleteApplicationLayout(username); + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/LayoutController.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/LayoutController.java new file mode 100644 index 000000000..315f04c62 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/controller/LayoutController.java @@ -0,0 +1,94 @@ +package org.finos.vuu.layoutserver.controller; + +import lombok.RequiredArgsConstructor; +import org.finos.vuu.layoutserver.dto.request.LayoutRequestDto; +import org.finos.vuu.layoutserver.dto.response.LayoutResponseDto; +import org.finos.vuu.layoutserver.dto.response.MetadataResponseDto; +import org.finos.vuu.layoutserver.model.Layout; +import org.finos.vuu.layoutserver.service.LayoutService; +import org.finos.vuu.layoutserver.service.MetadataService; +import org.modelmapper.ModelMapper; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.UUID; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/layouts") +@Validated +public class LayoutController { + + private final LayoutService layoutService; + private final MetadataService metadataService; + private final ModelMapper mapper; + + /** + * Gets the specified layout + * + * @param id ID of the layout to get + * @return the layout + */ + @ResponseStatus(HttpStatus.OK) + @GetMapping("/{id}") + public LayoutResponseDto getLayout(@PathVariable UUID id) { + return mapper.map(layoutService.getLayout(id), LayoutResponseDto.class); + } + + /** + * Gets metadata for all layouts + * + * @return the metadata + */ + @ResponseStatus(HttpStatus.OK) + @GetMapping("/metadata") + public List getMetadata() { + + return metadataService.getMetadata() + .stream() + .map(metadata -> mapper.map(metadata, MetadataResponseDto.class)) + .collect(java.util.stream.Collectors.toList()); + } + + /** + * Creates a new layout + * + * @param layoutToCreate the layout to be created + * @return the layout that has been created, with the autogenerated ID and created date + */ + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public LayoutResponseDto createLayout(@RequestBody @Valid LayoutRequestDto layoutToCreate) { + Layout layout = mapper.map(layoutToCreate, Layout.class); + + return mapper.map(layoutService.createLayout(layout), LayoutResponseDto.class); + } + + /** + * Updates the specified layout + * + * @param id ID of the layout to update + * @param layout the new layout + */ + @ResponseStatus(HttpStatus.NO_CONTENT) + @PutMapping("/{id}") + public void updateLayout(@PathVariable UUID id, @RequestBody @Valid LayoutRequestDto layout) { + Layout newLayout = mapper.map(layout, Layout.class); + + layoutService.updateLayout(id, newLayout); + } + + /** + * Deletes the specified layout + * + * @param id ID of the layout to delete + */ + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/{id}") + public void deleteLayout(@PathVariable UUID id) { + layoutService.deleteLayout(id); + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/LayoutRequestDto.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/LayoutRequestDto.java new file mode 100644 index 000000000..d1aa93157 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/LayoutRequestDto.java @@ -0,0 +1,19 @@ +package org.finos.vuu.layoutserver.dto.request; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; + +import javax.validation.constraints.NotNull; + +@Data +public class LayoutRequestDto { + + /** + * The definition of the layout as an arbitrary JSON structure, describing all required components + */ + @NotNull(message = "Definition must not be null") + private ObjectNode definition; + + @NotNull(message = "Metadata must not be null") + private MetadataRequestDto metadata; +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/MetadataRequestDto.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/MetadataRequestDto.java new file mode 100644 index 000000000..abbf99430 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/request/MetadataRequestDto.java @@ -0,0 +1,12 @@ +package org.finos.vuu.layoutserver.dto.request; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import lombok.Data; +import org.finos.vuu.layoutserver.model.BaseMetadata; + +@Data +public class MetadataRequestDto { + + @JsonUnwrapped + BaseMetadata baseMetadata; +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/ApplicationLayoutDto.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/ApplicationLayoutDto.java new file mode 100644 index 000000000..d04d48af5 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/ApplicationLayoutDto.java @@ -0,0 +1,10 @@ +package org.finos.vuu.layoutserver.dto.response; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; + +@Data +public class ApplicationLayoutDto { + private String username; + private ObjectNode definition; +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/ErrorResponse.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/ErrorResponse.java new file mode 100644 index 000000000..4ba22e316 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/ErrorResponse.java @@ -0,0 +1,24 @@ +package org.finos.vuu.layoutserver.dto.response; + +import lombok.Data; +import org.springframework.http.HttpStatus; + +import javax.servlet.http.HttpServletRequest; +import java.time.LocalDate; +import java.util.List; + +@Data +public class ErrorResponse { + private LocalDate timestamp = LocalDate.now(); + private int status; + private String error; + private List messages; + private String path; + + public ErrorResponse(HttpServletRequest request, List messages, HttpStatus status) { + this.status = status.value(); + this.error = status.getReasonPhrase(); + this.path = request.getRequestURI(); + this.messages = messages; + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/LayoutResponseDto.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/LayoutResponseDto.java new file mode 100644 index 000000000..aca2de742 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/LayoutResponseDto.java @@ -0,0 +1,19 @@ +package org.finos.vuu.layoutserver.dto.response; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; + +import java.util.UUID; + +@Data +public class LayoutResponseDto { + + private UUID id; + + /** + * The definition of the layout as an arbitrary JSON structure, describing all required components + */ + private ObjectNode definition; + + private MetadataResponseDto metadata; +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/MetadataResponseDto.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/MetadataResponseDto.java new file mode 100644 index 000000000..236034c1c --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/dto/response/MetadataResponseDto.java @@ -0,0 +1,20 @@ +package org.finos.vuu.layoutserver.dto.response; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import lombok.Data; +import org.finos.vuu.layoutserver.model.BaseMetadata; + +import java.time.LocalDate; +import java.util.UUID; + +@Data +public class MetadataResponseDto { + + private UUID id; + + @JsonUnwrapped + BaseMetadata baseMetadata; + + private LocalDate created; + private LocalDate updated; +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/exceptions/GlobalExceptionHandler.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/exceptions/GlobalExceptionHandler.java new file mode 100644 index 000000000..e0dd4b5c8 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/exceptions/GlobalExceptionHandler.java @@ -0,0 +1,50 @@ +package org.finos.vuu.layoutserver.exceptions; + +import org.finos.vuu.layoutserver.dto.response.ErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import javax.servlet.http.HttpServletRequest; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(NoSuchElementException.class) + public ResponseEntity handleNotFound(HttpServletRequest request, Exception ex) { + HttpStatus status = HttpStatus.NOT_FOUND; + return new ResponseEntity<>(new ErrorResponse(request, List.of(ex.getMessage()), status), status); + } + + @ExceptionHandler({ + HttpMessageNotReadableException.class, + MethodArgumentTypeMismatchException.class}) + public ResponseEntity handleBadRequest(HttpServletRequest request, Exception ex) { + HttpStatus status = HttpStatus.BAD_REQUEST; + return new ResponseEntity<>(new ErrorResponse(request, List.of(ex.getMessage()), status), status); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid(HttpServletRequest request, MethodArgumentNotValidException ex) { + HttpStatus status = HttpStatus.BAD_REQUEST; + List errors = ex.getFieldErrors() + .stream() + .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage()) + .collect(Collectors.toList()); + return new ResponseEntity<>(new ErrorResponse(request, errors, status), status); + } + + @ExceptionHandler(InternalServerErrorException.class) + public ResponseEntity handleInternalServerError(HttpServletRequest request, Exception ex) { + HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; + return new ResponseEntity<>(new ErrorResponse(request, List.of(ex.getMessage()), status), status); + + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/exceptions/InternalServerErrorException.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/exceptions/InternalServerErrorException.java new file mode 100644 index 000000000..b1164eab6 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/exceptions/InternalServerErrorException.java @@ -0,0 +1,7 @@ +package org.finos.vuu.layoutserver.exceptions; + +public class InternalServerErrorException extends RuntimeException { + public InternalServerErrorException(String message) { + super(message); + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/model/ApplicationLayout.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/ApplicationLayout.java new file mode 100644 index 000000000..3ec5631c9 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/ApplicationLayout.java @@ -0,0 +1,25 @@ +package org.finos.vuu.layoutserver.model; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; + +import javax.persistence.Column; +import javax.persistence.Convert; +import javax.persistence.Entity; +import javax.persistence.Id; + +@Data +@Entity +@RequiredArgsConstructor +@AllArgsConstructor +public class ApplicationLayout { + @Id + private String username; + + @Convert(converter = ObjectNodeConverter.class) + @Column(columnDefinition = "JSON") + private ObjectNode definition; +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/model/BaseMetadata.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/BaseMetadata.java new file mode 100644 index 000000000..2500eb247 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/BaseMetadata.java @@ -0,0 +1,19 @@ +package org.finos.vuu.layoutserver.model; + +import lombok.Data; + +import javax.persistence.Embeddable; +import javax.persistence.Lob; + +@Data +@Embeddable +public class BaseMetadata { + + private String name; + private String group; + + @Lob + private String screenshot; + + private String user; +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Layout.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Layout.java new file mode 100644 index 000000000..7941e1d71 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Layout.java @@ -0,0 +1,30 @@ +package org.finos.vuu.layoutserver.model; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Data; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; + +import javax.persistence.*; +import java.util.UUID; + +@Data +@Entity +public class Layout { + + @Id + @Column(columnDefinition = "BINARY(16)") + private UUID id; + + @Convert(converter = ObjectNodeConverter.class) + @Column(columnDefinition = "JSON") + private ObjectNode definition; + + @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true) + @JoinColumn(name = "metadata_id", referencedColumnName = "id") + private Metadata metadata; + + public void setId(UUID id) { + this.id = id; + this.metadata.setId(id); + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Metadata.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Metadata.java new file mode 100644 index 000000000..1e5abb00b --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/model/Metadata.java @@ -0,0 +1,32 @@ +package org.finos.vuu.layoutserver.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.persistence.Column; +import javax.persistence.Embedded; +import javax.persistence.Entity; +import javax.persistence.Id; +import java.time.LocalDate; +import java.util.UUID; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Entity +public class Metadata { + + @Id + @Column(columnDefinition = "BINARY(16)") + private UUID id; + + @Embedded + private BaseMetadata baseMetadata; + + private final LocalDate created = LocalDate.now(); + + private LocalDate updated; +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/ApplicationLayoutRepository.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/ApplicationLayoutRepository.java new file mode 100644 index 000000000..c553e7751 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/ApplicationLayoutRepository.java @@ -0,0 +1,9 @@ +package org.finos.vuu.layoutserver.repository; + +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ApplicationLayoutRepository extends CrudRepository { +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/LayoutRepository.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/LayoutRepository.java new file mode 100644 index 000000000..19f294ac1 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/LayoutRepository.java @@ -0,0 +1,10 @@ +package org.finos.vuu.layoutserver.repository; + +import org.finos.vuu.layoutserver.model.Layout; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface LayoutRepository extends CrudRepository {} \ No newline at end of file diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/MetadataRepository.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/MetadataRepository.java new file mode 100644 index 000000000..79ea560d1 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/repository/MetadataRepository.java @@ -0,0 +1,11 @@ +package org.finos.vuu.layoutserver.repository; + +import org.finos.vuu.layoutserver.model.Metadata; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + + +@Repository +public interface MetadataRepository extends CrudRepository {} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/service/ApplicationLayoutService.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/ApplicationLayoutService.java new file mode 100644 index 000000000..0727552be --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/ApplicationLayoutService.java @@ -0,0 +1,41 @@ +package org.finos.vuu.layoutserver.service; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.finos.vuu.layoutserver.repository.ApplicationLayoutRepository; +import org.finos.vuu.layoutserver.utils.DefaultApplicationLayoutLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; + +@RequiredArgsConstructor +@Service +public class ApplicationLayoutService { + + private static final Logger logger = LoggerFactory.getLogger(ApplicationLayoutService.class); + private final ApplicationLayoutRepository repository; + private final DefaultApplicationLayoutLoader defaultLoader; + + public void persistApplicationLayout(String username, ObjectNode layoutDefinition) { + repository.save(new ApplicationLayout(username, layoutDefinition)); + } + + public ApplicationLayout getApplicationLayout(String username) { + return repository.findById(username).orElseGet(() -> { + logger.info("No application layout for user, returning default"); + return defaultLoader.getDefaultLayout(); + }); + } + + public void deleteApplicationLayout(String username) { + try { + repository.deleteById(username); + } catch (EmptyResultDataAccessException e) { + throw new NoSuchElementException("No layout found for user: " + username); + } + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/service/LayoutService.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/LayoutService.java new file mode 100644 index 000000000..2d691728b --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/LayoutService.java @@ -0,0 +1,55 @@ +package org.finos.vuu.layoutserver.service; + +import lombok.RequiredArgsConstructor; +import org.finos.vuu.layoutserver.model.Layout; +import org.finos.vuu.layoutserver.model.Metadata; +import org.finos.vuu.layoutserver.repository.LayoutRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.NoSuchElementException; +import java.util.UUID; + +@RequiredArgsConstructor +@Service +public class LayoutService { + + private final LayoutRepository layoutRepository; + + public Layout getLayout(UUID id) { + return layoutRepository.findById(id) + .orElseThrow(() -> new NoSuchElementException("Layout with ID '" + id + "' not found")); + } + + public Layout createLayout(Layout layout) { + UUID id = UUID.randomUUID(); + + layout.setId(id); + + return layoutRepository.save(layout); + } + + public void updateLayout(UUID layoutId, Layout newLayout) { + Layout layoutToUpdate = getLayout(layoutId); + Metadata newMetadata = newLayout.getMetadata(); + + Metadata updatedMetadata = Metadata.builder() + .baseMetadata(newMetadata.getBaseMetadata()) + .updated(LocalDate.now()) + .id(layoutToUpdate.getMetadata().getId()) + .build(); + + layoutToUpdate.setDefinition(newLayout.getDefinition()); + layoutToUpdate.setMetadata(updatedMetadata); + + layoutRepository.save(layoutToUpdate); + } + + public void deleteLayout(UUID id) { + try { + layoutRepository.deleteById(id); + } catch (Exception e) { + throw new NoSuchElementException("Layout with ID '" + id + "' not found"); + } + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/service/MetadataService.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/MetadataService.java new file mode 100644 index 000000000..08398edc4 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/service/MetadataService.java @@ -0,0 +1,24 @@ +package org.finos.vuu.layoutserver.service; + +import lombok.RequiredArgsConstructor; +import org.finos.vuu.layoutserver.model.Metadata; +import org.finos.vuu.layoutserver.repository.MetadataRepository; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@RequiredArgsConstructor +@Service +public class MetadataService { + + private final MetadataRepository metadataRepository; + + public List getMetadata() { + List metadata = new ArrayList<>(); + + metadataRepository.findAll().forEach(metadata::add); + + return metadata; + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/DefaultApplicationLayoutLoader.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/DefaultApplicationLayoutLoader.java new file mode 100644 index 000000000..55abadbe5 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/DefaultApplicationLayoutLoader.java @@ -0,0 +1,40 @@ +package org.finos.vuu.layoutserver.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.finos.vuu.layoutserver.exceptions.InternalServerErrorException; +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class DefaultApplicationLayoutLoader { + private static final String DEFAULT_LAYOUT_FILE = "defaultApplicationLayout.json"; + private static ApplicationLayout defaultLayout; + + @Bean + public ApplicationLayout getDefaultLayout() { + if (defaultLayout == null) { + loadDefaultLayout(); + } + return defaultLayout; + } + + private void loadDefaultLayout() { + ObjectNode definition = loadDefaultLayoutJsonFile(); + defaultLayout = new ApplicationLayout(null, definition); + } + + private ObjectNode loadDefaultLayoutJsonFile() { + ObjectMapper objectMapper = new ObjectMapper(); + ClassPathResource resource = new ClassPathResource(DEFAULT_LAYOUT_FILE); + try { + return objectMapper.readValue(resource.getInputStream(), ObjectNode.class); + } catch (IOException e) { + throw new InternalServerErrorException("Failed to read default application layout"); + } + } +} diff --git a/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/ObjectNodeConverter.java b/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/ObjectNodeConverter.java new file mode 100644 index 000000000..28eec5186 --- /dev/null +++ b/layout-server/src/main/java/org/finos/vuu/layoutserver/utils/ObjectNodeConverter.java @@ -0,0 +1,42 @@ +package org.finos.vuu.layoutserver.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.persistence.AttributeConverter; +import java.io.IOException; + +public class ObjectNodeConverter implements AttributeConverter { + private static final Logger logger = LoggerFactory.getLogger(ObjectNodeConverter.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(ObjectNode definition) { + try { + return objectMapper.writeValueAsString(definition); + } catch (final JsonProcessingException e) { + logger.error("JSON writing error", e); + return null; + } + } + + @Override + public ObjectNode convertToEntityAttribute(String definition) { + try { + return objectMapper.readValue(extractDefinition(definition), ObjectNode.class); + } catch (final IOException e) { + logger.error("JSON reading error", e); + return null; + } + } + + private String extractDefinition(String definition) { + if (definition.startsWith("\"") && definition.endsWith("\"")) { + definition = definition.substring(1, definition.length() - 1); + } + return definition.replaceAll("\\\\", ""); + } +} diff --git a/layout-server/src/main/resources/application.properties b/layout-server/src/main/resources/application.properties new file mode 100644 index 000000000..afee88372 --- /dev/null +++ b/layout-server/src/main/resources/application.properties @@ -0,0 +1,9 @@ +server.port=8081 +server.servlet.contextPath=/api +springdoc.swagger-ui.path=/swagger +spring.datasource.url=jdbc:h2:mem:layoutdb;NON_KEYWORDS=GROUP,USER +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password=password +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.h2.console.enabled=true diff --git a/layout-server/src/main/resources/defaultApplicationLayout.json b/layout-server/src/main/resources/defaultApplicationLayout.json new file mode 100644 index 000000000..871b11b44 --- /dev/null +++ b/layout-server/src/main/resources/defaultApplicationLayout.json @@ -0,0 +1,22 @@ +{ + "id": "main-tabs", + "type": "Stack", + "props": { + "className": "vuuShell-mainTabs", + "TabstripProps": { + "allowAddTab": true, + "allowRenameTab": true, + "animateSelectionThumb": false, + "className": "vuuShellMainTabstrip", + "location": "main-tab" + }, + "preserve": true, + "active": 0 + }, + "children": [ + { + "type": "Placeholder", + "title": "Page 1" + } + ] +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/LayoutServerApplicationTests.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/LayoutServerApplicationTests.java new file mode 100644 index 000000000..0e56bd365 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/LayoutServerApplicationTests.java @@ -0,0 +1,13 @@ +package org.finos.vuu.layoutserver; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class LayoutServerApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutControllerTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutControllerTest.java new file mode 100644 index 000000000..bdf1971b8 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/ApplicationLayoutControllerTest.java @@ -0,0 +1,63 @@ +package org.finos.vuu.layoutserver.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.finos.vuu.layoutserver.dto.response.ApplicationLayoutDto; +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.finos.vuu.layoutserver.service.ApplicationLayoutService; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.modelmapper.ModelMapper; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class ApplicationLayoutControllerTest { + private static ApplicationLayoutService mockService; + private static ApplicationLayoutController controller; + private static final ModelMapper modelMapper = new ModelMapper(); + private static final ObjectNodeConverter objectNodeConverter = new ObjectNodeConverter(); + + @BeforeEach + public void setup() { + mockService = Mockito.mock(ApplicationLayoutService.class); + controller = new ApplicationLayoutController(mockService, modelMapper); + } + + @Test + public void getApplicationLayout_anyUsername_returnsLayoutFromService() throws JsonProcessingException { + String user = "user"; + ObjectNode definition = objectNodeConverter.convertToEntityAttribute("{\"id\":\"main-tabs\"}"); + + when(mockService.getApplicationLayout(user)) + .thenReturn(new ApplicationLayout(user, definition)); + + ApplicationLayoutDto response = controller.getApplicationLayout(user); + + assertThat(response.getUsername()).isEqualTo(user); + assertThat(response.getDefinition()).isEqualTo(definition); + + verify(mockService, times(1)).getApplicationLayout(user); + } + + @Test + public void persistApplicationLayout_anyInput_callsService() throws JsonProcessingException { + String user = "user"; + ObjectNode definition = objectNodeConverter.convertToEntityAttribute("{\"id\":\"main-tabs\"}"); + + controller.persistApplicationLayout(user, definition); + + verify(mockService, times(1)).persistApplicationLayout(user, definition); + } + + @Test + public void deleteApplicationLayout_anyUsername_callsService() { + String user = "user"; + + controller.deleteApplicationLayout(user); + + verify(mockService, times(1)).deleteApplicationLayout(user); + } +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/LayoutControllerTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/LayoutControllerTest.java new file mode 100644 index 000000000..0b128fbad --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/controller/LayoutControllerTest.java @@ -0,0 +1,164 @@ +package org.finos.vuu.layoutserver.controller; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.finos.vuu.layoutserver.dto.request.LayoutRequestDto; +import org.finos.vuu.layoutserver.dto.request.MetadataRequestDto; +import org.finos.vuu.layoutserver.dto.response.LayoutResponseDto; +import org.finos.vuu.layoutserver.dto.response.MetadataResponseDto; +import org.finos.vuu.layoutserver.model.BaseMetadata; +import org.finos.vuu.layoutserver.model.Layout; +import org.finos.vuu.layoutserver.model.Metadata; +import org.finos.vuu.layoutserver.service.LayoutService; +import org.finos.vuu.layoutserver.service.MetadataService; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; +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.modelmapper.ModelMapper; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LayoutControllerTest { + + private static final String LAYOUT_DEFINITION_STRING = "{\"id\":\"main-tabs\"}"; + private static final String LAYOUT_GROUP = "Test Group"; + private static final String LAYOUT_NAME = "Test Layout"; + private static final String LAYOUT_SCREENSHOT = "Test Screenshot"; + private static final String LAYOUT_USER = "Test User"; + private static final UUID VALID_ID = UUID.randomUUID(); + private static final UUID DOES_NOT_EXIST_ID = UUID.randomUUID(); + private static final ObjectNodeConverter objectNodeConverter = new ObjectNodeConverter(); + + @Mock + private LayoutService layoutService; + + @Mock + private MetadataService metadataService; + + @Mock + private ModelMapper modelMapper; + + @InjectMocks + private LayoutController layoutController; + + private Layout layout; + private Metadata metadata; + private BaseMetadata baseMetadata; + private LayoutRequestDto layoutRequest; + private LayoutResponseDto expectedLayoutResponse; + private MetadataResponseDto metadataResponse; + + @BeforeEach + public void setup() throws JsonProcessingException { + baseMetadata = new BaseMetadata(); + baseMetadata.setName(LAYOUT_NAME); + baseMetadata.setUser(LAYOUT_USER); + baseMetadata.setGroup(LAYOUT_GROUP); + baseMetadata.setScreenshot(LAYOUT_SCREENSHOT); + + metadata = Metadata.builder().id(VALID_ID).baseMetadata(baseMetadata).build(); + + layout = new Layout(); + layout.setMetadata(metadata); + layout.setId(VALID_ID); + layout.setDefinition(objectNodeConverter.convertToEntityAttribute(LAYOUT_DEFINITION_STRING)); + + layoutRequest = new LayoutRequestDto(); + MetadataRequestDto metadataRequestDto = new MetadataRequestDto(); + metadataRequestDto.setBaseMetadata(baseMetadata); + layoutRequest.setDefinition(layout.getDefinition()); + layoutRequest.setMetadata(metadataRequestDto); + + metadataResponse = getMetadataResponseDto(); + + expectedLayoutResponse = new LayoutResponseDto(); + expectedLayoutResponse.setId(layout.getId()); + expectedLayoutResponse.setDefinition(layout.getDefinition()); + expectedLayoutResponse.setMetadata(metadataResponse); + + } + + + @Test + void getLayout_layoutExists_returnsLayout() { + when(layoutService.getLayout(VALID_ID)).thenReturn(layout); + when(modelMapper.map(layout, LayoutResponseDto.class)).thenReturn(expectedLayoutResponse); + assertThat(layoutController.getLayout(VALID_ID)).isEqualTo(expectedLayoutResponse); + } + + @Test + void getLayout_layoutDoesNotExist_throwsNoSuchElementException() { + when(layoutService.getLayout(DOES_NOT_EXIST_ID)) + .thenThrow(NoSuchElementException.class); + + assertThrows(NoSuchElementException.class, + () -> layoutController.getLayout(DOES_NOT_EXIST_ID)); + } + + @Test + void getMetadata_metadataExists_returnsMetadata() { + List metadataList = List.of(metadata); + + when(metadataService.getMetadata()).thenReturn(metadataList); + when(modelMapper.map(metadata, MetadataResponseDto.class)) + .thenReturn(metadataResponse); + + assertThat(layoutController.getMetadata()).isEqualTo(List.of(metadataResponse)); + } + + @Test + void getMetadata_noMetadataExists_returnsEmptyArray() { + when(metadataService.getMetadata()).thenReturn(List.of()); + assertThat(layoutController.getMetadata()).isEmpty(); + } + + @Test + void createLayout_validLayout_returnsCreatedLayout() { + Layout layoutWithoutIds = layout; + layoutWithoutIds.setId(null); + + when(modelMapper.map(layoutRequest, Layout.class)).thenReturn(layoutWithoutIds); + when(layoutService.createLayout(layoutWithoutIds)).thenReturn(layout); + when(modelMapper.map(layout, LayoutResponseDto.class)).thenReturn(expectedLayoutResponse); + + assertThat(layoutController.createLayout(layoutRequest)).isEqualTo(expectedLayoutResponse); + } + + @Test + void updateLayout_validLayout_callsLayoutService() { + layout.setId(null); + + when(modelMapper.map(layoutRequest, Layout.class)).thenReturn(layout); + + layoutController.updateLayout(VALID_ID, layoutRequest); + + verify(layoutService).updateLayout(VALID_ID, layout); + } + + @Test + void deleteLayout__validId_callsLayoutService() { + layoutController.deleteLayout(VALID_ID); + + verify(layoutService).deleteLayout(VALID_ID); + } + + private MetadataResponseDto getMetadataResponseDto() { + MetadataResponseDto metadataResponse = new MetadataResponseDto(); + metadataResponse.setId(layout.getId()); + metadataResponse.setBaseMetadata(baseMetadata); + metadataResponse.setCreated(layout.getMetadata().getCreated()); + metadataResponse.setUpdated(layout.getMetadata().getUpdated()); + return metadataResponse; + } +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/ApplicationLayoutIntegrationTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/ApplicationLayoutIntegrationTest.java new file mode 100644 index 000000000..0f862b852 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/ApplicationLayoutIntegrationTest.java @@ -0,0 +1,181 @@ +package org.finos.vuu.layoutserver.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.finos.vuu.layoutserver.exceptions.InternalServerErrorException; +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.finos.vuu.layoutserver.repository.ApplicationLayoutRepository; +import org.finos.vuu.layoutserver.utils.DefaultApplicationLayoutLoader; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class ApplicationLayoutIntegrationTest { + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String BASE_URL = "/application-layouts"; + private static final String MISSING_USERNAME_ERROR_MESSAGE = + "Required request header 'username' for method parameter type String is not present"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ApplicationLayoutRepository repository; + @MockBean + private DefaultApplicationLayoutLoader mockLoader; + private final DefaultApplicationLayoutLoader realLoader = new DefaultApplicationLayoutLoader(); + + @Test + public void getApplicationLayout_noLayoutExists_returns200WithDefaultLayout() throws Exception { + when(mockLoader.getDefaultLayout()).thenReturn(realLoader.getDefaultLayout()); + + mockMvc.perform(get(BASE_URL).header("username", "new user")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username", nullValue())) + // Expecting application layout as defined in /test/resources/defaultApplicationLayout.json + .andExpect(jsonPath("$.definition.defaultLayoutKey", is("default-layout-value"))); + } + + @Test + public void getApplicationLayout_defaultFailsToLoad_returns500() throws Exception { + String errorMessage = "Failed to read default application layout"; + doThrow(new InternalServerErrorException(errorMessage)).when(mockLoader).getDefaultLayout(); + + mockMvc.perform(get(BASE_URL).header("username", "new user")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains(errorMessage))); + } + + @Test + public void getApplicationLayout_noUserInHeader_returns400() throws Exception { + String actualError = mockMvc.perform(get(BASE_URL)) + .andExpect(status().isBadRequest()) + .andReturn().getResponse().getErrorMessage(); + + assertThat(actualError).isEqualTo(MISSING_USERNAME_ERROR_MESSAGE); + } + + @Test + public void getApplicationLayout_layoutExists_returns200WithPersistedLayout() throws Exception { + String user = "user"; + + Map definition = new HashMap<>(); + definition.put("defKey", "defVal"); + + persistApplicationLayout(user, definition); + + mockMvc.perform(get(BASE_URL).header("username", user)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username", is(user))) + .andExpect(jsonPath("$.definition", is(definition))); + } + + @Test + public void persistApplicationLayout_noLayoutExists_returns201AndPersistsLayout() throws Exception { + String user = "user"; + String definition = "{\"key\": \"value\"}"; + + mockMvc.perform(put(BASE_URL).header("username", user) + .content(definition) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$").doesNotExist()); + + ApplicationLayout persistedLayout = repository.findById(user).orElseThrow(); + + assertThat(persistedLayout.getUsername()).isEqualTo(user); + assertThat(persistedLayout.getDefinition()).isEqualTo(objectMapper.readTree(definition)); + } + + @Test + public void persistApplicationLayout_layoutExists_returns201AndOverwritesLayout() throws Exception { + String user = "user"; + + Map initialDefinition = new HashMap<>(); + initialDefinition.put("initial-key", "initial-value"); + + persistApplicationLayout(user, initialDefinition); + + String newDefinition = "{\"new-key\": \"new-value\"}"; + + mockMvc.perform(put(BASE_URL).header("username", user) + .content(newDefinition) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$").doesNotExist()); + + assertThat(repository.findAll()).hasSize(1); + + ApplicationLayout retrievedLayout = repository.findById(user).orElseThrow(); + + assertThat(retrievedLayout.getUsername()).isEqualTo(user); + assertThat(retrievedLayout.getDefinition()).isEqualTo(objectMapper.readTree(newDefinition)); + } + + @Test + public void persistApplicationLayout_noUserInHeader_returns400() throws Exception { + String actualError = mockMvc.perform(put(BASE_URL)) + .andExpect(status().isBadRequest()) + .andReturn().getResponse().getErrorMessage(); + + assertThat(actualError).isEqualTo(MISSING_USERNAME_ERROR_MESSAGE); + } + + @Test + public void deleteApplicationLayout_noLayoutExists_returns404() throws Exception { + String user = "user"; + + mockMvc.perform(delete(BASE_URL).header("username", user)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains("No layout found for user: " + user))); + } + + @Test + public void deleteApplicationLayout_layoutExists_returns204AndDeletesLayout() throws Exception { + String user = "user"; + + Map initialDefinition = new HashMap<>(); + initialDefinition.put("initial-key", "initial-value"); + + persistApplicationLayout(user, initialDefinition); + + mockMvc.perform(delete(BASE_URL).header("username", user)) + .andExpect(status().isNoContent()) + .andExpect(jsonPath("$").doesNotExist()); + + assertThat(repository.findAll()).hasSize(0); + } + + @Test + public void deleteApplicationLayout_noUserInHeader_returns400() throws Exception { + String actualError = mockMvc.perform(delete(BASE_URL)) + .andExpect(status().isBadRequest()) + .andReturn().getResponse().getErrorMessage(); + + assertThat(actualError).isEqualTo(MISSING_USERNAME_ERROR_MESSAGE); + } + + private void persistApplicationLayout(String user, Map definition) { + repository.save(new ApplicationLayout(user, objectMapper.convertValue(definition, ObjectNode.class))); + } +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/LayoutIntegrationTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/LayoutIntegrationTest.java new file mode 100644 index 000000000..94eedc8e3 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/integration/LayoutIntegrationTest.java @@ -0,0 +1,480 @@ +package org.finos.vuu.layoutserver.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.jayway.jsonpath.JsonPath; +import org.finos.vuu.layoutserver.dto.request.LayoutRequestDto; +import org.finos.vuu.layoutserver.dto.request.MetadataRequestDto; +import org.finos.vuu.layoutserver.model.BaseMetadata; +import org.finos.vuu.layoutserver.model.Layout; +import org.finos.vuu.layoutserver.model.Metadata; +import org.finos.vuu.layoutserver.repository.LayoutRepository; +import org.finos.vuu.layoutserver.repository.MetadataRepository; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.iterableWithSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class LayoutIntegrationTest { + + private static final String DEFAULT_LAYOUT_DEFINITION_STRING = "{\"id\":\"main-tabs\"}"; + private static final String UPDATED_LAYOUT_DEFINITION_STRING = "{\"id\":\"updated-main-tabs\"}"; + private static final String DEFAULT_LAYOUT_NAME = "Default layout name"; + private static final String DEFAULT_LAYOUT_GROUP = "Default layout group"; + private static final String DEFAULT_LAYOUT_SCREENSHOT = "Default layout screenshot"; + private static final String DEFAULT_LAYOUT_USER = "Default layout user"; + private static final UUID DEFAULT_LAYOUT_ID = UUID.fromString("00000000-0000-0000-0000-000000000000"); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private static final ObjectNodeConverter objectNodeConverter = new ObjectNodeConverter(); + + @Autowired + private MockMvc mockMvc; + @Autowired + private LayoutRepository layoutRepository; + @Autowired + private MetadataRepository metadataRepository; + + @BeforeEach + void tearDown() { + layoutRepository.deleteAll(); + metadataRepository.deleteAll(); + } + + @Test + void getLayout_validIDAndLayoutExists_returns200WithLayout() throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + Map definition = objectMapper.convertValue(layout.getDefinition(), Map.class); + + mockMvc.perform(get("/layouts/{id}", layout.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.definition", + is(definition))) + .andExpect(jsonPath("$.metadata.name", + is(layout.getMetadata().getBaseMetadata().getName()))) + .andExpect(jsonPath("$.metadata.group", + is(layout.getMetadata().getBaseMetadata().getGroup()))) + .andExpect(jsonPath("$.metadata.screenshot", + is(layout.getMetadata().getBaseMetadata().getScreenshot()))) + .andExpect(jsonPath("$.metadata.user", + is(layout.getMetadata().getBaseMetadata().getUser()))); + } + + @Test + void getLayout_validIDButLayoutDoesNotExist_returns404() throws Exception { + UUID layoutID = UUID.randomUUID(); + + mockMvc.perform(get("/layouts/{id}", layoutID)).andExpect(status().isNotFound()); + } + + @Test + void getLayout_invalidId_returns400() throws Exception { + String layoutID = "invalidUUID"; + + mockMvc.perform(get("/layouts/{id}", layoutID)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", + contains("Failed to convert value of type 'java.lang.String' to required type 'java.util" + + ".UUID'; nested exception is java.lang.IllegalArgumentException: Invalid " + + "UUID string: invalidUUID"))); + } + + @Test + void getMetadata_singleMetadataExists_returnsMetadata() throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + mockMvc.perform(get("/layouts/metadata")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name", + is(layout.getMetadata().getBaseMetadata().getName()))) + .andExpect(jsonPath("$[0].group", + is(layout.getMetadata().getBaseMetadata().getGroup()))) + .andExpect(jsonPath("$[0].screenshot", + is(layout.getMetadata().getBaseMetadata().getScreenshot()))) + .andExpect(jsonPath("$[0].user", + is(layout.getMetadata().getBaseMetadata().getUser()))); + } + + @Test + void getMetadata_multipleMetadataExists_returnsAllMetadata() throws Exception { + UUID layout1Id = UUID.randomUUID(); + UUID layout2Id = UUID.randomUUID(); + Layout layout1 = createLayoutWithIdInDatabase(layout1Id); + Layout layout2 = createLayoutWithIdInDatabase(layout2Id); + layout2.setDefinition(objectNodeConverter.convertToEntityAttribute(UPDATED_LAYOUT_DEFINITION_STRING)); + layout2.getMetadata().getBaseMetadata().setName("Different name"); + layout2.getMetadata().getBaseMetadata().setGroup("Different group"); + layout2.getMetadata().getBaseMetadata().setScreenshot("Different screenshot"); + layout2.getMetadata().getBaseMetadata().setUser("Different user"); + layoutRepository.save(layout2); + + assertThat(layoutRepository.findById(layout1.getId()).orElseThrow()).isEqualTo(layout1); + assertThat(layoutRepository.findById(layout2.getId()).orElseThrow()).isEqualTo(layout2); + + mockMvc.perform(get("/layouts/metadata")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name", + is(layout1.getMetadata().getBaseMetadata().getName()))) + .andExpect(jsonPath("$[0].group", + is(layout1.getMetadata().getBaseMetadata().getGroup()))) + .andExpect(jsonPath("$[0].screenshot", + is(layout1.getMetadata().getBaseMetadata().getScreenshot()))) + .andExpect(jsonPath("$[0].user", + is(layout1.getMetadata().getBaseMetadata().getUser()))) + .andExpect(jsonPath("$[1].name", + is(layout2.getMetadata().getBaseMetadata().getName()))) + .andExpect(jsonPath("$[1].group", + is(layout2.getMetadata().getBaseMetadata().getGroup()))) + .andExpect(jsonPath("$[1].screenshot", + is(layout2.getMetadata().getBaseMetadata().getScreenshot()))) + .andExpect(jsonPath("$[1].user", + is(layout2.getMetadata().getBaseMetadata().getUser()))); + } + + @Test + void getMetadata_metadataDoesNotExist_returnsEmptyList() throws Exception { + mockMvc.perform(get("/layouts/metadata")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isEmpty()); + } + + @Test + void createLayout_validRequest_returnsCreatedLayoutAndLayoutIsPersisted() + throws Exception { + LayoutRequestDto layoutRequest = createValidLayoutRequest(); + + Map definition = objectMapper.convertValue(layoutRequest.getDefinition(), Map.class); + + MvcResult result = mockMvc.perform(post("/layouts") + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.definition", is(definition))) + .andExpect(jsonPath("$.metadata.name", + is(layoutRequest.getMetadata().getBaseMetadata().getName()))) + .andExpect(jsonPath("$.metadata.group", + is(layoutRequest.getMetadata().getBaseMetadata().getGroup()))) + .andExpect(jsonPath("$.metadata.screenshot", + is(layoutRequest.getMetadata().getBaseMetadata().getScreenshot()))) + .andExpect(jsonPath("$.metadata.user", + is(layoutRequest.getMetadata().getBaseMetadata().getUser()))) + .andReturn(); + + UUID createdLayoutId = UUID.fromString( + JsonPath.read(result.getResponse().getContentAsString(), "$.id")); + Layout createdLayout = layoutRepository.findById(createdLayoutId).orElseThrow(); + Metadata createdMetadata = metadataRepository.findById(createdLayout.getMetadata().getId()) + .orElseThrow(); + + // Check that the one-to-one relationship isn't causing duplicate/unexpected entries in + // the DB + assertThat(layoutRepository.findAll()).containsExactly(createdLayout); + assertThat(metadataRepository.findAll()).containsExactly(createdMetadata); + + assertThat(createdLayout.getDefinition()) + .isEqualTo(layoutRequest.getDefinition()); + assertThat(createdMetadata.getBaseMetadata().getName()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getName()); + assertThat(createdMetadata.getBaseMetadata().getGroup()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getGroup()); + assertThat(createdMetadata.getBaseMetadata().getScreenshot()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getScreenshot()); + assertThat(createdMetadata.getBaseMetadata().getUser()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getUser()); + } + + @Test + void createLayout_invalidRequestBodyDefinitionsIsNull_returns400AndDoesNotCreateLayout() + throws Exception { + LayoutRequestDto layoutRequest = createValidLayoutRequest(); + layoutRequest.setDefinition(null); + + mockMvc.perform(post("/layouts") + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains("definition: Definition must not be null"))); + + assertThat(layoutRepository.findAll()).isEmpty(); + assertThat(metadataRepository.findAll()).isEmpty(); + } + + @Test + void createLayout_invalidRequestBodyMetadataIsNull_returns400AndDoesNotCreateLayout() + throws Exception { + LayoutRequestDto layoutRequest = createValidLayoutRequest(); + layoutRequest.setMetadata(null); + + mockMvc.perform(post("/layouts") + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains("metadata: Metadata must not be null"))); + + assertThat(layoutRepository.findAll()).isEmpty(); + assertThat(metadataRepository.findAll()).isEmpty(); + } + + + @Test + void createLayout_invalidRequestBodyUnexpectedFormat_returns400() throws Exception { + String invalidLayout = "invalidLayout"; + + mockMvc.perform(post("/layouts") + .content(invalidLayout) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains( + "JSON parse error: Unrecognized token 'invalidLayout': was expecting (JSON " + + "String, Number, Array, Object or token 'null', 'true' or 'false'); nested " + + "exception is com.fasterxml.jackson.core.JsonParseException: Unrecognized " + + "token 'invalidLayout': was expecting (JSON String, Number, Array, Object " + + "or token 'null', 'true' or 'false')\n" + + " at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream);" + + " line: 1, column: 14]"))); + } + + @Test + void updateLayout_validIdAndValidRequest_returns204AndLayoutHasChanged() throws Exception { + Layout initialLayout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(initialLayout.getId()).orElseThrow()).isEqualTo( + initialLayout); + + LayoutRequestDto layoutRequest = createValidLayoutRequest(); + layoutRequest.setDefinition(objectNodeConverter.convertToEntityAttribute(UPDATED_LAYOUT_DEFINITION_STRING)); + layoutRequest.getMetadata().getBaseMetadata().setName("Updated name"); + layoutRequest.getMetadata().getBaseMetadata().setGroup("Updated group"); + layoutRequest.getMetadata().getBaseMetadata().setScreenshot("Updated screenshot"); + layoutRequest.getMetadata().getBaseMetadata().setUser("Updated user"); + + mockMvc.perform(put("/layouts/{id}", initialLayout.getId()) + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNoContent()) + .andExpect(jsonPath("$").doesNotExist()); + + Layout updatedLayout = layoutRepository.findById(initialLayout.getId()).orElseThrow(); + + assertThat(updatedLayout.getDefinition()) + .isEqualTo(layoutRequest.getDefinition()); + assertThat(updatedLayout.getMetadata().getBaseMetadata().getName()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getName()); + assertThat(updatedLayout.getMetadata().getBaseMetadata().getGroup()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getGroup()); + assertThat(updatedLayout.getMetadata().getBaseMetadata().getScreenshot()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getScreenshot()); + assertThat(updatedLayout.getMetadata().getBaseMetadata().getUser()) + .isEqualTo(layoutRequest.getMetadata().getBaseMetadata().getUser()); + + assertThat(updatedLayout).isNotEqualTo(initialLayout); + } + + @Test + void updateLayout_invalidRequestBodyDefinitionIsNull_returns400AndLayoutDoesNotChange() + throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + LayoutRequestDto request = createValidLayoutRequest(); + request.setDefinition(null); + + mockMvc.perform(put("/layouts/{id}", layout.getId()) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains("definition: Definition must not be null"))); + + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + } + + @Test + void updateLayout_invalidRequestBodyMetadataIsNull_returns400AndLayoutDoesNotChange() + throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + LayoutRequestDto request = createValidLayoutRequest(); + request.setMetadata(null); + + mockMvc.perform(put("/layouts/{id}", layout.getId()) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains("metadata: Metadata must not be null"))); + + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + } + + @Test + void updateLayout_invalidRequestBodyUnexpectedFormat_returns400AndLayoutDoesNotChange() + throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + String request = "invalidRequest"; + + mockMvc.perform(put("/layouts/{id}", layout.getId()) + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains( + "JSON parse error: Cannot construct instance of `org.finos.vuu.layoutserver.dto" + + ".request.LayoutRequestDto` (although at least one Creator exists): no " + + "String-argument constructor/factory method to deserialize from String " + + "value ('invalidRequest'); nested exception is com.fasterxml.jackson" + + ".databind.exc.MismatchedInputException: Cannot construct instance of `org" + + ".finos.vuu.layoutserver.dto.request.LayoutRequestDto` (although at least " + + "one Creator exists): no String-argument constructor/factory method to " + + "deserialize from String value ('invalidRequest')\n" + + " at [Source: (org.springframework.util.StreamUtils$NonClosingInputStream);" + + " line: 1, column: 1]"))); + + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo( + layout); + } + + @Test + void updateLayout_validIdButLayoutDoesNotExist_returnsNotFound() throws Exception { + UUID layoutID = UUID.randomUUID(); + LayoutRequestDto layoutRequest = createValidLayoutRequest(); + + mockMvc.perform(put("/layouts/{id}", layoutID) + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + void updateLayout_invalidId_returns400() throws Exception { + String layoutID = "invalidUUID"; + LayoutRequestDto layoutRequest = createValidLayoutRequest(); + + mockMvc.perform(put("/layouts/{id}", layoutID) + .content(objectMapper.writeValueAsString(layoutRequest)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains( + "Failed to convert value of type 'java.lang.String' to required type 'java.util" + + ".UUID'; nested exception is java.lang.IllegalArgumentException: Invalid " + + "UUID string: invalidUUID"))); + } + + @Test + void deleteLayout_validIdLayoutExists_returnsSuccessAndLayoutIsDeleted() throws Exception { + Layout layout = createDefaultLayoutInDatabase(); + assertThat(layoutRepository.findById(layout.getId()).orElseThrow()).isEqualTo(layout); + + mockMvc.perform(delete("/layouts/{id}", layout.getId())).andExpect(status().isNoContent()); + + assertThat(layoutRepository.findById(layout.getId())).isEmpty(); + } + + @Test + void deleteLayout_validIdLayoutDoesNotExist_returnsNotFound() throws Exception { + UUID layoutID = UUID.randomUUID(); + + mockMvc.perform(delete("/layouts/{id}", layoutID)).andExpect(status().isNotFound()); + } + + @Test + void deleteLayout_invalidId_returns400() throws Exception { + String layoutID = "invalidUUID"; + + mockMvc.perform(delete("/layouts/{id}", layoutID)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.messages", iterableWithSize(1))) + .andExpect(jsonPath("$.messages", contains( + "Failed to convert value of type 'java.lang.String' to required type 'java.util" + + ".UUID'; nested exception is java.lang.IllegalArgumentException: Invalid " + + "UUID string: invalidUUID"))); + } + + private Layout createDefaultLayoutInDatabase() { + Layout layout = new Layout(); + Metadata metadata = new Metadata(); + BaseMetadata baseMetadata = new BaseMetadata(); + + baseMetadata.setName(DEFAULT_LAYOUT_NAME); + baseMetadata.setGroup(DEFAULT_LAYOUT_GROUP); + baseMetadata.setScreenshot(DEFAULT_LAYOUT_SCREENSHOT); + baseMetadata.setUser(DEFAULT_LAYOUT_USER); + + metadata.setBaseMetadata(baseMetadata); + + layout.setDefinition(objectNodeConverter.convertToEntityAttribute(DEFAULT_LAYOUT_DEFINITION_STRING)); + layout.setMetadata(metadata); + layout.setId(DEFAULT_LAYOUT_ID); + + return layoutRepository.save(layout); + } + + private Layout createLayoutWithIdInDatabase(UUID id) { + Layout layout = new Layout(); + Metadata metadata = new Metadata(); + BaseMetadata baseMetadata = new BaseMetadata(); + + baseMetadata.setName(DEFAULT_LAYOUT_NAME); + baseMetadata.setGroup(DEFAULT_LAYOUT_GROUP); + baseMetadata.setScreenshot(DEFAULT_LAYOUT_SCREENSHOT); + baseMetadata.setUser(DEFAULT_LAYOUT_USER); + + metadata.setBaseMetadata(baseMetadata); + + layout.setDefinition(objectNodeConverter.convertToEntityAttribute(DEFAULT_LAYOUT_DEFINITION_STRING)); + layout.setMetadata(metadata); + layout.setId(id); + + return layoutRepository.save(layout); + } + + private LayoutRequestDto createValidLayoutRequest() { + BaseMetadata baseMetadata = new BaseMetadata(); + baseMetadata.setName(DEFAULT_LAYOUT_NAME); + baseMetadata.setGroup(DEFAULT_LAYOUT_GROUP); + baseMetadata.setScreenshot(DEFAULT_LAYOUT_SCREENSHOT); + baseMetadata.setUser(DEFAULT_LAYOUT_USER); + + MetadataRequestDto metadataRequest = new MetadataRequestDto(); + metadataRequest.setBaseMetadata(baseMetadata); + + LayoutRequestDto layoutRequest = new LayoutRequestDto(); + layoutRequest.setDefinition(objectNodeConverter.convertToEntityAttribute(DEFAULT_LAYOUT_DEFINITION_STRING)); + layoutRequest.setMetadata(metadataRequest); + + return layoutRequest; + } +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/model/LayoutTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/model/LayoutTest.java new file mode 100644 index 000000000..59d12a436 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/model/LayoutTest.java @@ -0,0 +1,23 @@ +package org.finos.vuu.layoutserver.model; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class LayoutTest { + + @Test + void setId_anyId_setsIdForBothLayoutAndMetadata() { + UUID id = UUID.fromString("00000000-0000-0000-0000-000000000000"); + Layout layout = new Layout(); + Metadata metadata = new Metadata(); + layout.setMetadata(metadata); + + layout.setId(id); + + assertThat(layout.getId()).isEqualTo(id); + assertThat(layout.getMetadata().getId()).isEqualTo(id); + } + } diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/service/ApplicationLayoutServiceTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/ApplicationLayoutServiceTest.java new file mode 100644 index 000000000..e5b8ecb20 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/ApplicationLayoutServiceTest.java @@ -0,0 +1,97 @@ +package org.finos.vuu.layoutserver.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.finos.vuu.layoutserver.model.ApplicationLayout; +import org.finos.vuu.layoutserver.repository.ApplicationLayoutRepository; +import org.finos.vuu.layoutserver.utils.DefaultApplicationLayoutLoader; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.dao.EmptyResultDataAccessException; + +import java.util.NoSuchElementException; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ApplicationLayoutServiceTest { + + private static ApplicationLayoutRepository mockRepo; + private static ApplicationLayoutService service; + private static final DefaultApplicationLayoutLoader defaultLoader = new DefaultApplicationLayoutLoader(); + private static final ObjectNodeConverter objectNodeConverter = new ObjectNodeConverter(); + + @BeforeEach + public void setup() { + mockRepo = Mockito.mock(ApplicationLayoutRepository.class); + service = new ApplicationLayoutService(mockRepo, defaultLoader); + } + + @Test + public void getApplicationLayout_noLayout_returnsDefault() throws JsonProcessingException { + when(mockRepo.findById(anyString())).thenReturn(Optional.empty()); + + ApplicationLayout actualLayout = service.getApplicationLayout("new user"); + + // Expecting application layout as defined in /test/resources/defaultApplicationLayout.json + ObjectNode expectedDefinition = objectNodeConverter.convertToEntityAttribute("{\"defaultLayoutKey\":\"default-layout-value\"}"); + + assertThat(actualLayout.getUsername()).isNull(); + assertThat(actualLayout.getDefinition()).isEqualTo(expectedDefinition); + } + + @Test + public void getApplicationLayout_layoutExists_returnsLayout() throws JsonProcessingException { + String user = "user"; + + ObjectNode expectedDefinition = objectNodeConverter.convertToEntityAttribute("{\"id\":\"main-tabs\"}"); + ApplicationLayout expectedLayout = new ApplicationLayout(user, expectedDefinition); + + when(mockRepo.findById(user)).thenReturn(Optional.of(expectedLayout)); + + ApplicationLayout actualLayout = service.getApplicationLayout(user); + + assertThat(actualLayout).isEqualTo(expectedLayout); + } + + @Test + public void createApplicationLayout_validDefinition_callsRepoSave() throws JsonProcessingException { + String user = "user"; + ObjectNode definition = objectNodeConverter.convertToEntityAttribute("{\"id\":\"main-tabs\"}"); + + service.persistApplicationLayout(user, definition); + + verify(mockRepo, times(1)) + .save(new ApplicationLayout(user, definition)); + } + + @Test + public void deleteApplicationLayout_entryExists_callsRepoDelete() { + String user = "user"; + + service.deleteApplicationLayout(user); + + verify(mockRepo, times(1)).deleteById(user); + } + + @Test + public void deleteApplicationLayout_deleteFails_throwsException() { + String user = "user"; + + doThrow(EmptyResultDataAccessException.class).when(mockRepo).deleteById(user); + + NoSuchElementException exception = assertThrows(NoSuchElementException.class, () -> + service.deleteApplicationLayout(user) + ); + + assertThat(exception.getMessage()).isEqualTo("No layout found for user: " + user); + } +} diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/service/LayoutServiceTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/LayoutServiceTest.java new file mode 100644 index 000000000..42af579a0 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/LayoutServiceTest.java @@ -0,0 +1,111 @@ +package org.finos.vuu.layoutserver.service; + +import org.finos.vuu.layoutserver.model.BaseMetadata; +import org.finos.vuu.layoutserver.model.Layout; +import org.finos.vuu.layoutserver.model.Metadata; +import org.finos.vuu.layoutserver.repository.LayoutRepository; +import org.finos.vuu.layoutserver.utils.ObjectNodeConverter; +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.dao.EmptyResultDataAccessException; + +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class LayoutServiceTest { + + private static final UUID LAYOUT_ID = UUID.randomUUID(); + + @Mock + private LayoutRepository layoutRepository; + + @InjectMocks + private LayoutService layoutService; + + private Layout layout; + + private static final ObjectNodeConverter objectNodeConverter = new ObjectNodeConverter(); + + @BeforeEach + public void setup() { + BaseMetadata baseMetadata = new BaseMetadata(); + baseMetadata.setName("Test Name"); + baseMetadata.setGroup("Test Group"); + baseMetadata.setScreenshot("Test Screenshot"); + baseMetadata.setUser("Test User"); + + Metadata metadata = Metadata.builder().id(LAYOUT_ID).baseMetadata(baseMetadata).build(); + + layout = new Layout(); + layout.setMetadata(metadata); + layout.setId(LAYOUT_ID); + layout.setDefinition(objectNodeConverter.convertToEntityAttribute("{\"id\":\"main-tabs\"}")); + } + + @Test + void getLayout_layoutExists_returnsLayout() { + when(layoutRepository.findById(LAYOUT_ID)).thenReturn(Optional.of(layout)); + + assertThat(layoutService.getLayout(LAYOUT_ID)).isEqualTo(layout); + } + + @Test + void getLayout_noLayoutsExist_throwsNotFoundException() { + when(layoutRepository.findById(LAYOUT_ID)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, + () -> layoutService.getLayout(LAYOUT_ID)); + } + + @Test + void createLayout_anyLayout_returnsNewLayout() { + when(layoutRepository.save(layout)).thenReturn(layout); + + assertThat(layoutService.createLayout(layout)).isEqualTo(layout); + } + + @Test + void updateLayout_layoutExists_callsRepositorySave() { + when(layoutRepository.findById(LAYOUT_ID)).thenReturn(Optional.of(layout)); + + layoutService.updateLayout(LAYOUT_ID, layout); + + verify(layoutRepository, times(1)).save(layout); + } + + @Test + void updateLayout_layoutDoesNotExist_throwsNoSuchElementException() { + when(layoutRepository.findById(LAYOUT_ID)).thenReturn(Optional.empty()); + + assertThrows(NoSuchElementException.class, + () -> layoutService.updateLayout(LAYOUT_ID, layout)); + } + + @Test + void deleteLayout_anyUUID_callsRepositoryDeleteById() { + layoutService.deleteLayout(LAYOUT_ID); + + verify(layoutRepository, times(1)).deleteById(LAYOUT_ID); + } + + @Test + void deleteLayout_noLayoutExists_throwsNoSuchElementException() { + doThrow(new EmptyResultDataAccessException(1)) + .when(layoutRepository).deleteById(LAYOUT_ID); + + assertThrows(NoSuchElementException.class, + () -> layoutService.deleteLayout(LAYOUT_ID)); + + verify(layoutRepository, times(1)).deleteById(LAYOUT_ID); + } +} \ No newline at end of file diff --git a/layout-server/src/test/java/org/finos/vuu/layoutserver/service/MetadataServiceTest.java b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/MetadataServiceTest.java new file mode 100644 index 000000000..74bbb4844 --- /dev/null +++ b/layout-server/src/test/java/org/finos/vuu/layoutserver/service/MetadataServiceTest.java @@ -0,0 +1,38 @@ +package org.finos.vuu.layoutserver.service; + +import org.finos.vuu.layoutserver.model.Metadata; +import org.finos.vuu.layoutserver.repository.MetadataRepository; +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.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MetadataServiceTest { + + @Mock + private MetadataRepository metadataRepository; + + @InjectMocks + private MetadataService metadataService; + + @Test + void getMetadata_metadataExists_returnsMetadata() { + Metadata metadata = Metadata.builder().build(); + + when(metadataRepository.findAll()).thenReturn(List.of(metadata)); + assertThat(metadataService.getMetadata()).isEqualTo(List.of(metadata)); + } + + @Test + void getMetadata_noMetadataExists_returnsEmptyList() { + when(metadataRepository.findAll()).thenReturn(List.of()); + assertThat(metadataService.getMetadata()).isEqualTo(List.of()); + } +} \ No newline at end of file diff --git a/layout-server/src/test/resources/application-test.properties b/layout-server/src/test/resources/application-test.properties new file mode 100644 index 000000000..2722b4aca --- /dev/null +++ b/layout-server/src/test/resources/application-test.properties @@ -0,0 +1,5 @@ +spring.datasource.url=jdbc:h2:mem:testdb;NON_KEYWORDS=GROUP,USER +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password=password +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect \ No newline at end of file diff --git a/layout-server/src/test/resources/defaultApplicationLayout.json b/layout-server/src/test/resources/defaultApplicationLayout.json new file mode 100644 index 000000000..87a79e544 --- /dev/null +++ b/layout-server/src/test/resources/defaultApplicationLayout.json @@ -0,0 +1,3 @@ +{ + "defaultLayoutKey": "default-layout-value" +} diff --git a/pom.xml b/pom.xml index 96462f744..0625fbcf6 100644 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,7 @@ toolbox vuu vuu-ui + layout-server benchmark example diff --git a/vuu-ui/packages/vuu-layout/src/layout-persistence/LayoutPersistenceManager.ts b/vuu-ui/packages/vuu-layout/src/layout-persistence/LayoutPersistenceManager.ts index 88371f969..88ca5bf96 100644 --- a/vuu-ui/packages/vuu-layout/src/layout-persistence/LayoutPersistenceManager.ts +++ b/vuu-ui/packages/vuu-layout/src/layout-persistence/LayoutPersistenceManager.ts @@ -1,5 +1,5 @@ import { LayoutJSON } from "@finos/vuu-layout"; -import { LayoutMetadata } from "@finos/vuu-shell"; +import { LayoutMetadata, LayoutMetadataDto } from "@finos/vuu-shell"; export interface LayoutPersistenceManager { /** @@ -11,9 +11,9 @@ export interface LayoutPersistenceManager { * @returns Unique identifier assigned to the saved layout */ createLayout: ( - metadata: Omit, + metadata: LayoutMetadataDto, layout: LayoutJSON - ) => Promise; + ) => Promise; /** * Overwrites an existing layout and its corresponding metadata with the provided information @@ -24,7 +24,7 @@ export interface LayoutPersistenceManager { */ updateLayout: ( id: string, - metadata: Omit, + metadata: LayoutMetadataDto, layout: LayoutJSON ) => Promise; diff --git a/vuu-ui/packages/vuu-layout/src/layout-persistence/LocalLayoutPersistenceManager.ts b/vuu-ui/packages/vuu-layout/src/layout-persistence/LocalLayoutPersistenceManager.ts index c1754ed1d..519dde763 100644 --- a/vuu-ui/packages/vuu-layout/src/layout-persistence/LocalLayoutPersistenceManager.ts +++ b/vuu-ui/packages/vuu-layout/src/layout-persistence/LocalLayoutPersistenceManager.ts @@ -1,7 +1,12 @@ -import { Layout, LayoutMetadata, WithId } from "@finos/vuu-shell"; +import { + Layout, + LayoutMetadata, + LayoutMetadataDto, + WithId, +} from "@finos/vuu-shell"; import { LayoutJSON, LayoutPersistenceManager } from "@finos/vuu-layout"; import { getLocalEntity, saveLocalEntity } from "@finos/vuu-filters"; -import { getUniqueId } from "@finos/vuu-utils"; +import { formatDate, getUniqueId } from "@finos/vuu-utils"; import { defaultLayout } from "./data"; @@ -16,21 +21,24 @@ export class LocalLayoutPersistenceManager implements LayoutPersistenceManager { } } createLayout( - metadata: Omit, + metadata: LayoutMetadataDto, layout: LayoutJSON - ): Promise { + ): Promise { return new Promise((resolve) => { Promise.all([this.loadLayouts(), this.loadMetadata()]).then( ([existingLayouts, existingMetadata]) => { const id = getUniqueId(); - this.appendAndPersist( + const newMetadata: LayoutMetadata = { + ...metadata, id, - metadata, - layout, - existingLayouts, - existingMetadata + created: formatDate(new Date(), "dd.mm.yyyy"), + }; + + this.saveLayoutsWithMetadata( + [...existingLayouts, { id, json: layout }], + [...existingMetadata, newMetadata] ); - resolve(id); + resolve(newMetadata); } ); }); @@ -38,18 +46,20 @@ export class LocalLayoutPersistenceManager implements LayoutPersistenceManager { updateLayout( id: string, - newMetadata: Omit, + newMetadata: LayoutMetadataDto, newLayout: LayoutJSON ): Promise { return new Promise((resolve, reject) => { this.validateIds(id) .then(() => Promise.all([this.loadLayouts(), this.loadMetadata()])) .then(([existingLayouts, existingMetadata]) => { - const layouts = existingLayouts.filter((layout) => layout.id !== id); - const metadata = existingMetadata.filter( - (metadata) => metadata.id !== id + const updatedLayouts = existingLayouts.map((layout) => + layout.id === id ? { ...layout, json: newLayout } : layout + ); + const updatedMetadata = existingMetadata.map((metadata) => + metadata.id === id ? { ...metadata, ...newMetadata } : metadata ); - this.appendAndPersist(id, newMetadata, newLayout, layouts, metadata); + this.saveLayoutsWithMetadata(updatedLayouts, updatedMetadata); resolve(); }) .catch((e) => reject(e)); @@ -77,10 +87,14 @@ export class LocalLayoutPersistenceManager implements LayoutPersistenceManager { this.validateId(id, "layout") .then(() => this.loadLayouts()) .then((existingLayouts) => { - const layouts = existingLayouts.find( + const foundLayout = existingLayouts.find( (layout) => layout.id === id - ) as Layout; - resolve(layouts.json); + ); + if (foundLayout) { + resolve(foundLayout.json); + } else { + reject(new Error(`no layout found matching id ${id}`)); + } }) .catch((e) => reject(e)); }); @@ -122,19 +136,6 @@ export class LocalLayoutPersistenceManager implements LayoutPersistenceManager { }); } - private appendAndPersist( - newId: string, - newMetadata: Omit, - newLayout: LayoutJSON, - existingLayouts: Layout[], - existingMetadata: LayoutMetadata[] - ) { - existingLayouts.push({ id: newId, json: newLayout }); - existingMetadata.push({ id: newId, ...newMetadata }); - - this.saveLayoutsWithMetadata(existingLayouts, existingMetadata); - } - private saveLayoutsWithMetadata( layouts: Layout[], metadata: LayoutMetadata[] diff --git a/vuu-ui/packages/vuu-layout/src/layout-persistence/RemoteLayoutPersistenceManager.ts b/vuu-ui/packages/vuu-layout/src/layout-persistence/RemoteLayoutPersistenceManager.ts new file mode 100644 index 000000000..14f2af95a --- /dev/null +++ b/vuu-ui/packages/vuu-layout/src/layout-persistence/RemoteLayoutPersistenceManager.ts @@ -0,0 +1,188 @@ +import { + ApplicationLayout, + LayoutMetadata, + LayoutMetadataDto, +} from "@finos/vuu-shell"; +import { LayoutPersistenceManager } from "./LayoutPersistenceManager"; +import { LayoutJSON } from "../layout-reducer"; + +const baseURL = process.env.LAYOUT_BASE_URL; +const metadataSaveLocation = "layouts/metadata"; +const layoutsSaveLocation = "layouts"; +const applicationLayoutsSaveLocation = "application-layouts"; + +export type CreateLayoutResponseDto = { metadata: LayoutMetadata }; +export type GetLayoutResponseDto = { definition: LayoutJSON }; + +export class RemoteLayoutPersistenceManager + implements LayoutPersistenceManager +{ + createLayout( + metadata: LayoutMetadataDto, + layout: LayoutJSON + ): Promise { + return new Promise((resolve, reject) => + fetch(`${baseURL}/${layoutsSaveLocation}`, { + headers: { + "Content-Type": "application/json", + }, + method: "POST", + body: JSON.stringify({ + metadata, + definition: layout, + }), + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + response.json().then(({ metadata }: CreateLayoutResponseDto) => { + if (!metadata) { + reject(new Error("Response did not contain valid metadata")); + } + resolve(metadata); + }); + }) + .catch((error: Error) => { + reject(error); + }) + ); + } + + updateLayout( + id: string, + metadata: LayoutMetadataDto, + newLayoutJson: LayoutJSON + ): Promise { + return new Promise((resolve, reject) => + fetch(`${baseURL}/${layoutsSaveLocation}/${id}`, { + method: "PUT", + body: JSON.stringify({ + metadata, + layout: newLayoutJson, + }), + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + resolve(); + }) + .catch((error: Error) => { + reject(error); + }) + ); + } + + deleteLayout(id: string): Promise { + return new Promise((resolve, reject) => + fetch(`${baseURL}/${layoutsSaveLocation}/${id}`, { + method: "DELETE", + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + resolve(); + }) + .catch((error: Error) => { + reject(error); + }) + ); + } + + loadLayout(id: string): Promise { + return new Promise((resolve, reject) => { + fetch(`${baseURL}/${layoutsSaveLocation}/${id}`, { + method: "GET", + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + response.json().then(({ definition }: GetLayoutResponseDto) => { + if (!definition) { + reject(new Error("Response did not contain a valid layout")); + } + resolve(definition); + }); + }) + .catch((error: Error) => { + reject(error); + }); + }); + } + + loadMetadata(): Promise { + return new Promise((resolve, reject) => + fetch(`${baseURL}/${metadataSaveLocation}`, { + method: "GET", + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + response.json().then((metadata: LayoutMetadata[]) => { + if (!metadata) { + reject(new Error("Response did not contain valid metadata")); + } + resolve(metadata); + }); + }) + .catch((error: Error) => { + reject(error); + }) + ); + } + + saveApplicationLayout(layout: LayoutJSON): Promise { + return new Promise((resolve, reject) => + fetch(`${baseURL}/${applicationLayoutsSaveLocation}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + username: "vuu-user", + }, + body: JSON.stringify(layout), + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + resolve(); + }) + .catch((error: Error) => { + reject(error); + }) + ); + } + + loadApplicationLayout(): Promise { + return new Promise((resolve, reject) => + fetch(`${baseURL}/${applicationLayoutsSaveLocation}`, { + method: "GET", + headers: { + username: "vuu-user", + }, + }) + .then((response) => { + if (!response.ok) { + reject(new Error(response.statusText)); + } + response.json().then((applicationLayout: ApplicationLayout) => { + if (!applicationLayout) { + reject( + new Error( + "Response did not contain valid application layout information" + ) + ); + } + resolve(applicationLayout.definition); + }); + }) + .catch((error: Error) => { + reject(error); + }) + ); + } +} diff --git a/vuu-ui/packages/vuu-layout/src/layout-persistence/index.ts b/vuu-ui/packages/vuu-layout/src/layout-persistence/index.ts index 10bbeed00..c00fe72d9 100644 --- a/vuu-ui/packages/vuu-layout/src/layout-persistence/index.ts +++ b/vuu-ui/packages/vuu-layout/src/layout-persistence/index.ts @@ -1,4 +1,5 @@ export * from "./data"; export * from "./LayoutPersistenceManager"; export * from "./LocalLayoutPersistenceManager"; +export * from './RemoteLayoutPersistenceManager'; export * from "./useLayoutContextMenuItems"; diff --git a/vuu-ui/packages/vuu-layout/src/layout-persistence/useLayoutContextMenuItems.tsx b/vuu-ui/packages/vuu-layout/src/layout-persistence/useLayoutContextMenuItems.tsx index 547f63f77..d0e746636 100644 --- a/vuu-ui/packages/vuu-layout/src/layout-persistence/useLayoutContextMenuItems.tsx +++ b/vuu-ui/packages/vuu-layout/src/layout-persistence/useLayoutContextMenuItems.tsx @@ -1,5 +1,5 @@ import { - LayoutMetadata, + LayoutMetadataDto, SaveLayoutPanel, useLayoutManager, } from "@finos/vuu-shell"; @@ -19,7 +19,7 @@ export const useLayoutContextMenuItems = (setDialogState: SetDialog) => { }, [setDialogState]); const handleSave = useCallback( - (layoutMetadata: Omit) => { + (layoutMetadata: LayoutMetadataDto) => { saveLayout(layoutMetadata); setDialogState(undefined); }, @@ -63,6 +63,7 @@ export const useLayoutContextMenuItems = (setDialogState: SetDialog) => { /> ), title: "Save Layout", + hideCloseButton: true, }); return true; } diff --git a/vuu-ui/packages/vuu-layout/test/layout-persistence/LocalLayoutPersistenceManager.test.ts b/vuu-ui/packages/vuu-layout/test/layout-persistence/LocalLayoutPersistenceManager.test.ts index a94ccc200..200dbbda3 100644 --- a/vuu-ui/packages/vuu-layout/test/layout-persistence/LocalLayoutPersistenceManager.test.ts +++ b/vuu-ui/packages/vuu-layout/test/layout-persistence/LocalLayoutPersistenceManager.test.ts @@ -1,5 +1,5 @@ import "../global-mocks"; -import { Layout, LayoutMetadata } from "@finos/vuu-shell"; +import { Layout, LayoutMetadata, LayoutMetadataDto } from "@finos/vuu-shell"; import { afterEach, describe, expect, it, vi } from "vitest"; import { LocalLayoutPersistenceManager } from "../../src/layout-persistence"; import { LayoutJSON } from "../../src/layout-reducer"; @@ -7,6 +7,8 @@ import { getLocalEntity, saveLocalEntity, } from "../../../vuu-filters/src/local-config"; +import { formatDate } from "@finos/vuu-utils"; +import { expectPromiseRejectsWithError } from "./utils"; vi.mock("@finos/vuu-filters", async () => { return { @@ -29,13 +31,15 @@ const persistenceManager = new LocalLayoutPersistenceManager(); const existingId = "existing_id"; +const newDate = formatDate(new Date(), "dd.mm.yyyy"); + const existingMetadata: LayoutMetadata = { id: existingId, name: "Existing Layout", group: "Group 1", screenshot: "screenshot", user: "vuu user", - date: "01/01/2023", + created: newDate, }; const existingLayout: Layout = { @@ -43,12 +47,19 @@ const existingLayout: Layout = { json: { type: "t0" }, }; -const metadataToAdd: Omit = { +const metadataToAdd: LayoutMetadataDto = { + name: "New Layout", + group: "Group 1", + screenshot: "screenshot", + user: "vuu user", +}; + +const metadataToUpdate: Omit = { name: "New Layout", group: "Group 1", screenshot: "screenshot", user: "vuu user", - date: "26/09/2023", + created: newDate, }; const layoutToAdd: LayoutJSON = { @@ -63,8 +74,8 @@ afterEach(() => { }); describe("createLayout", () => { - it("persists to local storage with a unique ID", async () => { - const returnedId = await persistenceManager.createLayout( + it("persists to local storage with a unique ID and current date", async () => { + const { id, created } = await persistenceManager.createLayout( metadataToAdd, layoutToAdd ); @@ -75,14 +86,16 @@ describe("createLayout", () => { const expectedMetadata: LayoutMetadata = { ...metadataToAdd, - id: returnedId, + id, + created, }; const expectedLayout: Layout = { json: layoutToAdd, - id: returnedId, + id, }; + expect(created).toEqual(newDate); expect(persistedMetadata).toEqual([expectedMetadata]); expect(persistedLayout).toEqual([expectedLayout]); }); @@ -91,11 +104,11 @@ describe("createLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout]); - const returnedId = await persistenceManager.createLayout( + const { id, created } = await persistenceManager.createLayout( metadataToAdd, layoutToAdd ); - expect(returnedId).not.toEqual(existingId); + expect(id).not.toEqual(existingId); const persistedMetadata = getLocalEntity(metadataSaveLocation); @@ -103,12 +116,13 @@ describe("createLayout", () => { const expectedMetadata: LayoutMetadata = { ...metadataToAdd, - id: returnedId, + id, + created, }; const expectedLayout: Layout = { json: layoutToAdd, - id: returnedId, + id, }; expect(persistedMetadata).toEqual([existingMetadata, expectedMetadata]); @@ -123,7 +137,7 @@ describe("updateLayout", () => { await persistenceManager.updateLayout( existingId, - metadataToAdd, + metadataToUpdate, layoutToAdd ); @@ -132,7 +146,7 @@ describe("updateLayout", () => { const persistedLayout = getLocalEntity(layoutsSaveLocation); const expectedMetadata: LayoutMetadata = { - ...metadataToAdd, + ...metadataToUpdate, id: existingId, }; @@ -148,9 +162,13 @@ describe("updateLayout", () => { it("errors if there is no metadata in local storage with requested ID ", async () => { saveLocalEntity(layoutsSaveLocation, [existingLayout]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `No metadata with ID ${existingId}` ); }); @@ -158,9 +176,13 @@ describe("updateLayout", () => { it("errors if there is no layout in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `No layout with ID ${existingId}` ); }); @@ -168,11 +190,11 @@ describe("updateLayout", () => { it("errors if there is no metadata or layout in local storage with requested ID ", async () => { const requestedId = "non_existent_id"; - expectError( + expectPromiseRejectsWithError( () => persistenceManager.updateLayout( requestedId, - metadataToAdd, + metadataToUpdate, layoutToAdd ), `No metadata with ID ${requestedId}; No layout with ID ${requestedId}` @@ -183,9 +205,13 @@ describe("updateLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `Non-unique metadata with ID ${existingId}` ); }); @@ -194,9 +220,13 @@ describe("updateLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `Non-unique layout with ID ${existingId}` ); }); @@ -205,9 +235,13 @@ describe("updateLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `Non-unique metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` ); }); @@ -215,9 +249,13 @@ describe("updateLayout", () => { it("errors if there are multiple metadata entries and no layouts in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `Non-unique metadata with ID ${existingId}; No layout with ID ${existingId}` ); }); @@ -225,9 +263,13 @@ describe("updateLayout", () => { it("errors if there are no metadata entries and multiple layouts in local storage with requested ID ", async () => { saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => - persistenceManager.updateLayout(existingId, metadataToAdd, layoutToAdd), + persistenceManager.updateLayout( + existingId, + metadataToUpdate, + layoutToAdd + ), `No metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` ); }); @@ -251,7 +293,7 @@ describe("deleteLayout", () => { it("errors if there is no metadata in local storage with requested ID ", async () => { saveLocalEntity(layoutsSaveLocation, [existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `No metadata with ID ${existingId}` ); @@ -260,7 +302,7 @@ describe("deleteLayout", () => { it("errors if there is no layout in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `No layout with ID ${existingId}` ); @@ -269,7 +311,7 @@ describe("deleteLayout", () => { it("errors if there is no metadata or layout in local storage with requested ID ", async () => { const requestedId = "non_existent_id"; - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(requestedId), `No metadata with ID ${requestedId}; No layout with ID ${requestedId}` ); @@ -279,7 +321,7 @@ describe("deleteLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `Non-unique metadata with ID ${existingId}` ); @@ -289,7 +331,7 @@ describe("deleteLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `Non-unique layout with ID ${existingId}` ); @@ -299,7 +341,7 @@ describe("deleteLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `Non-unique metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` ); @@ -308,7 +350,7 @@ describe("deleteLayout", () => { it("errors if there are multiple metadata entries and no layouts in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `Non-unique metadata with ID ${existingId}; No layout with ID ${existingId}` ); @@ -317,7 +359,7 @@ describe("deleteLayout", () => { it("errors if there are no metadata entries and multiple layouts in local storage with requested ID ", async () => { saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.deleteLayout(existingId), `No metadata with ID ${existingId}; Non-unique layout with ID ${existingId}` ); @@ -345,7 +387,7 @@ describe("loadLayout", () => { it("errors if there is no layout in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), `No layout with ID ${existingId}` ); @@ -354,7 +396,7 @@ describe("loadLayout", () => { it("errors if there is no metadata or layout in local storage with requested ID ", async () => { const requestedId = "non_existent_id"; - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(requestedId), `No layout with ID ${requestedId}` ); @@ -373,7 +415,7 @@ describe("loadLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), `Non-unique layout with ID ${existingId}` ); @@ -383,7 +425,7 @@ describe("loadLayout", () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), `Non-unique layout with ID ${existingId}` ); @@ -392,7 +434,7 @@ describe("loadLayout", () => { it("errors if there are multiple metadata entries and no layouts in local storage with requested ID ", async () => { saveLocalEntity(metadataSaveLocation, [existingMetadata, existingMetadata]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), `No layout with ID ${existingId}` ); @@ -401,7 +443,7 @@ describe("loadLayout", () => { it("errors if there are no metadata entries and multiple layouts in local storage with requested ID ", async () => { saveLocalEntity(layoutsSaveLocation, [existingLayout, existingLayout]); - expectError( + expectPromiseRejectsWithError( () => persistenceManager.loadLayout(existingId), `Non-unique layout with ID ${existingId}` ); @@ -429,7 +471,3 @@ describe("loadMetadata", () => { expect(await persistenceManager.loadMetadata()).toEqual([]); }); }); - -const expectError = (f: () => Promise, message: string) => { - expect(f).rejects.toStrictEqual(new Error(message)); -}; diff --git a/vuu-ui/packages/vuu-layout/test/layout-persistence/RemoteLayoutPersistenceManager.test.ts b/vuu-ui/packages/vuu-layout/test/layout-persistence/RemoteLayoutPersistenceManager.test.ts new file mode 100644 index 000000000..213d1e6a9 --- /dev/null +++ b/vuu-ui/packages/vuu-layout/test/layout-persistence/RemoteLayoutPersistenceManager.test.ts @@ -0,0 +1,304 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + GetLayoutResponseDto, + CreateLayoutResponseDto, + RemoteLayoutPersistenceManager, +} from "../../src/layout-persistence/RemoteLayoutPersistenceManager"; +import { LayoutMetadata, LayoutMetadataDto } from "@finos/vuu-shell"; +import { LayoutJSON } from "../../src/layout-reducer"; +import { v4 as uuidv4 } from "uuid"; +import { expectPromiseRejectsWithError } from "./utils"; + +const persistence = new RemoteLayoutPersistenceManager(); +const mockFetch = vi.fn(); + +global.fetch = mockFetch; + +const metadata: LayoutMetadata = { + id: "0001", + name: "layout 1", + group: "group 1", + screenshot: "screenshot", + user: "username", + created: "01.01.2000", +}; + +const metadataToAdd: LayoutMetadataDto = { + name: "layout 1", + group: "group 1", + screenshot: "screenshot", + user: "username", +}; + +const layout: LayoutJSON = { + type: "View", +}; + +const uniqueId = uuidv4(); +const dateString = new Date().toISOString(); +const fetchError = new Error("Something went wrong with your request"); + +type FetchResponse = { + json?: () => Promise; + ok: boolean; + statusText?: string; +}; + +describe("RemoteLayoutPersistenceManager", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createLayout", () => { + const responseJSON: CreateLayoutResponseDto = { + metadata: { + ...metadataToAdd, + id: uniqueId, + created: dateString, + }, + }; + + it("resolves with metadata when fetch resolves, response is ok and contains metadata", () => { + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve(responseJSON)), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + const result = persistence.createLayout(metadataToAdd, layout); + + expect(result).resolves.toStrictEqual(responseJSON.metadata); + }); + + it("rejects with error when response is not ok", () => { + const errorMessage = "Not Found"; + + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve(responseJSON)), + ok: false, + statusText: errorMessage, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.createLayout(metadata, layout), + errorMessage + ); + }); + + it("rejects with error when metadata in response is falsey", () => { + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve({})), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.createLayout(metadata, layout), + "Response did not contain valid metadata" + ); + }); + + it("rejects with error when fetch rejects", () => { + mockFetch.mockRejectedValue(fetchError); + + expectPromiseRejectsWithError( + () => persistence.createLayout(metadata, layout), + fetchError.message + ); + }); + }); + + describe("updateLayout", () => { + it("resolves when fetch resolves and response is ok", () => { + const fetchResponse: FetchResponse = { + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + const result = persistence.updateLayout(uniqueId, metadata, layout); + + expect(result).resolves.toBe(undefined); + }); + + it("rejects with error when response is not ok", () => { + const errorMessage = "Not Found"; + + const fetchResponse: FetchResponse = { + ok: false, + statusText: errorMessage, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.updateLayout(uniqueId, metadata, layout), + errorMessage + ); + }); + + it("rejects with error when fetch rejects", () => { + mockFetch.mockRejectedValue(fetchError); + + expectPromiseRejectsWithError( + () => persistence.updateLayout(uniqueId, metadata, layout), + fetchError.message + ); + }); + }); + + describe("deleteLayout", () => { + it("resolves when fetch resolves and response is ok", () => { + const fetchResponse: FetchResponse = { + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + const result = persistence.deleteLayout(uniqueId); + + expect(result).resolves.toBe(undefined); + }); + + it("rejects with error when response is not ok", () => { + const errorMessage = "Not Found"; + + const fetchResponse: FetchResponse = { + ok: false, + statusText: errorMessage, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.deleteLayout(uniqueId), + errorMessage + ); + }); + + it("rejects with error when fetch rejects", () => { + mockFetch.mockRejectedValue(fetchError); + + expectPromiseRejectsWithError( + () => persistence.deleteLayout(uniqueId), + fetchError.message + ); + }); + }); + + describe("loadMetadata", () => { + it("resolves with array of metadata when response is ok", () => { + const responseJson = [metadata]; + + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve(responseJson)), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + const result = persistence.loadMetadata(); + + expect(result).resolves.toBe(responseJson); + }); + + it("rejects with error when response is not ok", () => { + const errorMessage = "Not Found"; + + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve()), + ok: false, + statusText: errorMessage, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.loadMetadata(), + errorMessage + ); + }); + + it("rejects with error when metadata is falsey in response", () => { + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve()), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.loadMetadata(), + "Response did not contain valid metadata" + ); + }); + + it("rejects with error when fetch rejects", () => { + mockFetch.mockRejectedValue(fetchError); + + expectPromiseRejectsWithError( + () => persistence.loadMetadata(), + fetchError.message + ); + }); + }); + + describe("loadLayout", () => { + it("resolves with array of metadata when response is ok", () => { + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve({ definition: layout })), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + const result = persistence.loadLayout(uniqueId); + + expect(result).resolves.toBe(layout); + }); + + it("rejects with error when response is not ok", () => { + const errorMessage = "Not Found"; + + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve({})), + ok: false, + statusText: errorMessage, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.loadLayout(uniqueId), + errorMessage + ); + }); + + it("rejects with error when definition is falsey in response", () => { + const fetchResponse: FetchResponse = { + json: () => new Promise((resolve) => resolve({})), + ok: true, + }; + + mockFetch.mockResolvedValue(fetchResponse); + + expectPromiseRejectsWithError( + () => persistence.loadLayout(uniqueId), + "Response did not contain a valid layout" + ); + }); + + it("rejects with error when fetch rejects", () => { + mockFetch.mockRejectedValue(fetchError); + + expectPromiseRejectsWithError( + () => persistence.loadLayout(uniqueId), + fetchError.message + ); + }); + }); +}); diff --git a/vuu-ui/packages/vuu-layout/test/layout-persistence/utils.ts b/vuu-ui/packages/vuu-layout/test/layout-persistence/utils.ts new file mode 100644 index 000000000..1dff87194 --- /dev/null +++ b/vuu-ui/packages/vuu-layout/test/layout-persistence/utils.ts @@ -0,0 +1,8 @@ +import { expect } from "vitest"; + +export const expectPromiseRejectsWithError = ( + f: () => Promise, + message: string +) => { + expect(f).rejects.toStrictEqual(new Error(message)); +}; diff --git a/vuu-ui/packages/vuu-popups/src/dialog-header/DialogHeader.css b/vuu-ui/packages/vuu-popups/src/dialog-header/DialogHeader.css index d7d487ca3..6877e228f 100644 --- a/vuu-ui/packages/vuu-popups/src/dialog-header/DialogHeader.css +++ b/vuu-ui/packages/vuu-popups/src/dialog-header/DialogHeader.css @@ -3,23 +3,23 @@ --saltButton-width: 28px; --saltToolbar-background: transparent; --saltToolbar-height: calc(var(--salt-size-base) + 5px); + --vuuToolbarProxy-height: 22px; + --salt-text-fontFamily: Nunito Sans A-Variant, sans-serif; - align-items: flex-start; display: flex; + align-items: flex-start; color: var(--light-text-primary, #15171B); font-feature-settings: 'ss02' on, 'ss01' on, 'salt' on, 'liga' off; font-size: 16px; font-weight: 600; - padding: 0 6px; - } - .vuuDialogHeader > .Responsive-inner { +.vuuDialogHeader > .Responsive-inner { align-items: center; - } +} - .vuuDialogHeader > .Responsive-inner > :last-child{ +.vuuDialogHeader > .Responsive-inner > :last-child{ right: 2px; - } +} \ No newline at end of file diff --git a/vuu-ui/packages/vuu-popups/src/dialog/Dialog.css b/vuu-ui/packages/vuu-popups/src/dialog/Dialog.css index 15a253dab..e78d547bd 100644 --- a/vuu-ui/packages/vuu-popups/src/dialog/Dialog.css +++ b/vuu-ui/packages/vuu-popups/src/dialog/Dialog.css @@ -2,9 +2,13 @@ background: var(--salt-container-primary-background); border: var(--vuuDialog-border, solid 1px #ccc); border-radius: 5px; - padding: var(--vuuDialog-padding, 0); + padding: var(--vuuDialog-padding, 16px); box-shadow: var(--salt-overlayable-shadow, none); height: var(--vuuDialog-height, fit-content); width: var(--vuuDialog-width, fit-content); } +.vuuDialog-body { + padding-top: 16px; +} + diff --git a/vuu-ui/packages/vuu-popups/src/dialog/Dialog.tsx b/vuu-ui/packages/vuu-popups/src/dialog/Dialog.tsx index 5d4dff0ec..6338c682d 100644 --- a/vuu-ui/packages/vuu-popups/src/dialog/Dialog.tsx +++ b/vuu-ui/packages/vuu-popups/src/dialog/Dialog.tsx @@ -42,7 +42,7 @@ export const Dialog = ({ onClose={close} title={title} /> - {children} +
{children}
diff --git a/vuu-ui/packages/vuu-popups/src/dialog/useDialog.tsx b/vuu-ui/packages/vuu-popups/src/dialog/useDialog.tsx index 1e18c6aa9..582f68a10 100644 --- a/vuu-ui/packages/vuu-popups/src/dialog/useDialog.tsx +++ b/vuu-ui/packages/vuu-popups/src/dialog/useDialog.tsx @@ -4,6 +4,7 @@ import { Dialog } from "./Dialog"; export type DialogState = { content: ReactElement; title: string; + hideCloseButton?: boolean; }; export type SetDialog = (dialogState?: DialogState) => void; @@ -22,6 +23,7 @@ export const useDialog = () => { onClose={handleClose} style={{ maxHeight: 500 }} title={dialogState.title} + hideCloseButton={dialogState.hideCloseButton} > {dialogState.content} diff --git a/vuu-ui/packages/vuu-shell/src/layout-management/LayoutList.tsx b/vuu-ui/packages/vuu-shell/src/layout-management/LayoutList.tsx index 3cf44170c..82721ccd5 100644 --- a/vuu-ui/packages/vuu-shell/src/layout-management/LayoutList.tsx +++ b/vuu-ui/packages/vuu-shell/src/layout-management/LayoutList.tsx @@ -41,26 +41,26 @@ export const LayoutsList = (props: HTMLAttributes) => { source={Object.entries(layoutsByGroup)} ListItem={({ item }) => { if (!item) return <>; - const [groupName, layouts] = item; + const [groupName, layoutMetadata] = item; return ( <>
{groupName}
- {layouts.map((layout) => ( + {layoutMetadata.map((metadata) => (
handleLoadLayout(layout?.id)} + key={metadata?.id} + onClick={() => handleLoadLayout(metadata?.id)} >
- {layout?.name} + {metadata?.name}
-
{`${layout?.user}, ${layout?.date}`}
+
{`${metadata?.user}, ${metadata?.created}`}
diff --git a/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.css b/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.css index 81ab92060..f7c7a3fde 100644 --- a/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.css +++ b/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.css @@ -6,14 +6,13 @@ --salt-text-label-fontSize: 10px; --saltFormFieldLegacy-label-paddingLeft: 0; --saltFormField-label-fontWeight: 400; + --saltText-color: var(--text-secondary-foreground, #606477); } .saveLayoutPanel-panelContainer { display: flex; - padding: 16px; flex-direction: column; align-items: flex-start; - gap: 16px; } .saveLayoutPanel-panelContent { @@ -37,13 +36,13 @@ } .saveLayoutPanel-inputText { - color: var(--light-text-secondary, #606477); - font-family: Nunito Sans Regular; + border: none; + color: var(--light-text-primary, #15171B); + font-family: Nunito Sans A-Variant, serif; font-feature-settings: 'ss02' on, 'ss01' on, 'salt' on, 'liga' off; font-size: 12px; font-weight: 400; line-height: 16px; - border: none; padding-left: 4px; width: 100%; outline: none; @@ -81,11 +80,11 @@ .saveLayoutPanel-buttonsContainer { display: flex; - padding-top: 8px; justify-content: flex-end; align-items: flex-start; - gap: 8px; align-self: stretch; + padding-top: 24px; + gap: 8px; } .saveLayoutPanel-cancelButton, @@ -96,7 +95,6 @@ align-items: flex-start; gap: 8px; border-radius: 6px; - font-feature-settings: 'ss02' on, 'ss01' on, 'salt' on, 'liga' off; font-size: 12px; font-style: normal; font-weight: 700; diff --git a/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.tsx b/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.tsx index 513a5d05f..b0cf1cc54 100644 --- a/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.tsx +++ b/vuu-ui/packages/vuu-shell/src/layout-management/SaveLayoutPanel.tsx @@ -1,8 +1,8 @@ import { ChangeEvent, useEffect, useState } from "react"; import { Input, Button, FormField, FormFieldLabel, Text } from "@salt-ds/core"; import { ComboBox, Checkbox, RadioButton } from "@finos/vuu-ui-controls"; -import { formatDate, takeScreenshot } from "@finos/vuu-utils"; -import { LayoutMetadata } from "./layoutTypes"; +import { takeScreenshot } from "@finos/vuu-utils"; +import { LayoutMetadataDto } from "./layoutTypes"; import "./SaveLayoutPanel.css"; @@ -19,8 +19,8 @@ type RadioValue = (typeof radioValues)[number]; type SaveLayoutPanelProps = { onCancel: () => void; - onSave: (layoutMetadata: Omit) => void; - componentId?: string; + onSave: (layoutMetadata: LayoutMetadataDto) => void; + componentId?: string }; export const SaveLayoutPanel = (props: SaveLayoutPanelProps) => { @@ -45,10 +45,9 @@ export const SaveLayoutPanel = (props: SaveLayoutPanelProps) => { name: layoutName, group, screenshot: screenshot ?? "", - user: "User", - date: formatDate(new Date(), "dd.mm.yyyy"), - }); - }; + user: "User" + }) + } return (
diff --git a/vuu-ui/packages/vuu-shell/src/layout-management/layoutTypes.ts b/vuu-ui/packages/vuu-shell/src/layout-management/layoutTypes.ts index 96e0441cc..9e264f494 100644 --- a/vuu-ui/packages/vuu-shell/src/layout-management/layoutTypes.ts +++ b/vuu-ui/packages/vuu-shell/src/layout-management/layoutTypes.ts @@ -9,9 +9,16 @@ export interface LayoutMetadata extends WithId { group: string; screenshot: string; user: string; - date: string; + created: string; } +export type LayoutMetadataDto = Omit; + export interface Layout extends WithId { json: LayoutJSON; } + +export type ApplicationLayout = { + username: string, + definition: LayoutJSON +}; diff --git a/vuu-ui/packages/vuu-shell/src/layout-management/useLayoutManager.tsx b/vuu-ui/packages/vuu-shell/src/layout-management/useLayoutManager.tsx index 9785c7c6a..060d41a31 100644 --- a/vuu-ui/packages/vuu-shell/src/layout-management/useLayoutManager.tsx +++ b/vuu-ui/packages/vuu-shell/src/layout-management/useLayoutManager.tsx @@ -1,31 +1,34 @@ import React, { - useState, useCallback, useContext, useEffect, useRef, + useState, } from "react"; import { defaultLayout, LayoutJSON, LayoutPersistenceManager, LocalLayoutPersistenceManager, + RemoteLayoutPersistenceManager, resolveJSONPath, } from "@finos/vuu-layout"; -import { LayoutMetadata } from "./layoutTypes"; +import { LayoutMetadata, LayoutMetadataDto } from "./layoutTypes"; let _persistenceManager: LayoutPersistenceManager; const getPersistenceManager = () => { if (_persistenceManager === undefined) { - _persistenceManager = new LocalLayoutPersistenceManager(); + _persistenceManager = process.env.LOCAL + ? new LocalLayoutPersistenceManager() + : new RemoteLayoutPersistenceManager(); } return _persistenceManager; }; export const LayoutManagementContext = React.createContext<{ layoutMetadata: LayoutMetadata[]; - saveLayout: (n: Omit) => void; + saveLayout: (n: LayoutMetadataDto) => void; applicationLayout: LayoutJSON; saveApplicationLayout: (layout: LayoutJSON) => void; loadLayoutById: (id: string) => void; @@ -62,40 +65,56 @@ export const LayoutManagementProvider = ( useEffect(() => { const persistenceManager = getPersistenceManager(); - persistenceManager.loadMetadata().then((metadata) => { - setLayoutMetadata(metadata); - }); - persistenceManager.loadApplicationLayout().then((layout) => { - setApplicationLayout(layout); - }); + + persistenceManager + .loadMetadata() + .then((metadata) => { + setLayoutMetadata(metadata); + }) + .catch((error: Error) => { + //TODO: Show error toaster + console.error("Error occurred while retrieving metadata", error); + }); + + persistenceManager + .loadApplicationLayout() + .then((layout: LayoutJSON) => { + setApplicationLayout(layout); + }) + .catch((error: Error) => { + //TODO: Show error toaster + console.error( + "Error occurred while retrieving application layout", + error + ); + }); }, [setApplicationLayout]); const saveApplicationLayout = useCallback( (layout: LayoutJSON) => { - const persistenceManager = getPersistenceManager(); + console.log(`save application layout ${JSON.stringify(layout, null, 2)}`); setApplicationLayout(layout, false); - persistenceManager.saveApplicationLayout(layout); + getPersistenceManager().saveApplicationLayout(layout); }, [setApplicationLayout] ); - const saveLayout = useCallback((metadata: Omit) => { + const saveLayout = useCallback((metadata: LayoutMetadataDto) => { const layoutToSave = resolveJSONPath( applicationLayoutRef.current, "#main-tabs.ACTIVE_CHILD" ); if (layoutToSave) { - const persistenceManager = getPersistenceManager(); - persistenceManager + getPersistenceManager() .createLayout(metadata, layoutToSave) - .then((generatedId) => { - const newMetadata: LayoutMetadata = { - ...metadata, - id: generatedId, - }; - - setLayoutMetadata((prev) => [...prev, newMetadata]); + .then((metadata) => { + //TODO: Show success toast + setLayoutMetadata((prev) => [...prev, metadata]); + }) + .catch((error: Error) => { + //TODO: Show error toaster + console.error("Error occurred while saving layout", error); }); } //TODO else{ show error message} @@ -103,15 +122,16 @@ export const LayoutManagementProvider = ( const loadLayoutById = useCallback( (id: string) => { - const persistenceManager = getPersistenceManager(); - persistenceManager.loadLayout(id).then((layoutJson) => { - const { current: prev } = applicationLayoutRef; - setApplicationLayout({ - ...prev, - active: prev.children?.length ?? 0, - children: [...(prev.children || []), layoutJson], + getPersistenceManager() + .loadLayout(id) + .then((layoutJson) => { + const { current: prev } = applicationLayoutRef; + setApplicationLayout({ + ...prev, + active: prev.children?.length ?? 0, + children: [...(prev.children || []), layoutJson], + }); }); - }); }, [setApplicationLayout] ); diff --git a/vuu-ui/packages/vuu-theme/NunitoSans-Regular.woff b/vuu-ui/packages/vuu-theme/fonts/NunitoSans-Regular.woff similarity index 100% rename from vuu-ui/packages/vuu-theme/NunitoSans-Regular.woff rename to vuu-ui/packages/vuu-theme/fonts/NunitoSans-Regular.woff diff --git a/vuu-ui/packages/vuu-theme/fonts/NunitoSans.css b/vuu-ui/packages/vuu-theme/fonts/NunitoSans.css index 5ede2aac8..023d0ba13 100644 --- a/vuu-ui/packages/vuu-theme/fonts/NunitoSans.css +++ b/vuu-ui/packages/vuu-theme/fonts/NunitoSans.css @@ -7,7 +7,9 @@ font-display: swap; src: url(./NunitoSansv15Latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} /* latin */ +} + +/* latin */ @font-face { font-family: 'Nunito Sans'; font-style: normal; @@ -16,7 +18,9 @@ font-display: swap; src: url(./NunitoSansv15Latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} /* latin */ +} + +/* latin */ @font-face { font-family: 'Nunito Sans'; font-style: normal; @@ -26,7 +30,8 @@ src: url(./NunitoSansv15Latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } - /* latin */ + +/* latin */ @font-face { font-family: 'Nunito Sans'; font-style: normal; @@ -36,23 +41,31 @@ src: url(./NunitoSansv15Latin.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } - /* latin */ - @font-face { - font-family: 'Nunito Sans'; - font-style: normal; - font-weight: 700; - font-stretch: 100%; - font-display: swap; - src: url(./NunitoSansv15Latin.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; - } - /* latin */ - @font-face { - font-family: 'Nunito Sans'; - font-style: normal; - font-weight: 800; - font-stretch: 100%; - font-display: swap; - src: url(./NunitoSansv15Latin.woff2) format('woff2'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; - } \ No newline at end of file + +/* latin */ +@font-face { + font-family: 'Nunito Sans'; + font-style: normal; + font-weight: 700; + font-stretch: 100%; + font-display: swap; + src: url(./NunitoSansv15Latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* latin */ +@font-face { + font-family: 'Nunito Sans'; + font-style: normal; + font-weight: 800; + font-stretch: 100%; + font-display: swap; + src: url(./NunitoSansv15Latin.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* This font contains the variation of the 'a' character, which can be applied with `font-feature-settings` as per Figma */ +@font-face { + font-family: 'Nunito Sans A-Variant'; + src: url('./NunitoSans-Regular.woff') format('opentype'); +} diff --git a/vuu-ui/packages/vuu-ui-controls/src/inputs/Checkbox.css b/vuu-ui/packages/vuu-ui-controls/src/inputs/Checkbox.css index 39af11346..713cddbad 100644 --- a/vuu-ui/packages/vuu-ui-controls/src/inputs/Checkbox.css +++ b/vuu-ui/packages/vuu-ui-controls/src/inputs/Checkbox.css @@ -5,6 +5,7 @@ align-items: center; gap: 6px; color: var(--light-text-primary, #15171B); + font-family: Nunito Sans A-Variant, serif; font-feature-settings: 'ss02' on, 'ss01' on, 'salt' on, 'liga' off; font-size: 12px; font-weight: 400; diff --git a/vuu-ui/packages/vuu-ui-controls/src/inputs/RadioButton.css b/vuu-ui/packages/vuu-ui-controls/src/inputs/RadioButton.css index 26526a7fc..7f11e7945 100644 --- a/vuu-ui/packages/vuu-ui-controls/src/inputs/RadioButton.css +++ b/vuu-ui/packages/vuu-ui-controls/src/inputs/RadioButton.css @@ -4,6 +4,7 @@ align-items: center; gap: 6px; color: var(--light-text-primary, #15171B); + font-family: Nunito Sans A-Variant, sans-serif; font-feature-settings: 'ss02' on, 'ss01' on, 'salt' on, 'liga' off; font-size: 12px; font-weight: 400; diff --git a/vuu-ui/sample-apps/feature-basket-trading/src/new-basket-panel/NewBasketPanel.css b/vuu-ui/sample-apps/feature-basket-trading/src/new-basket-panel/NewBasketPanel.css index e6116a0f9..d6ece0fbe 100644 --- a/vuu-ui/sample-apps/feature-basket-trading/src/new-basket-panel/NewBasketPanel.css +++ b/vuu-ui/sample-apps/feature-basket-trading/src/new-basket-panel/NewBasketPanel.css @@ -8,6 +8,7 @@ .vuuBasketNewBasketPanel-body { flex: 1 1 auto; + padding: 16px 0; } .vuuBasketNewBasketPanel-buttonBar { @@ -15,9 +16,7 @@ --saltButton-padding: 12px; display: flex; align-items: flex-end; - gap: 8px; - height: 32px; justify-content: flex-end; - padding: 0 16px; - -} \ No newline at end of file + padding-top: 8px; + gap: 8px; +} diff --git a/vuu-ui/sample-apps/feature-basket-trading/src/new-basket-panel/NewBasketPanel.tsx b/vuu-ui/sample-apps/feature-basket-trading/src/new-basket-panel/NewBasketPanel.tsx index 5076a3ae4..a205b28bd 100644 --- a/vuu-ui/sample-apps/feature-basket-trading/src/new-basket-panel/NewBasketPanel.tsx +++ b/vuu-ui/sample-apps/feature-basket-trading/src/new-basket-panel/NewBasketPanel.tsx @@ -81,7 +81,11 @@ export const NewBasketPanel = ({
- +
Basket Name diff --git a/vuu-ui/scripts/esbuild.mjs b/vuu-ui/scripts/esbuild.mjs index 392ea30a7..254020925 100644 --- a/vuu-ui/scripts/esbuild.mjs +++ b/vuu-ui/scripts/esbuild.mjs @@ -26,6 +26,8 @@ export async function build(config) { define: { "process.env.NODE_ENV": `"${env}"`, "process.env.NODE_DEBUG": `false`, + "process.env.LOCAL": `true`, + "process.env.LAYOUT_BASE_URL": `"http://127.0.0.1:8081/api"`, }, external, footer, diff --git a/vuu-ui/showcase/vite.config.js b/vuu-ui/showcase/vite.config.js index 81ce7f139..a54edf443 100644 --- a/vuu-ui/showcase/vite.config.js +++ b/vuu-ui/showcase/vite.config.js @@ -8,6 +8,8 @@ export default defineConfig({ }, define: { "process.env.NODE_DEBUG": false, + "process.env.LOCAL": true, + "process.env.LAYOUT_BASE_URL": `"http://127.0.0.1:8081/api"`, }, esbuild: { jsx: `automatic`, diff --git a/vuu-ui/tsconfig.json b/vuu-ui/tsconfig.json index 028e09c31..c9c0d8494 100644 --- a/vuu-ui/tsconfig.json +++ b/vuu-ui/tsconfig.json @@ -4,7 +4,12 @@ "noImplicitAny": true, "target": "es2016", "downlevelIteration": true, - "lib": ["dom", "dom.iterable", "esnext", "WebWorker"], + "lib": [ + "dom", + "dom.iterable", + "esnext", + "WebWorker" + ], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, @@ -20,14 +25,17 @@ "sourceMap": true }, "include": [ - "packages/*/src", - "sample-apps/*/src", - "showcase/src", + "packages/*/src", + "sample-apps/*/src", + "showcase/src", "sample-apps/*/index.ts", "sample-apps/*/src", "global.d.ts" , "packages/vuu-data-test/src/UpdateGenerator.ts" ], - "exclude": ["**/*.cy.*", "**/*.test.*"], + "exclude": [ + "**/*.cy.*", + "**/*.test.*" + ], "references": [ {"path" : "packages/vuu-codemirror"}, {"path" : "packages/vuu-data"}, diff --git a/vuu/src/main/scala/org/finos/vuu/provider/VuuJoinTableProvider.scala b/vuu/src/main/scala/org/finos/vuu/provider/VuuJoinTableProvider.scala index 11ce14c0f..dd3dcb62b 100644 --- a/vuu/src/main/scala/org/finos/vuu/provider/VuuJoinTableProvider.scala +++ b/vuu/src/main/scala/org/finos/vuu/provider/VuuJoinTableProvider.scala @@ -1,12 +1,12 @@ package org.finos.vuu.provider import com.typesafe.scalalogging.StrictLogging -import org.finos.vuu.api.{JoinTableDef, TableDef} -import org.finos.vuu.core.table.{DataTable, JoinTable, JoinTableUpdate, RowWithData} -import org.finos.vuu.provider.join.{JoinDefToJoinTable, JoinManagerEventDataSink, JoinRelations, RightToLeftKeys} import org.finos.toolbox.jmx.MetricsProvider import org.finos.toolbox.lifecycle.LifecycleContainer import org.finos.toolbox.time.Clock +import org.finos.vuu.api.{JoinTableDef, TableDef} +import org.finos.vuu.core.table.{DataTable, JoinTable, JoinTableUpdate, RowWithData} +import org.finos.vuu.provider.join.{JoinDefToJoinTable, JoinManagerEventDataSink, JoinRelations, RightToLeftKeys} import java.util import java.util.concurrent.{ArrayBlockingQueue, ConcurrentHashMap}