diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java index 711e6f80..b704b617 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/controller/ScreenerResource.java @@ -253,8 +253,9 @@ public Response saveFormSchema( storageService.writeJsonToStorage(filePath, request.schema()); return Response.ok().build(); } catch (Exception e) { - Log.info(("Failed to save form for screener " + screenerId)); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + Log.error("Failed to save form for screener " + screenerId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Could not save the form. Please try again.")).build(); } } @@ -283,9 +284,12 @@ public Response publishScreener( Screener screener = screenerOpt.get(); screenerRepository.publishScreener(screener); return Response.ok().build(); + } catch (BadRequestException e) { + return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", e.getMessage())).build(); } catch (Exception e) { - Log.error("Error: Error updating screener to published. Screener: " + screenerId); - return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build(); + Log.error("Error updating screener to published. Screener: " + screenerId, e); + return Response.status(Response.Status.INTERNAL_SERVER_ERROR) + .entity(Map.of("error", "Could not publish the screener. Please try again.")).build(); } } diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/FirestoreUtils.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/FirestoreUtils.java index e3799896..99cdda5f 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/FirestoreUtils.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/FirestoreUtils.java @@ -199,6 +199,19 @@ public static String persistDocumentWithId(String collectionPath, String documen } + /* Writes a document under a known ID, replacing any document already there. */ + public static void replaceDocument(String collectionPath, String documentId, Map data) throws Exception { + try { + db.collection(collectionPath).document(documentId).set(data).get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new Exception("Thread interrupted while saving to Firestore", e); + } catch (ExecutionException e) { + throw new Exception("Failed to write document to Firestore", e); + } + } + + /* Reads a document inside a transaction, so the transaction fails if it changes before commit. */ public interface TransactionReader { Optional> get(String collection, String id) throws Exception; diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/GoogleStorageService.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/GoogleStorageService.java index 2f1c7995..5d42ece9 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/GoogleStorageService.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/GoogleStorageService.java @@ -47,7 +47,7 @@ public void writeStringToStorage(String filePath, String content, String content } @Override - public void writeBytesToStorage(String filePath, byte[] content, String contentType){ + public void writeBytesToStorage(String filePath, byte[] content, String contentType) throws Exception { try { BlobId blobId = BlobId.of(bucketName, filePath); BlobInfo blobInfo = BlobInfo.newBuilder(blobId) @@ -57,11 +57,12 @@ public void writeBytesToStorage(String filePath, byte[] content, String contentT Log.info("Uploaded to GCS: " + filePath); } catch (Exception e){ Log.error("Error writing bytes to GCS: " + e.getMessage()); + throw new Exception("Could not write file to cloud storage", e); } } @Override - public void writeJsonToStorage(String filePath, JsonNode json){ + public void writeJsonToStorage(String filePath, JsonNode json) throws Exception { try { ObjectMapper mapper = new ObjectMapper(); byte[] content = mapper.writeValueAsBytes(json); @@ -73,6 +74,7 @@ public void writeJsonToStorage(String filePath, JsonNode json){ Log.info("Uploaded to GCS: " + filePath); } catch (Exception e){ Log.error("Error writing JSON to GCS: " + e.getMessage()); + throw new Exception("Could not save JSON to cloud storage", e); } } @@ -189,7 +191,7 @@ public void updatePublishedFormSchemaArtifact(String screenerId, String publishe Log.info("Working form schema copied to published artifact path for screener: " + screenerId); } catch (Exception e) { Log.error("Error updating published form schema in cloud storage:", e); - throw new Exception("Error updating published form schema in cloud storage"); + throw new Exception("Error updating published form schema in cloud storage", e); } } } diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/StorageService.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/StorageService.java index 0d09ce75..47c1f77f 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/StorageService.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/StorageService.java @@ -11,9 +11,9 @@ public interface StorageService { void writeStringToStorage(String filePath, String content, String contentType) throws Exception; - void writeBytesToStorage(String filePath, byte[] content, String contentType); + void writeBytesToStorage(String filePath, byte[] content, String contentType) throws Exception; - void writeJsonToStorage(String filePath, JsonNode json); + void writeJsonToStorage(String filePath, JsonNode json) throws Exception; Optional getFileInputStreamFromStorage(String filePath); diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/impl/PublishedScreenerRepositoryImpl.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/impl/PublishedScreenerRepositoryImpl.java index ee463cf7..703cc0cb 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/impl/PublishedScreenerRepositoryImpl.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/impl/PublishedScreenerRepositoryImpl.java @@ -90,8 +90,12 @@ public void refreshPublishedScreenerbenefits(String publishedScreenerId, List data = mapper.convertValue(benefit, Map.class); - FirestoreUtils.persistDocument( + // Schema components refer to the working benefit IDs. Keep those IDs + // in the published subcollection so evaluation results match the snapshot. + // Overwrite: a document the delete above missed must not fail the publish. + FirestoreUtils.replaceDocument( calculateCustomBenefitCollection(publishedScreenerId), + benefit.getId(), data ); } diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/impl/ScreenerRepositoryImpl.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/impl/ScreenerRepositoryImpl.java index c23c45ca..4d046567 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/impl/ScreenerRepositoryImpl.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/persistence/impl/ScreenerRepositoryImpl.java @@ -188,23 +188,31 @@ public void deleteCustomBenefit(String screenerId, String benefitId) throws Exce /* Publishing logic */ @Override public void publishScreener(Screener screener) throws Exception { - if (screener.getPublishedScreenerId() == null || screener.getPublishedScreenerId().isEmpty()) { + // A working screener can exist before its form has ever been saved. Check + // storage before creating a public record or changing publication metadata. + String workingFormPath = storageService.getScreenerWorkingFormSchemaPath(screener.getId()); + if (storageService.readFormSchema(workingFormPath).isEmpty()) { + throw new jakarta.ws.rs.BadRequestException("Save the form in Form Editor before publishing."); + } + List benefits = this.getBenefitsInScreener(screener); + boolean firstPublish = screener.getPublishedScreenerId() == null || screener.getPublishedScreenerId().isEmpty(); + if (firstPublish) { String publishedScreenerId = publishedScreenerRepository.createPublishedScreener(screener); screener.setPublishedScreenerId(publishedScreenerId); - } else { - publishedScreenerRepository.updatePublishedScreener(screener.getPublishedScreenerId(), screener); } - screener.setLastPublishDate(Instant.now().toString()); - this.updateWorkingScreener(screener); - + storageService.updatePublishedFormSchemaArtifact( + screener.getId(), screener.getPublishedScreenerId() + ); publishedScreenerRepository.refreshPublishedScreenerbenefits( screener.getPublishedScreenerId(), - this.getBenefitsInScreener(screener) + benefits ); - storageService.updatePublishedFormSchemaArtifact( - screener.getId(), screener.getPublishedScreenerId() - ); + screener.setLastPublishDate(Instant.now().toString()); + if (!firstPublish) { + publishedScreenerRepository.updatePublishedScreener(screener.getPublishedScreenerId(), screener); + } + this.updateWorkingScreener(screener); } } diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/ExampleScreenerImportService.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/ExampleScreenerImportService.java index 0545731b..2b24c9b7 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/ExampleScreenerImportService.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/ExampleScreenerImportService.java @@ -68,12 +68,14 @@ public List importForUser(String userId) throws Exception { for (SeedScreenerData seedScreener : seedData.screeners()) { List importedBenefits = new ArrayList<>(); List importedBenefitDetails = new ArrayList<>(); + Map benefitIds = new LinkedHashMap<>(); for (Benefit seedBenefit : seedScreener.benefits()) { Benefit importedBenefit = cloneBenefit( seedBenefit, userId, importedCustomCheckIds); importedBenefits.add(importedBenefit); + benefitIds.put(seedBenefit.getId(), importedBenefit.getId()); importedBenefitDetails.add( new BenefitDetail(importedBenefit.getId(), importedBenefit.getName(), @@ -100,7 +102,7 @@ public List importForUser(String userId) throws Exception { .getScreenerWorkingFormSchemaPath(newScreenerId); storageService.writeJsonToStorage( formPath, - seedScreener.formSchema()); + NextStepsFormSchema.remapBenefits(seedScreener.formSchema(), benefitIds)); } importedScreenerIds.add(newScreenerId); diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/NextStepsFormSchema.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/NextStepsFormSchema.java new file mode 100644 index 00000000..7a4d5002 --- /dev/null +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/NextStepsFormSchema.java @@ -0,0 +1,34 @@ +package org.codeforphilly.bdt.builder.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.Map; + +/** Keeps component copies attached to their benefits when an import allocates new IDs. */ +final class NextStepsFormSchema { + private NextStepsFormSchema() {} + + static JsonNode remapBenefits(JsonNode schema, Map benefitIds) { + if (schema == null) return null; + JsonNode copy = schema.deepCopy(); + remapComponent(copy, benefitIds); + return copy; + } + + private static void remapComponent(JsonNode component, Map benefitIds) { + if (!component.isObject()) return; + if ("next_steps".equals(component.path("type").asText()) + && component.path("benefitContent").isObject()) { + ObjectNode content = JsonNodeFactory.instance.objectNode(); + component.path("benefitContent").fields().forEachRemaining(entry -> { + String importedId = benefitIds.get(entry.getKey()); + // Copies for removed benefits have no destination and can be discarded. + if (importedId != null) content.set(importedId, entry.getValue()); + }); + ((ObjectNode) component).set("benefitContent", content); + } + JsonNode children = component.path("components"); + if (children.isArray()) children.forEach(child -> remapComponent(child, benefitIds)); + } +} diff --git a/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/ScreenerTransferService.java b/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/ScreenerTransferService.java index 3c366fb1..6ef62619 100644 --- a/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/ScreenerTransferService.java +++ b/builder-api/src/main/java/org/codeforphilly/bdt/builder/service/ScreenerTransferService.java @@ -152,9 +152,11 @@ public Screener importScreener(String owner, ScreenerTransfer transfer) throws E } List benefits = new ArrayList<>(); List details = new ArrayList<>(); + Map benefitIds = new LinkedHashMap<>(); for (Benefit original : transfer.benefits()) { Benefit benefit = copy(original, Benefit.class); benefit.setId(UUID.randomUUID().toString()); + benefitIds.put(original.getId(), benefit.getId()); benefit.setOwnerId(owner); for (CheckConfig config : configs(benefit)) { String source = sourceId(config); @@ -184,7 +186,8 @@ public Screener importScreener(String owner, ScreenerTransfer transfer) throws E if (transfer.formSchema() != null && !transfer.formSchema().isNull()) { String path = storage.getScreenerWorkingFormSchemaPath(id); cleanup.push(() -> storage.deleteFile(path)); - storage.writeStringToStorage(path, mapper.writeValueAsString(transfer.formSchema()), "application/json"); + storage.writeStringToStorage(path, mapper.writeValueAsString( + NextStepsFormSchema.remapBenefits(transfer.formSchema(), benefitIds)), "application/json"); } screener.setBenefits(details); cleanup.push(() -> screeners.deleteWorkingScreener(id)); diff --git a/builder-api/src/test/java/org/codeforphilly/bdt/builder/controller/ScreenerResourceTest.java b/builder-api/src/test/java/org/codeforphilly/bdt/builder/controller/ScreenerResourceTest.java index 40541c6b..cd1757ae 100644 --- a/builder-api/src/test/java/org/codeforphilly/bdt/builder/controller/ScreenerResourceTest.java +++ b/builder-api/src/test/java/org/codeforphilly/bdt/builder/controller/ScreenerResourceTest.java @@ -10,6 +10,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.ws.rs.NotFoundException; import org.codeforphilly.bdt.builder.model.dto.screener.EditScreenerRequest; +import org.codeforphilly.bdt.builder.model.dto.screener.PublishScreenerRequest; +import org.codeforphilly.bdt.builder.model.dto.screener.SaveSchemaRequest; import org.codeforphilly.bdt.builder.persistence.DuplicateScreenerNameException; import org.codeforphilly.bdt.builder.persistence.ScreenerRepository; import org.eclipse.microprofile.jwt.JsonWebToken; @@ -50,6 +52,32 @@ void setUp() { when(screenerRepository.getWorkingScreener("screener-1")).thenReturn(Optional.of(screener)); } + @Test + void publishReturnsAnActionableErrorForAnUnsavedForm() throws Exception { + when(screenerRepository.getWorkingScreenerMetaDataOnly("screener-1")).thenReturn(Optional.of(screener)); + doThrow(new jakarta.ws.rs.BadRequestException("Save the form in Form Editor before publishing.")) + .when(screenerRepository).publishScreener(screener); + var request = new PublishScreenerRequest(); + request.screenerId = "screener-1"; + try (var response = resource.publishScreener(identity, request)) { + assertEquals(400, response.getStatus()); + assertEquals(Map.of("error", "Save the form in Form Editor before publishing."), response.getEntity()); + } + } + + @Test + void failedFormSaveDoesNotReturnSuccess() throws Exception { + resource.validator = mock(jakarta.validation.Validator.class); + var request = new SaveSchemaRequest(new ObjectMapper().readTree("{\"components\":[]}")); + when(resource.validator.validate(request)).thenReturn(java.util.Set.of()); + when(storage.getScreenerWorkingFormSchemaPath("screener-1")).thenReturn("form/working/screener-1.json"); + doThrow(new Exception("Storage write failed")).when(storage).writeJsonToStorage("form/working/screener-1.json", request.schema()); + try (var response = resource.saveFormSchema(identity, "screener-1", request)) { + assertEquals(500, response.getStatus()); + assertTrue(((Map) response.getEntity()).get("error").toString().contains("Could not save the form")); + } + } + @Test void changedNameIsCheckedForUniqueness() throws Exception { doThrow(new DuplicateScreenerNameException()).when(screenerRepository).renameWorkingScreener(any()); diff --git a/builder-api/src/test/java/org/codeforphilly/bdt/builder/persistence/GoogleStorageServiceTest.java b/builder-api/src/test/java/org/codeforphilly/bdt/builder/persistence/GoogleStorageServiceTest.java new file mode 100644 index 00000000..1618445e --- /dev/null +++ b/builder-api/src/test/java/org/codeforphilly/bdt/builder/persistence/GoogleStorageServiceTest.java @@ -0,0 +1,47 @@ +package org.codeforphilly.bdt.builder.persistence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.storage.Blob; +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class GoogleStorageServiceTest { + private final Storage storage = mock(Storage.class); + private final GoogleStorageService service = new GoogleStorageService(); + + @BeforeEach + void setup() { + service.storage = storage; + service.bucketName = "test-bucket"; + } + + @Test + void formSavePropagatesCloudStorageFailures() throws Exception { + var failure = new StorageException(503, "Storage unavailable"); + when(storage.createFrom(any(BlobInfo.class), any(InputStream.class))).thenThrow(failure); + var schema = new ObjectMapper().readTree("{\"components\":[]}"); + var error = assertThrows(Exception.class, () -> service.writeJsonToStorage("form/working/test.json", schema)); + assertSame(failure, error.getCause()); + } + + @Test + void publishedFormCopyPropagatesDestinationWriteFailures() throws Exception { + var source = mock(Blob.class); + when(storage.get(BlobId.of("test-bucket", "form/working/working.json"))).thenReturn(source); + when(source.exists()).thenReturn(true); + when(source.getContent()).thenReturn("{\"components\":[]}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var failure = new StorageException(503, "Write unavailable"); + when(storage.createFrom(any(BlobInfo.class), any(InputStream.class))).thenThrow(failure); + var error = assertThrows(Exception.class, () -> service.updatePublishedFormSchemaArtifact("working", "public")); + assertSame(failure, error.getCause().getCause()); + } +} diff --git a/builder-api/src/test/java/org/codeforphilly/bdt/builder/persistence/impl/ScreenerPublishingTest.java b/builder-api/src/test/java/org/codeforphilly/bdt/builder/persistence/impl/ScreenerPublishingTest.java new file mode 100644 index 00000000..a871adf8 --- /dev/null +++ b/builder-api/src/test/java/org/codeforphilly/bdt/builder/persistence/impl/ScreenerPublishingTest.java @@ -0,0 +1,89 @@ +package org.codeforphilly.bdt.builder.persistence.impl; + +import jakarta.ws.rs.BadRequestException; +import org.codeforphilly.bdt.builder.model.domain.Screener; +import org.codeforphilly.bdt.builder.persistence.PublishedScreenerRepository; +import org.codeforphilly.bdt.builder.persistence.StorageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ScreenerPublishingTest { + @Mock StorageService storage; + @Mock PublishedScreenerRepository published; + @Spy @InjectMocks ScreenerRepositoryImpl repository; + Screener screener; + + @BeforeEach + void setup() { + screener = Screener.create("owner", "Housing", null); + screener.setId("working"); + when(storage.getScreenerWorkingFormSchemaPath("working")).thenReturn("form/working/working.json"); + } + + @Test + void missingFormDoesNotCreateAPublicRecordOrMarkTheScreenerPublished() throws Exception { + when(storage.readFormSchema("form/working/working.json")).thenReturn(Optional.empty()); + var error = assertThrows(BadRequestException.class, () -> repository.publishScreener(screener)); + assertEquals("Save the form in Form Editor before publishing.", error.getMessage()); + assertNull(screener.getPublishedScreenerId()); + assertNull(screener.getLastPublishDate()); + verifyNoInteractions(published); + verify(repository, never()).updateWorkingScreener(any()); + verify(storage, never()).updatePublishedFormSchemaArtifact(anyString(), anyString()); + } + + @Test + void failedStorageReadIsNotTreatedAsAnUnsavedForm() throws Exception { + var failure = new IOException("Storage unavailable"); + when(storage.readFormSchema("form/working/working.json")).thenThrow(failure); + assertSame(failure, assertThrows(IOException.class, () -> repository.publishScreener(screener))); + verifyNoInteractions(published); + verify(repository, never()).updateWorkingScreener(any()); + } + + @Test + void failedFormCopyDoesNotRefreshBenefitsOrAdvancePublicationMetadata() throws Exception { + savedForm(); + screener.setPublishedScreenerId("public"); + screener.setLastPublishDate("previous publication"); + doThrow(new IOException("Write failed")).when(storage).updatePublishedFormSchemaArtifact("working", "public"); + assertThrows(IOException.class, () -> repository.publishScreener(screener)); + assertEquals("previous publication", screener.getLastPublishDate()); + verifyNoInteractions(published); + verify(repository, never()).updateWorkingScreener(any()); + } + + @Test + void publicationIsMarkedSuccessfulOnlyAfterArtifactsHaveBeenSaved() throws Exception { + savedForm(); + when(published.createPublishedScreener(screener)).thenReturn("public"); + doNothing().when(repository).updateWorkingScreener(screener); + repository.publishScreener(screener); + var order = inOrder(storage, published, repository); + order.verify(storage).readFormSchema("form/working/working.json"); + order.verify(published).createPublishedScreener(screener); + order.verify(storage).updatePublishedFormSchemaArtifact("working", "public"); + order.verify(published).refreshPublishedScreenerbenefits("public", List.of()); + order.verify(repository).updateWorkingScreener(screener); + assertNotNull(screener.getLastPublishDate()); + } + + private void savedForm() throws Exception { + when(storage.readFormSchema("form/working/working.json")).thenReturn(Optional.of(Map.of("components", List.of()))); + doReturn(List.of()).when(repository).getBenefitsInScreener(screener); + } +} diff --git a/builder-api/src/test/java/org/codeforphilly/bdt/builder/service/ScreenerTransferServiceTest.java b/builder-api/src/test/java/org/codeforphilly/bdt/builder/service/ScreenerTransferServiceTest.java index 011fc20b..0b10a9f4 100644 --- a/builder-api/src/test/java/org/codeforphilly/bdt/builder/service/ScreenerTransferServiceTest.java +++ b/builder-api/src/test/java/org/codeforphilly/bdt/builder/service/ScreenerTransferServiceTest.java @@ -80,6 +80,28 @@ void setup() throws Exception { when(screeners.getBenefitsInScreener(source)).thenReturn(benefits); } + @Test + void importRemapsNestedNextStepsCopiesWithoutReplacingAnalystContent() throws Exception { + Map field = Map.of("id", "next", "type", "next_steps", "showWhen", "whenComplete", + "benefitContent", Map.of("benefit-a", Map.of("eligible", Map.of("body", "Screener-specific instructions")), + "removed-benefit", Map.of("eligible", Map.of("body", "Removed content")))); + source.setFormSchema(Map.of("type", "default", "components", + List.of(Map.of("type", "group", "components", List.of(field))))); + when(storage.readFormSchema("form/working/" + source.getId() + ".json")) + .thenReturn(Optional.of(source.getFormSchema())); + ScreenerTransfer transfer = service.exportScreener("sender", source.getId()); + Screener imported = service.importScreener("recipient", transfer); + ArgumentCaptor saved = ArgumentCaptor.forClass(String.class); + verify(storage).writeStringToStorage(eq("form/working/" + imported.getId() + ".json"), saved.capture(), eq("application/json")); + var copiedField = mapper.readTree(saved.getValue()).path("components").get(0).path("components").get(0); + String newId = imported.getBenefits().getFirst().getId(); + assertEquals("Screener-specific instructions", copiedField.path("benefitContent").path(newId).path("eligible").path("body").asText()); + assertEquals(1, copiedField.path("benefitContent").size()); + assertEquals("whenComplete", copiedField.path("showWhen").asText()); + assertTrue(transfer.formSchema().path("components").get(0).path("components").get(0).path("benefitContent").has("benefit-a"), + "Import must not mutate a shared source schema"); + } + @Test void roundTripPreservesRulesVersionsFormAndSettingsWithNewOwnershipAndIds() throws Exception { var content = new NextSteps(new NextSteps.Eligible("https://example.org/apply", null, null, "Instructions\n\n- Bring ID"), diff --git a/builder-frontend/README.md b/builder-frontend/README.md index 3736d590..2556f590 100644 --- a/builder-frontend/README.md +++ b/builder-frontend/README.md @@ -57,3 +57,26 @@ The hook uses the viewer's `FormContext`, shared by both field renderers. The viewer fields. Consumers outside Preact can use `getSnapshot()` and `subscribe()` on the service; `subscribe()` returns an unsubscribe function and updates fire `screeningResults.changed` on the form's event bus. + +## Next steps component + +Analysts can place **Next steps** from the presentation palette. Its `next_steps` +schema field stores `benefitContent`, a map from benefit ID to the `NextSteps` +shape, and `showWhen`: `whenDecided` (default) or `whenComplete`. Content is copied +when the field is inserted. The properties panel opens a modal to edit the copy +or reset an individual benefit from its source. New benefits need content added +to existing components; removed benefits are ignored when displaying results. + +The editor loads benefit sources in the background. New components finish copying +their content when those requests settle; saving or opening their content modal +waits for that copy. Existing components and other form edits remain usable. +Failed loads show a retry action and preserve saved component content. Content edits +change the form draft and require saving the form. Publish snapshots that schema; +viewers do not fetch the working benefit content. +Publishing keeps benefit IDs stable so those results match the copied content. +Screener imports remap component copies to the newly allocated benefit IDs. + +The component renders sanitized Markdown without template evaluation. Web links +accept HTTP(S) URLs and open in a new tab. Pending or failed evaluations hide old +instructions until fresh decisions arrive. Forms containing the component use +its progress heading; existing forms without it keep the legacy completion message. diff --git a/builder-frontend/src/api/screener.ts b/builder-frontend/src/api/screener.ts index 6ed32356..8c11eadc 100644 --- a/builder-frontend/src/api/screener.ts +++ b/builder-frontend/src/api/screener.ts @@ -106,7 +106,7 @@ export const saveFormSchema = async (screenerId: string, schema: any) => { const response = await authPost(url.toString(), requestData); if (!response.ok) { - throw new Error(`Post failed with status: ${response.status}`); + throw await apiErrorFrom(response, "Could not save the form"); } } catch (error) { console.error("Error saving form schema:", error); @@ -131,7 +131,7 @@ export const publishScreener = async (screenerId: string): Promise => { const response = await authPost(url, { screenerId: screenerId }); if (!response.ok) { - throw new Error(`Submit failed with status: ${response.status}`); + throw await apiErrorFrom(response, "Could not publish the screener"); } } catch (error) { console.error("Error submitting form:", error); diff --git a/builder-frontend/src/components/Header/Header.tsx b/builder-frontend/src/components/Header/Header.tsx index 02b0cb9f..590fa335 100644 --- a/builder-frontend/src/components/Header/Header.tsx +++ b/builder-frontend/src/components/Header/Header.tsx @@ -6,7 +6,7 @@ import { HamburgerMenu } from "@/components/shared/HamburgerMenu"; import ANavBar from "@/components/shared/ANavbar"; import "./Header.css"; -import { Menu } from "lucide-solid"; +import Menu from "lucide-solid/icons/menu"; import { Button } from "@/components/shared/Button"; import { Modal } from "@/components/shared/Modal"; import { ExportExampleScreener } from "@/components/Header/ExportExampleScreener"; diff --git a/builder-frontend/src/components/screener/FormRenderer.tsx b/builder-frontend/src/components/screener/FormRenderer.tsx index 9d91d389..71ebef10 100644 --- a/builder-frontend/src/components/screener/FormRenderer.tsx +++ b/builder-frontend/src/components/screener/FormRenderer.tsx @@ -32,6 +32,7 @@ function FormRenderer({ results = () => undefined, pending = () => false, failed = () => false, + onNextStepsShownChange, }: { schema: { [key: string]: any }; formData: Accessor; @@ -43,6 +44,8 @@ function FormRenderer({ results?: Accessor; pending?: Accessor; failed?: Accessor; + /** Whether a Next steps field is rendered, so the page can skip its own completion message. */ + onNextStepsShownChange?: (shown: boolean) => void; }) { let container: Element | null = null; let form: Form | undefined; @@ -77,7 +80,12 @@ function FormRenderer({ container, additionalModules: [CustomFormFieldsModule, screeningResultsModule], }); - connectScreeningResults(form, { results, pending, failed }); + connectScreeningResults(form, { + results, + pending, + failed, + onNextStepsShownChange, + }); currentData = cloneDeep(formData()); const debouncedSubmit = debounce((data) => { diff --git a/builder-frontend/src/components/screener/Screener.tsx b/builder-frontend/src/components/screener/Screener.tsx index b1053fc0..3bf3f3ef 100644 --- a/builder-frontend/src/components/screener/Screener.tsx +++ b/builder-frontend/src/components/screener/Screener.tsx @@ -77,6 +77,8 @@ export default function Screener() { const [resultsStale, setResultsStale] = createSignal(false); const [evaluationFailed, setEvaluationFailed] = createSignal(false); const [showAllQuestions, setShowAllQuestions] = createSignal(false); + // A rendered Next steps field shows completion in its heading instead. + const [nextStepsShown, setNextStepsShown] = createSignal(false); const unneededQuestionPaths = createMemo( () => getUnneededQuestionPaths(screenerResult()), undefined, @@ -150,6 +152,7 @@ export default function Screener() { failed={evaluationFailed} submitForm={submitForm} evaluateInitialData={!!bridge && !disconnectReason()} + onNextStepsShownChange={setNextStepsShown} onDataChange={() => { evaluationSequence++; setResultsStale(true); @@ -161,10 +164,12 @@ export default function Screener() { change an answer to try again.

- + + + unneededQuestionPaths().length} showAllQuestions={showAllQuestions} diff --git a/builder-frontend/src/components/screenerEditor/FormEditorView.bindings.test.tsx b/builder-frontend/src/components/screenerEditor/FormEditorView.bindings.test.tsx index 6ee02841..4f47124b 100644 --- a/builder-frontend/src/components/screenerEditor/FormEditorView.bindings.test.tsx +++ b/builder-frontend/src/components/screenerEditor/FormEditorView.bindings.test.tsx @@ -2,6 +2,7 @@ import { createSignal } from "solid-js"; import { render } from "solid-js/web"; +import { act } from "preact/test-utils"; import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { extractFormPaths } from "@/utils/formSchemaUtils"; @@ -27,6 +28,13 @@ vi.mock("@/api/screener", () => ({ saveFormSchema: vi.fn(), fetchFormPaths: vi.fn(), })); +vi.mock("@/api/benefit", () => ({ + fetchScreenerBenefit: vi.fn(async (_screenerId, id) => ({ + id, + name: id, + checks: [], + })), +})); import { draftFormSchema, @@ -34,9 +42,11 @@ import { fetchFormPaths, } from "@/api/screener"; import FormEditorView from "./FormEditorView"; +import { fetchScreenerBenefit } from "@/api/benefit"; let dispose: (() => void) | undefined; beforeEach(() => { + editor.instance = undefined; vi.stubGlobal( "IntersectionObserver", class { @@ -46,11 +56,68 @@ beforeEach(() => { }, ); }); -afterEach(() => { +afterEach(async () => { + await act(async () => {}); dispose?.(); document.body.replaceChildren(); vi.clearAllMocks(); - vi.unstubAllGlobals(); +}); +// form-js has deferred Preact effects; keep the DOM shim for this file's environment. + +it("preserves saved component content after a failed source load and retry", async () => { + vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] }); + const benefit = { + id: "housing", + name: "Housing", + description: "", + checks: [], + nextSteps: { eligible: { body: "New source instructions" } }, + }; + vi.mocked(fetchScreenerBenefit) + .mockRejectedValueOnce(new Error("Offline")) + .mockResolvedValueOnce(benefit); + const [schema, setSchema] = createSignal({ + id: "form", + type: "default", + schemaVersion: 18, + components: [ + { + id: "next", + type: "next_steps", + benefitContent: { housing: { eligible: { body: "Published copy" } } }, + }, + ], + }); + const container = document.body.appendChild(document.createElement("div")); + dispose = render( + () => ( + [benefit]} + /> + ), + container, + ); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "could not be loaded", + ), + ); + expect( + editor.instance.saveSchema().components[0].benefitContent.housing.eligible + .body, + ).toBe("Published copy"); + Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "Try again")! + .click(); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')).toBeNull(), + ); + expect( + editor.instance.saveSchema().components[0].benefitContent.housing.eligible + .body, + ).toBe("Published copy"); }); it("keeps every required binding after the actual editor finishes importing and saving a draft", async () => { @@ -146,6 +213,36 @@ it("defaults the key of a newly added field to its ID", async () => { await vi.waitFor(() => expect(added.key).toBe(added.id)); }); +it("can explicitly save the initialized form before any fields are edited", async () => { + vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] }); + const [schema, setSchema] = createSignal(); + const container = document.body.appendChild(document.createElement("div")); + dispose = render( + () => , + container, + ); + await vi.waitFor(() => + expect( + editor.instance?.get("formFieldRegistry").get("BDT_Form"), + ).toBeDefined(), + ); + container + .querySelector( + '[data-testid="form-editor-save-button"]', + )! + .click(); + await vi.waitFor(() => + expect(saveFormSchema).toHaveBeenCalledWith( + "screener-1", + expect.objectContaining({ + type: "default", + schemaVersion: 18, + components: [], + }), + ), + ); +}); + it("provides reactive editor samples keyed to the screener's benefits", async () => { vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] }); const [schema, setSchema] = createSignal(); @@ -164,7 +261,7 @@ it("provides reactive editor samples keyed to the screener's benefits", async () container, ); await vi.waitFor(() => - expect(editor.instance.get("screeningResults").getSnapshot().mode).toBe( + expect(editor.instance?.get("screeningResults").getSnapshot().mode).toBe( "editor", ), ); @@ -176,3 +273,247 @@ it("provides reactive editor samples keyed to the screener's benefits", async () expect(service.getSnapshot().results.housing).toBeUndefined(); expect(editor.instance.saveSchema()).toEqual(saved); }); + +it("copies next steps only on insertion, keeps them out of answers, and preserves imports and undo/redo", async () => { + vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] }); + const source = { + id: "housing", + name: "Housing", + description: "", + checks: [], + nextSteps: { eligible: { body: "Original benefit instructions" } }, + }; + vi.mocked(fetchScreenerBenefit).mockResolvedValue(source); + const [schema, setSchema] = createSignal(); + const container = document.body.appendChild(document.createElement("div")); + dispose = render( + () => ( + [source]} + /> + ), + container, + ); + await vi.waitFor(() => expect(editor.instance).toBeDefined()); + await act(async () => {}); + const model = editor.instance.get("modeling"); + const registry = editor.instance.get("formFieldRegistry"); + const field = model.addFormField( + { type: "next_steps" }, + registry.getAll()[0], + 0, + ); + expect(field.benefitContent.housing).toEqual(source.nextSteps); + expect(field.key).toBeUndefined(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(field.key).toBeUndefined(); + expect(extractFormPaths(editor.instance.saveSchema())).toEqual([]); + source.nextSteps.eligible.body = "Updated benefit instructions"; + expect(field.benefitContent.housing.eligible.body).toBe( + "Original benefit instructions", + ); + editor.instance.get("commandStack").undo(); + expect(registry.get(field.id)).toBeUndefined(); + editor.instance.get("commandStack").redo(); + expect(registry.get(field.id).benefitContent.housing.eligible.body).toBe( + "Original benefit instructions", + ); + const saved = editor.instance.saveSchema(); + await editor.instance.importSchema(saved); + expect( + editor.instance.saveSchema().components[0].benefitContent.housing.eligible + .body, + ).toBe("Original benefit instructions"); +}); + +it("opens the editor before benefit content loads and fills components added meanwhile", async () => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value: () => {}, + }); + vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] }); + const source = { + id: "housing", + name: "Housing", + description: "", + checks: [], + nextSteps: { eligible: { body: "Benefit instructions" } }, + }; + let finishLoading!: (benefit: typeof source) => void; + vi.mocked(fetchScreenerBenefit).mockReturnValueOnce( + new Promise((resolve) => { + finishLoading = resolve; + }), + ); + const [schema, setSchema] = createSignal(); + const container = document.body.appendChild(document.createElement("div")); + dispose = render( + () => ( + [source]} + /> + ), + container, + ); + await vi.waitFor(() => expect(editor.instance).toBeDefined()); + expect( + container.querySelector( + '[data-testid="form-editor-save-button"]', + )!.disabled, + ).toBe(false); + const registry = editor.instance.get("formFieldRegistry"); + const field = editor.instance + .get("modeling") + .addFormField({ type: "next_steps" }, registry.getAll()[0], 0); + expect(field.benefitContent).toEqual({}); + const save = container.querySelector( + '[data-testid="form-editor-save-button"]', + )!; + expect(save.disabled).toBe(true); + editor.instance.get("selection").set(field); + await act(async () => {}); + const contentButton = () => + Array.from(container.querySelectorAll("button")).find((button) => + /Loading benefit content|Edit benefit content/.test( + button.textContent ?? "", + ), + ); + await vi.waitFor(() => expect(contentButton()?.disabled).toBe(true)); + finishLoading(source); + await vi.waitFor(() => + expect(registry.get(field.id).benefitContent.housing).toEqual( + source.nextSteps, + ), + ); + expect( + container.querySelector( + '[data-testid="form-editor-save-button"]', + )!.disabled, + ).toBe(false); + await vi.waitFor(() => expect(contentButton()?.disabled).toBe(false)); + contentButton()!.click(); + expect( + document.querySelector("#next-steps-body")?.value, + ).toBe("Benefit instructions"); +}); + +it("fetches only newly added benefits' content", async () => { + vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] }); + const benefit = (id: string) => ({ id, name: id, description: "" }); + const [benefits, setBenefits] = createSignal([benefit("housing")]); + const [schema, setSchema] = createSignal(); + const container = document.body.appendChild(document.createElement("div")); + dispose = render( + () => ( + + ), + container, + ); + await vi.waitFor(() => expect(fetchScreenerBenefit).toHaveBeenCalledTimes(1)); + setBenefits([benefit("housing"), benefit("food")]); + await vi.waitFor(() => expect(fetchScreenerBenefit).toHaveBeenCalledTimes(2)); + await act(async () => {}); + expect( + vi.mocked(fetchScreenerBenefit).mock.calls.map(([, id]) => id), + ).toEqual(["housing", "food"]); +}); + +async function insertBeforeSourcesLoad() { + vi.mocked(fetchFormPaths).mockResolvedValue({ paths: [] }); + const source = { + id: "housing", + name: "Housing", + description: "", + checks: [], + nextSteps: { eligible: { body: "Benefit instructions" } }, + }; + let finishLoading!: (benefit: typeof source) => void; + let failLoading!: (error: Error) => void; + vi.mocked(fetchScreenerBenefit).mockReturnValueOnce( + new Promise((resolve, reject) => { + finishLoading = resolve; + failLoading = reject; + }), + ); + const [schema, setSchema] = createSignal(); + const container = document.body.appendChild(document.createElement("div")); + dispose = render( + () => ( + [source]} + /> + ), + container, + ); + await vi.waitFor(() => + expect( + editor.instance?.get("formFieldRegistry").get("BDT_Form"), + ).toBeDefined(), + ); + const registry = editor.instance.get("formFieldRegistry"); + const field = editor.instance + .get("modeling") + .addFormField({ type: "next_steps" }, registry.get("BDT_Form"), 0); + return { source, finishLoading, failLoading, registry, field, container }; +} + +it("finishes copying a pending component after undo and redo", async () => { + const { source, finishLoading, registry, field } = + await insertBeforeSourcesLoad(); + const commands = editor.instance.get("commandStack"); + commands.undo(); + finishLoading(source); + await new Promise((resolve) => setTimeout(resolve, 0)); + await act(async () => {}); + commands.redo(); + await vi.waitFor(() => + expect(registry.get(field.id).benefitContent.housing).toEqual( + source.nextSteps, + ), + ); +}); + +it("does not fill an imported component that reuses a pending component's ID", async () => { + const { source, finishLoading, registry, field } = + await insertBeforeSourcesLoad(); + await editor.instance.importSchema({ + id: "BDT_Form", + type: "default", + schemaVersion: 18, + components: [{ id: field.id, type: "next_steps", benefitContent: {} }], + }); + finishLoading(source); + await new Promise((resolve) => setTimeout(resolve, 0)); + await act(async () => {}); + expect(registry.get(field.id).benefitContent).toEqual({}); +}); + +it("finishes pending copies when a failed source request is retried", async () => { + const { source, failLoading, registry, field, container } = + await insertBeforeSourcesLoad(); + failLoading(new Error("Offline")); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "could not be loaded", + ), + ); + vi.mocked(fetchScreenerBenefit).mockResolvedValueOnce(source); + Array.from(container.querySelectorAll("button")) + .find((button) => button.textContent === "Try again")! + .click(); + await vi.waitFor(() => + expect(registry.get(field.id).benefitContent.housing).toEqual( + source.nextSteps, + ), + ); +}); diff --git a/builder-frontend/src/components/screenerEditor/FormEditorView.test.tsx b/builder-frontend/src/components/screenerEditor/FormEditorView.test.tsx index 8d7206ee..271328b5 100644 --- a/builder-frontend/src/components/screenerEditor/FormEditorView.test.tsx +++ b/builder-frontend/src/components/screenerEditor/FormEditorView.test.tsx @@ -24,7 +24,12 @@ vi.mock("@bpmn-io/form-js-editor", () => ({ } get(name: string) { if (name === "screeningResults") return editor.screeningResults; - return { on: vi.fn(), editFormField: vi.fn(), setOptions: vi.fn() }; + return { + on: vi.fn(), + fire: vi.fn(), + editFormField: vi.fn(), + setOptions: vi.fn(), + }; } }, })); @@ -125,6 +130,55 @@ describe("AI form drafting", () => { expect(button()?.disabled).toBe(false); }); + it("reports failed form saves without discarding edits and clears the error on retry", async () => { + vi.mocked(saveFormSchema) + .mockRejectedValueOnce(new Error("Storage failed")) + .mockResolvedValueOnce(undefined); + const { container, schema } = await mount(draft); + const save = () => + container.querySelector( + '[data-testid="form-editor-save-button"]', + )!; + save().click(); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Could not save the form", + ), + ); + expect(schema()).toEqual(draft); + expect(save().className).toContain("btn-yellow"); + save().click(); + await vi.waitFor(() => expect(save().className).toContain("btn-blue")); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it("prevents duplicate form saves and keeps edits made during a save unsaved", async () => { + let finishSave!: () => void; + vi.mocked(saveFormSchema).mockReturnValueOnce( + new Promise((resolve) => { + finishSave = resolve; + }), + ); + const { container } = await mount(draft); + editor.handlers.changed({ schema: draft }); + const saveButton = () => + container.querySelector( + '[data-testid="form-editor-save-button"]', + )!; + saveButton().click(); + expect(saveButton().disabled).toBe(true); + saveButton().click(); + expect(saveFormSchema).toHaveBeenCalledTimes(1); + const newer = { + ...draft, + components: [...draft.components, { id: "next", type: "next_steps" }], + }; + editor.handlers.changed({ schema: newer }); + finishSave(); + await vi.waitFor(() => expect(saveButton().disabled).toBe(false)); + expect(saveButton().className).toContain("btn-yellow"); + }); + it("does not offer drafting on an existing form", async () => { expect((await mount(draft)).button()).toBeUndefined(); }); diff --git a/builder-frontend/src/components/screenerEditor/FormEditorView.tsx b/builder-frontend/src/components/screenerEditor/FormEditorView.tsx index 6c55adb2..3b593eb9 100644 --- a/builder-frontend/src/components/screenerEditor/FormEditorView.tsx +++ b/builder-frontend/src/components/screenerEditor/FormEditorView.tsx @@ -33,19 +33,81 @@ import { } from "../../api/screener"; import { extractFormPaths } from "../../utils/formSchemaUtils"; import Loading from "../Loading"; +import { fetchScreenerBenefit } from "@/api/benefit"; +import NextStepsContentModal from "./NextStepsContentModal"; +import { nextStepsEditorModule } from "./formJsExtensions/nextSteps/editorModule"; +import { + NEXT_STEPS_TYPE, + copyBenefitContent, +} from "./formJsExtensions/nextSteps/content"; import "@bpmn-io/form-js/dist/assets/form-js.css"; import "@bpmn-io/form-js-editor/dist/assets/form-js-editor.css"; -import { FormPath } from "@/types"; +import { Benefit, FormPath } from "@/types"; function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) { const [isUnsaved, setIsUnsaved] = createSignal(false); const [isSaving, setIsSaving] = createSignal(false); + const [saveError, setSaveError] = createSignal(""); const [isDrafting, setIsDrafting] = createSignal(false); const [draftError, setDraftError] = createSignal(""); let disposed = false; let importingDraft = false; const params = useParams(); + const [editorReady, setEditorReady] = createSignal(false); + const [contentField, setContentField] = createSignal(); + const [schemaRevision, setSchemaRevision] = createSignal(0); + // Benefit content is loaded in the background and only used to copy into a new + // Next steps component or reset one, so it never holds up the editor. Each benefit + // is fetched once; a failed fetch is retried on the next load. + const sourceRequests = new Map>(); + const fetchSource = (screenerId: string, benefitId: string) => { + const key = `${screenerId}/${benefitId}`; + let request = sourceRequests.get(key); + if (!request) { + request = fetchScreenerBenefit(screenerId, benefitId); + sourceRequests.set(key, request); + request.catch(() => { + if (sourceRequests.get(key) === request) sourceRequests.delete(key); + }); + } + return request; + }; + const [benefitSources, { refetch: reloadBenefitSources }] = createResource( + () => [params.screenerId, benefits()] as const, + async ([screenerId, details]) => { + const loaded = await Promise.allSettled( + details.map((benefit) => fetchSource(screenerId, benefit.id)), + ); + return { + failed: loaded.some((result) => result.status === "rejected"), + benefits: loaded.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ), + }; + }, + ); + const benefitContentError = () => benefitSources()?.failed ?? false; + // Components added before the content loaded get it once it arrives. + const fieldsAwaitingSources = new Map< + string, + { field: any; benefitIds: Set } + >(); + const isContentLoading = (field: any) => + benefitSources.loading && + fieldsAwaitingSources.get(field.id)?.field === field; + const hasPendingSourceCopy = () => { + schemaRevision(); + if (!editorReady() || !benefitSources.loading) return false; + const registry = formEditor.get("formFieldRegistry") as any; + return Array.from(fieldsAwaitingSources.values()).some( + ({ field, benefitIds }) => + registry.get(field.id) === field && + Array.from(benefitIds).some( + (id) => !Object.hasOwn(field.benefitContent ?? {}, id), + ), + ); + }; // Fetch form paths from backend (replaces local transformation logic) const [formPaths] = createResource( @@ -69,6 +131,7 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) { }; onMount(() => { + // Existing schema content is never refreshed from benefit sources automatically. formEditor = new FormEditor({ container, additionalModules: [ @@ -77,6 +140,21 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) { screeningResultsModule, pathOptionsModule, customKeyModule, + nextStepsEditorModule({ + benefits, + sources: () => benefitSources()?.benefits ?? [], + isContentLoading, + onInsert: (field) => { + if (benefitSources.loading) + fieldsAwaitingSources.set(field.id, { + field, + benefitIds: new Set(benefits().map((benefit) => benefit.id)), + }); + }, + open: (field) => { + if (!isContentLoading(field)) setContentField(field); + }, + }), ], }); const screeningResults = @@ -97,6 +175,7 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) { if (importingDraft) return; setIsUnsaved(true); setFormSchema(e.schema); + setSchemaRevision((revision) => revision + 1); }); // Set default key to field ID when a new form field is added @@ -111,6 +190,7 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) { !importingDraft && field && field.id && + field.type !== NEXT_STEPS_TYPE && field.type !== "group" && field.type !== "default" ) { @@ -122,20 +202,85 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) { }, 0); } }); + setEditorReady(true); + }); - onCleanup(() => { - disposed = true; - if (formEditor) { - formEditor.destroy(); - formEditor = null; - clearTimeout(timeoutId); + let copyUpdateQueued = false; + createEffect(() => { + benefitSources(); + benefitSources.loading; + schemaRevision(); + benefits(); + if (!editorReady() || copyUpdateQueued || !fieldsAwaitingSources.size) + return; + copyUpdateQueued = true; + // A changed event can fire inside undo/redo. Wait until its command finishes + // before starting another modeling command, and read the latest field objects. + queueMicrotask(() => { + copyUpdateQueued = false; + if (disposed || benefitSources.loading) return; + const loaded = benefitSources(); + if (!loaded) return; + const registry = formEditor.get("formFieldRegistry") as any; + const modeling = formEditor.get("modeling") as any; + const currentBenefits = new Set(benefits().map((benefit) => benefit.id)); + for (const [id, pending] of Array.from(fieldsAwaitingSources)) { + const field = registry.get(id); + if (!field) continue; + // Undo may restore this same object; imports create a different one, even + // if their schema reuses its ID. Never fill an imported component. + if (field !== pending.field) { + fieldsAwaitingSources.delete(id); + continue; + } + const content = field.benefitContent ?? {}; + const missing = loaded.benefits.filter( + (benefit) => + pending.benefitIds.has(benefit.id) && + currentBenefits.has(benefit.id) && + !Object.hasOwn(content, benefit.id), + ); + const copiedIds = new Set(missing.map((benefit) => benefit.id)); + for (const benefitId of pending.benefitIds) { + if ( + !currentBenefits.has(benefitId) || + Object.hasOwn(content, benefitId) || + copiedIds.has(benefitId) + ) + pending.benefitIds.delete(benefitId); + } + // Keep failed requests pending so a retry can finish the original copy. + if (!pending.benefitIds.size) fieldsAwaitingSources.delete(id); + if (missing.length) + modeling.editFormField(field, "benefitContent", { + ...content, + ...copyBenefitContent(missing), + }); } }); }); + createEffect(() => { + benefitSources(); + benefitSources.loading; + if (editorReady()) + (formEditor.get("eventBus") as any).fire( + "propertiesPanel.providersChanged", + ); + }); + + onCleanup(() => { + disposed = true; + if (formEditor) { + formEditor.destroy(); + formEditor = null; + clearTimeout(timeoutId); + } + }); + // Update path options when form paths load from backend createEffect(() => { - if (!formEditor || formPaths.loading) return; + if (!editorReady() || formPaths.loading) return; const currentFormPaths: FormPath[] = formPaths() || []; const pathOptionsService = formEditor.get( @@ -151,18 +296,24 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) { }); const handleSave = async () => { + if (isSaving() || hasPendingSourceCopy()) return; + setSaveError(""); const screenerId = params.screenerId; - const schema = formSchema(); + const schema = formSchema() ?? formEditor.saveSchema(); + const revision = schemaRevision(); setIsSaving(true); clearTimeout(timeoutId); try { await saveFormSchema(screenerId, schema); - setIsUnsaved(false); + if (schemaRevision() === revision) setIsUnsaved(false); } catch (error) { // Keep the form marked as unsaved so a failed request is not presented // to the user as a successful save. setIsUnsaved(true); + setSaveError( + "Could not save the form. Your changes are still here. Please try again.", + ); console.error("Failed to save form schema", error); } finally { setIsSaving(false); @@ -241,6 +392,41 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) { return ( <> + + + + + + + + {(field) => ( + benefitSources()?.benefits ?? []} + field={field} + save={(benefitContent) => + (formEditor.get("modeling") as any).editFormField( + field, + "benefitContent", + benefitContent, + ) + } + close={() => setContentField()} + /> + )} +
@@ -248,7 +434,10 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) { type="button" class="btn-default btn-blue disabled:opacity-50" disabled={ - isDrafting() || formPaths.loading || !formPaths()?.length + !editorReady() || + isDrafting() || + formPaths.loading || + !formPaths()?.length } onClick={handleDraft} > @@ -281,27 +470,29 @@ function FormEditorView({ formSchema, setFormSchema, benefits = () => [] }) {
- + - + +
+

+ Changes apply to this component in this screener. Save the form to + keep them. +

+ +

+ Component content updated. Save the form to keep your changes. +

+
+ + + + Add benefits to this screener to configure their next steps.

+ } + > + + + + + {(_key) => { + const benefit = current()!; + const draft = { + ...benefit, + checks: [], + nextSteps: content()[benefit.id], + } as Benefit; + return ( + draft} + save={async (nextSteps) => update(nextSteps)} + onDirtyChange={setDirty} + showSavedStatus={false} + /> + ); + }} + +
+ + + ); +} diff --git a/builder-frontend/src/components/screenerEditor/Publish.test.tsx b/builder-frontend/src/components/screenerEditor/Publish.test.tsx new file mode 100644 index 00000000..e42bffe1 --- /dev/null +++ b/builder-frontend/src/components/screenerEditor/Publish.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment jsdom +import { render } from "solid-js/web"; +import { afterEach, expect, it, vi } from "vitest"; +import Publish from "./Publish"; +import { publishScreener } from "@/api/screener"; +import { ApiError } from "@/api/check"; + +vi.mock("@solidjs/router", () => ({ + useParams: () => ({ screenerId: "screener-1" }), +})); +vi.mock("@/api/screener", () => ({ publishScreener: vi.fn() })); +vi.mock("@/components/shared/Tooltip", () => ({ default: () => null })); +vi.mock("./IntegrationOrigins", () => ({ default: () => null })); +let dispose: () => void; +afterEach(() => { + dispose?.(); + document.body.replaceChildren(); + vi.clearAllMocks(); +}); +function mount() { + const refetch = vi.fn(); + const container = document.body.appendChild(document.createElement("div")); + dispose = render( + () => ( + ({ screenerName: "Test" })} + refetchScreener={refetch} + setIntegrationOrigins={() => {}} + /> + ), + container, + ); + const button = container.querySelector( + '[data-testid="publish-screener-button"]', + )!; + return { container, button, refetch }; +} + +it("shows the missing-form instruction and allows a successful retry", async () => { + vi.mocked(publishScreener) + .mockRejectedValueOnce( + new ApiError("Save the form in Form Editor before publishing.", 400), + ) + .mockResolvedValueOnce(undefined); + const { container, button, refetch } = mount(); + expect(container.querySelector("#screener-url-info a[href]")).toBeNull(); + button.click(); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + "Save the form in Form Editor before publishing.", + ), + ); + expect(refetch).not.toHaveBeenCalled(); + expect(button.disabled).toBe(false); + button.click(); + await vi.waitFor(() => expect(refetch).toHaveBeenCalledTimes(1)); + expect(container.querySelector('[role="alert"]')).toBeNull(); +}); + +it("prevents duplicate publishes and reports storage failures", async () => { + let fail!: (error: Error) => void; + vi.mocked(publishScreener).mockReturnValueOnce( + new Promise((_resolve, reject) => { + fail = reject; + }), + ); + const { container, button } = mount(); + button.click(); + button.click(); + expect(publishScreener).toHaveBeenCalledTimes(1); + expect(button.disabled).toBe(true); + fail(new Error("Network failed")); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Could not publish", + ), + ); + expect(button.disabled).toBe(false); +}); diff --git a/builder-frontend/src/components/screenerEditor/Publish.tsx b/builder-frontend/src/components/screenerEditor/Publish.tsx index c6cde2bb..c2b511a6 100644 --- a/builder-frontend/src/components/screenerEditor/Publish.tsx +++ b/builder-frontend/src/components/screenerEditor/Publish.tsx @@ -1,9 +1,10 @@ -import { createSignal } from "solid-js"; +import { createSignal, Show } from "solid-js"; import { useParams } from "@solidjs/router"; import { publishScreener } from "../../api/screener"; import Tooltip from "../shared/Tooltip"; import { Button } from "@/components/shared/Button"; import IntegrationOrigins from "./IntegrationOrigins"; +import { ApiError } from "@/api/check"; export default function Publish({ screener, @@ -11,12 +12,13 @@ export default function Publish({ setIntegrationOrigins, }) { const [isLoading, setIsLoading] = createSignal(false); + const [publishError, setPublishError] = createSignal(""); const screenerName = () => { return screener()?.screenerName; }; const isPublished = () => { - return screener()?.publishedScreenerId !== null; + return !!screener()?.publishedScreenerId; }; const lastPublishDate = () => { return screener()?.lastPublishDate; @@ -34,12 +36,19 @@ export default function Publish({ const { screenerId } = useParams(); const handlePublish = async () => { + if (isLoading()) return; + setPublishError(""); try { setIsLoading(true); await publishScreener(screenerId); refetchScreener(); - setIsLoading(false); } catch (e) { + setPublishError( + e instanceof ApiError && e.status === 400 + ? e.message + : "Could not publish the screener. Please try again.", + ); + } finally { setIsLoading(false); } }; @@ -59,6 +68,11 @@ export default function Publish({
{screenerName()}
+ + +
Screener URL:
{isPublished() ? ( diff --git a/builder-frontend/src/components/screenerEditor/formJsExtensions/customFormFields/index.js b/builder-frontend/src/components/screenerEditor/formJsExtensions/customFormFields/index.js index eb6a00da..b355a54e 100644 --- a/builder-frontend/src/components/screenerEditor/formJsExtensions/customFormFields/index.js +++ b/builder-frontend/src/components/screenerEditor/formJsExtensions/customFormFields/index.js @@ -1,5 +1,6 @@ import { YesNoQuestion } from './YesNoQuestion'; import { ChecklistWithNone } from './ChecklistWithNone'; +import { NextSteps } from '../nextSteps/NextSteps'; /* * This is a module definition to register custom @@ -9,6 +10,7 @@ class CustomFormFieldsModule { constructor(formFields) { formFields.register(YesNoQuestion.config.type, YesNoQuestion); formFields.register(ChecklistWithNone.config.type, ChecklistWithNone); + formFields.register(NextSteps.config.type, NextSteps); } } diff --git a/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/NextSteps.test.ts b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/NextSteps.test.ts new file mode 100644 index 00000000..fd0d5632 --- /dev/null +++ b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/NextSteps.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { Form } from "@bpmn-io/form-js-viewer"; +import { NextSteps } from "./NextSteps"; +import { webLink } from "./content"; +import { + screeningResultsModule, + ScreeningResultsService, +} from "../screeningResults/screeningResults"; +import type { ScreenerResult } from "@/types"; + +const registration = { + __init__: ["nextStepsField"], + nextStepsField: [ + "type", + class { + static $inject = ["formFields"]; + constructor(fields: any) { + fields.register("next_steps", NextSteps); + } + }, + ], +}; +let form: Form; +afterEach(() => { + form?.destroy(); + document.body.replaceChildren(); +}); +const benefitContent = { + housing: { + eligible: { + applyUrl: "https://example.org/apply", + helpUrl: "https://example.org/help", + helpPhone: "+1 (215) 555-0100", + body: "Bring **proof of residence**.\n\n- Photo ID\n- [Application guide](https://example.org/guide)", + }, + ineligible: { note: "Ask about **other housing help**." }, + }, + removed: { eligible: { body: "Removed benefit instructions" } }, +}; +async function mount(showWhen = "whenDecided", content = benefitContent) { + const container = document.body.appendChild(document.createElement("div")); + form = new Form({ + container, + additionalModules: [registration, screeningResultsModule], + }); + await form.importSchema({ + id: "form", + type: "default", + schemaVersion: 18, + components: [ + { id: "next", type: "next_steps", showWhen, benefitContent: content }, + ], + }); + return { + container, + service: form.get("screeningResults"), + }; +} +const result = (status = "TRUE"): ScreenerResult => ({ + housing: { + name: "Housing assistance", + result: status as any, + check_results: {}, + }, + food: { + name: "Food assistance", + result: "UNABLE_TO_DETERMINE", + check_results: {}, + }, +}); + +it("shows decided benefits immediately, preserves answers/schema, and lists undecided benefits", async () => { + const { container, service } = await mount(); + expect(container.querySelector("h2")).toBeNull(); + service.update(result(), false); + await vi.waitFor(() => + expect(container.textContent).toContain("Your next steps so far"), + ); + expect(container.querySelector("strong")?.textContent).toBe( + "proof of residence", + ); + expect( + container.querySelector('a[href="tel:+12155550100"]')?.textContent, + ).toBe("+1 (215) 555-0100"); + expect(container.textContent).toContain("Still needs more information"); + expect(container.textContent).not.toContain("Removed benefit instructions"); + const links = Array.from( + container.querySelectorAll('.bdt-next-steps a[href^="https:"]'), + ); + expect(links).toHaveLength(3); + links.forEach((link) => { + expect(link.getAttribute("target")).toBe("_blank"); + expect(link.getAttribute("rel")).toContain("noopener"); + }); + expect((form as any)._getState().data).toEqual({}); +}); + +it("uses failed check aliases with name fallback and the analyst's note", async () => { + const { container, service } = await mount(); + const results = result("FALSE"); + results.food.result = "FALSE"; + results.housing.check_results = Object.fromEntries([ + [ + "a", + { + name: "Residence", + aliasName: "Primary residence required", + result: "FALSE", + }, + ], + ["b", { name: "Income limit", aliasName: " ", result: "FALSE" }], + ["c", { name: "Not failed", result: "TRUE" }], + ]) as any; + service.update(results, false); + await vi.waitFor(() => + expect(container.textContent).toContain( + "Screening complete — your next steps", + ), + ); + expect(container.textContent).toContain("Requirements not met"); + expect( + Array.from(container.querySelectorAll("li")).map( + (node) => node.textContent, + ), + ).toEqual(["Primary residence required", "Income limit"]); + expect(container.textContent).toContain("other housing help"); + expect(container.querySelector('a[href*="apply"]')).toBeNull(); +}); + +it("waits for every benefit in whenComplete mode and hides stale instructions during debounce and failure", async () => { + const { container, service } = await mount("whenComplete"); + service.update(result(), false); + await vi.waitFor(() => + expect(container.textContent).toContain("Answer the remaining questions"), + ); + expect(container.querySelector(".bdt-next-steps a")).toBeNull(); + const complete = result(); + complete.food.result = "TRUE"; + service.update(complete, false); + await vi.waitFor(() => + expect(container.querySelector("h2")?.textContent).toContain( + "Screening complete", + ), + ); + const announcement = container.querySelector( + ".bdt-next-steps-visually-hidden", + )!; + expect(announcement.getAttribute("role")).toBe("status"); + expect(announcement.textContent).toContain("Screening complete"); + service.update(complete, true); + await vi.waitFor(() => + expect(container.textContent).toContain("Updating your next steps"), + ); + expect(container.querySelector(".bdt-next-steps a")).toBeNull(); + service.update(complete, true, true); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')).not.toBeNull(), + ); + expect(container.textContent).not.toContain("Screening complete"); + // The same region announces completion again. + expect(container.querySelector('[role="status"]')).toBe(announcement); + service.update(complete, false, false); + await vi.waitFor(() => + expect(container.querySelector(".bdt-next-steps a")).not.toBeNull(), + ); +}); + +it("sanitizes imported Markdown and contact links without evaluating templates", async () => { + const { container, service } = await mount("whenDecided", { + housing: { + eligible: { + applyUrl: "javascript:alert(1)", + helpUrl: "data:text/html,evil", + helpPhone: "javascript:alert(1)", + body: "[Bad](javascript:alert(1))\n\n[Good](https://example.org) {{ custom.secret }}\n\n[Email us](mailto:help@example.org) [Call](tel:+12155550100)", + }, + }, + } as any); + service.update(result(), false); + await vi.waitFor(() => + expect(container.textContent).toContain("custom.secret"), + ); + expect( + container.querySelector( + "script, [onerror], a[href^='javascript:'], a[href^='data:']", + ), + ).toBeNull(); + expect( + container.querySelector("a[target='_blank']")?.getAttribute("rel"), + ).toContain("noopener"); + // Email and phone links stay clickable and open in the person's own app. + const email = container.querySelector("a[href='mailto:help@example.org']"); + expect(email?.textContent).toBe("Email us"); + expect(email?.hasAttribute("target")).toBe(false); + expect( + container.querySelector("a[href='tel:+12155550100']")?.textContent, + ).toBe("Call"); +}); + +it("reports whether it is rendered, so a hidden component keeps the page's completion message", async () => { + const container = document.body.appendChild(document.createElement("div")); + form = new Form({ + container, + additionalModules: [registration, screeningResultsModule], + }); + const service = form.get("screeningResults"); + const changes: boolean[] = []; + form.on("screeningResults.nextStepsShown", ({ shown }: any) => + changes.push(shown), + ); + const schema = (hide: string) => ({ + id: "form", + type: "default", + schemaVersion: 18, + components: [ + { id: "show", type: "checkbox", key: "show", label: "Show" }, + { id: "next", type: "next_steps", conditional: { hide } }, + ], + }); + await form.importSchema(schema("=true")); + expect(service.isNextStepsShown()).toBe(false); + await form.importSchema(schema("=not(show)"), { show: true }); + await vi.waitFor(() => expect(service.isNextStepsShown()).toBe(true)); + await form.importSchema(schema("=not(show)"), { show: true }); + await Promise.resolve(); + expect(changes).toEqual([true]); + await form.importSchema(schema("=not(show)"), { show: false }); + await vi.waitFor(() => expect(service.isNextStepsShown()).toBe(false)); + expect(changes).toEqual([true, false]); +}); + +it("shows only the links the editor would save", () => { + expect(webLink("https://example.org/my form")).toBe( + "https://example.org/my%20form", + ); + expect(webLink("http://my_host/")).toBeUndefined(); + expect(webLink("https://user:secret@example.org/")).toBeUndefined(); + expect(webLink("")).toBeUndefined(); +}); diff --git a/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/NextSteps.ts b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/NextSteps.ts new file mode 100644 index 00000000..45d6a4ab --- /dev/null +++ b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/NextSteps.ts @@ -0,0 +1,197 @@ +import { html } from "htm/preact"; +import { useContext, useLayoutEffect, useMemo } from "preact/hooks"; +import { + FormContext, + iconsByType, + sanitizeHTML, + Text, +} from "@bpmn-io/form-js-viewer"; +import type { BenefitResult, NextSteps as Content } from "@/types"; +import { useScreeningResults } from "../screeningResults/useScreeningResults"; +import { + NEXT_STEPS_TYPE, + contactLink, + webLink, + type BenefitContent, +} from "./content"; +import "./nextSteps.css"; + +/** No template evaluation: analyst Markdown is content, never a FEEL expression. */ +function Markdown({ text }: { text?: string | null }) { + const { getService } = useContext(FormContext); + const renderer = getService("markdownRenderer"); + const markup = useMemo(() => { + const container = document.createElement("div"); + container.innerHTML = sanitizeHTML(renderer.render(text ?? "")); + container.querySelectorAll("a").forEach((link) => { + const safe = webLink(link.getAttribute("href")); + const contact = contactLink(link.getAttribute("href")); + if (safe) { + link.href = safe; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } else if (contact) { + link.href = contact; + link.removeAttribute("target"); + } else { + link.removeAttribute("href"); + link.removeAttribute("target"); + } + }); + return { __html: container.innerHTML }; + }, [renderer, text]); + return html`
`; +} + +function Eligible({ content }: { content?: Content }) { + const apply = webLink(content?.eligible?.applyUrl); + const help = webLink(content?.eligible?.helpUrl); + const phone = content?.eligible?.helpPhone?.trim(); + const dial = + phone && /^[+\d\s().-]+$/.test(phone) + ? phone.replace(/[^+\d]/g, "") + : undefined; + return html` +

You may be eligible.

+
+ ${apply && + html`Apply for this benefit`} + ${help && + html`Get help`} + ${phone && + html`Help phone:${" "} + ${dial ? html`${phone}` : phone}`} +
+ <${Markdown} text=${content?.eligible?.body} /> + `; +} + +type Field = { showWhen?: string; benefitContent?: BenefitContent }; + +export function NextSteps({ field }: { field: Field }) { + const { getService } = useContext(FormContext); + useLayoutEffect( + () => getService("screeningResults", false)?.showNextSteps(), + [getService], + ); + const { results, pending, failed, mode } = useScreeningResults(); + const benefits = Object.entries(results ?? {}); + const complete = + benefits.length > 0 && + benefits.every(([, benefit]) => ["TRUE", "FALSE"].includes(benefit.result)); + // Stays in the DOM so screen readers announce completion, as the page's message did. + return html`
+

+ ${mode === "live" && complete && !pending && !failed + ? "Screening complete. Your next steps are below." + : ""} +

+ <${Body} + field=${field} + benefits=${benefits} + complete=${complete} + pending=${pending} + failed=${failed} + mode=${mode} + /> +
`; +} + +function Body({ + field, + benefits, + complete, + pending, + failed, + mode, +}: { + field: Field; + benefits: [string, BenefitResult][]; + complete: boolean; + pending: boolean; + failed: boolean; + mode: "live" | "editor"; +}) { + if (failed) + return html`
+

+ We couldn't update your next steps. Change an answer to try again. +

+
`; + if (pending) + return html`
+

Updating your next steps…

+
`; + if (!benefits.length) + return mode === "editor" + ? html`
+ Next steps will appear after screening. +
` + : null; + const hideDecided = field.showWhen === "whenComplete" && !complete; + return html`
+ ${mode === "editor" && + html`

+ Example results for the editing canvas +

`} +

+ ${complete + ? "Screening complete — your next steps" + : "Your next steps so far"} +

+ ${hideDecided && + html`

Answer the remaining questions to see your next steps.

`} + ${benefits.map(([id, benefit]) => { + const content = field.benefitContent?.[id]; + if (benefit.result === "UNABLE_TO_DETERMINE") + return html`
+

${benefit.name}

+

Still needs more information.

+
`; + if (hideDecided) return null; + const unmet = Object.values(benefit.check_results ?? {}).filter( + (check) => check.result === "FALSE", + ); + return html`
+

${benefit.name}

+ ${benefit.result === "TRUE" + ? html`<${Eligible} content=${content} />` + : html` +

+ You do not appear to be eligible. +

+ ${unmet.length > 0 && + html`

Requirements not met

+
    + ${unmet.map( + (check) => + html`
  • ${check.aliasName?.trim() || check.name}
  • `, + )} +
`} + <${Markdown} text=${content?.ineligible?.note} /> + `} +
`; + })} +
`; +} + +NextSteps.config = { + ...Text.config, + type: NEXT_STEPS_TYPE, + name: "Next steps", + icon: iconsByType("text"), + propertiesPanelEntries: [], + create: (options = {}) => ({ showWhen: "whenDecided", ...options }), +}; diff --git a/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/content.ts b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/content.ts new file mode 100644 index 00000000..d316b8c1 --- /dev/null +++ b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/content.ts @@ -0,0 +1,32 @@ +import cloneDeep from "lodash/cloneDeep"; +import type { Benefit, NextSteps } from "@/types"; +import { normalizeHttpUrl } from "@/utils/httpUrl"; + +export const NEXT_STEPS_TYPE = "next_steps"; +export type BenefitContent = Record; + +export function copyBenefitContent(benefits: Benefit[]): BenefitContent { + return Object.fromEntries( + benefits.map((benefit) => [benefit.id, cloneDeep(benefit.nextSteps ?? {})]), + ); +} + +/** + * Accept only the web links the editor would save, including when a schema was imported + * by hand. An invalid link is omitted. + */ +export function webLink(value?: string | null): string | undefined { + return (value && normalizeHttpUrl(value)) || undefined; +} + +/** Email and phone links an analyst may write in Markdown. */ +export function contactLink(value?: string | null): string | undefined { + if (!value) return; + try { + const url = new URL(value); + if (["mailto:", "tel:"].includes(url.protocol) && url.pathname) + return url.href; + } catch { + /* An invalid contact is omitted. */ + } +} diff --git a/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/editorModule.test.ts b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/editorModule.test.ts new file mode 100644 index 00000000..0f3eeddf --- /dev/null +++ b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/editorModule.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { render } from "preact"; +import { afterEach, expect, it } from "vitest"; +import { nextStepsEditorModule } from "./editorModule"; +import { NEXT_STEPS_TYPE } from "./content"; + +afterEach(() => document.body.replaceChildren()); + +function panelFor(field: any, options: { benefits: any[]; sources: any[] }) { + const module = nextStepsEditorModule({ + benefits: () => options.benefits, + sources: () => options.sources, + open: () => {}, + }); + const Provider = module.nextStepsProperties[1] as any; + const provider = new Provider( + { registerProvider: () => {} }, + { on: () => {} }, + ); + const [group] = provider.getGroups(field, () => {})([]); + const container = document.body.appendChild(document.createElement("div")); + render(group.entries[0].component(), container); + return container; +} + +it("warns about screener benefits whose content failed to load", () => { + const container = panelFor( + { type: NEXT_STEPS_TYPE, benefitContent: { food: {} } }, + { + benefits: [ + { id: "food", name: "Food", description: "" }, + { id: "housing", name: "Housing", description: "" }, + ], + // Housing's content could not be fetched. + sources: [{ id: "food", name: "Food", description: "", checks: [] }], + }, + ); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Some benefits have no content", + ); +}); + +it("does not warn when every screener benefit has content", () => { + const container = panelFor( + { type: NEXT_STEPS_TYPE, benefitContent: { food: {} } }, + { + benefits: [{ id: "food", name: "Food", description: "" }], + sources: [], + }, + ); + expect(container.querySelector('[role="alert"]')).toBeNull(); +}); diff --git a/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/editorModule.ts b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/editorModule.ts new file mode 100644 index 00000000..f88d1764 --- /dev/null +++ b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/editorModule.ts @@ -0,0 +1,100 @@ +import { html } from "htm/preact"; +import { NEXT_STEPS_TYPE, copyBenefitContent } from "./content"; +import type { Benefit, BenefitDetail } from "@/types"; + +/** Editor-only wiring. Imports and duplicates retain their saved per-screener copy. */ +export function nextStepsEditorModule(options: { + /** The screener's benefits, including any whose content failed to load. */ + benefits: () => BenefitDetail[]; + /** Loaded benefit content to copy into new components. */ + sources: () => Benefit[]; + /** Called after a new component copies the loaded content. */ + onInsert?: (field: any) => void; + isContentLoading?: (field: any) => boolean; + open: (field: any) => void; +}) { + class NextStepsProperties { + static $inject = ["propertiesPanel", "eventBus"]; + constructor(propertiesPanel: any, eventBus: any) { + propertiesPanel.registerProvider(this, 500); + eventBus.on( + "commandStack.formField.add.preExecute", + ({ context }: any) => { + const field = context.formField; + if ( + field.type === NEXT_STEPS_TYPE && + field.benefitContent === undefined + ) { + field.benefitContent = copyBenefitContent(options.sources()); + options.onInsert?.(field); + } + }, + ); + } + getGroups(field: any, editField: any) { + return (groups: any[]) => { + if (field.type !== NEXT_STEPS_TYPE) return groups; + groups.push({ + id: "next-steps", + label: "Next steps", + entries: [ + { + id: "next-steps-content", + component: () => + html`
+ + + + ${options.isContentLoading?.(field) && + html`

Loading benefit content…

`} + ${options + .benefits() + .some( + (benefit) => + !Object.hasOwn(field.benefitContent ?? {}, benefit.id), + ) && + html`

+ Some benefits have no content in this component. Edit + benefit content to add it. +

`} +
`, + }, + ], + }); + return groups; + }; + } + } + return { + __init__: ["nextStepsProperties"], + nextStepsProperties: ["type", NextStepsProperties], + }; +} diff --git a/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/nextSteps.css b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/nextSteps.css new file mode 100644 index 00000000..6e4b6d7d --- /dev/null +++ b/builder-frontend/src/components/screenerEditor/formJsExtensions/nextSteps/nextSteps.css @@ -0,0 +1,65 @@ +.bdt-next-steps { + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 20px; + color: #1e293b; +} +.bdt-next-steps h2 { + font-size: 1.4rem; + font-weight: 700; + margin: 0 0 16px; +} +.bdt-next-steps h3 { + font-size: 1.1rem; + font-weight: 700; + margin: 0 0 8px; +} +.bdt-next-steps h4 { + font-weight: 600; + margin: 12px 0 4px; +} +.bdt-next-steps > section + section { + border-top: 1px solid #e2e8f0; + margin-top: 20px; + padding-top: 20px; +} +.bdt-next-steps a { + color: #1d4ed8; + text-decoration: underline; + overflow-wrap: anywhere; +} +.bdt-next-steps p { + margin: 8px 0; +} +.bdt-next-steps ul, +.bdt-next-steps ol { + padding-left: 24px; + margin: 8px 0; +} +.bdt-next-steps ul { + list-style: disc; +} +.bdt-next-steps ol { + list-style: decimal; +} +.bdt-next-steps-contacts { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + margin: 12px 0; +} +.bdt-next-steps-sample { + color: #475569; + font-style: italic; +} +.bdt-next-steps-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/builder-frontend/src/components/screenerEditor/formJsExtensions/screeningResults/screeningResults.ts b/builder-frontend/src/components/screenerEditor/formJsExtensions/screeningResults/screeningResults.ts index eb108062..c215cdb0 100644 --- a/builder-frontend/src/components/screenerEditor/formJsExtensions/screeningResults/screeningResults.ts +++ b/builder-frontend/src/components/screenerEditor/formJsExtensions/screeningResults/screeningResults.ts @@ -8,6 +8,7 @@ import type { } from "@/types"; export const SCREENING_RESULTS_CHANGED = "screeningResults.changed"; +export const NEXT_STEPS_SHOWN_CHANGED = "screeningResults.nextStepsShown"; export interface ScreeningResultsSnapshot { readonly results: ScreenerResult | undefined; @@ -19,9 +20,9 @@ export interface ScreeningResultsSnapshot { } interface EventBus { - fire(event: string, payload: { snapshot: ScreeningResultsSnapshot }): void; - on(event: string, listener: () => void): void; - off(event: string, listener: () => void): void; + fire(event: string, payload: object): void; + on(event: string, listener: (payload: any) => void): void; + off(event: string, listener: (payload: any) => void): void; } /** What fields see before any results, or in a form without screeningResultsModule. */ @@ -40,9 +41,38 @@ export class ScreeningResultsService { // Fields get a frozen copy so they can't change the parent's results. private liveSource: ScreenerResult | undefined; private liveCopy: ScreenerResult | undefined; + private nextStepsShown = 0; + private reportedNextStepsShown = false; constructor(private eventBus: EventBus) {} + /** + * Next steps fields call this while rendered, so the page can drop its own completion + * message only when one is actually showing (not hidden by a condition). Returns the + * call to make when the field is removed. + */ + showNextSteps = (): (() => void) => { + this.nextStepsShown++; + this.reportNextStepsShown(); + let removed = false; + return () => { + if (removed) return; + removed = true; + this.nextStepsShown--; + // A re-import replaces the field in one render; don't report the gap. + queueMicrotask(() => this.reportNextStepsShown()); + }; + }; + + isNextStepsShown = (): boolean => this.reportedNextStepsShown; + + private reportNextStepsShown(): void { + const shown = this.nextStepsShown > 0; + if (shown === this.reportedNextStepsShown) return; + this.reportedNextStepsShown = shown; + this.eventBus.fire(NEXT_STEPS_SHOWN_CHANGED, { shown }); + } + getSnapshot = (): ScreeningResultsSnapshot => this.snapshot; subscribe = (listener: () => void): (() => void) => { @@ -165,12 +195,21 @@ export function connectScreeningResults( results: Accessor; pending: Accessor; failed: Accessor; + onNextStepsShownChange?: (shown: boolean) => void; }, ): void { const service = form.get("screeningResults"); createEffect(() => service.update(live.results(), live.pending(), live.failed()), ); + const { onNextStepsShownChange } = live; + if (onNextStepsShownChange) { + form + .get("eventBus") + .on(NEXT_STEPS_SHOWN_CHANGED, () => + onNextStepsShownChange(service.isNextStepsShown()), + ); + } } export const screeningResultsModule = { diff --git a/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/NextStepsEditor.test.tsx b/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/NextStepsEditor.test.tsx index f73ba3c1..3c39cd6a 100644 --- a/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/NextStepsEditor.test.tsx +++ b/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/NextStepsEditor.test.tsx @@ -4,7 +4,8 @@ import { render } from "solid-js/web"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { Benefit, NextSteps } from "@/types"; import { ApiError } from "@/api/check"; -import NextStepsEditor, { normalizeHttpUrl } from "./NextStepsEditor"; +import NextStepsEditor from "./NextStepsEditor"; +import { normalizeHttpUrl } from "@/utils/httpUrl"; describe("NextStepsEditor", () => { let dispose: (() => void) | undefined; diff --git a/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/NextStepsEditor.tsx b/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/NextStepsEditor.tsx index 6a6afac1..71b6ac82 100644 --- a/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/NextStepsEditor.tsx +++ b/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/NextStepsEditor.tsx @@ -1,8 +1,9 @@ -import { Accessor, createSignal, For, Show } from "solid-js"; +import { Accessor, createEffect, createSignal, For, Show } from "solid-js"; import { createStore } from "solid-js/store"; import { Button } from "@/components/shared/Button"; import { ApiError } from "@/api/check"; import type { Benefit, NextSteps } from "@/types"; +import { normalizeHttpUrl } from "@/utils/httpUrl"; // Keep the limits in step with NextSteps.java. const contacts = [ @@ -11,50 +12,33 @@ const contacts = [ { key: "helpPhone", label: "Help phone number", type: "tel", maxLength: 50 }, ] as const; -// The server checks links with java.net.URI, which is stricter than the browser's URL parser. -// Send the browser's normalized URL with anything URI rejects percent-encoded, or null if the -// link is not one the server accepts. -const encodeForUri = (part: string) => - part - .replace(/%(?![0-9A-Fa-f]{2})/g, "%25") - .replace(/[^A-Za-z0-9\-._~:/?@!$&'()*+,;=%]/g, encodeURIComponent); - -export function normalizeHttpUrl(value: string): string | null { - let url: URL; - try { - url = new URL(value); - } catch { - return null; - } - if ( - !["https:", "http:"].includes(url.protocol) || - !url.hostname || - url.hostname.includes("_") || - url.username || - url.password - ) - return null; - return ( - url.origin + - encodeForUri(url.pathname + url.search) + - (url.hash && "#" + encodeForUri(url.hash.slice(1))) - ); -} - export default function NextStepsEditor(props: { benefit: Accessor; save: (nextSteps: NextSteps) => Promise; + /** Called with whether the draft has edits that have not been saved. */ + onDirtyChange?: (dirty: boolean) => void; + /** False when the parent reports successful saves itself. */ + showSavedStatus?: boolean; }) { // Read once: refetching checks must not overwrite unsaved instructions. The parent // remounts the editor for a different benefit. const content = props.benefit().nextSteps; - const [draft, setDraft] = createStore({ + const initial = { applyUrl: content?.eligible?.applyUrl ?? "", helpUrl: content?.eligible?.helpUrl ?? "", helpPhone: content?.eligible?.helpPhone ?? "", body: content?.eligible?.body ?? "", note: content?.ineligible?.note ?? "", - }); + }; + const [draft, setDraft] = createStore({ ...initial }); + const [saved, setSaved] = createSignal(initial); + createEffect(() => + props.onDirtyChange?.( + (Object.keys(initial) as (keyof typeof initial)[]).some( + (key) => draft[key] !== saved()[key], + ), + ), + ); const [saving, setSaving] = createSignal(false); const [status, setStatus] = createSignal<{ error: boolean; text: string }>(); @@ -79,6 +63,7 @@ export default function NextStepsEditor(props: { } } setSaving(true); + const submitted = { ...draft }; try { await props.save({ eligible: { @@ -89,7 +74,9 @@ export default function NextStepsEditor(props: { }, ineligible: { note: optional(draft.note) }, }); - setStatus({ error: false, text: "Next steps saved." }); + setSaved(submitted); + if (props.showSavedStatus !== false) + setStatus({ error: false, text: "Next steps saved." }); } catch (error) { // A 400 names the field to fix; retrying the same content would fail again. setStatus({ diff --git a/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/SelectedEligibilityCheck.tsx b/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/SelectedEligibilityCheck.tsx index 213701de..3b93a686 100644 --- a/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/SelectedEligibilityCheck.tsx +++ b/builder-frontend/src/components/screenerEditor/manageBenefits/configureBenefit/SelectedEligibilityCheck.tsx @@ -10,7 +10,7 @@ import type { ParameterDefinition, ParameterValues, } from "@/types"; -import { PencilIcon } from "lucide-solid"; +import PencilIcon from "lucide-solid/icons/pencil"; const SelectedEligibilityCheck = ({ checkConfig, diff --git a/builder-frontend/src/components/screenerEditor/preview/FormRenderer.tsx b/builder-frontend/src/components/screenerEditor/preview/FormRenderer.tsx index d82a2cbe..257eaf40 100644 --- a/builder-frontend/src/components/screenerEditor/preview/FormRenderer.tsx +++ b/builder-frontend/src/components/screenerEditor/preview/FormRenderer.tsx @@ -26,6 +26,7 @@ function FormRenderer({ results = () => undefined, pending = () => false, failed = () => false, + onNextStepsShownChange, }: { schema: Accessor; formData: Accessor; @@ -35,6 +36,8 @@ function FormRenderer({ results?: Accessor; pending?: Accessor; failed?: Accessor; + /** Whether a Next steps field is rendered, so the page can skip its own completion message. */ + onNextStepsShownChange?: (shown: boolean) => void; }) { let container: HTMLDivElement | undefined; let form: Form | undefined; @@ -70,7 +73,12 @@ function FormRenderer({ container, additionalModules: [CustomFormFieldsModule, screeningResultsModule], }); - connectScreeningResults(form, { results, pending, failed }); + connectScreeningResults(form, { + results, + pending, + failed, + onNextStepsShownChange, + }); currentData = cloneDeep(formData()); const debouncedSubmit = debounce( diff --git a/builder-frontend/src/components/screenerEditor/preview/Preview.tsx b/builder-frontend/src/components/screenerEditor/preview/Preview.tsx index d9bc33b8..3a2571fe 100644 --- a/builder-frontend/src/components/screenerEditor/preview/Preview.tsx +++ b/builder-frontend/src/components/screenerEditor/preview/Preview.tsx @@ -25,6 +25,8 @@ const Preview = ({ screener, formSchema }) => { const [evaluationFailed, setEvaluationFailed] = createSignal(false); let evaluationVersion = 0; const [showAllQuestions, setShowAllQuestions] = createSignal(false); + // A rendered Next steps field shows completion in its heading instead. + const [nextStepsShown, setNextStepsShown] = createSignal(false); const unneededQuestionPaths = createMemo( () => getUnneededQuestionPaths(results()), undefined, @@ -80,6 +82,7 @@ const Preview = ({ screener, formSchema }) => { results={results} pending={resultsStale} failed={evaluationFailed} + onNextStepsShownChange={setNextStepsShown} submitForm={handleSubmitForm} onDataChange={() => { evaluationVersion++; @@ -92,7 +95,9 @@ const Preview = ({ screener, formSchema }) => { last successful evaluation.

- + + + unneededQuestionPaths().length} showAllQuestions={showAllQuestions} diff --git a/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenuPanel.tsx b/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenuPanel.tsx index 3a9a37f4..e6fb4e84 100644 --- a/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenuPanel.tsx +++ b/builder-frontend/src/components/shared/HamburgerMenu/HamburgerMenuPanel.tsx @@ -1,6 +1,6 @@ import { Component, JSX, Show } from "solid-js"; import { useHamburgerMenuContext } from "./HamburgerMenuWrapper"; -import { X } from "lucide-solid"; +import X from "lucide-solid/icons/x"; interface Props { children: JSX.Element; diff --git a/builder-frontend/src/components/shared/Modal.tsx b/builder-frontend/src/components/shared/Modal.tsx index 56c5bea8..19c6f555 100644 --- a/builder-frontend/src/components/shared/Modal.tsx +++ b/builder-frontend/src/components/shared/Modal.tsx @@ -1,6 +1,6 @@ import { Accessor, Component, createEffect, ParentProps, Show } from "solid-js"; import { Portal } from "solid-js/web"; -import { CircleX } from "lucide-solid"; +import CircleX from "lucide-solid/icons/circle-x"; import styles from "./Modal.module.css"; diff --git a/builder-frontend/src/components/shared/screeningCompletionFlow.test.tsx b/builder-frontend/src/components/shared/screeningCompletionFlow.test.tsx index e38770fb..6e12da39 100644 --- a/builder-frontend/src/components/shared/screeningCompletionFlow.test.tsx +++ b/builder-frontend/src/components/shared/screeningCompletionFlow.test.tsx @@ -72,14 +72,14 @@ describe.each(["preview", "published"])("completion in %s", (mode) => { document.body.replaceChildren(); }); - async function mount() { + async function mount(schema = { components: [] } as any) { const container = document.body.appendChild(document.createElement("div")); dispose = render( () => mode === "preview" ? ( ({ id: "working" })} - formSchema={() => ({ components: [] })} + formSchema={() => schema} /> ) : ( @@ -90,6 +90,25 @@ describe.each(["preview", "published"])("completion in %s", (mode) => { return container; } + it("uses a rendered component's heading instead of a second completion message", async () => { + const schema = { + components: [{ type: "group", components: [{ type: "next_steps" }] }], + }; + vi.mocked(fetchPublishedScreener).mockResolvedValueOnce({ + screenerName: "Test", + formSchema: schema, + }); + api().mockResolvedValueOnce(decided); + const container = await mount(schema); + await renderer.props.submitForm({ answer: true }); + // Until the component renders (for example, while a condition hides it). + expect(container.textContent).toContain("Screening complete"); + renderer.props.onNextStepsShownChange(true); + expect(container.textContent).not.toContain("Screening complete"); + renderer.props.onNextStepsShownChange(false); + expect(container.textContent).toContain("Screening complete"); + }); + it("ignores an older decided result while the latest answers remain undecided", async () => { let finishOlder!: (value: ScreenerResult) => void; let finishLatest!: (value: ScreenerResult) => void; diff --git a/builder-frontend/src/utils/httpUrl.ts b/builder-frontend/src/utils/httpUrl.ts new file mode 100644 index 00000000..377ed031 --- /dev/null +++ b/builder-frontend/src/utils/httpUrl.ts @@ -0,0 +1,29 @@ +// The server checks links with java.net.URI, which is stricter than the browser's URL parser. +// Send the browser's normalized URL with anything URI rejects percent-encoded, or null if the +// link is not one the server accepts. +const encodeForUri = (part: string) => + part + .replace(/%(?![0-9A-Fa-f]{2})/g, "%25") + .replace(/[^A-Za-z0-9\-._~:/?@!$&'()*+,;=%]/g, encodeURIComponent); + +export function normalizeHttpUrl(value: string): string | null { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if ( + !["https:", "http:"].includes(url.protocol) || + !url.hostname || + url.hostname.includes("_") || + url.username || + url.password + ) + return null; + return ( + url.origin + + encodeForUri(url.pathname + url.search) + + (url.hash && "#" + encodeForUri(url.hash.slice(1))) + ); +} diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 51cf952b..1e874099 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -40,7 +40,7 @@ export default defineConfig({ // Main test project - depends on setup for authentication { name: 'chromium', - testIgnore: /crmIntegration\.spec\.ts/, + testIgnore: /(crmIntegration|nextStepsPublic)\.spec\.ts/, use: { browserName: 'chromium', }, @@ -50,7 +50,7 @@ export default defineConfig({ // Uses public screeners with intercepted APIs; no emulator/login setup. { name: 'crm-integrations', - testMatch: /crmIntegration\.spec\.ts/, + testMatch: /(crmIntegration|nextStepsPublic)\.spec\.ts/, use: { browserName: 'chromium' }, }, diff --git a/e2e/tests/nextSteps.spec.ts b/e2e/tests/nextSteps.spec.ts new file mode 100644 index 00000000..7e27efbe --- /dev/null +++ b/e2e/tests/nextSteps.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from "@playwright/test"; +import { authLogin } from "./authLogin"; +import { + resetEmulator, + seedScreenerWithForm, + TEST_SCREENER_ID, + TEST_BENEFIT_ID, +} from "./firestore"; + +test.beforeEach(resetEmulator); +test.beforeEach(authLogin); +test.afterEach(resetEmulator); + +test("analysts copy, edit, reset, and publish a screener's next steps", async ({ + page, +}) => { + await seedScreenerWithForm(); + await page.goto(`/screeners/${TEST_SCREENER_ID}`); + await page.getByTestId(`edit-benefit-${TEST_BENEFIT_ID}`).click(); + await page + .getByLabel("Application instructions", { exact: true }) + .fill("Original instructions from the benefit."); + await page + .getByLabel("Application URL", { exact: true }) + .fill("https://example.org/apply"); + await page + .getByRole("button", { name: "Save next steps", exact: true }) + .click(); + await expect( + page.getByText("Next steps saved.", { exact: true }), + ).toBeVisible(); + await page.getByTestId("editor-section-formEditor").click(); + await page + .locator('[data-field-type="next_steps"]') + .dragTo(page.locator('[data-id="BDT_Form"].fjs-drop-container-vertical')); + const editContent = page.getByRole("button", { + name: "Edit benefit content", + exact: true, + }); + await page.locator('[data-title="Next steps"]').waitFor(); + if (!(await editContent.isVisible())) + await page.locator('[data-title="Next steps"]').click(); + await editContent.click(); + const modal = page.getByRole("dialog", { + name: "Edit next steps content", + exact: true, + }); + await expect( + modal.getByLabel("Application instructions", { exact: true }), + ).toHaveValue("Original instructions from the benefit."); + await modal + .getByLabel("Application instructions", { exact: true }) + .fill("Use these screener-specific instructions."); + await modal + .getByRole("button", { name: "Save next steps", exact: true }) + .click(); + await expect(modal.getByRole("status")).toContainText( + "Component content updated", + ); + await modal + .getByRole("button", { name: "Reset from benefit", exact: true }) + .click(); + await expect( + modal.getByLabel("Application instructions", { exact: true }), + ).toHaveValue("Original instructions from the benefit."); + await modal + .getByLabel("Application instructions", { exact: true }) + .fill("Published screener instructions."); + await modal + .getByRole("button", { name: "Save next steps", exact: true }) + .click(); + await modal.getByRole("button", { name: "Close", exact: true }).click(); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes("/save-form-schema") && + response.request().method() === "POST" && + response.ok(), + ), + page.getByTestId("form-editor-save-button").click(), + ]); + await page.getByTestId("editor-section-manageBenefits").click(); + await page.getByTestId(`edit-benefit-${TEST_BENEFIT_ID}`).click(); + await expect( + page.getByLabel("Application instructions", { exact: true }), + ).toHaveValue("Original instructions from the benefit."); + await page.getByTestId("editor-section-publish").click(); + await page.getByTestId("publish-screener-button").click(); + const link = page.locator("#screener-url-info").getByRole("link"); + await expect(link).toBeVisible(); + await page.goto((await link.getAttribute("href"))!); + const evaluation = page.waitForResponse( + (response) => + response.url().includes("/api/published/") && + response.url().endsWith("/evaluate") && + response.request().method() === "POST", + ); + await page.getByLabel("Is owner occupant?", { exact: true }).check(); + expect(Object.keys(await (await evaluation).json())).toEqual([ + TEST_BENEFIT_ID, + ]); + await expect( + page.getByText("Published screener instructions.", { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Apply for this benefit", exact: true }), + ).toHaveAttribute("href", "https://example.org/apply"); +}); diff --git a/e2e/tests/nextStepsPublic.spec.ts b/e2e/tests/nextStepsPublic.spec.ts new file mode 100644 index 00000000..ae05eaef --- /dev/null +++ b/e2e/tests/nextStepsPublic.spec.ts @@ -0,0 +1,154 @@ +import { test, expect } from "@playwright/test"; + +for (const showWhen of ["whenDecided", "whenComplete"]) { + test(`published next steps show safely with ${showWhen} and suppress stale decisions`, async ({ + page, + }) => { + let fail = false; + await page.route("**/api/published/**", async (route) => { + const headers = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + }; + if (route.request().method() === "OPTIONS") + return route.fulfill({ status: 204, headers }); + if (route.request().method() === "GET") + return route.fulfill({ + headers, + json: { + screenerName: "Next steps test", + formSchema: { + id: "form", + type: "default", + schemaVersion: 18, + components: [ + { + id: "income", + type: "number", + key: "income", + label: "Household income", + }, + { + id: "done", + type: "checkbox", + key: "done", + label: "Answer remaining question", + }, + { + id: "next", + type: "next_steps", + showWhen, + benefitContent: { + housing: { + eligible: { + applyUrl: "https://example.org/apply", + helpPhone: "215-555-0100", + body: "Bring **photo ID**. [Help](https://example.org/help)", + }, + ineligible: { note: "Ask about other assistance." }, + }, + }, + }, + ], + }, + }, + }); + if (fail) return route.fulfill({ headers, status: 503, json: {} }); + const answers = route.request().postDataJSON(); + await new Promise((resolve) => setTimeout(resolve, 150)); + return route.fulfill({ + headers, + json: { + housing: { + name: "Housing assistance", + result: answers.income < 1000 ? "TRUE" : "FALSE", + check_results: { + income: { + name: "Income", + aliasName: "Income must be below $1,000", + result: answers.income < 1000 ? "TRUE" : "FALSE", + inputPaths: [], + parameters: {}, + }, + }, + }, + food: { + name: "Food assistance", + result: answers.done ? "TRUE" : "UNABLE_TO_DETERMINE", + check_results: {}, + }, + }, + }); + }); + await page.goto("/screener/next-steps-test"); + const income = page.getByLabel("Household income", { exact: true }); + await income.fill("500"); + if (showWhen === "whenDecided") { + await expect( + page.getByRole("heading", { name: "Your next steps so far" }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Apply for this benefit" }), + ).toBeVisible(); + await expect( + page.getByText("Still needs more information.", { exact: true }), + ).toBeVisible(); + } else { + await expect( + page.getByText( + "Answer the remaining questions to see your next steps.", + ), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Apply for this benefit" }), + ).toHaveCount(0); + } + await page.getByLabel("Answer remaining question", { exact: true }).check(); + await expect( + page.getByRole("heading", { + name: "Screening complete — your next steps", + exact: true, + }), + ).toBeVisible(); + const next = page.getByRole("region", { + name: "Your next steps", + exact: true, + }); + await expect(next.locator("strong")).toHaveText("photo ID"); + await expect( + next.getByRole("link", { name: "Help", exact: true }), + ).toHaveAttribute("target", "_blank"); + await expect( + page.getByText("Screening complete. All benefits", { exact: false }), + ).toHaveCount(0); + expect(await page.evaluate(() => (window as any).bad)).toBeUndefined(); + await income.fill("2000"); + await expect( + page.getByText("Updating your next steps…", { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Apply for this benefit" }), + ).toHaveCount(0); + await expect( + next.getByText("Requirements not met", { exact: true }), + ).toBeVisible(); + await expect(next.getByRole("listitem")).toHaveText( + "Income must be below $1,000", + ); + await expect(next.getByText("Ask about other assistance.")).toBeVisible(); + fail = true; + await income.fill("600"); + await expect( + page.getByText( + "We couldn't update your next steps. Change an answer to try again.", + ), + ).toBeVisible(); + await expect( + page.getByRole("heading", { + name: "Screening complete — your next steps", + exact: true, + }), + ).toHaveCount(0); + }); +} diff --git a/e2e/tests/screener.spec.ts b/e2e/tests/screener.spec.ts index 87066881..f8e2a615 100644 --- a/e2e/tests/screener.spec.ts +++ b/e2e/tests/screener.spec.ts @@ -299,4 +299,44 @@ test.describe("Screener Builder Tests", () => { ).toBeVisible(); }); }); + + test("publishing an unsaved form explains how to recover", async ({ + page, + }) => { + await seedScreenerWithConfiguredBenefit(); + await page.goto(`/screeners/${TEST_SCREENER_ID}`); + await navigateToPublish(page); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().endsWith("/api/publish") && response.status() === 400, + ), + publishScreener(page), + ]); + await expect(page.getByRole("alert")).toHaveText( + "Save the form in Form Editor before publishing.", + ); + await expect(page.locator("#screener-url-info a[href]")).toHaveCount(0); + await expect(page.locator("#screener-last-published-info")).toContainText( + "Not yet published", + ); + + await navigateToFormEditor(page); + await Promise.all([ + page.waitForResponse( + (response) => + response.url().includes("/api/save-form-schema?") && + response.request().method() === "POST" && + response.ok(), + ), + createFormWithCheckbox(page), + ]); + await navigateToPublish(page); + await publishScreener(page); + await expect(page.locator("#screener-url-info a[href]")).toHaveAttribute( + "href", + /\/screener\/.+/, + ); + await expect(page.getByRole("alert")).toHaveCount(0); + }); });