diff --git a/.github/workflows/backend-test-action.yml b/.github/workflows/backend-test-action.yml index 50f40c9fb2..c31d16410f 100644 --- a/.github/workflows/backend-test-action.yml +++ b/.github/workflows/backend-test-action.yml @@ -10,11 +10,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK ${{vars.JAVA_VERSION}} uses: actions/setup-java@v4 with: - java-version: '17' + java-version: ${{vars.JAVA_VERSION}} distribution: 'adopt' - name: Use Maven to run unittests and integration tests - run: mvn clean verify \ No newline at end of file + run: mvn clean verify -X \ No newline at end of file diff --git a/.github/workflows/demo-deploy-action.yml b/.github/workflows/demo-deploy-action.yml index b237b77150..59102f9846 100644 --- a/.github/workflows/demo-deploy-action.yml +++ b/.github/workflows/demo-deploy-action.yml @@ -50,10 +50,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK ${{vars.JAVA_VERSION}} uses: actions/setup-java@v4 with: - java-version: '17' + java-version: ${{vars.JAVA_VERSION}} distribution: 'adopt' - name: Set up node 18 diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index d3f81979b6..44dee585c9 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -26,10 +26,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK ${{vars.JAVA_VERSION}} uses: actions/setup-java@v4 with: - java-version: '17' + java-version: ${{vars.JAVA_VERSION}} distribution: 'adopt' - name: Set up node 18 diff --git a/.github/workflows/format-action.yml b/.github/workflows/format-action.yml index bf3d25bd7f..8ab0c9dd70 100644 --- a/.github/workflows/format-action.yml +++ b/.github/workflows/format-action.yml @@ -46,10 +46,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK ${{vars.JAVA_VERSION}} uses: actions/setup-java@v4 with: - java-version: '17' + java-version: ${{vars.JAVA_VERSION}} distribution: 'adopt' server-id: github settings-path: ${{github.workspace}} diff --git a/.github/workflows/frontend-test-action.yml b/.github/workflows/frontend-test-action.yml index 73c35e400c..2f34b3596b 100644 --- a/.github/workflows/frontend-test-action.yml +++ b/.github/workflows/frontend-test-action.yml @@ -28,10 +28,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK ${{vars.JAVA_VERSION}} uses: actions/setup-java@v4 with: - java-version: '17' + java-version: ${{vars.JAVA_VERSION}} distribution: 'adopt' - uses: abhi1693/setup-browser@v0.3.5 diff --git a/.github/workflows/staging-deploy-action.yml b/.github/workflows/staging-deploy-action.yml index 1a4495dcd9..07d18713e1 100644 --- a/.github/workflows/staging-deploy-action.yml +++ b/.github/workflows/staging-deploy-action.yml @@ -50,10 +50,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK 17 + - name: Set up JDK ${{vars.JAVA_VERSION}} uses: actions/setup-java@v4 with: - java-version: '17' + java-version: ${{vars.JAVA_VERSION}} distribution: 'adopt' - name: Set up node diff --git a/.run/OkrApplication-debug-docker.run.xml b/.run/OkrApplication-debug-docker.run.xml new file mode 100755 index 0000000000..f4213371b4 --- /dev/null +++ b/.run/OkrApplication-debug-docker.run.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml index a4877fbaaa..2e00fc4f7d 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -71,10 +71,6 @@ com.h2database h2 - - org.springframework.boot - spring-boot-starter-data-ldap - com.tngtech.archunit archunit-junit5 @@ -87,6 +83,15 @@ 3.26.3 test + + org.springframework.boot + spring-boot-devtools + + + org.springframework + springloaded + 1.2.8.RELEASE + @@ -202,8 +207,6 @@ ${project.build.directory}/jacoco-output/merged.exec - - @@ -240,6 +243,7 @@ + code-format format @@ -282,5 +286,37 @@ + + debug + + + org.springframework.boot + spring-boot-devtools + + + org.springframework + springloaded + 1.2.8.RELEASE + + + + + no-formatter + + + + net.revelc.code.formatter + formatter-maven-plugin + 2.23.0 + + + code-format + none + + + + + + diff --git a/backend/src/main/java/ch/puzzle/okr/controller/QuarterController.java b/backend/src/main/java/ch/puzzle/okr/controller/QuarterController.java index eb92ef5841..1cc1383dbc 100644 --- a/backend/src/main/java/ch/puzzle/okr/controller/QuarterController.java +++ b/backend/src/main/java/ch/puzzle/okr/controller/QuarterController.java @@ -17,7 +17,7 @@ import java.util.List; @RestController -@RequestMapping("api/v1/quarters") +@RequestMapping("api/v2/quarters") public class QuarterController { private final QuarterBusinessService quarterBusinessService; @@ -28,9 +28,17 @@ public QuarterController(QuarterBusinessService quarterBusinessService) { @Operation(summary = "Get quarters", description = "Get a List of quarters depending on current date") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Returned a List of quarters", content = { - @Content(mediaType = "application/json", schema = @Schema(implementation = TeamDto.class)) }) }) + @Content(mediaType = "application/json", schema = @Schema(implementation = Quarter.class)) }) }) @GetMapping("") public ResponseEntity> getCurrentQuarters() { return ResponseEntity.status(HttpStatus.OK).body(this.quarterBusinessService.getQuarters()); } + + @Operation(summary = "Get current quarter", description = "Get the current quarter depending on current date") + @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Returned the current quarter", content = { + @Content(mediaType = "application/json", schema = @Schema(implementation = Quarter.class)) }) }) + @GetMapping("/current") + public ResponseEntity getCurrentQuarter() { + return ResponseEntity.status(HttpStatus.OK).body(this.quarterBusinessService.getCurrentQuarter()); + } } diff --git a/backend/src/main/java/ch/puzzle/okr/mapper/CnAttributesMapper.java b/backend/src/main/java/ch/puzzle/okr/mapper/CnAttributesMapper.java deleted file mode 100644 index d315869043..0000000000 --- a/backend/src/main/java/ch/puzzle/okr/mapper/CnAttributesMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package ch.puzzle.okr.mapper; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.ldap.core.AttributesMapper; -import org.springframework.stereotype.Component; - -import javax.naming.NamingException; -import javax.naming.directory.Attribute; -import javax.naming.directory.Attributes; - -@Component -public class CnAttributesMapper implements AttributesMapper { - - @Override - public String mapFromAttributes(Attributes attributes) throws NamingException { - Attribute cnAttribute = attributes.get("cn"); - if (cnAttribute != null) { - return cnAttribute.get().toString(); - } - return null; - } -} diff --git a/backend/src/main/java/ch/puzzle/okr/repository/TeamRepository.java b/backend/src/main/java/ch/puzzle/okr/repository/TeamRepository.java index 6a792921c5..14e48978ed 100644 --- a/backend/src/main/java/ch/puzzle/okr/repository/TeamRepository.java +++ b/backend/src/main/java/ch/puzzle/okr/repository/TeamRepository.java @@ -1,20 +1,11 @@ package ch.puzzle.okr.repository; import ch.puzzle.okr.models.Team; -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 TeamRepository extends CrudRepository { - @Query(value = """ - select distinct teamOrg.team_id from team_organisation teamOrg - inner join organisation o on o.id = teamOrg.organisation_id - where o.org_name in (:organisationNames) - """, nativeQuery = true) - List findTeamIdsByOrganisationNames(@Param("organisationNames") List organisationNames); - List findTeamsByName(String name); } diff --git a/backend/src/main/java/ch/puzzle/okr/service/persistence/ObjectivePersistenceService.java b/backend/src/main/java/ch/puzzle/okr/service/persistence/ObjectivePersistenceService.java index 675d8ec249..9e7a2bcd34 100644 --- a/backend/src/main/java/ch/puzzle/okr/service/persistence/ObjectivePersistenceService.java +++ b/backend/src/main/java/ch/puzzle/okr/service/persistence/ObjectivePersistenceService.java @@ -40,6 +40,17 @@ public String getModelName() { return OBJECTIVE; } + /** + * Get the number of Objectives of a Team in a Quarter. The underling sql looks like "select count(*) from objective + * where teamId = team.id and quarterId = quarter.id." + * + * @param team + * Team + * @param quarter + * Quarter + * + * @return number of Objectives of team in quarter + */ public Integer countByTeamAndQuarter(Team team, Quarter quarter) { return getRepository().countByTeamAndQuarter(team, quarter); } diff --git a/backend/src/main/java/ch/puzzle/okr/service/persistence/TeamPersistenceService.java b/backend/src/main/java/ch/puzzle/okr/service/persistence/TeamPersistenceService.java index 45d3eba4ae..533949d848 100644 --- a/backend/src/main/java/ch/puzzle/okr/service/persistence/TeamPersistenceService.java +++ b/backend/src/main/java/ch/puzzle/okr/service/persistence/TeamPersistenceService.java @@ -20,14 +20,6 @@ public String getModelName() { return TEAM; } - public List findTeamIdsByOrganisationName(String organisationName) { - return findTeamIdsByOrganisationNames(List.of(organisationName)); - } - - public List findTeamIdsByOrganisationNames(List organisationNames) { - return getRepository().findTeamIdsByOrganisationNames(organisationNames); - } - public List findTeamsByName(String name) { return getRepository().findTeamsByName(name); } diff --git a/backend/src/main/java/ch/puzzle/okr/util/CollectionUtils.java b/backend/src/main/java/ch/puzzle/okr/util/CollectionUtils.java new file mode 100644 index 0000000000..5c3a002a28 --- /dev/null +++ b/backend/src/main/java/ch/puzzle/okr/util/CollectionUtils.java @@ -0,0 +1,14 @@ +package ch.puzzle.okr.util; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +public class CollectionUtils { + + public static List iterableToList(Iterable iterable) { + return StreamSupport // + .stream(iterable.spliterator(), false) // + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/ch/puzzle/okr/util/quarter/generate/h2/QuarterFunction.java b/backend/src/main/java/ch/puzzle/okr/util/quarter/generate/h2/QuarterFunction.java index d8c663576c..fec72a232a 100644 --- a/backend/src/main/java/ch/puzzle/okr/util/quarter/generate/h2/QuarterFunction.java +++ b/backend/src/main/java/ch/puzzle/okr/util/quarter/generate/h2/QuarterFunction.java @@ -2,6 +2,8 @@ import ch.puzzle.okr.util.quarter.generate.QuarterData; import ch.puzzle.okr.util.quarter.generate.Quarters; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -21,6 +23,7 @@ public class QuarterFunction { public static final int NEXT_QUARTER_DB_ID = 3; private static final Map QUARTERS = new HashMap<>(); + private static final Logger logger = LoggerFactory.getLogger(QuarterFunction.class); public static void initQuarterData() { LocalDate now = LocalDate.now(); @@ -37,27 +40,40 @@ private static void registerCurrentQuarterForDate(LocalDate date, int dbId) { } public static String currentQuarterLabel() { - return QUARTERS.get(CURRENT_QUARTER_DB_ID).label(); + var label = QUARTERS.get(CURRENT_QUARTER_DB_ID).label(); + logger.info("currentQuarterLabel : {}", label); + return label; + } public static String currentQuarterStartDate() { - return QUARTERS.get(CURRENT_QUARTER_DB_ID).startDateAsIsoString(); + var start = QUARTERS.get(CURRENT_QUARTER_DB_ID).startDateAsIsoString(); + logger.info("currentQuarterStartDate: {}", start); + return start; } public static String currentQuarterEndDate() { - return QUARTERS.get(CURRENT_QUARTER_DB_ID).endDateAsIsoString(); + var end = QUARTERS.get(CURRENT_QUARTER_DB_ID).endDateAsIsoString(); + logger.info("currentQuarterEndDate : {}", end); + return end; } public static String nextQuarterLabel() { - return QUARTERS.get(NEXT_QUARTER_DB_ID).label(); + var label = QUARTERS.get(NEXT_QUARTER_DB_ID).label(); + logger.info("nextQuarterLabel : {}", label); + return label; } public static String nextQuarterStartDate() { - return QUARTERS.get(NEXT_QUARTER_DB_ID).startDateAsIsoString(); + var start = QUARTERS.get(NEXT_QUARTER_DB_ID).startDateAsIsoString(); + logger.info("nextQuarterStartDate : {}", start); + return start; } public static String nextQuarterEndDate() { - return QUARTERS.get(NEXT_QUARTER_DB_ID).endDateAsIsoString(); + var end = QUARTERS.get(NEXT_QUARTER_DB_ID).endDateAsIsoString(); + logger.info("nextQuarterEndDate : {}", end); + return end; } } diff --git a/backend/src/main/resources/application-staging.properties b/backend/src/main/resources/application-staging.properties index 31ce92a3c2..38e4a47571 100644 --- a/backend/src/main/resources/application-staging.properties +++ b/backend/src/main/resources/application-staging.properties @@ -1,10 +1,12 @@ # logging level for staging -logging.level.org.springframework=debug +logging.level.ch.puzzle.okr=DEBUG +#logging.level.org.flywaydb.core=DEBUG + connect.src=http://localhost:8544 http://localhost:8545 -hibernate.connection.url=jdbc:postgresql://okr-dev-db:5432/okr +hibernate.connection.url=jdbc:postgresql://localhost:5432/okr hibernate.connection.username=user hibernate.connection.password=pwd hibernate.multiTenancy=SCHEMA @@ -16,7 +18,7 @@ okr.datasource.driver-class-name=org.postgresql.Driver okr.user.champion.usernames=peggimann # pitc -okr.tenants.pitc.datasource.url=jdbc:postgresql://okr-dev-db:5432/okr +okr.tenants.pitc.datasource.url=jdbc:postgresql://localhost:5432/okr okr.tenants.pitc.datasource.username=user okr.tenants.pitc.datasource.password=pwd okr.tenants.pitc.datasource.schema=okr_pitc @@ -27,7 +29,7 @@ okr.tenants.pitc.security.oauth2.frontend.client-id=pitc_okr_staging # acme -okr.tenants.acme.datasource.url=jdbc:postgresql://okr-dev-db:5432/okr +okr.tenants.acme.datasource.url=jdbc:postgresql://localhost:5432/okr okr.tenants.acme.datasource.username=user okr.tenants.acme.datasource.password=pwd okr.tenants.acme.datasource.schema=okr_acme diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 8f56d0e783..03faf88d85 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -17,11 +17,6 @@ logging.level.org.springframework.security=INFO caching.authorization.users.TTL=1800000 caching.users.TTL=1800000 -spring.ldap.urls=ldap://ldap.puzzle.ch:389 -spring.ldap.base=dc=puzzle,dc=itc -spring.ldap.username=uid=okrtoolbind,ou=binduser,ou=users,dc=puzzle,dc=itc -spring.ldap.password= - # security configuration server.port=8080 # server.servlet.context-path=/resource-server diff --git a/backend/src/test/java/ch/puzzle/okr/SpringCachingConfigTest.java b/backend/src/test/java/ch/puzzle/okr/SpringCachingConfigTest.java index aa87d01441..1ca5d0825a 100644 --- a/backend/src/test/java/ch/puzzle/okr/SpringCachingConfigTest.java +++ b/backend/src/test/java/ch/puzzle/okr/SpringCachingConfigTest.java @@ -6,7 +6,10 @@ import ch.puzzle.okr.service.authorization.AuthorizationRegistrationService; import ch.puzzle.okr.test.SpringIntegrationTest; import ch.puzzle.okr.test.TestHelper; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; @@ -14,7 +17,6 @@ import static ch.puzzle.okr.SpringCachingConfig.AUTHORIZATION_USER_CACHE; import static ch.puzzle.okr.test.TestHelper.defaultUser; import static org.junit.jupiter.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.assertNotNull; @SpringIntegrationTest class SpringCachingConfigTest { diff --git a/backend/src/test/java/ch/puzzle/okr/controller/QuarterControllerIT.java b/backend/src/test/java/ch/puzzle/okr/controller/QuarterControllerIT.java index cfd844139f..5a582cd117 100644 --- a/backend/src/test/java/ch/puzzle/okr/controller/QuarterControllerIT.java +++ b/backend/src/test/java/ch/puzzle/okr/controller/QuarterControllerIT.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.BDDMockito; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -48,7 +49,7 @@ class QuarterControllerIT { void shouldGetAllQuarters() throws Exception { BDDMockito.given(quarterBusinessService.getQuarters()).willReturn(quaterList); - mvc.perform(get("/api/v1/quarters").contentType(MediaType.APPLICATION_JSON)) + mvc.perform(get("/api/v2/quarters").contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()).andExpect(jsonPath("$", Matchers.hasSize(3))) .andExpect(jsonPath("$[0].id", Is.is(1))).andExpect(jsonPath("$[0].label", Is.is("GJ 22/23-Q2"))) .andExpect(jsonPath("$[0].startDate", Is.is(LocalDate.of(2022, 9, 1).toString()))) @@ -64,7 +65,14 @@ void shouldGetAllQuarters() throws Exception { void shouldGetAllTeamsIfNoTeamsExists() throws Exception { BDDMockito.given(quarterBusinessService.getQuarters()).willReturn(Collections.emptyList()); - mvc.perform(get("/api/v1/quarters").contentType(MediaType.APPLICATION_JSON)) + mvc.perform(get("/api/v2/quarters").contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()).andExpect(jsonPath("$", Matchers.hasSize(0))); } -} \ No newline at end of file + + @Test + void shouldCallCurrentQuarterAfterRequest() throws Exception { + mvc.perform(get("/api/v2/quarters/current").contentType(MediaType.APPLICATION_JSON)); + + BDDMockito.verify(quarterBusinessService, Mockito.times(1)).getCurrentQuarter(); + } +} diff --git a/backend/src/test/java/ch/puzzle/okr/mapper/CnAttributesMapperTest.java b/backend/src/test/java/ch/puzzle/okr/mapper/CnAttributesMapperTest.java deleted file mode 100644 index 20be29cc7c..0000000000 --- a/backend/src/test/java/ch/puzzle/okr/mapper/CnAttributesMapperTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package ch.puzzle.okr.mapper; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import javax.naming.NamingException; -import javax.naming.directory.Attributes; -import javax.naming.directory.BasicAttributes; - -public class CnAttributesMapperTest { - - private final CnAttributesMapper cnAttributesMapper = new CnAttributesMapper(); - - @DisplayName("for Cn AttributeId should return AttributeValue") - @Test - void forCnAttributeIdShouldReturnAttributeValue() throws NamingException { - Attributes attributes = new BasicAttributes(); - attributes.put("cn", "Mango"); - - Assertions.assertEquals("Mango", cnAttributesMapper.mapFromAttributes(attributes)); - } - - @DisplayName("for non Cn AttributeId should return null") - @Test - void forNonCnAttributeIdShouldReturnNull() throws NamingException { - Attributes attributes = new BasicAttributes(); - attributes.put("ou", "Juicy, Fruit"); - - Assertions.assertNull(cnAttributesMapper.mapFromAttributes(attributes)); - } -} diff --git a/backend/src/test/java/ch/puzzle/okr/service/authorization/AuthorizationRegistrationServiceIT.java b/backend/src/test/java/ch/puzzle/okr/service/authorization/AuthorizationRegistrationServiceIT.java index 8ebcaa9351..4f0e3c77e4 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/authorization/AuthorizationRegistrationServiceIT.java +++ b/backend/src/test/java/ch/puzzle/okr/service/authorization/AuthorizationRegistrationServiceIT.java @@ -6,10 +6,7 @@ import ch.puzzle.okr.multitenancy.TenantContext; import ch.puzzle.okr.service.persistence.UserPersistenceService; import ch.puzzle.okr.test.SpringIntegrationTest; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; @@ -34,6 +31,8 @@ class AuthorizationRegistrationServiceIT { private final String tenant = TestHelper.SCHEMA_PITC; private final String key = tenant + "_" + user.getEmail(); + private static final String EMAIL_WUNDERLAND = "wunderland@puzzle.ch"; + @BeforeEach void setUp() { TenantContext.setCurrentTenant(tenant); @@ -41,10 +40,30 @@ void setUp() { @AfterEach void tearDown() { + resetOkrChampionStatus(); + clearCache(); + TenantContext.setCurrentTenant(null); + } + + private void resetOkrChampionStatus() { + Optional userFromDb = userPersistenceService.findByEmail(EMAIL_WUNDERLAND); + assertTrue(userFromDb.isPresent()); + + userFromDb.get().setOkrChampion(false); + userPersistenceService.save(userFromDb.get()); + assertOkrChampionStatusInDb(userFromDb.get().getEmail(), false); + } + + private void clearCache() { Cache cache = cacheManager.getCache(AUTHORIZATION_USER_CACHE); assertNotNull(cache); cache.clear(); - TenantContext.setCurrentTenant(null); + } + + private void assertOkrChampionStatusInDb(String email, boolean expectedOkrChampionStatus) { + var userInDb = userPersistenceService.findByEmail(email); + assertTrue(userInDb.isPresent()); + assertEquals(expectedOkrChampionStatus, userInDb.get().isOkrChampion()); } @Test @@ -81,6 +100,8 @@ void registerAuthorizationUser_shouldSetOkrChampionsToFalse() { // assert assertFalse(processedUser.user().isOkrChampion()); Optional userFromDB = userPersistenceService.findByEmail(user.getEmail()); + + assertTrue(userFromDB.isPresent()); assertFalse(userFromDB.get().isOkrChampion()); // cleanup @@ -89,30 +110,31 @@ void registerAuthorizationUser_shouldSetOkrChampionsToFalse() { /* * Special test setup.
 - the user wunderland@puzzle.ch is an existing user in the H2 db (created via
-     * X_TestData.sql) - the user wunderland@puzzle.ch is also defined in application-integration-test.properties as
-     * user champion - with this combination we can test, that the user in the db (which has initial isOkrChampion ==
-     * false) is after calling updateOrAddAuthorizationUser() a user champion. - because the user wunderland@puzzle.ch
-     * exists before the test, we make no clean in db (we don't remove it) 
+ * V100_0_0__TestData.sql) - the user wunderland@puzzle.ch is also defined in + * application-integration-test.properties as user champion - with this combination we can test, that the user in + * the db (which has initial isOkrChampion == false) is after calling updateOrAddAuthorizationUser() a user + * champion. - the OkrChampion status must manually be reset (in the tearDown method) */ @Test @DisplayName("registerAuthorizationUser for a user with an email defined in the application-integration-test.properties should set OkrChampions to true") void registerAuthorizationUserShouldSetOkrChampionsToTrue() { // arrange - User user = User.Builder.builder() // - .withFirstname("Alice") // - .withLastname("Wunderland") // - .withEmail("wunderland@puzzle.ch") // user.champion.emails from application-integration-test.properties - .build(); - - userPersistenceService.getOrCreateUser(user); // updates input user with id from DB !!! + assertOkrChampionStatusInDb(EMAIL_WUNDERLAND, false); // pre-condition // act - AuthorizationUser processedUser = authorizationRegistrationService.updateOrAddAuthorizationUser(user); + // load user from db (by email) and set OkrChampion status based on property + // "okr.tenants.pitc.user.champion.emails" from application-integration-test.properties file + AuthorizationUser processedUser = authorizationRegistrationService + .updateOrAddAuthorizationUser(User.Builder.builder() // + .withFirstname("Alice") // + .withLastname("Wunderland") // + .withEmail(EMAIL_WUNDERLAND) // user.champion.emails from + // application-integration-test.properties + .build()); // assert assertTrue(processedUser.user().isOkrChampion()); - Optional userFromDB = userPersistenceService.findByEmail(user.getEmail()); - assertTrue(userFromDB.get().isOkrChampion()); + assertOkrChampionStatusInDb(processedUser.user().getEmail(), true); } @Test @@ -138,6 +160,7 @@ void registerAuthorizationUser_shouldSetFirstnameAndLastnameFromToken() { // assert Optional userFromDB = userPersistenceService.findByEmail(user.getEmail()); + assertTrue(userFromDB.isPresent()); assertEquals(userFromDB.get().getFirstname(), firstNameFromToken); assertEquals(userFromDB.get().getLastname(), lastNameFromToken); diff --git a/backend/src/test/java/ch/puzzle/okr/service/authorization/AuthorizationServiceTest.java b/backend/src/test/java/ch/puzzle/okr/service/authorization/AuthorizationServiceTest.java index eb4e88823f..957b27540f 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/authorization/AuthorizationServiceTest.java +++ b/backend/src/test/java/ch/puzzle/okr/service/authorization/AuthorizationServiceTest.java @@ -1,6 +1,5 @@ package ch.puzzle.okr.service.authorization; -import ch.puzzle.okr.test.TestHelper; import ch.puzzle.okr.dto.ErrorDto; import ch.puzzle.okr.exception.OkrResponseStatusException; import ch.puzzle.okr.models.Objective; @@ -13,6 +12,7 @@ import ch.puzzle.okr.models.keyresult.KeyResultMetric; import ch.puzzle.okr.security.JwtHelper; import ch.puzzle.okr.service.persistence.ObjectivePersistenceService; +import ch.puzzle.okr.test.TestHelper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -28,9 +28,9 @@ import java.util.List; import static ch.puzzle.okr.ErrorKey.*; -import static ch.puzzle.okr.test.TestHelper.*; import static ch.puzzle.okr.service.authorization.AuthorizationService.hasRoleWriteAndReadAll; import static ch.puzzle.okr.service.authorization.AuthorizationService.hasRoleWriteForTeam; +import static ch.puzzle.okr.test.TestHelper.*; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; diff --git a/backend/src/test/java/ch/puzzle/okr/service/authorization/TeamAuthorizationServiceTest.java b/backend/src/test/java/ch/puzzle/okr/service/authorization/TeamAuthorizationServiceTest.java index 09243f4aac..6fb9ec4f34 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/authorization/TeamAuthorizationServiceTest.java +++ b/backend/src/test/java/ch/puzzle/okr/service/authorization/TeamAuthorizationServiceTest.java @@ -130,7 +130,7 @@ void getAllTeamsShouldReturnAllTeams(boolean isWriteable) { if (isWriteable) { when(authorizationService.updateOrAddAuthorizationUser()).thenReturn(okrChampionUser); } else { - when(authorizationService.updateOrAddAuthorizationUser()).thenReturn(userWithoutWriteAllRole()); + when(authorizationService.updateOrAddAuthorizationUser()).thenReturn(defaultAuthorizationUser()); } when(teamBusinessService.getAllTeams(any())).thenReturn(teamList); diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/AuthorizationCriteriaParametersTest.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/AuthorizationCriteriaParametersTest.java new file mode 100644 index 0000000000..7cff83afd8 --- /dev/null +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/AuthorizationCriteriaParametersTest.java @@ -0,0 +1,327 @@ +package ch.puzzle.okr.service.persistence; + +import ch.puzzle.okr.models.Objective; +import ch.puzzle.okr.models.User; +import jakarta.persistence.*; +import org.apache.commons.lang3.NotImplementedException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.*; +import java.util.stream.Stream; + +import static ch.puzzle.okr.test.TestHelper.defaultAuthorizationUser; +import static ch.puzzle.okr.test.TestHelper.mockAuthorizationUser; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class AuthorizationCriteriaParametersTest { + + @DisplayName("setParameters() should be successful with default authorization user") + @Test + void setParametersShouldBeSuccessfulWithDefaultAuthorizationUser() { + // arrange + var criteria = new AuthorizationCriteria(); + TypedQueryMock typedQueryMock = new TypedQueryMock<>(); + + // act + criteria.setParameters(typedQueryMock, defaultAuthorizationUser()); + + // assert + var expected = """ + teamDraftState, State=DRAFT + userTeamIds, ListN=[1] + publishedStates, ListN=[ONGOING, SUCCESSFUL, NOTSUCCESSFUL] + """; + + assertEquals(expected, typedQueryMock.getLog()); + } + + @DisplayName("setParameters() should be successful when user is okr champion") + @Test + void setParametersShouldBeSuccessfulWhenUserIsOkrChampion() { + // arrange + var user = User.Builder.builder() // + .withId(23L) // + .withFirstname("Hanna") // + .withLastname("muster") // + .withEmail("hanna.muster@example.com") // + .withOkrChampion(true) // + .build(); + var criteria = new AuthorizationCriteria(); + TypedQueryMock typedQueryMock = new TypedQueryMock<>(); + + // act + criteria.setParameters(typedQueryMock, mockAuthorizationUser(user)); + + // assert + var expected = """ + allDraftState, State=DRAFT + publishedStates, ListN=[ONGOING, SUCCESSFUL, NOTSUCCESSFUL] + """; + + assertEquals(expected, typedQueryMock.getLog()); + } + + @DisplayName("setParameters() should be successful when team ids or objective query are empty") + @ParameterizedTest + @MethodSource("provideListAndString") + void setParametersShouldBeSuccessfulWhenTeamIdsOrObjectiveQueryAreEmpty(List teamIds, String objectiveQuery) { + // arrange + var criteria = new AuthorizationCriteria(); + TypedQueryMock typedQueryMock = new TypedQueryMock<>(); + + // act + criteria.setParameters(typedQueryMock, teamIds, objectiveQuery, defaultAuthorizationUser()); + + // assert + var expected = """ + teamDraftState, State=DRAFT + userTeamIds, ListN=[1] + publishedStates, ListN=[ONGOING, SUCCESSFUL, NOTSUCCESSFUL] + """; + + assertEquals(expected, typedQueryMock.getLog()); + } + + private static Stream provideListAndString() { + return Stream.of( // + Arguments.of(List.of(), null), // + Arguments.of(List.of(), ""), // + Arguments.of(null, null), // + Arguments.of(null, "")); + } + + @DisplayName("setParameters() should be successful when team ids and objective query are not empty") + @Test + void setParametersShouldBeSuccessfulWhenTeamIdsAndObjectiveQueryAreNotEmpty() { + // arrange + TypedQueryMock typedQueryMock = new TypedQueryMock<>(); + var criteria = new AuthorizationCriteria(); + var anyTeamIds = List.of(99L); + var anyNonEmptyString = "OBJECTIVEQUERY"; + + // act + criteria.setParameters(typedQueryMock, anyTeamIds, anyNonEmptyString, defaultAuthorizationUser()); + + // assert + var expected = """ + teamIds, List12=[99] + objectiveQuery, String=OBJECTIVEQUERY + teamDraftState, State=DRAFT + userTeamIds, ListN=[1] + publishedStates, ListN=[ONGOING, SUCCESSFUL, NOTSUCCESSFUL] + """; + + assertEquals(expected, typedQueryMock.getLog()); + } + + // TypedQuery implementation for testing. The setParameterX() methods calls are logged in an internal StringBuilder + // which is return by getLog(). This log can be used for checking the internal state of the TypedQuery. All other + // methods are not implemented. + private static class TypedQueryMock implements TypedQuery { + + private final StringBuilder log = new StringBuilder(); + + public String getLog() { + return log.toString(); + } + + @Override + public TypedQuery setParameter(Parameter parameter, T t) { + log.append(parameter.getName()).append(", ") // + .append(t.getClass().getSimpleName()).append("=").append(t) // + .append("\n"); + return null; + } + + @Override + public TypedQuery setParameter(Parameter parameter, Calendar calendar, + TemporalType temporalType) { + log.append(parameter.getName()).append(", ") // + .append(calendar.getTime()).append(", ") // + .append(temporalType.name()) // + .append("\n"); + return null; + } + + @Override + public TypedQuery setParameter(Parameter parameter, Date date, TemporalType temporalType) { + log.append(parameter.getName()).append(", ") // + .append(date).append(", ") // + .append(temporalType.name()) // + .append("\n"); + return null; + } + + @Override + public TypedQuery setParameter(String s, Object o) { + log.append(s).append(", ") // + .append(o.getClass().getSimpleName()).append("=").append(o) // + .append("\n"); + return null; + } + + @Override + public TypedQuery setParameter(String s, Calendar calendar, TemporalType temporalType) { + log.append(s).append(", ") // + .append(calendar.getTime()).append(", ") // + .append(temporalType.name()) // + .append("\n"); + return null; + } + + @Override + public TypedQuery setParameter(String s, Date date, TemporalType temporalType) { + log.append(s).append(", ") // + .append(date).append(", ") // + .append(temporalType.name()) // + .append("\n"); + return null; + } + + @Override + public TypedQuery setParameter(int i, Object o) { + log.append(i).append(", ") // + .append(o.getClass().getSimpleName()).append("=").append(o) // + .append("\n"); + return null; + } + + @Override + public TypedQuery setParameter(int i, Calendar calendar, TemporalType temporalType) { + log.append(i).append(", ") // + .append(calendar.getTime()).append(", ") // + .append(temporalType.name()) // + .append("\n"); + return null; + } + + @Override + public TypedQuery setParameter(int i, Date date, TemporalType temporalType) { + log.append(i).append(", ") // + .append(date).append(", ") // + .append(temporalType.name()) // + .append("\n"); + return null; + } + + @Override + public List getResultList() { + throw new NotImplementedException(); + } + + @Override + public Objective getSingleResult() { + throw new NotImplementedException(); + } + + @Override + public int executeUpdate() { + throw new NotImplementedException(); + } + + @Override + public TypedQuery setMaxResults(int i) { + throw new NotImplementedException(); + } + + @Override + public int getMaxResults() { + throw new NotImplementedException(); + } + + @Override + public TypedQuery setFirstResult(int i) { + throw new NotImplementedException(); + } + + @Override + public int getFirstResult() { + throw new NotImplementedException(); + } + + @Override + public TypedQuery setHint(String s, Object o) { + throw new NotImplementedException(); + } + + @Override + public Map getHints() { + throw new NotImplementedException(); + } + + @Override + public Set> getParameters() { + throw new NotImplementedException(); + } + + @Override + public Parameter getParameter(String s) { + throw new NotImplementedException(); + } + + @Override + public Parameter getParameter(String s, Class aClass) { + throw new NotImplementedException(); + } + + @Override + public Parameter getParameter(int i) { + throw new NotImplementedException(); + } + + @Override + public Parameter getParameter(int i, Class aClass) { + throw new NotImplementedException(); + } + + @Override + public boolean isBound(Parameter parameter) { + throw new NotImplementedException(); + } + + @Override + public T getParameterValue(Parameter parameter) { + throw new NotImplementedException(); + } + + @Override + public Object getParameterValue(String s) { + throw new NotImplementedException(); + } + + @Override + public Object getParameterValue(int i) { + throw new NotImplementedException(); + } + + @Override + public TypedQuery setFlushMode(FlushModeType flushModeType) { + throw new NotImplementedException(); + } + + @Override + public FlushModeType getFlushMode() { + throw new NotImplementedException(); + } + + @Override + public TypedQuery setLockMode(LockModeType lockModeType) { + throw new NotImplementedException(); + } + + @Override + public LockModeType getLockMode() { + throw new NotImplementedException(); + } + + @Override + public T unwrap(Class aClass) { + throw new NotImplementedException(); + } + } + +} diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/AuthorizationCriteriaTest.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/AuthorizationCriteriaTest.java new file mode 100644 index 0000000000..bdb382edaf --- /dev/null +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/AuthorizationCriteriaTest.java @@ -0,0 +1,104 @@ +package ch.puzzle.okr.service.persistence; + +import ch.puzzle.okr.models.Objective; +import ch.puzzle.okr.models.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static ch.puzzle.okr.test.TestHelper.defaultAuthorizationUser; +import static ch.puzzle.okr.test.TestHelper.mockAuthorizationUser; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +public class AuthorizationCriteriaTest { + + @DisplayName("appendObjective() should be successful with default authorization user") + @Test + void appendObjectiveShouldBeSuccessfulWithDefaultAuthorizationUser() { + // arrange + var criteria = new AuthorizationCriteria(); + + // act + var current = criteria.appendObjective(defaultAuthorizationUser()); + + // assert + var expected = " and ((o.state=:teamDraftState and o.team.id IN (:userTeamIds)) or o.state IN (:publishedStates))"; + assertEquals(expected, current); + } + + @DisplayName("appendObjective() should be successful when user is okrChampion") + @Test + void appendObjectiveShouldBeSuccessfulWhenUserIsOkrChampion() { + // arrange + var user = User.Builder.builder() // + .withId(23L) // + .withFirstname("Hanna") // + .withLastname("muster") // + .withEmail("hanna.muster@example.com") // + .withOkrChampion(true) // + .build(); + var criteria = new AuthorizationCriteria(); + + // act + var current = criteria.appendObjective(mockAuthorizationUser(user)); + + // assert + var expected = " and (o.state=:allDraftState or o.state IN (:publishedStates))"; + assertEquals(expected, current); + } + + @DisplayName("appendOverview() should be successful when team ids or objective query are empty") + @ParameterizedTest + @MethodSource("provideListAndString") + void appendOverviewShouldBeSuccessfulWhenTeamIdsOrObjectiveQueryAreEmpty(List teamIds, + String objectiveQuery) { + // arrange + var criteria = new AuthorizationCriteria(); + + // act + var current = criteria.appendOverview(teamIds, objectiveQuery, defaultAuthorizationUser()); + + // assert + var expected = "\n and ((o.objectiveState=:teamDraftState and o.overviewId.teamId IN (:userTeamIds)) or o.objectiveState IN (:publishedStates) or o.overviewId.objectiveId = -1)"; + assertEquals(expected, current); + } + + private static Stream provideListAndString() { + return Stream.of( // + Arguments.of(List.of(), null), // + Arguments.of(List.of(), ""), // + Arguments.of(null, null), // + Arguments.of(null, "")); + } + + @DisplayName("appendOverview() should be successful when team ids and objective query are not empty") + @Test + void appendOverviewShouldBeSuccessfulWhenTeamIdsAndObjectiveQueryAreNotEmpty() { + // arrange + var criteria = new AuthorizationCriteria(); + var anyTeamIds = List.of(99L); + var anyNonEmptyString = "OBJECTIVEQUERY"; + var startingNewLine = "\n"; + var singleSpace = " "; + + // act + var current = criteria.appendOverview(anyTeamIds, anyNonEmptyString, defaultAuthorizationUser()); + + // assert + var expected = startingNewLine + singleSpace + + """ + and o.overviewId.teamId in (:teamIds) + and lower(coalesce(o.objectiveTitle, '')) like lower(concat('%',:objectiveQuery,'%')) + and ((o.objectiveState=:teamDraftState and o.overviewId.teamId IN (:userTeamIds)) or o.objectiveState IN (:publishedStates) or o.overviewId.objectiveId = -1)"""; + + assertEquals(expected, current); + assertFalse(current.contains(anyNonEmptyString)); + } + +} 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 419971e392..0837b5f996 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 @@ -1,52 +1,32 @@ package ch.puzzle.okr.service.persistence; -import ch.puzzle.okr.test.TestHelper; -import ch.puzzle.okr.dto.ErrorDto; -import ch.puzzle.okr.exception.OkrResponseStatusException; -import ch.puzzle.okr.models.Objective; -import ch.puzzle.okr.models.User; import ch.puzzle.okr.models.checkin.CheckIn; -import ch.puzzle.okr.models.checkin.CheckInMetric; -import ch.puzzle.okr.models.keyresult.KeyResultMetric; import ch.puzzle.okr.multitenancy.TenantContext; import ch.puzzle.okr.test.SpringIntegrationTest; +import ch.puzzle.okr.test.TestHelper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.server.ResponseStatusException; -import java.time.LocalDateTime; import java.util.List; import java.util.Objects; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; +import static ch.puzzle.okr.Constants.CHECK_IN; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; @SpringIntegrationTest class CheckInPersistenceServiceIT { - CheckIn createdCheckIn; + + private static final long KEY_RESULT_ID = 7L; @Autowired private CheckInPersistenceService checkInPersistenceService; - private static CheckIn createCheckIn(Long id) { - return createCheckIn(id, 1); - } - - private static final String UPDATED_CHECKIN = "Updated CheckIn"; - - private static CheckIn createCheckIn(Long id, int version) { - return CheckInMetric.Builder.builder().withValue(30D).withId(id).withVersion(version) - .withCreatedBy(User.Builder.builder().withId(1L).withFirstname("Frank").build()) - .withCreatedOn(LocalDateTime.MAX) - .withKeyResult(KeyResultMetric.Builder.builder().withBaseline(1.0).withStretchGoal(13.0).withId(8L) - .withObjective(Objective.Builder.builder().withId(1L).build()).build()) - .withChangeInfo("ChangeInfo").withInitiatives("Initiatives").withModifiedOn(LocalDateTime.MAX) - .withConfidence(5).build(); - } - @BeforeEach void setUp() { TenantContext.setCurrentTenant(TestHelper.SCHEMA_PITC); @@ -54,97 +34,53 @@ void setUp() { @AfterEach void tearDown() { - try { - if (createdCheckIn != null) { - checkInPersistenceService.findById(createdCheckIn.getId()); - checkInPersistenceService.deleteById(createdCheckIn.getId()); - } - } catch (ResponseStatusException ex) { - // created CheckIn already deleted - } finally { - createdCheckIn = null; - } TenantContext.setCurrentTenant(null); } + // uses data from V100_0_0__TestData.sql + @DisplayName("getCheckInsByKeyResultIdOrderByCheckInDate() should get checkIns by keyResultId and order them by date desc") @Test - void saveCheckInShouldSaveNewCheckIn() { - CheckIn checkIn = createCheckIn(null); - - createdCheckIn = checkInPersistenceService.save(checkIn); - - assertNotNull(createdCheckIn.getId()); - assertEquals(checkIn.getModifiedOn(), createdCheckIn.getModifiedOn()); - assertEquals(((CheckInMetric) checkIn).getValue(), ((CheckInMetric) createdCheckIn).getValue()); - assertEquals(checkIn.getCreatedBy(), createdCheckIn.getCreatedBy()); - assertEquals(checkIn.getCreatedOn(), createdCheckIn.getCreatedOn()); - assertEquals(checkIn.getInitiatives(), createdCheckIn.getInitiatives()); - assertEquals(checkIn.getChangeInfo(), createdCheckIn.getChangeInfo()); + void getCheckInsByKeyResultIdOrderByCheckInDateShouldGetCheckInsByKeyResultIdAndOrderThemByDateDesc() { + // act + List checkIns = checkInPersistenceService + .getCheckInsByKeyResultIdOrderByCheckInDateDesc(KEY_RESULT_ID); + + // assert + assertThat(2, greaterThanOrEqualTo(checkIns.size())); + CheckIn firstCheckIn = checkIns.get(0); + CheckIn lastCheckIn = checkIns.get(checkIns.size() - 1); + assertFirstIsCreatedAfterSecond(firstCheckIn, lastCheckIn); } - @Test - void updateKeyResultShouldUpdateKeyResult() { - createdCheckIn = checkInPersistenceService.save(createCheckIn(null)); - CheckIn updateCheckIn = createCheckIn(createdCheckIn.getId(), createdCheckIn.getVersion()); - updateCheckIn.setChangeInfo(UPDATED_CHECKIN); - - CheckIn updatedCheckIn = checkInPersistenceService.save(updateCheckIn); - - assertEquals(createdCheckIn.getId(), updatedCheckIn.getId()); - assertEquals(createdCheckIn.getVersion() + 1, updatedCheckIn.getVersion()); - assertEquals(UPDATED_CHECKIN, updatedCheckIn.getChangeInfo()); + private void assertFirstIsCreatedAfterSecond(CheckIn first, CheckIn second) { + assertTrue(first.getCreatedOn().isAfter(second.getCreatedOn())); } + // uses data from V100_0_0__TestData.sql + @DisplayName("getLastCheckInOfKeyResult() should get last checkIn of keyResult") @Test - void updateKeyResultShouldThrowExceptionWhenAlreadyUpdated() { - createdCheckIn = checkInPersistenceService.save(createCheckIn(null)); - CheckIn updateCheckIn = createCheckIn(createdCheckIn.getId(), 0); - updateCheckIn.setChangeInfo(UPDATED_CHECKIN); + void getLastCheckInOfKeyResultShouldGetLastCheckInOfKeyResult() { + // act + var lastCheckIn = checkInPersistenceService.getLastCheckInOfKeyResult(KEY_RESULT_ID); - OkrResponseStatusException exception = assertThrows(OkrResponseStatusException.class, - () -> checkInPersistenceService.save(updateCheckIn)); - - List expectedErrors = List.of(new ErrorDto("DATA_HAS_BEEN_UPDATED", List.of("Check-in"))); - - assertEquals(UNPROCESSABLE_ENTITY, exception.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(exception.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(exception.getReason())); + // assert + var allCheckins = checkInPersistenceService.getCheckInsByKeyResultIdOrderByCheckInDateDesc(KEY_RESULT_ID); + assertLastIsCreatedAfterAllOtherCheckIns(lastCheckIn, allCheckins); } - @Test - void getAllCheckInShouldReturnListOfAllCheckIns() { - List checkIns = checkInPersistenceService.findAll(); - - assertEquals(19, checkIns.size()); - } - - @Test - void getCheckInByIdShouldReturnCheckInProperly() { - CheckIn checkIn = checkInPersistenceService.findById(20L); - - assertEquals(20L, checkIn.getId()); - assertEquals(0.5, ((CheckInMetric) checkIn).getValue(), 0.01); - assertEquals( - "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 ", - checkIn.getChangeInfo()); + private void assertLastIsCreatedAfterAllOtherCheckIns(CheckIn last, List allCheckIns) { + for (CheckIn checkInLoop : allCheckIns) { + if (!Objects.equals(checkInLoop.getId(), last.getId())) { + assertTrue(last.getCreatedOn().isAfter(checkInLoop.getCreatedOn())); + } + } } + @DisplayName("getModelName() should return checkIn") @Test - void shouldGetCheckInsByKeyResultIdAndOrderThemByDateDesc() { - List checkIns = checkInPersistenceService.getCheckInsByKeyResultIdOrderByCheckInDateDesc(7L); - assertTrue(checkIns.get(0).getCreatedOn().isAfter(checkIns.get(checkIns.size() - 1).getCreatedOn())); + void getModelNameShouldReturnCheckIn() { + assertEquals(CHECK_IN, checkInPersistenceService.getModelName()); } - @Test - void shouldGetLastCheckInOfKeyResult() { - CheckIn checkIn = checkInPersistenceService.getLastCheckInOfKeyResult(7L); - List checkInList = checkInPersistenceService.getCheckInsByKeyResultIdOrderByCheckInDateDesc(7L); - for (CheckIn checkInLoop : checkInList) { - if (!Objects.equals(checkInLoop.getId(), checkIn.getId())) { - assertTrue(checkIn.getCreatedOn().isAfter(checkInLoop.getCreatedOn())); - } - } - } } 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 ccc6c3a8a0..9708e9d779 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 @@ -2,56 +2,68 @@ import ch.puzzle.okr.dto.ErrorDto; import ch.puzzle.okr.exception.OkrResponseStatusException; -import ch.puzzle.okr.models.*; +import ch.puzzle.okr.models.Objective; +import ch.puzzle.okr.models.Quarter; +import ch.puzzle.okr.models.Team; import ch.puzzle.okr.models.authorization.AuthorizationUser; import ch.puzzle.okr.multitenancy.TenantContext; import ch.puzzle.okr.test.SpringIntegrationTest; import ch.puzzle.okr.test.TestHelper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; -import java.time.LocalDateTime; import java.util.List; +import java.util.stream.Stream; -import static ch.puzzle.okr.test.TestConstants.GJ_FOR_TESTS_QUARTER_ID; +import static ch.puzzle.okr.exception.OkrResponseStatusException.of; import static ch.puzzle.okr.test.TestHelper.defaultAuthorizationUser; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.http.HttpStatus.*; +import static org.junit.jupiter.params.provider.Arguments.arguments; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; +// tests are using data from V100_0_0__TestData.sql @SpringIntegrationTest class ObjectivePersistenceServiceIT { - private static final String REASON = "not authorized to read objective"; - private static final OkrResponseStatusException exception = OkrResponseStatusException.of(REASON); - private static final String HIGHER_CUSTOMER_HAPPINESS = "Wir wollen die Kundenzufriedenheit steigern"; - private static final String MODEL_WITH_ID_NOT_FOUND = "MODEL_WITH_ID_NOT_FOUND"; + private static final long INVALID_OBJECTIVE_ID = 321L; + private static final long INVALID_KEY_RESULT_ID = 321L; + private static final long INVALID_CHECK_IN_ID = 321L; + private static final long INVALID_TEAM_ID = 321L; + private static final long INVALID_QUARTER_ID = 12L; + + private static final long ID_OF_OBJECTIVE_3 = 3L; + private static final long ID_OF_OBJECTIVE_8 = 8L; + private static final long ID_OF_OBJECTIVE_9 = 9L; + private static final long ID_OF_OBJECTIVE_10 = 10L; + + private static final String TITLE_OF_OBJECTIVE_3 = "Wir wollen die Kundenzufriedenheit steigern"; + private static final String TITLE_OF_OBJECTIVE_8 = "consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua"; + private static final String TITLE_OF_OBJECTIVE_9 = "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."; + private static final String TITLE_OF_OBJECTIVE_10 = "should not appear on staging, no sea takimata sanctus est Lorem ipsum dolor sit amet."; + + private static final long ID_OF_KEY_RESULT_5 = 5L; + private static final long ID_OF_CHECK_IN_7 = 7L; + private static final long ID_OF_TEAM_6 = 6L; + + private static final String REASON_UNAUTHORIZED = "not authorized to read objective"; + private static final OkrResponseStatusException NO_RESULT_EXCEPTION = of(REASON_UNAUTHORIZED); + private static final String OBJECTIVE = "Objective"; private static final String ATTRIBUTE_NULL = "ATTRIBUTE_NULL"; + private static final long CURRENT_QUARTER_ID = 2L; + private final AuthorizationUser authorizationUser = defaultAuthorizationUser(); - private Objective createdObjective; @Autowired private ObjectivePersistenceService objectivePersistenceService; - @Autowired - private TeamPersistenceService teamPersistenceService; - @Autowired - private QuarterPersistenceService quarterPersistenceService; - - private static Objective createObjective(Long id) { - return createObjective(id, 1); - } - - private static Objective createObjective(Long id, int version) { - return Objective.Builder.builder().withId(id).withVersion(version).withTitle("title") - .withCreatedBy(User.Builder.builder().withId(1L).build()) - .withTeam(Team.Builder.builder().withId(5L).build()) - .withQuarter(Quarter.Builder.builder().withId(GJ_FOR_TESTS_QUARTER_ID).build()) - .withDescription("This is our description").withState(State.DRAFT).withCreatedOn(LocalDateTime.MAX) - .withModifiedOn(LocalDateTime.MAX).withModifiedBy(User.Builder.builder().withId(1L).build()).build(); - } @BeforeEach void setUp() { @@ -60,206 +72,209 @@ void setUp() { @AfterEach void tearDown() { - try { - if (createdObjective != null) { - objectivePersistenceService.findById(createdObjective.getId()); - objectivePersistenceService.deleteById(createdObjective.getId()); - } - } catch (ResponseStatusException ex) { - // created key result already deleted - } finally { - createdObjective = null; - } TenantContext.setCurrentTenant(null); } - @Test - void findAllShouldReturnListOfObjectives() { - List objectives = objectivePersistenceService.findAll(); - - assertEquals(7, objectives.size()); - } - + @DisplayName("findObjectiveById() should return objective properly") @Test void findObjectiveByIdShouldReturnObjectiveProperly() { - Objective objective = objectivePersistenceService.findObjectiveById(3L, authorizationUser, exception); + // act + var objective = objectivePersistenceService.findObjectiveById(ID_OF_OBJECTIVE_3, authorizationUser, + NO_RESULT_EXCEPTION); - assertEquals(3L, objective.getId()); - assertEquals(HIGHER_CUSTOMER_HAPPINESS, objective.getTitle()); + // assert + assertObjective(ID_OF_OBJECTIVE_3, TITLE_OF_OBJECTIVE_3, objective); } + @DisplayName("findObjectiveById() should throw exception when objective not found") @Test void findObjectiveByIdShouldThrowExceptionWhenObjectiveNotFound() { - ResponseStatusException findObjectiveException = assertThrows(OkrResponseStatusException.class, - () -> objectivePersistenceService.findObjectiveById(321L, authorizationUser, - ObjectivePersistenceServiceIT.exception)); + // act + var exception = assertThrows(OkrResponseStatusException.class, () -> objectivePersistenceService + .findObjectiveById(INVALID_OBJECTIVE_ID, authorizationUser, NO_RESULT_EXCEPTION)); - assertEquals(UNAUTHORIZED, findObjectiveException.getStatusCode()); - assertEquals(REASON, findObjectiveException.getReason()); + // assert + var expectedErrors = List.of(new ErrorDto(REASON_UNAUTHORIZED, List.of())); + assertResponseStatusException(UNAUTHORIZED, expectedErrors, exception); } + @DisplayName("findObjectiveById() should throw exception when objective id is null") @Test void findObjectiveByIdShouldThrowExceptionWhenObjectiveIdIsNull() { - OkrResponseStatusException findObjectiveException = assertThrows(OkrResponseStatusException.class, - () -> objectivePersistenceService.findObjectiveById(null, authorizationUser, - ObjectivePersistenceServiceIT.exception)); - - List expectedErrors = List.of(new ErrorDto(ATTRIBUTE_NULL, List.of("ID", OBJECTIVE))); + // act + var exception = assertThrows(OkrResponseStatusException.class, + () -> objectivePersistenceService.findObjectiveById(null, authorizationUser, NO_RESULT_EXCEPTION)); - assertEquals(BAD_REQUEST, findObjectiveException.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(findObjectiveException.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(findObjectiveException.getReason())); + // assert + var expectedErrors = List.of(new ErrorDto(ATTRIBUTE_NULL, List.of("ID", OBJECTIVE))); + assertResponseStatusException(BAD_REQUEST, expectedErrors, exception); } + @DisplayName("findObjectiveByKeyResultId() should return objective properly") @Test void findObjectiveByKeyResultIdShouldReturnObjectiveProperly() { - Objective objective = objectivePersistenceService.findObjectiveByKeyResultId(5L, authorizationUser, exception); + // act + var objective = objectivePersistenceService.findObjectiveByKeyResultId(ID_OF_KEY_RESULT_5, authorizationUser, + NO_RESULT_EXCEPTION); - assertEquals(3L, objective.getId()); - assertEquals(HIGHER_CUSTOMER_HAPPINESS, objective.getTitle()); + // assert + assertObjective(ID_OF_OBJECTIVE_3, TITLE_OF_OBJECTIVE_3, objective); } + @DisplayName("findObjectiveByKeyResultId() should throw exception when objective not found") @Test void findObjectiveByKeyResultIdShouldThrowExceptionWhenObjectiveNotFound() { - ResponseStatusException objectiveByKeyResultException = assertThrows(ResponseStatusException.class, - () -> objectivePersistenceService.findObjectiveByKeyResultId(321L, authorizationUser, - ObjectivePersistenceServiceIT.exception)); + // act + var exception = assertThrows(OkrResponseStatusException.class, () -> objectivePersistenceService + .findObjectiveByKeyResultId(INVALID_KEY_RESULT_ID, authorizationUser, NO_RESULT_EXCEPTION)); - assertEquals(UNAUTHORIZED, objectiveByKeyResultException.getStatusCode()); - assertEquals(REASON, objectiveByKeyResultException.getReason()); + // assert + var expectedErrors = List.of(new ErrorDto(REASON_UNAUTHORIZED, List.of())); + assertResponseStatusException(UNAUTHORIZED, expectedErrors, exception); } + @DisplayName("findObjectiveByKeyResultId() should throw exception when objective id is null") @Test void findObjectiveByKeyResultIdShouldThrowExceptionWhenObjectiveIdIsNull() { - OkrResponseStatusException objectiveByKeyResultException = assertThrows(OkrResponseStatusException.class, - () -> objectivePersistenceService.findObjectiveByKeyResultId(null, authorizationUser, - ObjectivePersistenceServiceIT.exception)); + // act + var exception = assertThrows(OkrResponseStatusException.class, () -> objectivePersistenceService + .findObjectiveByKeyResultId(null, authorizationUser, NO_RESULT_EXCEPTION)); - List expectedErrors = List.of(new ErrorDto(ATTRIBUTE_NULL, List.of("ID", OBJECTIVE))); - - assertEquals(BAD_REQUEST, objectiveByKeyResultException.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(objectiveByKeyResultException.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(objectiveByKeyResultException.getReason())); + // assert + var expectedErrors = List.of(new ErrorDto(ATTRIBUTE_NULL, List.of("ID", OBJECTIVE))); + assertResponseStatusException(BAD_REQUEST, expectedErrors, exception); } + @DisplayName("findObjectiveByCheckInId() should return objective properly") @Test void findObjectiveByCheckInIdShouldReturnObjectiveProperly() { - Objective objective = objectivePersistenceService.findObjectiveByCheckInId(7L, authorizationUser, exception); + // act + var objective = objectivePersistenceService.findObjectiveByCheckInId(ID_OF_CHECK_IN_7, authorizationUser, + NO_RESULT_EXCEPTION); - assertEquals(3L, objective.getId()); - assertEquals(HIGHER_CUSTOMER_HAPPINESS, objective.getTitle()); + // assert + assertObjective(ID_OF_OBJECTIVE_3, TITLE_OF_OBJECTIVE_3, objective); } + @DisplayName("findObjectiveByCheckInId() should throw exception when objective not found") @Test void findObjectiveByCheckInIdShouldThrowExceptionWhenObjectiveNotFound() { - ResponseStatusException objectiveByCheckInException = assertThrows(ResponseStatusException.class, - () -> objectivePersistenceService.findObjectiveByCheckInId(321L, authorizationUser, - ObjectivePersistenceServiceIT.exception)); + // act + var exception = assertThrows(OkrResponseStatusException.class, () -> objectivePersistenceService + .findObjectiveByCheckInId(INVALID_CHECK_IN_ID, authorizationUser, NO_RESULT_EXCEPTION)); - assertEquals(UNAUTHORIZED, objectiveByCheckInException.getStatusCode()); - assertEquals(REASON, objectiveByCheckInException.getReason()); + // assert + var expectedErrors = List.of(new ErrorDto(REASON_UNAUTHORIZED, List.of())); + assertResponseStatusException(UNAUTHORIZED, expectedErrors, exception); } + @DisplayName("findObjectiveByCheckInId() should throw exception when objective id is null") @Test void findObjectiveByCheckInIdShouldThrowExceptionWhenObjectiveIdIsNull() { - OkrResponseStatusException objectiveByCheckInException = assertThrows(OkrResponseStatusException.class, - () -> objectivePersistenceService.findObjectiveByCheckInId(null, authorizationUser, - ObjectivePersistenceServiceIT.exception)); - - List expectedErrors = List.of(new ErrorDto(ATTRIBUTE_NULL, List.of("ID", OBJECTIVE))); + // act + var exception = assertThrows(OkrResponseStatusException.class, () -> objectivePersistenceService + .findObjectiveByCheckInId(null, authorizationUser, ObjectivePersistenceServiceIT.NO_RESULT_EXCEPTION)); - assertEquals(BAD_REQUEST, objectiveByCheckInException.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(objectiveByCheckInException.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(objectiveByCheckInException.getReason())); + // assert + var expectedErrors = List.of(new ErrorDto(ATTRIBUTE_NULL, List.of("ID", OBJECTIVE))); + assertResponseStatusException(BAD_REQUEST, expectedErrors, exception); } + @DisplayName("findObjectiveByTeamId() should return objectives of team properly") @Test - void saveObjectiveShouldSaveNewObjective() { - Objective objective = createObjective(null); - - createdObjective = objectivePersistenceService.save(objective); - - assertNotNull(createdObjective.getId()); - assertEquals(objective.getDescription(), createdObjective.getDescription()); - assertEquals(objective.getDescription(), createdObjective.getDescription()); - assertEquals(objective.getModifiedOn(), createdObjective.getModifiedOn()); + void findObjectiveByTeamIdShouldReturnObjectivesOfTeamProperly() { + // act + var objectives = objectivePersistenceService.findObjectiveByTeamId(ID_OF_TEAM_6); + + // assert + assertEquals(3, objectives.size()); + assertObjective(ID_OF_OBJECTIVE_8, TITLE_OF_OBJECTIVE_8, objectives.get(0)); + assertObjective(ID_OF_OBJECTIVE_9, TITLE_OF_OBJECTIVE_9, objectives.get(1)); + assertObjective(ID_OF_OBJECTIVE_10, TITLE_OF_OBJECTIVE_10, objectives.get(2)); } + @DisplayName("findObjectiveByTeamId() should return empty list when objective not found") @Test - void updateObjectiveShouldUpdateObjective() { - createdObjective = objectivePersistenceService.save(createObjective(null)); - Objective updateObjective = createObjective(createdObjective.getId(), createdObjective.getVersion()); - updateObjective.setState(State.ONGOING); - - Objective updatedObjective = objectivePersistenceService.save(updateObjective); + void findObjectiveByTeamIdShouldReturnEmptyListWhenObjectiveNotFound() { + // act + var objectives = objectivePersistenceService.findObjectiveByTeamId(INVALID_TEAM_ID); - assertEquals(createdObjective.getId(), updatedObjective.getId()); - assertEquals(State.ONGOING, updatedObjective.getState()); + // assert + assertTrue(objectives.isEmpty()); } + @DisplayName("findObjectiveByTeamId() should return empty list when objective id is null") @Test - void updateObjectiveShouldThrowExceptionWhenAlreadyUpdated() { - createdObjective = objectivePersistenceService.save(createObjective(null)); - Objective updateObjective = createObjective(createdObjective.getId(), 0); - updateObjective.setState(State.ONGOING); - - OkrResponseStatusException objectiveSaveException = assertThrows(OkrResponseStatusException.class, - () -> objectivePersistenceService.save(updateObjective)); - List expectedErrors = List.of(new ErrorDto("DATA_HAS_BEEN_UPDATED", List.of(OBJECTIVE))); - - assertEquals(UNPROCESSABLE_ENTITY, objectiveSaveException.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(objectiveSaveException.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(objectiveSaveException.getReason())); - } - - @Test - void deleteObjectiveShouldThrowExceptionWhenKeyResultNotFound() { - Objective objective = createObjective(321L); - createdObjective = objectivePersistenceService.save(objective); - objectivePersistenceService.deleteById(createdObjective.getId()); - - Long objectiveId = createdObjective.getId(); - OkrResponseStatusException findObjectiveException = assertThrows(OkrResponseStatusException.class, - () -> objectivePersistenceService.findById(objectiveId)); + void findObjectiveByTeamIdShouldReturnEmptyListWhenObjectiveIdIsNull() { + // act + var objectives = objectivePersistenceService.findObjectiveByTeamId(null); - List expectedErrors = List.of(new ErrorDto(MODEL_WITH_ID_NOT_FOUND, List.of(OBJECTIVE, "200"))); - - assertEquals(NOT_FOUND, findObjectiveException.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(findObjectiveException.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(findObjectiveException.getReason())); + // assert + assertTrue(objectives.isEmpty()); } + @DisplayName("countByTeamAndQuarter() should return number of objectives for current quarter") @Test - void countByTeamAndQuarterShouldThrowErrorIfQuarterDoesNotExist() { - Team teamId5 = teamPersistenceService.findById(5L); - OkrResponseStatusException countByTeamException = assertThrows(OkrResponseStatusException.class, - () -> objectivePersistenceService.countByTeamAndQuarter(teamId5, - quarterPersistenceService.findById(12L))); - - List expectedErrors = List.of(new ErrorDto(MODEL_WITH_ID_NOT_FOUND, List.of("Quarter", "12"))); + void countByTeamAndQuarterShouldReturnNumberOfObjectivesForCurrentQuarter() { + // arrange: there are 3 objectives for the current quarter (id 2) for team with id 6 + var team = Team.Builder.builder().withId(ID_OF_TEAM_6).build(); + var quarter = Quarter.Builder.builder().withId(CURRENT_QUARTER_ID).build(); - assertEquals(NOT_FOUND, countByTeamException.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(countByTeamException.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(countByTeamException.getReason())); + // act + var count = objectivePersistenceService.countByTeamAndQuarter(team, quarter); - Quarter quarterId2 = quarterPersistenceService.findById(2L); - OkrResponseStatusException exceptionTeam = assertThrows(OkrResponseStatusException.class, - () -> objectivePersistenceService.countByTeamAndQuarter(teamPersistenceService.findById(500L), - quarterId2)); + // assert + assertEquals(3, count); + } - List expectedErrorsTeam = List.of(new ErrorDto(MODEL_WITH_ID_NOT_FOUND, List.of("Team", "500"))); + @DisplayName("countByTeamAndQuarter() should return zero when team or quarter is not valid or null") + @ParameterizedTest + @MethodSource("invalidTeamsAndQuarters") + void countByTeamAndQuarterShouldReturnZeroWhenTeamOrQuarterIsNotValidOrNull(Team team, Quarter quarter) { + // act + var count = objectivePersistenceService.countByTeamAndQuarter(team, quarter); - assertEquals(NOT_FOUND, exceptionTeam.getStatusCode()); - assertThat(expectedErrorsTeam).hasSameElementsAs(exceptionTeam.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrorsTeam).contains(exceptionTeam.getReason())); + // assert + assertEquals(0, count); + } + private static Stream invalidTeamsAndQuarters() { + var validTeam = Team.Builder.builder().withId(ID_OF_TEAM_6).build(); + var invalidTeam = Team.Builder.builder().withId(INVALID_TEAM_ID).build(); + var validQuarter = Quarter.Builder.builder().withId(CURRENT_QUARTER_ID).build(); + var invalidQuarter = Quarter.Builder.builder().withId(INVALID_QUARTER_ID).build(); + + return Stream.of( + // valid team + invalid quarter + arguments(validTeam, invalidQuarter), + // valid team + null quarter + arguments(validTeam, null), + // invalid team + valid quarter + arguments(invalidTeam, validQuarter), + // invalid team + null quarter + arguments(null, validQuarter), + // invalid team + invalid quarter + arguments(invalidTeam, invalidQuarter), + // null team + null quarter + arguments(null, null)); } + @DisplayName("getModelName() should return Objective") @Test - void countByTeamAndQuarterShouldReturnCountValue() { - Integer count = objectivePersistenceService.countByTeamAndQuarter(Team.Builder.builder().withId(5L).build(), - Quarter.Builder.builder().withId(2L).build()); + void getModelNameShouldReturnObjective() { + assertEquals(OBJECTIVE, objectivePersistenceService.getModelName()); + } + + private void assertResponseStatusException(HttpStatus expectedStatus, List expectedErrors, + OkrResponseStatusException currentException) { + assertEquals(expectedStatus, currentException.getStatusCode()); + assertThat(expectedErrors).hasSameElementsAs(currentException.getErrors()); + assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(currentException.getReason())); + } - assertEquals(2, count); + private void assertObjective(Long expectedId, String expectedTitle, Objective currentObjective) { + assertEquals(expectedId, currentObjective.getId()); + assertEquals(expectedTitle, currentObjective.getTitle()); } + } diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/PersistenceBaseTestIT.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/PersistenceBaseTestIT.java new file mode 100644 index 0000000000..7f5a60a5e5 --- /dev/null +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/PersistenceBaseTestIT.java @@ -0,0 +1,182 @@ +package ch.puzzle.okr.service.persistence; + +import ch.puzzle.okr.dto.ErrorDto; +import ch.puzzle.okr.models.User; +import ch.puzzle.okr.multitenancy.TenantContext; +import ch.puzzle.okr.repository.UserRepository; +import ch.puzzle.okr.test.SpringIntegrationTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +import static ch.puzzle.okr.test.TestHelper.getAllErrorKeys; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.http.HttpStatus.*; + +/** + * Testing the functionality of the abstract PersistenceBase and use UserRepository as example of a CrudRepository + * implementation. + *

+ * Tests depending on data from V100_0_0__TestData.sql + */ +@SpringIntegrationTest +public class PersistenceBaseTestIT { + + private User createdUser; + + private static final long NON_EXISTING_USER_ID = 321L; + private static final long USER_PACO_ID = 1L; + private static final User USER_WITHOUT_CONSTRAINTS = User.Builder.builder() // + .withFirstname("Hans") // + .withLastname("Muster") // + .withEmail("hans.muster@puzzle.ch") // + .build(); + + @Autowired + private PersistenceBase persistenceBase; + + @BeforeEach + void setUp() { + TenantContext.setCurrentTenant("pitc"); + } + + @AfterEach + void tearDown() { + if (createdUser != null) { + persistenceBase.deleteById(createdUser.getId()); + createdUser = null; + } + TenantContext.setCurrentTenant(null); + } + + @DisplayName("findById() should return single entity if entity with id exists") + @Test + void findByIdShouldReturnSingleEntityIfEntityWithIdExists() { + var foundUser = persistenceBase.findById(USER_PACO_ID); + + assertEquals(USER_PACO_ID, foundUser.getId()); + assertUser("Paco", "Eggimann", "peggimann@puzzle.ch", foundUser); + } + + @DisplayName("findById() should throw exception if entity with id does not exist") + @Test + void findByIdShouldThrowExceptionIfEntityWithIdDoesNotExist() { + var exception = assertThrows(ResponseStatusException.class, + () -> persistenceBase.findById(NON_EXISTING_USER_ID)); + + assertEquals(NOT_FOUND, exception.getStatusCode()); + assertErrorKey("MODEL_WITH_ID_NOT_FOUND", exception); + } + + @DisplayName("findById() should throw exception if id is null") + @Test + void findByIdShouldThrowExceptionIfIdIsNull() { + var exception = assertThrows(ResponseStatusException.class, () -> persistenceBase.findById(null)); + + assertEquals(BAD_REQUEST, exception.getStatusCode()); + assertErrorKey("ATTRIBUTE_NULL", exception); + } + + @DisplayName("findAll() should return all entities as list") + @Test + void findAllShouldReturnAllEntitiesAsList() throws ResponseStatusException { + var userList = persistenceBase.findAll(); + + assertThat(userList.size()).isGreaterThanOrEqualTo(7); + } + + @DisplayName("save() should add new entity") + @Test + void saveShouldAddNewEntity() throws ResponseStatusException { + createdUser = persistenceBase.save(USER_WITHOUT_CONSTRAINTS); + + assertNotNull(createdUser); + assertUser("Hans", "Muster", "hans.muster@puzzle.ch", createdUser); + } + + @DisplayName("save() should throw exception in the case of optimistic locking failure") + @Test + void saveShouldThrowExceptionInTheCaseOfOptimisticLockingFailure() throws ResponseStatusException { + // arrange + var testRepository = mock(UserRepository.class); + when(testRepository.save(any())).thenThrow(OptimisticLockingFailureException.class); + + var persistenceBaseForTest = new PersistenceBase<>(testRepository) { + @Override + public String getModelName() { + return "for_test"; + } + }; + + // act + assert + var exception = assertThrows(ResponseStatusException.class, () -> persistenceBaseForTest.save(createdUser)); + + // assert + assertEquals(UNPROCESSABLE_ENTITY, exception.getStatusCode()); + assertErrorKey("DATA_HAS_BEEN_UPDATED", exception); + } + + @DisplayName("save() existing entity with different data should update existing entity") + @Test + void saveExistingEntityWithDifferentDataShouldUpdateExistingEntity() throws ResponseStatusException { + // arrange + createdUser = persistenceBase.save(USER_WITHOUT_CONSTRAINTS); + var createdUserId = createdUser.getId(); + var foundUser = persistenceBase.findById(createdUserId); + + // pro-condition + assertEquals("Hans", createdUser.getFirstname()); + + // act + foundUser.setFirstname("Pekka"); + persistenceBase.save(foundUser); + foundUser = persistenceBase.findById(createdUserId); + + // assert + assertEquals(createdUserId, foundUser.getId()); + assertEquals("Pekka", foundUser.getFirstname()); + } + + @DisplayName("deleteById() should delete entity") + @Test + void deleteByIdShouldDeleteEntity() throws ResponseStatusException { + // arrange + createdUser = persistenceBase.save(USER_WITHOUT_CONSTRAINTS); + var createdUserId = createdUser.getId(); + assertNotNull(persistenceBase.findById(createdUserId)); + + // act + persistenceBase.deleteById(createdUserId); + + // assert + assertEntityNotFound(createdUserId); + } + + private static void assertUser(String expectedFirstName, String expectedLastName, String expectedEmail, + User currentUser) { + assertEquals(expectedFirstName, currentUser.getFirstname()); + assertEquals(expectedLastName, currentUser.getLastname()); + assertEquals(expectedEmail, currentUser.getEmail()); + } + + private void assertErrorKey(String errorKey, ResponseStatusException exception) { + var errorKeys = getAllErrorKeys(List.of(new ErrorDto(errorKey, List.of("User")))); + assertTrue(errorKeys.contains(exception.getReason())); + } + + private void assertEntityNotFound(long entityId) { + var exception = assertThrows(ResponseStatusException.class, () -> persistenceBase.findById(entityId)); + assertEquals(NOT_FOUND, exception.getStatusCode()); + assertErrorKey("MODEL_WITH_ID_NOT_FOUND", exception); + } +} \ No newline at end of file diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/QuarterPersistenceServiceIT.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/QuarterPersistenceServiceIT.java index 23a1ce5faf..1eee877cc1 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/persistence/QuarterPersistenceServiceIT.java +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/QuarterPersistenceServiceIT.java @@ -1,7 +1,5 @@ package ch.puzzle.okr.service.persistence; -import ch.puzzle.okr.dto.ErrorDto; -import ch.puzzle.okr.exception.OkrResponseStatusException; import ch.puzzle.okr.models.Quarter; import ch.puzzle.okr.multitenancy.TenantContext; import ch.puzzle.okr.test.SpringIntegrationTest; @@ -16,12 +14,10 @@ import java.time.LocalDate; import java.util.List; +import static ch.puzzle.okr.Constants.QUARTER; import static ch.puzzle.okr.test.TestConstants.GJ_FOR_TESTS_QUARTER_ID; import static ch.puzzle.okr.test.TestConstants.GJ_FOR_TEST_QUARTER_LABEL; -import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.http.HttpStatus.BAD_REQUEST; -import static org.springframework.http.HttpStatus.NOT_FOUND; @SpringIntegrationTest class QuarterPersistenceServiceIT { @@ -39,40 +35,6 @@ void tearDown() { TenantContext.setCurrentTenant(null); } - @Test - void shouldReturnSingleQuarterWhenFindingByValidId() { - Quarter returnedQuarter = quarterPersistenceService.findById(GJ_FOR_TESTS_QUARTER_ID); - - assertEquals(GJ_FOR_TESTS_QUARTER_ID, returnedQuarter.getId()); - assertEquals(GJ_FOR_TEST_QUARTER_LABEL, returnedQuarter.getLabel()); - assertEquals(LocalDate.of(2000, 7, 1), returnedQuarter.getStartDate()); - assertEquals(LocalDate.of(2000, 9, 30), returnedQuarter.getEndDate()); - } - - @Test - void shouldThrowExceptionWhenFindingQuarterNotFound() { - OkrResponseStatusException exception = assertThrows(OkrResponseStatusException.class, - () -> quarterPersistenceService.findById(321L)); - - List expectedErrors = List.of(new ErrorDto("MODEL_WITH_ID_NOT_FOUND", List.of("Quarter", "321"))); - - assertEquals(NOT_FOUND, exception.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(exception.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(exception.getReason())); - } - - @Test - void shouldThrowExceptionWhenFindingQuarterWithIdNull() { - OkrResponseStatusException exception = assertThrows(OkrResponseStatusException.class, - () -> quarterPersistenceService.findById(null)); - - List expectedErrors = List.of(new ErrorDto("ATTRIBUTE_NULL", List.of("ID", "Quarter"))); - - assertEquals(BAD_REQUEST, exception.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(exception.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(exception.getReason())); - } - @DisplayName("getMostCurrentQuarters() should return current quarter and future quarter and GJForTests quarter") @Test void getMostCurrentQuartersShouldReturnCurrentQuarterAndFutureQuarterAndGJForTestsQuarter() { @@ -141,4 +103,10 @@ void findByLabelShouldReturnNullWhenLabelIsNull() { // assert assertNull(returnedQuarter); } + + @DisplayName("getModelName() should return Quarter") + @Test + void getModelNameShouldReturnQuarter() { + assertEquals(QUARTER, quarterPersistenceService.getModelName()); + } } diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/TeamPersistenceServiceIT.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/TeamPersistenceServiceIT.java index f71c9daa79..f37812cb21 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/persistence/TeamPersistenceServiceIT.java +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/TeamPersistenceServiceIT.java @@ -1,29 +1,22 @@ package ch.puzzle.okr.service.persistence; -import ch.puzzle.okr.test.TestHelper; -import ch.puzzle.okr.dto.ErrorDto; -import ch.puzzle.okr.exception.OkrResponseStatusException; import ch.puzzle.okr.models.Team; import ch.puzzle.okr.multitenancy.TenantContext; import ch.puzzle.okr.test.SpringIntegrationTest; -import org.junit.jupiter.api.AfterEach; +import ch.puzzle.okr.test.TestHelper; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.web.server.ResponseStatusException; import java.util.List; -import static ch.puzzle.okr.test.TestConstants.TEAM_PUZZLE; -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.http.HttpStatus.*; +import static ch.puzzle.okr.Constants.TEAM; +import static org.junit.jupiter.api.Assertions.assertEquals; @SpringIntegrationTest class TeamPersistenceServiceIT { - private static final String NEW_TEAM = "New Team"; - private Team createdTeam; @Autowired private TeamPersistenceService teamPersistenceService; @@ -32,112 +25,20 @@ void setUp() { TenantContext.setCurrentTenant(TestHelper.SCHEMA_PITC); } - @AfterEach - void tearDown() { - try { - if (createdTeam != null) { - teamPersistenceService.findById(createdTeam.getId()); - teamPersistenceService.deleteById(createdTeam.getId()); - } - } catch (ResponseStatusException ex) { - // created team already deleted - } finally { - createdTeam = null; - } - TenantContext.setCurrentTenant(null); - } - - @Test - void getTeamByIdShouldReturnTeam() throws ResponseStatusException { - Team team = teamPersistenceService.findById(5L); - - assertEquals(5L, team.getId()); - assertEquals(TEAM_PUZZLE, team.getName()); - } - - @Test - void getTeamByIdShouldThrowExceptionWhenTeamNotFound() { - OkrResponseStatusException exception = assertThrows(OkrResponseStatusException.class, - () -> teamPersistenceService.findById(321L)); - - List expectedErrors = List.of(new ErrorDto("MODEL_WITH_ID_NOT_FOUND", List.of("Team", "321"))); - - assertEquals(NOT_FOUND, exception.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(exception.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(exception.getReason())); - } - - @Test - void getTeamByIdShouldThrowExceptionWhenTeamIdIsNull() { - OkrResponseStatusException exception = assertThrows(OkrResponseStatusException.class, - () -> teamPersistenceService.findById(null)); - - List expectedErrors = List.of(new ErrorDto("ATTRIBUTE_NULL", List.of("ID", "Team"))); - - assertEquals(BAD_REQUEST, exception.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(exception.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(exception.getReason())); - } - - @Test - void shouldSaveANewTeam() { - Team team = Team.Builder.builder().withName("TestTeam").build(); - - createdTeam = teamPersistenceService.save(team); - assertNotNull(createdTeam.getId()); - assertEquals("TestTeam", createdTeam.getName()); - } - + // uses data from V100_0_0__TestData.sql + @DisplayName("findTeamsByName() should return teams with matching name") @Test - void shouldUpdateTeamProperly() { - Team team = Team.Builder.builder().withName(NEW_TEAM).build(); - createdTeam = teamPersistenceService.save(team); - createdTeam.setName("Updated Team"); - - Team returnedTeam = teamPersistenceService.save(createdTeam); - - assertEquals(createdTeam.getId(), returnedTeam.getId()); - assertEquals("Updated Team", returnedTeam.getName()); - } - - @Test - void updateTeamShouldThrowExceptionWhenAlreadyUpdated() { - Team team = Team.Builder.builder().withVersion(1).withName(NEW_TEAM).build(); - createdTeam = teamPersistenceService.save(team); - Team changedTeam = Team.Builder.builder().withId(createdTeam.getId()).withVersion(0).withName("Changed Team") - .build(); - - OkrResponseStatusException exception = assertThrows(OkrResponseStatusException.class, - () -> teamPersistenceService.save(changedTeam)); - List expectedErrors = List.of(new ErrorDto("DATA_HAS_BEEN_UPDATED", List.of("Team"))); - - assertEquals(UNPROCESSABLE_ENTITY, exception.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(exception.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(exception.getReason())); - } - - @Test - void shouldDeleteTeam() { - Team team = Team.Builder.builder().withName(NEW_TEAM).build(); - createdTeam = teamPersistenceService.save(team); - teamPersistenceService.deleteById(createdTeam.getId()); - - OkrResponseStatusException exception = assertThrows(OkrResponseStatusException.class, - () -> teamPersistenceService.findById(createdTeam.getId())); - - List expectedErrors = List - .of(ErrorDto.of("MODEL_WITH_ID_NOT_FOUND", List.of("Team", createdTeam.getId()))); + void findTeamsByNameShouldReturnTeamsWithMatchingName() { + List teams = teamPersistenceService.findTeamsByName("LoremIpsum"); - assertEquals(NOT_FOUND, exception.getStatusCode()); - assertThat(expectedErrors).hasSameElementsAs(exception.getErrors()); - assertTrue(TestHelper.getAllErrorKeys(expectedErrors).contains(exception.getReason())); + assertEquals(1, teams.size()); + assertEquals(6, teams.get(0).getId()); + assertEquals("LoremIpsum", teams.get(0).getName()); } + @DisplayName("getModelName() should return Team") @Test - void shouldFindTeamsByName() { - Team team = Team.Builder.builder().withName(NEW_TEAM).build(); - createdTeam = teamPersistenceService.save(team); - List teams = teamPersistenceService.findTeamsByName(NEW_TEAM); - assertThat(teams).contains(createdTeam); + void getModelNameShouldReturnTeam() { + assertEquals(TEAM, teamPersistenceService.getModelName()); } } \ No newline at end of file diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/UserPersistenceServiceIT.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/UserPersistenceServiceIT.java index ca1dc10624..0104060b48 100644 --- a/backend/src/test/java/ch/puzzle/okr/service/persistence/UserPersistenceServiceIT.java +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/UserPersistenceServiceIT.java @@ -3,24 +3,23 @@ import ch.puzzle.okr.models.User; import ch.puzzle.okr.multitenancy.TenantContext; import ch.puzzle.okr.test.SpringIntegrationTest; -import org.assertj.core.api.Assertions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.Optional; +import static ch.puzzle.okr.Constants.USER; +import static ch.puzzle.okr.util.CollectionUtils.iterableToList; import static org.junit.jupiter.api.Assertions.*; @SpringIntegrationTest class UserPersistenceServiceIT { - private static final String EMAIL_ALICE = "wunderland@puzzle.ch"; - - User createdUser; + private User createdUser; @Autowired private UserPersistenceService userPersistenceService; @@ -39,63 +38,144 @@ void tearDown() { TenantContext.setCurrentTenant(null); } + @DisplayName("save() should save user with empty user team list") @Test - void shouldReturnAllUsersCorrect() throws ResponseStatusException { - List userList = userPersistenceService.findAll(); + void saveShouldSaveUserWithEmptyUserTeamList() { + // arrange + var newUser = User.Builder.builder() // + .withFirstname("Hans") // + .withLastname("Muster") // + .withEmail("muster@puzzle.ch") // + .withUserTeamList(List.of()).build(); + + // act + createdUser = userPersistenceService.save(newUser); + + // assert + assertNotNull(createdUser.getId()); + assertUser("Hans", "Muster", "muster@puzzle.ch", createdUser); + } - Assertions.assertThat(userList.size()).isGreaterThanOrEqualTo(7); + @DisplayName("save() should save user with null value for user team list") + @Test + void saveShouldSaveUserWithNullUserTeamList() { + // arrange + var newUser = User.Builder.builder() // + .withFirstname("Hans") // + .withLastname("Muster") // + .withEmail("muster@puzzle.ch") // + .withUserTeamList(null).build(); + + // act + createdUser = userPersistenceService.save(newUser); + + // assert + assertNotNull(createdUser.getId()); + assertUser("Hans", "Muster", "muster@puzzle.ch", createdUser); } + @DisplayName("saveAll() should save all users in the input list") @Test - void shouldReturnSingleUserWhenFindingOwnerByValidId() { - User returnedUser = userPersistenceService.findById(1L); + void saveAllShouldSaveAllUsersInTheInputList() { + // arrange + var newUser = User.Builder.builder() // + .withFirstname("Hans") // + .withLastname("Muster") // + .withEmail("muster@puzzle.ch") // + .build(); + + // act + var createdUsers = iterableToList(userPersistenceService.saveAll(List.of(newUser))); + + // assert + assertEquals(1, createdUsers.size()); + createdUser = createdUsers.get(0); - assertEquals(1L, returnedUser.getId()); - assertEquals("Paco", returnedUser.getFirstname()); - assertEquals("Eggimann", returnedUser.getLastname()); - assertEquals("peggimann@puzzle.ch", returnedUser.getEmail()); + assertNotNull(createdUser.getId()); + assertUser("Hans", "Muster", "muster@puzzle.ch", createdUser); } + @DisplayName("getOrCreateUser() should return single user when user found") @Test - void shouldThrowExceptionWhenFindingOwnerNotFound() { - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> userPersistenceService.findById(321L)); + void getOrCreateUserShouldReturnSingleUserWhenUserFound() { + // arrange + var existingUser = User.Builder.builder().withEmail("wunderland@puzzle.ch").build(); - assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode()); - assertEquals("MODEL_WITH_ID_NOT_FOUND", exception.getReason()); + // act + var returnedUser = userPersistenceService.getOrCreateUser(existingUser); + + // assert + assertUser(11L, "Alice", "Wunderland", "wunderland@puzzle.ch", returnedUser); } + @DisplayName("getOrCreateUser() should return saved user when user not found") @Test - void shouldThrowExceptionWhenFindingOwnerWithNullId() { - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> userPersistenceService.findById(null)); + void getOrCreateUserShouldReturnSavedUserWhenUserNotFound() { + // arrange + var newUser = User.Builder.builder() // + .withId(null) // + .withFirstname("firstname") // + .withLastname("lastname") // + .withEmail("lastname@puzzle.ch") // + .build(); + + // act + createdUser = userPersistenceService.getOrCreateUser(newUser); - assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); - assertEquals("ATTRIBUTE_NULL", exception.getReason()); + // assert + assertNotNull(createdUser.getId()); + assertUser("firstname", "lastname", "lastname@puzzle.ch", createdUser); } + // uses data from V100_0_0__TestData.sql + @DisplayName("findByEmail() should return user if email is found") @Test - void getOrCreateUserShouldReturnSingleUserWhenUserFound() { - User existingUser = User.Builder.builder().withEmail(EMAIL_ALICE).build(); + void findByEmailShouldReturnUserIfEmailIsFound() { + Optional user = userPersistenceService.findByEmail("gl@gl.com"); - User returnedUser = userPersistenceService.getOrCreateUser(existingUser); + assertTrue(user.isPresent()); + assertEquals("Jaya", user.get().getFirstname()); + assertEquals("Norris", user.get().getLastname()); + } - assertEquals(11L, returnedUser.getId()); - assertEquals("Alice", returnedUser.getFirstname()); - assertEquals("Wunderland", returnedUser.getLastname()); - assertEquals("wunderland@puzzle.ch", returnedUser.getEmail()); + @DisplayName("findByEmail() should return empty optional if email is not found") + @Test + void findByEmailShouldReturnEmptyOptionalIfEmailIsNotFound() { + assertTrue(userPersistenceService.findByEmail("not_valid@gl.com").isEmpty()); } + @DisplayName("findByEmail() should return empty optional if email is null") @Test - void getOrCreateUserShouldReturnSavedUserWhenUserNotFound() { - User newUser = User.Builder.builder().withId(null).withFirstname("firstname").withLastname("lastname") - .withEmail("lastname@puzzle.ch").build(); + void findByEmailShouldReturnEmptyOptionalIfEmailIsNull() { + assertTrue(userPersistenceService.findByEmail(null).isEmpty()); + } - createdUser = userPersistenceService.getOrCreateUser(newUser); + // uses data from V100_0_0__TestData.sql + @DisplayName("findAllOkrChampions() should return all okr champions") + @Test + void findAllOkrChampionsShouldReturnAllOkrChampions() { + // act + var allOkrChampions = userPersistenceService.findAllOkrChampions(); - assertNotNull(createdUser.getId()); - assertEquals("firstname", createdUser.getFirstname()); - assertEquals("lastname", createdUser.getLastname()); - assertEquals("lastname@puzzle.ch", createdUser.getEmail()); + // assert + assertEquals(1, allOkrChampions.size()); + assertUser(61L, "Jaya", "Norris", "gl@gl.com", allOkrChampions.get(0)); + } + + @DisplayName("getModelName() should return user") + @Test + void getModelNameShouldReturnUser() { + assertEquals(USER, userPersistenceService.getModelName()); + } + + private void assertUser(Long id, String firstName, String lastName, String email, User currentUser) { + assertEquals(id, currentUser.getId()); + assertUser(firstName, lastName, email, currentUser); + } + + private void assertUser(String firstName, String lastName, String email, User currentUser) { + assertEquals(firstName, currentUser.getFirstname()); + assertEquals(lastName, currentUser.getLastname()); + assertEquals(email, currentUser.getEmail()); } } \ No newline at end of file diff --git a/backend/src/test/java/ch/puzzle/okr/service/persistence/UserTeamPersistenceServiceIT.java b/backend/src/test/java/ch/puzzle/okr/service/persistence/UserTeamPersistenceServiceIT.java new file mode 100644 index 0000000000..cc2dfe71b8 --- /dev/null +++ b/backend/src/test/java/ch/puzzle/okr/service/persistence/UserTeamPersistenceServiceIT.java @@ -0,0 +1,98 @@ +package ch.puzzle.okr.service.persistence; + +import ch.puzzle.okr.multitenancy.TenantContext; +import ch.puzzle.okr.test.SpringIntegrationTest; +import ch.puzzle.okr.test.TestHelper; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +// uses test date from V100_0_0__TestData.sql +@SpringIntegrationTest +public class UserTeamPersistenceServiceIT { + private static final Long ID_OF_USER_ALICE = 11L; // user Alice is only in team Lorem + private static final Long ID_OF_TEAM_LOREM = 6L; // team Lorem has 3 users + + private static final Long ID_OF_USER_BOB = 21L; // user Bob is only in team Cube + private static final Long ID_OF_TEAM_CUBE = 8L; // team Cube has 2 users + + @Autowired + private UserTeamPersistenceService userTeamPersistenceService; + + @Autowired + private UserPersistenceService userPersistenceService; + + @Autowired + private TeamPersistenceService teamPersistenceService; + + static { + TenantContext.setCurrentTenant(TestHelper.SCHEMA_PITC); + } + + @DisplayName("delete() should remove single user from team") + @Test + @Transactional + void deleteShouldRemoveSingleUserFromTeam() { + // arrange + var user = userPersistenceService.findById(ID_OF_USER_ALICE); + var team = teamPersistenceService.findById(ID_OF_TEAM_LOREM); + + // preconditions: user Alice is in team Lorem and team Lorem has 3 users + assertUserIsInTeam(ID_OF_USER_ALICE, ID_OF_TEAM_LOREM, 3); + + // arrange + var userTeamToRemove = user.getUserTeamList().get(0); // Alice is only in Team Lorem + + // act + user.getUserTeamList().remove(userTeamToRemove); + team.getUserTeamList().remove(userTeamToRemove); + userTeamPersistenceService.delete(userTeamToRemove); + + // assert: user Alice is no longer in team Lorem and team Lorem has 2 users + assertUserIsRemovedFromTeam(ID_OF_USER_ALICE, ID_OF_TEAM_LOREM, 2); + } + + @DisplayName("deleteAll() should remove list of users from team") + @Test + @Transactional + void deleteAllShouldRemoveListOfUsersFromTeam() { + // arrange + var user = userPersistenceService.findById(ID_OF_USER_BOB); + var team = teamPersistenceService.findById(ID_OF_TEAM_CUBE); + + // preconditions: user Bob is in a team Cube and team Cube has 2 users + assertUserIsInTeam(ID_OF_USER_BOB, ID_OF_TEAM_CUBE, 2); + + // arrange + var userTeamToRemove = user.getUserTeamList().get(0); // Bos is only in Team Cube + + // act + user.getUserTeamList().remove(userTeamToRemove); + team.getUserTeamList().remove(userTeamToRemove); + userTeamPersistenceService.deleteAll(List.of(userTeamToRemove)); + + // assert: user Bob is no longer in team Cube and team Cube has 1 user + assertUserIsRemovedFromTeam(ID_OF_USER_BOB, ID_OF_TEAM_CUBE, 1); + } + + private void assertUserIsInTeam(Long userId, Long teamId, int expectedUsersInTeam) { + var user = userPersistenceService.findById(userId); + Assertions.assertEquals(1, user.getUserTeamList().size()); + + var team = this.teamPersistenceService.findById(teamId); + Assertions.assertEquals(expectedUsersInTeam, team.getUserTeamList().size()); + } + + private void assertUserIsRemovedFromTeam(Long userId, Long teamId, int expectedUsersInTeam) { + var reloadedUser = userPersistenceService.findById(userId); + Assertions.assertEquals(0, reloadedUser.getUserTeamList().size()); + + var reloadedTeam = this.teamPersistenceService.findById(teamId); + Assertions.assertEquals(expectedUsersInTeam, reloadedTeam.getUserTeamList().size()); + } + +} diff --git a/backend/src/test/java/ch/puzzle/okr/test/TestHelper.java b/backend/src/test/java/ch/puzzle/okr/test/TestHelper.java index 75d53c1db4..86bb30b3f7 100644 --- a/backend/src/test/java/ch/puzzle/okr/test/TestHelper.java +++ b/backend/src/test/java/ch/puzzle/okr/test/TestHelper.java @@ -56,19 +56,22 @@ public static UserTeam defaultUserTeam(Long id, User user) { } public static AuthorizationUser defaultAuthorizationUser() { - return mockAuthorizationUser(1L, FIRSTNAME, LASTNAME, EMAIL); - } - - public static AuthorizationUser userWithoutWriteAllRole() { - return mockAuthorizationUser(1L, FIRSTNAME, LASTNAME, EMAIL); + return mockAuthorizationUser(1L, FIRSTNAME, LASTNAME, EMAIL, false); } public static AuthorizationUser mockAuthorizationUser(User user) { - return mockAuthorizationUser(user.getId(), user.getFirstname(), user.getLastname(), user.getEmail()); + return mockAuthorizationUser(user.getId(), user.getFirstname(), user.getLastname(), user.getEmail(), + user.isOkrChampion()); } - public static AuthorizationUser mockAuthorizationUser(Long id, String firstname, String lastname, String email) { - User user = User.Builder.builder().withId(id).withFirstname(firstname).withLastname(lastname).withEmail(email) + public static AuthorizationUser mockAuthorizationUser(Long id, String firstname, String lastname, String email, + boolean isOkrChampion) { + User user = User.Builder.builder() // + .withId(id) // + .withFirstname(firstname) // + .withLastname(lastname) // + .withEmail(email) // + .withOkrChampion(isOkrChampion) // .build(); user.setUserTeamList(List.of(defaultUserTeam(1L, user))); return new AuthorizationUser(user); diff --git a/docker/dev-with-prod/docker-compose.yml b/docker/dev-with-prod/docker-compose.yml new file mode 100644 index 0000000000..0fbb4064c0 --- /dev/null +++ b/docker/dev-with-prod/docker-compose.yml @@ -0,0 +1,61 @@ +include: + - ../docker-compose.yml +services: + spring: + tty: true + container_name: spring + build: + context: . + dockerfile: local-prod.Dockerfile + restart: always + environment: + SPRING_PROFILES_ACTIVE: dev + volumes: + - ../../../okr/backend/target:/app-root/backend + network_mode: "host" + depends_on: + maven-init: + condition: service_completed_successfully + + maven: + tty: true + container_name: maven + restart: on-failure + image: maven:3.9.9-amazoncorretto-21 + command: mvn fizzed-watcher:run + working_dir: /app-root/ + volumes: + - ../../../okr:/app-root + - ~/.m2/repository:/root/.m2/repository + depends_on: + maven-init: + condition: service_completed_successfully + + maven-init: + tty: true + container_name: maven-init + image: maven:3.9.9-amazoncorretto-21 + command: mvn -B clean package -P build-for-docker,debug,no-formatter + working_dir: /app-root/ + volumes: + - ../../../okr:/app-root + - ~/.m2/repository:/root/.m2/repository + depends_on: + angular: + condition: service_healthy + + angular: + container_name: angular + image: node:22 + tty: true + restart: on-failure + volumes: + - ../../../okr:/opt + command: [ "/bin/bash", "-c", "cd /opt/frontend && rm -rf dist && npm ci && npm run watch:prod" ] + healthcheck: + test: bash -c "[ -f /opt/frontend/dist/frontend/index.html ]" + interval: 10s + retries: 999 + start_period: 30s + timeout: 10s + diff --git a/docker/dev-with-prod/local-prod.Dockerfile b/docker/dev-with-prod/local-prod.Dockerfile new file mode 100644 index 0000000000..a9bfc82e0f --- /dev/null +++ b/docker/dev-with-prod/local-prod.Dockerfile @@ -0,0 +1,12 @@ +FROM alpine:3.20 + +USER root + +RUN apk update && apk add --upgrade curl && apk --no-cache add openjdk21 inotify-tools + +RUN adduser --home /app-root --uid 1001 --disabled-password okr +USER 1001 + +WORKDIR app-root/backend + +ENTRYPOINT ["/bin/sh", "-c", "export BACKEND_VERSION=$(find . -type f -name 'backend-*.jar' -print -quit | sed -n 's/.*backend-\\(.*\\)\\.jar/\\1/p'); if ! unzip -p backend-${BACKEND_VERSION}.jar META-INF/MANIFEST.MF | grep -q 'Main-Class:'; then echo 'Error: no main manifest attribute, exiting.'; exit 1; fi; java -Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=*:5005 -jar backend-${BACKEND_VERSION}.jar & pid=$!; while true; do inotifywait -e modify backend-${BACKEND_VERSION}.jar; exit 1; done"] \ No newline at end of file diff --git a/docker/local-prod/docker-compose.yml b/docker/local-prod/docker-compose.yml deleted file mode 100644 index 0bee2c0f2d..0000000000 --- a/docker/local-prod/docker-compose.yml +++ /dev/null @@ -1,35 +0,0 @@ -include: - - ../docker-compose.yml -services: - spring: - container_name: spring - build: - context: . - dockerfile: local-prod.Dockerfile - restart: always - ports: - - 8080:8080 - environment: - SPRING_PROFILES_ACTIVE: staging - LOGGING_LEVEL_ORG_SPRINGFRAMEWORK: debug - volumes: - - ../../../okr/backend/target:/app-root/backend - - maven: - container_name: maven - image: maven:3.9.9-amazoncorretto-21 - command: sh -c "mvn fizzed-watcher:run" - working_dir: /app-root/ - volumes: - - ../../../okr:/app-root/ - - ~/.m2/repository:/root/.m2/repository - - angular: - container_name: angular - image: node:22 - user: "${UID:-1000}:${GID:-1000}" - volumes: - - ../../../okr:/opt - - /etc/passwd:/etc/passwd:ro - - /etc/group:/etc/group:ro - command: [ "/bin/bash", "-c", "cd /opt/frontend && npm ci && npm run watch:prod" ] diff --git a/docker/local-prod/local-prod.Dockerfile b/docker/local-prod/local-prod.Dockerfile deleted file mode 100644 index 9077d161ac..0000000000 --- a/docker/local-prod/local-prod.Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM alpine:3.20 - -USER root - -RUN apk update && apk add --upgrade curl && apk --no-cache add openjdk17 - -RUN adduser --home /app-root --uid 1001 --disabled-password okr -USER 1001 - -WORKDIR app-root/backend - -ENTRYPOINT ["/bin/sh", "-c", "export BACKEND_VERSION=$(find . -type f -name 'backend-*.jar' -print -quit | sed -n 's/.*backend-\\(.*\\)\\.jar/\\1/p'); java -jar backend-${BACKEND_VERSION}.jar"] \ No newline at end of file diff --git a/frontend/cypress/e2e/checkIn.cy.ts b/frontend/cypress/e2e/checkIn.cy.ts index 45728dedc0..6024396ae2 100644 --- a/frontend/cypress/e2e/checkIn.cy.ts +++ b/frontend/cypress/e2e/checkIn.cy.ts @@ -131,7 +131,8 @@ describe('OKR Check-in e2e tests', () => { cy.contains('Letztes Check-in (' + getCurrentDate() + ')'); }); - it('Should generate checkin list', () => { + // TODO: Re-enable tests in ticket #1014 https://github.com/puzzle/okr/issues/1014 + xit('Should generate checkin list', () => { cy.getByTestId('objective').first().getByTestId('add-keyResult').first().click(); cy.getByTestId('submit').should('be.disabled'); @@ -163,12 +164,12 @@ describe('OKR Check-in e2e tests', () => { cy.contains(getCurrentDate()); cy.contains('Wert: 30%'); cy.contains('Wert: 50%'); - cy.contains('Confidence: 5 / 10'); - cy.contains('Confidence: 6 / 10'); - cy.contains('Veränderungen: We bought a new house'); - cy.contains('Veränderungen: This was a good idea'); - cy.contains('Massnahmen: We have to buy more PCs'); - cy.contains('Massnahmen: Will be difficult'); + checkForAttribute('Confidence:', '5 / 10'); + checkForAttribute('Confidence:', '6 / 10'); + checkForAttribute('Veränderungen:', 'We bought a new house'); + checkForAttribute('Veränderungen:', 'This was a good idea'); + checkForAttribute('Massnahmen:', 'We have to buy more PCs'); + checkForAttribute('Massnahmen:', 'Will be difficult'); cy.contains('Schliessen'); }); @@ -193,15 +194,16 @@ describe('OKR Check-in e2e tests', () => { cy.getByTestId('add-check-in').first().click(); cy.fillOutCheckInMetric(30, 5, 'Here we are', 'A cat would be great'); - cy.contains('Aktuell: CHF 30.-'); + cy.contains('Aktuell: 30 CHF'); cy.getByTestId('show-all-checkins').click(); cy.wait(500); cy.contains('Check-in History'); + cy.contains('Wert: 30 CHF'); cy.getByTestId('edit-check-in').first().click(); cy.contains('Here we edit a metric checkin'); - cy.contains('CHF 30.-'); - cy.contains('Confidence um Target Zone (CHF 213.-) zu erreichen'); + cy.contains('30 CHF'); + cy.contains('Confidence um Target Zone (213 CHF) zu erreichen'); cy.contains('5/10'); cy.getByTestId('check-in-metric-value').click().clear().type('200'); cy.getByTestId('confidence-slider').realMouseDown(); @@ -211,10 +213,70 @@ describe('OKR Check-in e2e tests', () => { cy.getByTestId('submit-check-in').click(); cy.wait(200); - cy.contains('CHF 200.-'); + cy.contains('200 CHF'); cy.contains('We bought a new sheep'); }); + it('Should generate right labels in checkin history list', () => { + cy.getByTestId('objective').first().getByTestId('add-keyResult').first().click(); + cy.getByTestId('submit').should('be.disabled'); + cy.fillOutKeyResult( + 'A new KeyResult for checking checkin list', + 'EUR', + '10', + '300', + null, + null, + null, + null, + 'This is my description', + ); + + cy.getByTestId('submit').click(); + + cy.getByTestId('keyresult').contains('A new KeyResult for checking checkin list').click(); + + cy.getByTestId('add-check-in').first().click(); + cy.fillOutCheckInMetric(30, 5, 'Here we are', 'A cat would be great'); + cy.contains('Aktuell: 30 EUR'); + cy.getByTestId('show-all-checkins').click(); + + cy.wait(500); + cy.contains('Check-in History'); + cy.contains('Wert: 30 EUR'); + + cy.getByTestId('closeButton').click(); + // Wait this time because of the toaster message + cy.wait(2000); + cy.getByTestId('close-drawer').click(); + + cy.getByTestId('objective').first().getByTestId('add-keyResult').first().click(); + cy.fillOutKeyResult( + 'There is another kr with fte', + 'FTE', + '10', + '300', + null, + null, + null, + null, + 'This is my description', + ); + + cy.getByTestId('submit').click(); + + cy.getByTestId('keyresult').contains('There is another kr with fte').click(); + + cy.getByTestId('add-check-in').first().click(); + cy.fillOutCheckInMetric(30, 5, 'Here we are', 'A cat would be great'); + cy.contains('Aktuell: 30 FTE'); + cy.getByTestId('show-all-checkins').click(); + + cy.wait(500); + cy.contains('Check-in History'); + cy.contains('Wert: 30 FTE'); + }); + it('Edit ordinal checkin', () => { cy.getByTestId('objective').first().getByTestId('add-keyResult').first().click(); cy.getByTestId('submit').should('be.disabled'); @@ -307,12 +369,12 @@ describe('OKR Check-in e2e tests', () => { cy.intercept('**/keyresults/*').as('getKeyResultsAfterSave'); cy.getByTestId('add-check-in').first().click(); - cy.get('#old-value').should('not.exist'); + cy.getByTestId('old-checkin-value').should('not.exist'); cy.fillOutCheckInMetric(10, 0, 'changeinfo', 'initiatives'); cy.wait('@getKeyResultsAfterSave'); cy.getByTestId('add-check-in').first().click(); - cy.get('#old-value label + div').contains('10 %'); + cy.contains('Letzter Wert').siblings('div').contains('10%'); }); }); }); @@ -359,3 +421,7 @@ function getCurrentDate() { return dd_str + '.' + mm_str + '.' + yyyy; } + +function checkForAttribute(title: string, value: string) { + cy.get('mat-dialog-container').contains(value).parent().should('contain', title); +} diff --git a/frontend/cypress/e2e/duplicated-scoring.cy.ts b/frontend/cypress/e2e/duplicated-scoring.cy.ts index d1152ced59..6a87dbd243 100644 --- a/frontend/cypress/e2e/duplicated-scoring.cy.ts +++ b/frontend/cypress/e2e/duplicated-scoring.cy.ts @@ -9,7 +9,10 @@ describe('e2e test for scoring adjustment on objective duplicate', () => { }); it('Create ordinal checkin and validate value of scoring component', () => { + cy.intercept('POST', '**/keyresults').as('createKeyresult'); cy.createOrdinalKeyresult('stretch keyresult for testing', null); + cy.wait('@createKeyresult'); + cy.contains('stretch keyresult for testing'); cy.getByTestId('keyresult').get(':contains("stretch keyresult for testing")').last().click(); cy.getByTestId('add-check-in').click(); cy.getByTestId(`stretch-radio`).click(); @@ -26,7 +29,7 @@ describe('e2e test for scoring adjustment on objective duplicate', () => { cy.visit('/?quarter=3'); let scoringBlock1 = cy - .getByTestId('objective') + .get('.objective:contains("A duplicated Objective for this tool")') .first() .getByTestId('key-result') .first() diff --git a/frontend/cypress/e2e/keyresult.cy.ts b/frontend/cypress/e2e/keyresult.cy.ts index f7f9ef7b67..aaa5d3b709 100644 --- a/frontend/cypress/e2e/keyresult.cy.ts +++ b/frontend/cypress/e2e/keyresult.cy.ts @@ -294,7 +294,7 @@ describe('OKR Overview', () => { cy.getByTestId('edit-keyResult').click(); cy.getByTestId('delete-keyResult').click(); - cy.getByTestId('confirmYes').click(); + cy.getByTestId('confirm-yes').click(); cy.contains('Puzzle ITC'); cy.get('A keyresult to delete').should('not.exist'); diff --git a/frontend/cypress/e2e/objective.cy.ts b/frontend/cypress/e2e/objective.cy.ts index 94c861e59a..afca9c4c76 100644 --- a/frontend/cypress/e2e/objective.cy.ts +++ b/frontend/cypress/e2e/objective.cy.ts @@ -28,8 +28,9 @@ describe('OKR Objective e2e tests', () => { .get('.objective-menu-option') .contains('Objective veröffentlichen') .click(); - cy.getByTestId('confirmYes').click(); - + cy.contains('Objective veröffentlichen'); + cy.contains('Soll dieses Objective veröffentlicht werden?'); + cy.getByTestId('confirm-yes').click(); cy.getByTestId('objective') .filter(':contains(A objective in state draft)') .last() @@ -128,6 +129,10 @@ describe('OKR Objective e2e tests', () => { .contains('Objective wiedereröffnen') .click(); + cy.contains('Objective wiedereröffnen'); + cy.contains('Soll dieses Objective wiedereröffnet werden?'); + cy.getByTestId('confirm-yes').click(); + cy.getByTestId('objective') .filter(':contains("This objective will be reopened after")') .last() @@ -151,7 +156,9 @@ describe('OKR Objective e2e tests', () => { .click() .wait(500) .tabForward(); - cy.focused().click().wait(500); + cy.contains('Objective als Draft speichern'); + cy.contains('Soll dieses Objective als Draft gespeichert werden?'); + cy.getByTestId('confirm-yes').click(); cy.getByTestId('objective') .filter(':contains("This objective will be returned to draft state")') diff --git a/frontend/cypress/e2e/overview.cy.ts b/frontend/cypress/e2e/overview.cy.ts index 9f472d899b..a8515217f4 100644 --- a/frontend/cypress/e2e/overview.cy.ts +++ b/frontend/cypress/e2e/overview.cy.ts @@ -19,6 +19,6 @@ describe('OKR Overview', () => { it('Check font ', () => { cy.get('.team-title').first().invoke('css', 'font-family').should('eq', 'Roboto, sans-serif'); - cy.get('.team-title').first().invoke('css', 'font-weight').should('eq', '700'); + cy.get('.team-title').first().invoke('css', 'font-weight').should('eq', '600'); }); }); diff --git a/frontend/cypress/e2e/tab.cy.ts b/frontend/cypress/e2e/tab.cy.ts index 4d1cdd714b..343f1b979d 100644 --- a/frontend/cypress/e2e/tab.cy.ts +++ b/frontend/cypress/e2e/tab.cy.ts @@ -72,7 +72,7 @@ describe('Tab workflow tests', () => { } function openKeyresultDetail() { - cy.get('.objective').first().focus(); + cy.get("[src='assets/icons/ongoing-icon.svg']").parentsUntil('#objective-column').last().focus(); cy.tabForwardUntil('[data-testId="key-result"]'); cy.focused().contains('Fail'); cy.focused().contains('Commit'); @@ -124,6 +124,7 @@ describe('Tab workflow tests', () => { cy.loginAsUser(users.gl); onlyOn('chrome'); cy.tabForward(); + cy.tabForward(); }); // Header from here @@ -211,13 +212,17 @@ describe('Tab workflow tests', () => { editInputFields('Edited by Cypress too'); cy.tabForward(); cy.tabForward(); + cy.focused().contains('Speichern'); cy.realPress('Enter'); - cy.contains('Edited by Cypress'); + cy.focused().invoke('attr', 'data-testid').should('contain', 'three-dot-menu'); + cy.focused().parentsUntil('#objective-column').last().contains('Edited by Cypress'); }); it('Duplicate objective with tab', () => { openThreeDotMenu(); cy.realPress('ArrowDown'); + cy.realPress('ArrowDown'); + cy.realPress('ArrowDown'); cy.focused().contains('Objective duplizieren'); cy.realPress('Enter'); cy.wait(500); @@ -241,7 +246,6 @@ describe('Tab workflow tests', () => { it('Complete objective dialog with tab', () => { openThreeDotMenu(); cy.realPress('ArrowDown'); - cy.realPress('ArrowDown'); cy.focused().contains('Objective abschliessen'); cy.realPress('Enter'); cy.wait(500); @@ -416,7 +420,7 @@ describe('Tab workflow tests', () => { cy.tabForwardUntil('[data-testId="add-action-plan-line"]'); cy.tabBackward(); cy.realPress('Enter'); - cy.tabForwardUntil('[data-testId="confirmYes"]'); + cy.tabForwardUntil('[data-testId="confirm-yes"]'); cy.realPress('Enter'); cy.tabForward(); cy.tabForwardUntil('[data-testId="submit"]'); diff --git a/frontend/cypress/e2e/teammanagement.cy.ts b/frontend/cypress/e2e/teammanagement.cy.ts index d89d8b06e1..6f595384cd 100644 --- a/frontend/cypress/e2e/teammanagement.cy.ts +++ b/frontend/cypress/e2e/teammanagement.cy.ts @@ -5,6 +5,40 @@ describe('Team management tests', () => { const teamName = uniqueSuffix('New Team'); const nameEsha = users.bl.name; + describe('Routing to overview', () => { + beforeEach(() => { + cy.loginAsUser(users.gl); + }); + it('should navigate to overview when clicking logo', () => { + cy.getByTestId('team-management').click(); + cy.getByTestId('logo').click(); + cy.url().should('not.include', 'team-management'); + }); + it('should navigate to overview when pressing back to overview', () => { + cy.getByTestId('team-management').click(); + cy.getByTestId('routerLink-to-overview').click(); + cy.url().should('not.include', 'team-management'); + }); + it('should preserve team filter', () => { + cy.get('mat-chip:visible:contains("/BBT")').click(); + cy.get('mat-chip:visible:contains("Puzzle ITC")').click(); + checkTeamsSelected(); + cy.getByTestId('team-management').click(); + checkTeamsSelected(); + cy.getByTestId('routerLink-to-overview').click(); + checkTeamsSelected(); + cy.getByTestId('team-management').click(); + cy.getByTestId('logo').click(); + checkTeamsSelected(); + }); + + function checkTeamsSelected() { + cy.url().should('include', 'teams='); + cy.url().should('include', '6'); + cy.url().should('include', '4'); + } + }); + describe('As GL', () => { before(() => { // login as bl to ensure this user exists in database @@ -44,8 +78,8 @@ describe('Team management tests', () => { cy.getByTestId('remove-from-member-list').click(); // dialog - cy.contains(`Jaya Norris wirklich aus Team ${teamName} entfernen?`); - cy.getByTestId('cancelDialog-confirm').click(); + cy.contains(`Möchtest du Jaya Norris wirklich aus dem Team '${teamName}' entfernen?`); + cy.getByTestId('confirm-yes').click(); cy.wait('@removeUser'); @@ -60,8 +94,8 @@ describe('Team management tests', () => { cy.getByTestId('remove-from-member-list').click(); // cancel dialog - cy.contains(`Jaya Norris wirklich aus Team ${teamName} entfernen?`); - cy.getByTestId('cancelDialog-cancel').click(); + cy.contains(`Möchtest du Jaya Norris wirklich aus dem Team '${teamName}' entfernen?`); + cy.getByTestId('confirm-no').click(); cy.get('@removeUser.all').then((interceptions) => { expect(interceptions).to.have.length(0); @@ -96,14 +130,18 @@ describe('Team management tests', () => { cy.getByTestId('teamDeleteButton').click(); // cancel dialog => cancel - cy.contains(`${teamName} wirklich löschen?`); - cy.getByTestId('cancelDialog-cancel').click(); + cy.contains( + `Möchtest du das Team '${teamName}' wirklich löschen? Zugehörige Objectives werden dadurch in allen Quartalen ebenfalls gelöscht!`, + ); + cy.getByTestId('confirm-no').click(); // try again and confirm dialog cy.getByTestId('teamMoreButton').click(); cy.getByTestId('teamDeleteButton').click(); - cy.contains(`${teamName} wirklich löschen?`); - cy.getByTestId('cancelDialog-confirm').click(); + cy.contains( + `Möchtest du das Team '${teamName}' wirklich löschen? Zugehörige Objectives werden dadurch in allen Quartalen ebenfalls gelöscht!`, + ); + cy.getByTestId('confirm-yes').click(); cy.wait(['@saveTeam', '@getUsers']); @@ -149,6 +187,10 @@ describe('Team management tests', () => { const firstNameStefan = uniqueSuffix('Stefan'); cy.getByTestId('invite-member').click(); + cy.wait(1000); // wait for dialog to open + cy.tabForward(); + cy.contains('Members registrieren'); + fillOutNewUser(firstNameClaudia, 'Meier', mailUserClaudia); cy.tabForward(); cy.tabForward(); @@ -305,7 +347,7 @@ describe('Team management tests', () => { // add findus peterson cy.getByTestId('search-member-to-add').click().type('Find', { delay: 1 }); - cy.get(matOption).contains('Findus Peterson').click(); + cy.contains(matOption, 'Findus Peterson').click(); // add robin papierer cy.getByTestId('search-member-to-add').click(); @@ -336,6 +378,7 @@ describe('Team management tests', () => { return; } $row.find(`[data-testId='edit-role']`).click(); + cy.wait(500); // wait for dialog to open }) .then(() => { cy.getByTestId('select-team-role').click(); @@ -356,7 +399,7 @@ describe('Team management tests', () => { it('should remove BBT membership of findus', () => { navigateToUser('Findus Peterson'); cy.getByTestId('delete-team-member').click(); - cy.getByTestId('cancelDialog-confirm').click(); + cy.getByTestId('confirm-yes').click(); cy.get('app-member-detail').contains('/BBT').should('not.exist'); }); @@ -365,12 +408,12 @@ describe('Team management tests', () => { navigateToUser(nameEsha); cy.getByTestId('delete-team-member').eq(0).click(); - cy.getByTestId('cancelDialog-confirm').click(); + cy.getByTestId('confirm-yes').click(); cy.wait('@removeUser'); cy.getByTestId('delete-team-member').eq(0).click(); - cy.getByTestId('cancelDialog-confirm').click(); + cy.getByTestId('confirm-yes').click(); cy.get('app-member-detail').should('not.contain', '/BBT').and('not.contain', 'LoremIpsum'); }); diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 1fa1df2314..30e6b962c6 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -1,6 +1,7 @@ import { validateScoring } from './scoringSupport'; Cypress.Commands.add('loginAsUser', (user: any) => { + cy.viewport(1920, 1080); loginWithCredentials(user.username, user.password); overviewIsLoaded(); }); diff --git a/frontend/package.json b/frontend/package.json index 4c12464841..08e2415442 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,7 +3,7 @@ "version": "2.0.0", "scripts": { "ng": "ng", - "start": "ng serve ", + "start": "ng serve", "build": "ng build", "build:staging": "ng build --configuration staging", "watch": "ng build --watch --configuration development", diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 8b3716657a..a6df9fb465 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1,2 +1,6 @@ - - +

+ +
+
+ +
diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss index 97a874648e..8ba091ef8b 100644 --- a/frontend/src/app/app.component.scss +++ b/frontend/src/app/app.component.scss @@ -1,12 +1,3 @@ -.okr-label { - margin-left: 35px; -} - -.overview-item { - margin-right: 20px; - margin-left: 50px; -} - app-application-top-bar { z-index: 1001; position: relative; diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 58fa564e59..6e3150d3c1 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -166,9 +166,9 @@ export const MY_FORMATS = { MatChipsModule, CdkDropList, CdkDrag, - SharedModule, A11yModule, CdkDragHandle, + SharedModule, ], providers: [ { diff --git a/frontend/src/app/callback/callback.component.html b/frontend/src/app/callback/callback.component.html deleted file mode 100644 index 9a5f2cf345..0000000000 --- a/frontend/src/app/callback/callback.component.html +++ /dev/null @@ -1 +0,0 @@ -

You are getting forwarded!

diff --git a/frontend/src/app/callback/callback.component.ts b/frontend/src/app/callback/callback.component.ts deleted file mode 100644 index ac042fdc53..0000000000 --- a/frontend/src/app/callback/callback.component.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Component } from '@angular/core'; - -@Component({ - selector: 'app-callback', - templateUrl: './callback.component.html', - styleUrl: './callback.component.css', -}) -export class CallbackComponent {} diff --git a/frontend/src/app/components/action-plan/action-plan.component.html b/frontend/src/app/components/action-plan/action-plan.component.html index b056643283..68388cea32 100644 --- a/frontend/src/app/components/action-plan/action-plan.component.html +++ b/frontend/src/app/components/action-plan/action-plan.component.html @@ -1,11 +1,11 @@ -
- +
+
@@ -42,11 +42,11 @@
-
+
-
+
diff --git a/frontend/src/app/components/action-plan/action-plan.component.spec.ts b/frontend/src/app/components/action-plan/action-plan.component.spec.ts index 83deab4d69..2ce210d5f0 100644 --- a/frontend/src/app/components/action-plan/action-plan.component.spec.ts +++ b/frontend/src/app/components/action-plan/action-plan.component.spec.ts @@ -2,13 +2,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActionPlanComponent } from './action-plan.component'; import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatDialogRef } from '@angular/material/dialog'; import { CdkDrag, CdkDropList } from '@angular/cdk/drag-drop'; import { ActionService } from '../../services/action.service'; import { action1, action2, action3, addedAction } from '../../shared/testData'; import { BehaviorSubject, of } from 'rxjs'; import { Action } from '../../shared/types/model/Action'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { DialogService } from '../../services/dialog.service'; +import { ConfirmDialogComponent } from '../../shared/dialog/confirm-dialog/confirm-dialog.component'; const actionServiceMock = { deleteAction: jest.fn(), @@ -22,9 +24,10 @@ describe('ActionPlanComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ declarations: [ActionPlanComponent], - imports: [HttpClientTestingModule, MatDialogModule, CdkDropList, CdkDrag, TranslateModule.forRoot()], + imports: [HttpClientTestingModule, CdkDropList, CdkDrag, TranslateModule.forRoot()], providers: [ TranslateService, + DialogService, { provide: ActionService, useValue: actionServiceMock, @@ -48,7 +51,9 @@ describe('ActionPlanComponent', () => { it('should remove item from actionplan array', () => { component.control = new BehaviorSubject([action1, action2]); actionServiceMock.deleteAction.mockReturnValue(of(null)); - jest.spyOn(component.dialog, 'open').mockReturnValue({ afterClosed: () => of(true) } as MatDialogRef); + jest + .spyOn(component.dialogService, 'openConfirmDialog') + .mockReturnValue({ afterClosed: () => of(true) } as MatDialogRef); component.removeAction(0); @@ -59,7 +64,7 @@ describe('ActionPlanComponent', () => { }); it('should remove item from actionplan without opening dialog when action has no text and id', () => { - const dialogSpy = jest.spyOn(component.dialog, 'open'); + const dialogSpy = jest.spyOn(component.dialogService, 'open'); component.control = new BehaviorSubject([action3]); component.removeAction(0); @@ -70,7 +75,7 @@ describe('ActionPlanComponent', () => { }); it('should decrease index of active item when index is the same as the one of the removed item', () => { - jest.spyOn(component.dialog, 'open'); + jest.spyOn(component.dialogService, 'open'); component.control = new BehaviorSubject([action2, action3, action1]); component.activeItem = 2; diff --git a/frontend/src/app/components/action-plan/action-plan.component.ts b/frontend/src/app/components/action-plan/action-plan.component.ts index 8f0625ceeb..e08f5da787 100644 --- a/frontend/src/app/components/action-plan/action-plan.component.ts +++ b/frontend/src/app/components/action-plan/action-plan.component.ts @@ -2,11 +2,9 @@ import { Component, ElementRef, Input, QueryList, ViewChildren } from '@angular/ import { CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; import { Action } from '../../shared/types/model/Action'; import { ActionService } from '../../services/action.service'; -import { MatDialog } from '@angular/material/dialog'; -import { ConfirmDialogComponent } from '../../shared/dialog/confirm-dialog/confirm-dialog.component'; import { BehaviorSubject } from 'rxjs'; -import { isMobileDevice, trackByFn } from '../../shared/common'; -import { CONFIRM_DIALOG_WIDTH } from '../../shared/constantLibary'; +import { trackByFn } from '../../shared/common'; +import { DialogService } from '../../services/dialog.service'; @Component({ selector: 'app-action-plan', @@ -23,7 +21,7 @@ export class ActionPlanComponent { constructor( private actionService: ActionService, - public dialog: MatDialog, + public dialogService: DialogService, ) {} handleKeyDown(event: Event, currentIndex: number) { @@ -99,28 +97,8 @@ export class ActionPlanComponent { this.activeItem--; } if (actions[index].action !== '' || actions[index].id) { - const dialogConfig = isMobileDevice() - ? { - maxWidth: '100vw', - maxHeight: '100vh', - height: '100vh', - width: CONFIRM_DIALOG_WIDTH, - } - : { - width: '45em', - height: 'auto', - }; - this.dialog - .open(ConfirmDialogComponent, { - data: { - title: 'Action', - isAction: true, - }, - width: dialogConfig.width, - height: dialogConfig.height, - maxHeight: dialogConfig.maxHeight, - maxWidth: dialogConfig.maxWidth, - }) + this.dialogService + .openConfirmDialog('DELETE.ACTION') .afterClosed() .subscribe((result) => { if (result) { diff --git a/frontend/src/app/components/application-banner/application-banner.component.html b/frontend/src/app/components/application-banner/application-banner.component.html index fd59295d0d..39f0ba7b8a 100644 --- a/frontend/src/app/components/application-banner/application-banner.component.html +++ b/frontend/src/app/components/application-banner/application-banner.component.html @@ -1,6 +1,6 @@
-
+
@@ -35,7 +35,7 @@
-
+
diff --git a/frontend/src/app/components/application-banner/application-banner.component.scss b/frontend/src/app/components/application-banner/application-banner.component.scss index 0c0c6289e3..6bc8b1f220 100644 --- a/frontend/src/app/components/application-banner/application-banner.component.scss +++ b/frontend/src/app/components/application-banner/application-banner.component.scss @@ -4,6 +4,10 @@ padding-left: 1.5rem; } +p.mb-2 { + font-size: 16px; +} + .filters { flex: 1; } diff --git a/frontend/src/app/components/application-banner/application-banner.component.ts b/frontend/src/app/components/application-banner/application-banner.component.ts index cce55ad140..d9a064f348 100644 --- a/frontend/src/app/components/application-banner/application-banner.component.ts +++ b/frontend/src/app/components/application-banner/application-banner.component.ts @@ -10,7 +10,6 @@ import { import { BehaviorSubject } from 'rxjs'; import { RefreshDataService } from '../../services/refresh-data.service'; import { DEFAULT_HEADER_HEIGHT_PX, PUZZLE_TOP_BAR_HEIGHT } from '../../shared/constantLibary'; -import { isMobileDevice } from '../../shared/common'; @Component({ selector: 'app-application-banner', @@ -25,7 +24,6 @@ export class ApplicationBannerComponent implements AfterViewInit, OnDestroy { resizeObserver: ResizeObserver; bannerHeight: number = DEFAULT_HEADER_HEIGHT_PX; lastScrollPosition: number = 0; - protected readonly isMobileDevice = isMobileDevice; constructor(private refreshDataService: RefreshDataService) { this.resizeObserver = new ResizeObserver((entries: ResizeObserverEntry[]) => { diff --git a/frontend/src/app/components/application-top-bar/application-top-bar.component.html b/frontend/src/app/components/application-top-bar/application-top-bar.component.html index 0577a3193a..3b66f02ff5 100644 --- a/frontend/src/app/components/application-top-bar/application-top-bar.component.html +++ b/frontend/src/app/components/application-top-bar/application-top-bar.component.html @@ -1,24 +1,26 @@
- - okr-logo - + + okr-logo +
- -
-
-
-
- Wert: {{ getMetricKeyResult().unit | unitLabelTransformation }} - {{ +checkIn.value! | unitValueTransformation: getMetricKeyResult().unit }} - Wert: {{ checkIn.value }} +
+

Wert:

+
+ + {{ +checkIn.value! | unitValueTransformation }}{{ getMetricKeyResult().unit | unitLabelTransformation }} + + + {{ checkIn.value }} + +
+

Confidence:

+
{{ checkIn.confidence }} / 10
-
- Confidence: {{ checkIn.confidence }} / 10 + +
+

Veränderungen:

+ {{ checkIn.changeInfo }} +
+ +
+

Massnahmen:

+ {{ checkIn.initiatives }}
-
-
- Veränderungen: - {{ checkIn.changeInfo }} -
-
- Massnahmen: - {{ checkIn.initiatives }}
-
- - - - -
+ + + +
+ +
+
+ diff --git a/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.scss b/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.scss index 2d5a6334b6..c2c8998c4f 100644 --- a/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.scss +++ b/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.scss @@ -1,85 +1,4 @@ -@import "../style/variables"; - -.cancel-button { - background-color: $pz-dark-blue; - border-radius: 8px; - width: min-content; -} - -.edit-check-in { - width: 20px; - height: 20px; -} - -.edit-check-in-button { - @extend .edit-check-in; - margin-right: 5px; -} - -.check-in-box { - background-color: #f5f5f5; -} - -.label-width { - width: 19%; -} - -.value-width { - width: 81%; -} - -.dialog-content { - max-height: 28.5vh; -} - -.close-button { - margin-top: 32px; -} - -@media only screen and (max-width: 690px) { - .label-width { - width: 60%; - } -} - -@media only screen and (min-height: 600px) and (max-width: 450px) { - .dialog-content { - max-height: 500px; - } -} - -@media only screen and (min-height: 700px) and (max-width: 450px) { - .dialog-content { - max-height: 590px; - } -} - -@media only screen and (min-height: 800px) and (max-width: 450px) { - .dialog-content { - max-height: 625px; - } -} - -@media only screen and (min-height: 850px) and (max-width: 450px) { - .dialog-content { - max-height: 685px; - } -} - -@media only screen and (min-height: 875px) and (max-width: 450px) { - .dialog-content { - max-height: 715px; - } -} - -@media only screen and (min-height: 900px) and (max-width: 450px) { - .dialog-content { - max-height: 750px; - } -} - -@media only screen and (min-height: 1000px) and (max-width: 450px) { - .dialog-content { - max-height: 550px; - } +//TODO: Just Temporarily so the application look decent +h4 { + font-size: 1rem; } diff --git a/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.spec.ts b/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.spec.ts index 7fa0c1ee94..ee75865c64 100644 --- a/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.spec.ts +++ b/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.spec.ts @@ -2,9 +2,18 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CheckInHistoryDialogComponent } from './check-in-history-dialog.component'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; import { checkInMetric, checkInMetricWriteableFalse, keyResult } from '../../shared/testData'; import { By } from '@angular/platform-browser'; +import { DialogService } from '../../services/dialog.service'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MatIconModule } from '@angular/material/icon'; +import { SpinnerComponent } from '../../shared/custom/spinner/spinner.component'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { DialogTemplateCoreComponent } from '../../shared/custom/dialog-template-core/dialog-template-core.component'; +import { MatDividerModule } from '@angular/material/divider'; const checkInService = { getAllCheckInOfKeyResult: jest.fn(), @@ -16,9 +25,15 @@ describe('CheckInHistoryDialogComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ - declarations: [CheckInHistoryDialogComponent], - imports: [HttpClientTestingModule, MatDialogModule], + declarations: [CheckInHistoryDialogComponent, DialogTemplateCoreComponent, SpinnerComponent], + + imports: [TranslateModule.forRoot(), MatIconModule, MatProgressSpinner, MatDividerModule, MatDialogModule], providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), + TranslateService, + DialogService, { provide: MAT_DIALOG_DATA, useValue: { keyResult: keyResult } }, { provide: MatDialogRef, useValue: {} }, ], @@ -35,7 +50,7 @@ describe('CheckInHistoryDialogComponent', () => { expect(component).toBeTruthy(); }); - it('should not display edit check-in button if writeable is false', async () => { + it.skip('should not display edit check-in button if writeable is false', async () => { const buttons = fixture.debugElement.queryAll(By.css('button')); expect(buttons.length).toBe(1); }); diff --git a/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.ts b/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.ts index 4241ad6426..19b1866b44 100644 --- a/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.ts +++ b/frontend/src/app/components/check-in-history-dialog/check-in-history-dialog.component.ts @@ -1,13 +1,14 @@ import { Component, Inject, OnInit } from '@angular/core'; import { CheckInMin } from '../../shared/types/model/CheckInMin'; import { CheckInService } from '../../services/check-in.service'; -import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; -import { DATE_FORMAT, OKR_DIALOG_CONFIG } from '../../shared/constantLibary'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { DATE_FORMAT } from '../../shared/constantLibary'; import { KeyResult } from '../../shared/types/model/KeyResult'; import { CheckInFormComponent } from '../checkin/check-in-form/check-in-form.component'; import { Observable, of } from 'rxjs'; import { KeyResultMetric } from '../../shared/types/model/KeyResultMetric'; import { RefreshDataService } from '../../services/refresh-data.service'; +import { DialogService } from '../../services/dialog.service'; @Component({ selector: 'app-check-in-history-dialog', @@ -23,7 +24,7 @@ export class CheckInHistoryDialogComponent implements OnInit { constructor( @Inject(MAT_DIALOG_DATA) public data: any, private checkInService: CheckInService, - private dialog: MatDialog, + private dialogService: DialogService, public dialogRef: MatDialogRef, private refreshDataService: RefreshDataService, ) {} @@ -35,19 +36,15 @@ export class CheckInHistoryDialogComponent implements OnInit { } openCheckInDialogForm(checkIn: CheckInMin) { - const dialogConfig = OKR_DIALOG_CONFIG; - const dialogRef = this.dialog.open(CheckInFormComponent, { + const dialogRef = this.dialogService.open(CheckInFormComponent, { data: { keyResult: this.keyResult, checkIn: checkIn, }, - height: dialogConfig.height, - width: dialogConfig.width, - maxHeight: dialogConfig.maxHeight, - maxWidth: dialogConfig.maxWidth, }); dialogRef.afterClosed().subscribe(() => { this.loadCheckInHistory(); + this.refreshDataService.reloadKeyResultSubject.next(); this.refreshDataService.markDataRefresh(); }); } diff --git a/frontend/src/app/components/checkin/check-in-form-metric/check-in-form-metric.component.html b/frontend/src/app/components/checkin/check-in-form-metric/check-in-form-metric.component.html index 45d9ce03ec..f66dfb6ea5 100644 --- a/frontend/src/app/components/checkin/check-in-form-metric/check-in-form-metric.component.html +++ b/frontend/src/app/components/checkin/check-in-form-metric/check-in-form-metric.component.html @@ -1,28 +1,34 @@ -
-
-
- -
-
- +
+
+
+ +
+ +
+ +
+ + {{ generateUnitLabel() }} - {{ generateUnitLabel() }}
+ + {{ getErrorMessage("MUST_BE_NUMBER", "Neuer Wert") }} +
-
- -
- {{ checkIn.value }} {{ generateUnitLabel() }} +
+ +
+
+

Letzter Wert

+
+
{{ checkIn.value }}{{ generateUnitLabel() }}
- - {{ getErrorMessage("MUST_BE_NUMBER", "Neuer Wert") }} -
diff --git a/frontend/src/app/components/checkin/check-in-form-metric/check-in-form-metric.component.scss b/frontend/src/app/components/checkin/check-in-form-metric/check-in-form-metric.component.scss index 46bd29b25b..e69de29bb2 100644 --- a/frontend/src/app/components/checkin/check-in-form-metric/check-in-form-metric.component.scss +++ b/frontend/src/app/components/checkin/check-in-form-metric/check-in-form-metric.component.scss @@ -1,51 +0,0 @@ -.custom-input { - margin-top: 3px; -} - -mat-label { - color: black; -} - -.value-field { - height: 2rem; - padding: 0.437rem 0.625rem 0.375rem 0.625rem !important; -} - -.unit-label { - margin-left: 10px; -} - -.disabled-field { - background-color: #f5f5f5; - width: 218px; -} - -@media only screen and (min-width: 601px) { - .gap-between { - gap: 5rem; - } -} - -@media only screen and (min-width: 575px) and (max-width: 600px) { - #new-value { - order: 0; - } - #old-value { - order: 1; - } - .gap-between { - gap: 3rem; - } -} - -@media only screen and (max-width: 574px) { - #new-value { - order: 1; - } - #old-value { - order: 0; - } - .gap-between { - gap: 1rem; - } -} diff --git a/frontend/src/app/components/checkin/check-in-form-ordinal/check-in-form-ordinal.component.html b/frontend/src/app/components/checkin/check-in-form-ordinal/check-in-form-ordinal.component.html index 511f0222b1..1477819976 100644 --- a/frontend/src/app/components/checkin/check-in-form-ordinal/check-in-form-ordinal.component.html +++ b/frontend/src/app/components/checkin/check-in-form-ordinal/check-in-form-ordinal.component.html @@ -1,25 +1,28 @@
-
- - +
+ +
- Fail:   Commit / Target / Stretch noch nicht erreicht + Fail:  Commit / Target / Stretch noch nicht erreicht
+
-

Commit:  {{ keyResult.commitZone }}

+ Commit: {{ keyResult.commitZone }}
+
-

Target:  {{ keyResult.targetZone }}

+ Target: {{ keyResult.targetZone }}
+
-

Stretch:  {{ keyResult.stretchZone }}

+ Stretch: {{ keyResult.stretchZone }}
diff --git a/frontend/src/app/components/checkin/check-in-form-ordinal/check-in-form-ordinal.component.scss b/frontend/src/app/components/checkin/check-in-form-ordinal/check-in-form-ordinal.component.scss index 55e3fe921d..62b280fbef 100644 --- a/frontend/src/app/components/checkin/check-in-form-ordinal/check-in-form-ordinal.component.scss +++ b/frontend/src/app/components/checkin/check-in-form-ordinal/check-in-form-ordinal.component.scss @@ -17,6 +17,6 @@ mat-label { color: black; } .radio-text-container { - background: $show-element; + background: $display-element; height: auto; } diff --git a/frontend/src/app/components/checkin/check-in-form/check-in-form.component.html b/frontend/src/app/components/checkin/check-in-form/check-in-form.component.html index c821f83b3e..29b2f62070 100644 --- a/frontend/src/app/components/checkin/check-in-form/check-in-form.component.html +++ b/frontend/src/app/components/checkin/check-in-form/check-in-form.component.html @@ -1,42 +1,45 @@ -
- - -
- - -
- -
-

Key Result

-

{{ keyResult.title }}

+ + + +
+

Key Result

+ +

{{ keyResult.title }}

+
-
- - - - - {{ action.action }} + +
+

Action Plan:

+
+ + + +
+ {{ action.action }} +
- +
-
- - - - {{ getErrorMessage("MAX_VALUE", "Kommentar / Veränderung", 4096) }} - +
+ +
+ + + {{ getErrorMessage("MAX_VALUE", "Kommentar / Veränderung", 4096) }} + +
-
- - - - {{ getErrorMessage("MAX_VALUE", "Massnahmen", 4096) }} - +
+ +
+ + + {{ getErrorMessage("MAX_VALUE", "Massnahmen", 4096) }} + +
-
-
- + - - - - + +
+ + +
+
+ diff --git a/frontend/src/app/components/checkin/check-in-form/check-in-form.component.scss b/frontend/src/app/components/checkin/check-in-form/check-in-form.component.scss index 669c394785..010e8abc58 100644 --- a/frontend/src/app/components/checkin/check-in-form/check-in-form.component.scss +++ b/frontend/src/app/components/checkin/check-in-form/check-in-form.component.scss @@ -1,14 +1,3 @@ -@import "../style/variables"; - -.action-list-item { - background-color: $show-element; - height: auto; - min-height: 40px; - padding: 10px; - margin: auto; - color: black; -} - .confidence-label { margin-bottom: -7px; } @@ -16,7 +5,3 @@ app-check-in-form-ordinal { padding-top: 0.1rem; } - -.dialog-content { - max-height: 45vh; -} diff --git a/frontend/src/app/components/checkin/check-in-form/check-in-form.component.spec.ts b/frontend/src/app/components/checkin/check-in-form/check-in-form.component.spec.ts index 65f71b851e..55819aace2 100644 --- a/frontend/src/app/components/checkin/check-in-form/check-in-form.component.spec.ts +++ b/frontend/src/app/components/checkin/check-in-form/check-in-form.component.spec.ts @@ -11,7 +11,6 @@ import { keyResultMetric, keyResultOrdinal, } from '../../../shared/testData'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; import { MatIconModule } from '@angular/material/icon'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelectModule } from '@angular/material/select'; @@ -23,11 +22,16 @@ import { ParseUnitValuePipe } from '../../../shared/pipes/parse-unit-value/parse import { CheckInService } from '../../../services/check-in.service'; import { of } from 'rxjs'; import { ActionService } from '../../../services/action.service'; +// @ts-ignore import * as de from '../../../../assets/i18n/de.json'; import { TranslateTestingModule } from 'ngx-translate-testing'; -import { DialogHeaderComponent } from '../../../shared/custom/dialog-header/dialog-header.component'; import { ConfidenceComponent } from '../../confidence/confidence.component'; import { MatSliderModule } from '@angular/material/slider'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { DialogTemplateCoreComponent } from '../../../shared/custom/dialog-template-core/dialog-template-core.component'; +import { MatDividerModule } from '@angular/material/divider'; const dialogMock = { close: jest.fn(), @@ -48,7 +52,6 @@ describe('CheckInFormComponent', () => { beforeEach(() => { TestBed.configureTestingModule({ imports: [ - HttpClientTestingModule, MatDialogModule, MatIconModule, MatFormFieldModule, @@ -63,15 +66,19 @@ describe('CheckInFormComponent', () => { TranslateTestingModule.withTranslations({ de: de, }), + MatDividerModule, ], providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), { provide: MAT_DIALOG_DATA, useValue: { keyResult: {} } }, { provide: MatDialogRef, useValue: dialogMock }, { provide: CheckInService, useValue: checkInServiceMock }, { provide: ActionService, useValue: actionServiceMock }, ParseUnitValuePipe, ], - declarations: [CheckInFormComponent, DialogHeaderComponent, ConfidenceComponent], + declarations: [CheckInFormComponent, DialogTemplateCoreComponent, ConfidenceComponent], }); fixture = TestBed.createComponent(CheckInFormComponent); component = fixture.componentInstance; diff --git a/frontend/src/app/components/checkin/check-in-form/check-in-form.component.ts b/frontend/src/app/components/checkin/check-in-form/check-in-form.component.ts index baa3b8b1a0..a47c0b1da7 100644 --- a/frontend/src/app/components/checkin/check-in-form/check-in-form.component.ts +++ b/frontend/src/app/components/checkin/check-in-form/check-in-form.component.ts @@ -111,8 +111,8 @@ export class CheckInFormComponent implements OnInit { return this.keyResult as KeyResultOrdinal; } - getActions(): Action[] | null { - return this.dialogForm.controls['actionList'].value; + getActions(): Action[] { + return this.dialogForm.controls['actionList'].value || []; } changeIsChecked(event: any, index: number) { @@ -120,4 +120,8 @@ export class CheckInFormComponent implements OnInit { actions[index] = { ...actions[index], isChecked: event.checked }; this.dialogForm.patchValue({ actionList: actions }); } + + getDialogTitle(): string { + return this.checkIn.id ? 'Check-in bearbeiten' : 'Check-in erfassen'; + } } diff --git a/frontend/src/app/components/confidence/confidence.component.html b/frontend/src/app/components/confidence/confidence.component.html index aa8b974de0..fa587720f9 100644 --- a/frontend/src/app/components/confidence/confidence.component.html +++ b/frontend/src/app/components/confidence/confidence.component.html @@ -1,13 +1,14 @@ -
-

{{ checkIn.confidence }}/{{ max }}

+

+ {{ checkIn.confidence }}/{{ max }} +

-
+ diff --git a/frontend/src/app/components/confidence/confidence.component.scss b/frontend/src/app/components/confidence/confidence.component.scss index e16f415ca5..dae966717a 100644 --- a/frontend/src/app/components/confidence/confidence.component.scss +++ b/frontend/src/app/components/confidence/confidence.component.scss @@ -37,5 +37,5 @@ mat-slider { } .height { - height: 21px; + height: fit-content; } diff --git a/frontend/src/app/components/key-result-form/key-result-form.component.html b/frontend/src/app/components/key-result-form/key-result-form.component.html index 9816b6710d..1697565fe0 100644 --- a/frontend/src/app/components/key-result-form/key-result-form.component.html +++ b/frontend/src/app/components/key-result-form/key-result-form.component.html @@ -1,7 +1,7 @@ -
-
-
- + +
+ +
- - {{ getErrorMessage("SIZE_BETWEEN", "Commit Zone", 1, 400) }} - -
+ +
+
+ +
+ + + {{ getErrorMessage("SIZE_BETWEEN", "Commit Zone", 1, 400) }} + +
+
+
-
- - - - {{ getErrorMessage("SIZE_BETWEEN", "Target Zone", 1, 400) }} - -
+
+
+ +
+ + + {{ getErrorMessage("SIZE_BETWEEN", "Target Zone", 1, 400) }} + +
+
+
-
- - - - {{ getErrorMessage("SIZE_BETWEEN", "Stretch Zone", 1, 400) }} - -
-
+
+
+ +
+ + + {{ getErrorMessage("SIZE_BETWEEN", "Stretch Zone", 1, 400) }} +
diff --git a/frontend/src/app/components/keyresult-type/keyresult-type.component.scss b/frontend/src/app/components/keyresult-type/keyresult-type.component.scss index 9b3e87d79e..0edb31b8a7 100644 --- a/frontend/src/app/components/keyresult-type/keyresult-type.component.scss +++ b/frontend/src/app/components/keyresult-type/keyresult-type.component.scss @@ -1,7 +1,11 @@ +@import "custom_bootstrap"; + .active { border-left: #909090 1px solid; border-top: #909090 1px solid; border-right: #909090 1px solid; + border-bottom: #909090 0 solid; + border-top-left-radius: 5px; border-top-right-radius: 5px; background: white; @@ -10,22 +14,21 @@ .non-active { color: #9c9c9c; + background: white; + border: #909090 0 solid; border-bottom: #909090 1px solid; } .tab-title { - display: flex; - justify-content: center; - align-items: center; - height: 39px; - margin-bottom: 16px; + @extend .p-2; + @extend .tab-focus; } .buffer { border-bottom: #909090 1px solid; } -.tabfocus { +.tab-focus { outline: none; &:focus-visible { border-radius: 5px; @@ -33,55 +36,7 @@ } } -.metric { - width: 87px; -} - -.ordinal { - width: 75px; -} - -.ordinal-input-fields { - height: 4.3rem; - padding: 0.437rem 0.625rem 0.375rem 0.625rem !important; -} - -.metric-fields { - height: 2rem; - padding: 0.437rem 0.625rem 0.375rem 0.625rem !important; -} - -.input-style { - width: 31%; -} - .unit-dropdown { - @extend .metric-fields; border: solid 1px #909090; cursor: pointer; } - -@media only screen and (min-width: 820px) { - .input-alignments { - display: flex; - justify-content: space-between; - } - - .input-style { - margin-bottom: 0; - } -} - -@media only screen and (max-width: 820px) { - .metric-fields { - width: 100%; - } - - .ordinal-input-fields { - width: 100%; - } - - .input-style { - width: auto; - } -} diff --git a/frontend/src/app/components/keyresult/keyresult.component.html b/frontend/src/app/components/keyresult/keyresult.component.html index 926322cd1c..a39ba63848 100644 --- a/frontend/src/app/components/keyresult/keyresult.component.html +++ b/frontend/src/app/components/keyresult/keyresult.component.html @@ -5,26 +5,23 @@ class="key-result rounded-2 p-3 w-100 focus-outline" tabindex="0" > -

{{ keyResult.title }}

+

{{ keyResult.title }}

-
-
- - Letztes Check-in - {{ - keyResult!.lastCheckIn!.createdOn | date: DATE_FORMAT - }} - Keines - +
+
+
Letztes Check-in
+ {{ + keyResult.lastCheckIn == null ? "Keines" : (keyResult!.lastCheckIn!.createdOn | date: DATE_FORMAT) + }}
-
-

Confidence

+
+
Confidence
diff --git a/frontend/src/app/components/keyresult/keyresult.component.scss b/frontend/src/app/components/keyresult/keyresult.component.scss index 19f57b5764..7bfb87d523 100644 --- a/frontend/src/app/components/keyresult/keyresult.component.scss +++ b/frontend/src/app/components/keyresult/keyresult.component.scss @@ -15,10 +15,3 @@ cursor: pointer; } } - -.keyresult-title { - font-size: 1rem; - font-weight: normal; - word-wrap: anywhere; - color: black; -} diff --git a/frontend/src/app/components/objective-detail/objective-detail.component.html b/frontend/src/app/components/objective-detail/objective-detail.component.html index 4c79701994..708ed37002 100644 --- a/frontend/src/app/components/objective-detail/objective-detail.component.html +++ b/frontend/src/app/components/objective-detail/objective-detail.component.html @@ -1,22 +1,22 @@
-
-

{{ objective.title }}

+
+

{{ objective.title }}

-
+ -
-

Beschrieb

+
+

Beschrieb

-

-
+

{{ objective.description }}

@@ -47,7 +47,7 @@

{{ objective.title }}

Objective bearbeiten
-
+
diff --git a/frontend/src/app/components/objective-detail/objective-detail.component.scss b/frontend/src/app/components/objective-detail/objective-detail.component.scss index df5ca6aea7..b9bc65ea45 100644 --- a/frontend/src/app/components/objective-detail/objective-detail.component.scss +++ b/frontend/src/app/components/objective-detail/objective-detail.component.scss @@ -1,7 +1,3 @@ :host { width: 100%; } - -.sub-title-objective { - font-size: 1rem; -} diff --git a/frontend/src/app/components/objective-detail/objective-detail.component.spec.ts b/frontend/src/app/components/objective-detail/objective-detail.component.spec.ts index 71e220d089..8a397f7582 100644 --- a/frontend/src/app/components/objective-detail/objective-detail.component.spec.ts +++ b/frontend/src/app/components/objective-detail/objective-detail.component.spec.ts @@ -9,6 +9,7 @@ import { of } from 'rxjs'; import { MatDialogModule } from '@angular/material/dialog'; import { ActivatedRoute } from '@angular/router'; import { MatIconModule } from '@angular/material/icon'; +import { TranslateModule } from '@ngx-translate/core'; let objectiveService = { getFullObjective: jest.fn(), @@ -28,7 +29,7 @@ describe('ObjectiveDetailComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [HttpClientTestingModule, MatDialogModule, MatIconModule], + imports: [HttpClientTestingModule, MatDialogModule, MatIconModule, TranslateModule.forRoot()], providers: [ { provide: ObjectiveService, useValue: objectiveService }, { provide: ActivatedRoute, useValue: activatedRouteMock }, @@ -55,8 +56,8 @@ describe('ObjectiveDetailComponent', () => { it('get data from backend', () => { fixture.detectChanges(); component.objectiveId = 2; - const title = fixture.debugElement.query(By.css('.title')).nativeElement.innerHTML; - const description = fixture.debugElement.query(By.css('[data-test-id="description"]')).nativeElement.innerHTML; + const title = fixture.debugElement.query(By.css('[data-testId="objective-title"]'))?.nativeElement.innerHTML; + const description = fixture.debugElement.query(By.css('[data-testId="description"]'))?.nativeElement.innerHTML; expect(title).toContain(objective.title); expect(description).toContain(objective.description); }); diff --git a/frontend/src/app/components/objective-detail/objective-detail.component.ts b/frontend/src/app/components/objective-detail/objective-detail.component.ts index a16837c818..5bd2ba1b87 100644 --- a/frontend/src/app/components/objective-detail/objective-detail.component.ts +++ b/frontend/src/app/components/objective-detail/objective-detail.component.ts @@ -2,12 +2,11 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; import { Objective } from '../../shared/types/model/Objective'; import { ObjectiveService } from '../../services/objective.service'; import { BehaviorSubject, catchError, EMPTY } from 'rxjs'; -import { MatDialog } from '@angular/material/dialog'; import { RefreshDataService } from '../../services/refresh-data.service'; import { KeyresultDialogComponent } from '../keyresult-dialog/keyresult-dialog.component'; import { ObjectiveFormComponent } from '../../shared/dialog/objective-dialog/objective-form.component'; import { ActivatedRoute, Router } from '@angular/router'; -import { OKR_DIALOG_CONFIG } from '../../shared/constantLibary'; +import { DialogService } from '../../services/dialog.service'; @Component({ selector: 'app-objective-detail', @@ -21,7 +20,7 @@ export class ObjectiveDetailComponent { constructor( private objectiveService: ObjectiveService, - private dialog: MatDialog, + private dialogService: DialogService, private refreshDataService: RefreshDataService, private router: Router, private route: ActivatedRoute, @@ -48,14 +47,8 @@ export class ObjectiveDetailComponent { } openAddKeyResultDialog() { - const dialogConfig = OKR_DIALOG_CONFIG; - - this.dialog + this.dialogService .open(KeyresultDialogComponent, { - height: dialogConfig.height, - width: dialogConfig.width, - maxHeight: dialogConfig.maxHeight, - maxWidth: dialogConfig.maxWidth, data: { objective: this.objective$.getValue(), keyResult: null, @@ -71,10 +64,8 @@ export class ObjectiveDetailComponent { } openEditObjectiveDialog() { - this.dialog + this.dialogService .open(ObjectiveFormComponent, { - width: '45em', - height: 'auto', data: { objective: { objectiveId: this.objective$.getValue().id, diff --git a/frontend/src/app/components/objective-filter/objective-filter.component.html b/frontend/src/app/components/objective-filter/objective-filter.component.html index 8b9f1f6b68..b1392ebd18 100644 --- a/frontend/src/app/components/objective-filter/objective-filter.component.html +++ b/frontend/src/app/components/objective-filter/objective-filter.component.html @@ -1,4 +1,4 @@ - + @@ -20,6 +20,7 @@ diff --git a/frontend/src/app/components/objective-filter/objective-filter.component.scss b/frontend/src/app/components/objective-filter/objective-filter.component.scss index 6575e0f171..6dd5e1064d 100644 --- a/frontend/src/app/components/objective-filter/objective-filter.component.scss +++ b/frontend/src/app/components/objective-filter/objective-filter.component.scss @@ -1,3 +1,7 @@ #objective-form-field { width: 300px; } + +.search-scale { + transform: scale(1.2); +} diff --git a/frontend/src/app/components/objective/ObjectiveMenuActions.ts b/frontend/src/app/components/objective/ObjectiveMenuActions.ts new file mode 100644 index 0000000000..a33b090e23 --- /dev/null +++ b/frontend/src/app/components/objective/ObjectiveMenuActions.ts @@ -0,0 +1,77 @@ +import { DialogService } from '../../services/dialog.service'; +import { Objective } from '../../shared/types/model/Objective'; +import { RefreshDataService } from '../../services/refresh-data.service'; +import { ObjectiveMin } from '../../shared/types/model/ObjectiveMin'; +import { ObjectiveFormComponent } from '../../shared/dialog/objective-dialog/objective-form.component'; +import { CompleteDialogComponent } from '../../shared/dialog/complete-dialog/complete-dialog.component'; +import { + ObjectiveMenuAction, + ObjectiveMenuAfterAction, + ObjectiveMenuEntry, +} from '../../services/objective-menu-actions.service'; +import { ObjectiveMenuAfterActions } from './ObjectiveMenuAfterActions'; + +export class ObjectiveMenuActions { + constructor( + private readonly dialogService: DialogService, + private readonly refreshDataService: RefreshDataService, + private readonly afterActions: ObjectiveMenuAfterActions, + ) {} + + releaseFromQuarterAction(objective: ObjectiveMin): ObjectiveMenuEntry { + const action: ObjectiveMenuAction = () => this.dialogService.openConfirmDialog('CONFIRMATION.RELEASE'); + const afterAction: ObjectiveMenuAfterAction = (objective, dialogResult) => + this.afterActions.releaseFromQuarter(objective); + return { displayName: 'Objective veröffentlichen', action: action, afterAction: afterAction }; + } + + releaseFromBacklogAction(objective: ObjectiveMin): ObjectiveMenuEntry { + const config = { data: { objective: { objectiveId: objective.id }, action: 'releaseBacklog' } }; + const action: ObjectiveMenuAction = () => this.dialogService.open(ObjectiveFormComponent, config); + const afterAction: ObjectiveMenuAfterAction = () => this.refreshDataService.markDataRefresh(); + return { displayName: 'Objective veröffentlichen', action: action, afterAction }; + } + + editObjectiveAction(objective: ObjectiveMin): ObjectiveMenuEntry { + const config = { data: { objective: { objectiveId: objective.id } } }; + const action: ObjectiveMenuAction = () => this.dialogService.open(ObjectiveFormComponent, config); + const afterAction: ObjectiveMenuAfterAction = () => { + this.refreshDataService.markDataRefresh(); + }; + return { displayName: 'Objective bearbeiten', action: action, afterAction: afterAction }; + } + + duplicateObjectiveAction(objective: ObjectiveMin): ObjectiveMenuEntry { + const config = { data: { objective: { objectiveId: objective.id }, action: 'duplicate' } }; + const action: ObjectiveMenuAction = () => this.dialogService.open(ObjectiveFormComponent, config); + const afterAction: ObjectiveMenuAfterAction = () => this.refreshDataService.markDataRefresh(); + return { displayName: 'Objective duplizieren', action: action, afterAction: afterAction }; + } + + completeObjectiveAction(objective: ObjectiveMin): ObjectiveMenuEntry { + const config = { + data: { objectiveTitle: objective.title }, + }; + const action: ObjectiveMenuAction = () => this.dialogService.open(CompleteDialogComponent, config); + const afterAction: ObjectiveMenuAfterAction = (obj: Objective, result: any) => + this.afterActions.completeObjective(obj, result); + + return { displayName: 'Objective abschliessen', action: action, afterAction: afterAction }; + } + + objectiveBackToDraft(): ObjectiveMenuEntry { + const action: ObjectiveMenuAction = () => this.dialogService.openConfirmDialog('CONFIRMATION.TO_DRAFT'); + const afterAction: ObjectiveMenuAfterAction = (obj: Objective, result: any) => + this.afterActions.objectiveBackToDraft(obj); + + return { displayName: 'Objective als Draft speichern', action: action, afterAction: afterAction }; + } + + objectiveReopen(): ObjectiveMenuEntry { + const action: ObjectiveMenuAction = () => this.dialogService.openConfirmDialog('CONFIRMATION.REOPEN'); + const afterAction: ObjectiveMenuAfterAction = (obj: Objective, result: any) => + this.afterActions.objectiveReopen(obj); + + return { displayName: 'Objective wiedereröffnen', action: action, afterAction: afterAction }; + } +} diff --git a/frontend/src/app/components/objective/ObjectiveMenuAfterActions.ts b/frontend/src/app/components/objective/ObjectiveMenuAfterActions.ts new file mode 100644 index 0000000000..5691305fa9 --- /dev/null +++ b/frontend/src/app/components/objective/ObjectiveMenuAfterActions.ts @@ -0,0 +1,50 @@ +import { Objective } from '../../shared/types/model/Objective'; +import { State } from '../../shared/types/enums/State'; +import { Completed } from '../../shared/types/model/Completed'; +import { ObjectiveService } from '../../services/objective.service'; +import { RefreshDataService } from '../../services/refresh-data.service'; + +export class ObjectiveMenuAfterActions { + constructor( + private readonly objectiveService: ObjectiveService, + private readonly refreshDataService: RefreshDataService, + ) {} + + completeObjective(objective: Objective, result: { endState: string; comment: string | null; objective: any }) { + objective.state = result.endState as State; + const completed: Completed = { + id: null, + version: objective.version, + objective: objective, + comment: result.comment, + }; + this.objectiveService.updateObjective(objective).subscribe(() => { + this.objectiveService.createCompleted(completed).subscribe(() => { + this.refreshDataService.markDataRefresh(); + }); + }); + } + + releaseFromQuarter(objective: Objective) { + objective.state = 'ONGOING' as State; + this.objectiveService.updateObjective(objective).subscribe(() => { + this.refreshDataService.markDataRefresh(); + }); + } + + objectiveBackToDraft(objective: Objective) { + objective.state = 'DRAFT' as State; + this.objectiveService.updateObjective(objective).subscribe(() => { + this.refreshDataService.markDataRefresh(); + }); + } + + objectiveReopen(objective: Objective) { + objective.state = 'ONGOING' as State; + this.objectiveService.updateObjective(objective).subscribe(() => { + this.objectiveService.deleteCompleted(objective.id).subscribe(() => { + this.refreshDataService.markDataRefresh(); + }); + }); + } +} diff --git a/frontend/src/app/components/objective/objective.component.html b/frontend/src/app/components/objective/objective.component.html index 48fd01a194..fcf84893a2 100644 --- a/frontend/src/app/components/objective/objective.component.html +++ b/frontend/src/app/components/objective/objective.component.html @@ -1,81 +1,82 @@ -
-
-
-
- +
+
+
+
+ The objectives state +

{{ objective.title }}

+
+
- -
-
- -
+
+ +
-
- -
+
+ +
+
-
- - - + + + diff --git a/frontend/src/app/components/objective/objective.component.spec.ts b/frontend/src/app/components/objective/objective.component.spec.ts index c429f95f77..79385ab23c 100644 --- a/frontend/src/app/components/objective/objective.component.spec.ts +++ b/frontend/src/app/components/objective/objective.component.spec.ts @@ -7,23 +7,19 @@ import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { By } from '@angular/platform-browser'; import { State } from '../../shared/types/enums/State'; -import { RouterTestingModule } from '@angular/router/testing'; import { OverviewService } from '../../services/overview.service'; -import { objective, objectiveMin } from '../../shared/testData'; +import { objectiveMin } from '../../shared/testData'; import { MatMenuHarness } from '@angular/material/menu/testing'; import { KeyresultComponent } from '../keyresult/keyresult.component'; import { MatDialogModule } from '@angular/material/dialog'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { ScoringComponent } from '../../shared/custom/scoring/scoring.component'; import { ConfidenceComponent } from '../confidence/confidence.component'; import { ReactiveFormsModule } from '@angular/forms'; +// @ts-ignore import * as de from '../../../assets/i18n/de.json'; import { TranslateTestingModule } from 'ngx-translate-testing'; -import { ObjectiveMin } from '../../shared/types/model/ObjectiveMin'; -import { MenuEntry } from '../../shared/types/menu-entry'; -import { of } from 'rxjs'; import { ObjectiveService } from '../../services/objective.service'; const overviewServiceMock = { @@ -31,12 +27,9 @@ const overviewServiceMock = { }; const objectiveServiceMock = { - getFullObjective(objectiveMin: ObjectiveMin) { - let ongoingObjective = objective; - ongoingObjective.state = State.ONGOING; - return of(ongoingObjective); - }, + getFullObjective: jest.fn(), }; + describe('ObjectiveColumnComponent', () => { let component: ObjectiveComponent; let fixture: ComponentFixture; @@ -50,9 +43,7 @@ describe('ObjectiveColumnComponent', () => { MatMenuModule, MatCardModule, NoopAnimationsModule, - RouterTestingModule, MatDialogModule, - HttpClientTestingModule, MatIconModule, MatTooltipModule, ReactiveFormsModule, @@ -114,46 +105,4 @@ describe('ObjectiveColumnComponent', () => { const button = fixture.debugElement.query(By.css('[data-testId="add-keyResult"]')); expect(button).toBeFalsy(); }); - - it('Correct method should be called when back to draft is clicked', () => { - jest.spyOn(component, 'objectiveBackToDraft'); - component.objective$.next(objectiveMin); - fixture.detectChanges(); - const menuEntry: MenuEntry = - component.getOngoingMenuActions()[ - component - .getOngoingMenuActions() - .map((menuAction) => menuAction.action) - .indexOf('todraft') - ]; - component.handleDialogResult(menuEntry, { endState: '', comment: null, objective: objective }); - fixture.detectChanges(); - expect(component.objectiveBackToDraft).toHaveBeenCalled(); - }); - - test('Should set isBacklogQuarter right', async () => { - expect(component.isBacklogQuarter).toBeFalsy(); - - objectiveMin.quarter.label = 'Backlog'; - - component.objective = objectiveMin; - fixture.detectChanges(); - component.ngOnInit(); - - expect(component.isBacklogQuarter).toBeTruthy(); - }); - - test('Should return correct menu entries when backlog', async () => { - objectiveMin.quarter.label = 'Backlog'; - component.objective = objectiveMin; - fixture.detectChanges(); - component.ngOnInit(); - - let menuActions = component.getDraftMenuActions(); - - expect(menuActions.length).toEqual(3); - expect(menuActions[0].displayName).toEqual('Objective bearbeiten'); - expect(menuActions[1].displayName).toEqual('Objective duplizieren'); - expect(menuActions[2].displayName).toEqual('Objective veröffentlichen'); - }); }); diff --git a/frontend/src/app/components/objective/objective.component.ts b/frontend/src/app/components/objective/objective.component.ts index 96aa53acf1..d67984a57b 100644 --- a/frontend/src/app/components/objective/objective.component.ts +++ b/frontend/src/app/components/objective/objective.component.ts @@ -1,272 +1,86 @@ -import { ChangeDetectionStrategy, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core'; -import { MenuEntry } from '../../shared/types/menu-entry'; +import { Component, Input, OnInit, ViewChild } from '@angular/core'; import { ObjectiveMin } from '../../shared/types/model/ObjectiveMin'; import { Router } from '@angular/router'; -import { ObjectiveFormComponent } from '../../shared/dialog/objective-dialog/objective-form.component'; -import { MatDialog } from '@angular/material/dialog'; -import { BehaviorSubject } from 'rxjs'; +import { map, ReplaySubject, take } from 'rxjs'; import { RefreshDataService } from '../../services/refresh-data.service'; -import { State } from '../../shared/types/enums/State'; import { ObjectiveService } from '../../services/objective.service'; -import { ConfirmDialogComponent } from '../../shared/dialog/confirm-dialog/confirm-dialog.component'; -import { CompleteDialogComponent } from '../../shared/dialog/complete-dialog/complete-dialog.component'; -import { Completed } from '../../shared/types/model/Completed'; -import { Objective } from '../../shared/types/model/Objective'; import { trackByFn } from '../../shared/common'; import { KeyresultDialogComponent } from '../keyresult-dialog/keyresult-dialog.component'; import { TranslateService } from '@ngx-translate/core'; -import { GJ_REGEX_PATTERN, OKR_DIALOG_CONFIG } from '../../shared/constantLibary'; +import { DialogService } from '../../services/dialog.service'; +import { ObjectiveMenuActionsService, ObjectiveMenuEntry } from '../../services/objective-menu-actions.service'; +import { State } from '../../shared/types/enums/State'; +import { MatMenuTrigger } from '@angular/material/menu'; @Component({ selector: 'app-objective-column', templateUrl: './objective.component.html', styleUrls: ['./objective.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ObjectiveComponent implements OnInit { - @Input() - isWritable!: boolean; - - menuEntries: MenuEntry[] = []; - isComplete: boolean = false; - isBacklogQuarter: boolean = false; +export class ObjectiveComponent { + @Input() isWritable!: boolean; + public objective$ = new ReplaySubject(); + menuEntries = this.objective$.pipe(map((objective) => this.objectiveMenuActionsService.getMenu(objective))); protected readonly trackByFn = trackByFn; - @ViewChild('menuButton') private menuButton!: ElementRef; + @ViewChild(MatMenuTrigger) trigger: MatMenuTrigger | undefined; constructor( - private matDialog: MatDialog, - private router: Router, - private refreshDataService: RefreshDataService, - private objectiveService: ObjectiveService, - private translate: TranslateService, + private readonly dialogService: DialogService, + private readonly router: Router, + private readonly refreshDataService: RefreshDataService, + private readonly objectiveService: ObjectiveService, + private readonly translate: TranslateService, + private readonly objectiveMenuActionsService: ObjectiveMenuActionsService, ) {} - @Input() - set objective(objective: ObjectiveMin) { + @Input() set objective(objective: ObjectiveMin) { this.objective$.next(objective); } - public objective$ = new BehaviorSubject({} as ObjectiveMin); - - ngOnInit() { - if (this.objective$.value.state.includes('successful') || this.objective$.value.state.includes('not-successful')) { - this.isComplete = true; - } - if (!GJ_REGEX_PATTERN.test(this.objective$.value.quarter.label)) { - this.isBacklogQuarter = true; - } - } - - formatObjectiveState(state: string): string { - const lastIndex = state.lastIndexOf('-'); - if (lastIndex !== -1) { - return state.substring(0, lastIndex).toUpperCase(); - } else { - return state.toUpperCase(); - } - } - - getStateTooltip(): string { - return this.translate.instant('INFORMATION.OBJECTIVE_STATE_TOOLTIP'); - } - - getMenu(): void { - if (this.objective$.value.state.includes('successful') || this.objective$.value.state.includes('not-successful')) { - this.menuEntries = this.getCompletedMenuActions(); - } else { - if (this.objective$.value.state === State.ONGOING) { - this.menuEntries = this.getOngoingMenuActions(); - } else { - this.menuEntries = this.getDraftMenuActions(); - } - } - } - - getOngoingMenuActions() { - return [ - ...this.getDefaultMenuActions(), - ...[ - { - displayName: 'Objective abschliessen', - action: 'complete', - dialog: { dialog: CompleteDialogComponent, data: { objectiveTitle: this.objective$.value.title } }, - }, - { - displayName: 'Objective als Draft speichern', - action: 'todraft', - dialog: { - dialog: ConfirmDialogComponent, - data: { title: 'Objective', action: 'todraft' }, - }, - }, - ], - ]; - } - - getDraftMenuActions() { - let menuEntries = { - displayName: 'Objective veröffentlichen', - action: this.isBacklogQuarter ? 'releaseBacklog' : 'release', - dialog: { - dialog: this.isBacklogQuarter ? ObjectiveFormComponent : ConfirmDialogComponent, - data: { - title: 'Objective', - action: this.isBacklogQuarter ? 'releaseBacklog' : 'release', - objectiveId: this.isBacklogQuarter ? this.objective$.value.id : undefined, - }, - }, - }; - - return [...this.getDefaultMenuActions(), menuEntries]; + getStateTooltip(stateString: string): string { + const state = this.getStateByValue(stateString); + return this.translate.instant('INFORMATION.OBJECTIVE_STATE_TOOLTIP', { state: state }); } - getDefaultMenuActions() { - return [ - { - displayName: 'Objective bearbeiten', - dialog: { dialog: ObjectiveFormComponent, data: { objectiveId: this.objective$.value.id } }, - }, - { - displayName: 'Objective duplizieren', - action: 'duplicate', - dialog: { dialog: ObjectiveFormComponent, data: { objectiveId: this.objective$.value.id } }, - }, - ]; - } - - getCompletedMenuActions() { - return [ - { displayName: 'Objective wiedereröffnen', action: 'reopen' }, - { - displayName: 'Objective duplizieren', - action: 'duplicate', - dialog: { dialog: ObjectiveFormComponent, data: { objectiveId: this.objective$.value.id } }, - }, - ]; - } - - redirect(menuEntry: MenuEntry) { - if (menuEntry.dialog) { - const dialogConfig = OKR_DIALOG_CONFIG; - - if (menuEntry.action == 'release' || menuEntry.action == 'todraft') { - dialogConfig.width = 'auto'; - } - const matDialogRef = this.matDialog.open(menuEntry.dialog.dialog, { - data: { - title: menuEntry.dialog.data.title, - action: menuEntry.action, - objective: menuEntry.dialog.data, - objectiveTitle: menuEntry.dialog.data.objectiveTitle, - }, - height: dialogConfig.height, - width: dialogConfig.width, - maxHeight: dialogConfig.maxHeight, - maxWidth: dialogConfig.maxWidth, - }); - matDialogRef.afterClosed().subscribe((result) => { - this.menuButton.nativeElement.focus(); - if (result) { - this.handleDialogResult(menuEntry, result); - } - }); - } else { - this.reopenRedirect(menuEntry); - } - } - - handleDialogResult(menuEntry: MenuEntry, result: { endState: string; comment: string | null; objective: any }) { - if (menuEntry.action) { - this.objectiveService.getFullObjective(this.objective$.value.id).subscribe((objective) => { - if (menuEntry.action == 'complete') { - this.completeObjective(objective, result); - } else if (menuEntry.action == 'release') { - this.releaseObjective(objective); - } else if (menuEntry.action == 'duplicate') { - this.refreshDataService.markDataRefresh(); - } else if (menuEntry.action == 'releaseBacklog') { - this.refreshDataService.markDataRefresh(); - } else if (menuEntry.action == 'todraft') { - this.objectiveBackToDraft(objective); - } - }); - } else { - if (result?.objective) { - this.refreshDataService.markDataRefresh(); - } - } - } - - completeObjective(objective: Objective, result: { endState: string; comment: string | null; objective: any }) { - objective.state = result.endState as State; - const completed: Completed = { - id: null, - version: objective.version, - objective: objective, - comment: result.comment, - }; - this.objectiveService.updateObjective(objective).subscribe(() => { - this.objectiveService.createCompleted(completed).subscribe(() => { - this.isComplete = true; - this.refreshDataService.markDataRefresh(); - }); - }); - } - - releaseObjective(objective: Objective) { - objective.state = 'ONGOING' as State; - this.objectiveService.updateObjective(objective).subscribe(() => { - this.refreshDataService.markDataRefresh(); - }); - } - - objectiveBackToDraft(objective: Objective) { - objective.state = 'DRAFT' as State; - this.objectiveService.updateObjective(objective).subscribe(() => { - this.refreshDataService.markDataRefresh(); - }); - } - - reopenRedirect(menuEntry: MenuEntry) { - if (menuEntry.action === 'reopen') { - this.objectiveService.getFullObjective(this.objective$.value.id).subscribe((objective) => { - objective.state = 'ONGOING' as State; - this.objectiveService.updateObjective(objective).subscribe(() => { - this.objectiveService.deleteCompleted(objective.id).subscribe(() => { - this.isComplete = false; - this.refreshDataService.markDataRefresh(); - }); + redirect(menuEntry: ObjectiveMenuEntry, objectiveMin: ObjectiveMin) { + const matDialogRef = menuEntry.action(); + matDialogRef + .afterClosed() + .pipe(take(1)) + .subscribe((result) => { + this.objectiveService.getFullObjective(objectiveMin.id).subscribe((objective) => { + menuEntry.afterAction(objective, result); + this.trigger?.focus(); }); }); - } else { - this.router.navigate([menuEntry.route!]); - } } - openObjectiveDetail() { - this.router.navigate(['details/objective', this.objective$.value.id]); + openObjectiveDetail(objectiveId: number) { + this.router.navigate(['details/objective', objectiveId]); } - openAddKeyResultDialog() { - const dialogConfig = OKR_DIALOG_CONFIG; - - this.matDialog + openAddKeyResultDialog(objective: ObjectiveMin) { + this.dialogService .open(KeyresultDialogComponent, { - height: dialogConfig.height, - width: dialogConfig.width, - maxHeight: dialogConfig.maxHeight, - maxWidth: dialogConfig.maxWidth, data: { - objective: this.objective$.value, + objective: objective, keyResult: null, }, }) .afterClosed() .subscribe((result) => { if (result?.openNew) { - this.openAddKeyResultDialog(); + this.openAddKeyResultDialog(objective); } this.refreshDataService.markDataRefresh(); }); } + + isObjectiveComplete(objective: ObjectiveMin): boolean { + return objective.state == State.SUCCESSFUL || objective.state == State.NOTSUCCESSFUL; + } + + getStateByValue(value: string): string { + return Object.keys(State).find((key) => State[key as keyof typeof State] === value) ?? ''; + } } diff --git a/frontend/src/app/components/overview/overview.component.html b/frontend/src/app/components/overview/overview.component.html index 18289e8543..9cfe539a04 100644 --- a/frontend/src/app/components/overview/overview.component.html +++ b/frontend/src/app/components/overview/overview.component.html @@ -1,5 +1,5 @@ -
+
+ - - {{ getQuarterLabel(quarter, i) }} + + {{ quarter.fullLabel() }} diff --git a/frontend/src/app/components/quarter-filter/quarter-filter.component.spec.ts b/frontend/src/app/components/quarter-filter/quarter-filter.component.spec.ts index e2507f74ce..bcd2e4bdd5 100644 --- a/frontend/src/app/components/quarter-filter/quarter-filter.component.spec.ts +++ b/frontend/src/app/components/quarter-filter/quarter-filter.component.spec.ts @@ -2,7 +2,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { QuarterFilterComponent } from './quarter-filter.component'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { OverviewService } from '../../services/overview.service'; -import { quarter } from '../../shared/testData'; import { Observable, of } from 'rxjs'; import { Quarter } from '../../shared/types/model/Quarter'; import { QuarterService } from '../../services/quarter.service'; @@ -21,16 +20,19 @@ const overviewService = { }; const quarters = [ - { id: 999, label: 'Backlog', startDate: null, endDate: null }, - { ...quarter, id: 2 }, - { ...quarter, id: 5 }, - { ...quarter, id: 7 }, + new Quarter(999, 'Backlog', null, null), + new Quarter(2, '23.02.2025', new Date(), new Date()), + new Quarter(5, '23.02.2025', new Date(), new Date()), + new Quarter(7, '23.02.2025', new Date(), new Date()), ]; const quarterService = { getAllQuarters(): Observable { return of(quarters); }, + getCurrentQuarter(): Observable { + return of(quarters[2]); + }, }; describe('QuarterFilterComponent', () => { @@ -68,13 +70,14 @@ describe('QuarterFilterComponent', () => { it('should set correct default quarter if no route param is defined', async () => { jest.spyOn(component, 'changeDisplayedQuarter'); + jest.spyOn(quarters[2] as any, 'isCurrent').mockReturnValue(true); const quarterSelect = await loader.getHarness(MatSelectHarness); expect(quarterSelect).toBeTruthy(); component.ngOnInit(); fixture.detectChanges(); - expect(component.quarterId).toBe(quarters[2].id); + expect(component.currentQuarterId).toBe(quarters[2].id); expect(await quarterSelect.getValueText()).toBe(quarters[2].label + ' Aktuell'); - expect(component.changeDisplayedQuarter).toHaveBeenCalledTimes(0); + expect(component.changeDisplayedQuarter).toHaveBeenCalledTimes(1); }); it('should set correct value in form according to route param', async () => { @@ -89,7 +92,7 @@ describe('QuarterFilterComponent', () => { component.ngOnInit(); fixture.detectChanges(); - expect(component.quarterId).toBe(quarters[3].id); + expect(component.currentQuarterId).toBe(quarters[3].id); expect(await quarterSelect.getValueText()).toBe(quarters[3].label); expect(component.changeDisplayedQuarter).toHaveBeenCalledTimes(1); }); @@ -106,7 +109,7 @@ describe('QuarterFilterComponent', () => { routerHarness.detectChanges(); component.ngOnInit(); fixture.detectChanges(); - expect(component.quarterId).toBe(quarters[2].id); + expect(component.currentQuarterId).toBe(quarters[2].id); expect(await quarterSelect.getValueText()).toBe(quarters[2].label + ' Aktuell'); expect(component.changeDisplayedQuarter).toHaveBeenCalledTimes(1); expect(router.url).toBe('/?quarter=' + quarters[2].id); diff --git a/frontend/src/app/components/quarter-filter/quarter-filter.component.ts b/frontend/src/app/components/quarter-filter/quarter-filter.component.ts index 1374bbc135..023a242adf 100644 --- a/frontend/src/app/components/quarter-filter/quarter-filter.component.ts +++ b/frontend/src/app/components/quarter-filter/quarter-filter.component.ts @@ -1,10 +1,10 @@ import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output } from '@angular/core'; import { QuarterService } from '../../services/quarter.service'; import { Quarter } from '../../shared/types/model/Quarter'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, forkJoin } from 'rxjs'; import { ActivatedRoute, Router } from '@angular/router'; -import { getQuarterLabel, getValueFromQuery } from '../../shared/common'; import { RefreshDataService } from '../../services/refresh-data.service'; +import { getValueFromQuery } from '../../shared/common'; @Component({ selector: 'app-quarter-filter', @@ -14,7 +14,7 @@ import { RefreshDataService } from '../../services/refresh-data.service'; export class QuarterFilterComponent implements OnInit { quarters: BehaviorSubject = new BehaviorSubject([]); @Output() quarterLabel$ = new EventEmitter(); - quarterId: number = -1; + currentQuarterId: number = -1; constructor( private quarterService: QuarterService, @@ -24,28 +24,30 @@ export class QuarterFilterComponent implements OnInit { ) {} ngOnInit() { - this.quarterService.getAllQuarters().subscribe((quarters) => { + const allQuarters$ = this.quarterService.getAllQuarters(); + const currentQuarter$ = this.quarterService.getCurrentQuarter(); + forkJoin([allQuarters$, currentQuarter$]).subscribe(([quarters, currentQuarter]) => { this.quarters.next(quarters); const quarterQuery = this.route.snapshot.queryParams['quarter']; const quarterId: number = getValueFromQuery(quarterQuery)[0]; if (quarters.map((quarter) => quarter.id).includes(quarterId)) { - this.quarterId = quarterId; + this.currentQuarterId = quarterId; this.changeDisplayedQuarter(); } else { - this.quarterId = quarters[2].id; - if (quarterQuery !== undefined) { - this.changeDisplayedQuarter(); - } else { + this.currentQuarterId = currentQuarter.id; + this.changeDisplayedQuarter(); + + if (quarterQuery === undefined) { this.refreshDataService.quarterFilterReady.next(); } } - const quarterLabel = quarters.find((e) => e.id == this.quarterId)?.label || ''; + const quarterLabel = quarters.find((e) => e.id == this.currentQuarterId)?.label || ''; this.quarterLabel$.next(quarterLabel); }); } changeDisplayedQuarter() { - const id = this.quarterId; + const id = this.currentQuarterId; const quarterLabel = this.quarters.getValue().find((e) => e.id == id)?.label || ''; this.quarterLabel$.next(quarterLabel); @@ -53,6 +55,4 @@ export class QuarterFilterComponent implements OnInit { .navigate([], { queryParams: { quarter: id } }) .then(() => this.refreshDataService.quarterFilterReady.next()); } - - protected readonly getQuarterLabel = getQuarterLabel; } diff --git a/frontend/src/app/components/team/team.component.html b/frontend/src/app/components/team/team.component.html index 601f32af68..dd4efb9bec 100644 --- a/frontend/src/app/components/team/team.component.html +++ b/frontend/src/app/components/team/team.component.html @@ -1,10 +1,8 @@
- -
-

{{ OVEntity.team.name }}

-
-
+
+

{{ OVEntity.team.name }}

+
-
-
diff --git a/frontend/src/app/shared/custom/dialog-header/dialog-header.component.scss b/frontend/src/app/shared/custom/dialog-header/dialog-header.component.scss deleted file mode 100644 index 40bb4dbcda..0000000000 --- a/frontend/src/app/shared/custom/dialog-header/dialog-header.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -.closing-button { - right: 0; -} - -@media only screen and (min-width: 800px) { - .dialog-header { - margin-bottom: 1rem; - } -} - -mat-icon { - display: flex; - justify-content: center; - align-items: center; -} diff --git a/frontend/src/app/shared/custom/dialog-header/dialog-header.component.spec.ts b/frontend/src/app/shared/custom/dialog-header/dialog-header.component.spec.ts deleted file mode 100644 index 2065d3916a..0000000000 --- a/frontend/src/app/shared/custom/dialog-header/dialog-header.component.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; - -import { DialogHeaderComponent } from './dialog-header.component'; - -describe('DialogHeaderComponent', () => { - let component: DialogHeaderComponent; - let fixture: ComponentFixture; - - beforeEach(() => { - TestBed.configureTestingModule({ - declarations: [DialogHeaderComponent], - }); - fixture = TestBed.createComponent(DialogHeaderComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); -}); diff --git a/frontend/src/app/shared/custom/dialog-header/dialog-header.component.ts b/frontend/src/app/shared/custom/dialog-header/dialog-header.component.ts deleted file mode 100644 index 3808dc9bb7..0000000000 --- a/frontend/src/app/shared/custom/dialog-header/dialog-header.component.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Component, Input } from '@angular/core'; - -@Component({ - selector: 'app-dialog-header', - templateUrl: './dialog-header.component.html', - styleUrls: ['./dialog-header.component.scss'], -}) -export class DialogHeaderComponent { - @Input() - dialogTitle!: string; -} diff --git a/frontend/src/app/callback/callback.component.css b/frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.cy.ts similarity index 100% rename from frontend/src/app/callback/callback.component.css rename to frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.cy.ts diff --git a/frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.html b/frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.html new file mode 100644 index 0000000000..c74e127ca2 --- /dev/null +++ b/frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.html @@ -0,0 +1,35 @@ +
+
+
+

+ {{ title }} + +

+
+
+
+ + + + + +
+ +
+
+ + + +
+ + + + +
+
+ +
+
+
diff --git a/frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.scss b/frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.scss new file mode 100644 index 0000000000..769a21b288 --- /dev/null +++ b/frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.scss @@ -0,0 +1,24 @@ +@import "custom_bootstrap"; + +mat-dialog-content { + //scrollbar-gutter: stable both-edges; + $dialog-header-height: 48px; + $full-dialog-content-height: calc( + 100vh - $top-bar-height - $dialog-header-height - $dialog-content-padding-y - $dialog-action-buttons-div-height + ); + @extend .d-flex; + @extend .flex-column; + @extend .flex-wrap; + @extend .container-fluid; + max-height: $full-dialog-content-height !important; + + @include media-breakpoint-down(sm) { + height: $full-dialog-content-height !important; + } +} + +mat-dialog-actions { + @extend .d-flex; + @extend .justify-content-between; + height: $dialog-action-buttons-div-height !important; +} diff --git a/frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.ts b/frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.ts new file mode 100644 index 0000000000..95f651d3dd --- /dev/null +++ b/frontend/src/app/shared/custom/dialog-template-core/dialog-template-core.component.ts @@ -0,0 +1,22 @@ +import { Component, Input } from '@angular/core'; +import { Observable, of } from 'rxjs'; + +@Component({ + selector: 'app-dialog-template-core', + templateUrl: './dialog-template-core.component.html', + styleUrl: './dialog-template-core.component.scss', +}) +export class DialogTemplateCoreComponent { + @Input() observable: Observable = of({}); + @Input() title: string = ''; + + isValueReady(obj: any): boolean { + if (obj == null) { + return false; + } + if (Array.isArray(obj)) { + return obj.length > 0; + } + return true; + } +} diff --git a/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.html b/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.html index 043cd96ff0..92bb32f252 100644 --- a/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.html +++ b/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.html @@ -1,3 +1 @@ -
- okr-logo -
+okr-logo diff --git a/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.scss b/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.scss index 4a53cd9648..e69de29bb2 100644 --- a/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.scss +++ b/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.scss @@ -1,11 +0,0 @@ -div { - display: block; - width: 100px; - - @media (min-width: 576px) { - width: 180px; - } - @media (min-width: 992px) { - width: 274px; - } -} diff --git a/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.ts b/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.ts index 092e4ae4e3..5fb7a3c399 100644 --- a/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.ts +++ b/frontend/src/app/shared/custom/okr-tangram/okr-tangram.component.ts @@ -1,7 +1,6 @@ import { Component } from '@angular/core'; -import { isMobileDevice } from '../../common'; import { ConfigService } from '../../../services/config.service'; -import { BehaviorSubject, Subscription } from 'rxjs'; +import { map, Observable } from 'rxjs'; @Component({ selector: 'app-okr-tangram', @@ -9,23 +8,12 @@ import { BehaviorSubject, Subscription } from 'rxjs'; styleUrl: 'okr-tangram.component.scss', }) export class OkrTangramComponent { - private readonly MOBILE_WIDTH = 100; - private readonly DESKTOP_WIDTH = 274; + private readonly DEFAULT_TRIANGLE_SRC = 'assets/images/empty.svg'; + trianglesSrc$ = new Observable(); - getWidth() { - return isMobileDevice() ? this.MOBILE_WIDTH : this.DESKTOP_WIDTH; - } - - private subscription?: Subscription; - trianglesSrc$ = new BehaviorSubject('assets/images/empty.svg'); - - constructor(private configService: ConfigService) {} - - ngOnInit(): void { - this.subscription = this.configService.config$.subscribe((config) => { - if (config.triangles) { - this.trianglesSrc$.next(config.triangles); - } - }); + constructor(private readonly configService: ConfigService) { + this.trianglesSrc$ = this.configService.config$.pipe( + map((config) => config.triangles || this.DEFAULT_TRIANGLE_SRC), + ); } } diff --git a/frontend/src/app/shared/custom/scoring/scoring.component.html b/frontend/src/app/shared/custom/scoring/scoring.component.html index 938a1fee4a..4d7af54310 100644 --- a/frontend/src/app/shared/custom/scoring/scoring.component.html +++ b/frontend/src/app/shared/custom/scoring/scoring.component.html @@ -1,17 +1,15 @@
-
- - Fail - - - Commit - - - Target - -
+ +

Fail

+
+ +

Commit

+
+ +

Target

+
-
- Stretch +
+

Stretch

- - - - - @if (data.dialogText) { -
- {{ this.data.dialogText }} -
- } -
- - - - - diff --git a/frontend/src/app/shared/dialog/cancel-dialog/cancel-dialog.component.scss b/frontend/src/app/shared/dialog/cancel-dialog/cancel-dialog.component.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/src/app/shared/dialog/cancel-dialog/cancel-dialog.component.ts b/frontend/src/app/shared/dialog/cancel-dialog/cancel-dialog.component.ts deleted file mode 100644 index c2a33fc611..0000000000 --- a/frontend/src/app/shared/dialog/cancel-dialog/cancel-dialog.component.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Component, Inject } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; - -export type CancelDialogData = { - dialogTitle: string; - dialogText?: string; -}; - -@Component({ - selector: 'app-cancel-dialog', - templateUrl: './cancel-dialog.component.html', - styleUrl: './cancel-dialog.component.scss', -}) -export class CancelDialogComponent { - constructor( - @Inject(MAT_DIALOG_DATA) public data: CancelDialogData, - private dialogRef: MatDialogRef, - ) {} - - closeAndConfirm() { - this.dialogRef.close(true); - } -} diff --git a/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.html b/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.html index 5a0cb5d688..e4eae92721 100644 --- a/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.html +++ b/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.html @@ -1,20 +1,18 @@ -
- -
- - -
-
-

Objective

-

{{ data.objectiveTitle }}

+ + +
+

Objective

+ +

{{ data.objectiveTitle }}

+
-
- -
-
+

Bewertung

+
+
-
+
+
-
- - - - {{ getErrorMessage("MAX_VALUE", "Kommentar", 4096) }} - +
+ +
+ + + {{ getErrorMessage("MAX_VALUE", "Kommentar", 4096) }} + +
-
- + - -
-
+ +
- +
-
- + + diff --git a/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.scss b/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.scss index 2244ba1bf0..f757130a0f 100644 --- a/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.scss +++ b/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.scss @@ -73,7 +73,3 @@ div:hover { @extend .card-shadow; background-color: #ba3838; } - -.dialog-content { - max-height: 30vh; -} diff --git a/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.spec.ts b/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.spec.ts index e949a38b9a..885b3850f6 100644 --- a/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.spec.ts +++ b/frontend/src/app/shared/dialog/complete-dialog/complete-dialog.component.spec.ts @@ -2,10 +2,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { CompleteDialogComponent } from './complete-dialog.component'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { DialogHeaderComponent } from '../../custom/dialog-header/dialog-header.component'; import { TranslateService } from '@ngx-translate/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatIconModule } from '@angular/material/icon'; +import { DialogTemplateCoreComponent } from '../../custom/dialog-template-core/dialog-template-core.component'; +import { MatDividerModule } from '@angular/material/divider'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; const dialogMock = { close: jest.fn(), @@ -21,12 +25,16 @@ let matDataMock: { objective: { objectiveId: number | undefined; teamId: number describe('CompleteDialogComponent', () => { let component: CompleteDialogComponent; let fixture: ComponentFixture; + let debugElement: any; beforeEach(() => { TestBed.configureTestingModule({ - imports: [FormsModule, ReactiveFormsModule, MatDialogModule, MatIconModule], - declarations: [CompleteDialogComponent, DialogHeaderComponent], + imports: [FormsModule, ReactiveFormsModule, MatDialogModule, MatIconModule, MatDividerModule], + declarations: [CompleteDialogComponent, DialogTemplateCoreComponent], providers: [ + provideRouter([]), + provideHttpClient(), + provideHttpClientTesting(), { provide: MatDialogRef, useValue: dialogMock }, { provide: MAT_DIALOG_DATA, useValue: matDataMock }, { provide: TranslateService, useValue: {} }, @@ -35,6 +43,7 @@ describe('CompleteDialogComponent', () => { fixture = TestBed.createComponent(CompleteDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); + debugElement = fixture.debugElement.nativeElement; }); it('should create', () => { @@ -45,7 +54,7 @@ describe('CompleteDialogComponent', () => { let elements = document.querySelectorAll('.valuation-card'); let successful = document.querySelectorAll('.card-hover-successful'); let notSuccessful = document.querySelectorAll('.card-hover-not-successful'); - let submitButton = document.querySelectorAll('button')[1]; + let submitButton = debugElement.querySelector('[data-testid="submit"]'); expect(elements.length).toEqual(2); expect(successful.length).toEqual(1); @@ -53,18 +62,18 @@ describe('CompleteDialogComponent', () => { expect(component.completeForm.value.isSuccessful).toBeNull(); expect(component.completeForm.value.comment).toBeNull(); expect(component.completeForm.invalid).toBeTruthy(); - expect(submitButton.disabled).toBeTruthy(); + expect(submitButton!.hasAttribute('disabled')).toBeTruthy(); }); it('should change isSuccessful value on card click and remove class card-hover', () => { component.switchSuccessState('successful'); let elements = document.querySelectorAll('.card-hover'); - let submitButton = document.querySelectorAll('button')[1]; + let submitButton = debugElement.querySelector('[data-testid="submit"]'); expect(component.completeForm.value.isSuccessful).toBeTruthy(); expect(component.completeForm.invalid).toBeFalsy(); expect(elements.length).toEqual(0); - expect(submitButton.disabled).toBeTruthy(); + expect(submitButton!.hasAttribute('disabled')).toBeTruthy(); component.completeForm.patchValue({ isSuccessful: null }); component.switchSuccessState('notSuccessful'); diff --git a/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.html b/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.html index 60f6120ae7..c34a464c2b 100644 --- a/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.html +++ b/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.html @@ -1,16 +1,23 @@ -
- -
+ + +
+ {{ this.dialogText.trim() }} +
+
- -
- {{ this.dialogText.trim() }} -
-
+ +
+ - - - - + +
+
+
diff --git a/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.spec.ts b/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.spec.ts index 01761a3fcf..22c02998c8 100644 --- a/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.spec.ts +++ b/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.spec.ts @@ -11,8 +11,12 @@ import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; import { ReactiveFormsModule } from '@angular/forms'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { MatIconModule } from '@angular/material/icon'; +import { ConfirmDialogData } from '../../../services/dialog.service'; +import { DialogTemplateCoreComponent } from '../../custom/dialog-template-core/dialog-template-core.component'; +import { MatDividerModule } from '@angular/material/divider'; -const dialogMock = { +const dialogRefMock = { close: jest.fn(), }; @@ -31,12 +35,14 @@ describe('ConfirmDialogComponent', () => { MatRadioModule, ReactiveFormsModule, TranslateModule.forRoot(), + MatIconModule, + MatDividerModule, ], - declarations: [ConfirmDialogComponent], + declarations: [ConfirmDialogComponent, DialogTemplateCoreComponent], providers: [ TranslateService, - { provide: MAT_DIALOG_DATA, useValue: {} }, - { provide: MatDialogRef, useValue: dialogMock }, + { provide: MAT_DIALOG_DATA, useValue: { title: '', text: '' } as ConfirmDialogData }, + { provide: MatDialogRef, useValue: dialogRefMock }, ], }); fixture = TestBed.createComponent(ConfirmDialogComponent); @@ -51,17 +57,17 @@ describe('ConfirmDialogComponent', () => { it('should call close method with parameter: true if clicked to submit button', async () => { let buttons = await loader.getAllHarnesses(MatButtonHarness); - const submitButton = buttons[0]; + const submitButton = buttons[1]; await submitButton.click(); - expect(dialogMock.close).toHaveBeenCalledWith(true); + expect(dialogRefMock.close).toHaveBeenCalledWith(true); }); it('should call close method with parameter: "" if clicked to cancel button', async () => { let buttons = await loader.getAllHarnesses(MatButtonHarness); - const cancelButton = buttons[1]; + const cancelButton = buttons[0]; await cancelButton.click(); - expect(dialogMock.close).toHaveBeenCalledWith(''); + expect(dialogRefMock.close).toHaveBeenCalledWith(''); }); }); diff --git a/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.ts b/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.ts index 74490e9c98..dbb86d6cd5 100644 --- a/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.ts +++ b/frontend/src/app/shared/dialog/confirm-dialog/confirm-dialog.component.ts @@ -1,6 +1,7 @@ -import { Component, Inject, OnInit } from '@angular/core'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Component, Inject, InjectionToken, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogConfig, MatDialogRef } from '@angular/material/dialog'; import { TranslateService } from '@ngx-translate/core'; +import { ConfirmDialogData } from '../../../services/dialog.service'; @Component({ selector: 'app-confirm-dialog', @@ -11,44 +12,13 @@ export class ConfirmDialogComponent implements OnInit { dialogTitle: string = ''; dialogText: string = ''; constructor( - @Inject(MAT_DIALOG_DATA) public data: any, + @Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData, public dialogRef: MatDialogRef, - private translate: TranslateService, ) {} ngOnInit() { - if (this.data.draftCreate) { - this.dialogTitle = 'Check-in im Draft-Status'; - this.dialogText = - 'Dein Objective befindet sich noch im DRAFT Status. Möchtest du das Check-in trotzdem erfassen?'; - } else if (this.data.action) { - if (this.data.action === 'release') { - this.dialogTitle = this.data.title + ' veröffentlichen'; - this.dialogText = 'Soll dieses ' + this.data.title + ' veröffentlicht werden?'; - } else if (this.data.action === 'todraft') { - this.dialogTitle = this.data.title + ' als Draft speichern'; - this.dialogText = 'Soll dieses ' + this.data.title + ' als Draft gespeichert werden?'; - } - } else { - this.dialogTitle = this.data.title + ' löschen'; - if (this.data.isAction) { - this.dialogText = 'Möchtest du diese Action wirklich löschen?'; - } else { - let error; - switch (this.data.title) { - case 'Team': - error = 'DELETE_TEAM'; - break; - case 'Objective': - error = 'DELETE_OBJECTIVE'; - break; - case 'Key Result': - error = 'DELETE_KEY_RESULT'; - break; - } - this.dialogText = this.translate.instant('INFORMATION.' + error); - } - } + this.dialogTitle = this.data.title || 'Are you sure?'; + this.dialogText = this.data.text || 'Are you sure you want to delete this item?'; } closeAndDelete() { diff --git a/frontend/src/app/shared/dialog/example-dialog/example-dialog.component.html b/frontend/src/app/shared/dialog/example-dialog/example-dialog.component.html index 83957d9873..37a8bc048e 100644 --- a/frontend/src/app/shared/dialog/example-dialog/example-dialog.component.html +++ b/frontend/src/app/shared/dialog/example-dialog/example-dialog.component.html @@ -1,50 +1,55 @@ -

This is an example dialog

- -
- - Name - -
- + + This is an example dialog + + + + + Name + +
+ + {{ errorMessages[errorKey.toUpperCase()] }} + +
+
+ + + Male + Female + Other + +
+ {{ errorMessages[errorKey.toUpperCase()] }}
- - - - Male - Female - Other - -
- - {{ errorMessages[errorKey.toUpperCase()] }} - -
- - Please select a hobby - - {{ hobby }} - - -
- - {{ errorMessages[errorKey.toUpperCase()] }} - -
+ + Please select a hobby + + {{ hobby }} + + +
+ + {{ errorMessages[errorKey.toUpperCase()] }} + +
+ +
- - + +
- - - + +
+
+
diff --git a/frontend/src/app/shared/dialog/objective-dialog/objective-form.component.html b/frontend/src/app/shared/dialog/objective-dialog/objective-form.component.html index 28e461b3bf..8479d497d8 100644 --- a/frontend/src/app/shared/dialog/objective-dialog/objective-form.component.html +++ b/frontend/src/app/shared/dialog/objective-dialog/objective-form.component.html @@ -1,30 +1,9 @@ -
-
- -
-
- -
-
- - -
-
- - -
-
-
-
- + + + +
+ +