diff --git a/.context/STATE.md b/.context/STATE.md index 6c09c25..87000be 100644 --- a/.context/STATE.md +++ b/.context/STATE.md @@ -66,6 +66,7 @@ Recent delivery: - folder delete confirmation is now a centered modal with blue-tinted backdrop, theme-aware styling, and keyboard focus trapping - landing/login/register now use the same gray dark surface family as workspace for visual consistency - workspace now uses a fixed-height split-pane shell with independent sidebar/grid scrolling (page scroll disabled in workspace view) +- workspace filter metadata now refreshes in place after successful workspace bulk edits, successful imports, and when returning from a successfully edited test case details page; current filter/page context is preserved where possible while component/status/tag options are re-fetched from metadata APIs Primary active goals: diff --git a/src/main/java/com/formswim/teststream/bulk/controllers/BulkMutationController.java b/src/main/java/com/formswim/teststream/bulk/controllers/BulkMutationController.java index d63b43e..bce7a5e 100644 --- a/src/main/java/com/formswim/teststream/bulk/controllers/BulkMutationController.java +++ b/src/main/java/com/formswim/teststream/bulk/controllers/BulkMutationController.java @@ -1,6 +1,7 @@ package com.formswim.teststream.bulk.controllers; import java.util.Optional; +import java.util.stream.Collectors; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; @@ -116,10 +117,20 @@ public ResponseEntity apiBulkEditTestCases(@Valid @RequestBody B String normalizedStatusValue = normalizeTrimmed(request.getStatusValue()); request.setFindText(normalizedFindText); request.setStatusValue(normalizedStatusValue); + request.setFieldValues(request.getFieldValues().entrySet().stream() + .filter(entry -> entry.getKey() != null) + .collect(Collectors.toMap( + entry -> entry.getKey().trim(), + entry -> entry.getValue() == null ? "" : entry.getValue(), + (left, right) -> right, + java.util.LinkedHashMap::new + ))); boolean hasTextOperation = !normalizedFindText.isBlank(); boolean hasStatusOperation = !normalizedStatusValue.isBlank(); - if (!hasTextOperation && !hasStatusOperation) { + boolean hasDirectSetOperation = !request.getFieldValues().isEmpty(); + boolean hasStepMutationOperation = !request.getStepMutations().isEmpty(); + if (!hasTextOperation && !hasStatusOperation && !hasDirectSetOperation && !hasStepMutationOperation) { return ResponseEntity.badRequest().build(); } diff --git a/src/main/java/com/formswim/teststream/bulk/dto/BulkEditRequest.java b/src/main/java/com/formswim/teststream/bulk/dto/BulkEditRequest.java index fdbea84..a482dce 100644 --- a/src/main/java/com/formswim/teststream/bulk/dto/BulkEditRequest.java +++ b/src/main/java/com/formswim/teststream/bulk/dto/BulkEditRequest.java @@ -3,7 +3,9 @@ import jakarta.validation.constraints.NotNull; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * Request payload for PATCH /api/testcases/bulk-edit. @@ -27,6 +29,10 @@ public class BulkEditRequest { private String statusValue; + private Map fieldValues = new LinkedHashMap<>(); + + private List stepMutations = new ArrayList<>(); + /** * Returns requested work keys as a non-null list. */ @@ -104,4 +110,115 @@ public String getStatusValue() { public void setStatusValue(String statusValue) { this.statusValue = statusValue; } + + /** + * Optional direct-assignment values keyed by canonical field names. + */ + public Map getFieldValues() { + if (fieldValues == null) { + fieldValues = new LinkedHashMap<>(); + } + return fieldValues; + } + + public void setFieldValues(Map fieldValues) { + this.fieldValues = fieldValues == null ? new LinkedHashMap<>() : new LinkedHashMap<>(fieldValues); + } + + /** + * Optional list of step-level mutations applied in order for each selected test case. + */ + public List getStepMutations() { + if (stepMutations == null) { + stepMutations = new ArrayList<>(); + } + return stepMutations; + } + + public void setStepMutations(List stepMutations) { + this.stepMutations = stepMutations == null ? new ArrayList<>() : new ArrayList<>(stepMutations); + } + + public static class StepMutation { + + private String op; + + private Integer stepNumber; + + private Integer afterStepNumber; + + private String stepSummary; + + private String testData; + + private String expectedResult; + + private String field; + + private String value; + + public String getOp() { + return op; + } + + public void setOp(String op) { + this.op = op; + } + + public Integer getStepNumber() { + return stepNumber; + } + + public void setStepNumber(Integer stepNumber) { + this.stepNumber = stepNumber; + } + + public Integer getAfterStepNumber() { + return afterStepNumber; + } + + public void setAfterStepNumber(Integer afterStepNumber) { + this.afterStepNumber = afterStepNumber; + } + + public String getStepSummary() { + return stepSummary; + } + + public void setStepSummary(String stepSummary) { + this.stepSummary = stepSummary; + } + + public String getTestData() { + return testData; + } + + public void setTestData(String testData) { + this.testData = testData; + } + + public String getExpectedResult() { + return expectedResult; + } + + public void setExpectedResult(String expectedResult) { + this.expectedResult = expectedResult; + } + + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } } diff --git a/src/main/java/com/formswim/teststream/bulk/service/TestCaseBulkEditService.java b/src/main/java/com/formswim/teststream/bulk/service/TestCaseBulkEditService.java index 5aa38d8..764e3e3 100644 --- a/src/main/java/com/formswim/teststream/bulk/service/TestCaseBulkEditService.java +++ b/src/main/java/com/formswim/teststream/bulk/service/TestCaseBulkEditService.java @@ -12,11 +12,13 @@ import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Objects; import java.util.Set; @Service @@ -59,7 +61,25 @@ public class TestCaseBulkEditService { "expectedResult" ); + private static final Set DIRECT_ASSIGNABLE_FIELDS = Set.of( + "status", + "summary", + "description", + "precondition", + "priority", + "components", + "sprint", + "fixVersions", + "version", + "folder", + "testCaseType", + "labels", + "estimatedTime", + "storyLinkages" + ); + private static final Map FIELD_ALIASES = Map.ofEntries( + Map.entry("status", "status"), Map.entry("summary", "summary"), Map.entry("description", "description"), Map.entry("precondition", "precondition"), @@ -150,6 +170,15 @@ public BulkEditResult bulkEditByWorkKeys(String teamKey, BulkEditRequest request String requestedStatusValue = normalizeStatusValue(request.getStatusValue()); boolean hasTextOperation = findText != null && !findText.isEmpty(); Set requestedFields = hasTextOperation ? resolveRequestedFields(request.getFields()) : Set.of(); + Map directFieldAssignments = resolveDirectFieldAssignments(request.getFieldValues()); + String directStatusValue = directFieldAssignments.get("status"); + if (directStatusValue != null && requestedStatusValue != null && !Objects.equals(directStatusValue, requestedStatusValue)) { + throw new IllegalArgumentException("Conflicting status inputs provided"); + } + if (directStatusValue != null) { + requestedStatusValue = null; + } + List stepMutations = resolveStepMutations(request.getStepMutations()); List normalizedWorkKeys = new ArrayList<>(normalizedUniqueWorkKeys); Set ownedWorkKeys = new HashSet<>(testCaseRepository.findOwnedWorkKeysIn(teamKey, normalizedWorkKeys)); @@ -186,28 +215,42 @@ public BulkEditResult bulkEditByWorkKeys(String teamKey, BulkEditRequest request for (TestCase testCase : cases) { boolean caseChanged = false; - boolean stepChanged = false; - ReplaceOutcome summaryOutcome = updateIfRequested(requestedFields.contains("summary"), testCase.getSummary(), findText, replaceText, caseSensitive); + ReplaceOutcome summaryOutcome = updateCaseFieldOutcome( + "summary", requestedFields.contains("summary"), testCase.getSummary(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setSummary(summaryOutcome.updatedValue()); caseChanged = caseChanged || summaryOutcome.changed(); totalReplacements += summaryOutcome.replacementCount(); - ReplaceOutcome descriptionOutcome = updateIfRequested(requestedFields.contains("description"), testCase.getDescription(), findText, replaceText, caseSensitive); + ReplaceOutcome descriptionOutcome = updateCaseFieldOutcome( + "description", requestedFields.contains("description"), testCase.getDescription(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setDescription(descriptionOutcome.updatedValue()); caseChanged = caseChanged || descriptionOutcome.changed(); totalReplacements += descriptionOutcome.replacementCount(); - ReplaceOutcome preconditionOutcome = updateIfRequested(requestedFields.contains("precondition"), testCase.getPrecondition(), findText, replaceText, caseSensitive); + ReplaceOutcome preconditionOutcome = updateCaseFieldOutcome( + "precondition", requestedFields.contains("precondition"), testCase.getPrecondition(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setPrecondition(preconditionOutcome.updatedValue()); caseChanged = caseChanged || preconditionOutcome.changed(); totalReplacements += preconditionOutcome.replacementCount(); - ReplaceOutcome priorityOutcome = updateIfRequested(requestedFields.contains("priority"), testCase.getPriority(), findText, replaceText, caseSensitive); + ReplaceOutcome priorityOutcome = updateCaseFieldOutcome( + "priority", requestedFields.contains("priority"), testCase.getPriority(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setPriority(priorityOutcome.updatedValue()); caseChanged = caseChanged || priorityOutcome.changed(); totalReplacements += priorityOutcome.replacementCount(); + ReplaceOutcome statusOutcome = updateCaseFieldOutcome( + "status", requestedFields.contains("status"), testCase.getStatus(), findText, replaceText, caseSensitive, directFieldAssignments + ); + testCase.setStatus(statusOutcome.updatedValue()); + caseChanged = caseChanged || statusOutcome.changed(); + totalReplacements += statusOutcome.replacementCount(); + ReplaceOutcome assigneeOutcome = updateIfRequested(requestedFields.contains("assignee"), testCase.getAssignee(), findText, replaceText, caseSensitive); testCase.setAssignee(assigneeOutcome.updatedValue()); caseChanged = caseChanged || assigneeOutcome.changed(); @@ -218,45 +261,64 @@ public BulkEditResult bulkEditByWorkKeys(String teamKey, BulkEditRequest request caseChanged = caseChanged || reporterOutcome.changed(); totalReplacements += reporterOutcome.replacementCount(); - ReplaceOutcome estimatedTimeOutcome = updateIfRequested(requestedFields.contains("estimatedTime"), testCase.getEstimatedTime(), findText, replaceText, caseSensitive); + ReplaceOutcome estimatedTimeOutcome = updateCaseFieldOutcome( + "estimatedTime", requestedFields.contains("estimatedTime"), testCase.getEstimatedTime(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setEstimatedTime(estimatedTimeOutcome.updatedValue()); caseChanged = caseChanged || estimatedTimeOutcome.changed(); totalReplacements += estimatedTimeOutcome.replacementCount(); - ReplaceOutcome labelsOutcome = updateIfRequested(requestedFields.contains("labels"), testCase.getLabels(), findText, replaceText, caseSensitive); + ReplaceOutcome labelsOutcome = updateCaseFieldOutcome( + "labels", requestedFields.contains("labels"), testCase.getLabels(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setLabels(labelsOutcome.updatedValue()); caseChanged = caseChanged || labelsOutcome.changed(); totalReplacements += labelsOutcome.replacementCount(); - ReplaceOutcome componentsOutcome = updateIfRequested(requestedFields.contains("components"), testCase.getComponents(), findText, replaceText, caseSensitive); + ReplaceOutcome componentsOutcome = updateCaseFieldOutcome( + "components", requestedFields.contains("components"), testCase.getComponents(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setComponents(componentsOutcome.updatedValue()); caseChanged = caseChanged || componentsOutcome.changed(); totalReplacements += componentsOutcome.replacementCount(); - ReplaceOutcome sprintOutcome = updateIfRequested(requestedFields.contains("sprint"), testCase.getSprint(), findText, replaceText, caseSensitive); + ReplaceOutcome sprintOutcome = updateCaseFieldOutcome( + "sprint", requestedFields.contains("sprint"), testCase.getSprint(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setSprint(sprintOutcome.updatedValue()); caseChanged = caseChanged || sprintOutcome.changed(); totalReplacements += sprintOutcome.replacementCount(); - ReplaceOutcome fixVersionsOutcome = updateIfRequested(requestedFields.contains("fixVersions"), testCase.getFixVersions(), findText, replaceText, caseSensitive); + ReplaceOutcome fixVersionsOutcome = updateCaseFieldOutcome( + "fixVersions", requestedFields.contains("fixVersions"), testCase.getFixVersions(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setFixVersions(fixVersionsOutcome.updatedValue()); caseChanged = caseChanged || fixVersionsOutcome.changed(); totalReplacements += fixVersionsOutcome.replacementCount(); - ReplaceOutcome versionOutcome = updateIfRequested(requestedFields.contains("version"), testCase.getVersion(), findText, replaceText, caseSensitive); + ReplaceOutcome versionOutcome = updateCaseFieldOutcome( + "version", requestedFields.contains("version"), testCase.getVersion(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setVersion(versionOutcome.updatedValue()); caseChanged = caseChanged || versionOutcome.changed(); totalReplacements += versionOutcome.replacementCount(); - ReplaceOutcome folderOutcome = updateIfRequested(requestedFields.contains("folder"), testCase.getFolder(), findText, replaceText, caseSensitive); - testCase.setFolder(folderOutcome.updatedValue()); - caseChanged = caseChanged || folderOutcome.changed(); + ReplaceOutcome folderOutcome = updateCaseFieldOutcome( + "folder", requestedFields.contains("folder"), testCase.getFolder(), findText, replaceText, caseSensitive, directFieldAssignments + ); + String previousFolderValue = testCase.getFolder(); + String folderValue = normalizeFolderPath(folderOutcome.updatedValue()); + testCase.setFolder(folderValue); + boolean folderChanged = !equalsValue(previousFolderValue, folderValue); + caseChanged = caseChanged || folderChanged; totalReplacements += folderOutcome.replacementCount(); - if (folderOutcome.changed() && folderOutcome.updatedValue() != null && !folderOutcome.updatedValue().isBlank()) { - syncedFolders.add(folderOutcome.updatedValue()); + if (folderChanged && folderValue != null && !folderValue.isBlank()) { + syncedFolders.add(folderValue); } - ReplaceOutcome testCaseTypeOutcome = updateIfRequested(requestedFields.contains("testCaseType"), testCase.getTestCaseType(), findText, replaceText, caseSensitive); + ReplaceOutcome testCaseTypeOutcome = updateCaseFieldOutcome( + "testCaseType", requestedFields.contains("testCaseType"), testCase.getTestCaseType(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setTestCaseType(testCaseTypeOutcome.updatedValue()); caseChanged = caseChanged || testCaseTypeOutcome.changed(); totalReplacements += testCaseTypeOutcome.replacementCount(); @@ -276,7 +338,9 @@ public BulkEditResult bulkEditByWorkKeys(String teamKey, BulkEditRequest request caseChanged = caseChanged || updatedByOutcome.changed(); totalReplacements += updatedByOutcome.replacementCount(); - ReplaceOutcome storyLinkagesOutcome = updateIfRequested(requestedFields.contains("storyLinkages"), testCase.getStoryLinkages(), findText, replaceText, caseSensitive); + ReplaceOutcome storyLinkagesOutcome = updateCaseFieldOutcome( + "storyLinkages", requestedFields.contains("storyLinkages"), testCase.getStoryLinkages(), findText, replaceText, caseSensitive, directFieldAssignments + ); testCase.setStoryLinkages(storyLinkagesOutcome.updatedValue()); caseChanged = caseChanged || storyLinkagesOutcome.changed(); totalReplacements += storyLinkagesOutcome.replacementCount(); @@ -296,10 +360,6 @@ public BulkEditResult bulkEditByWorkKeys(String teamKey, BulkEditRequest request caseChanged = true; } - if (caseChanged) { - updatedCaseCount++; - } - boolean anyStepChanged = false; for (TestStep step : testCase.getSteps()) { boolean currentStepChanged = false; @@ -325,6 +385,17 @@ public BulkEditResult bulkEditByWorkKeys(String teamKey, BulkEditRequest request } } + StepMutationOutcome stepMutationOutcome = applyStepMutations(testCase, stepMutations); + if (stepMutationOutcome.mutated()) { + caseChanged = true; + anyStepChanged = true; + updatedStepCount += stepMutationOutcome.affectedStepCount(); + } + + if (caseChanged) { + updatedCaseCount++; + } + if (caseChanged || anyStepChanged) { testCase.setUpdatedOn(updatedOnValue); } @@ -460,6 +531,260 @@ private String normalizeStatusValue(String rawStatusValue) { return trimmed; } + private Map resolveDirectFieldAssignments(Map rawFieldValues) { + if (rawFieldValues == null || rawFieldValues.isEmpty()) { + return Collections.emptyMap(); + } + + Map resolved = new java.util.LinkedHashMap<>(); + for (Map.Entry entry : rawFieldValues.entrySet()) { + String rawField = entry.getKey(); + if (rawField == null) { + continue; + } + + String normalized = rawField.trim(); + if (normalized.isEmpty()) { + continue; + } + + String canonicalField = toCanonicalField(normalized); + if (canonicalField == null || !DIRECT_ASSIGNABLE_FIELDS.contains(canonicalField)) { + throw new IllegalArgumentException("Unsupported direct field: " + normalized); + } + + resolved.put(canonicalField, entry.getValue()); + } + + if (resolved.isEmpty()) { + throw new IllegalArgumentException("At least one valid direct field is required"); + } + + return resolved; + } + + private String applyDirectAssignment(String fieldKey, String currentValue, Map directFieldAssignments) { + if (!directFieldAssignments.containsKey(fieldKey)) { + return currentValue; + } + return directFieldAssignments.get(fieldKey); + } + + private ReplaceOutcome updateCaseFieldOutcome(String fieldKey, + boolean shouldEdit, + String source, + String findText, + String replaceText, + boolean caseSensitive, + Map directFieldAssignments) { + ReplaceOutcome replacementOutcome = updateIfRequested(shouldEdit, source, findText, replaceText, caseSensitive); + if (!directFieldAssignments.containsKey(fieldKey)) { + return replacementOutcome; + } + + String finalValue = applyDirectAssignment(fieldKey, replacementOutcome.updatedValue(), directFieldAssignments); + return new ReplaceOutcome(finalValue, 0, !equalsValue(source, finalValue)); + } + + private List resolveStepMutations(List rawMutations) { + if (rawMutations == null || rawMutations.isEmpty()) { + return Collections.emptyList(); + } + + List resolved = new ArrayList<>(); + for (BulkEditRequest.StepMutation rawMutation : rawMutations) { + if (rawMutation == null || rawMutation.getOp() == null) { + continue; + } + + String op = rawMutation.getOp().trim().toLowerCase(Locale.ROOT); + if (op.isEmpty()) { + continue; + } + + if ("insert_after".equals(op)) { + Integer afterStepNumber = rawMutation.getAfterStepNumber(); + if (afterStepNumber == null || afterStepNumber < 0) { + throw new IllegalArgumentException("insert_after requires afterStepNumber >= 0"); + } + resolved.add(StepMutationCommand.insertAfter( + afterStepNumber, + nullToEmpty(rawMutation.getStepSummary()), + nullToEmpty(rawMutation.getTestData()), + nullToEmpty(rawMutation.getExpectedResult()) + )); + continue; + } + + if ("delete".equals(op)) { + Integer stepNumber = rawMutation.getStepNumber(); + if (stepNumber == null || stepNumber <= 0) { + throw new IllegalArgumentException("delete requires stepNumber >= 1"); + } + resolved.add(StepMutationCommand.delete(stepNumber)); + continue; + } + + if ("update".equals(op)) { + Integer stepNumber = rawMutation.getStepNumber(); + if (stepNumber == null || stepNumber <= 0) { + throw new IllegalArgumentException("update requires stepNumber >= 1"); + } + String field = canonicalStepField(rawMutation.getField()); + if (field == null) { + throw new IllegalArgumentException("update requires field in [stepSummary, testData, expectedResult]"); + } + resolved.add(StepMutationCommand.update(stepNumber, field, nullToEmpty(rawMutation.getValue()))); + continue; + } + + throw new IllegalArgumentException("Unsupported step mutation op: " + rawMutation.getOp()); + } + + if (resolved.isEmpty()) { + throw new IllegalArgumentException("At least one valid step mutation is required"); + } + return resolved; + } + + private StepMutationOutcome applyStepMutations(TestCase testCase, List mutations) { + if (mutations == null || mutations.isEmpty()) { + return StepMutationOutcome.none(); + } + + List steps = testCase.getSteps(); + int totalAffectedStepCount = 0; + boolean mutated = false; + + for (StepMutationCommand mutation : mutations) { + if (mutation.op() == StepMutationOp.INSERT_AFTER) { + int insertIndex; + if (mutation.anchorStepNumber() == 0) { + insertIndex = 0; + } else { + int anchorIndex = indexOfStepNumber(steps, mutation.anchorStepNumber()); + if (anchorIndex < 0) { + throw new IllegalArgumentException("Step not found for insert_after: " + mutation.anchorStepNumber()); + } + insertIndex = anchorIndex + 1; + } + + int insertedStepNumber = insertIndex + 1; + TestStep inserted = new TestStep( + insertedStepNumber, + mutation.stepSummary(), + mutation.testData(), + mutation.expectedResult() + ); + inserted.setTestCase(testCase); + steps.add(insertIndex, inserted); + + for (int i = insertIndex + 1; i < steps.size(); i++) { + steps.get(i).setStepNumber(i + 1); + } + + totalAffectedStepCount += (steps.size() - insertIndex); + mutated = true; + continue; + } + + if (mutation.op() == StepMutationOp.DELETE) { + int deleteIndex = indexOfStepNumber(steps, mutation.anchorStepNumber()); + if (deleteIndex < 0) { + throw new IllegalArgumentException("Step not found for delete: " + mutation.anchorStepNumber()); + } + + steps.remove(deleteIndex); + for (int i = deleteIndex; i < steps.size(); i++) { + steps.get(i).setStepNumber(i + 1); + } + + totalAffectedStepCount += (steps.size() - deleteIndex + 1); + mutated = true; + continue; + } + + if (mutation.op() == StepMutationOp.UPDATE) { + int updateIndex = indexOfStepNumber(steps, mutation.anchorStepNumber()); + if (updateIndex < 0) { + throw new IllegalArgumentException("Step not found for update: " + mutation.anchorStepNumber()); + } + + TestStep step = steps.get(updateIndex); + boolean changed = false; + if ("stepSummary".equals(mutation.fieldKey())) { + changed = !equalsValue(step.getStepSummary(), mutation.fieldValue()); + if (changed) { + step.setStepSummary(mutation.fieldValue()); + } + } else if ("testData".equals(mutation.fieldKey())) { + changed = !equalsValue(step.getTestData(), mutation.fieldValue()); + if (changed) { + step.setTestData(mutation.fieldValue()); + } + } else if ("expectedResult".equals(mutation.fieldKey())) { + changed = !equalsValue(step.getExpectedResult(), mutation.fieldValue()); + if (changed) { + step.setExpectedResult(mutation.fieldValue()); + } + } + + if (changed) { + totalAffectedStepCount += 1; + mutated = true; + } + } + } + + return new StepMutationOutcome(totalAffectedStepCount, mutated); + } + + private int indexOfStepNumber(List steps, int stepNumber) { + for (int i = 0; i < steps.size(); i++) { + if (steps.get(i).getStepNumber() == stepNumber) { + return i; + } + } + return -1; + } + + private String canonicalStepField(String rawField) { + if (rawField == null) { + return null; + } + String normalized = rawField.trim(); + if (normalized.isEmpty()) { + return null; + } + String key = normalized.replace("_", "").replace("-", "").toLowerCase(Locale.ROOT); + if ("stepsummary".equals(key)) { + return "stepSummary"; + } + if ("testdata".equals(key)) { + return "testData"; + } + if ("expectedresult".equals(key)) { + return "expectedResult"; + } + return null; + } + + private String normalizeFolderPath(String rawPath) { + if (rawPath == null) { + return ""; + } + String normalized = rawPath.trim().replace('\\', '/').replaceAll("/+", "/"); + return normalized.replaceAll("^/+", "").replaceAll("/+$", "").trim(); + } + + private String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private boolean equalsValue(String left, String right) { + return Objects.equals(left, right); + } + /** * Appends one classified failure entry into the response payload. */ @@ -469,4 +794,39 @@ private void addFailure(BulkEditResult result, String workKey, String reason) { private record ReplaceOutcome(String updatedValue, int replacementCount, boolean changed) { } + + private record StepMutationCommand(StepMutationOp op, + int anchorStepNumber, + String stepSummary, + String testData, + String expectedResult, + String fieldKey, + String fieldValue) { + private static StepMutationCommand insertAfter(int afterStepNumber, + String stepSummary, + String testData, + String expectedResult) { + return new StepMutationCommand(StepMutationOp.INSERT_AFTER, afterStepNumber, stepSummary, testData, expectedResult, "", ""); + } + + private static StepMutationCommand delete(int stepNumber) { + return new StepMutationCommand(StepMutationOp.DELETE, stepNumber, "", "", "", "", ""); + } + + private static StepMutationCommand update(int stepNumber, String fieldKey, String fieldValue) { + return new StepMutationCommand(StepMutationOp.UPDATE, stepNumber, "", "", "", fieldKey, fieldValue); + } + } + + private record StepMutationOutcome(int affectedStepCount, boolean mutated) { + private static StepMutationOutcome none() { + return new StepMutationOutcome(0, false); + } + } + + private enum StepMutationOp { + INSERT_AFTER, + DELETE, + UPDATE + } } diff --git a/src/main/java/com/formswim/teststream/shared/domain/TestStep.java b/src/main/java/com/formswim/teststream/shared/domain/TestStep.java index b80d20e..610c449 100644 --- a/src/main/java/com/formswim/teststream/shared/domain/TestStep.java +++ b/src/main/java/com/formswim/teststream/shared/domain/TestStep.java @@ -51,6 +51,10 @@ public int getStepNumber() { return stepNumber; } + public void setStepNumber(int stepNumber) { + this.stepNumber = stepNumber; + } + public String getStepSummary() { return stepSummary; } diff --git a/src/main/java/com/formswim/teststream/testcase/controllers/TestCaseMutationController.java b/src/main/java/com/formswim/teststream/testcase/controllers/TestCaseMutationController.java new file mode 100644 index 0000000..c0ddb72 --- /dev/null +++ b/src/main/java/com/formswim/teststream/testcase/controllers/TestCaseMutationController.java @@ -0,0 +1,136 @@ +package com.formswim.teststream.testcase.controllers; + +import java.util.Map; +import java.util.Optional; + +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.transaction.TransactionSystemException; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +import com.formswim.teststream.auth.model.AppUser; +import com.formswim.teststream.auth.service.CurrentUserService; +import com.formswim.teststream.testcase.dto.TestCaseBulkDeleteRequest; +import com.formswim.teststream.testcase.dto.TestCaseBulkDeleteResponse; +import com.formswim.teststream.testcase.dto.TestCaseCreateRequest; +import com.formswim.teststream.testcase.dto.TestCaseCreateResponse; +import com.formswim.teststream.testcase.services.TestCaseBadRequestException; +import com.formswim.teststream.testcase.services.TestCaseConflictException; +import com.formswim.teststream.testcase.services.TestCaseNotFoundException; +import com.formswim.teststream.testcase.services.WorkspaceTestCaseMutationService; + +import jakarta.servlet.http.HttpSession; + +@Controller +@RequestMapping +public class TestCaseMutationController { + + private final CurrentUserService currentUserService; + private final WorkspaceTestCaseMutationService workspaceTestCaseMutationService; + + public TestCaseMutationController(CurrentUserService currentUserService, + WorkspaceTestCaseMutationService workspaceTestCaseMutationService) { + this.currentUserService = currentUserService; + this.workspaceTestCaseMutationService = workspaceTestCaseMutationService; + } + + @PostMapping("/api/testcases") + @ResponseBody + public ResponseEntity createTestCase(@RequestBody TestCaseCreateRequest 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 { + TestCaseCreateResponse created = workspaceTestCaseMutationService.createBlankTestCase( + user.getTeamKey(), + user.getEmail(), + request + ); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } catch (TestCaseBadRequestException ex) { + return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage())); + } catch (TestCaseConflictException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("message", ex.getMessage())); + } catch (DataIntegrityViolationException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("message", "Testcase could not be created due to a conflicting update. Please retry.")); + } catch (TransactionSystemException ex) { + return ResponseEntity.badRequest().body(Map.of("message", "Testcase request failed validation.")); + } + } + + @DeleteMapping("/api/testcases/{workKey}") + @ResponseBody + public ResponseEntity deleteTestCase(@PathVariable String workKey, + 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 { + workspaceTestCaseMutationService.deleteTestCase(user.getTeamKey(), workKey); + return ResponseEntity.noContent().build(); + } catch (TestCaseBadRequestException ex) { + return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage())); + } catch (TestCaseNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("message", ex.getMessage())); + } catch (DataIntegrityViolationException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("message", "Testcase could not be deleted because it is still referenced.")); + } catch (TransactionSystemException ex) { + return ResponseEntity.badRequest().body(Map.of("message", "Testcase delete request failed validation.")); + } + } + + @PostMapping("/api/testcases/bulk-delete") + @ResponseBody + public ResponseEntity bulkDeleteTestCases(@RequestBody TestCaseBulkDeleteRequest 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 { + TestCaseBulkDeleteResponse result = workspaceTestCaseMutationService.bulkDeleteTestCases(user.getTeamKey(), request); + return ResponseEntity.ok(result); + } catch (TestCaseBadRequestException ex) { + return ResponseEntity.badRequest().body(Map.of("message", ex.getMessage())); + } catch (DataIntegrityViolationException ex) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("message", "Testcase bulk delete conflicted with another request. Please retry.")); + } catch (TransactionSystemException ex) { + return ResponseEntity.badRequest().body(Map.of("message", "Testcase bulk delete request failed validation.")); + } + } +} diff --git a/src/main/java/com/formswim/teststream/testcase/dto/TestCaseBulkDeleteRequest.java b/src/main/java/com/formswim/teststream/testcase/dto/TestCaseBulkDeleteRequest.java new file mode 100644 index 0000000..4c56421 --- /dev/null +++ b/src/main/java/com/formswim/teststream/testcase/dto/TestCaseBulkDeleteRequest.java @@ -0,0 +1,16 @@ +package com.formswim.teststream.testcase.dto; + +import java.util.List; + +public class TestCaseBulkDeleteRequest { + + private List workKeys; + + public List getWorkKeys() { + return workKeys; + } + + public void setWorkKeys(List workKeys) { + this.workKeys = workKeys; + } +} diff --git a/src/main/java/com/formswim/teststream/testcase/dto/TestCaseBulkDeleteResponse.java b/src/main/java/com/formswim/teststream/testcase/dto/TestCaseBulkDeleteResponse.java new file mode 100644 index 0000000..2f814e6 --- /dev/null +++ b/src/main/java/com/formswim/teststream/testcase/dto/TestCaseBulkDeleteResponse.java @@ -0,0 +1,4 @@ +package com.formswim.teststream.testcase.dto; + +public record TestCaseBulkDeleteResponse(int requestedCount, int deletedCount, int missingCount) { +} diff --git a/src/main/java/com/formswim/teststream/testcase/dto/TestCaseCreateRequest.java b/src/main/java/com/formswim/teststream/testcase/dto/TestCaseCreateRequest.java new file mode 100644 index 0000000..9ae2c07 --- /dev/null +++ b/src/main/java/com/formswim/teststream/testcase/dto/TestCaseCreateRequest.java @@ -0,0 +1,32 @@ +package com.formswim.teststream.testcase.dto; + +public class TestCaseCreateRequest { + + private String workKey; + private String name; + private String folder; + + public String getWorkKey() { + return workKey; + } + + public void setWorkKey(String workKey) { + this.workKey = workKey; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFolder() { + return folder; + } + + public void setFolder(String folder) { + this.folder = folder; + } +} diff --git a/src/main/java/com/formswim/teststream/testcase/dto/TestCaseCreateResponse.java b/src/main/java/com/formswim/teststream/testcase/dto/TestCaseCreateResponse.java new file mode 100644 index 0000000..92ea9d5 --- /dev/null +++ b/src/main/java/com/formswim/teststream/testcase/dto/TestCaseCreateResponse.java @@ -0,0 +1,4 @@ +package com.formswim.teststream.testcase.dto; + +public record TestCaseCreateResponse(Long id, String workKey, String name, String folder) { +} diff --git a/src/main/java/com/formswim/teststream/testcase/services/TestCaseBadRequestException.java b/src/main/java/com/formswim/teststream/testcase/services/TestCaseBadRequestException.java new file mode 100644 index 0000000..fa988d5 --- /dev/null +++ b/src/main/java/com/formswim/teststream/testcase/services/TestCaseBadRequestException.java @@ -0,0 +1,7 @@ +package com.formswim.teststream.testcase.services; + +public class TestCaseBadRequestException extends RuntimeException { + public TestCaseBadRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/com/formswim/teststream/testcase/services/TestCaseConflictException.java b/src/main/java/com/formswim/teststream/testcase/services/TestCaseConflictException.java new file mode 100644 index 0000000..adb7882 --- /dev/null +++ b/src/main/java/com/formswim/teststream/testcase/services/TestCaseConflictException.java @@ -0,0 +1,7 @@ +package com.formswim.teststream.testcase.services; + +public class TestCaseConflictException extends RuntimeException { + public TestCaseConflictException(String message) { + super(message); + } +} diff --git a/src/main/java/com/formswim/teststream/testcase/services/TestCaseNotFoundException.java b/src/main/java/com/formswim/teststream/testcase/services/TestCaseNotFoundException.java new file mode 100644 index 0000000..e6af8f9 --- /dev/null +++ b/src/main/java/com/formswim/teststream/testcase/services/TestCaseNotFoundException.java @@ -0,0 +1,7 @@ +package com.formswim.teststream.testcase.services; + +public class TestCaseNotFoundException extends RuntimeException { + public TestCaseNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/formswim/teststream/testcase/services/WorkspaceTestCaseMutationService.java b/src/main/java/com/formswim/teststream/testcase/services/WorkspaceTestCaseMutationService.java new file mode 100644 index 0000000..91da823 --- /dev/null +++ b/src/main/java/com/formswim/teststream/testcase/services/WorkspaceTestCaseMutationService.java @@ -0,0 +1,173 @@ +package com.formswim.teststream.testcase.services; + +import java.util.List; +import java.time.LocalDate; + +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; +import com.formswim.teststream.testcase.dto.TestCaseBulkDeleteRequest; +import com.formswim.teststream.testcase.dto.TestCaseBulkDeleteResponse; +import com.formswim.teststream.testcase.dto.TestCaseCreateRequest; +import com.formswim.teststream.testcase.dto.TestCaseCreateResponse; +import com.formswim.teststream.workspace.services.FolderPathSyncService; + +@Service +public class WorkspaceTestCaseMutationService { + + private static final int MAX_WORK_KEY_LENGTH = 100; + private static final int MAX_TESTCASE_NAME_LENGTH = 255; + private static final String FOLDER_SYNC_ACTOR = "testcase-create"; + + private final TestCaseRepository testCaseRepository; + private final FolderPathSyncService folderPathSyncService; + + public WorkspaceTestCaseMutationService(TestCaseRepository testCaseRepository, + FolderPathSyncService folderPathSyncService) { + this.testCaseRepository = testCaseRepository; + this.folderPathSyncService = folderPathSyncService; + } + + @Transactional + public TestCaseCreateResponse createBlankTestCase(String teamKey, String actorEmail, TestCaseCreateRequest request) { + if (request == null) { + throw new TestCaseBadRequestException("Request body is required."); + } + + String workKey = normalizeWorkKey(request.getWorkKey()); + String name = normalizeName(request.getName()); + String folder = normalizeFolder(request.getFolder()); + String actor = normalizeActor(actorEmail); + String now = LocalDate.now().toString(); + + try { + if (!folder.isBlank()) { + folderPathSyncService.ensureFolderPathExists(teamKey, folder, FOLDER_SYNC_ACTOR); + } + } catch (IllegalArgumentException ex) { + throw new TestCaseBadRequestException(ex.getMessage()); + } + + if (testCaseRepository.existsByTeamKeyAndWorkKey(teamKey, workKey)) { + throw new TestCaseConflictException("A testcase with this ID already exists."); + } + + TestCase testCase = new TestCase( + teamKey, + workKey, + name, + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + folder, + "", + actor, + now, + actor, + now, + "", + "", + "" + ); + + TestCase saved = testCaseRepository.save(testCase); + return new TestCaseCreateResponse(saved.getId(), saved.getWorkKey(), saved.getSummary(), saved.getFolder()); + } + + @Transactional + public void deleteTestCase(String teamKey, String rawWorkKey) { + String workKey = normalizeDeleteWorkKey(rawWorkKey); + TestCase testCase = testCaseRepository.findByTeamKeyAndWorkKey(teamKey, workKey) + .orElseThrow(() -> new TestCaseNotFoundException("Testcase not found.")); + testCaseRepository.delete(testCase); + } + + @Transactional + public TestCaseBulkDeleteResponse bulkDeleteTestCases(String teamKey, TestCaseBulkDeleteRequest request) { + if (request == null || request.getWorkKeys() == null || request.getWorkKeys().isEmpty()) { + throw new TestCaseBadRequestException("At least one testcase ID is required."); + } + + List normalizedWorkKeys = request.getWorkKeys().stream() + .map(this::normalizeDeleteWorkKey) + .distinct() + .toList(); + + if (normalizedWorkKeys.isEmpty()) { + throw new TestCaseBadRequestException("At least one testcase ID is required."); + } + + List owned = testCaseRepository.findOwnedWorkKeysIn(teamKey, normalizedWorkKeys); + if (owned.isEmpty()) { + return new TestCaseBulkDeleteResponse(normalizedWorkKeys.size(), 0, normalizedWorkKeys.size()); + } + + List ownedCases = testCaseRepository.findAllWithStepsByTeamKeyAndWorkKeyIn(teamKey, owned); + int deletedCount = ownedCases.size(); + if (deletedCount > 0) { + testCaseRepository.deleteAll(ownedCases); + testCaseRepository.flush(); + } + int missingCount = Math.max(normalizedWorkKeys.size() - deletedCount, 0); + return new TestCaseBulkDeleteResponse(normalizedWorkKeys.size(), deletedCount, missingCount); + } + + private String normalizeWorkKey(String raw) { + String value = raw == null ? "" : raw.trim(); + if (value.isBlank()) { + throw new TestCaseBadRequestException("Testcase ID is required."); + } + if (value.length() > MAX_WORK_KEY_LENGTH) { + throw new TestCaseBadRequestException("Testcase ID cannot exceed 100 characters."); + } + return value; + } + + private String normalizeName(String raw) { + String value = raw == null ? "" : raw.trim(); + if (value.isBlank()) { + throw new TestCaseBadRequestException("Testcase name is required."); + } + if (value.length() > MAX_TESTCASE_NAME_LENGTH) { + throw new TestCaseBadRequestException("Testcase name cannot exceed 255 characters."); + } + return value; + } + + private String normalizeFolder(String raw) { + if (raw == null) { + return ""; + } + String normalized = raw.trim().replace('\\', '/').replaceAll("/+", "/"); + return normalized.replaceAll("^/+", "").replaceAll("/+$", "").trim(); + } + + private String normalizeActor(String actorEmail) { + if (actorEmail == null || actorEmail.isBlank()) { + return "unknown"; + } + return actorEmail.trim(); + } + + private String normalizeDeleteWorkKey(String rawWorkKey) { + String workKey = rawWorkKey == null ? "" : rawWorkKey.trim(); + if (workKey.isBlank()) { + throw new TestCaseBadRequestException("Testcase ID is required."); + } + if (workKey.length() > MAX_WORK_KEY_LENGTH) { + throw new TestCaseBadRequestException("Testcase ID cannot exceed 100 characters."); + } + return workKey; + } +} 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 05d098b..d36952a 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 @@ -121,6 +121,27 @@ export function createWorkspacePageApi(options) { return parseJsonOrEmpty(response); } + async function createTestCase(input) { + const response = await fetch('/api/testcases', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getCsrfHeaders() + }, + body: JSON.stringify({ + workKey: input?.workKey || '', + name: input?.name || '', + folder: input?.folder || '' + }) + }); + + if (!response.ok) { + return toApiError(response, 'Failed to create testcase.'); + } + + return parseJsonOrEmpty(response); + } + async function updateFolder(folderId, input) { const payload = {}; if (Object.prototype.hasOwnProperty.call(input || {}, 'name')) { @@ -159,6 +180,38 @@ export function createWorkspacePageApi(options) { } } + async function deleteTestCase(workKey) { + const response = await fetch('/api/testcases/' + encodeURIComponent(String(workKey || '')), { + method: 'DELETE', + headers: { + ...getCsrfHeaders() + } + }); + + if (!response.ok) { + return toApiError(response, 'Failed to delete testcase.'); + } + } + + async function bulkDeleteTestCases(input) { + const response = await fetch('/api/testcases/bulk-delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getCsrfHeaders() + }, + body: JSON.stringify({ + workKeys: Array.isArray(input?.workKeys) ? input.workKeys : [] + }) + }); + + if (!response.ok) { + return toApiError(response, 'Failed to delete testcase.'); + } + + return parseJsonOrEmpty(response); + } + function fetchFilterOptions() { return Promise.all([ fetchOptionValues(componentsBaseUrl), @@ -229,7 +282,10 @@ export function createWorkspacePageApi(options) { return { bulkMove, createFolder, + createTestCase, + bulkDeleteTestCases, deleteFolder, + deleteTestCase, fetchFilterOptions, fetchFolders, fetchFolderNodes, diff --git a/src/main/resources/static/js/workspace/bulk-edit.js b/src/main/resources/static/js/workspace/bulk-edit.js index 49bdb38..03005e1 100644 --- a/src/main/resources/static/js/workspace/bulk-edit.js +++ b/src/main/resources/static/js/workspace/bulk-edit.js @@ -633,12 +633,18 @@ export function createBulkEdit(options) { config.showNotice(didChange ? 'success' : 'error', message); } - if (didChange && typeof config.refreshCurrentPage === 'function') { - await config.refreshCurrentPage(); - } + if (didChange && typeof config.refreshWorkspace === 'function') { + await config.refreshWorkspace({ + includeFolders: payload.fields.includes('folder') + }); + } else { + if (didChange && typeof config.refreshCurrentPage === 'function') { + await config.refreshCurrentPage(); + } - if (didChange && typeof config.refreshFolders === 'function') { - await config.refreshFolders(); + if (didChange && typeof config.refreshFolders === 'function') { + await config.refreshFolders(); + } } if (didChange) { diff --git a/src/main/resources/static/js/workspace/components/workspace-folder-tree.js b/src/main/resources/static/js/workspace/components/workspace-folder-tree.js index 005624c..54e08d1 100644 --- a/src/main/resources/static/js/workspace/components/workspace-folder-tree.js +++ b/src/main/resources/static/js/workspace/components/workspace-folder-tree.js @@ -768,10 +768,18 @@ export function createWorkspaceFolderTree(options) { } try { + const previousSelected = uiState.getSelectedFolder(); const updated = await api.updateFolder(dragPayload.id, { parentId: nextParentId }); - const selected = uiState.getSelectedFolder(); - if (selected && selected === sourcePath && updated?.path) { - uiState.setSelectedFolder(uiState.normalizeFolder(updated.path)); + const updatedPath = uiState.normalizeFolder(updated?.path || ''); + if (previousSelected && sourcePath && updatedPath) { + if (previousSelected === sourcePath) { + uiState.setSelectedFolder(updatedPath); + } else if (previousSelected.startsWith(sourcePath + '/')) { + const suffix = previousSelected.slice(sourcePath.length); + uiState.setSelectedFolder(uiState.normalizeFolder(updatedPath + suffix)); + } + } + if (uiState.getSelectedFolder() !== previousSelected) { emitFolderChanged(uiState.getSelectedFolder()); } await loadFolders(); diff --git a/src/main/resources/static/js/workspace/features/workspace-refresh-coordinator.js b/src/main/resources/static/js/workspace/features/workspace-refresh-coordinator.js new file mode 100644 index 0000000..5a87138 --- /dev/null +++ b/src/main/resources/static/js/workspace/features/workspace-refresh-coordinator.js @@ -0,0 +1,53 @@ +import { consumeWorkspaceRefreshPending } from './workspace-refresh-state.js'; + +function navigationWasBackForward() { + const navigationEntries = window.performance?.getEntriesByType?.('navigation'); + if (!Array.isArray(navigationEntries) || navigationEntries.length === 0) { + return false; + } + + return navigationEntries[0]?.type === 'back_forward'; +} + +export function createWorkspaceRefreshCoordinator(options) { + const dataController = options?.dataController; + const folderTreeController = options?.folderTreeController; + + async function refreshWorkspace(input) { + const refreshOptions = input || {}; + const tasks = [ + dataController.loadCurrentPage(refreshOptions.pageLoadOptions || {}), + dataController.loadFilterOptions() + ]; + + if (refreshOptions.includeFolders && typeof folderTreeController?.loadFolders === 'function') { + tasks.push(folderTreeController.loadFolders()); + } + + await Promise.all(tasks); + } + + function bindRestoreRefresh() { + window.addEventListener('pageshow', (event) => { + const pendingRefresh = consumeWorkspaceRefreshPending(); + if (!pendingRefresh) { + return; + } + const isRestoreNavigation = Boolean(event.persisted) || navigationWasBackForward(); + if (!isRestoreNavigation) { + return; + } + + refreshWorkspace({ + includeFolders: pendingRefresh.includeFolders + }).catch((error) => { + console.error('Failed to refresh workspace after navigation restore', error); + }); + }); + } + + return { + bindRestoreRefresh, + refreshWorkspace + }; +} diff --git a/src/main/resources/static/js/workspace/features/workspace-refresh-state.js b/src/main/resources/static/js/workspace/features/workspace-refresh-state.js new file mode 100644 index 0000000..7bbdcfa --- /dev/null +++ b/src/main/resources/static/js/workspace/features/workspace-refresh-state.js @@ -0,0 +1,58 @@ +const WORKSPACE_REFRESH_STORAGE_KEY = 'workspace:pending-refresh'; + +function getStorage() { + try { + return window.sessionStorage; + } catch (error) { + return null; + } +} + +export function markWorkspaceRefreshPending(options) { + const storage = getStorage(); + if (!storage) { + return; + } + + + let existingValue = {}; + const rawValue = storage.getItem(WORKSPACE_REFRESH_STORAGE_KEY); + if (rawValue) { + try { + existingValue = JSON.parse(rawValue) || {}; + } catch (error) { + existingValue = {}; + } + } + + const nextValue = { + includeFolders: Boolean(existingValue?.includeFolders) || Boolean(options?.includeFolders) + }; + + storage.setItem(WORKSPACE_REFRESH_STORAGE_KEY, JSON.stringify(nextValue)); +} + +export function consumeWorkspaceRefreshPending() { + const storage = getStorage(); + if (!storage) { + return null; + } + + const rawValue = storage.getItem(WORKSPACE_REFRESH_STORAGE_KEY); + if (!rawValue) { + return null; + } + + storage.removeItem(WORKSPACE_REFRESH_STORAGE_KEY); + + try { + const parsed = JSON.parse(rawValue); + return { + includeFolders: Boolean(parsed?.includeFolders) + }; + } catch (error) { + return { + includeFolders: false + }; + } +} diff --git a/src/main/resources/static/js/workspace/page.js b/src/main/resources/static/js/workspace/page.js index 6c5edf5..2775941 100644 --- a/src/main/resources/static/js/workspace/page.js +++ b/src/main/resources/static/js/workspace/page.js @@ -9,6 +9,7 @@ import { bindWorkspaceHeaderControls } from './features/workspace-header-control import { createWorkspaceImportController } from './features/workspace-import-controller.js'; import { createWorkspaceMoveController } from './features/workspace-move-controller.js'; import { createWorkspaceOrganizeModal } from './features/workspace-organize-modal.js'; +import { createWorkspaceRefreshCoordinator } from './features/workspace-refresh-coordinator.js'; import { bindWorkspacePreviewControls } from './features/workspace-preview-controls.js'; import { bindWorkspaceRowActions } from './features/workspace-row-actions.js'; import { bindWorkspaceThemeControls } from './features/workspace-theme-controls.js'; @@ -29,6 +30,7 @@ const bulkBar = document.getElementById('bulkBar'); const bulkCount = document.getElementById('bulkCount'); const bulkExportSelected = document.getElementById('bulkExportSelected'); const bulkEditOpen = document.getElementById('bulkEditOpen'); +const bulkDelete = document.getElementById('bulkDelete'); const bulkOrganize = document.getElementById('bulkOrganize'); const pageInfo = document.getElementById('wsPageInfo'); const prevPageButton = document.getElementById('wsPrevPage'); @@ -51,6 +53,11 @@ const sidebarTitle = document.getElementById('wsSidebarTitle'); const sidebarInner = document.getElementById('wsSidebarInner'); const sidebarHeader = document.getElementById('wsSidebarHeader'); const newFolderButton = document.getElementById('wsNewFolderButton'); +const newTestcaseButton = document.getElementById('wsNewTestcaseButton'); +const newTestcaseForm = document.getElementById('wsNewTestcaseForm'); +const newTestcaseIdInput = document.getElementById('wsNewTestcaseId'); +const newTestcaseNameInput = document.getElementById('wsNewTestcaseName'); +const newTestcaseCancel = document.getElementById('wsNewTestcaseCancel'); const organizeModal = document.getElementById('organizeModal'); const organizeBackdrop = document.getElementById('organizeBackdrop'); @@ -160,6 +167,11 @@ const folderTreeController = createWorkspaceFolderTree({ } }); +const refreshCoordinator = createWorkspaceRefreshCoordinator({ + dataController, + folderTreeController +}); + const rowActions = bindWorkspaceRowActions({ tbody, selection, @@ -187,14 +199,119 @@ importController = createWorkspaceImportController({ importNoticeMessage, importNoticeClose, onUploadComplete: async () => { + await refreshCoordinator.refreshWorkspace({ + includeFolders: true, + pageLoadOptions: { page: 0 } + }); + } +}); +showNotice = (type, message) => importController.showNotice(type, message); + +function clearNewTestcaseForm() { + if (newTestcaseIdInput) { + newTestcaseIdInput.value = ''; + } + if (newTestcaseNameInput) { + newTestcaseNameInput.value = ''; + } +} + +function hideNewTestcaseForm() { + if (!newTestcaseForm) { + return; + } + newTestcaseForm.classList.add('hidden'); + clearNewTestcaseForm(); +} + +function showNewTestcaseForm() { + if (!newTestcaseForm) { + return; + } + newTestcaseForm.classList.remove('hidden'); + window.requestAnimationFrame(() => { + if (newTestcaseIdInput) { + newTestcaseIdInput.focus(); + newTestcaseIdInput.select(); + } + }); +} + +async function submitNewTestcaseFromForm() { + if (typeof api.createTestCase !== 'function') { + showNotice('error', 'Testcase create API is unavailable.'); + return; + } + + const workKey = String(newTestcaseIdInput?.value || '').trim(); + const name = String(newTestcaseNameInput?.value || '').trim(); + const folder = uiState.getSelectedFolder() || ''; + + if (!workKey || !name) { + showNotice('error', 'Testcase ID and name are required.'); + return; + } + + try { + await api.createTestCase({ + workKey, + name, + folder + }); + + hideNewTestcaseForm(); await Promise.all([ dataController.loadCurrentPage({ page: 0 }), - dataController.loadFilterOptions(), - folderTreeController.loadFolders() + dataController.loadFilterOptions() ]); + showNotice('success', 'Testcase successfully created'); + } catch (error) { + showNotice('error', error?.message || 'Failed to create testcase.'); } -}); -showNotice = (type, message) => importController.showNotice(type, message); +} + +if (newTestcaseButton) { + newTestcaseButton.addEventListener('click', () => { + if (!newTestcaseForm || newTestcaseForm.classList.contains('hidden')) { + showNewTestcaseForm(); + return; + } + hideNewTestcaseForm(); + }); +} + +if (newTestcaseIdInput) { + newTestcaseIdInput.addEventListener('keydown', async (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + hideNewTestcaseForm(); + return; + } + }); +} + +if (newTestcaseNameInput) { + newTestcaseNameInput.addEventListener('keydown', async (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + hideNewTestcaseForm(); + return; + } + }); +} + +if (newTestcaseForm) { + newTestcaseForm.addEventListener('submit', async (event) => { + event.preventDefault(); + await submitNewTestcaseFromForm(); + }); +} + +if (newTestcaseCancel) { + newTestcaseCancel.addEventListener('click', () => { + hideNewTestcaseForm(); + }); +} const moveController = createWorkspaceMoveController({ api, @@ -252,11 +369,8 @@ const bulkEdit = createBulkEdit({ token: document.querySelector('meta[name="_csrf"]')?.content || '' }), getSelectedIds: () => selection.getSelectedIds(), - refreshCurrentPage: async () => { - await dataController.loadCurrentPage(); - }, - refreshFolders: async () => { - await folderTreeController.loadFolders(); + refreshWorkspace: async (options) => { + await refreshCoordinator.refreshWorkspace(options); }, showNotice: (type, message) => { importController.showNotice(type, message); @@ -313,6 +427,7 @@ if (nextPageButton) { dataController.loadCurrentPage(); dataController.loadFilterOptions(); folderTreeController.loadFolders(); +refreshCoordinator.bindRestoreRefresh(); if (bulkDeselectAll) { @@ -320,3 +435,155 @@ if (bulkDeselectAll) { selection.clearSelection(); }); } + +function confirmBulkDeleteTestcases(count) { + return new Promise((resolve) => { + const isSoftTheme = document.body?.dataset?.workspaceTheme !== 'black'; + const previousActiveElement = document.activeElement; + + const root = document.createElement('div'); + root.className = 'fixed inset-0 z-[1400] flex items-center justify-center p-4'; + root.style.backgroundColor = isSoftTheme ? 'rgba(20, 20, 20, 0.46)' : 'rgba(0, 0, 0, 0.62)'; + root.style.backdropFilter = 'blur(2px)'; + + const notice = document.createElement('div'); + notice.className = 'w-[min(32rem,calc(100vw-2rem))] border px-5 py-5 text-sm text-white/85 rounded-md shadow-[0_24px_80px_rgba(0,0,0,0.55)]'; + notice.style.backgroundColor = isSoftTheme ? '#2b2b2b' : 'rgba(0, 0, 0, 0.95)'; + notice.style.borderColor = 'rgba(255, 255, 255, 0.24)'; + notice.setAttribute('role', 'dialog'); + notice.setAttribute('aria-modal', 'true'); + notice.setAttribute('aria-label', 'Confirm testcase delete'); + + const headingWrap = document.createElement('div'); + headingWrap.className = 'min-w-0'; + + const badge = document.createElement('span'); + badge.className = 'font-bold text-black px-2 py-1 mr-3'; + badge.style.backgroundColor = '#E7FF02'; + badge.textContent = 'Confirm'; + + const heading = document.createElement('span'); + heading.className = 'font-medium text-white'; + heading.textContent = 'Delete testcase(s)?'; + + const message = document.createElement('p'); + message.className = 'mt-2 break-words text-white/75'; + message.textContent = 'Delete ' + count + ' selected testcase(s)? This action cannot be undone.'; + + const actions = document.createElement('div'); + actions.className = 'mt-5 flex items-center justify-end gap-2'; + + const cancelButton = document.createElement('button'); + cancelButton.type = 'button'; + cancelButton.className = 'px-3 py-1.5 border border-white/20 hover:border-white/40 text-white/85 hover:text-white transition-colors text-xs'; + cancelButton.textContent = 'Cancel'; + + const deleteButton = document.createElement('button'); + deleteButton.type = 'button'; + deleteButton.className = 'px-3 py-1.5 border text-black font-semibold text-xs transition-colors'; + deleteButton.style.borderColor = '#E7FF02'; + deleteButton.style.backgroundColor = '#E7FF02'; + deleteButton.textContent = 'Delete'; + + actions.appendChild(cancelButton); + actions.appendChild(deleteButton); + + headingWrap.appendChild(badge); + headingWrap.appendChild(heading); + headingWrap.appendChild(message); + notice.appendChild(headingWrap); + notice.appendChild(actions); + root.appendChild(notice); + document.body.appendChild(root); + + const cleanup = (confirmed) => { + document.removeEventListener('keydown', onKeyDown); + if (root.parentNode) { + root.parentNode.removeChild(root); + } + if (previousActiveElement && typeof previousActiveElement.focus === 'function') { + previousActiveElement.focus(); + } + resolve(Boolean(confirmed)); + }; + + const onKeyDown = (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + cleanup(false); + return; + } + + if (event.key === 'Tab') { + const first = cancelButton; + const last = deleteButton; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + return; + } + if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + + document.addEventListener('keydown', onKeyDown); + cancelButton.addEventListener('click', () => cleanup(false)); + deleteButton.addEventListener('click', () => cleanup(true)); + root.addEventListener('click', (event) => { + if (event.target === root) { + cleanup(false); + } + }); + + window.requestAnimationFrame(() => { + cancelButton.focus(); + }); + }); +} + +if (bulkDelete) { + bulkDelete.addEventListener('click', async () => { + if (typeof api.bulkDeleteTestCases !== 'function') { + showNotice('error', 'Testcase delete API is unavailable.'); + return; + } + + const selectedWorkKeys = selection.getSelectedIds(); + if (!Array.isArray(selectedWorkKeys) || selectedWorkKeys.length === 0) { + showNotice('error', 'Select at least one testcase to delete.'); + return; + } + + const confirmed = await confirmBulkDeleteTestcases(selectedWorkKeys.length); + if (!confirmed) { + return; + } + + let result; + try { + result = await api.bulkDeleteTestCases({ workKeys: selectedWorkKeys }); + } catch (error) { + showNotice('error', error?.message || 'Testcase delete failed.'); + return; + } + + await Promise.all([ + dataController.loadCurrentPage(), + dataController.loadFilterOptions() + ]); + selection.clearSelection(); + + const deletedCount = Number(result?.deletedCount || 0); + const requestedCount = Number(result?.requestedCount || selectedWorkKeys.length); + + if (deletedCount >= requestedCount && requestedCount > 0) { + showNotice('success', 'Testcase deleted.'); + return; + } + + showNotice('error', 'Testcase delete failed.'); + }); +} 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 dd90da1..0a70a06 100644 --- a/src/main/resources/static/js/workspace/test-case-details.js +++ b/src/main/resources/static/js/workspace/test-case-details.js @@ -1,3 +1,5 @@ +import { markWorkspaceRefreshPending } from './features/workspace-refresh-state.js'; + function escapeHtml(value) { return String(value) .replace(/&/g, '&') @@ -206,6 +208,23 @@ function getApiFieldKey(fieldKey) { return stepMatch ? stepMatch[1] : fieldKey; } +function isStepField(fieldKey) { + return /^step-\d+-/.test(fieldKey); +} + +function parseStepFieldInfo(fieldKey) { + const match = String(fieldKey || '').match(/^step-(\d+)-(.+)$/); + if (!match) { + return null; + } + return { + stepNumber: Number(match[1]), + apiField: match[2] + }; +} + +const CUSTOM_SELECT_VALUE = '__custom__'; + function showNotice(type, message) { const notice = document.getElementById('detailsNotice'); const msg = document.getElementById('detailsNoticeMsg'); @@ -251,19 +270,45 @@ function flashFieldSaved(fieldKey) { async function saveFieldEdit(workKey, fieldKey, oldValue, newValue) { const csrf = getCsrf(); + const currentValue = String(oldValue || ''); + + if (isStepField(fieldKey)) { + const parsed = parseStepFieldInfo(fieldKey); + if (!parsed || !Number.isFinite(parsed.stepNumber) || parsed.stepNumber <= 0) { + const err = new Error('Invalid step field key'); + err.status = 400; + throw err; + } + return applyStepMutation(workKey, { + op: 'update', + stepNumber: parsed.stepNumber, + field: parsed.apiField, + value: newValue + }); + } + const apiField = getApiFieldKey(fieldKey); + const payload = { + workKeys: [workKey] + }; + + if (fieldKey === 'status') { + payload.fieldValues = { status: newValue }; + } else if (currentValue.trim()) { + payload.findText = currentValue; + payload.replaceText = newValue; + payload.fields = [apiField]; + } else { + payload.fieldValues = { [apiField]: newValue }; + } + const response = await fetch('/api/testcases/bulk-edit', { method: 'PATCH', headers: { 'Content-Type': 'application/json', [csrf.headerName]: csrf.token }, - body: JSON.stringify({ - workKeys: [workKey], - findText: oldValue, - replaceText: newValue, - fields: [apiField] - }) + body: JSON.stringify(payload) }); if (!response.ok) { const err = new Error('Edit failed with status ' + response.status); @@ -273,6 +318,30 @@ async function saveFieldEdit(workKey, fieldKey, oldValue, newValue) { return response.json(); } +async function applyStepMutation(workKey, mutation) { + const csrf = getCsrf(); + const payload = { + workKeys: [workKey], + stepMutations: [mutation] + }; + + const response = await fetch('/api/testcases/bulk-edit', { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + [csrf.headerName]: csrf.token + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const err = new Error('Step mutation failed with status ' + response.status); + err.status = response.status; + throw err; + } + return response.json(); +} + function updateUpdatedOn() { const el = document.getElementById('detailsUpdatedOn'); if (!el) { @@ -289,14 +358,23 @@ function updateUpdatedOn() { function updateFolderSegments(newFolderPath) { const container = document.getElementById('folderSegmentsContainer'); + const displayEl = document.querySelector('[data-field-display="folder"]'); if (!container) { return; } const segments = String(newFolderPath || '').split('/').map((s) => s.trim()).filter(Boolean); if (segments.length === 0) { container.innerHTML = ''; + container.classList.add('hidden'); + if (displayEl) { + displayEl.classList.remove('sr-only'); + } return; } + container.classList.remove('hidden'); + if (displayEl) { + displayEl.classList.add('sr-only'); + } container.innerHTML = segments.map((segment, index) => { const sep = index < segments.length - 1 ? '/' @@ -305,6 +383,22 @@ function updateFolderSegments(newFolderPath) { }).join(''); } +function getEditTriggers(fieldKey) { + return Array.from(document.querySelectorAll( + '[data-edit-field="' + fieldKey + '"], [data-edit-trigger="' + fieldKey + '"]' + )); +} + +function shouldShowDisplayElement(fieldKey, displayEl) { + if (fieldKey !== 'folder') { + return true; + } + + const folderValue = displayEl?.dataset?.rawValue || ''; + const segments = folderValue.split('/').map((segment) => segment.trim()).filter(Boolean); + return segments.length === 0; +} + function applyDisplayUpdate(fieldKey, newValue) { const displayEl = document.querySelector('[data-field-display="' + fieldKey + '"]'); if (!displayEl) { @@ -315,17 +409,57 @@ function applyDisplayUpdate(fieldKey, newValue) { const emptyLabel = displayEl.dataset.emptyLabel || 'Not provided'; displayEl.innerHTML = renderMarkdown(newValue, emptyLabel); } else { - displayEl.textContent = newValue; + displayEl.textContent = newValue || displayEl.dataset.emptyLabel || '-'; } if (fieldKey === 'folder') { updateFolderSegments(newValue); } } +function syncCustomInputVisibility(fieldKey) { + const input = document.querySelector('[data-field-input="' + fieldKey + '"]'); + const customInput = document.querySelector('[data-field-custom-input="' + fieldKey + '"]'); + if (!input || !customInput || input.tagName !== 'SELECT') { + return; + } + const showCustom = input.value === CUSTOM_SELECT_VALUE; + customInput.classList.toggle('hidden', !showCustom); + if (showCustom) { + customInput.focus(); + } +} + +function ensureSelectHasValue(selectEl, value) { + if (!selectEl || selectEl.tagName !== 'SELECT') { + return; + } + const hasOption = Array.from(selectEl.options).some((option) => option.value === value); + if (!hasOption && value) { + const option = document.createElement('option'); + option.value = value; + option.textContent = value + ' (current)'; + selectEl.appendChild(option); + } +} + +function getFieldEditorValue(fieldKey, textarea, input) { + if (textarea) { + return textarea.value || ''; + } + if (!input) { + return ''; + } + if (input.tagName === 'SELECT' && input.value === CUSTOM_SELECT_VALUE) { + const customInput = document.querySelector('[data-field-custom-input="' + fieldKey + '"]'); + return customInput?.value || ''; + } + return input.value || ''; +} + function activateEditMode(fieldKey) { const displayEl = document.querySelector('[data-field-display="' + fieldKey + '"]'); const editContainer = document.querySelector('[data-field-edit="' + fieldKey + '"]'); - const editBtn = document.querySelector('[data-edit-field="' + fieldKey + '"]'); + const editTriggers = getEditTriggers(fieldKey); const textarea = document.querySelector('[data-field-textarea="' + fieldKey + '"]'); const input = document.querySelector('[data-field-input="' + fieldKey + '"]'); if (!displayEl || !editContainer) { @@ -337,11 +471,32 @@ function activateEditMode(fieldKey) { textarea.style.height = ''; } if (input) { - input.value = currentValue; + if (input.tagName === 'SELECT') { + const hasOption = Array.from(input.options).some((option) => option.value === currentValue); + if (hasOption) { + input.value = currentValue; + const customInput = document.querySelector('[data-field-custom-input="' + fieldKey + '"]'); + if (customInput) { + customInput.value = ''; + } + } else if (currentValue) { + input.value = CUSTOM_SELECT_VALUE; + const customInput = document.querySelector('[data-field-custom-input="' + fieldKey + '"]'); + if (customInput) { + customInput.value = currentValue; + } + } else { + input.value = ''; + } + syncCustomInputVisibility(fieldKey); + } else { + ensureSelectHasValue(input, currentValue); + input.value = currentValue; + } } displayEl.classList.add('hidden'); - if (editBtn) { - editBtn.classList.add('hidden'); + for (const editTrigger of editTriggers) { + editTrigger.classList.add('hidden'); } editContainer.classList.remove('hidden'); if (textarea) { @@ -356,15 +511,15 @@ function activateEditMode(fieldKey) { function deactivateEditMode(fieldKey) { const displayEl = document.querySelector('[data-field-display="' + fieldKey + '"]'); const editContainer = document.querySelector('[data-field-edit="' + fieldKey + '"]'); - const editBtn = document.querySelector('[data-edit-field="' + fieldKey + '"]'); + const editTriggers = getEditTriggers(fieldKey); if (editContainer) { editContainer.classList.add('hidden'); } - if (displayEl) { + if (displayEl && shouldShowDisplayElement(fieldKey, displayEl)) { displayEl.classList.remove('hidden'); } - if (editBtn) { - editBtn.classList.remove('hidden'); + for (const editTrigger of editTriggers) { + editTrigger.classList.remove('hidden'); } } @@ -377,7 +532,7 @@ function getEditErrorMessage(error) { return 'You do not have permission to edit this test case.'; } if (status === 400) { - return 'The current value may have changed. Refresh and try again.'; + return 'This edit was not accepted. Check the value and try again.'; } return 'Edit failed. Please try again.'; } @@ -388,8 +543,8 @@ function initFieldEditing() { return; } - document.querySelectorAll('[data-edit-field]').forEach((editBtn) => { - const fieldKey = editBtn.dataset.editField; + document.querySelectorAll('[data-edit-field], [data-edit-trigger]').forEach((editBtn) => { + const fieldKey = editBtn.dataset.editField || editBtn.dataset.editTrigger; editBtn.addEventListener('click', () => activateEditMode(fieldKey)); }); @@ -412,7 +567,7 @@ function initFieldEditing() { const input = document.querySelector('[data-field-input="' + fieldKey + '"]'); const oldValue = displayEl?.dataset?.rawValue || ''; - const newValue = (textarea || input)?.value || ''; + const newValue = getFieldEditorValue(fieldKey, textarea, input); if (newValue === oldValue) { deactivateEditMode(fieldKey); @@ -420,14 +575,6 @@ function initFieldEditing() { return; } - if (!oldValue.trim()) { - if (errorEl) { - errorEl.textContent = 'Empty fields cannot be edited here. Use bulk edit to add content.'; - errorEl.classList.remove('hidden'); - } - return; - } - if (errorEl) { errorEl.classList.add('hidden'); } @@ -438,17 +585,23 @@ function initFieldEditing() { try { const result = await saveFieldEdit(workKey, fieldKey, oldValue, newValue); - const didChange = Number(result?.totalReplacements || 0) > 0; + const didChange = Number(result?.updatedCaseCount || 0) > 0 || Number(result?.updatedStepCount || 0) > 0; if (didChange) { applyDisplayUpdate(fieldKey, newValue); deactivateEditMode(fieldKey); updateUpdatedOn(); + const refreshPendingOptions = {}; + if (fieldKey === 'folder') { + refreshPendingOptions.includeFolders = true; + } + flashFieldSaved(fieldKey); + markWorkspaceRefreshPending(refreshPendingOptions); showNotice('success', 'Field updated.'); } else { if (errorEl) { - errorEl.textContent = 'No match found. The value may have changed — refresh and try again.'; + errorEl.textContent = 'No changes were applied.'; errorEl.classList.remove('hidden'); } } @@ -487,6 +640,17 @@ function initFieldEditing() { }); }); + document.querySelectorAll('[data-field-input]').forEach((input) => { + if (input.tagName !== 'SELECT') { + return; + } + const fieldKey = input.dataset.fieldInput; + if (!fieldKey) { + return; + } + input.addEventListener('change', () => syncCustomInputVisibility(fieldKey)); + }); + document.addEventListener('keydown', (e) => { if (e.key !== 'Escape') { return; @@ -512,6 +676,101 @@ function initFieldEditing() { initFieldEditing(); +function setStepActionButtonsDisabled(disabled) { + document.querySelectorAll('[data-step-action]').forEach((button) => { + button.disabled = disabled; + }); +} + +function normalizeStepCountLabel(count) { + return count + (count === 1 ? ' step' : ' steps'); +} + +function updateStepCountBadges(count) { + const topBadge = document.getElementById('detailsStepCountBadge'); + if (topBadge) { + topBadge.textContent = String(count); + } + + const executionBadge = document.getElementById('detailsExecutionStepCount'); + if (executionBadge) { + executionBadge.textContent = normalizeStepCountLabel(count); + } +} + +function initStepMutations() { + const workKey = getWorkKey(); + if (!workKey) { + return; + } + + document.querySelectorAll('[data-step-action]').forEach((button) => { + button.addEventListener('click', async () => { + if (button.disabled) { + return; + } + + const action = button.dataset.stepAction; + const stepNumber = Number(button.dataset.stepNumber || '0'); + let mutation = null; + + if (action === 'add-after' || action === 'add-first') { + mutation = { + op: 'insert_after', + afterStepNumber: Number.isFinite(stepNumber) ? stepNumber : 0, + stepSummary: '', + testData: '', + expectedResult: '' + }; + } else if (action === 'delete') { + const confirmed = window.confirm('Delete step ' + stepNumber + '? This will renumber following steps.'); + if (!confirmed) { + return; + } + mutation = { + op: 'delete', + stepNumber + }; + } + + if (!mutation) { + return; + } + + const originalLabel = button.textContent; + button.textContent = 'Saving...'; + setStepActionButtonsDisabled(true); + + try { + const result = await applyStepMutation(workKey, mutation); + const changed = Number(result?.updatedCaseCount || 0) > 0 || Number(result?.updatedStepCount || 0) > 0; + if (!changed) { + showNotice('error', 'No step changes were applied.'); + return; + } + + const visibleStepCards = document.querySelectorAll('[data-step-action="delete"]').length; + const expectedCount = action === 'delete' + ? Math.max(0, visibleStepCards - 1) + : visibleStepCards + 1; + updateStepCountBadges(expectedCount); + + markWorkspaceRefreshPending(); + showNotice('success', action === 'delete' ? 'Step deleted.' : 'Step added.'); + window.location.reload(); + } catch (error) { + const message = getEditErrorMessage(error); + showNotice('error', message); + } finally { + button.textContent = originalLabel; + setStepActionButtonsDisabled(false); + } + }); + }); +} + +initStepMutations(); + function initDoubleClickEdit() { const workKey = getWorkKey(); if (!workKey) { diff --git a/src/main/resources/templates/test-case-details.html b/src/main/resources/templates/test-case-details.html index c642b67..adf0fe0 100644 --- a/src/main/resources/templates/test-case-details.html +++ b/src/main/resources/templates/test-case-details.html @@ -85,36 +85,40 @@

- Status: - - Priority: - - Steps: 0 + Steps: 0