diff --git a/backend/src/main/java/ch/puzzle/okr/Constants.java b/backend/src/main/java/ch/puzzle/okr/Constants.java index 38220a0e91..197d197e85 100644 --- a/backend/src/main/java/ch/puzzle/okr/Constants.java +++ b/backend/src/main/java/ch/puzzle/okr/Constants.java @@ -7,11 +7,14 @@ private Constants() { public static final String KEY_RESULT_TYPE_METRIC = "metric"; public static final String KEY_RESULT_TYPE_ORDINAL = "ordinal"; public static final String OBJECTIVE = "Objective"; + public static final String OBJECTIVE_LOWERCASE = "objective"; public static final String STATE_DRAFT = "Draft"; public static final String KEY_RESULT = "KeyResult"; public static final String CHECK_IN = "Check-in"; public static final String ACTION = "Action"; public static final String ALIGNMENT = "Alignment"; + public static final String ALIGNMENT_VIEW = "AlignmentView"; + public static final String ALIGNED_OBJECTIVE_ID = "alignedObjectiveId"; public static final String COMPLETED = "Completed"; public static final String ORGANISATION = "Organisation"; public static final String QUARTER = "Quarter"; diff --git a/backend/src/main/java/ch/puzzle/okr/ErrorKey.java b/backend/src/main/java/ch/puzzle/okr/ErrorKey.java index 47a11e39af..50d20d7df7 100644 --- a/backend/src/main/java/ch/puzzle/okr/ErrorKey.java +++ b/backend/src/main/java/ch/puzzle/okr/ErrorKey.java @@ -4,5 +4,5 @@ public enum ErrorKey { ATTRIBUTE_NULL, ATTRIBUTE_CHANGED, ATTRIBUTE_SET_FORBIDDEN, ATTRIBUTE_NOT_SET, ATTRIBUTE_CANNOT_CHANGE, ATTRIBUTE_MUST_BE_DRAFT, KEY_RESULT_CONVERSION, ALREADY_EXISTS_SAME_NAME, CONVERT_TOKEN, DATA_HAS_BEEN_UPDATED, MODEL_NULL, MODEL_WITH_ID_NOT_FOUND, NOT_AUTHORIZED_TO_READ, NOT_AUTHORIZED_TO_WRITE, NOT_AUTHORIZED_TO_DELETE, - TOKEN_NULL, NOT_LINK_YOURSELF, NOT_LINK_IN_SAME_TEAM, ALIGNMENT_ALREADY_EXISTS + TOKEN_NULL, NOT_LINK_YOURSELF, NOT_LINK_IN_SAME_TEAM, ALIGNMENT_ALREADY_EXISTS, ALIGNMENT_DATA_FAIL } diff --git a/backend/src/main/java/ch/puzzle/okr/controller/AlignmentController.java b/backend/src/main/java/ch/puzzle/okr/controller/AlignmentController.java new file mode 100644 index 0000000000..d4d307fac0 --- /dev/null +++ b/backend/src/main/java/ch/puzzle/okr/controller/AlignmentController.java @@ -0,0 +1,41 @@ +package ch.puzzle.okr.controller; + +import ch.puzzle.okr.dto.alignment.AlignmentLists; +import ch.puzzle.okr.service.business.AlignmentBusinessService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("api/v2/alignments") +public class AlignmentController { + private final AlignmentBusinessService alignmentBusinessService; + + public AlignmentController(AlignmentBusinessService alignmentBusinessService) { + this.alignmentBusinessService = alignmentBusinessService; + } + + @Operation(summary = "Get AlignmentLists from filter", description = "Get a list of AlignmentObjects with all AlignmentConnections, which match current quarter, team and objective filter") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Returned AlignmentLists, which match current filters", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = AlignmentLists.class)) }), + @ApiResponse(responseCode = "400", description = "Can't generate AlignmentLists from current filters", content = @Content) }) + @GetMapping("/alignmentLists") + public ResponseEntity getAlignments( + @RequestParam(required = false, defaultValue = "", name = "teamFilter") List teamFilter, + @RequestParam(required = false, defaultValue = "", name = "quarterFilter") Long quarterFilter, + @RequestParam(required = false, defaultValue = "", name = "objectiveQuery") String objectiveQuery) { + return ResponseEntity.status(HttpStatus.OK) + .body(alignmentBusinessService.getAlignmentListsByFilters(quarterFilter, teamFilter, objectiveQuery)); + } +} diff --git a/backend/src/main/java/ch/puzzle/okr/dto/alignment/AlignmentConnectionDto.java b/backend/src/main/java/ch/puzzle/okr/dto/alignment/AlignmentConnectionDto.java new file mode 100644 index 0000000000..a721058a0a --- /dev/null +++ b/backend/src/main/java/ch/puzzle/okr/dto/alignment/AlignmentConnectionDto.java @@ -0,0 +1,5 @@ + +package ch.puzzle.okr.dto.alignment; + +public record AlignmentConnectionDto(Long alignedObjectiveId, Long targetObjectiveId, Long targetKeyResultId) { +} diff --git a/backend/src/main/java/ch/puzzle/okr/dto/alignment/AlignmentLists.java b/backend/src/main/java/ch/puzzle/okr/dto/alignment/AlignmentLists.java new file mode 100644 index 0000000000..4206fcde23 --- /dev/null +++ b/backend/src/main/java/ch/puzzle/okr/dto/alignment/AlignmentLists.java @@ -0,0 +1,7 @@ +package ch.puzzle.okr.dto.alignment; + +import java.util.List; + +public record AlignmentLists(List alignmentObjectDtoList, + List alignmentConnectionDtoList) { +} diff --git a/backend/src/main/java/ch/puzzle/okr/dto/alignment/AlignmentObjectDto.java b/backend/src/main/java/ch/puzzle/okr/dto/alignment/AlignmentObjectDto.java new file mode 100644 index 0000000000..b23c24f1d0 --- /dev/null +++ b/backend/src/main/java/ch/puzzle/okr/dto/alignment/AlignmentObjectDto.java @@ -0,0 +1,5 @@ +package ch.puzzle.okr.dto.alignment; + +public record AlignmentObjectDto(Long objectId, String objectTitle, String objectTeamName, String objectState, + String objectType) { +} diff --git a/backend/src/main/java/ch/puzzle/okr/models/alignment/AlignmentView.java b/backend/src/main/java/ch/puzzle/okr/models/alignment/AlignmentView.java new file mode 100644 index 0000000000..6fd0ea825c --- /dev/null +++ b/backend/src/main/java/ch/puzzle/okr/models/alignment/AlignmentView.java @@ -0,0 +1,236 @@ +package ch.puzzle.okr.models.alignment; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import org.hibernate.annotations.Immutable; + +import java.util.Objects; + +@Entity +@Immutable +public class AlignmentView { + + @Id + private String uniqueId; + private Long id; + private String title; + private Long teamId; + private String teamName; + private Long quarterId; + private String state; + private String objectType; + private String connectionRole; + private Long counterpartId; + private String counterpartType; + + public AlignmentView() { + } + + private AlignmentView(Builder builder) { + setUniqueId(builder.uniqueId); + setId(builder.id); + setTitle(builder.title); + setTeamId(builder.teamId); + setTeamName(builder.teamName); + setQuarterId(builder.quarterId); + setState(builder.state); + setObjectType(builder.objectType); + setConnectionRole(builder.connectionRole); + setCounterpartId(builder.counterpartId); + setCounterpartType(builder.counterpartType); + } + + public void setUniqueId(String uniqueId) { + this.uniqueId = uniqueId; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public Long getTeamId() { + return teamId; + } + + public void setTeamId(Long teamId) { + this.teamId = teamId; + } + + public String getTeamName() { + return teamName; + } + + public void setTeamName(String teamName) { + this.teamName = teamName; + } + + public Long getQuarterId() { + return quarterId; + } + + public void setQuarterId(Long quarterId) { + this.quarterId = quarterId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } + + public String getObjectType() { + return objectType; + } + + public void setObjectType(String objectType) { + this.objectType = objectType; + } + + public String getConnectionRole() { + return connectionRole; + } + + public void setConnectionRole(String connectionItem) { + this.connectionRole = connectionItem; + } + + public Long getCounterpartId() { + return counterpartId; + } + + public void setCounterpartId(Long refId) { + this.counterpartId = refId; + } + + public String getCounterpartType() { + return counterpartType; + } + + public void setCounterpartType(String refType) { + this.counterpartType = refType; + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + AlignmentView that = (AlignmentView) o; + return Objects.equals(uniqueId, that.uniqueId) && Objects.equals(id, that.id) + && Objects.equals(title, that.title) && Objects.equals(teamId, that.teamId) + && Objects.equals(teamName, that.teamName) && Objects.equals(quarterId, that.quarterId) + && Objects.equals(state, that.state) && Objects.equals(objectType, that.objectType) + && Objects.equals(connectionRole, that.connectionRole) + && Objects.equals(counterpartId, that.counterpartId) + && Objects.equals(counterpartType, that.counterpartType); + } + + @Override + public int hashCode() { + return Objects.hash(uniqueId, id, title, teamId, teamName, quarterId, state, objectType, connectionRole, + counterpartId, counterpartType); + } + + @Override + public String toString() { + return "AlignmentView{" + "uniqueId='" + uniqueId + '\'' + ", id=" + id + ", title='" + title + '\'' + + ", teamId=" + teamId + ", teamName='" + teamName + '\'' + ", quarterId=" + quarterId + ", state='" + + state + '\'' + ", objectType='" + objectType + '\'' + ", connectionItem='" + connectionRole + '\'' + + ", refId=" + counterpartId + ", refType='" + counterpartType + '\'' + '}'; + } + + public static final class Builder { + private String uniqueId; + private Long id; + private String title; + private Long teamId; + private String teamName; + private Long quarterId; + private String state; + private String objectType; + private String connectionRole; + private Long counterpartId; + private String counterpartType; + + private Builder() { + } + + public static AlignmentView.Builder builder() { + return new AlignmentView.Builder(); + } + + public Builder withUniqueId(String val) { + uniqueId = val; + return this; + } + + public Builder withId(Long val) { + id = val; + return this; + } + + public Builder withTitle(String val) { + title = val; + return this; + } + + public Builder withTeamId(Long val) { + teamId = val; + return this; + } + + public Builder withTeamName(String val) { + teamName = val; + return this; + } + + public Builder withQuarterId(Long val) { + quarterId = val; + return this; + } + + public Builder withState(String val) { + state = val; + return this; + } + + public Builder withObjectType(String val) { + objectType = val; + return this; + } + + public Builder withConnectionRole(String val) { + connectionRole = val; + return this; + } + + public Builder withCounterpartId(Long val) { + counterpartId = val; + return this; + } + + public Builder withCounterpartType(String val) { + counterpartType = val; + return this; + } + + public AlignmentView build() { + return new AlignmentView(this); + } + } +} diff --git a/backend/src/main/java/ch/puzzle/okr/repository/AlignmentViewRepository.java b/backend/src/main/java/ch/puzzle/okr/repository/AlignmentViewRepository.java new file mode 100644 index 0000000000..b1bd63c4f3 --- /dev/null +++ b/backend/src/main/java/ch/puzzle/okr/repository/AlignmentViewRepository.java @@ -0,0 +1,14 @@ +package ch.puzzle.okr.repository; + +import ch.puzzle.okr.models.alignment.AlignmentView; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface AlignmentViewRepository extends CrudRepository { + + @Query(value = "SELECT * FROM alignment_view where quarter_id = :quarterId ", nativeQuery = true) + List getAlignmentViewByQuarterId(@Param("quarterId") Long quarterId); +} diff --git a/backend/src/main/java/ch/puzzle/okr/service/business/AlignmentBusinessService.java b/backend/src/main/java/ch/puzzle/okr/service/business/AlignmentBusinessService.java index 460b6513f2..a9c51e229a 100644 --- a/backend/src/main/java/ch/puzzle/okr/service/business/AlignmentBusinessService.java +++ b/backend/src/main/java/ch/puzzle/okr/service/business/AlignmentBusinessService.java @@ -1,21 +1,32 @@ package ch.puzzle.okr.service.business; import ch.puzzle.okr.ErrorKey; +import ch.puzzle.okr.dto.alignment.AlignmentConnectionDto; +import ch.puzzle.okr.dto.alignment.AlignmentLists; +import ch.puzzle.okr.dto.alignment.AlignmentObjectDto; import ch.puzzle.okr.dto.alignment.AlignedEntityDto; import ch.puzzle.okr.exception.OkrResponseStatusException; import ch.puzzle.okr.models.Objective; import ch.puzzle.okr.models.alignment.Alignment; +import ch.puzzle.okr.models.alignment.AlignmentView; import ch.puzzle.okr.models.alignment.KeyResultAlignment; import ch.puzzle.okr.models.alignment.ObjectiveAlignment; import ch.puzzle.okr.models.keyresult.KeyResult; import ch.puzzle.okr.service.persistence.AlignmentPersistenceService; +import ch.puzzle.okr.service.persistence.AlignmentViewPersistenceService; import ch.puzzle.okr.service.persistence.KeyResultPersistenceService; import ch.puzzle.okr.service.persistence.ObjectivePersistenceService; import ch.puzzle.okr.service.validation.AlignmentValidationService; +import org.apache.commons.lang3.StringUtils; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import static ch.puzzle.okr.Constants.OBJECTIVE_LOWERCASE; @Service public class AlignmentBusinessService { @@ -24,15 +35,25 @@ public class AlignmentBusinessService { private final AlignmentValidationService alignmentValidationService; private final ObjectivePersistenceService objectivePersistenceService; private final KeyResultPersistenceService keyResultPersistenceService; + private final AlignmentViewPersistenceService alignmentViewPersistenceService; + private final QuarterBusinessService quarterBusinessService; public AlignmentBusinessService(AlignmentPersistenceService alignmentPersistenceService, AlignmentValidationService alignmentValidationService, ObjectivePersistenceService objectivePersistenceService, - KeyResultPersistenceService keyResultPersistenceService) { + KeyResultPersistenceService keyResultPersistenceService, + AlignmentViewPersistenceService alignmentViewPersistenceService, + QuarterBusinessService quarterBusinessService) { this.alignmentPersistenceService = alignmentPersistenceService; this.alignmentValidationService = alignmentValidationService; this.objectivePersistenceService = objectivePersistenceService; this.keyResultPersistenceService = keyResultPersistenceService; + this.alignmentViewPersistenceService = alignmentViewPersistenceService; + this.quarterBusinessService = quarterBusinessService; + } + + protected record DividedAlignmentViewLists(List filterMatchingAlignments, + List nonMatchingAlignments) { } public AlignedEntityDto getTargetIdByAlignedObjectiveId(Long alignedObjectiveId) { @@ -48,6 +69,10 @@ public AlignedEntityDto getTargetIdByAlignedObjectiveId(Long alignedObjectiveId) } public void createEntity(Objective alignedObjective) { + validateOnCreateAndSaveAlignment(alignedObjective); + } + + private void validateOnCreateAndSaveAlignment(Objective alignedObjective) { Alignment alignment = buildAlignmentModel(alignedObjective, 0); alignmentValidationService.validateOnCreate(alignment); alignmentPersistenceService.save(alignment); @@ -55,36 +80,36 @@ public void createEntity(Objective alignedObjective) { public void updateEntity(Long objectiveId, Objective objective) { Alignment savedAlignment = alignmentPersistenceService.findByAlignedObjectiveId(objectiveId); - if (savedAlignment == null) { - createEntity(objective); + validateOnCreateAndSaveAlignment(objective); } else { - handleExistingAlignment(objective, savedAlignment); + if (objective.getAlignedEntity() == null) { + validateOnDeleteAndDeleteById(savedAlignment.getId()); + } else { + Alignment alignment = buildAlignmentModel(objective, savedAlignment.getVersion()); + validateOnUpdateAndRecreateOrSaveAlignment(alignment, savedAlignment); + } } } - private void handleExistingAlignment(Objective objective, Alignment savedAlignment) { - if (objective.getAlignedEntity() == null) { - validateAndDeleteAlignmentById(savedAlignment.getId()); + private void validateOnUpdateAndRecreateOrSaveAlignment(Alignment alignment, Alignment savedAlignment) { + if (isAlignmentTypeChange(alignment, savedAlignment)) { + validateOnUpdateAndRecreateAlignment(savedAlignment.getId(), alignment); } else { - validateAndUpdateAlignment(objective, savedAlignment); + validateOnUpdateAndSaveAlignment(savedAlignment.getId(), alignment); } } - private void validateAndUpdateAlignment(Objective objective, Alignment savedAlignment) { - Alignment alignment = buildAlignmentModel(objective, savedAlignment.getVersion()); - - alignment.setId(savedAlignment.getId()); - alignmentValidationService.validateOnUpdate(savedAlignment.getId(), alignment); - updateAlignment(savedAlignment, alignment); + private void validateOnUpdateAndRecreateAlignment(Long id, Alignment alignment) { + alignment.setId(id); + alignmentValidationService.validateOnUpdate(id, alignment); + alignmentPersistenceService.recreateEntity(id, alignment); } - private void updateAlignment(Alignment savedAlignment, Alignment alignment) { - if (isAlignmentTypeChange(alignment, savedAlignment)) { - alignmentPersistenceService.recreateEntity(savedAlignment.getId(), alignment); - } else { - alignmentPersistenceService.save(alignment); - } + private void validateOnUpdateAndSaveAlignment(Long id, Alignment alignment) { + alignment.setId(id); + alignmentValidationService.validateOnUpdate(id, alignment); + alignmentPersistenceService.save(alignment); } public Alignment buildAlignmentModel(Objective alignedObjective, int version) { @@ -100,8 +125,10 @@ public Alignment buildAlignmentModel(Objective alignedObjective, int version) { Long entityId = alignedObjective.getAlignedEntity().id(); KeyResult targetKeyResult = keyResultPersistenceService.findById(entityId); - return KeyResultAlignment.Builder.builder().withAlignedObjective(alignedObjective) - .withTargetKeyResult(targetKeyResult).withVersion(version).build(); + return KeyResultAlignment.Builder.builder() // + .withAlignedObjective(alignedObjective) // + .withTargetKeyResult(targetKeyResult) // + .withVersion(version).build(); } else { throw new OkrResponseStatusException(HttpStatus.BAD_REQUEST, ErrorKey.ATTRIBUTE_NOT_SET, List.of("alignedEntity", alignedObjective.getAlignedEntity())); @@ -114,35 +141,206 @@ public boolean isAlignmentTypeChange(Alignment alignment, Alignment savedAlignme } public void updateKeyResultIdOnIdChange(Long oldKeyResultId, KeyResult keyResult) { - List keyResultAlignmentList = alignmentPersistenceService - .findByKeyResultAlignmentId(oldKeyResultId); - keyResultAlignmentList.forEach(alignment -> { - alignment.setAlignmentTarget(keyResult); - alignmentValidationService.validateOnUpdate(alignment.getId(), alignment); - alignmentPersistenceService.save(alignment); - }); + alignmentPersistenceService.findByKeyResultAlignmentId(oldKeyResultId) + .forEach(alignment -> validateOnUpdateAndSaveAlignment(keyResult, alignment)); + } + + private void validateOnUpdateAndSaveAlignment(KeyResult keyResult, KeyResultAlignment alignment) { + alignment.setAlignmentTarget(keyResult); + alignmentValidationService.validateOnUpdate(alignment.getId(), alignment); + alignmentPersistenceService.save(alignment); } public void deleteAlignmentByObjectiveId(Long objectiveId) { + ensureAlignmentIdIsNotNull(objectiveId); + alignmentPersistenceService.findByObjectiveAlignmentId(objectiveId) + .forEach(objectiveAlignment -> validateOnDeleteAndDeleteById(objectiveAlignment.getId())); + } + + private void ensureAlignmentIdIsNotNull(Long objectiveId) { Alignment alignment = alignmentPersistenceService.findByAlignedObjectiveId(objectiveId); if (alignment != null) { - validateAndDeleteAlignmentById(alignment.getId()); + validateOnDeleteAndDeleteById(alignment.getId()); } - List objectiveAlignmentList = alignmentPersistenceService - .findByObjectiveAlignmentId(objectiveId); - objectiveAlignmentList - .forEach(objectiveAlignment -> validateAndDeleteAlignmentById(objectiveAlignment.getId())); + } + + private void validateOnDeleteAndDeleteById(Long id) { + alignmentValidationService.validateOnDelete(id); + alignmentPersistenceService.deleteById(id); } public void deleteAlignmentByKeyResultId(Long keyResultId) { - List keyResultAlignmentList = alignmentPersistenceService - .findByKeyResultAlignmentId(keyResultId); - keyResultAlignmentList - .forEach(keyResultAlignment -> validateAndDeleteAlignmentById(keyResultAlignment.getId())); + alignmentPersistenceService.findByKeyResultAlignmentId(keyResultId) + .forEach(keyResultAlignment -> validateOnDeleteAndDeleteById(keyResultAlignment.getId())); + } + + public AlignmentLists getAlignmentListsByFilters(Long quarterFilter, List teamFilter, + String objectiveFilter) { + quarterFilter = quarterFilter(quarterFilter); + teamFilter = Objects.requireNonNullElse(teamFilter, List.of()); + alignmentValidationService.validateOnAlignmentGet(quarterFilter, teamFilter); + + if (teamFilter.isEmpty()) { + return new AlignmentLists(List.of(), List.of()); + } + + List correctAlignmentViewList = correctAlignmentViewList(quarterFilter, teamFilter, + objectiveFilter); + sourceAndTargetListsEqualSameSize(correctAlignmentViewList, quarterFilter, teamFilter, objectiveFilter); + return generateAlignmentLists(correctAlignmentViewList); + } + + private Long quarterFilter(Long quarterFilter) { + if (Objects.isNull(quarterFilter)) { + return quarterBusinessService.getCurrentQuarter().getId(); + } + return quarterFilter; + } + + private List correctAlignmentViewList(Long quarterFilter, List teamFilter, + String objectiveFilter) { + List alignmentViewListByQuarter = alignmentViewPersistenceService + .getAlignmentViewListByQuarterId(quarterFilter); + + DividedAlignmentViewLists dividedAlignmentViewLists = filterAndDivideAlignmentViews(alignmentViewListByQuarter, + teamFilter, objectiveFilter); + return getAlignmentCounterpart(dividedAlignmentViewLists); + } + + protected void sourceAndTargetListsEqualSameSize(List finalList, Long quarterFilter, + List teamFilter, String objectiveFilter) { + List sourceList = finalList.stream() // + .filter(alignmentView -> Objects.equals(alignmentView.getConnectionRole(), "source")) // + .toList(); + + List targetList = finalList.stream() // + .filter(alignmentView -> Objects.equals(alignmentView.getConnectionRole(), "target")) // + .toList(); + + if (sourceList.size() != targetList.size()) { + throw new OkrResponseStatusException(HttpStatus.BAD_REQUEST, ErrorKey.ALIGNMENT_DATA_FAIL, + List.of("alignmentData", quarterFilter, teamFilter, objectiveFilter)); + } + } + + protected AlignmentLists generateAlignmentLists(List alignmentViewList) { + List distictObjectDtoList = createDistinctAlignmentObjectDtoList(alignmentViewList); + List alignmentConnectionDtoList = createAlignmentConnectionDtoListFromConnections( + alignmentViewList); + + return new AlignmentLists(distictObjectDtoList, alignmentConnectionDtoList); + } + + private List createDistinctAlignmentObjectDtoList(List alignmentViewList) { + List alignmentObjectDtoList = new ArrayList<>(); + alignmentViewList.forEach(alignmentView -> alignmentObjectDtoList.add(new AlignmentObjectDto( // + alignmentView.getId(), // + alignmentView.getTitle(), // + alignmentView.getTeamName(), // + alignmentView.getState(), // + alignmentView.getObjectType()))); + + return alignmentObjectDtoList.stream() // + .distinct() // + .toList(); + } + + private List createAlignmentConnectionDtoListFromConnections( + List alignmentViewList) { + List alignmentConnectionDtoList = new ArrayList<>(); + alignmentViewList.forEach(alignmentView -> { + if (Objects.equals(alignmentView.getConnectionRole(), "source")) { + if (Objects.equals(alignmentView.getCounterpartType(), OBJECTIVE_LOWERCASE)) { + alignmentConnectionDtoList.add(new AlignmentConnectionDto( // + alignmentView.getId(), alignmentView.getCounterpartId(), null)); + } else { + alignmentConnectionDtoList.add(new AlignmentConnectionDto( // + alignmentView.getId(), null, alignmentView.getCounterpartId())); + } + } + }); + return alignmentConnectionDtoList; + } + + protected List getAlignmentCounterpart(DividedAlignmentViewLists alignmentViewLists) { + List nonMatchingAlignments = alignmentViewLists.nonMatchingAlignments(); + List filterMatchingAlignments = alignmentViewLists.filterMatchingAlignments(); + List correctAlignmentViewList = correctAlignmentViewList(filterMatchingAlignments, + nonMatchingAlignments); + return createFinalAlignmentViewList(filterMatchingAlignments, correctAlignmentViewList); + } + + private List correctAlignmentViewList(List filterMatchingAlignments, + List nonMatchingAlignments) { + List correctAlignmentViewList = new ArrayList<>(); + filterMatchingAlignments.forEach(alignment -> { + Optional matchingObject = findMatchingAlignmentInList(nonMatchingAlignments, alignment); + matchingObject.map(correctAlignmentViewList::add); + }); + return correctAlignmentViewList; + } + + private Optional findMatchingAlignmentInList(List alignmentList, + AlignmentView alignment) { + return alignmentList.stream().filter(view -> isMatching(alignment, view)).findFirst(); + } + + private boolean isMatching(AlignmentView firstAlignment, AlignmentView secondAlignment) { + return Objects.equals(secondAlignment.getId(), firstAlignment.getCounterpartId()) + && Objects.equals(secondAlignment.getObjectType(), firstAlignment.getCounterpartType()) + && Objects.equals(secondAlignment.getCounterpartId(), firstAlignment.getId()) + && Objects.equals(secondAlignment.getCounterpartType(), firstAlignment.getObjectType()); + } + + private List createFinalAlignmentViewList(List filterMatchingAlignments, + List correctAlignmentViewList) { + List finalAlignmentViewList = new ArrayList<>(filterMatchingAlignments); + if (!correctAlignmentViewList.isEmpty()) { + finalAlignmentViewList.addAll(correctAlignmentViewList); + } + return finalAlignmentViewList; + } + + protected DividedAlignmentViewLists filterAndDivideAlignmentViews(List alignmentViewList, + List teamFilter, String objectiveFilter) { + List filterMatchingAlignments = filterAlignmentListByTeamAndObjective(alignmentViewList, + teamFilter, objectiveFilter); + List nonMatchingAlignments = filterNonMatchingAlignments(alignmentViewList, + filterMatchingAlignments); + + return new DividedAlignmentViewLists(filterMatchingAlignments, nonMatchingAlignments); + } + + private List filterAlignmentListByTeamAndObjective(List alignmentViewList, + List teamFilter, String objectiveFilter) { + List filteredList = filterByTeam(alignmentViewList, teamFilter); + if (StringUtils.isNotBlank(objectiveFilter)) { + filteredList = filterByObjective(filteredList, objectiveFilter); + } + return filteredList; + } + + private List filterByTeam(List alignmentViewList, List teamFilter) { + return alignmentViewList.stream() // + .filter(alignmentView -> teamFilter.contains(alignmentView.getTeamId())) // + .toList(); + } + + private List filterByObjective(List filteredList, String objectiveFilter) { + return filteredList.stream() // + .filter(alignmentView -> isObjectiveAndMatchesFilter(alignmentView, objectiveFilter)) // + .toList(); + } + + private static boolean isObjectiveAndMatchesFilter(AlignmentView alignmentView, String objectiveFilter) { + return Objects.equals(alignmentView.getObjectType(), OBJECTIVE_LOWERCASE) + && alignmentView.getTitle().toLowerCase().contains(objectiveFilter.toLowerCase()); } - private void validateAndDeleteAlignmentById(Long alignmentId) { - alignmentValidationService.validateOnDelete(alignmentId); - alignmentPersistenceService.deleteById(alignmentId); + private List filterNonMatchingAlignments(List alignmentViewList, + List nonMatchingAlignments) { + return alignmentViewList.stream() // + .filter(alignmentView -> !nonMatchingAlignments.contains(alignmentView)) // + .toList(); } } diff --git a/backend/src/main/java/ch/puzzle/okr/service/business/ObjectiveBusinessService.java b/backend/src/main/java/ch/puzzle/okr/service/business/ObjectiveBusinessService.java index 7157f6ef85..00940f98e1 100644 --- a/backend/src/main/java/ch/puzzle/okr/service/business/ObjectiveBusinessService.java +++ b/backend/src/main/java/ch/puzzle/okr/service/business/ObjectiveBusinessService.java @@ -56,13 +56,22 @@ public List getAlignmentPossibilities(Long quarterId) { validator.validateOnGet(quarterId); List objectivesByQuarter = objectivePersistenceService.findObjectiveByQuarterId(quarterId); - List alignmentDtoList = new ArrayList<>(); + List teamList = getTeamsFromObjectives(objectivesByQuarter); + + return createAlignmentDtoForEveryTeam(teamList, objectivesByQuarter); + } - List teamList = objectivesByQuarter.stream() // + private List getTeamsFromObjectives(List objectiveList) { + return objectiveList.stream() // .map(Objective::getTeam) // .distinct() // .sorted(Comparator.comparing(Team::getName)) // .toList(); + } + + private List createAlignmentDtoForEveryTeam(List teamList, + List objectivesByQuarter) { + List alignmentDtoList = new ArrayList<>(); teamList.forEach(team -> { List filteredObjectiveList = objectivesByQuarter.stream() @@ -70,7 +79,6 @@ public List getAlignmentPossibilities(Long quarterId) { .sorted(Comparator.comparing(Objective::getTitle)).toList(); List alignmentObjectDtoList = generateAlignmentObjects(filteredObjectiveList); - AlignmentDto alignmentDto = new AlignmentDto(team.getId(), team.getName(), alignmentObjectDtoList); alignmentDtoList.add(alignmentDto); }); @@ -113,6 +121,26 @@ public List getEntitiesByTeamId(Long id) { @Transactional public Objective updateEntity(Long id, Objective objective, AuthorizationUser authorizationUser) { Objective savedObjective = objectivePersistenceService.findById(id); + Objective updatedObjective = updateObjectiveWithSavedAttrs(objective, savedObjective, authorizationUser); + + validator.validateOnUpdate(id, updatedObjective); + savedObjective = objectivePersistenceService.save(updatedObjective); + handleAlignedEntity(id, savedObjective, updatedObjective); + return savedObjective; + } + + private void handleAlignedEntity(Long id, Objective savedObjective, Objective updatedObjective) { + AlignedEntityDto alignedEntity = alignmentBusinessService + .getTargetIdByAlignedObjectiveId(savedObjective.getId()); + if ((updatedObjective.getAlignedEntity() != null) + || updatedObjective.getAlignedEntity() == null && alignedEntity != null) { + savedObjective.setAlignedEntity(updatedObjective.getAlignedEntity()); + alignmentBusinessService.updateEntity(id, savedObjective); + } + } + + private Objective updateObjectiveWithSavedAttrs(Objective objective, Objective savedObjective, + AuthorizationUser authorizationUser) { objective.setCreatedBy(savedObjective.getCreatedBy()); objective.setCreatedOn(savedObjective.getCreatedOn()); objective.setModifiedBy(authorizationUser.user()); @@ -123,15 +151,7 @@ public Objective updateEntity(Long id, Objective objective, AuthorizationUser au not = " NOT "; } logger.debug("quarter has changed and is{}changeable, {}", not, objective); - validator.validateOnUpdate(id, objective); - savedObjective = objectivePersistenceService.save(objective); - AlignedEntityDto alignedEntity = alignmentBusinessService - .getTargetIdByAlignedObjectiveId(savedObjective.getId()); - if ((objective.getAlignedEntity() != null) || objective.getAlignedEntity() == null && alignedEntity != null) { - savedObjective.setAlignedEntity(objective.getAlignedEntity()); - alignmentBusinessService.updateEntity(id, savedObjective); - } - return savedObjective; + return objective; } public boolean isImUsed(Objective objective) { @@ -169,23 +189,34 @@ public Objective duplicateObjective(Long id, Objective objective, AuthorizationU Objective duplicatedObjective = createEntity(objective, authorizationUser); List keyResultsOfDuplicatedObjective = keyResultBusinessService.getAllKeyResultsByObjective(id); for (KeyResult keyResult : keyResultsOfDuplicatedObjective) { - if (keyResult.getKeyResultType().equals(KEY_RESULT_TYPE_METRIC)) { - KeyResult keyResultMetric = KeyResultMetric.Builder.builder().withObjective(duplicatedObjective) - .withTitle(keyResult.getTitle()).withDescription(keyResult.getDescription()) - .withOwner(keyResult.getOwner()).withUnit(((KeyResultMetric) keyResult).getUnit()) - .withBaseline(0D).withStretchGoal(1D).build(); - keyResultBusinessService.createEntity(keyResultMetric, authorizationUser); - } else if (keyResult.getKeyResultType().equals(KEY_RESULT_TYPE_ORDINAL)) { - KeyResult keyResultOrdinal = KeyResultOrdinal.Builder.builder().withObjective(duplicatedObjective) - .withTitle(keyResult.getTitle()).withDescription(keyResult.getDescription()) - .withOwner(keyResult.getOwner()).withCommitZone("-").withTargetZone("-").withStretchZone("-") - .build(); - keyResultBusinessService.createEntity(keyResultOrdinal, authorizationUser); - } + createKeyResult(keyResult, duplicatedObjective, authorizationUser); } return duplicatedObjective; } + private void createKeyResult(KeyResult keyResult, Objective objective, AuthorizationUser authorizationUser) { + if (keyResult.getKeyResultType().equals(KEY_RESULT_TYPE_METRIC)) { + createMetricKeyResult(keyResult, objective, authorizationUser); + } else if (keyResult.getKeyResultType().equals(KEY_RESULT_TYPE_ORDINAL)) { + createOrdinalKeyResult(keyResult, objective, authorizationUser); + } + } + + private void createMetricKeyResult(KeyResult keyResult, Objective objective, AuthorizationUser authorizationUser) { + KeyResult keyResultMetric = KeyResultMetric.Builder.builder().withObjective(objective) + .withTitle(keyResult.getTitle()).withDescription(keyResult.getDescription()) + .withOwner(keyResult.getOwner()).withUnit(((KeyResultMetric) keyResult).getUnit()).withBaseline(0D) + .withStretchGoal(1D).build(); + keyResultBusinessService.createEntity(keyResultMetric, authorizationUser); + } + + private void createOrdinalKeyResult(KeyResult keyResult, Objective objective, AuthorizationUser authorizationUser) { + KeyResult keyResultOrdinal = KeyResultOrdinal.Builder.builder().withObjective(objective) + .withTitle(keyResult.getTitle()).withDescription(keyResult.getDescription()) + .withOwner(keyResult.getOwner()).withCommitZone("-").withTargetZone("-").withStretchZone("-").build(); + keyResultBusinessService.createEntity(keyResultOrdinal, authorizationUser); + } + @Transactional public void deleteEntityById(Long id) { validator.validateOnDelete(id); diff --git a/backend/src/main/java/ch/puzzle/okr/service/persistence/AlignmentViewPersistenceService.java b/backend/src/main/java/ch/puzzle/okr/service/persistence/AlignmentViewPersistenceService.java new file mode 100644 index 0000000000..74bb0d9038 --- /dev/null +++ b/backend/src/main/java/ch/puzzle/okr/service/persistence/AlignmentViewPersistenceService.java @@ -0,0 +1,26 @@ +package ch.puzzle.okr.service.persistence; + +import ch.puzzle.okr.models.alignment.AlignmentView; +import ch.puzzle.okr.repository.AlignmentViewRepository; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static ch.puzzle.okr.Constants.ALIGNMENT_VIEW; + +@Service +public class AlignmentViewPersistenceService extends PersistenceBase { + + protected AlignmentViewPersistenceService(AlignmentViewRepository repository) { + super(repository); + } + + @Override + public String getModelName() { + return ALIGNMENT_VIEW; + } + + public List getAlignmentViewListByQuarterId(Long quarterId) { + return getRepository().getAlignmentViewByQuarterId(quarterId); + } +} diff --git a/backend/src/main/java/ch/puzzle/okr/service/validation/AlignmentValidationService.java b/backend/src/main/java/ch/puzzle/okr/service/validation/AlignmentValidationService.java index 40bbba8ee5..ef126e12fc 100644 --- a/backend/src/main/java/ch/puzzle/okr/service/validation/AlignmentValidationService.java +++ b/backend/src/main/java/ch/puzzle/okr/service/validation/AlignmentValidationService.java @@ -15,18 +15,25 @@ import java.util.List; import java.util.Objects; +import static ch.puzzle.okr.Constants.ALIGNED_OBJECTIVE_ID; + @Service public class AlignmentValidationService extends ValidationBase { private final AlignmentPersistenceService alignmentPersistenceService; private final TeamPersistenceService teamPersistenceService; + private final QuarterValidationService quarterValidationService; + private final TeamValidationService teamValidationService; public AlignmentValidationService(AlignmentPersistenceService alignmentPersistenceService, - TeamPersistenceService teamPersistenceService) { + TeamPersistenceService teamPersistenceService, QuarterValidationService quarterValidationService, + TeamValidationService teamValidationService) { super(alignmentPersistenceService); this.alignmentPersistenceService = alignmentPersistenceService; this.teamPersistenceService = teamPersistenceService; + this.quarterValidationService = quarterValidationService; + this.teamValidationService = teamValidationService; } @Override @@ -53,7 +60,7 @@ public void validateOnUpdate(Long id, Alignment model) { private void throwExceptionWhenAlignmentObjectIsNull(Alignment model) { if (model.getAlignedObjective() == null) { throw new OkrResponseStatusException(HttpStatus.BAD_REQUEST, ErrorKey.ATTRIBUTE_NULL, - List.of("alignedObjectiveId")); + List.of(ALIGNED_OBJECTIVE_ID)); } else if (model instanceof ObjectiveAlignment objectiveAlignment) { if (objectiveAlignment.getAlignmentTarget() == null) { throw new OkrResponseStatusException(HttpStatus.BAD_REQUEST, ErrorKey.ATTRIBUTE_NULL, @@ -98,7 +105,22 @@ private void throwExceptionWhenAlignedIdIsSameAsTargetId(Alignment model) { private void throwExceptionWhenAlignmentWithAlignedObjectiveAlreadyExists(Alignment model) { if (this.alignmentPersistenceService.findByAlignedObjectiveId(model.getAlignedObjective().getId()) != null) { throw new OkrResponseStatusException(HttpStatus.BAD_REQUEST, ErrorKey.ALIGNMENT_ALREADY_EXISTS, - List.of("alignedObjectiveId", model.getAlignedObjective().getId())); + List.of(ALIGNED_OBJECTIVE_ID, model.getAlignedObjective().getId())); } } + + public void validateOnAlignmentGet(Long quarterId, List teamFilter) { + validateQuarter(quarterId); + teamFilter.forEach(this::validateTeam); + } + + public void validateTeam(Long id) { + teamValidationService.validateOnGet(id); + teamValidationService.doesEntityExist(id); + } + + public void validateQuarter(Long id) { + quarterValidationService.validateOnGet(id); + quarterValidationService.doesEntityExist(id); + } } diff --git a/backend/src/main/resources/db/data-migration/V2_0_99__newQuarterData.sql b/backend/src/main/resources/db/data-migration/V2_0_99__newQuarterData.sql index bbaff68591..9aee34b4e2 100644 --- a/backend/src/main/resources/db/data-migration/V2_0_99__newQuarterData.sql +++ b/backend/src/main/resources/db/data-migration/V2_0_99__newQuarterData.sql @@ -33,7 +33,14 @@ values (19, 1, 'Lorem Ipsum sit amet diri guru humu saguri alam apmach helum di (22, 1, 'Lorem Ipsum sit amet diri guru humu saguri alam apmach helum di gau', '2023-10-02 13:07:56.000000', 'Wing Wang Tala Tala Ting Tang', 1, 7, 6, 'DRAFT', null, '2023-10-02 09:08:40.000000'), (21, 1, 'Lorem Ipsum sit amet diri guru humu saguri alam apmach helum di gau', '2023-10-02 13:07:09.000000', - 'Ting Tang Wala Wala Bing Bang', 1, 7, 6, 'DRAFT', null, '2023-10-02 09:07:39.000000'); + 'Ting Tang Wala Wala Bing Bang', 1, 7, 6, 'DRAFT', null, '2023-10-02 09:07:39.000000'), + (40,1,'', '2024-04-04 13:45:13.000000','Wir wollen eine gute Mitarbeiterzufriedenheit.', 1, 6, 5, 'ONGOING', null,'2024-04-04 13:44:52.000000'), + (41,1,'','2024-04-04 13:59:06.511620','Das Projekt generiert 10000 CHF Umsatz',1,6,5,'ONGOING',null,'2024-04-04 13:59:06.523496'), + (42,1,'','2024-04-04 13:59:40.835896','Die Lehrlinge sollen Freude haben',1,6,4,'ONGOING',null,'2024-04-04 13:59:40.848992'), + (43,1,'','2024-04-04 14:00:05.586152','Der Firmenumsatz steigt',1,6,5,'ONGOING',null,'2024-04-04 14:00:05.588509'), + (44,1,'','2024-04-04 14:00:28.221906','Die Members sollen gerne zur Arbeit kommen',1,6,6,'ONGOING',null,'2024-04-04 14:00:28.229058'), + (45,1,'','2024-04-04 14:00:47.659884','Unsere Designer äussern sich zufrieden',1,6,8,'ONGOING',null,'2024-04-04 14:00:47.664414'), + (46,1,'','2024-04-04 14:00:57.485887','Unsere Designer kommen gerne zur Arbeit',1,6,8,'ONGOING',null,'2024-04-04 14:00:57.494192'); insert into key_result (id, version, baseline, description, modified_on, stretch_goal, title, created_by_id, objective_id, owner_id, unit, key_result_type, created_on, commit_zone, target_zone, @@ -123,7 +130,9 @@ values (20, 1, 0, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lore', '2023-10-02 13:15:22.000000', null, 'Clap of thunder bilge aft log crows nest landlubber or just lubber overhaul', 1, 11, 1, '', 'ordinal', - '2023-10-02 09:16:07.000000', 'This is the commit zone', 'This is the target zone', 'This is the stretch zone'); + '2023-10-02 09:16:07.000000', 'This is the commit zone', 'This is the target zone', 'This is the stretch zone'), + (40,1,50,'',null,70,'60% sind in der Membersumfrage zufrienden',1,40,1,'PERCENT','metric','2024-04-04 14:06:21.689768',null,null,null), + (41,1,20000,'',null,80000,'Wir erreichen einen Umsatz von 70000 CHF',1,46,1,'CHF','metric','2024-04-04 14:06:42.100353',null,null,null); insert into check_in (id, version, change_info, created_on, initiatives, modified_on, value_metric, created_by_id, key_result_id, @@ -171,7 +180,8 @@ values (21, 1, null, 1, 30, 2, 'ordinal', 'FAIL'), (32, 1, 'Lorem ipsum dolor sit amet, richi rogsi brokilon', '2023-10-02 08:50:44.059000', ' sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat', '2023-10-02 22:00:00.000000', - 13, 1, 31, 3, 'metric', null); + 13, 1, 31, 3, 'metric', null), + (40,1,'','2024-04-04 14:10:33.377726','','2024-04-04 14:10:33.377739',30000,1,41,7,'metric',null); insert into quarter (id, label, start_date, end_date) values (8, 'GJ 23/24-Q3', '2024-01-01', '2024-03-31'); @@ -190,4 +200,18 @@ insert into completed (id, version, objective_id, comment) values (1, 1, 15, 'Not successful because there were many events this month'), (2, 1, 19, 'Was not successful because we were too slow'), (3, 1, 18, 'Sadly we had not enough members to complete this objective'), - (4, 1, 20, 'Objective could be completed fast and easy'); \ No newline at end of file + (4, 1, 20, 'Objective could be completed fast and easy'); + +insert into alignment(id, aligned_objective_id, alignment_type, target_key_result_id, target_objective_id, version) +values (1, 4, 'objective', null, 6, 0), + (2, 3, 'objective', null, 6, 0), + (3, 8, 'objective', null, 3, 0), + (4, 9, 'keyResult', 8, null, 0), + (5, 10, 'keyResult', 5, null, 0), + (6, 5, 'keyResult', 4, null, 0), + (7, 6, 'keyResult', 3, null, 0), +-- (8, 41, 'objective', null, 40, 0), + (9, 42, 'objective', null, 40, 0), + (10, 43, 'keyResult', 41, null, 0), + (11, 44, 'objective', null, 42, 0), + (12, 45, 'keyResult', 40, null, 0); diff --git a/backend/src/main/resources/db/h2-db/data-test-h2/V100_0_0__TestData.sql b/backend/src/main/resources/db/h2-db/data-test-h2/V100_0_0__TestData.sql index c712d2532c..8b3131a332 100644 --- a/backend/src/main/resources/db/h2-db/data-test-h2/V100_0_0__TestData.sql +++ b/backend/src/main/resources/db/h2-db/data-test-h2/V100_0_0__TestData.sql @@ -72,7 +72,14 @@ values (4, 1, '', '2023-07-25 08:17:51.309958', 66, 'Build a company culture tha null, '2023-07-25 08:39:45.772126'), (8,1, '', '2023-07-25 08:39:28.175703', 40, 'consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua', - 1, 2, 6, 'ONGOING', null, '2023-07-25 08:39:28.175703'); + 1, 2, 6, 'ONGOING', null, '2023-07-25 08:39:28.175703'), + (40,1,'', '2024-04-04 13:45:13.000000',40,'Wir wollen eine gute Mitarbeiterzufriedenheit.', 1, 9, 5, 'ONGOING', null,'2024-04-04 13:44:52.000000'), + (41,1,'','2024-04-04 13:59:06.511620',40,'Das Projekt generiert 10000 CHF Umsatz',1,9,5,'ONGOING',null,'2024-04-04 13:59:06.523496'), + (42,1,'','2024-04-04 13:59:40.835896',40,'Die Lehrlinge sollen Freude haben',1,9,4,'ONGOING',null,'2024-04-04 13:59:40.848992'), + (43,1,'','2024-04-04 14:00:05.586152',40,'Der Firmenumsatz steigt',1,9,5,'ONGOING',null,'2024-04-04 14:00:05.588509'), + (44,1,'','2024-04-04 14:00:28.221906',40,'Die Members sollen gerne zur Arbeit kommen',1,9,6,'ONGOING',null,'2024-04-04 14:00:28.229058'), + (45,1,'','2024-04-04 14:00:47.659884',40,'Unsere Designer äussern sich zufrieden',1,9,8,'ONGOING',null,'2024-04-04 14:00:47.664414'), + (46,1,'','2024-04-04 14:00:57.485887',40,'Unsere Designer kommen gerne zur Arbeit',1,9,8,'ONGOING',null,'2024-04-04 14:00:57.494192'); insert into key_result (id, version, baseline, description, modified_on, stretch_goal, title, created_by_id, objective_id, owner_id, key_result_type, created_on, unit, commit_zone, target_zone, stretch_zone) @@ -91,7 +98,9 @@ values (10,1, 465, '', '2023-07-25 08:23:02.273028', 60, 'Im Durchschnitt soll (19,1, 50, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lore', '2023-07-25 08:42:56.407125', 1, 'nsetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At ', 1, 8, 1, 'metric', '2023-07-25 08:42:56.407125', 'PERCENT', null, null, null), (17,1, 525, 'asdf', '2023-07-25 08:41:52.844903', 20000000, 'vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lore', 1, 9, 1, 'metric', '2023-07-25 08:41:52.844903', 'PERCENT', null, null, null), (9,1, 100, '', '2023-07-25 08:48:45.825328', 80, 'Die Member des BBT reduzieren Ihre Lautstärke um 20%', 1, 5, 1, 'metric', '2023-07-25 08:48:45.825328', 'PERCENT', null, null, null), - (18,1, 0, 'consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lore', '2023-07-25 08:42:24.779721', 1, 'Lorem', 1, 8, 1, 'metric', '2023-07-25 08:42:24.779721', 'PERCENT', null, null, null); + (18,1, 0, 'consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lore', '2023-07-25 08:42:24.779721', 1, 'Lorem', 1, 8, 1, 'metric', '2023-07-25 08:42:24.779721', 'PERCENT', null, null, null), + (40,1,50,'',null,70,'60% sind in der Membersumfrage zufrienden',1,40,1,'metric','2024-04-04 14:06:21.689768','PERCENT',null,null,null), + (41,1,20000,'',null,80000,'Wir erreichen einen Umsatz von 70000 CHF',1,46,1,'metric','2024-04-04 14:06:42.100353','CHF',null,null,null); insert into check_in (id, version, change_info, created_on, initiatives, modified_on, value_metric, created_by_id, key_result_id, confidence, check_in_type, zone) values (1,1, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam', '2023-07-25 08:44:13.865976', '', '2023-07-24 22:00:00.000000', 77, 1, 8, 5, 'metric', null), @@ -112,11 +121,22 @@ values (1,1, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam (17,1, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores ', '2023-07-25 08:49:32.030171', 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores ', '2023-07-24 22:00:00.000000', 66.7, 1, 16, 5, 'metric', null), (18,1, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores ', '2023-07-25 08:49:56.975649', 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores ', '2023-07-24 22:00:00.000000', 99, 1, 15, 5, 'metric', null), (19,1, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores ', '2023-07-25 08:50:19.024254', 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores ', '2023-07-24 22:00:00.000000', 35, 1, 19, 5, 'metric', null), - (20,1, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores ', '2023-07-25 08:50:44.059020', 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores ', '2023-07-24 22:00:00.000000', 0.5, 1, 18, 5, 'metric', null); + (20,1, 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores ', '2023-07-25 08:50:44.059020', 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores ', '2023-07-24 22:00:00.000000', 0.5, 1, 18, 5, 'metric', null), + (40,1,'','2024-04-04 14:10:33.377726','','2024-04-04 14:10:33.377739',30000,1,41,7,'metric',null); -insert into alignment (id, version, aligned_objective_id, alignment_type, target_key_result_id, target_objective_id) values - (1,1, 4, 'objective', null, 3), - (2,1, 9, 'keyResult', 8, null); +insert into alignment (id, aligned_objective_id, alignment_type, target_key_result_id, target_objective_id, version) values + (1, 9, 'keyResult', 8, null, 1), + (2, 4, 'objective', null, 6, 0), + (3, 3, 'objective', null, 6, 0), + (4, 8, 'objective', null, 3, 0), + (5, 10, 'keyResult', 5, null, 0), + (6, 5, 'keyResult', 4, null, 0), + (7, 6, 'keyResult', 3, null, 0), + (8, 41, 'objective', null, 40, 0), + (9, 42, 'objective', null, 40, 0), + (10, 43, 'keyResult', 40, null, 0), + (11, 44, 'objective', null, 42, 0), + (12, 45, 'keyResult', 41, null, 0); insert into completed (id, version, objective_id, comment) values (1,1, 4, 'Das hat geklappt'), diff --git a/backend/src/main/resources/db/h2-db/database-h2-schema/V1_0_0__current-db-schema-for-testing.sql b/backend/src/main/resources/db/h2-db/database-h2-schema/V1_0_0__current-db-schema-for-testing.sql index 19f8f23ee7..acb9f76816 100644 --- a/backend/src/main/resources/db/h2-db/database-h2-schema/V1_0_0__current-db-schema-for-testing.sql +++ b/backend/src/main/resources/db/h2-db/database-h2-schema/V1_0_0__current-db-schema-for-testing.sql @@ -227,3 +227,56 @@ create table if not exists team_organisation constraint fk_team_organisation_team foreign key (team_id) references team ); + +DROP VIEW IF EXISTS ALIGNMENT_VIEW; +CREATE VIEW ALIGNMENT_VIEW AS +SELECT + CONCAT(OA.ID, COALESCE(A.TARGET_OBJECTIVE_ID, A.TARGET_KEY_RESULT_ID),'S','objective',A.ALIGNMENT_TYPE) AS UNIQUE_ID, + OA.ID AS ID, + OA.TITLE AS TITLE, + OTT.ID AS TEAM_ID, + OTT.NAME AS TEAM_NAME, + OA.QUARTER_ID AS QUARTER_ID, + OA.STATE AS STATE, + 'objective' AS OBJECT_TYPE, + 'source' AS CONNECTION_ROLE, + COALESCE(A.TARGET_OBJECTIVE_ID, A.TARGET_KEY_RESULT_ID) AS COUNTERPART_ID, + A.ALIGNMENT_TYPE AS COUNTERPART_TYPE +FROM ALIGNMENT A + LEFT JOIN OBJECTIVE OA ON OA.ID = A.ALIGNED_OBJECTIVE_ID + LEFT JOIN TEAM OTT ON OTT.ID = OA.TEAM_ID +UNION +SELECT + CONCAT(OT.ID, A.ALIGNED_OBJECTIVE_ID,'T','objective','objective') AS UNIQUE_ID, + OT.ID AS ID, + OT.TITLE AS TITLE, + OTT.ID AS TEAM_ID, + OTT.NAME AS TEAM_NAME, + OT.QUARTER_ID AS QUARTER_ID, + OT.STATE AS STATE, + 'objective' AS OBJECT_TYPE, + 'target' AS CONNECTION_ROLE, + A.ALIGNED_OBJECTIVE_ID AS COUNTERPART_ID, + 'objective' AS COUNTERPART_TYPE +FROM ALIGNMENT A + LEFT JOIN OBJECTIVE OT ON OT.ID = A.TARGET_OBJECTIVE_ID + LEFT JOIN TEAM OTT ON OTT.ID = OT.TEAM_ID +WHERE ALIGNMENT_TYPE = 'objective' +UNION +SELECT + CONCAT(KRT.ID, A.ALIGNED_OBJECTIVE_ID,'T','keyResult','keyResult') AS UNIQUE_ID, + KRT.ID AS ID, + KRT.TITLE AS TITLE, + OTT.ID AS TEAM_ID, + OTT.NAME AS TEAM_NAME, + O.QUARTER_ID AS QUARTER_ID, + NULL AS STATE, + 'keyResult' AS OBJECT_TYPE, + 'target' AS CONNECTION_ROLE, + A.ALIGNED_OBJECTIVE_ID AS COUNTERPART_ID, + 'objective' AS COUNTERPART_TYPE +FROM ALIGNMENT A + LEFT JOIN KEY_RESULT KRT ON KRT.ID = A.TARGET_KEY_RESULT_ID + LEFT JOIN OBJECTIVE O ON O.ID = KRT.OBJECTIVE_ID + LEFT JOIN TEAM OTT ON OTT.ID = O.TEAM_ID +WHERE ALIGNMENT_TYPE = 'keyResult'; \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V2_1_4__createAlignmentView.sql b/backend/src/main/resources/db/migration/V2_1_4__createAlignmentView.sql new file mode 100644 index 0000000000..2cced34040 --- /dev/null +++ b/backend/src/main/resources/db/migration/V2_1_4__createAlignmentView.sql @@ -0,0 +1,56 @@ +DROP VIEW IF EXISTS alignment_view; +CREATE VIEW alignment_view AS +SELECT + concat(oa.id, coalesce(a.target_objective_id, a.target_key_result_id),'S','objective',a.alignment_type) as unique_id, + oa.id as id, + oa.title as title, + ott.id as team_id, + ott.name as team_name, + oa.quarter_id as quarter_id, + oa.state as state, + 'objective' as object_type, + 'source' as connection_role, + coalesce(a.target_objective_id, a.target_key_result_id) as counterpart_id, + a.alignment_type as counterpart_type +FROM alignment a + LEFT JOIN objective oa ON oa.id = a.aligned_objective_id + LEFT JOIN team ott ON ott.id = oa.team_id + +UNION + +SELECT + concat(ot.id, a.aligned_objective_id,'T','objective','objective') as unique_id, + ot.id as id, + ot.title as title, + ott.id as team_id, + ott.name as team_name, + ot.quarter_id as quarter_id, + ot.state as state, + 'objective' as object_type, + 'target' as connection_role, + a.aligned_objective_id as counterpart_id, + 'objective' as counterpart_type +FROM alignment a + LEFT JOIN objective ot ON ot.id = a.target_objective_id + LEFT JOIN team ott ON ott.id = ot.team_id +where alignment_type = 'objective' + +UNION + +SELECT + concat(krt.id, a.aligned_objective_id,'T','keyResult','keyResult') as unique_id, + krt.id as id, + krt.title as title, + ott.id as team_id, + ott.name as team_name, + o.quarter_id as quarter_id, + null as state, + 'keyResult' as object_type, + 'target' as connection_role, + a.aligned_objective_id as counterpart_id, + 'objective' as counterpart_type +FROM alignment a + LEFT JOIN key_result krt ON krt.id = a.target_key_result_id + LEFT JOIN objective o ON o.id = krt.objective_id + LEFT JOIN team ott ON ott.id = o.team_id +where alignment_type = 'keyResult'; \ No newline at end of file diff --git a/backend/src/test/java/ch/puzzle/okr/controller/AlignmentControllerIT.java b/backend/src/test/java/ch/puzzle/okr/controller/AlignmentControllerIT.java new file mode 100644 index 0000000000..33dd701e76 --- /dev/null +++ b/backend/src/test/java/ch/puzzle/okr/controller/AlignmentControllerIT.java @@ -0,0 +1,72 @@ +package ch.puzzle.okr.controller; + +import ch.puzzle.okr.dto.alignment.AlignmentConnectionDto; +import ch.puzzle.okr.dto.alignment.AlignmentLists; +import ch.puzzle.okr.dto.alignment.AlignmentObjectDto; +import ch.puzzle.okr.service.business.AlignmentBusinessService; +import org.hamcrest.Matchers; +import org.hamcrest.core.Is; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.BDDMockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import java.util.List; + +import static ch.puzzle.okr.TestConstants.TEAM_PUZZLE; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +@WithMockUser(value = "spring") +@ExtendWith(MockitoExtension.class) +@WebMvcTest(AlignmentController.class) +class AlignmentControllerIT { + @Autowired + private MockMvc mvc; + @MockBean + private AlignmentBusinessService alignmentBusinessService; + + private static final String OBJECTIVE = "objective"; + private static final String ONGOING = "ONGOING"; + static AlignmentObjectDto alignmentObjectDto1 = new AlignmentObjectDto(3L, "Title of first Objective", TEAM_PUZZLE, + ONGOING, OBJECTIVE); + static AlignmentObjectDto alignmentObjectDto2 = new AlignmentObjectDto(4L, "Title of second Objective", "BBT", + ONGOING, OBJECTIVE); + static AlignmentConnectionDto alignmentConnectionDto = new AlignmentConnectionDto(4L, 3L, null); + + static AlignmentLists alignmentLists = new AlignmentLists(List.of(alignmentObjectDto1, alignmentObjectDto2), + List.of(alignmentConnectionDto)); + + @Test + void shouldReturnCorrectAlignmentData() throws Exception { + BDDMockito.given(alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(4L, 5L, 8L), "")) + .willReturn(alignmentLists); + + mvc.perform(get("/api/v2/alignments/alignmentLists?quarterFilter=2&teamFilter=4,5,8") + .contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(jsonPath("$.alignmentObjectDtoList", Matchers.hasSize(2))) + .andExpect(jsonPath("$.alignmentObjectDtoList[1].objectId", Is.is(4))) + .andExpect(jsonPath("$.alignmentConnectionDtoList[0].alignedObjectiveId", Is.is(4))) + .andExpect(jsonPath("$.alignmentConnectionDtoList[0].targetObjectiveId", Is.is(3))); + } + + @Test + void shouldReturnCorrectAlignmentDataWithObjectiveSearch() throws Exception { + BDDMockito.given(alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(4L, 5L, 8L), "secon")) + .willReturn(alignmentLists); + + mvc.perform(get("/api/v2/alignments/alignmentLists?quarterFilter=2&teamFilter=4,5,8&objectiveQuery=secon") + .contentType(MediaType.APPLICATION_JSON)).andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(jsonPath("$.alignmentObjectDtoList", Matchers.hasSize(2))) + .andExpect(jsonPath("$.alignmentObjectDtoList[1].objectId", Is.is(4))) + .andExpect(jsonPath("$.alignmentConnectionDtoList[0].alignedObjectiveId", Is.is(4))) + .andExpect(jsonPath("$.alignmentConnectionDtoList[0].targetObjectiveId", Is.is(3))); + } +} diff --git a/backend/src/test/java/ch/puzzle/okr/service/business/AlignmentBusinessServiceIT.java b/backend/src/test/java/ch/puzzle/okr/service/business/AlignmentBusinessServiceIT.java new file mode 100644 index 0000000000..e4b9bf7e79 --- /dev/null +++ b/backend/src/test/java/ch/puzzle/okr/service/business/AlignmentBusinessServiceIT.java @@ -0,0 +1,133 @@ +package ch.puzzle.okr.service.business; + +import ch.puzzle.okr.dto.alignment.AlignmentLists; +import ch.puzzle.okr.test.SpringIntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@SpringIntegrationTest +class AlignmentBusinessServiceIT { + @Autowired + private AlignmentBusinessService alignmentBusinessService; + + private final String OBJECTIVE = "objective"; + + @Test + void shouldReturnCorrectAlignmentData() { + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(9L, List.of(5L, 6L), ""); + + assertEquals(6, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(4, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(40L, alignmentLists.alignmentObjectDtoList().get(0).objectId()); + assertEquals(OBJECTIVE, alignmentLists.alignmentObjectDtoList().get(0).objectType()); + assertEquals(40L, alignmentLists.alignmentObjectDtoList().get(1).objectId()); + assertEquals("keyResult", alignmentLists.alignmentObjectDtoList().get(1).objectType()); + assertEquals(41L, alignmentLists.alignmentObjectDtoList().get(2).objectId()); + assertEquals(43L, alignmentLists.alignmentObjectDtoList().get(3).objectId()); + assertEquals(44L, alignmentLists.alignmentObjectDtoList().get(4).objectId()); + assertEquals(42L, alignmentLists.alignmentObjectDtoList().get(5).objectId()); + assertEquals(41L, alignmentLists.alignmentConnectionDtoList().get(0).alignedObjectiveId()); + assertEquals(40L, alignmentLists.alignmentConnectionDtoList().get(0).targetObjectiveId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(0).targetKeyResultId()); + assertEquals(43L, alignmentLists.alignmentConnectionDtoList().get(1).alignedObjectiveId()); + assertEquals(40L, alignmentLists.alignmentConnectionDtoList().get(1).targetKeyResultId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(1).targetObjectiveId()); + assertEquals(44L, alignmentLists.alignmentConnectionDtoList().get(2).alignedObjectiveId()); + assertEquals(42L, alignmentLists.alignmentConnectionDtoList().get(2).targetObjectiveId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(2).targetKeyResultId()); + assertEquals(42L, alignmentLists.alignmentConnectionDtoList().get(3).alignedObjectiveId()); + assertEquals(40L, alignmentLists.alignmentConnectionDtoList().get(3).targetObjectiveId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(3).targetKeyResultId()); + } + + @Test + void shouldReturnCorrectAlignmentDataWhenLimitedTeamMatching() { + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(9L, List.of(6L), ""); + + assertEquals(2, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(1, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(44L, alignmentLists.alignmentObjectDtoList().get(0).objectId()); + assertEquals(OBJECTIVE, alignmentLists.alignmentObjectDtoList().get(0).objectType()); + assertEquals(42L, alignmentLists.alignmentObjectDtoList().get(1).objectId()); + assertEquals(OBJECTIVE, alignmentLists.alignmentObjectDtoList().get(1).objectType()); + assertEquals(44L, alignmentLists.alignmentConnectionDtoList().get(0).alignedObjectiveId()); + assertEquals(42L, alignmentLists.alignmentConnectionDtoList().get(0).targetObjectiveId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(0).targetKeyResultId()); + } + + @Test + void shouldReturnCorrectAlignmentDataWithObjectiveSearch() { + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(9L, List.of(4L, 5L, 6L, 8L), + "lehrling"); + + assertEquals(3, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(2, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(42L, alignmentLists.alignmentObjectDtoList().get(0).objectId()); + assertEquals(OBJECTIVE, alignmentLists.alignmentObjectDtoList().get(0).objectType()); + assertEquals(40L, alignmentLists.alignmentObjectDtoList().get(1).objectId()); + assertEquals(OBJECTIVE, alignmentLists.alignmentObjectDtoList().get(1).objectType()); + assertEquals(44L, alignmentLists.alignmentObjectDtoList().get(2).objectId()); + assertEquals(OBJECTIVE, alignmentLists.alignmentObjectDtoList().get(2).objectType()); + assertEquals(42L, alignmentLists.alignmentConnectionDtoList().get(0).alignedObjectiveId()); + assertEquals(40L, alignmentLists.alignmentConnectionDtoList().get(0).targetObjectiveId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(0).targetKeyResultId()); + assertEquals(44L, alignmentLists.alignmentConnectionDtoList().get(1).alignedObjectiveId()); + assertEquals(42L, alignmentLists.alignmentConnectionDtoList().get(1).targetObjectiveId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(1).targetKeyResultId()); + } + + @Test + void shouldReturnCorrectAlignmentDataWithKeyResultWhenMatchingObjectiveSearch() { + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(9L, List.of(4L, 5L, 6L, 8L), + "firmenums"); + + assertEquals(2, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(1, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(43L, alignmentLists.alignmentObjectDtoList().get(0).objectId()); + assertEquals(OBJECTIVE, alignmentLists.alignmentObjectDtoList().get(0).objectType()); + assertEquals(40L, alignmentLists.alignmentObjectDtoList().get(1).objectId()); + assertEquals("keyResult", alignmentLists.alignmentObjectDtoList().get(1).objectType()); + assertEquals(43L, alignmentLists.alignmentConnectionDtoList().get(0).alignedObjectiveId()); + assertEquals(40L, alignmentLists.alignmentConnectionDtoList().get(0).targetKeyResultId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(0).targetObjectiveId()); + } + + @Test + void shouldReturnEmptyAlignmentDataWhenNoAlignments() { + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(3L, List.of(5L, 6L), ""); + + assertEquals(0, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(0, alignmentLists.alignmentConnectionDtoList().size()); + } + + @Test + void shouldReturnEmptyAlignmentDataWhenNoMatchingObjectiveSearch() { + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(9L, List.of(4L, 5L, 6L, 8L), + "spass"); + + assertEquals(0, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(0, alignmentLists.alignmentConnectionDtoList().size()); + } + + @Test + void shouldReturnCorrectAlignmentDataWhenEmptyQuarterFilter() { + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(null, + List.of(4L, 5L, 6L, 8L), ""); + + assertEquals(8, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(5, alignmentLists.alignmentConnectionDtoList().size()); + } + + @Test + void shouldReturnEmptyAlignmentDataWhenEmptyTeamFilter() { + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, null, ""); + + assertEquals(0, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(0, alignmentLists.alignmentConnectionDtoList().size()); + } +} diff --git a/backend/src/test/java/ch/puzzle/okr/service/business/AlignmentBusinessServiceTest.java b/backend/src/test/java/ch/puzzle/okr/service/business/AlignmentBusinessServiceTest.java index 6737dd466b..7e81b66335 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/business/AlignmentBusinessServiceTest.java +++ b/backend/src/test/java/ch/puzzle/okr/service/business/AlignmentBusinessServiceTest.java @@ -2,18 +2,23 @@ import ch.puzzle.okr.TestHelper; import ch.puzzle.okr.dto.ErrorDto; +import ch.puzzle.okr.dto.alignment.AlignmentLists; import ch.puzzle.okr.dto.alignment.AlignedEntityDto; import ch.puzzle.okr.exception.OkrResponseStatusException; import ch.puzzle.okr.models.Objective; +import ch.puzzle.okr.models.Quarter; import ch.puzzle.okr.models.alignment.Alignment; +import ch.puzzle.okr.models.alignment.AlignmentView; import ch.puzzle.okr.models.alignment.KeyResultAlignment; import ch.puzzle.okr.models.alignment.ObjectiveAlignment; import ch.puzzle.okr.models.keyresult.KeyResult; import ch.puzzle.okr.models.keyresult.KeyResultMetric; import ch.puzzle.okr.service.persistence.AlignmentPersistenceService; +import ch.puzzle.okr.service.persistence.AlignmentViewPersistenceService; import ch.puzzle.okr.service.persistence.KeyResultPersistenceService; import ch.puzzle.okr.service.persistence.ObjectivePersistenceService; import ch.puzzle.okr.service.validation.AlignmentValidationService; +import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -37,6 +42,10 @@ class AlignmentBusinessServiceTest { @Mock AlignmentPersistenceService alignmentPersistenceService; @Mock + AlignmentViewPersistenceService alignmentViewPersistenceService; + @Mock + QuarterBusinessService quarterBusinessService; + @Mock AlignmentValidationService validator; @InjectMocks private AlignmentBusinessService alignmentBusinessService; @@ -59,6 +68,24 @@ class AlignmentBusinessServiceTest { .withAlignedObjective(objective2).withTargetObjective(objective1).build(); KeyResultAlignment keyResultAlignment = KeyResultAlignment.Builder.builder().withId(6L) .withAlignedObjective(objective3).withTargetKeyResult(metricKeyResult).build(); + Quarter quarter = Quarter.Builder.builder().withId(2L).withLabel("GJ 23/24-Q1").build(); + + AlignmentView alignmentView1 = AlignmentView.Builder.builder().withUniqueId("45TkeyResultkeyResult").withId(4L) + .withTitle("Antwortzeit für Supportanfragen um 33% verkürzen.").withTeamId(5L).withTeamName("Puzzle ITC") + .withQuarterId(2L).withObjectType("keyResult").withConnectionRole("target").withCounterpartId(5L) + .withCounterpartType("objective").build(); + AlignmentView alignmentView2 = AlignmentView.Builder.builder().withUniqueId("54SobjectivekeyResult").withId(5L) + .withTitle("Wir wollen das leiseste Team bei Puzzle sein.").withTeamId(4L).withTeamName("/BBT") + .withQuarterId(2L).withObjectType("objective").withConnectionRole("source").withCounterpartId(4L) + .withCounterpartType("keyResult").build(); + AlignmentView alignmentView3 = AlignmentView.Builder.builder().withUniqueId("4041Tobjectiveobjective").withId(40L) + .withTitle("Wir wollen eine gute Mitarbeiterzufriedenheit.").withTeamId(6L).withTeamName("LoremIpsum") + .withQuarterId(2L).withObjectType("objective").withConnectionRole("target").withCounterpartId(41L) + .withCounterpartType("objective").build(); + AlignmentView alignmentView4 = AlignmentView.Builder.builder().withUniqueId("4140Sobjectiveobjective").withId(41L) + .withTitle("Das Projekt generiert 10000 CHF Umsatz").withTeamId(6L).withTeamName("LoremIpsum") + .withQuarterId(2L).withObjectType("objective").withConnectionRole("source").withCounterpartId(40L) + .withCounterpartType("objective").build(); @Test void shouldGetTargetAlignmentIdObjective() { @@ -261,4 +288,308 @@ void shouldDeleteByKeyResultId() { // assert verify(alignmentPersistenceService, times(1)).deleteById(keyResultAlignment.getId()); } + + @Test + void shouldReturnCorrectAlignmentData() { + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(2L)) + .thenReturn(List.of(alignmentView1, alignmentView2)); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(4L), ""); + + verify(alignmentViewPersistenceService, times(1)).getAlignmentViewListByQuarterId(2L); + assertEquals(1, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(2, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(5L, alignmentLists.alignmentObjectDtoList().get(0).objectId()); + assertEquals(4L, alignmentLists.alignmentObjectDtoList().get(1).objectId()); + assertEquals(5L, alignmentLists.alignmentConnectionDtoList().get(0).alignedObjectiveId()); + assertEquals(4L, alignmentLists.alignmentConnectionDtoList().get(0).targetKeyResultId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(0).targetObjectiveId()); + } + + @Test + void shouldReturnCorrectAlignmentDataWithMultipleTeamFilter() { + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(2L)) + .thenReturn(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4)); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(4L, 6L), ""); + + verify(alignmentViewPersistenceService, times(1)).getAlignmentViewListByQuarterId(2L); + assertEquals(2, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(4, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(5L, alignmentLists.alignmentObjectDtoList().get(0).objectId()); + assertEquals(40L, alignmentLists.alignmentObjectDtoList().get(1).objectId()); + assertEquals(5L, alignmentLists.alignmentConnectionDtoList().get(0).alignedObjectiveId()); + assertEquals(4L, alignmentLists.alignmentConnectionDtoList().get(0).targetKeyResultId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(0).targetObjectiveId()); + assertEquals(41L, alignmentLists.alignmentConnectionDtoList().get(1).alignedObjectiveId()); + assertEquals(40L, alignmentLists.alignmentConnectionDtoList().get(1).targetObjectiveId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(1).targetKeyResultId()); + } + + @Test + void shouldReturnCorrectAlignmentDataWhenTeamFilterHasLimitedMatch() { + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(2L)) + .thenReturn(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4)); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(4L), ""); + + verify(alignmentViewPersistenceService, times(1)).getAlignmentViewListByQuarterId(2L); + assertEquals(1, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(2, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(5L, alignmentLists.alignmentObjectDtoList().get(0).objectId()); + assertEquals(4L, alignmentLists.alignmentObjectDtoList().get(1).objectId()); + assertEquals(5L, alignmentLists.alignmentConnectionDtoList().get(0).alignedObjectiveId()); + assertEquals(4L, alignmentLists.alignmentConnectionDtoList().get(0).targetKeyResultId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(0).targetObjectiveId()); + } + + @Test + void shouldReturnEmptyAlignmentDataWhenNoMatchingTeam() { + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(2L)) + .thenReturn(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4)); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(12L), ""); + + verify(alignmentViewPersistenceService, times(1)).getAlignmentViewListByQuarterId(2L); + assertEquals(0, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(0, alignmentLists.alignmentObjectDtoList().size()); + } + + @Test + void shouldReturnEmptyAlignmentDataWhenNoTeamFilterProvided() { + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, null, ""); + + verify(alignmentViewPersistenceService, times(0)).getAlignmentViewListByQuarterId(2L); + verify(validator, times(1)).validateOnAlignmentGet(2L, List.of()); + assertEquals(0, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(0, alignmentLists.alignmentObjectDtoList().size()); + } + + @Test + void shouldReturnEmptyAlignmentDataWhenNoQuarterFilterProvided() { + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(any())) + .thenReturn(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4)); + when(quarterBusinessService.getCurrentQuarter()).thenReturn(quarter); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(null, List.of(4L, 6L), ""); + + verify(alignmentViewPersistenceService, times(1)).getAlignmentViewListByQuarterId(2L); + verify(quarterBusinessService, times(1)).getCurrentQuarter(); + assertEquals(2, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(4, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(5L, alignmentLists.alignmentObjectDtoList().get(0).objectId()); + assertEquals(40L, alignmentLists.alignmentObjectDtoList().get(1).objectId()); + assertEquals(5L, alignmentLists.alignmentConnectionDtoList().get(0).alignedObjectiveId()); + assertEquals(4L, alignmentLists.alignmentConnectionDtoList().get(0).targetKeyResultId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(0).targetObjectiveId()); + assertEquals(41L, alignmentLists.alignmentConnectionDtoList().get(1).alignedObjectiveId()); + assertEquals(40L, alignmentLists.alignmentConnectionDtoList().get(1).targetObjectiveId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(1).targetKeyResultId()); + } + + @Test + void shouldReturnCorrectAlignmentDataWithObjectiveSearch() { + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(2L)) + .thenReturn(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4)); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(4L, 5L, 6L), + "leise"); + + verify(alignmentViewPersistenceService, times(1)).getAlignmentViewListByQuarterId(2L); + assertEquals(1, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(2, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(5L, alignmentLists.alignmentObjectDtoList().get(0).objectId()); + assertEquals(4L, alignmentLists.alignmentObjectDtoList().get(1).objectId()); + assertEquals(5L, alignmentLists.alignmentConnectionDtoList().get(0).alignedObjectiveId()); + assertEquals(4L, alignmentLists.alignmentConnectionDtoList().get(0).targetKeyResultId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(0).targetObjectiveId()); + } + + @Test + void shouldReturnEmptyAlignmentDataWhenNoMatchingObjectiveFromObjectiveSearch() { + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(2L)) + .thenReturn(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4)); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(4L, 5L, 6L), + "Supportanfragen"); + + verify(alignmentViewPersistenceService, times(1)).getAlignmentViewListByQuarterId(2L); + assertEquals(0, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(0, alignmentLists.alignmentObjectDtoList().size()); + } + + @Test + void shouldReturnEmptyAlignmentDataWhenNoMatchingObjectiveSearch() { + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(2L)) + .thenReturn(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4)); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(4L, 5L, 6L), + "wird nicht vorkommen"); + + verify(alignmentViewPersistenceService, times(1)).getAlignmentViewListByQuarterId(2L); + assertEquals(0, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(0, alignmentLists.alignmentObjectDtoList().size()); + } + + @Test + void shouldReturnEmptyAlignmentDataWhenNoAlignmentViews() { + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(2L)).thenReturn(List.of()); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(4L, 5L, 6L), + ""); + + verify(alignmentViewPersistenceService, times(1)).getAlignmentViewListByQuarterId(2L); + assertEquals(0, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(0, alignmentLists.alignmentObjectDtoList().size()); + } + + @Test + void shouldReturnCorrectAlignmentListsWithComplexAlignments() { + AlignmentView alignmentView1 = AlignmentView.Builder.builder().withUniqueId("36TkeyResultkeyResult").withId(3L) + .withTitle("Steigern der URS um 25%").withTeamId(5L).withTeamName("Puzzle ITC").withQuarterId(2L) + .withObjectType("keyResult").withConnectionRole("target").withCounterpartId(6L) + .withCounterpartType("objective").build(); + AlignmentView alignmentView2 = AlignmentView.Builder.builder().withUniqueId("63SobjectivekeyResult").withId(6L) + .withTitle("Als BBT wollen wir den Arbeitsalltag der Members von Puzzle ITC erleichtern.") + .withTeamId(4L).withTeamName("/BBT").withQuarterId(2L).withObjectType("objective") + .withConnectionRole("source").withCounterpartId(3L).withCounterpartType("keyResult").build(); + AlignmentView alignmentView3 = AlignmentView.Builder.builder().withUniqueId("63Tobjectiveobjective").withId(6L) + .withTitle("Als BBT wollen wir den Arbeitsalltag der Members von Puzzle ITC erleichtern.") + .withTeamId(4L).withTeamName("/BBT").withQuarterId(2L).withObjectType("objective") + .withConnectionRole("target").withCounterpartId(3L).withCounterpartType("objective").build(); + AlignmentView alignmentView4 = AlignmentView.Builder.builder().withUniqueId("36Sobjectiveobjective").withId(3L) + .withTitle("Wir wollen die Kundenzufriedenheit steigern").withTeamId(4L).withTeamName("/BBT") + .withQuarterId(2L).withObjectType("objective").withConnectionRole("source").withCounterpartId(6L) + .withCounterpartType("objective").build(); + + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(2L)) + .thenReturn(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4)); + + AlignmentLists alignmentLists = alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(5L), ""); + + verify(alignmentViewPersistenceService, times(1)).getAlignmentViewListByQuarterId(2L); + assertEquals(1, alignmentLists.alignmentConnectionDtoList().size()); + assertEquals(2, alignmentLists.alignmentObjectDtoList().size()); + assertEquals(3L, alignmentLists.alignmentObjectDtoList().get(0).objectId()); + assertEquals("keyResult", alignmentLists.alignmentObjectDtoList().get(0).objectType()); + assertEquals(6L, alignmentLists.alignmentObjectDtoList().get(1).objectId()); + assertEquals("objective", alignmentLists.alignmentObjectDtoList().get(1).objectType()); + assertEquals(6L, alignmentLists.alignmentConnectionDtoList().get(0).alignedObjectiveId()); + assertEquals(3L, alignmentLists.alignmentConnectionDtoList().get(0).targetKeyResultId()); + assertNull(alignmentLists.alignmentConnectionDtoList().get(0).targetObjectiveId()); + } + + @Test + void shouldCorrectFilterAlignmentViewListsWithAllCorrectData() { + AlignmentBusinessService.DividedAlignmentViewLists dividedAlignmentViewLists = alignmentBusinessService + .filterAndDivideAlignmentViews(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4), + List.of(4L, 6L, 5L), ""); + + assertEquals(4, dividedAlignmentViewLists.filterMatchingAlignments().size()); + assertEquals(0, dividedAlignmentViewLists.nonMatchingAlignments().size()); + assertEquals(4, dividedAlignmentViewLists.filterMatchingAlignments().get(0).getId()); + assertEquals(5, dividedAlignmentViewLists.filterMatchingAlignments().get(1).getId()); + assertEquals(40, dividedAlignmentViewLists.filterMatchingAlignments().get(2).getId()); + assertEquals(41, dividedAlignmentViewLists.filterMatchingAlignments().get(3).getId()); + } + + @Test + void shouldCorrectFilterAlignmentViewListsWithLimitedTeamFilter() { + AlignmentBusinessService.DividedAlignmentViewLists dividedAlignmentViewLists = alignmentBusinessService + .filterAndDivideAlignmentViews(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4), + List.of(6L), ""); + + assertEquals(2, dividedAlignmentViewLists.filterMatchingAlignments().size()); + assertEquals(2, dividedAlignmentViewLists.nonMatchingAlignments().size()); + assertEquals(40, dividedAlignmentViewLists.filterMatchingAlignments().get(0).getId()); + assertEquals(41, dividedAlignmentViewLists.filterMatchingAlignments().get(1).getId()); + assertEquals(4, dividedAlignmentViewLists.nonMatchingAlignments().get(0).getId()); + assertEquals(5, dividedAlignmentViewLists.nonMatchingAlignments().get(1).getId()); + } + + @Test + void shouldCorrectFilterAlignmentViewListsWithObjectiveSearch() { + AlignmentBusinessService.DividedAlignmentViewLists dividedAlignmentViewLists = alignmentBusinessService + .filterAndDivideAlignmentViews(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4), + List.of(4L, 6L, 5L), "leise"); + + assertEquals(1, dividedAlignmentViewLists.filterMatchingAlignments().size()); + assertEquals(3, dividedAlignmentViewLists.nonMatchingAlignments().size()); + assertEquals(5, dividedAlignmentViewLists.filterMatchingAlignments().get(0).getId()); + assertEquals(4, dividedAlignmentViewLists.nonMatchingAlignments().get(0).getId()); + assertEquals(40, dividedAlignmentViewLists.nonMatchingAlignments().get(1).getId()); + assertEquals(41, dividedAlignmentViewLists.nonMatchingAlignments().get(2).getId()); + } + + @Test + void shouldCorrectFilterWhenNoMatchingObjectiveSearch() { + AlignmentBusinessService.DividedAlignmentViewLists dividedAlignmentViewLists = alignmentBusinessService + .filterAndDivideAlignmentViews(List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4), + List.of(4L, 6L, 5L), "verk"); + + assertEquals(0, dividedAlignmentViewLists.filterMatchingAlignments().size()); + assertEquals(4, dividedAlignmentViewLists.nonMatchingAlignments().size()); + assertEquals(4, dividedAlignmentViewLists.nonMatchingAlignments().get(0).getId()); + assertEquals(5, dividedAlignmentViewLists.nonMatchingAlignments().get(1).getId()); + assertEquals(40, dividedAlignmentViewLists.nonMatchingAlignments().get(2).getId()); + assertEquals(41, dividedAlignmentViewLists.nonMatchingAlignments().get(3).getId()); + } + + @Test + void shouldThrowErrorWhenPersistenceServiceReturnsIncorrectData() { + AlignmentView alignmentView5 = AlignmentView.Builder.builder().withUniqueId("23TkeyResultkeyResult").withId(20L) + .withTitle("Dies hat kein Gegenstück").withTeamId(5L).withTeamName("Puzzle ITC").withQuarterId(2L) + .withObjectType("keyResult").withConnectionRole("target").withCounterpartId(37L) + .withCounterpartType("objective").build(); + List finalList = List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4, + alignmentView5); + + doNothing().when(validator).validateOnAlignmentGet(anyLong(), anyList()); + when(alignmentViewPersistenceService.getAlignmentViewListByQuarterId(2L)).thenReturn(finalList); + + OkrResponseStatusException exception = assertThrows(OkrResponseStatusException.class, + () -> alignmentBusinessService.getAlignmentListsByFilters(2L, List.of(5L), "")); + + List expectedErrors = List + .of(new ErrorDto("ALIGNMENT_DATA_FAIL", List.of("alignmentData", "2", "[5]", ""))); + + assertEquals(BAD_REQUEST, exception.getStatusCode()); + Assertions.assertThat(expectedErrors).hasSameElementsAs(exception.getErrors()); + assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(exception.getReason())); + } + + @Test + void shouldNotThrowErrorWhenSameAmountOfSourceAndTarget() { + List finalList = List.of(alignmentView1, alignmentView2, alignmentView3, alignmentView4); + + assertDoesNotThrow( + () -> alignmentBusinessService.sourceAndTargetListsEqualSameSize(finalList, 2L, List.of(5L), "")); + } + + @Test + void shouldThrowErrorWhenNotSameAmountOfSourceAndTarget() { + List finalList = List.of(alignmentView1, alignmentView2, alignmentView3); + + OkrResponseStatusException exception = assertThrows(OkrResponseStatusException.class, + () -> alignmentBusinessService.sourceAndTargetListsEqualSameSize(finalList, 2L, List.of(5L), "")); + + List expectedErrors = List + .of(new ErrorDto("ALIGNMENT_DATA_FAIL", List.of("alignmentData", "2", "[5]", ""))); + + assertEquals(BAD_REQUEST, exception.getStatusCode()); + Assertions.assertThat(expectedErrors).hasSameElementsAs(exception.getErrors()); + assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(exception.getReason())); + } } diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/AlignmentPersistenceServiceIT.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/AlignmentPersistenceServiceIT.java index 428c415aa1..34e2f56a20 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/persistence/AlignmentPersistenceServiceIT.java +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/AlignmentPersistenceServiceIT.java @@ -235,13 +235,13 @@ private void shouldDeleteOldAlignment(Long alignmentId) { } private void assertAlignment(ObjectiveAlignment objectiveAlignment) { - assertEquals(1L, objectiveAlignment.getId()); + assertEquals(4L, objectiveAlignment.getId()); assertEquals(3L, objectiveAlignment.getAlignmentTarget().getId()); - assertEquals(4L, objectiveAlignment.getAlignedObjective().getId()); + assertEquals(8L, objectiveAlignment.getAlignedObjective().getId()); } private void assertAlignment(KeyResultAlignment keyResultAlignment) { - assertEquals(2L, keyResultAlignment.getId()); + assertEquals(1L, keyResultAlignment.getId()); assertEquals(8L, keyResultAlignment.getAlignmentTarget().getId()); assertEquals(9L, keyResultAlignment.getAlignedObjective().getId()); } diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/AlignmentViewPersistenceServiceIT.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/AlignmentViewPersistenceServiceIT.java new file mode 100644 index 0000000000..9d36c2ede3 --- /dev/null +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/AlignmentViewPersistenceServiceIT.java @@ -0,0 +1,53 @@ +package ch.puzzle.okr.service.persistence; + +import ch.puzzle.okr.models.alignment.AlignmentView; +import ch.puzzle.okr.test.SpringIntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringIntegrationTest +class AlignmentViewPersistenceServiceIT { + @Autowired + private AlignmentViewPersistenceService alignmentViewPersistenceService; + + private static final List expectedAlignmentViewIds = List.of(40L, 41L, 42L, 43L, 44L, 45L); + + private static final List expectedAlignmentViewTeamIds = List.of(4L, 5L, 6L, 8L); + + private static final List expectedAlignmentViewQuarterId = List.of(9L); + + @Test + void getAlignmentsByFiltersShouldReturnListOfAlignmentViews() { + List alignmentViewList = alignmentViewPersistenceService.getAlignmentViewListByQuarterId(9L); + + assertEquals(10, alignmentViewList.size()); + + assertThat(getAlignmentViewIds(alignmentViewList)).hasSameElementsAs(expectedAlignmentViewIds); + assertThat(getAlignmentViewTeamIds(alignmentViewList)).hasSameElementsAs(expectedAlignmentViewTeamIds); + assertThat(getAlignmentViewQuarterIds(alignmentViewList)).hasSameElementsAs(expectedAlignmentViewQuarterId); + } + + @Test + void getAlignmentsByFiltersShouldReturnEmptyListOfAlignmentViewsWhenQuarterNotExisting() { + List alignmentViewList = alignmentViewPersistenceService.getAlignmentViewListByQuarterId(311L); + + assertEquals(0, alignmentViewList.size()); + } + + private List getAlignmentViewIds(List alignmentViewIds) { + return alignmentViewIds.stream().map(AlignmentView::getId).toList(); + } + + private List getAlignmentViewTeamIds(List alignmentViewIds) { + return alignmentViewIds.stream().map(AlignmentView::getTeamId).toList(); + } + + private List getAlignmentViewQuarterIds(List alignmentViewIds) { + return alignmentViewIds.stream().map(AlignmentView::getQuarterId).toList(); + } +} diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/CheckInPersistenceServiceIT.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/CheckInPersistenceServiceIT.java index b985c482f5..ab366e16d5 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/persistence/CheckInPersistenceServiceIT.java +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/CheckInPersistenceServiceIT.java @@ -108,7 +108,7 @@ void updateKeyResultShouldThrowExceptionWhenAlreadyUpdated() { void getAllCheckInShouldReturnListOfAllCheckIns() { List checkIns = checkInPersistenceService.findAll(); - assertEquals(19, checkIns.size()); + assertEquals(20, checkIns.size()); } @Test diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/ObjectivePersistenceServiceIT.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/ObjectivePersistenceServiceIT.java index bda4b4976a..343224c4b6 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/persistence/ObjectivePersistenceServiceIT.java +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/ObjectivePersistenceServiceIT.java @@ -68,7 +68,7 @@ void tearDown() { void findAllShouldReturnListOfObjectives() { List objectives = objectivePersistenceService.findAll(); - assertEquals(7, objectives.size()); + assertEquals(14, objectives.size()); } @Test diff --git a/backend/src/test/java/ch/puzzle/okr/service/validation/AlignmentValidationServiceTest.java b/backend/src/test/java/ch/puzzle/okr/service/validation/AlignmentValidationServiceTest.java index 8d370e4bc0..80c1803f2c 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/validation/AlignmentValidationServiceTest.java +++ b/backend/src/test/java/ch/puzzle/okr/service/validation/AlignmentValidationServiceTest.java @@ -36,6 +36,10 @@ class AlignmentValidationServiceTest { AlignmentPersistenceService alignmentPersistenceService; @Mock TeamPersistenceService teamPersistenceService; + @Mock + QuarterValidationService quarterValidationService; + @Mock + TeamValidationService teamValidationService; @Spy @InjectMocks private AlignmentValidationService validator; @@ -54,6 +58,9 @@ class AlignmentValidationServiceTest { .withTargetObjective(objective1).build(); KeyResultAlignment keyResultAlignment = KeyResultAlignment.Builder.builder().withId(6L) .withAlignedObjective(objective3).withTargetKeyResult(metricKeyResult).build(); + Long quarterId = 1L; + Long teamId = 1L; + List teamIds = List.of(1L, 2L, 3L, 4L); @BeforeEach void setUp() { @@ -424,4 +431,26 @@ void validateOnDeleteShouldThrowExceptionIfAlignmentIdIsNull() { assertEquals(List.of(new ErrorDto("ATTRIBUTE_NULL", List.of("ID", "Alignment"))), exception.getErrors()); } + @Test + void validateOnGetShouldCallQuarterValidator() { + validator.validateQuarter(quarterId); + verify(quarterValidationService, times(1)).validateOnGet(quarterId); + verify(quarterValidationService, times(1)).doesEntityExist(quarterId); + } + + @Test + void validateOnGetShouldCallTeamValidator() { + validator.validateTeam(teamId); + verify(teamValidationService, times(1)).validateOnGet(teamId); + verify(teamValidationService, times(1)).doesEntityExist(teamId); + } + + @Test + void validateOnGetShouldCallQuarterValidatorAndTeamValidator() { + validator.validateOnAlignmentGet(quarterId, teamIds); + verify(quarterValidationService, times(1)).validateOnGet(quarterId); + verify(quarterValidationService, times(1)).doesEntityExist(quarterId); + verify(teamValidationService, times(teamIds.size())).validateOnGet(anyLong()); + verify(teamValidationService, times(teamIds.size())).doesEntityExist(anyLong()); + } } diff --git a/frontend/cypress/e2e/diagram.cy.ts b/frontend/cypress/e2e/diagram.cy.ts new file mode 100644 index 0000000000..76e53bb336 --- /dev/null +++ b/frontend/cypress/e2e/diagram.cy.ts @@ -0,0 +1,79 @@ +import * as users from '../fixtures/users.json'; + +describe('OKR diagram e2e tests', () => { + describe('tests via click', () => { + beforeEach(() => { + cy.loginAsUser(users.gl); + cy.visit('/?quarter=10'); + cy.getByTestId('add-objective').first().click(); + cy.fillOutObjective('An Objective for Testing', 'safe-draft', '10'); + }); + + it('Can switch to diagram with the tab-switch', () => { + cy.get('h1:contains(Puzzle ITC)').should('have.length', 1); + cy.get('mat-chip:visible:contains("Puzzle ITC")').should('have.length', 1); + cy.contains('Overview'); + cy.contains('Network'); + cy.contains('An Objective for Testing'); + + cy.getByTestId('diagramTab').first().click(); + + cy.contains('Kein Alignment vorhanden'); + cy.get('h1:visible:contains(Puzzle ITC)').should('have.length', 0); + cy.get('mat-chip:visible:contains("Puzzle ITC")').should('have.length', 1); + cy.getByTestId('objective').should('not.be.visible'); + + cy.getByTestId('overviewTab').first().click(); + + cy.get('h1:contains(Puzzle ITC)').should('have.length', 1); + cy.get('mat-chip:visible:contains("Puzzle ITC")').should('have.length', 1); + cy.contains('An Objective for Testing'); + cy.getByTestId('objective').should('be.visible'); + }); + + it('Can switch to diagram and the filter stay the same', () => { + cy.get('h1:contains(Puzzle ITC)').should('have.length', 1); + cy.get('mat-chip:visible:contains("Puzzle ITC")').should('have.length', 1); + cy.contains('Overview'); + cy.contains('Network'); + cy.contains('An Objective for Testing'); + cy.getByTestId('quarterFilter').should('contain', 'GJ 24/25-Q1'); + cy.get('mat-chip:visible:contains("Puzzle ITC")') + .should('have.css', 'background-color') + .and('eq', 'rgb(30, 90, 150)'); + cy.get('mat-chip:visible:contains("/BBT")') + .should('have.css', 'background-color') + .and('eq', 'rgb(255, 255, 255)'); + + cy.get('mat-chip:visible:contains("/BBT")').click(); + cy.get('mat-chip:visible:contains("Puzzle ITC")').click(); + + cy.getByTestId('diagramTab').first().click(); + + cy.contains('Kein Alignment vorhanden'); + cy.get('h1:contains(Puzzle ITC)').should('have.length', 0); + cy.get('mat-chip:visible:contains("Puzzle ITC")').should('have.length', 1); + cy.getByTestId('quarterFilter').should('contain', 'GJ 24/25-Q1'); + cy.getByTestId('objective').should('have.length', 0); + cy.get('mat-chip:visible:contains("/BBT")').should('have.css', 'background-color').and('eq', 'rgb(30, 90, 150)'); + cy.get('mat-chip:visible:contains("Puzzle ITC")') + .should('have.css', 'background-color') + .and('eq', 'rgb(255, 255, 255)'); + cy.get('mat-chip:visible:contains("Puzzle ITC")').click(); + + cy.getByTestId('quarterFilter').first().focus(); + cy.focused().realPress('ArrowDown'); + + cy.getByTestId('quarterFilter').should('contain', 'GJ 23/24-Q4'); + cy.get('canvas').should('have.length', 3); + + cy.getByTestId('overviewTab').first().click(); + + cy.get('mat-chip:visible:contains("/BBT")').should('have.css', 'background-color').and('eq', 'rgb(30, 90, 150)'); + cy.get('mat-chip:visible:contains("Puzzle ITC")') + .should('have.css', 'background-color') + .and('eq', 'rgb(30, 90, 150)'); + cy.getByTestId('quarterFilter').should('contain', 'GJ 23/24-Q4'); + }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f1d013ec79..57b940cd93 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,6 +23,7 @@ "@ngx-translate/http-loader": "^8.0.0", "angular-oauth2-oidc": "^17.0.0", "bootstrap": "^5.3.2", + "cytoscape": "^3.28.1", "moment": "^2.30.1", "ngx-toastr": "^18.0.0", "rxjs": "^7.8.1", @@ -35,6 +36,7 @@ "@angular/compiler-cli": "^17.0.6", "@cypress/schematic": "^2.5.1", "@cypress/skip-test": "^2.6.1", + "@types/cytoscape": "^3.21.0", "@types/jest": "^29.5.11", "cypress": "^13.6.3", "cypress-real-events": "^1.11.0", @@ -5723,6 +5725,12 @@ "@types/node": "*" } }, + "node_modules/@types/cytoscape": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.0.tgz", + "integrity": "sha512-RN5SPiyVDpUP+LoOlxxlOYAMzkE7iuv3gA1jt3Hx2qTwArpZVPPdO+SI0hUj49OAn4QABR7JK9Gi0hibzGE0Aw==", + "dev": true + }, "node_modules/@types/eslint": { "version": "8.56.2", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", @@ -8277,6 +8285,18 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/cytoscape": { + "version": "3.28.1", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.28.1.tgz", + "integrity": "sha512-xyItz4O/4zp9/239wCcH8ZcFuuZooEeF8KHRmzjDfGdXsj3OG9MFSMA0pJE0uX3uCN/ygof6hHf4L7lst+JaDg==", + "dependencies": { + "heap": "^0.2.6", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -9741,6 +9761,11 @@ "node": ">= 0.4" } }, + "node_modules/heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==" + }, "node_modules/hosted-git-info": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.1.tgz", @@ -12858,8 +12883,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.debounce": { "version": "4.0.8", diff --git a/frontend/package.json b/frontend/package.json index 378772ff9f..8e0c3b5641 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,7 @@ "@ngx-translate/http-loader": "^8.0.0", "angular-oauth2-oidc": "^17.0.0", "bootstrap": "^5.3.2", + "cytoscape": "^3.28.1", "moment": "^2.30.1", "ngx-toastr": "^18.0.0", "rxjs": "^7.8.1", @@ -48,6 +49,7 @@ "@angular/compiler-cli": "^17.0.6", "@cypress/schematic": "^2.5.1", "@cypress/skip-test": "^2.6.1", + "@types/cytoscape": "^3.21.0", "@types/jest": "^29.5.11", "cypress": "^13.6.3", "cypress-real-events": "^1.11.0", diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 59dab8cddb..3986b7b0d6 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -72,6 +72,7 @@ import { CdkDrag, CdkDragHandle, CdkDropList } from '@angular/cdk/drag-drop'; import { TeamManagementComponent } from './shared/dialog/team-management/team-management.component'; import { KeyresultDialogComponent } from './shared/dialog/keyresult-dialog/keyresult-dialog.component'; import { CustomizationService } from './shared/services/customization.service'; +import { DiagramComponent } from './diagram/diagram.component'; function initOauthFactory(configService: ConfigService, oauthService: OAuthService) { return async () => { @@ -134,6 +135,7 @@ export const MY_FORMATS = { ActionPlanComponent, TeamManagementComponent, KeyresultDialogComponent, + DiagramComponent, ], imports: [ CommonModule, diff --git a/frontend/src/app/diagram/diagram.component.html b/frontend/src/app/diagram/diagram.component.html new file mode 100644 index 0000000000..9e34e3ff55 --- /dev/null +++ b/frontend/src/app/diagram/diagram.component.html @@ -0,0 +1,9 @@ +
+ +
+

Kein Alignment vorhanden

+ +
diff --git a/frontend/src/app/diagram/diagram.component.scss b/frontend/src/app/diagram/diagram.component.scss new file mode 100644 index 0000000000..e19ddd7398 --- /dev/null +++ b/frontend/src/app/diagram/diagram.component.scss @@ -0,0 +1,9 @@ +#cy { + margin-top: 30px; + width: calc(100vw - 60px); + height: calc(100vh - 360px); +} + +.puzzle-logo { + filter: invert(38%) sepia(31%) saturate(216%) hue-rotate(167deg) brightness(96%) contrast(85%); +} diff --git a/frontend/src/app/diagram/diagram.component.spec.ts b/frontend/src/app/diagram/diagram.component.spec.ts new file mode 100644 index 0000000000..dda3c6e3aa --- /dev/null +++ b/frontend/src/app/diagram/diagram.component.spec.ts @@ -0,0 +1,201 @@ +import { DiagramComponent } from './diagram.component'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { AlignmentLists } from '../shared/types/model/AlignmentLists'; +import { alignmentLists, alignmentListsKeyResult, keyResult, keyResultMetric } from '../shared/testData'; +import * as functions from './svgGeneration'; +import { getDraftIcon, getNotSuccessfulIcon, getOnGoingIcon, getSuccessfulIcon } from './svgGeneration'; +import { of } from 'rxjs'; +import { KeyresultService } from '../shared/services/keyresult.service'; +import { ParseUnitValuePipe } from '../shared/pipes/parse-unit-value/parse-unit-value.pipe'; + +const keyResultServiceMock = { + getFullKeyResult: jest.fn(), +}; + +describe('DiagramComponent', () => { + let component: DiagramComponent; + let fixture: ComponentFixture; + + beforeEach(() => { + TestBed.configureTestingModule({ + declarations: [DiagramComponent], + imports: [HttpClientTestingModule], + providers: [{ provide: KeyresultService, useValue: keyResultServiceMock }, ParseUnitValuePipe], + }); + fixture = TestBed.createComponent(DiagramComponent); + URL.createObjectURL = jest.fn(); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should call cleanUpDiagram when ngOnDestroy gets called', () => { + jest.spyOn(component, 'cleanUpDiagram'); + + component.ngOnDestroy(); + expect(component.cleanUpDiagram).toHaveBeenCalled(); + }); + + it('should call generateElements if alignmentData is present', () => { + jest.spyOn(component, 'generateNodes'); + + component.prepareDiagramData(alignmentLists); + expect(component.generateNodes).toHaveBeenCalled(); + expect(component.alignmentDataCache?.alignmentObjectDtoList.length).not.toEqual(0); + }); + + it('should not call generateElements if alignmentData is empty', () => { + jest.spyOn(component, 'generateNodes'); + + let alignmentLists: AlignmentLists = { + alignmentObjectDtoList: [], + alignmentConnectionDtoList: [], + }; + + component.prepareDiagramData(alignmentLists); + expect(component.generateNodes).not.toHaveBeenCalled(); + }); + + it('should call prepareDiagramData when Subject receives new data', () => { + jest.spyOn(component, 'cleanUpDiagram'); + jest.spyOn(component, 'prepareDiagramData'); + + component.ngAfterViewInit(); + component.alignmentData$.next(alignmentLists); + + expect(component.cleanUpDiagram).toHaveBeenCalled(); + expect(component.prepareDiagramData).toHaveBeenCalledWith(alignmentLists); + }); + + it('should generate correct diagramData for Objectives', () => { + jest.spyOn(component, 'generateConnections'); + jest.spyOn(component, 'generateDiagram'); + jest.spyOn(component, 'generateObjectiveSVG').mockReturnValue('Test.svg'); + + let edge = { + data: { + source: 'Ob1', + target: 'Ob2', + }, + }; + let element1 = { + data: { + id: 'Ob1', + }, + style: { + 'background-image': 'Test.svg', + }, + }; + let element2 = { + data: { + id: 'Ob2', + }, + style: { + 'background-image': 'Test.svg', + }, + }; + + let diagramElements: any[] = [element1, element2]; + let edges: any[] = [edge]; + + component.generateNodes(alignmentLists); + + expect(component.generateConnections).toHaveBeenCalled(); + expect(component.diagramData).toEqual(diagramElements.concat(edges)); + }); + + it('should generate correct diagramData for KeyResult Metric', () => { + jest.spyOn(component, 'generateConnections'); + jest.spyOn(component, 'generateDiagram'); + jest.spyOn(component, 'generateObjectiveSVG').mockReturnValue('TestObjective.svg'); + jest.spyOn(component, 'generateKeyResultSVG').mockReturnValue('TestKeyResult.svg'); + jest.spyOn(keyResultServiceMock, 'getFullKeyResult').mockReturnValue(of(keyResultMetric)); + + let diagramData: any[] = getReturnedAlignmentDataKeyResult(); + + component.generateNodes(alignmentListsKeyResult); + + expect(component.generateConnections).toHaveBeenCalled(); + expect(component.diagramData).toEqual(diagramData); + }); + + it('should generate correct diagramData for KeyResult Ordinal', () => { + jest.spyOn(component, 'generateConnections'); + jest.spyOn(component, 'generateDiagram'); + jest.spyOn(component, 'generateObjectiveSVG').mockReturnValue('TestObjective.svg'); + jest.spyOn(component, 'generateKeyResultSVG').mockReturnValue('TestKeyResult.svg'); + jest.spyOn(keyResultServiceMock, 'getFullKeyResult').mockReturnValue(of(keyResult)); + + let diagramData: any[] = getReturnedAlignmentDataKeyResult(); + + component.generateNodes(alignmentListsKeyResult); + + expect(component.generateConnections).toHaveBeenCalled(); + expect(component.diagramData).toEqual(diagramData); + }); + + it('should generate correct SVGs for Objective', () => { + jest.spyOn(functions, 'generateObjectiveSVG'); + + component.generateObjectiveSVG('Title 1', 'Team name 1', 'ONGOING'); + expect(functions.generateObjectiveSVG).toHaveBeenCalledWith('Title 1', 'Team name 1', getOnGoingIcon); + + component.generateObjectiveSVG('Title 2', 'Team name 2', 'SUCCESSFUL'); + expect(functions.generateObjectiveSVG).toHaveBeenCalledWith('Title 2', 'Team name 2', getSuccessfulIcon); + + component.generateObjectiveSVG('Title 3', 'Team name 3', 'NOTSUCCESSFUL'); + expect(functions.generateObjectiveSVG).toHaveBeenCalledWith('Title 3', 'Team name 3', getNotSuccessfulIcon); + + component.generateObjectiveSVG('Title 4', 'Team name 4', 'DRAFT'); + expect(functions.generateObjectiveSVG).toHaveBeenCalledWith('Title 4', 'Team name 4', getDraftIcon); + }); + + it('should generate correct SVGs for KeyResult', () => { + jest.spyOn(functions, 'generateObjectiveSVG'); + + component.generateObjectiveSVG('Title 1', 'Team name 1', 'ONGOING'); + expect(functions.generateObjectiveSVG).toHaveBeenCalledWith('Title 1', 'Team name 1', getOnGoingIcon); + + component.generateObjectiveSVG('Title 2', 'Team name 2', 'SUCCESSFUL'); + expect(functions.generateObjectiveSVG).toHaveBeenCalledWith('Title 2', 'Team name 2', getSuccessfulIcon); + + component.generateObjectiveSVG('Title 3', 'Team name 3', 'NOTSUCCESSFUL'); + expect(functions.generateObjectiveSVG).toHaveBeenCalledWith('Title 3', 'Team name 3', getNotSuccessfulIcon); + + component.generateObjectiveSVG('Title 4', 'Team name 4', 'DRAFT'); + expect(functions.generateObjectiveSVG).toHaveBeenCalledWith('Title 4', 'Team name 4', getDraftIcon); + }); +}); + +function getReturnedAlignmentDataKeyResult(): any[] { + let edge = { + data: { + source: 'Ob3', + target: 'KR102', + }, + }; + let element1 = { + data: { + id: 'Ob3', + }, + style: { + 'background-image': 'TestObjective.svg', + }, + }; + let element2 = { + data: { + id: 'KR102', + }, + style: { + 'background-image': 'TestKeyResult.svg', + }, + }; + + let diagramElements: any[] = [element1, element2]; + let edges: any[] = [edge]; + + return diagramElements.concat(edges); +} diff --git a/frontend/src/app/diagram/diagram.component.ts b/frontend/src/app/diagram/diagram.component.ts new file mode 100644 index 0000000000..6d35f71bad --- /dev/null +++ b/frontend/src/app/diagram/diagram.component.ts @@ -0,0 +1,272 @@ +import { AfterViewInit, Component, Input, OnDestroy } from '@angular/core'; +import { map, Observable, of, Subject, zip } from 'rxjs'; +import { AlignmentLists } from '../shared/types/model/AlignmentLists'; +import cytoscape from 'cytoscape'; +import { + generateKeyResultSVG, + generateNeutralKeyResultSVG, + generateObjectiveSVG, + getDraftIcon, + getNotSuccessfulIcon, + getOnGoingIcon, + getSuccessfulIcon, +} from './svgGeneration'; +import { KeyresultService } from '../shared/services/keyresult.service'; +import { KeyResult } from '../shared/types/model/KeyResult'; +import { KeyResultMetric } from '../shared/types/model/KeyResultMetric'; +import { calculateCurrentPercentage } from '../shared/common'; +import { KeyResultOrdinal } from '../shared/types/model/KeyResultOrdinal'; +import { Router } from '@angular/router'; +import { AlignmentObject } from '../shared/types/model/AlignmentObject'; +import { AlignmentConnection } from '../shared/types/model/AlignmentConnection'; +import { Zone } from '../shared/types/enums/Zone'; +import { ObjectiveState } from '../shared/types/enums/ObjectiveState'; +import { RefreshDataService } from '../shared/services/refresh-data.service'; + +@Component({ + selector: 'app-diagram', + templateUrl: './diagram.component.html', + styleUrl: './diagram.component.scss', +}) +export class DiagramComponent implements AfterViewInit, OnDestroy { + @Input() + public alignmentData$: Subject = new Subject(); + cy!: cytoscape.Core; + diagramData: any[] = []; + alignmentDataCache: AlignmentLists | null = null; + reloadRequired: boolean | null | undefined = false; + + constructor( + private keyResultService: KeyresultService, + private refreshDataService: RefreshDataService, + private router: Router, + ) {} + + ngAfterViewInit(): void { + this.refreshDataService.reloadAlignmentSubject.subscribe((value: boolean | null | undefined): void => { + this.reloadRequired = value; + }); + + this.alignmentData$.subscribe((alignmentData: AlignmentLists): void => { + if (this.reloadRequired == true || JSON.stringify(this.alignmentDataCache) !== JSON.stringify(alignmentData)) { + this.reloadRequired = undefined; + this.alignmentDataCache = alignmentData; + this.diagramData = []; + this.cleanUpDiagram(); + this.prepareDiagramData(alignmentData); + } + }); + } + + ngOnDestroy(): void { + this.cleanUpDiagram(); + this.alignmentData$.unsubscribe(); + this.refreshDataService.reloadAlignmentSubject.unsubscribe(); + } + + generateDiagram(): void { + this.cy = cytoscape({ + container: document.getElementById('cy'), + elements: this.diagramData, + + zoom: 1, + zoomingEnabled: true, + userZoomingEnabled: true, + wheelSensitivity: 0.3, + + style: [ + { + selector: '[id^="Ob"]', + style: { + height: 160, + width: 160, + }, + }, + { + selector: '[id^="KR"]', + style: { + height: 120, + width: 120, + }, + }, + { + selector: 'edge', + style: { + width: 1, + 'line-color': '#000000', + 'target-arrow-color': '#000000', + 'target-arrow-shape': 'triangle', + 'curve-style': 'bezier', + }, + }, + ], + + layout: { + name: 'cose', + }, + }); + + this.cy.on('tap', 'node', (evt: cytoscape.EventObject) => { + let node = evt.target; + node.style({ + 'border-width': 0, + }); + + let type: string = node.id().charAt(0) == 'O' ? 'objective' : 'keyresult'; + this.router.navigate([type.toLowerCase(), node.id().substring(2)]); + }); + + this.cy.on('mouseover', 'node', (evt: cytoscape.EventObject) => { + let node = evt.target; + node.style({ + 'border-color': '#1E5A96', + 'border-width': 2, + }); + }); + + this.cy.on('mouseout', 'node', (evt: cytoscape.EventObject) => { + evt.target.style({ + 'border-width': 0, + }); + }); + } + + prepareDiagramData(alignmentData: AlignmentLists): void { + if (alignmentData.alignmentObjectDtoList.length != 0) { + this.generateNodes(alignmentData); + } + } + + generateNodes(alignmentData: AlignmentLists): void { + let observableArray: any[] = []; + let diagramElements: any[] = []; + alignmentData.alignmentObjectDtoList.forEach((alignmentObject: AlignmentObject) => { + if (alignmentObject.objectType == 'objective') { + let node = { + data: { + id: 'Ob' + alignmentObject.objectId, + }, + style: { + 'background-image': this.generateObjectiveSVG( + alignmentObject.objectTitle, + alignmentObject.objectTeamName, + alignmentObject.objectState!, + ), + }, + }; + diagramElements.push(node); + observableArray.push(of(node)); + } else { + let observable: Observable = this.keyResultService.getFullKeyResult(alignmentObject.objectId).pipe( + map((keyResult: KeyResult) => { + let keyResultState: string | undefined; + + if (keyResult.keyResultType == 'metric') { + let metricKeyResult: KeyResultMetric = keyResult as KeyResultMetric; + let percentage: number = calculateCurrentPercentage(metricKeyResult); + keyResultState = this.generateMetricKeyResultState(percentage); + } else { + let ordinalKeyResult: KeyResultOrdinal = keyResult as KeyResultOrdinal; + keyResultState = ordinalKeyResult.lastCheckIn?.value.toString(); + } + let element = this.generateKeyResultElement(alignmentObject, keyResultState); + diagramElements.push(element); + }), + ); + observableArray.push(observable); + } + }); + + zip(observableArray).subscribe(async () => { + await this.generateConnections(alignmentData, diagramElements); + }); + } + + generateMetricKeyResultState(percentage: number): string | undefined { + let keyResultState: string | undefined; + if (percentage < 30) { + keyResultState = 'FAIL'; + } else if (percentage < 70) { + keyResultState = 'COMMIT'; + } else if (percentage < 100) { + keyResultState = 'TARGET'; + } else if (percentage >= 100) { + keyResultState = 'STRETCH'; + } else { + keyResultState = undefined; + } + return keyResultState; + } + + generateKeyResultElement(alignmentObject: AlignmentObject, keyResultState: string | undefined) { + return { + data: { + id: 'KR' + alignmentObject.objectId, + }, + style: { + 'background-image': this.generateKeyResultSVG( + alignmentObject.objectTitle, + alignmentObject.objectTeamName, + keyResultState, + ), + }, + }; + } + + async generateConnections(alignmentData: AlignmentLists, diagramElements: any[]) { + let edges: any[] = []; + alignmentData.alignmentConnectionDtoList.forEach((alignmentConnection: AlignmentConnection) => { + let edge = { + data: { + source: 'Ob' + alignmentConnection.alignedObjectiveId, + target: + alignmentConnection.targetKeyResultId == null + ? 'Ob' + alignmentConnection.targetObjectiveId + : 'KR' + alignmentConnection.targetKeyResultId, + }, + }; + edges.push(edge); + }); + this.diagramData = diagramElements.concat(edges); + + // Sometimes the DOM Element #cy is not ready when cytoscape tries to generate the diagram + // To avoid this, we use here a setTimeout() + setTimeout(() => this.generateDiagram(), 0); + } + + generateObjectiveSVG(title: string, teamName: string, state: string): string { + switch (state) { + case ObjectiveState.ONGOING: + return generateObjectiveSVG(title, teamName, getOnGoingIcon); + case ObjectiveState.SUCCESSFUL: + return generateObjectiveSVG(title, teamName, getSuccessfulIcon); + case ObjectiveState.NOTSUCCESSFUL: + return generateObjectiveSVG(title, teamName, getNotSuccessfulIcon); + default: + return generateObjectiveSVG(title, teamName, getDraftIcon); + } + } + + generateKeyResultSVG(title: string, teamName: string, state: string | undefined): string { + switch (state) { + case Zone.FAIL: + return generateKeyResultSVG(title, teamName, '#BA3838', 'white'); + case Zone.COMMIT: + return generateKeyResultSVG(title, teamName, '#FFD600', 'black'); + case Zone.TARGET: + return generateKeyResultSVG(title, teamName, '#1E8A29', 'black'); + case Zone.STRETCH: + return generateKeyResultSVG(title, teamName, '#1E5A96', 'white'); + default: + return generateNeutralKeyResultSVG(title, teamName); + } + } + + cleanUpDiagram() { + if (this.cy) { + this.cy.edges().remove(); + this.cy.nodes().remove(); + this.cy.removeAllListeners(); + } + } +} diff --git a/frontend/src/app/diagram/svgGeneration.ts b/frontend/src/app/diagram/svgGeneration.ts new file mode 100644 index 0000000000..e08baf0ed2 --- /dev/null +++ b/frontend/src/app/diagram/svgGeneration.ts @@ -0,0 +1,204 @@ +export function generateObjectiveSVG(title: string, teamName: string, iconFunction: any) { + let svg = ` + + + + + + + + + + + + + ${iconFunction} + + + +
+

+ ${title} +

+ +
+
+ + +
+

${teamName}

+
+
+
+`; + + let blob: Blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + return URL.createObjectURL(blob); +} + +export function generateKeyResultSVG(title: string, teamName: string, backgroundColor: any, fontColor: any) { + let svg = ` + + + + + + + + + + + + +
+

+ ${title} +

+ +
+
+ + +
+

${teamName}

+
+
+
+ `; + + let blob: Blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + return URL.createObjectURL(blob); +} + +export function generateNeutralKeyResultSVG(title: string, teamName: string) { + let svg = ` + + + + + + + + + + + + +
+

+ ${title} +

+ +
+
+ + +
+

${teamName}

+
+
+
+ `; + + let blob: Blob = new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }); + return URL.createObjectURL(blob); +} + +export function getDraftIcon() { + return ` + + + + + + + + `; +} + +export function getOnGoingIcon() { + return ` + + + + + + + + `; +} + +export function getSuccessfulIcon() { + return ` + + + + + + + + `; +} + +export function getNotSuccessfulIcon() { + return ` + + + + + + + + `; +} diff --git a/frontend/src/app/keyresult-detail/keyresult-detail.component.ts b/frontend/src/app/keyresult-detail/keyresult-detail.component.ts index 5326c6d430..79d03c2f54 100644 --- a/frontend/src/app/keyresult-detail/keyresult-detail.component.ts +++ b/frontend/src/app/keyresult-detail/keyresult-detail.component.ts @@ -118,6 +118,8 @@ export class KeyresultDetailComponent implements OnInit { this.refreshDataService.markDataRefresh(); } else if (result?.closeState === CloseState.DELETED) { this.router.navigate(['']).then(() => this.refreshDataService.markDataRefresh()); + } else if (result == '') { + return; } else { this.loadKeyResult(this.keyResult$.getValue().id); } @@ -180,9 +182,11 @@ export class KeyresultDetailComponent implements OnInit { keyResult: this.keyResult$.getValue(), }, }); - dialogRef.afterClosed().subscribe(() => { - this.loadKeyResult(this.keyResult$.getValue().id); - this.refreshDataService.markDataRefresh(); + dialogRef.afterClosed().subscribe((result) => { + if (result != '' && result != undefined) { + this.loadKeyResult(this.keyResult$.getValue().id); + this.refreshDataService.markDataRefresh(true); + } }); } diff --git a/frontend/src/app/objective-detail/objective-detail.component.ts b/frontend/src/app/objective-detail/objective-detail.component.ts index 40253b3588..747bd70ea0 100644 --- a/frontend/src/app/objective-detail/objective-detail.component.ts +++ b/frontend/src/app/objective-detail/objective-detail.component.ts @@ -65,6 +65,7 @@ export class ObjectiveDetailComponent { .subscribe((result) => { if (result?.openNew) { this.openAddKeyResultDialog(); + return; } this.refreshDataService.markDataRefresh(); }); @@ -84,8 +85,10 @@ export class ObjectiveDetailComponent { }) .afterClosed() .subscribe((result) => { - if (result.delete) { + if (result && result.delete) { this.router.navigate(['']); + } else if (result == '' || result == undefined) { + return; } else { this.loadObjective(this.objective$.value.id); } diff --git a/frontend/src/app/overview/overview.component.html b/frontend/src/app/overview/overview.component.html index a8e1e40d7f..5c46bf9c35 100644 --- a/frontend/src/app/overview/overview.component.html +++ b/frontend/src/app/overview/overview.component.html @@ -3,14 +3,50 @@
- - -
-

Kein Team ausgewählt

- +
+
+
+ Overview +
+
+ Network +
+
+
+
+ +
+
+ + +
+

Kein Team ausgewählt

+ +
+
+ +
+ +
diff --git a/frontend/src/app/overview/overview.component.scss b/frontend/src/app/overview/overview.component.scss index 0f59a39431..f327dbff74 100644 --- a/frontend/src/app/overview/overview.component.scss +++ b/frontend/src/app/overview/overview.component.scss @@ -13,3 +13,50 @@ .puzzle-logo { filter: invert(38%) sepia(31%) saturate(216%) hue-rotate(167deg) brightness(96%) contrast(85%); } + +.active { + border-left: #909090 1px solid; + border-top: #909090 1px solid; + border-right: #909090 1px solid; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + background: white; + color: black; +} + +.non-active { + color: #9c9c9c; + border-bottom: #909090 1px solid; +} + +.tab-title { + display: flex; + justify-content: center; + align-items: center; + height: 39px; + margin-bottom: 16px; +} + +.buffer { + border-bottom: #909090 1px solid; +} + +.tabfocus { + outline: none; + &:focus-visible { + border-radius: 5px; + border: 2px solid #1a4e83; + } +} + +.overview { + width: 87px; +} + +.diagram { + width: 100px; +} + +.hidden { + display: none; +} diff --git a/frontend/src/app/overview/overview.component.spec.ts b/frontend/src/app/overview/overview.component.spec.ts index 4599cb1e60..6f9e20117e 100644 --- a/frontend/src/app/overview/overview.component.spec.ts +++ b/frontend/src/app/overview/overview.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { OverviewComponent } from './overview.component'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { overViewEntity1 } from '../shared/testData'; +import { alignmentLists, overViewEntity1 } from '../shared/testData'; import { BehaviorSubject, of, Subject } from 'rxjs'; import { OverviewService } from '../shared/services/overview.service'; import { AppRoutingModule } from '../app-routing.module'; @@ -16,11 +16,16 @@ import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { AlignmentService } from '../shared/services/alignment.service'; const overviewService = { getOverview: jest.fn(), }; +const alignmentService = { + getAlignmentByFilter: jest.fn(), +}; + const authGuardMock = () => { return Promise.resolve(true); }; @@ -53,6 +58,10 @@ describe('OverviewComponent', () => { provide: OverviewService, useValue: overviewService, }, + { + provide: AlignmentService, + useValue: alignmentService, + }, { provide: authGuard, useValue: authGuardMock, @@ -132,6 +141,23 @@ describe('OverviewComponent', () => { expect(component.loadOverview).toHaveBeenLastCalledWith(); }); + it('should call overviewService on overview', async () => { + jest.spyOn(overviewService, 'getOverview'); + component.isOverview = true; + + component.loadOverview(3, [5, 6], ''); + expect(overviewService.getOverview).toHaveBeenCalled(); + }); + + it('should call alignmentService on diagram', async () => { + jest.spyOn(alignmentService, 'getAlignmentByFilter').mockReturnValue(of(alignmentLists)); + component.isOverview = false; + fixture.detectChanges(); + + component.loadOverview(3, [5, 6], ''); + expect(alignmentService.getAlignmentByFilter).toHaveBeenCalled(); + }); + function markFiltersAsReady() { refreshDataServiceMock.quarterFilterReady.next(null); refreshDataServiceMock.teamFilterReady.next(null); diff --git a/frontend/src/app/overview/overview.component.ts b/frontend/src/app/overview/overview.component.ts index 674346df80..43116692ce 100644 --- a/frontend/src/app/overview/overview.component.ts +++ b/frontend/src/app/overview/overview.component.ts @@ -5,6 +5,8 @@ import { OverviewService } from '../shared/services/overview.service'; import { ActivatedRoute } from '@angular/router'; import { RefreshDataService } from '../shared/services/refresh-data.service'; import { getQueryString, getValueFromQuery, isMobileDevice, trackByFn } from '../shared/common'; +import { AlignmentService } from '../shared/services/alignment.service'; +import { AlignmentLists } from '../shared/types/model/AlignmentLists'; @Component({ selector: 'app-overview', @@ -14,13 +16,16 @@ import { getQueryString, getValueFromQuery, isMobileDevice, trackByFn } from '.. }) export class OverviewComponent implements OnInit, OnDestroy { overviewEntities$: Subject = new Subject(); + alignmentLists$: Subject = new Subject(); protected readonly trackByFn = trackByFn; private destroyed$: ReplaySubject = new ReplaySubject(1); hasAdminAccess: ReplaySubject = new ReplaySubject(1); overviewPadding: Subject = new Subject(); + isOverview: boolean = true; constructor( private overviewService: OverviewService, + private alignmentService: AlignmentService, private refreshDataService: RefreshDataService, private activatedRoute: ActivatedRoute, private changeDetector: ChangeDetectorRef, @@ -62,7 +67,15 @@ export class OverviewComponent implements OnInit, OnDestroy { this.loadOverview(quarterId, teamIds, objectiveQueryString); } - loadOverview(quarterId?: number, teamIds?: number[], objectiveQuery?: string) { + loadOverview(quarterId?: number, teamIds?: number[], objectiveQuery?: string): void { + if (this.isOverview) { + this.loadOverviewData(quarterId, teamIds, objectiveQuery); + } else { + this.loadAlignmentData(quarterId, teamIds, objectiveQuery); + } + } + + loadOverviewData(quarterId?: number, teamIds?: number[], objectiveQuery?: string): void { this.overviewService .getOverview(quarterId, teamIds, objectiveQuery) .pipe( @@ -77,8 +90,32 @@ export class OverviewComponent implements OnInit, OnDestroy { }); } + loadAlignmentData(quarterId?: number, teamIds?: number[], objectiveQuery?: string): void { + this.alignmentService + .getAlignmentByFilter(quarterId, teamIds, objectiveQuery) + .pipe( + catchError(() => { + this.loadOverview(); + return EMPTY; + }), + ) + .subscribe((alignmentLists: AlignmentLists) => { + this.alignmentLists$.next(alignmentLists); + }); + } + ngOnDestroy(): void { this.destroyed$.next(true); this.destroyed$.complete(); } + + switchPage(input: string) { + if (input == 'diagram' && this.isOverview) { + this.isOverview = false; + this.loadOverviewWithParams(); + } else if (input == 'overview' && !this.isOverview) { + this.isOverview = true; + this.loadOverviewWithParams(); + } + } } diff --git a/frontend/src/app/shared/dialog/check-in-history-dialog/check-in-history-dialog.component.ts b/frontend/src/app/shared/dialog/check-in-history-dialog/check-in-history-dialog.component.ts index 8add8dad5d..a8880f8908 100644 --- a/frontend/src/app/shared/dialog/check-in-history-dialog/check-in-history-dialog.component.ts +++ b/frontend/src/app/shared/dialog/check-in-history-dialog/check-in-history-dialog.component.ts @@ -57,9 +57,11 @@ export class CheckInHistoryDialogComponent implements OnInit { maxHeight: dialogConfig.maxHeight, maxWidth: dialogConfig.maxWidth, }); - dialogRef.afterClosed().subscribe(() => { + dialogRef.afterClosed().subscribe((result) => { this.loadCheckInHistory(); - this.refreshDataService.markDataRefresh(); + if (result != '' && result != undefined) { + this.refreshDataService.markDataRefresh(true); + } }); } diff --git a/frontend/src/app/shared/dialog/checkin/check-in-form/check-in-form.component.ts b/frontend/src/app/shared/dialog/checkin/check-in-form/check-in-form.component.ts index c6955bd251..c164ab74eb 100644 --- a/frontend/src/app/shared/dialog/checkin/check-in-form/check-in-form.component.ts +++ b/frontend/src/app/shared/dialog/checkin/check-in-form/check-in-form.component.ts @@ -98,7 +98,9 @@ export class CheckInFormComponent implements OnInit { this.checkInService.saveCheckIn(checkIn).subscribe(() => { this.actionService.updateActions(this.dialogForm.value.actionList!).subscribe(() => { - this.dialogRef.close(); + this.dialogRef.close({ + checkIn: checkIn, + }); }); }); } diff --git a/frontend/src/app/shared/dialog/objective-dialog/objective-form.component.spec.ts b/frontend/src/app/shared/dialog/objective-dialog/objective-form.component.spec.ts index c117fcb782..0f98cd7a76 100644 --- a/frontend/src/app/shared/dialog/objective-dialog/objective-form.component.spec.ts +++ b/frontend/src/app/shared/dialog/objective-dialog/objective-form.component.spec.ts @@ -11,11 +11,11 @@ import { ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ObjectiveService } from '../../services/objective.service'; import { - alignmentObject1, - alignmentObject2, - alignmentObject3, alignmentPossibility1, alignmentPossibility2, + alignmentPossibilityObject1, + alignmentPossibilityObject2, + alignmentPossibilityObject3, objective, objectiveWithAlignment, quarter, @@ -230,7 +230,7 @@ describe('ObjectiveDialogComponent', () => { description: 'Test description', quarter: 0, team: 0, - alignment: alignmentObject2, + alignment: alignmentPossibilityObject2, createKeyResults: false, }); @@ -258,7 +258,7 @@ describe('ObjectiveDialogComponent', () => { description: 'Test description', quarter: 0, team: 0, - alignment: alignmentObject3, + alignment: alignmentPossibilityObject3, createKeyResults: false, }); @@ -316,7 +316,7 @@ describe('ObjectiveDialogComponent', () => { description: 'Test description', quarter: 1, team: 1, - alignment: alignmentObject3, + alignment: alignmentPossibilityObject3, createKeyResults: false, }); @@ -376,7 +376,7 @@ describe('ObjectiveDialogComponent', () => { expect(rawFormValue.description).toBe(objectiveWithAlignment.description); expect(rawFormValue.team).toBe(objectiveWithAlignment.teamId); expect(rawFormValue.quarter).toBe(objectiveWithAlignment.quarterId); - expect(rawFormValue.alignment).toBe(alignmentObject2); + expect(rawFormValue.alignment).toBe(alignmentPossibilityObject2); }); it('should return correct value if allowed to save to backlog', async () => { @@ -531,7 +531,7 @@ describe('ObjectiveDialogComponent', () => { expect(component.alignmentPossibilities).toStrictEqual([alignmentPossibility1, alignmentPossibility2]); expect(component.filteredAlignmentOptions$.getValue()).toEqual([alignmentPossibility1, alignmentPossibility2]); - expect(component.objectiveForm.getRawValue().alignment).toEqual(alignmentObject2); + expect(component.objectiveForm.getRawValue().alignment).toEqual(alignmentPossibilityObject2); }); it('should load existing keyResult alignment to objectiveForm', async () => { @@ -541,7 +541,7 @@ describe('ObjectiveDialogComponent', () => { expect(component.alignmentPossibilities).toStrictEqual([alignmentPossibility1, alignmentPossibility2]); expect(component.filteredAlignmentOptions$.getValue()).toEqual([alignmentPossibility1, alignmentPossibility2]); - expect(component.objectiveForm.getRawValue().alignment).toEqual(alignmentObject3); + expect(component.objectiveForm.getRawValue().alignment).toEqual(alignmentPossibilityObject3); }); it('should filter correct alignment possibilities', async () => { @@ -552,7 +552,7 @@ describe('ObjectiveDialogComponent', () => { let modifiedAlignmentPossibility: AlignmentPossibility = { teamId: 1, teamName: 'Puzzle ITC', - alignmentObjects: [alignmentObject3], + alignmentObjects: [alignmentPossibilityObject3], }; expect(component.filteredAlignmentOptions$.getValue()).toEqual([modifiedAlignmentPossibility]); @@ -563,7 +563,7 @@ describe('ObjectiveDialogComponent', () => { modifiedAlignmentPossibility = { teamId: 1, teamName: 'Puzzle ITC', - alignmentObjects: [alignmentObject2, alignmentObject3], + alignmentObjects: [alignmentPossibilityObject2, alignmentPossibilityObject3], }; expect(component.filteredAlignmentOptions$.getValue()).toEqual([modifiedAlignmentPossibility]); @@ -575,12 +575,12 @@ describe('ObjectiveDialogComponent', () => { { teamId: 1, teamName: 'Puzzle ITC', - alignmentObjects: [alignmentObject3], + alignmentObjects: [alignmentPossibilityObject3], }, { teamId: 2, teamName: 'We are cube', - alignmentObjects: [alignmentObject1], + alignmentObjects: [alignmentPossibilityObject1], }, ]; expect(component.filteredAlignmentOptions$.getValue()).toEqual(modifiedAlignmentPossibilities); diff --git a/frontend/src/app/shared/services/alignment.service.spec.ts b/frontend/src/app/shared/services/alignment.service.spec.ts new file mode 100644 index 0000000000..c3d7f558bf --- /dev/null +++ b/frontend/src/app/shared/services/alignment.service.spec.ts @@ -0,0 +1,65 @@ +import { TestBed } from '@angular/core/testing'; + +import { AlignmentService } from './alignment.service'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { HttpClient } from '@angular/common/http'; +import { of } from 'rxjs'; +import { alignmentLists } from '../testData'; + +const httpClient = { + get: jest.fn(), +}; + +describe('AlignmentService', () => { + let service: AlignmentService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [{ provide: HttpClient, useValue: httpClient }], + }).compileComponents(); + service = TestBed.inject(AlignmentService); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should set params of filter correctly without objectiveQuery', (done) => { + jest.spyOn(httpClient, 'get').mockReturnValue(of(alignmentLists)); + service.getAlignmentByFilter(2, [4, 5], '').subscribe((alignmentLists) => { + alignmentLists.alignmentObjectDtoList.forEach((object) => { + expect(object.objectType).toEqual('objective'); + expect(object.objectTeamName).toEqual('Example Team'); + }); + alignmentLists.alignmentConnectionDtoList.forEach((connection) => { + expect(connection.targetKeyResultId).toEqual(null); + expect(connection.alignedObjectiveId).toEqual(1); + expect(connection.targetObjectiveId).toEqual(2); + }); + done(); + }); + expect(httpClient.get).toHaveBeenCalledWith('/api/v2/alignments/alignmentLists', { + params: { quarterFilter: 2, teamFilter: [4, 5] }, + }); + }); + + it('should set params of filter correctly with objectiveQuery', (done) => { + jest.spyOn(httpClient, 'get').mockReturnValue(of(alignmentLists)); + service.getAlignmentByFilter(2, [4, 5], 'objective').subscribe((alignmentLists) => { + alignmentLists.alignmentObjectDtoList.forEach((object) => { + expect(object.objectType).toEqual('objective'); + expect(object.objectTeamName).toEqual('Example Team'); + }); + alignmentLists.alignmentConnectionDtoList.forEach((connection) => { + expect(connection.targetKeyResultId).toEqual(null); + expect(connection.alignedObjectiveId).toEqual(1); + expect(connection.targetObjectiveId).toEqual(2); + }); + done(); + }); + expect(httpClient.get).toHaveBeenCalledWith('/api/v2/alignments/alignmentLists', { + params: { objectiveQuery: 'objective', quarterFilter: 2, teamFilter: [4, 5] }, + }); + }); +}); diff --git a/frontend/src/app/shared/services/alignment.service.ts b/frontend/src/app/shared/services/alignment.service.ts new file mode 100644 index 0000000000..36964b8457 --- /dev/null +++ b/frontend/src/app/shared/services/alignment.service.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { AlignmentLists } from '../types/model/AlignmentLists'; +import { optionalValue } from '../common'; + +@Injectable({ + providedIn: 'root', +}) +export class AlignmentService { + constructor(private httpClient: HttpClient) {} + + getAlignmentByFilter(quarterId?: number, teamIds?: number[], objectiveQuery?: string): Observable { + const params = optionalValue({ + teamFilter: teamIds, + quarterFilter: quarterId, + objectiveQuery: objectiveQuery, + }); + + return this.httpClient.get(`/api/v2/alignments/alignmentLists`, { params: params }); + } +} diff --git a/frontend/src/app/shared/services/refresh-data.service.ts b/frontend/src/app/shared/services/refresh-data.service.ts index d9f994e4c9..637eb78ba8 100644 --- a/frontend/src/app/shared/services/refresh-data.service.ts +++ b/frontend/src/app/shared/services/refresh-data.service.ts @@ -7,13 +7,15 @@ import { DEFAULT_HEADER_HEIGHT_PX } from '../constantLibary'; }) export class RefreshDataService { public reloadOverviewSubject: Subject = new Subject(); + public reloadAlignmentSubject: Subject = new Subject(); public quarterFilterReady: Subject = new Subject(); public teamFilterReady: Subject = new Subject(); public okrBannerHeightSubject: BehaviorSubject = new BehaviorSubject(DEFAULT_HEADER_HEIGHT_PX); - markDataRefresh() { + markDataRefresh(reload?: boolean | null) { this.reloadOverviewSubject.next(); + this.reloadAlignmentSubject.next(reload); } } diff --git a/frontend/src/app/shared/testData.ts b/frontend/src/app/shared/testData.ts index 65dac779cb..52e16248d0 100644 --- a/frontend/src/app/shared/testData.ts +++ b/frontend/src/app/shared/testData.ts @@ -19,6 +19,9 @@ import { Action } from './types/model/Action'; import { OrganisationState } from './types/enums/OrganisationState'; import { Organisation } from './types/model/Organisation'; import { Dashboard } from './types/model/Dashboard'; +import { AlignmentObject } from './types/model/AlignmentObject'; +import { AlignmentConnection } from './types/model/AlignmentConnection'; +import { AlignmentLists } from './types/model/AlignmentLists'; import { AlignmentPossibilityObject } from './types/model/AlignmentPossibilityObject'; import { AlignmentPossibility } from './types/model/AlignmentPossibility'; @@ -633,19 +636,19 @@ export const keyResultActions: KeyResultMetric = { writeable: true, }; -export const alignmentObject1: AlignmentPossibilityObject = { +export const alignmentPossibilityObject1: AlignmentPossibilityObject = { objectId: 1, objectTitle: 'We want to increase the income puzzle buy', objectType: 'objective', }; -export const alignmentObject2: AlignmentPossibilityObject = { +export const alignmentPossibilityObject2: AlignmentPossibilityObject = { objectId: 2, objectTitle: 'Our office has more plants for', objectType: 'objective', }; -export const alignmentObject3: AlignmentPossibilityObject = { +export const alignmentPossibilityObject3: AlignmentPossibilityObject = { objectId: 1, objectTitle: 'We buy 3 palms puzzle', objectType: 'keyResult', @@ -654,11 +657,65 @@ export const alignmentObject3: AlignmentPossibilityObject = { export const alignmentPossibility1: AlignmentPossibility = { teamId: 1, teamName: 'Puzzle ITC', - alignmentObjects: [alignmentObject2, alignmentObject3], + alignmentObjects: [alignmentPossibilityObject2, alignmentPossibilityObject3], }; export const alignmentPossibility2: AlignmentPossibility = { teamId: 2, teamName: 'We are cube', - alignmentObjects: [alignmentObject1], + alignmentObjects: [alignmentPossibilityObject1], +}; + +export const alignmentObject1: AlignmentObject = { + objectId: 1, + objectTitle: 'Title 1', + objectTeamName: 'Example Team', + objectState: 'ONGOING', + objectType: 'objective', +}; + +export const alignmentObject2: AlignmentObject = { + objectId: 2, + objectTitle: 'Title 2', + objectTeamName: 'Example Team', + objectState: 'DRAFT', + objectType: 'objective', +}; + +export const alignmentObject3: AlignmentObject = { + objectId: 3, + objectTitle: 'Title 3', + objectTeamName: 'Example Team', + objectState: 'DRAFT', + objectType: 'objective', +}; + +export const alignmentObjectKeyResult: AlignmentObject = { + objectId: 102, + objectTitle: 'Title 1', + objectTeamName: 'Example Team', + objectState: null, + objectType: 'keyResult', +}; + +export const alignmentConnection: AlignmentConnection = { + alignedObjectiveId: 1, + targetObjectiveId: 2, + targetKeyResultId: null, +}; + +export const alignmentConnectionKeyResult: AlignmentConnection = { + alignedObjectiveId: 3, + targetObjectiveId: null, + targetKeyResultId: 102, +}; + +export const alignmentLists: AlignmentLists = { + alignmentObjectDtoList: [alignmentObject1, alignmentObject2], + alignmentConnectionDtoList: [alignmentConnection], +}; + +export const alignmentListsKeyResult: AlignmentLists = { + alignmentObjectDtoList: [alignmentObject3, alignmentObjectKeyResult], + alignmentConnectionDtoList: [alignmentConnectionKeyResult], }; diff --git a/frontend/src/app/shared/types/enums/ObjectiveState.ts b/frontend/src/app/shared/types/enums/ObjectiveState.ts new file mode 100644 index 0000000000..342fa5a49d --- /dev/null +++ b/frontend/src/app/shared/types/enums/ObjectiveState.ts @@ -0,0 +1,6 @@ +export enum ObjectiveState { + DRAFT = 'DRAFT', + ONGOING = 'ONGOING', + SUCCESSFUL = 'SUCCESSFUL', + NOTSUCCESSFUL = 'NOTSUCCESSFUL', +} diff --git a/frontend/src/app/shared/types/model/AlignmentConnection.ts b/frontend/src/app/shared/types/model/AlignmentConnection.ts new file mode 100644 index 0000000000..1e6d5a6305 --- /dev/null +++ b/frontend/src/app/shared/types/model/AlignmentConnection.ts @@ -0,0 +1,5 @@ +export interface AlignmentConnection { + alignedObjectiveId: number; + targetObjectiveId: number | null; + targetKeyResultId: number | null; +} diff --git a/frontend/src/app/shared/types/model/AlignmentLists.ts b/frontend/src/app/shared/types/model/AlignmentLists.ts new file mode 100644 index 0000000000..c5d41d7ef9 --- /dev/null +++ b/frontend/src/app/shared/types/model/AlignmentLists.ts @@ -0,0 +1,7 @@ +import { AlignmentObject } from './AlignmentObject'; +import { AlignmentConnection } from './AlignmentConnection'; + +export interface AlignmentLists { + alignmentObjectDtoList: AlignmentObject[]; + alignmentConnectionDtoList: AlignmentConnection[]; +} diff --git a/frontend/src/app/shared/types/model/AlignmentObject.ts b/frontend/src/app/shared/types/model/AlignmentObject.ts new file mode 100644 index 0000000000..320f5222d7 --- /dev/null +++ b/frontend/src/app/shared/types/model/AlignmentObject.ts @@ -0,0 +1,7 @@ +export interface AlignmentObject { + objectId: number; + objectTitle: string; + objectTeamName: string; + objectState: string | null; + objectType: string; +} diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index a7353961ff..3721d7dd9f 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -56,7 +56,8 @@ "ILLEGAL_CHANGE_OBJECTIVE_QUARTER": "Element kann nicht in ein anderes Quartal verlegt werden.", "NOT_LINK_YOURSELF": "Das Objective kann nicht auf sich selbst zeigen.", "NOT_LINK_IN_SAME_TEAM": "Das Objective kann nicht auf ein Objekt im selben Team zeigen.", - "ALIGNMENT_ALREADY_EXISTS": "Es existiert bereits ein Alignment ausgehend vom Objective mit der ID {1}." + "ALIGNMENT_ALREADY_EXISTS": "Es existiert bereits ein Alignment ausgehend vom Objective mit der ID {1}.", + "ALIGNMENT_DATA_FAIL": "Es gab ein Problem bei der Zusammenstellung der Daten für das Diagramm mit den Filter Quartal: {1}, Team: {2} und ObjectiveQuery: {3}." }, "SUCCESS": { "TEAM": {