diff --git a/src/main/java/com/formswim/teststream/shared/domain/Tag.java b/src/main/java/com/formswim/teststream/shared/domain/Tag.java new file mode 100644 index 0000000..31fa30e --- /dev/null +++ b/src/main/java/com/formswim/teststream/shared/domain/Tag.java @@ -0,0 +1,67 @@ +package com.formswim.teststream.shared.domain; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table( + name = "custom_tags", + uniqueConstraints = { + @UniqueConstraint(name = "uk_custom_tags_team_name", columnNames = { "team_key", "name" }) + } +) +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String name; + + @Column(nullable = false, length = 7) + private String color; + + @Column(name = "team_key", nullable = false, length = 100) + private String teamKey; + + protected Tag() { + } + + public Tag(String name, String color, String teamKey) { + this.name = name; + this.color = color; + this.teamKey = teamKey; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + @JsonIgnore + public String getTeamKey() { + return teamKey; + } +} diff --git a/src/main/java/com/formswim/teststream/shared/domain/TagRepository.java b/src/main/java/com/formswim/teststream/shared/domain/TagRepository.java new file mode 100644 index 0000000..fbfa57c --- /dev/null +++ b/src/main/java/com/formswim/teststream/shared/domain/TagRepository.java @@ -0,0 +1,27 @@ +package com.formswim.teststream.shared.domain; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface TagRepository extends JpaRepository { + + List findByTeamKeyOrderByNameAsc(String teamKey); + + Optional findByTeamKeyAndId(String teamKey, Long id); + + boolean existsByTeamKeyAndName(String teamKey, String name); + + @Query(value = "SELECT tag_id, COUNT(*) FROM test_case_custom_tags WHERE tag_id IN (:tagIds) GROUP BY tag_id", nativeQuery = true) + List countUsageByTagIds(@Param("tagIds") List tagIds); + + @Modifying + @Query(value = "DELETE FROM test_case_custom_tags WHERE tag_id = :tagId", nativeQuery = true) + void deleteFromJoinTableByTagId(@Param("tagId") Long tagId); +} diff --git a/src/main/java/com/formswim/teststream/shared/domain/TestCase.java b/src/main/java/com/formswim/teststream/shared/domain/TestCase.java index 062b9fb..6c3a505 100644 --- a/src/main/java/com/formswim/teststream/shared/domain/TestCase.java +++ b/src/main/java/com/formswim/teststream/shared/domain/TestCase.java @@ -3,14 +3,20 @@ import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; import jakarta.persistence.OneToMany; import jakarta.persistence.OrderBy; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import org.hibernate.annotations.BatchSize; + import java.util.ArrayList; import java.util.List; @@ -104,6 +110,15 @@ public class TestCase { @OrderBy("stepNumber ASC") private List steps = new ArrayList<>(); + @ManyToMany(fetch = FetchType.LAZY) + @BatchSize(size = 50) + @JoinTable( + name = "test_case_custom_tags", + joinColumns = @JoinColumn(name = "test_case_id"), + inverseJoinColumns = @JoinColumn(name = "tag_id") + ) + private List customTags = new ArrayList<>(); + protected TestCase() { } @@ -351,4 +366,12 @@ public void setFlakyScore(String flakyScore) { public List getSteps() { return steps; } + + public List getCustomTags() { + return customTags; + } + + public void setCustomTags(List customTags) { + this.customTags = customTags; + } } diff --git a/src/main/java/com/formswim/teststream/shared/domain/TestCaseRepository.java b/src/main/java/com/formswim/teststream/shared/domain/TestCaseRepository.java index dae8e7a..2817fd0 100644 --- a/src/main/java/com/formswim/teststream/shared/domain/TestCaseRepository.java +++ b/src/main/java/com/formswim/teststream/shared/domain/TestCaseRepository.java @@ -27,7 +27,10 @@ public interface TestCaseRepository extends JpaRepository { and (:tag is null or :tag = '' or lower(coalesce(testCase.components, '')) like lower(concat('%', :tag, '%')) or lower(coalesce(testCase.testCaseType, '')) like lower(concat('%', :tag, '%'))) - and ( + and (:customTagId is null or exists ( + select 1 from testCase.customTags ct where ct.id = :customTagId + )) + and ( :folder is null or :folder = '' or lower(trim(function('replace', coalesce(testCase.folder, ''), '\\', '/'))) = lower(:folder) or lower(trim(function('replace', coalesce(testCase.folder, ''), '\\', '/'))) like lower(concat(:folder, '/%')) @@ -45,6 +48,10 @@ or exists ( where testStep.testCase = testCase and lower(coalesce(testStep.stepSummary, '')) like lower(concat('%', :search, '%')) ) + or exists ( + select 1 from testCase.customTags ct + where lower(ct.name) like lower(concat('%', :search, '%')) + ) ) order by testCase.id desc """, @@ -57,7 +64,10 @@ select count(distinct testCase.id) and (:tag is null or :tag = '' or lower(coalesce(testCase.components, '')) like lower(concat('%', :tag, '%')) or lower(coalesce(testCase.testCaseType, '')) like lower(concat('%', :tag, '%'))) - and ( + and (:customTagId is null or exists ( + select 1 from testCase.customTags ct where ct.id = :customTagId + )) + and ( :folder is null or :folder = '' or lower(trim(function('replace', coalesce(testCase.folder, ''), '\\', '/'))) = lower(:folder) or lower(trim(function('replace', coalesce(testCase.folder, ''), '\\', '/'))) like lower(concat(:folder, '/%')) @@ -75,6 +85,10 @@ or exists ( where testStep.testCase = testCase and lower(coalesce(testStep.stepSummary, '')) like lower(concat('%', :search, '%')) ) + or exists ( + select 1 from testCase.customTags ct + where lower(ct.name) like lower(concat('%', :search, '%')) + ) ) """ ) @@ -83,6 +97,7 @@ Page findWorkspaceCaseIdsByFilters(@Param("teamKey") String teamKey, @Param("status") String status, @Param("component") String component, @Param("tag") String tag, + @Param("customTagId") Long customTagId, @Param("folder") String folder, Pageable pageable); @@ -133,6 +148,15 @@ int bulkMoveToFolderByTeamKeyAndWorkKeys(@Param("teamKey") String teamKey, List findAllWithStepsByTeamKeyAndWorkKeyIn(String teamKey, Collection workKeys); + @Query(""" + select distinct testCase + from TestCase testCase + left join fetch testCase.customTags + where testCase.teamKey = :teamKey and testCase.workKey = :workKey + """) + Optional findWithCustomTagsByTeamKeyAndWorkKey(@Param("teamKey") String teamKey, + @Param("workKey") String workKey); + @Query(""" select distinct trim(function('replace', testCase.folder, '\\', '/')) from TestCase testCase @@ -235,4 +259,6 @@ int bulkRepathDescendants(@Param("teamKey") String teamKey, @Param("suffixStartIndex") int suffixStartIndex); List findByTeamKeyAndStatus(String teamKey, String status); + + List findByTeamKeyAndWorkKeyIn(String teamKey, Collection workKeys); } diff --git a/src/main/java/com/formswim/teststream/workspace/controllers/WorkspaceMetadataController.java b/src/main/java/com/formswim/teststream/workspace/controllers/WorkspaceMetadataController.java index 3df0769..7e66657 100644 --- a/src/main/java/com/formswim/teststream/workspace/controllers/WorkspaceMetadataController.java +++ b/src/main/java/com/formswim/teststream/workspace/controllers/WorkspaceMetadataController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; @@ -28,9 +29,16 @@ import com.formswim.teststream.workspace.dto.FolderCreateRequest; import com.formswim.teststream.workspace.dto.FolderResponse; import com.formswim.teststream.workspace.dto.FolderUpdateRequest; +import com.formswim.teststream.workspace.dto.BulkTagRequest; +import com.formswim.teststream.workspace.dto.TagRequest; +import com.formswim.teststream.workspace.dto.TagResponse; import com.formswim.teststream.workspace.services.FolderBadRequestException; import com.formswim.teststream.workspace.services.FolderConflictException; import com.formswim.teststream.workspace.services.FolderNotFoundException; +import com.formswim.teststream.workspace.services.TagBadRequestException; +import com.formswim.teststream.workspace.services.TagConflictException; +import com.formswim.teststream.workspace.services.TagNotFoundException; +import com.formswim.teststream.workspace.services.TagService; import com.formswim.teststream.workspace.services.WorkspaceFolderService; import com.formswim.teststream.workspace.services.WorkspaceFolderMutationService; import com.formswim.teststream.workspace.services.WorkspaceQueryService; @@ -47,19 +55,21 @@ public class WorkspaceMetadataController { private final WorkspaceQueryService workspaceQueryService; private final WorkspaceFolderService workspaceFolderService; private final WorkspaceFolderMutationService workspaceFolderMutationService; - + private final TagService tagService; public WorkspaceMetadataController(TestCaseRepository testCaseRepository, CurrentUserService currentUserService, - WorkspaceQueryService workspaceQueryService, + WorkspaceQueryService workspaceQueryService, WorkspaceFolderService workspaceFolderService, - WorkspaceFolderMutationService workspaceFolderMutationService + WorkspaceFolderMutationService workspaceFolderMutationService, + TagService tagService ) { this.testCaseRepository = testCaseRepository; this.currentUserService = currentUserService; this.workspaceQueryService = workspaceQueryService; this.workspaceFolderService = workspaceFolderService; this.workspaceFolderMutationService = workspaceFolderMutationService; + this.tagService = tagService; } /** * GET /api/folders @@ -259,4 +269,134 @@ public ResponseEntity> getStatusesByTeamKey(HttpSession session, Au .collect(Collectors.toList()); return ResponseEntity.ok(statuses); } + + @GetMapping("/api/custom-tags") + @ResponseBody + public ResponseEntity> getCustomTags(HttpSession session, Authentication authentication) { + Optional currentUser = currentUserService.resolveCurrentUser(session, authentication); + if (currentUser.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + AppUser user = currentUser.get(); + if (user.getTeamKey() == null || user.getTeamKey().isBlank()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return ResponseEntity.ok(tagService.listTags(user.getTeamKey())); + } + + @PostMapping("/api/custom-tags") + @ResponseBody + public ResponseEntity createCustomTag(@RequestBody TagRequest request, + HttpSession session, + Authentication authentication) { + Optional currentUser = currentUserService.resolveCurrentUser(session, authentication); + if (currentUser.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + AppUser user = currentUser.get(); + if (user.getTeamKey() == null || user.getTeamKey().isBlank()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + try { + TagResponse created = tagService.createTag(user.getTeamKey(), request); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } catch (TagBadRequestException ex) { + return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage())); + } catch (TagConflictException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("message", ex.getMessage())); + } + } + + @PatchMapping("/api/custom-tags/{id}") + @ResponseBody + public ResponseEntity updateCustomTag(@PathVariable Long id, + @RequestBody TagRequest request, + HttpSession session, + Authentication authentication) { + Optional currentUser = currentUserService.resolveCurrentUser(session, authentication); + if (currentUser.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + AppUser user = currentUser.get(); + if (user.getTeamKey() == null || user.getTeamKey().isBlank()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + try { + TagResponse updated = tagService.updateTag(user.getTeamKey(), id, request); + return ResponseEntity.ok(updated); + } catch (TagBadRequestException ex) { + return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage())); + } catch (TagConflictException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("message", ex.getMessage())); + } catch (TagNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", ex.getMessage())); + } + } + + @DeleteMapping("/api/custom-tags/{id}") + @ResponseBody + public ResponseEntity deleteCustomTag(@PathVariable Long id, + HttpSession session, + Authentication authentication) { + Optional currentUser = currentUserService.resolveCurrentUser(session, authentication); + if (currentUser.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + AppUser user = currentUser.get(); + if (user.getTeamKey() == null || user.getTeamKey().isBlank()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + try { + tagService.deleteTag(user.getTeamKey(), id); + return ResponseEntity.noContent().build(); + } catch (TagNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", ex.getMessage())); + } + } + + @PutMapping("/api/testcases/{workKey}/custom-tags") + @ResponseBody + public ResponseEntity setTestCaseTags(@PathVariable String workKey, + @RequestBody List tagIds, + HttpSession session, + Authentication authentication) { + Optional currentUser = currentUserService.resolveCurrentUser(session, authentication); + if (currentUser.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + AppUser user = currentUser.get(); + if (user.getTeamKey() == null || user.getTeamKey().isBlank()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + try { + List tags = tagService.setTagsForTestCase(user.getTeamKey(), workKey, tagIds); + return ResponseEntity.ok(tags); + } catch (TagNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", ex.getMessage())); + } + } + + @PatchMapping("/api/testcases/bulk-custom-tags") + @ResponseBody + public ResponseEntity bulkSetCustomTag(@RequestBody BulkTagRequest request, + HttpSession session, + Authentication authentication) { + Optional currentUser = currentUserService.resolveCurrentUser(session, authentication); + if (currentUser.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + AppUser user = currentUser.get(); + if (user.getTeamKey() == null || user.getTeamKey().isBlank()) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + if (request.getTagId() == null || request.getWorkKeys() == null || request.getWorkKeys().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("message", "tagId and workKeys are required.")); + } + try { + tagService.bulkSetTag(user.getTeamKey(), request.getWorkKeys(), request.getTagId(), request.isAdd()); + return ResponseEntity.noContent().build(); + } catch (TagNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", ex.getMessage())); + } + } } diff --git a/src/main/java/com/formswim/teststream/workspace/controllers/WorkspacePageController.java b/src/main/java/com/formswim/teststream/workspace/controllers/WorkspacePageController.java index 291e55a..32bd68d 100644 --- a/src/main/java/com/formswim/teststream/workspace/controllers/WorkspacePageController.java +++ b/src/main/java/com/formswim/teststream/workspace/controllers/WorkspacePageController.java @@ -20,6 +20,7 @@ import com.formswim.teststream.auth.service.CurrentUserService; import com.formswim.teststream.shared.domain.TestCase; import com.formswim.teststream.shared.domain.TestCaseRepository; +import com.formswim.teststream.workspace.dto.TagResponse; import com.formswim.teststream.workspace.services.WorkspaceQueryService; import jakarta.servlet.http.HttpSession; @@ -76,6 +77,7 @@ public String workspace(HttpSession session, workspaceQueryService.normalizeQueryParam(component), null, null, + null, pageable ); @@ -145,6 +147,9 @@ public String testCaseDetails(@PathVariable String id, model.addAttribute("detailsUpdatedOn", testCase.getUpdatedOn()); model.addAttribute("detailsStoryLinkages", testCase.getStoryLinkages()); model.addAttribute("detailsSteps", testCase.getSteps()); + model.addAttribute("detailsCustomTags", testCase.getCustomTags().stream() + .map(TagResponse::from) + .collect(java.util.stream.Collectors.toList())); return "test-case-details"; } diff --git a/src/main/java/com/formswim/teststream/workspace/controllers/WorkspaceQueryController.java b/src/main/java/com/formswim/teststream/workspace/controllers/WorkspaceQueryController.java index 66c1073..bd8efce 100644 --- a/src/main/java/com/formswim/teststream/workspace/controllers/WorkspaceQueryController.java +++ b/src/main/java/com/formswim/teststream/workspace/controllers/WorkspaceQueryController.java @@ -49,6 +49,7 @@ public ResponseEntity> apiGetTestCases(HttpSession session, @RequestParam(required = false) String status, @RequestParam(required = false) String component, @RequestParam(required = false) String tag, + @RequestParam(required = false) Long customTagId, @RequestParam(required = false) String folder, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "50") int size, @@ -78,6 +79,7 @@ public ResponseEntity> apiGetTestCases(HttpSession session, normalizedStatus, normalizedComponent, normalizedTag, + customTagId, null, pageable ); @@ -98,6 +100,7 @@ public ResponseEntity> apiGetTestCases(HttpSession session, normalizedStatus, normalizedComponent, normalizedTag, + customTagId, null, Pageable.unpaged() ); diff --git a/src/main/java/com/formswim/teststream/workspace/dto/BulkTagRequest.java b/src/main/java/com/formswim/teststream/workspace/dto/BulkTagRequest.java new file mode 100644 index 0000000..04584d1 --- /dev/null +++ b/src/main/java/com/formswim/teststream/workspace/dto/BulkTagRequest.java @@ -0,0 +1,19 @@ +package com.formswim.teststream.workspace.dto; + +import java.util.List; + +public class BulkTagRequest { + + private List workKeys; + private Long tagId; + private boolean add; + + public List getWorkKeys() { return workKeys; } + public void setWorkKeys(List workKeys) { this.workKeys = workKeys; } + + public Long getTagId() { return tagId; } + public void setTagId(Long tagId) { this.tagId = tagId; } + + public boolean isAdd() { return add; } + public void setAdd(boolean add) { this.add = add; } +} diff --git a/src/main/java/com/formswim/teststream/workspace/dto/TagRequest.java b/src/main/java/com/formswim/teststream/workspace/dto/TagRequest.java new file mode 100644 index 0000000..e7a46d9 --- /dev/null +++ b/src/main/java/com/formswim/teststream/workspace/dto/TagRequest.java @@ -0,0 +1,23 @@ +package com.formswim.teststream.workspace.dto; + +public class TagRequest { + + private String name; + private String color; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } +} diff --git a/src/main/java/com/formswim/teststream/workspace/dto/TagResponse.java b/src/main/java/com/formswim/teststream/workspace/dto/TagResponse.java new file mode 100644 index 0000000..4917541 --- /dev/null +++ b/src/main/java/com/formswim/teststream/workspace/dto/TagResponse.java @@ -0,0 +1,42 @@ +package com.formswim.teststream.workspace.dto; + +import com.formswim.teststream.shared.domain.Tag; + +public class TagResponse { + + private final Long id; + private final String name; + private final String color; + private final long usageCount; + + public TagResponse(Long id, String name, String color, long usageCount) { + this.id = id; + this.name = name; + this.color = color; + this.usageCount = usageCount; + } + + public static TagResponse from(Tag tag) { + return new TagResponse(tag.getId(), tag.getName(), tag.getColor(), 0); + } + + public static TagResponse from(Tag tag, long usageCount) { + return new TagResponse(tag.getId(), tag.getName(), tag.getColor(), usageCount); + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getColor() { + return color; + } + + public long getUsageCount() { + return usageCount; + } +} diff --git a/src/main/java/com/formswim/teststream/workspace/services/TagBadRequestException.java b/src/main/java/com/formswim/teststream/workspace/services/TagBadRequestException.java new file mode 100644 index 0000000..cbf8adc --- /dev/null +++ b/src/main/java/com/formswim/teststream/workspace/services/TagBadRequestException.java @@ -0,0 +1,7 @@ +package com.formswim.teststream.workspace.services; + +public class TagBadRequestException extends RuntimeException { + public TagBadRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/com/formswim/teststream/workspace/services/TagConflictException.java b/src/main/java/com/formswim/teststream/workspace/services/TagConflictException.java new file mode 100644 index 0000000..dadcbdc --- /dev/null +++ b/src/main/java/com/formswim/teststream/workspace/services/TagConflictException.java @@ -0,0 +1,7 @@ +package com.formswim.teststream.workspace.services; + +public class TagConflictException extends RuntimeException { + public TagConflictException(String message) { + super(message); + } +} diff --git a/src/main/java/com/formswim/teststream/workspace/services/TagNotFoundException.java b/src/main/java/com/formswim/teststream/workspace/services/TagNotFoundException.java new file mode 100644 index 0000000..727c1c4 --- /dev/null +++ b/src/main/java/com/formswim/teststream/workspace/services/TagNotFoundException.java @@ -0,0 +1,7 @@ +package com.formswim.teststream.workspace.services; + +public class TagNotFoundException extends RuntimeException { + public TagNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/formswim/teststream/workspace/services/TagService.java b/src/main/java/com/formswim/teststream/workspace/services/TagService.java new file mode 100644 index 0000000..1b522e0 --- /dev/null +++ b/src/main/java/com/formswim/teststream/workspace/services/TagService.java @@ -0,0 +1,147 @@ +package com.formswim.teststream.workspace.services; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.formswim.teststream.shared.domain.Tag; +import com.formswim.teststream.shared.domain.TagRepository; +import com.formswim.teststream.shared.domain.TestCase; +import com.formswim.teststream.shared.domain.TestCaseRepository; +import com.formswim.teststream.workspace.dto.TagRequest; +import com.formswim.teststream.workspace.dto.TagResponse; + +@Service +public class TagService { + + private static final Pattern HEX_COLOR = Pattern.compile("^#[0-9A-Fa-f]{6}$"); + + private final TagRepository tagRepository; + private final TestCaseRepository testCaseRepository; + + public TagService(TagRepository tagRepository, TestCaseRepository testCaseRepository) { + this.tagRepository = tagRepository; + this.testCaseRepository = testCaseRepository; + } + + public List listTags(String teamKey) { + List tags = tagRepository.findByTeamKeyOrderByNameAsc(teamKey); + if (tags.isEmpty()) { + return List.of(); + } + + List tagIds = tags.stream().map(Tag::getId).collect(Collectors.toList()); + Map countByTagId = new HashMap<>(); + for (Object[] row : tagRepository.countUsageByTagIds(tagIds)) { + countByTagId.put(((Number) row[0]).longValue(), ((Number) row[1]).longValue()); + } + + return tags.stream() + .map(tag -> TagResponse.from(tag, countByTagId.getOrDefault(tag.getId(), 0L))) + .collect(Collectors.toList()); + } + + @Transactional + public TagResponse createTag(String teamKey, TagRequest request) { + String name = request.getName() == null ? "" : request.getName().trim(); + String color = request.getColor() == null ? "" : request.getColor().trim(); + + if (name.isEmpty()) { + throw new TagBadRequestException("Tag name is required."); + } + if (name.length() > 50) { + throw new TagBadRequestException("Tag name must be 50 characters or fewer."); + } + if (!HEX_COLOR.matcher(color).matches()) { + throw new TagBadRequestException("Tag color must be a valid hex color (e.g. #e11d48)."); + } + if (tagRepository.existsByTeamKeyAndName(teamKey, name)) { + throw new TagConflictException("A tag named \"" + name + "\" already exists."); + } + + Tag tag = tagRepository.save(new Tag(name, color, teamKey)); + return TagResponse.from(tag); + } + + @Transactional + public TagResponse updateTag(String teamKey, Long tagId, TagRequest request) { + String name = request.getName() == null ? "" : request.getName().trim(); + String color = request.getColor() == null ? "" : request.getColor().trim(); + + if (name.isEmpty()) { + throw new TagBadRequestException("Tag name is required."); + } + if (name.length() > 50) { + throw new TagBadRequestException("Tag name must be 50 characters or fewer."); + } + if (!HEX_COLOR.matcher(color).matches()) { + throw new TagBadRequestException("Tag color must be a valid hex color (e.g. #e11d48)."); + } + + Tag tag = tagRepository.findByTeamKeyAndId(teamKey, tagId) + .orElseThrow(() -> new TagNotFoundException("Tag not found.")); + + if (!tag.getName().equals(name) && tagRepository.existsByTeamKeyAndName(teamKey, name)) { + throw new TagConflictException("A tag named \"" + name + "\" already exists."); + } + + tag.setName(name); + tag.setColor(color); + tagRepository.save(tag); + + return TagResponse.from(tag); + } + + @Transactional + public void bulkSetTag(String teamKey, List workKeys, Long tagId, boolean add) { + if (workKeys == null || workKeys.isEmpty()) return; + + Tag tag = tagRepository.findByTeamKeyAndId(teamKey, tagId) + .orElseThrow(() -> new TagNotFoundException("Tag not found.")); + + List cases = testCaseRepository.findByTeamKeyAndWorkKeyIn(teamKey, workKeys); + for (TestCase tc : cases) { + List tags = new ArrayList<>(tc.getCustomTags()); + if (add) { + if (tags.stream().noneMatch(t -> t.getId().equals(tagId))) { + tags.add(tag); + } + } else { + tags.removeIf(t -> t.getId().equals(tagId)); + } + tc.setCustomTags(tags); + } + testCaseRepository.saveAll(cases); + } + + @Transactional + public void deleteTag(String teamKey, Long tagId) { + Tag tag = tagRepository.findByTeamKeyAndId(teamKey, tagId) + .orElseThrow(() -> new TagNotFoundException("Tag not found.")); + tagRepository.deleteFromJoinTableByTagId(tagId); + tagRepository.delete(tag); + } + + @Transactional + public List setTagsForTestCase(String teamKey, String workKey, List tagIds) { + TestCase testCase = testCaseRepository.findByTeamKeyAndWorkKey(teamKey, workKey) + .orElseThrow(() -> new TagNotFoundException("Test case not found.")); + + List ids = tagIds == null ? List.of() : tagIds; + List tags = ids.stream() + .map((Long id) -> tagRepository.findByTeamKeyAndId(teamKey, id) + .orElseThrow(() -> new TagNotFoundException("Tag " + id + " not found."))) + .collect(Collectors.toList()); + + testCase.setCustomTags(tags); + testCaseRepository.save(testCase); + + return tags.stream().map(TagResponse::from).collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/formswim/teststream/workspace/services/WorkspaceQueryService.java b/src/main/java/com/formswim/teststream/workspace/services/WorkspaceQueryService.java index ebc7d04..e41d7c4 100644 --- a/src/main/java/com/formswim/teststream/workspace/services/WorkspaceQueryService.java +++ b/src/main/java/com/formswim/teststream/workspace/services/WorkspaceQueryService.java @@ -10,6 +10,7 @@ import java.util.stream.Collectors; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import com.formswim.teststream.shared.domain.TestCase; import com.formswim.teststream.shared.domain.TestCaseRepository; @@ -30,12 +31,14 @@ public String normalizeQueryParam(String value) { return normalized.isEmpty() ? null : normalized; } + @Transactional(readOnly = true) public List findCasesByOrderedIds(List ids) { if (ids == null || ids.isEmpty()) { return List.of(); } List cases = testCaseRepository.findAllWithStepsByIdIn(ids); + cases.forEach(tc -> tc.getCustomTags().size()); // initialize within transaction via @BatchSize(50) Map positionById = new HashMap<>(); for (int index = 0; index < ids.size(); index++) { positionById.put(ids.get(index), index); diff --git a/src/main/resources/static/js/workspace/api/workspace-page-api.js b/src/main/resources/static/js/workspace/api/workspace-page-api.js index d36952a..2bacf4d 100644 --- a/src/main/resources/static/js/workspace/api/workspace-page-api.js +++ b/src/main/resources/static/js/workspace/api/workspace-page-api.js @@ -212,15 +212,23 @@ export function createWorkspacePageApi(options) { return parseJsonOrEmpty(response); } + function fetchCustomTags() { + return fetch('/api/custom-tags') + .then((r) => r.ok ? r.json() : []) + .then((data) => (Array.isArray(data) ? data : [])); + } + function fetchFilterOptions() { return Promise.all([ fetchOptionValues(componentsBaseUrl), fetchOptionValues(statusesBaseUrl), - fetchOptionValues(tagsBaseUrl) - ]).then(([components, statuses, tags]) => ({ + fetchOptionValues(tagsBaseUrl), + fetchCustomTags() + ]).then(([components, statuses, tags, customTags]) => ({ components, statuses, - tags + tags, + customTags })); } diff --git a/src/main/resources/static/js/workspace/components/workspace-bulk-tags.js b/src/main/resources/static/js/workspace/components/workspace-bulk-tags.js new file mode 100644 index 0000000..42cfefe --- /dev/null +++ b/src/main/resources/static/js/workspace/components/workspace-bulk-tags.js @@ -0,0 +1,237 @@ +const TAG_COLORS = [ + { label: 'Red', value: '#ef4444' }, + { label: 'Orange', value: '#f97316' }, + { label: 'Amber', value: '#f59e0b' }, + { label: 'Yellow', value: '#eab308' }, + { label: 'Lime', value: '#84cc16' }, + { label: 'Green', value: '#22c55e' }, + { label: 'Emerald', value: '#10b981' }, + { label: 'Teal', value: '#14b8a6' }, + { label: 'Cyan', value: '#06b6d4' }, + { label: 'Blue', value: '#3b82f6' }, + { label: 'Indigo', value: '#6366f1' }, + { label: 'Violet', value: '#8b5cf6' }, + { label: 'Purple', value: '#a855f7' }, + { label: 'Pink', value: '#ec4899' }, + { label: 'Rose', value: '#f43f5e' }, + { label: 'Gray', value: '#6b7280' }, +]; + +function escHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export function createBulkTagAssignment({ getSelectedIds, getCurrentPageCases, getCsrf, onComplete }) { + let popover = null; + let anchor = null; + let allTags = []; + let newTagColor = TAG_COLORS[0].value; + + function loadAllTags() { + return fetch('/api/custom-tags') + .then((r) => r.ok ? r.json() : []) + .then((data) => { allTags = Array.isArray(data) ? data : []; }); + } + + function getTagCounts() { + const selectedSet = new Set(getSelectedIds()); + const cases = getCurrentPageCases().filter((tc) => selectedSet.has(tc.workKey)); + const total = cases.length; + const counts = new Map(); + for (const tc of cases) { + for (const tag of (tc.customTags || [])) { + counts.set(tag.id, (counts.get(tag.id) || 0) + 1); + } + } + return { counts, total }; + } + + function applyTag(tagId, add) { + const csrf = getCsrf(); + return fetch('/api/testcases/bulk-custom-tags', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', [csrf.headerName]: csrf.token }, + body: JSON.stringify({ workKeys: getSelectedIds(), tagId, add }) + }).then((r) => { if (!r.ok) throw new Error('Failed to update tags'); }); + } + + function createTag(name, color) { + const csrf = getCsrf(); + return fetch('/api/custom-tags', { + method: 'POST', + headers: { 'Content-Type': 'application/json', [csrf.headerName]: csrf.token }, + body: JSON.stringify({ name, color }) + }).then((r) => r.json().then((body) => { + if (!r.ok) throw new Error(body?.message || 'Failed to create tag'); + return body; + })); + } + + function renderPopover() { + if (!popover) return; + + const { counts, total } = getTagCounts(); + + if (total === 0) { + popover.innerHTML = '

No test cases selected.

'; + return; + } + + const list = allTags.map((tag) => { + const count = counts.get(tag.id) || 0; + const allHave = count === total; + const countBadge = count > 0 + ? `${count}/${total}` + : ''; + return `
+ ${escHtml(tag.name)} + ${countBadge} +
+ ${!allHave ? `` : ''} + ${count > 0 ? `` : ''} +
+
`; + }).join(''); + + const colorSwatches = TAG_COLORS.map((c) => { + const sel = c.value === newTagColor ? 'ring-2 ring-white/60' : ''; + return ``; + }).join(''); + + popover.innerHTML = ` +
${total} selected
+
${list || '

No tags yet.

'}
+
+
New tag
+
${colorSwatches}
+
+ + +
+ +
`; + + // ── Assign existing tag ───────────────────────────────────────────── + popover.querySelectorAll('.ws-bulk-tag-add').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const tagId = Number(btn.dataset.tagId); + btn.textContent = '…'; + btn.disabled = true; + applyTag(tagId, true) + .then(() => onComplete()) + .then(() => renderPopover()) + .catch((err) => { console.error(err); renderPopover(); }); + }); + }); + + // ── Remove existing tag ───────────────────────────────────────────── + popover.querySelectorAll('.ws-bulk-tag-remove').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const tagId = Number(btn.dataset.tagId); + btn.textContent = '…'; + btn.disabled = true; + applyTag(tagId, false) + .then(() => onComplete()) + .then(() => renderPopover()) + .catch((err) => { console.error(err); renderPopover(); }); + }); + }); + + // ── Color swatches ────────────────────────────────────────────────── + popover.querySelectorAll('.ws-bulk-color-swatch').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const savedName = popover.querySelector('#wsBulkNewTagName')?.value || ''; + newTagColor = btn.dataset.color; + renderPopover(); + const nameInput = popover.querySelector('#wsBulkNewTagName'); + if (nameInput) { nameInput.value = savedName; nameInput.focus(); } + }); + }); + + // ── Create + assign new tag ───────────────────────────────────────── + const createBtn = popover.querySelector('#wsBulkNewTagCreate'); + const nameInput = popover.querySelector('#wsBulkNewTagName'); + const errorEl = popover.querySelector('#wsBulkNewTagError'); + + function showError(msg) { + if (errorEl) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); } + } + + function handleCreate() { + const name = (nameInput?.value || '').trim(); + if (!name) { showError('Tag name is required.'); return; } + if (createBtn) { createBtn.textContent = '…'; createBtn.disabled = true; } + createTag(name, newTagColor) + .then((tag) => { + allTags.push(tag); + allTags.sort((a, b) => a.name.localeCompare(b.name)); + return applyTag(tag.id, true); + }) + .then(() => onComplete()) + .then(() => renderPopover()) + .catch((err) => { + if (createBtn) { createBtn.textContent = 'Add'; createBtn.disabled = false; } + showError(err.message || 'Could not create tag.'); + }); + } + + if (createBtn) createBtn.addEventListener('click', handleCreate); + if (nameInput) { + nameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); handleCreate(); } + if (e.key === 'Escape') close(); + }); + } + } + + function positionPopover() { + if (!popover || !anchor) return; + const rect = anchor.getBoundingClientRect(); + const popoverHeight = popover.offsetHeight || 320; + popover.style.left = Math.min(rect.left, window.innerWidth - 260) + 'px'; + if (rect.top - popoverHeight - 4 >= 0) { + popover.style.top = (rect.top - popoverHeight - 4) + 'px'; + } else { + popover.style.top = (rect.bottom + 4) + 'px'; + } + } + + function onOutsideClick(e) { + if (popover && !popover.contains(e.target) && e.target !== anchor && !anchor?.contains(e.target)) { + close(); + } + } + + function open(anchorEl) { + if (anchor === anchorEl && popover) { close(); return; } + close(); + anchor = anchorEl; + popover = document.createElement('div'); + popover.className = 'fixed z-50 w-64 border border-white/20 bg-black shadow-xl'; + document.body.appendChild(popover); + + loadAllTags().then(() => { + renderPopover(); + positionPopover(); + }); + + setTimeout(() => { document.addEventListener('mousedown', onOutsideClick); }, 0); + } + + function close() { + if (popover) { popover.remove(); popover = null; } + anchor = null; + newTagColor = TAG_COLORS[0].value; + document.removeEventListener('mousedown', onOutsideClick); + } + + return { open, close }; +} diff --git a/src/main/resources/static/js/workspace/components/workspace-inline-preview-row.js b/src/main/resources/static/js/workspace/components/workspace-inline-preview-row.js index 4496857..693293f 100644 --- a/src/main/resources/static/js/workspace/components/workspace-inline-preview-row.js +++ b/src/main/resources/static/js/workspace/components/workspace-inline-preview-row.js @@ -36,6 +36,15 @@ function renderMetaItem(label, value) { ''; } +function renderCustomTagBadges(testCase) { + const tags = Array.isArray(testCase?.customTags) ? testCase.customTags : []; + if (tags.length === 0) return '-'; + return tags.map((tag) => { + const color = escapeHtml(tag.color || '#6b7280'); + return '' + escapeHtml(tag.name || '') + ''; + }).join(''); +} + function renderTagBadges(testCase) { const values = []; values.push(...parseDelimited(testCase?.components)); @@ -148,9 +157,13 @@ export function renderInlinePreviewRow(testCase, options) { '

' + textOrDash(testCase?.precondition) + '

' + '' + '
' + - '

Tags

' + + '

Type

' + '
' + renderTagBadges(testCase) + '
' + '
' + + '
' + + '

Tags

' + + '
' + renderCustomTagBadges(testCase) + '
' + + '
' + '
' + '
' + '

Steps

' + diff --git a/src/main/resources/static/js/workspace/components/workspace-tags.js b/src/main/resources/static/js/workspace/components/workspace-tags.js new file mode 100644 index 0000000..e57d1b6 --- /dev/null +++ b/src/main/resources/static/js/workspace/components/workspace-tags.js @@ -0,0 +1,408 @@ +// Color palette for custom tags +const TAG_COLORS = [ + { label: 'Red', value: '#ef4444' }, + { label: 'Orange', value: '#f97316' }, + { label: 'Amber', value: '#f59e0b' }, + { label: 'Yellow', value: '#eab308' }, + { label: 'Lime', value: '#84cc16' }, + { label: 'Green', value: '#22c55e' }, + { label: 'Emerald', value: '#10b981' }, + { label: 'Teal', value: '#14b8a6' }, + { label: 'Cyan', value: '#06b6d4' }, + { label: 'Blue', value: '#3b82f6' }, + { label: 'Indigo', value: '#6366f1' }, + { label: 'Violet', value: '#8b5cf6' }, + { label: 'Purple', value: '#a855f7' }, + { label: 'Pink', value: '#ec4899' }, + { label: 'Rose', value: '#f43f5e' }, + { label: 'Gray', value: '#6b7280' }, +]; + +function getCsrfHeaders() { + const headerName = document.querySelector('meta[name="_csrf_header"]')?.content || 'X-CSRF-TOKEN'; + const token = document.querySelector('meta[name="_csrf"]')?.content || ''; + return { [headerName]: token }; +} + +function escHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export function createWorkspaceTags({ onTagsChanged, onTagListChanged }) { + let popover = null; + let currentAnchor = null; + let currentWorkKey = null; + let allTags = []; + let assignedTagIds = new Set(); + let newTagColor = TAG_COLORS[0].value; + let editingTagId = null; + let editTagColor = TAG_COLORS[0].value; + + function loadAllTags() { + return fetch('/api/custom-tags') + .then((r) => r.ok ? r.json() : []) + .then((data) => { allTags = Array.isArray(data) ? data : []; }); + } + + function saveTags(workKey, tagIds) { + return fetch('/api/testcases/' + encodeURIComponent(workKey) + '/custom-tags', { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + body: JSON.stringify(tagIds) + }).then((r) => r.ok ? r.json() : Promise.reject(new Error('Failed to save tags'))); + } + + function createTag(name, color) { + return fetch('/api/custom-tags', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + body: JSON.stringify({ name, color }) + }).then((r) => r.json().then((body) => { + if (!r.ok) throw new Error(body?.message || 'Failed to create tag'); + return body; + })); + } + + function updateTag(tagId, name, color) { + return fetch('/api/custom-tags/' + encodeURIComponent(tagId), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + body: JSON.stringify({ name, color }) + }).then((r) => r.json().then((body) => { + if (!r.ok) throw new Error(body?.message || 'Failed to update tag'); + return body; + })); + } + + function deleteTag(tagId) { + return fetch('/api/custom-tags/' + encodeURIComponent(tagId), { + method: 'DELETE', + headers: getCsrfHeaders() + }).then((r) => { if (!r.ok && r.status !== 204) throw new Error('Failed to delete tag'); }); + } + + function renderPopover() { + if (!popover) return; + + const list = allTags.map((tag) => { + // ── Edit mode for this row ────────────────────────────────────── + if (tag.id === editingTagId) { + const editSwatches = TAG_COLORS.map((c) => { + const sel = c.value === editTagColor ? 'ring-2 ring-white/60' : ''; + return ``; + }).join(''); + return `
+
${editSwatches}
+
+ + + +
+ +
`; + } + + // ── Normal row ────────────────────────────────────────────────── + const checked = assignedTagIds.has(tag.id) ? 'checked' : ''; + const checkId = 'ws-tag-check-' + tag.id; + const usageCount = tag.usageCount || 0; + const usageBadge = usageCount > 0 + ? `${usageCount}` + : ''; + return `
+ + + ${usageBadge} + + +
`; + }).join(''); + + const colorSwatches = TAG_COLORS.map((c) => { + const selected = c.value === newTagColor ? 'ring-2 ring-white/60' : ''; + return ``; + }).join(''); + + popover.innerHTML = ` +
Assign tags
+
${list || '

No tags yet.

'}
+
+
New tag
+
${colorSwatches}
+
+ + +
+ +
`; + + // ── Checkbox toggles ──────────────────────────────────────────────── + popover.querySelectorAll('.ws-tag-check').forEach((checkbox) => { + checkbox.addEventListener('change', () => { + const tagId = Number(checkbox.dataset.tagId); + const previouslyChecked = !checkbox.checked; + + if (checkbox.checked) { + assignedTagIds.add(tagId); + } else { + assignedTagIds.delete(tagId); + } + + if (typeof onTagsChanged === 'function') { + const optimisticTags = allTags.filter((t) => assignedTagIds.has(t.id)); + onTagsChanged({ workKey: currentWorkKey, tags: optimisticTags }); + } + + const savedWorkKey = currentWorkKey; + saveTags(savedWorkKey, Array.from(assignedTagIds)) + .then((updatedTags) => { + if (typeof onTagsChanged === 'function') { + onTagsChanged({ workKey: savedWorkKey, tags: updatedTags }); + } + }) + .catch((err) => { + console.error('Failed to save tags:', err); + if (previouslyChecked) { + assignedTagIds.add(tagId); + checkbox.checked = true; + } else { + assignedTagIds.delete(tagId); + checkbox.checked = false; + } + if (typeof onTagsChanged === 'function') { + const rolledBackTags = allTags.filter((t) => assignedTagIds.has(t.id)); + onTagsChanged({ workKey: savedWorkKey, tags: rolledBackTags }); + } + }); + }); + }); + + // ── Edit buttons ──────────────────────────────────────────────────── + popover.querySelectorAll('.ws-tag-edit-btn').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + editingTagId = Number(btn.dataset.tagId); + editTagColor = btn.dataset.tagColor; + renderPopover(); + popover.querySelector('.ws-tag-edit-name')?.focus(); + }); + }); + + // ── Edit color swatches ───────────────────────────────────────────── + popover.querySelectorAll('.ws-edit-color-swatch').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const savedName = popover.querySelector('.ws-tag-edit-name')?.value || ''; + editTagColor = btn.dataset.color; + renderPopover(); + const nameInput = popover.querySelector('.ws-tag-edit-name'); + if (nameInput) { nameInput.value = savedName; nameInput.focus(); } + }); + }); + + // ── Edit cancel ───────────────────────────────────────────────────── + popover.querySelectorAll('.ws-tag-edit-cancel').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + editingTagId = null; + renderPopover(); + }); + }); + + // ── Edit save ─────────────────────────────────────────────────────── + popover.querySelectorAll('.ws-tag-edit-save').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const tagId = Number(btn.dataset.tagId); + const row = btn.closest('[data-edit-tag-id]'); + const nameInput = row?.querySelector('.ws-tag-edit-name'); + const errorEl = row?.querySelector('.ws-tag-edit-error'); + const name = (nameInput?.value || '').trim(); + if (!name) { + if (errorEl) { errorEl.textContent = 'Tag name is required.'; errorEl.classList.remove('hidden'); } + return; + } + updateTag(tagId, name, editTagColor) + .then((updatedTag) => { + const idx = allTags.findIndex((t) => t.id === tagId); + if (idx !== -1) { + allTags[idx] = { ...allTags[idx], name: updatedTag.name, color: updatedTag.color }; + } + allTags.sort((a, b) => a.name.localeCompare(b.name)); + editingTagId = null; + renderPopover(); + if (assignedTagIds.has(tagId) && typeof onTagsChanged === 'function') { + onTagsChanged({ workKey: currentWorkKey, tags: allTags.filter((t) => assignedTagIds.has(t.id)) }); + } + if (typeof onTagListChanged === 'function') { + onTagListChanged(); + } + }) + .catch((err) => { + if (errorEl) { errorEl.textContent = err.message || 'Could not update tag.'; errorEl.classList.remove('hidden'); } + }); + }); + }); + + // Enter / Escape in edit name input + const editNameInput = popover.querySelector('.ws-tag-edit-name'); + if (editNameInput) { + editNameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); popover.querySelector('.ws-tag-edit-save')?.click(); } + if (e.key === 'Escape') { e.stopPropagation(); editingTagId = null; renderPopover(); } + }); + } + + // ── Delete buttons ────────────────────────────────────────────────── + popover.querySelectorAll('.ws-tag-delete').forEach((btn) => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + const tagId = Number(btn.dataset.tagId); + const tagName = btn.dataset.tagName || 'this tag'; + const usageCount = Number(btn.dataset.usageCount) || 0; + if (usageCount > 0) { + const noun = usageCount === 1 ? '1 test case' : usageCount + ' test cases'; + if (!confirm(`Delete "${tagName}"? It is currently assigned to ${noun} and will be removed from all of them.`)) { + return; + } + } + deleteTag(tagId) + .then(() => { + allTags = allTags.filter((t) => t.id !== tagId); + assignedTagIds.delete(tagId); + if (editingTagId === tagId) editingTagId = null; + return saveTags(currentWorkKey, Array.from(assignedTagIds)); + }) + .then((updatedTags) => { + renderPopover(); + if (typeof onTagsChanged === 'function') { + onTagsChanged({ workKey: currentWorkKey, tags: updatedTags }); + } + if (typeof onTagListChanged === 'function') { + onTagListChanged({ deletedTagId: tagId }); + } + }) + .catch((err) => { console.error('Delete tag failed:', err); }); + }); + }); + + // ── New tag color swatches ────────────────────────────────────────── + popover.querySelectorAll('.ws-color-swatch').forEach((btn) => { + btn.addEventListener('click', () => { + const savedName = popover.querySelector('#wsNewTagName')?.value || ''; + newTagColor = btn.dataset.color; + renderPopover(); + const nameInput = popover.querySelector('#wsNewTagName'); + if (nameInput) { nameInput.value = savedName; nameInput.focus(); } + }); + }); + + // ── Create new tag ────────────────────────────────────────────────── + const createBtn = popover.querySelector('#wsNewTagCreate'); + const nameInput = popover.querySelector('#wsNewTagName'); + const errorEl = popover.querySelector('#wsNewTagError'); + + function handleCreate() { + const name = (nameInput?.value || '').trim(); + if (!name) { showError('Tag name is required.'); return; } + createTag(name, newTagColor) + .then((tag) => { + allTags.push(tag); + allTags.sort((a, b) => a.name.localeCompare(b.name)); + assignedTagIds.add(tag.id); + return saveTags(currentWorkKey, Array.from(assignedTagIds)); + }) + .then((updatedTags) => { + renderPopover(); + if (typeof onTagsChanged === 'function') { + onTagsChanged({ workKey: currentWorkKey, tags: updatedTags }); + } + if (typeof onTagListChanged === 'function') { + onTagListChanged(); + } + }) + .catch((err) => showError(err.message || 'Could not create tag.')); + } + + function showError(msg) { + if (errorEl) { errorEl.textContent = msg; errorEl.classList.remove('hidden'); } + } + + if (createBtn) createBtn.addEventListener('click', handleCreate); + if (nameInput) { + nameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); handleCreate(); } + if (e.key === 'Escape') close(); + }); + } + } + + function positionPopover(anchor) { + if (!popover || !anchor) return; + const rect = anchor.getBoundingClientRect(); + const spaceBelow = window.innerHeight - rect.bottom; + const spaceAbove = rect.top; + const popoverHeight = popover.offsetHeight || 300; + + popover.style.left = Math.min(rect.left, window.innerWidth - 280) + 'px'; + if (spaceBelow >= popoverHeight || spaceBelow >= spaceAbove) { + popover.style.top = (rect.bottom + 4) + 'px'; + } else { + popover.style.top = (rect.top - popoverHeight - 4) + 'px'; + } + } + + function open({ workKey, anchor, testCase }) { + if (currentAnchor === anchor && popover && !popover.classList.contains('hidden')) { + close(); + return; + } + close(); + + currentWorkKey = workKey; + currentAnchor = anchor; + assignedTagIds = new Set( + (Array.isArray(testCase?.customTags) ? testCase.customTags : []).map((t) => t.id) + ); + + popover = document.createElement('div'); + popover.id = 'wsTagPopover'; + popover.className = 'fixed z-50 w-64 border border-white/20 bg-black shadow-xl'; + document.body.appendChild(popover); + + loadAllTags().then(() => { + renderPopover(); + positionPopover(anchor); + }); + + // Close on outside click + setTimeout(() => { + document.addEventListener('mousedown', onOutsideClick); + }, 0); + } + + function onOutsideClick(e) { + if (popover && !popover.contains(e.target) && e.target !== currentAnchor && !currentAnchor?.contains(e.target)) { + close(); + } + } + + function close() { + if (popover) { + popover.remove(); + popover = null; + } + currentAnchor = null; + currentWorkKey = null; + editingTagId = null; + document.removeEventListener('mousedown', onOutsideClick); + } + + return { open, close, loadAllTags }; +} diff --git a/src/main/resources/static/js/workspace/features/workspace-data-controller.js b/src/main/resources/static/js/workspace/features/workspace-data-controller.js index 1f684dd..62fcdda 100644 --- a/src/main/resources/static/js/workspace/features/workspace-data-controller.js +++ b/src/main/resources/static/js/workspace/features/workspace-data-controller.js @@ -52,7 +52,9 @@ export function createWorkspaceDataController(options) { const filterComponent = options.filterComponent; const filterStatus = options.filterStatus; const filterTag = options.filterTag; + const filterCustomTag = options.filterCustomTag; const syncRowSelectionUi = options.syncRowSelectionUi; + const onTagClick = typeof options.onTagClick === 'function' ? options.onTagClick : null; const onAfterRender = typeof options.onAfterRender === 'function' ? options.onAfterRender : null; let currentPageCases = []; @@ -63,7 +65,8 @@ export function createWorkspaceDataController(options) { searchInput, filterComponent, filterStatus, - filterTag + filterTag, + filterCustomTag }; } @@ -110,7 +113,8 @@ export function createWorkspaceDataController(options) { selection.retainSelectedIds(visibleIds); const expandedPreviewKeys = uiState.getExpandedPreviewKeys(); grid.renderRows(currentPageCases, new Set(selection.getSelectedIds()), { - expandedPreviewKeys + expandedPreviewKeys, + onTagClick }); selection.setVisibleIds(visibleIds); selection.bindRowCheckboxes(tbody); @@ -191,6 +195,7 @@ export function createWorkspaceDataController(options) { populateSelect(filterComponent, values.components); populateSelect(filterStatus, values.statuses); populateSelect(filterTag, values.tags); + populateCustomTagSelect(filterCustomTag, values.customTags); }) .catch(() => { const components = uniqueSorted(currentPageCases.map((item) => item?.components)); @@ -202,6 +207,27 @@ export function createWorkspaceDataController(options) { }); } + function populateCustomTagSelect(selectEl, customTags) { + if (!selectEl) return; + const previous = String(selectEl.value || '').trim(); + selectEl.innerHTML = ''; + for (const tag of (customTags || [])) { + const option = document.createElement('option'); + option.value = String(tag.id); + option.textContent = tag.name; + selectEl.appendChild(option); + } + if (previous) selectEl.value = previous; + } + + function updateTestCaseTags(workKey, tags) { + const idx = currentPageCases.findIndex((tc) => tc.workKey === workKey); + if (idx !== -1) { + currentPageCases[idx] = { ...currentPageCases[idx], customTags: tags }; + renderCurrentPage(); + } + } + function getCurrentPageCases() { return currentPageCases.slice(); } @@ -216,6 +242,7 @@ export function createWorkspaceDataController(options) { loadCurrentPage, loadFilterOptions, rerenderCurrentPage, - updatePageControls + updatePageControls, + updateTestCaseTags }; } diff --git a/src/main/resources/static/js/workspace/grid.js b/src/main/resources/static/js/workspace/grid.js index d67bee8..3345556 100644 --- a/src/main/resources/static/js/workspace/grid.js +++ b/src/main/resources/static/js/workspace/grid.js @@ -66,6 +66,19 @@ function buildTagBadges(testCase, maxVisible = 3) { }; } +function buildCustomTagBadges(customTags) { + if (!customTags || customTags.length === 0) { + return ''; + } + let html = ''; + for (const tag of customTags) { + const color = escHtml(tag.color || '#6b7280'); + const name = escHtml(tag.name || ''); + html += '' + name + ''; + } + return html; +} + function buildPreviewElementId(workKey) { return 'ws-preview-' + String(workKey || '').replace(/[^a-zA-Z0-9_-]/g, '-'); } @@ -165,9 +178,9 @@ export function createGrid(tbody) { for (const testCase of testCases) { const workKey = testCase.workKey || '-'; const title = testCase.summary || '-'; - const folder = testCase.folder || ''; const status = testCase.status || '-'; const tagModel = buildTagBadges(testCase, 3); + const customTags = Array.isArray(testCase.customTags) ? testCase.customTags : []; const updated = testCase.updatedOn || '-'; const previewUrl = '/workspace/test-cases/' + encodeURIComponent(workKey); const isSelected = selectedIds.has(workKey); @@ -186,10 +199,13 @@ export function createGrid(tbody) { row.dataset.status = status; row.dataset.updated = updated; + const customTagBadgesHtml = buildCustomTagBadges(customTags); const titleCell = '
' + '
' + escHtml(title) + '
' + - (folder ? '
' + escHtml(folder) + '
' : '') + + '
' + + customTagBadgesHtml + + '
' + '
'; const tagsCell = tagModel.html || '-'; @@ -253,6 +269,22 @@ export function createGrid(tbody) { }); } + const customTagsEl = row.querySelector('.ws-custom-tags'); + const customTagsTriggerEl = row.querySelector('.ws-custom-tags-trigger'); + if (customTagsEl && customTagsTriggerEl && viewOptions.onTagClick) { + const handler = (e) => { + e.stopPropagation(); + viewOptions.onTagClick({ workKey, anchor: customTagsTriggerEl, testCase }); + }; + customTagsTriggerEl.addEventListener('click', handler); + customTagsTriggerEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handler(e); + } + }); + } + tbody.appendChild(row); if (isExpanded) { diff --git a/src/main/resources/static/js/workspace/page.js b/src/main/resources/static/js/workspace/page.js index 2775941..ce35b15 100644 --- a/src/main/resources/static/js/workspace/page.js +++ b/src/main/resources/static/js/workspace/page.js @@ -1,6 +1,8 @@ import { createWorkspacePageApi } from './api/workspace-page-api.js'; import { createBulkEdit } from './bulk-edit.js'; +import { createBulkTagAssignment } from './components/workspace-bulk-tags.js'; import { createWorkspaceFolderTree } from './components/workspace-folder-tree.js'; +import { createWorkspaceTags } from './components/workspace-tags.js'; import { createDrawer } from './drawer.js'; import { bindSelectedExport } from './export-selected.js'; import { createWorkspaceDataController } from './features/workspace-data-controller.js'; @@ -26,6 +28,7 @@ const searchInput = document.getElementById('wsSearch'); const filterComponent = document.getElementById('wsFilterComponent'); const filterStatus = document.getElementById('wsFilterStatus'); const filterTag = document.getElementById('wsFilterTag'); +const filterCustomTag = document.getElementById('wsFilterCustomTag'); const bulkBar = document.getElementById('bulkBar'); const bulkCount = document.getElementById('bulkCount'); const bulkExportSelected = document.getElementById('bulkExportSelected'); @@ -82,7 +85,8 @@ uiState.hydrateInputs({ searchInput, filterComponent, filterStatus, - filterTag + filterTag, + filterCustomTag }); const api = createWorkspacePageApi({ @@ -128,6 +132,19 @@ let previewControls = { refresh() {} }; +const tagsController = createWorkspaceTags({ + onTagsChanged: ({ workKey, tags }) => { + dataController.updateTestCaseTags(workKey, tags); + }, + onTagListChanged: ({ deletedTagId } = {}) => { + if (deletedTagId && filterCustomTag && filterCustomTag.value === String(deletedTagId)) { + filterCustomTag.value = ''; + dataController.applyFilters({ resetPage: true }); + } + dataController.loadFilterOptions(); + } +}); + const dataController = createWorkspaceDataController({ api, uiState, @@ -142,7 +159,9 @@ const dataController = createWorkspaceDataController({ filterComponent, filterStatus, filterTag, + filterCustomTag, syncRowSelectionUi: () => syncRowSelectionUi(), + onTagClick: (args) => tagsController.open(args), onAfterRender: () => { previewControls.refresh(); } @@ -377,6 +396,20 @@ const bulkEdit = createBulkEdit({ } }); +const bulkTagOpen = document.getElementById('bulkTagOpen'); +const bulkTagAssignment = createBulkTagAssignment({ + getSelectedIds: () => selection.getSelectedIds(), + getCurrentPageCases: () => dataController.getCurrentPageCases(), + getCsrf: () => ({ + token: document.querySelector('meta[name="_csrf"]')?.content || '', + headerName: document.querySelector('meta[name="_csrf_header"]')?.content || 'X-CSRF-TOKEN' + }), + onComplete: () => dataController.applyFilters({ resetPage: false }) +}); +if (bulkTagOpen) { + bulkTagOpen.addEventListener('click', () => bulkTagAssignment.open(bulkTagOpen)); +} + selection.setSelectionChangeHandler((selectedIds) => { exportSelected.updateButtonState(selectedIds); syncRowSelectionUi(); @@ -405,6 +438,9 @@ if (filterStatus) { if (filterTag) { filterTag.addEventListener('change', () => dataController.applyFilters({ resetPage: true })); } +if (filterCustomTag) { + filterCustomTag.addEventListener('change', () => dataController.applyFilters({ resetPage: true })); +} if (prevPageButton) { prevPageButton.addEventListener('click', () => { const pageState = uiState.getPageState(); diff --git a/src/main/resources/static/js/workspace/state/workspace-ui-state.js b/src/main/resources/static/js/workspace/state/workspace-ui-state.js index 1a5f4c4..e9b1718 100644 --- a/src/main/resources/static/js/workspace/state/workspace-ui-state.js +++ b/src/main/resources/static/js/workspace/state/workspace-ui-state.js @@ -39,6 +39,9 @@ export function createWorkspaceUiState(options) { if (elements.filterTag) { elements.filterTag.dataset.pendingValue = initialSearchParams.get('tag') || ''; } + if (elements.filterCustomTag) { + elements.filterCustomTag.dataset.pendingValue = initialSearchParams.get('customTagId') || ''; + } } function getActiveFilterValue(selectEl) { @@ -56,6 +59,7 @@ export function createWorkspaceUiState(options) { component: getActiveFilterValue(input.filterComponent), status: getActiveFilterValue(input.filterStatus), tag: getActiveFilterValue(input.filterTag), + customTagId: getActiveFilterValue(input.filterCustomTag), folder: selectedFolder }; } @@ -78,6 +82,9 @@ export function createWorkspaceUiState(options) { if (query.tag) { params.set('tag', query.tag); } + if (query.customTagId) { + params.set('customTagId', query.customTagId); + } if (query.folder) { params.set('folder', query.folder); } @@ -101,6 +108,9 @@ export function createWorkspaceUiState(options) { if (query.tag) { params.set('tag', query.tag); } + if (query.customTagId) { + params.set('customTagId', query.customTagId); + } if (query.folder) { params.set('folder', query.folder); } diff --git a/src/main/resources/static/js/workspace/test-case-details.js b/src/main/resources/static/js/workspace/test-case-details.js index 0a70a06..44d4f1b 100644 --- a/src/main/resources/static/js/workspace/test-case-details.js +++ b/src/main/resources/static/js/workspace/test-case-details.js @@ -1,4 +1,4 @@ -import { markWorkspaceRefreshPending } from './features/workspace-refresh-state.js'; +import { createWorkspaceTags } from './components/workspace-tags.js'; function escapeHtml(value) { return String(value) @@ -786,3 +786,47 @@ function initDoubleClickEdit() { } initDoubleClickEdit(); + +// ---- Tag management ---- + +function getAssignedTagsFromDom() { + return Array.from(document.querySelectorAll('#detailsTagBadges [data-tag-id]')) + .map((el) => ({ id: Number(el.dataset.tagId) })); +} + +function updateDetailsBadges(tags) { + const container = document.getElementById('detailsTagBadges'); + if (!container) return; + if (!tags || tags.length === 0) { + container.innerHTML = 'No tags assigned'; + return; + } + container.innerHTML = tags.map((tag) => { + const color = escapeHtml(tag.color || '#6b7280'); + return '' + + escapeHtml(tag.name || '') + ''; + }).join(''); +} + +function initDetailsTagManagement() { + const workKey = getWorkKey(); + const editBtn = document.getElementById('detailsTagsEditBtn'); + if (!workKey || !editBtn) return; + + const tagsController = createWorkspaceTags({ + onTagsChanged: ({ tags }) => updateDetailsBadges(tags), + onTagListChanged: () => {} + }); + + editBtn.addEventListener('click', () => { + tagsController.open({ + workKey, + anchor: editBtn, + testCase: { customTags: getAssignedTagsFromDom() } + }); + }); +} + +initDetailsTagManagement(); diff --git a/src/main/resources/templates/test-case-details.html b/src/main/resources/templates/test-case-details.html index adf0fe0..2f71653 100644 --- a/src/main/resources/templates/test-case-details.html +++ b/src/main/resources/templates/test-case-details.html @@ -480,6 +480,29 @@

+ +
+
+

Tags

+ +
+
+ + Tag + + + No tags assigned + +
+
+
diff --git a/src/main/resources/templates/upload-review.html b/src/main/resources/templates/upload-review.html index eebe4c6..ac30648 100644 --- a/src/main/resources/templates/upload-review.html +++ b/src/main/resources/templates/upload-review.html @@ -250,7 +250,7 @@

TC-101

-

Tags

+

Type

Bulk edit +

-

Tags

+

Type

-

diff --git a/src/main/resources/templates/workspace/_header.html b/src/main/resources/templates/workspace/_header.html index 94ed2f8..4a552b2 100644 --- a/src/main/resources/templates/workspace/_header.html +++ b/src/main/resources/templates/workspace/_header.html @@ -15,7 +15,7 @@ -
+
- +
+
+ + +
@@ -48,7 +54,7 @@ - Export + Export All
diff --git a/src/main/resources/templates/workspace/_main.html b/src/main/resources/templates/workspace/_main.html index 3faf6cc..3e99e2f 100644 --- a/src/main/resources/templates/workspace/_main.html +++ b/src/main/resources/templates/workspace/_main.html @@ -68,7 +68,7 @@ Title Status - Tags + Type Last updated Actions diff --git a/src/test/java/com/formswim/teststream/workspace/TagApiIntegrationTests.java b/src/test/java/com/formswim/teststream/workspace/TagApiIntegrationTests.java new file mode 100644 index 0000000..9e4dc63 --- /dev/null +++ b/src/test/java/com/formswim/teststream/workspace/TagApiIntegrationTests.java @@ -0,0 +1,555 @@ +package com.formswim.teststream.workspace; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.formswim.teststream.auth.model.AppUser; +import com.formswim.teststream.auth.repository.UserRepository; +import com.formswim.teststream.shared.domain.Tag; +import com.formswim.teststream.shared.domain.TagRepository; +import com.formswim.teststream.shared.domain.TestCase; +import com.formswim.teststream.shared.domain.TestCaseRepository; +import com.formswim.teststream.support.TestCaseFixtures; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class TagApiIntegrationTests { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private UserRepository userRepository; + @Autowired private TagRepository tagRepository; + @Autowired private TestCaseRepository testCaseRepository; + + @BeforeEach + void setUp() { + testCaseRepository.deleteAll(); + tagRepository.deleteAll(); + userRepository.deleteAll(); + + userRepository.save(new AppUser("team1.user@example.com", "test-hash", "TEAM1")); + userRepository.save(new AppUser("team2.user@example.com", "test-hash", "TEAM2")); + userRepository.save(new AppUser("noteam.user@example.com", "test-hash", " ")); + } + + // ── GET /api/custom-tags ──────────────────────────────────────────────── + + @Test + void listTagsReturnsEmptyArrayWhenNoTagsExist() throws Exception { + mockMvc.perform(get("/api/custom-tags") + .with(user("team1.user@example.com").roles("USER"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + } + + @Test + void listTagsReturnsOnlyTagsBelongingToCallerTeam() throws Exception { + tagRepository.save(new Tag("Alpha", "#ef4444", "TEAM1")); + tagRepository.save(new Tag("Beta", "#3b82f6", "TEAM2")); + + mockMvc.perform(get("/api/custom-tags") + .with(user("team1.user@example.com").roles("USER"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].name").value("Alpha")); + } + + @Test + void listTagsReturnsSortedByNameAscending() throws Exception { + tagRepository.save(new Tag("Zebra", "#ef4444", "TEAM1")); + tagRepository.save(new Tag("Apple", "#22c55e", "TEAM1")); + tagRepository.save(new Tag("Mango", "#f97316", "TEAM1")); + + mockMvc.perform(get("/api/custom-tags") + .with(user("team1.user@example.com").roles("USER"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("Apple")) + .andExpect(jsonPath("$[1].name").value("Mango")) + .andExpect(jsonPath("$[2].name").value("Zebra")); + } + + @Test + void listTagsIncludesUsageCount() throws Exception { + Tag tag = tagRepository.save(new Tag("Smoke", "#6366f1", "TEAM1")); + + TestCase tc1 = TestCaseFixtures.basicCase("TEAM1", "TC-101", "Auth"); + TestCase tc2 = TestCaseFixtures.basicCase("TEAM1", "TC-102", "Auth"); + tc1.setCustomTags(List.of(tag)); + tc2.setCustomTags(List.of(tag)); + testCaseRepository.saveAll(List.of(tc1, tc2)); + + mockMvc.perform(get("/api/custom-tags") + .with(user("team1.user@example.com").roles("USER"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].usageCount").value(2)); + } + + @Test + void listTagsReturnsUnauthorizedForUnknownUser() throws Exception { + mockMvc.perform(get("/api/custom-tags") + .with(user("ghost@example.com").roles("USER"))) + .andExpect(status().isUnauthorized()); + } + + @Test + void listTagsReturnsForbiddenForUserWithNoTeamKey() throws Exception { + mockMvc.perform(get("/api/custom-tags") + .with(user("noteam.user@example.com").roles("USER"))) + .andExpect(status().isForbidden()); + } + + @Test + void listTagsDoesNotExposeTeamKeyInResponse() throws Exception { + tagRepository.save(new Tag("Secret", "#ef4444", "TEAM1")); + + String body = mockMvc.perform(get("/api/custom-tags") + .with(user("team1.user@example.com").roles("USER"))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + assertThat(body).doesNotContain("teamKey"); + assertThat(body).doesNotContain("TEAM1"); + } + + // ── POST /api/custom-tags ─────────────────────────────────────────────── + + @Test + void createTagSucceedsWithValidNameAndColor() throws Exception { + Map payload = Map.of("name", "Flaky", "color", "#f43f5e"); + + mockMvc.perform(post("/api/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Flaky")) + .andExpect(jsonPath("$.color").value("#f43f5e")) + .andExpect(jsonPath("$.id").isNumber()); + + assertThat(tagRepository.findByTeamKeyOrderByNameAsc("TEAM1")).hasSize(1); + } + + @Test + void createTagTrimsWhitespaceFromName() throws Exception { + Map payload = Map.of("name", " Flaky ", "color", "#ef4444"); + + mockMvc.perform(post("/api/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.name").value("Flaky")); + } + + @Test + void createTagReturnsBadRequestForBlankName() throws Exception { + Map payload = Map.of("name", " ", "color", "#ef4444"); + + mockMvc.perform(post("/api/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void createTagReturnsBadRequestForInvalidHexColor() throws Exception { + Map payload = Map.of("name", "Bug", "color", "red"); + + mockMvc.perform(post("/api/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isBadRequest()); + } + + @Test + void createTagReturnsBadRequestForNameExceeding50Characters() throws Exception { + Map payload = Map.of("name", "A".repeat(51), "color", "#ef4444"); + + mockMvc.perform(post("/api/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isBadRequest()); + } + + @Test + void createTagReturnsConflictForDuplicateNameWithinTeam() throws Exception { + tagRepository.save(new Tag("Duplicate", "#ef4444", "TEAM1")); + + Map payload = Map.of("name", "Duplicate", "color", "#3b82f6"); + + mockMvc.perform(post("/api/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + void createTagAllowsSameNameAcrossTeams() throws Exception { + tagRepository.save(new Tag("Shared", "#ef4444", "TEAM2")); + + Map payload = Map.of("name", "Shared", "color", "#3b82f6"); + + mockMvc.perform(post("/api/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isCreated()); + } + + // ── PATCH /api/custom-tags/{id} ───────────────────────────────────────── + + @Test + void updateTagSucceedsWithNewNameAndColor() throws Exception { + Tag tag = tagRepository.save(new Tag("Old Name", "#ef4444", "TEAM1")); + Map payload = Map.of("name", "New Name", "color", "#3b82f6"); + + mockMvc.perform(patch("/api/custom-tags/" + tag.getId()) + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("New Name")) + .andExpect(jsonPath("$.color").value("#3b82f6")); + } + + @Test + void updateTagAllowsKeepingSameName() throws Exception { + Tag tag = tagRepository.save(new Tag("Stable", "#ef4444", "TEAM1")); + Map payload = Map.of("name", "Stable", "color", "#22c55e"); + + mockMvc.perform(patch("/api/custom-tags/" + tag.getId()) + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Stable")) + .andExpect(jsonPath("$.color").value("#22c55e")); + } + + @Test + void updateTagReturnsBadRequestForBlankName() throws Exception { + Tag tag = tagRepository.save(new Tag("Valid", "#ef4444", "TEAM1")); + Map payload = Map.of("name", "", "color", "#ef4444"); + + mockMvc.perform(patch("/api/custom-tags/" + tag.getId()) + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isBadRequest()); + } + + @Test + void updateTagReturnsConflictWhenRenamedToExistingTagName() throws Exception { + tagRepository.save(new Tag("Taken", "#ef4444", "TEAM1")); + Tag tag = tagRepository.save(new Tag("Mine", "#3b82f6", "TEAM1")); + Map payload = Map.of("name", "Taken", "color", "#3b82f6"); + + mockMvc.perform(patch("/api/custom-tags/" + tag.getId()) + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isConflict()); + } + + @Test + void updateTagReturnsNotFoundForAnotherTeamsTag() throws Exception { + Tag team2Tag = tagRepository.save(new Tag("Other", "#ef4444", "TEAM2")); + Map payload = Map.of("name", "Hijack", "color", "#ef4444"); + + mockMvc.perform(patch("/api/custom-tags/" + team2Tag.getId()) + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isNotFound()); + } + + // ── DELETE /api/custom-tags/{id} ──────────────────────────────────────── + + @Test + void deleteTagSucceedsAndRemovesItFromRepository() throws Exception { + Tag tag = tagRepository.save(new Tag("Doomed", "#ef4444", "TEAM1")); + + mockMvc.perform(delete("/api/custom-tags/" + tag.getId()) + .with(csrf()) + .with(user("team1.user@example.com").roles("USER"))) + .andExpect(status().isNoContent()); + + assertThat(tagRepository.findByTeamKeyOrderByNameAsc("TEAM1")).isEmpty(); + } + + @Test + void deleteTagSucceedsWhenTagIsAssignedToTestCases() throws Exception { + Tag tag = tagRepository.save(new Tag("Assigned", "#ef4444", "TEAM1")); + + TestCase tc = TestCaseFixtures.basicCase("TEAM1", "TC-101", "Auth"); + tc.setCustomTags(List.of(tag)); + testCaseRepository.save(tc); + + mockMvc.perform(delete("/api/custom-tags/" + tag.getId()) + .with(csrf()) + .with(user("team1.user@example.com").roles("USER"))) + .andExpect(status().isNoContent()); + + assertThat(tagRepository.findById(tag.getId())).isEmpty(); + TestCase updated = testCaseRepository.findWithCustomTagsByTeamKeyAndWorkKey("TEAM1", "TC-101").orElseThrow(); + assertThat(updated.getCustomTags()).isEmpty(); + } + + @Test + void deleteTagReturnsNotFoundForAnotherTeamsTag() throws Exception { + Tag team2Tag = tagRepository.save(new Tag("NotMine", "#ef4444", "TEAM2")); + + mockMvc.perform(delete("/api/custom-tags/" + team2Tag.getId()) + .with(csrf()) + .with(user("team1.user@example.com").roles("USER"))) + .andExpect(status().isNotFound()); + + assertThat(tagRepository.findById(team2Tag.getId())).isPresent(); + } + + @Test + void deleteTagReturnsNotFoundForNonExistentId() throws Exception { + mockMvc.perform(delete("/api/custom-tags/99999") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER"))) + .andExpect(status().isNotFound()); + } + + // ── PUT /api/testcases/{workKey}/custom-tags ──────────────────────────── + + @Test + void setTagsForTestCaseAssignsTagsAndReturnsThem() throws Exception { + Tag t1 = tagRepository.save(new Tag("Smoke", "#ef4444", "TEAM1")); + Tag t2 = tagRepository.save(new Tag("Regression", "#3b82f6", "TEAM1")); + testCaseRepository.save(TestCaseFixtures.basicCase("TEAM1", "TC-101", "Auth")); + + mockMvc.perform(put("/api/testcases/TC-101/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(List.of(t1.getId(), t2.getId())))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + + TestCase updated = testCaseRepository.findWithCustomTagsByTeamKeyAndWorkKey("TEAM1", "TC-101").orElseThrow(); + assertThat(updated.getCustomTags()).hasSize(2); + } + + @Test + void setTagsForTestCaseClearsTagsWhenEmptyListProvided() throws Exception { + Tag tag = tagRepository.save(new Tag("Smoke", "#ef4444", "TEAM1")); + TestCase tc = TestCaseFixtures.basicCase("TEAM1", "TC-101", "Auth"); + tc.setCustomTags(List.of(tag)); + testCaseRepository.save(tc); + + mockMvc.perform(put("/api/testcases/TC-101/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(List.of()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(0)); + + TestCase updated = testCaseRepository.findWithCustomTagsByTeamKeyAndWorkKey("TEAM1", "TC-101").orElseThrow(); + assertThat(updated.getCustomTags()).isEmpty(); + } + + @Test + void setTagsReturnsNotFoundForUnknownWorkKey() throws Exception { + Tag tag = tagRepository.save(new Tag("Smoke", "#ef4444", "TEAM1")); + + mockMvc.perform(put("/api/testcases/MISSING-999/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(List.of(tag.getId())))) + .andExpect(status().isNotFound()); + } + + @Test + void setTagsReturnsNotFoundWhenTagBelongsToAnotherTeam() throws Exception { + Tag team2Tag = tagRepository.save(new Tag("Foreign", "#ef4444", "TEAM2")); + testCaseRepository.save(TestCaseFixtures.basicCase("TEAM1", "TC-101", "Auth")); + + mockMvc.perform(put("/api/testcases/TC-101/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(List.of(team2Tag.getId())))) + .andExpect(status().isNotFound()); + } + + @Test + void setTagsCannotAssignTagsToAnotherTeamsTestCase() throws Exception { + Tag tag = tagRepository.save(new Tag("Mine", "#ef4444", "TEAM1")); + testCaseRepository.save(TestCaseFixtures.basicCase("TEAM2", "TC-201", "Auth")); + + mockMvc.perform(put("/api/testcases/TC-201/custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(List.of(tag.getId())))) + .andExpect(status().isNotFound()); + } + + // ── PATCH /api/testcases/bulk-custom-tags ─────────────────────────────── + + @Test + void bulkAddTagAssignsTagToAllSelectedTestCases() throws Exception { + Tag tag = tagRepository.save(new Tag("Bulk", "#ef4444", "TEAM1")); + testCaseRepository.save(TestCaseFixtures.basicCase("TEAM1", "TC-101", "Auth")); + testCaseRepository.save(TestCaseFixtures.basicCase("TEAM1", "TC-102", "Auth")); + + Map payload = Map.of("workKeys", List.of("TC-101", "TC-102"), "tagId", tag.getId(), "add", true); + + mockMvc.perform(patch("/api/testcases/bulk-custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isNoContent()); + + assertThat(testCaseRepository.findWithCustomTagsByTeamKeyAndWorkKey("TEAM1", "TC-101").orElseThrow().getCustomTags()).hasSize(1); + assertThat(testCaseRepository.findWithCustomTagsByTeamKeyAndWorkKey("TEAM1", "TC-102").orElseThrow().getCustomTags()).hasSize(1); + } + + @Test + void bulkRemoveTagDetachesTagFromSelectedTestCases() throws Exception { + Tag tag = tagRepository.save(new Tag("ToRemove", "#3b82f6", "TEAM1")); + TestCase tc1 = TestCaseFixtures.basicCase("TEAM1", "TC-101", "Auth"); + TestCase tc2 = TestCaseFixtures.basicCase("TEAM1", "TC-102", "Auth"); + tc1.setCustomTags(List.of(tag)); + tc2.setCustomTags(List.of(tag)); + testCaseRepository.saveAll(List.of(tc1, tc2)); + + Map payload = Map.of("workKeys", List.of("TC-101", "TC-102"), "tagId", tag.getId(), "add", false); + + mockMvc.perform(patch("/api/testcases/bulk-custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isNoContent()); + + assertThat(testCaseRepository.findWithCustomTagsByTeamKeyAndWorkKey("TEAM1", "TC-101").orElseThrow().getCustomTags()).isEmpty(); + assertThat(testCaseRepository.findWithCustomTagsByTeamKeyAndWorkKey("TEAM1", "TC-102").orElseThrow().getCustomTags()).isEmpty(); + } + + @Test + void bulkAddTagIsIdempotentWhenTagAlreadyAssigned() throws Exception { + Tag tag = tagRepository.save(new Tag("Idempotent", "#ef4444", "TEAM1")); + TestCase tc = TestCaseFixtures.basicCase("TEAM1", "TC-101", "Auth"); + tc.setCustomTags(List.of(tag)); + testCaseRepository.save(tc); + + Map payload = Map.of("workKeys", List.of("TC-101"), "tagId", tag.getId(), "add", true); + + mockMvc.perform(patch("/api/testcases/bulk-custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isNoContent()); + + assertThat(testCaseRepository.findWithCustomTagsByTeamKeyAndWorkKey("TEAM1", "TC-101").orElseThrow().getCustomTags()).hasSize(1); + } + + @Test + void bulkSetTagReturnsBadRequestWhenTagIdMissing() throws Exception { + Map payload = Map.of("workKeys", List.of("TC-101"), "add", true); + + mockMvc.perform(patch("/api/testcases/bulk-custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isBadRequest()); + } + + @Test + void bulkSetTagReturnsBadRequestWhenWorkKeysIsEmpty() throws Exception { + Tag tag = tagRepository.save(new Tag("Empty", "#ef4444", "TEAM1")); + Map payload = Map.of("workKeys", List.of(), "tagId", tag.getId(), "add", true); + + mockMvc.perform(patch("/api/testcases/bulk-custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isBadRequest()); + } + + @Test + void bulkSetTagReturnsNotFoundForAnotherTeamsTag() throws Exception { + Tag team2Tag = tagRepository.save(new Tag("Foreign", "#3b82f6", "TEAM2")); + testCaseRepository.save(TestCaseFixtures.basicCase("TEAM1", "TC-101", "Auth")); + + Map payload = Map.of("workKeys", List.of("TC-101"), "tagId", team2Tag.getId(), "add", true); + + mockMvc.perform(patch("/api/testcases/bulk-custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isNotFound()); + } + + @Test + void bulkSetTagSilentlyIgnoresWorkKeysBelongingToAnotherTeam() throws Exception { + Tag tag = tagRepository.save(new Tag("Boundary", "#ef4444", "TEAM1")); + testCaseRepository.save(TestCaseFixtures.basicCase("TEAM1", "TC-101", "Auth")); + testCaseRepository.save(TestCaseFixtures.basicCase("TEAM2", "TC-201", "Auth")); + + Map payload = Map.of("workKeys", List.of("TC-101", "TC-201"), "tagId", tag.getId(), "add", true); + + mockMvc.perform(patch("/api/testcases/bulk-custom-tags") + .with(csrf()) + .with(user("team1.user@example.com").roles("USER")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsBytes(payload))) + .andExpect(status().isNoContent()); + + assertThat(testCaseRepository.findWithCustomTagsByTeamKeyAndWorkKey("TEAM1", "TC-101").orElseThrow().getCustomTags()).hasSize(1); + assertThat(testCaseRepository.findWithCustomTagsByTeamKeyAndWorkKey("TEAM2", "TC-201").orElseThrow().getCustomTags()).isEmpty(); + } +}