diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java index 5a2d96ee2a67..11f33b91bd24 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/NiFiServiceFacade.java @@ -237,6 +237,16 @@ public interface NiFiServiceFacade { Set getConnectorControllerServices(String connectorId, String processGroupId, boolean includeAncestorGroups, boolean includeDescendantGroups, boolean includeReferencingComponents); + /** + * Returns the parameter context bound to the specified process group within the connector's hierarchy. Sensitive parameter values are masked + * by the underlying DTO factory. + * + * @param connectorId the connector id + * @param processGroupId the process group id within the connector's hierarchy + * @return the parameter context entity with effective parameters (inherited included), or {@code null} if the process group has no bound parameter context + */ + ParameterContextEntity getConnectorParameterContext(String connectorId, String processGroupId); + void verifyCanVerifyConnectorConfigurationStep(String connectorId, String configurationStepName); List performConnectorConfigurationStepVerification(String connectorId, String configurationStepName, ConfigurationStepConfigurationDTO configurationStepConfiguration); diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java index 9de055e4aa7c..c13966b16abb 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/StandardNiFiServiceFacade.java @@ -3950,6 +3950,27 @@ public Set getConnectorControllerServices(final String .collect(Collectors.toSet()); } + @Override + public ParameterContextEntity getConnectorParameterContext(final String connectorId, final String processGroupId) { + final ConnectorNode connectorNode = connectorDAO.getConnector(connectorId, ConnectorSyncMode.LOCAL_ONLY); + final ProcessGroup managedProcessGroup = connectorNode.getActiveFlowContext().getManagedProcessGroup(); + final ProcessGroup targetProcessGroup = managedProcessGroup.findProcessGroup(processGroupId); + if (targetProcessGroup == null) { + throw new ResourceNotFoundException("Process Group with ID " + processGroupId + " was not found within Connector " + connectorId); + } + + final ParameterContext parameterContext = targetProcessGroup.getParameterContext(); + if (parameterContext == null) { + return null; + } + + // Connector-managed parameter contexts (and any contexts they inherit from) are not registered with the + // global flow's ParameterContextManager, so a DAO-backed lookup would fail to resolve inherited parameters. + // The DTO factory walks the in-memory inheritance graph reachable from the supplied context to resolve + // parameter source contexts for connector-managed flows, making an empty lookup safe here. + return createParameterContextEntity(parameterContext, true, NiFiUserUtils.getNiFiUser(), ParameterContextLookup.EMPTY); + } + @Override public void verifyCanVerifyConnectorConfigurationStep(final String connectorId, final String configurationStepName) { connectorDAO.verifyCanVerifyConfigurationStep(connectorId, configurationStepName); diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java index 9b51c56b9aa4..087a45fdfcf4 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/ConnectorResource.java @@ -93,6 +93,7 @@ import org.apache.nifi.web.api.entity.ControllerServiceEntity; import org.apache.nifi.web.api.entity.ControllerServicesEntity; import org.apache.nifi.web.api.entity.DropRequestEntity; +import org.apache.nifi.web.api.entity.ParameterContextEntity; import org.apache.nifi.web.api.entity.ProcessGroupFlowEntity; import org.apache.nifi.web.api.entity.ProcessGroupStatusEntity; import org.apache.nifi.web.api.entity.SearchResultsEntity; @@ -1874,6 +1875,59 @@ public Response getControllerServicesFromConnectorProcessGroup( return generateOkResponse(entity).build(); } + /** + * Retrieves the parameter context bound to the specified process group within a connector. + * + * @param connectorId The id of the connector + * @param processGroupId The process group id within the connector's hierarchy + * @return A parameterContextEntity, or 204 No Content if the process group has no bound parameter context + */ + @GET + @Consumes(MediaType.WILDCARD) + @Produces(MediaType.APPLICATION_JSON) + @Path("/{connectorId}/flow/process-groups/{processGroupId}/parameter-context") + @Operation( + summary = "Gets the parameter context bound to a process group within a connector", + responses = { + @ApiResponse(responseCode = "200", content = @Content(schema = @Schema(implementation = ParameterContextEntity.class))), + @ApiResponse(responseCode = "204", description = "The specified process group has no bound parameter context."), + @ApiResponse(responseCode = "400", description = "NiFi was unable to complete the request because it was invalid. The request should not be retried without modification."), + @ApiResponse(responseCode = "401", description = "Client could not be authenticated."), + @ApiResponse(responseCode = "403", description = "Client is not authorized to make this request."), + @ApiResponse(responseCode = "404", description = "The specified resource could not be found."), + @ApiResponse(responseCode = "409", description = "The request was valid but NiFi was not in the appropriate state to process it.") + }, + security = { + @SecurityRequirement(name = "Read - /connectors/{uuid}") + }, + description = "Returns the parameter context (with effective parameters, including those inherited from other contexts) bound to the " + + "specified process group within the connector's hierarchy. Sensitive parameter values are masked. Returns 204 No Content if the " + + "process group has no bound parameter context." + ) + public Response getParameterContextForConnectorProcessGroup( + @Parameter(description = "The connector id.", required = true) + @PathParam("connectorId") final String connectorId, + @Parameter(description = "The process group id.", required = true) + @PathParam("processGroupId") final String processGroupId) { + + if (isReplicateRequest()) { + return replicate(HttpMethod.GET); + } + + // authorize access to the connector + serviceFacade.authorizeAccess(lookup -> { + final Authorizable connector = lookup.getConnector(connectorId); + connector.authorize(authorizer, RequestAction.READ, NiFiUserUtils.getNiFiUser()); + }); + + final ParameterContextEntity entity = serviceFacade.getConnectorParameterContext(connectorId, processGroupId); + if (entity == null) { + return Response.noContent().build(); + } + + return generateOkResponse(entity).build(); + } + /** * Retrieves the status for the process group managed by the specified connector. * diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java index 0e09c68ea04f..3ba5b326601b 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/main/java/org/apache/nifi/web/api/dto/DtoFactory.java @@ -1620,8 +1620,7 @@ public ParameterDTO createParameterDto(final ParameterContext parameterContext, final Set referencingComponentEntities = createAffectedComponentEntities(referencingComponents, revisionManager); dto.setReferencingComponents(referencingComponentEntities); - final ParameterContext containingParameterContext = (parameter.getParameterContextId() == null) - ? parameterContext : parameterContextLookup.getParameterContext(parameter.getParameterContextId()); + final ParameterContext containingParameterContext = resolveContainingParameterContext(parameterContext, parameter, parameterContextLookup); dto.setInherited(!containingParameterContext.getIdentifier().equals(parameterContext.getIdentifier())); @@ -1631,6 +1630,55 @@ public ParameterDTO createParameterDto(final ParameterContext parameterContext, return dto; } + /** + * Resolves the {@link ParameterContext} where the given parameter was originally defined. + * + *

For parameters declared directly on the current context (or whose source id matches the current + * context's identifier), the current context is returned without consulting any external lookup. For + * inherited parameters, the source context is found by walking the in-memory inheritance graph reachable + * from the current context via {@link ParameterContext#getInheritedParameterContexts()}. If the source + * context is not reachable on that graph (expected only for legacy callers that pass a registry-backed + * lookup), the provided {@link ParameterContextLookup} is consulted as a fallback. If neither resolves + * the source id (e.g. an empty lookup combined with an unreachable id from a transient data + * inconsistency), the current context is returned so the parameter is reported as locally defined + * rather than producing a {@link NullPointerException} at the call site.

+ */ + private ParameterContext resolveContainingParameterContext(final ParameterContext parameterContext, final Parameter parameter, + final ParameterContextLookup parameterContextLookup) { + final String sourceId = parameter.getParameterContextId(); + if (sourceId == null || sourceId.equals(parameterContext.getIdentifier())) { + return parameterContext; + } + + final ParameterContext fromGraph = findInheritedParameterContext(parameterContext, sourceId, new HashSet<>()); + if (fromGraph != null) { + return fromGraph; + } + + final ParameterContext fromLookup = parameterContextLookup.getParameterContext(sourceId); + return fromLookup != null ? fromLookup : parameterContext; + } + + private ParameterContext findInheritedParameterContext(final ParameterContext parameterContext, final String sourceId, final Set visited) { + if (parameterContext == null || !visited.add(parameterContext.getIdentifier())) { + return null; + } + if (sourceId.equals(parameterContext.getIdentifier())) { + return parameterContext; + } + final List inherited = parameterContext.getInheritedParameterContexts(); + if (inherited == null) { + return null; + } + for (final ParameterContext inheritedContext : inherited) { + final ParameterContext match = findInheritedParameterContext(inheritedContext, sourceId, visited); + if (match != null) { + return match; + } + } + return null; + } + public ReportingTaskDTO createReportingTaskDto(final ReportingTaskNode reportingTaskNode) { final BundleCoordinate bundleCoordinate = reportingTaskNode.getBundleCoordinate(); final List compatibleBundles = extensionManager.getBundles(reportingTaskNode.getCanonicalClassName()).stream().filter(bundle -> { diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java index 640894bcf244..c1cba635aecf 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/StandardNiFiServiceFacadeTest.java @@ -71,6 +71,8 @@ import org.apache.nifi.history.History; import org.apache.nifi.history.HistoryQuery; import org.apache.nifi.nar.ExtensionManager; +import org.apache.nifi.parameter.ParameterContext; +import org.apache.nifi.parameter.ParameterContextLookup; import org.apache.nifi.processor.Processor; import org.apache.nifi.registry.flow.FlowRegistryUtil; import org.apache.nifi.registry.flow.RegisteredFlowSnapshot; @@ -104,6 +106,7 @@ import org.apache.nifi.web.api.dto.CountersSnapshotDTO; import org.apache.nifi.web.api.dto.DtoFactory; import org.apache.nifi.web.api.dto.EntityFactory; +import org.apache.nifi.web.api.dto.ParameterContextDTO; import org.apache.nifi.web.api.dto.ProcessGroupDTO; import org.apache.nifi.web.api.dto.RemoteProcessGroupDTO; import org.apache.nifi.web.api.dto.RevisionDTO; @@ -118,6 +121,7 @@ import org.apache.nifi.web.api.entity.ConnectorEntity; import org.apache.nifi.web.api.entity.CopyRequestEntity; import org.apache.nifi.web.api.entity.CopyResponseEntity; +import org.apache.nifi.web.api.entity.ParameterContextEntity; import org.apache.nifi.web.api.entity.ProcessGroupEntity; import org.apache.nifi.web.api.entity.SecretsEntity; import org.apache.nifi.web.api.entity.StatusHistoryEntity; @@ -2246,4 +2250,100 @@ public void testGetConnectorClusterNodeRequest() { assertNotNull(entity.getComponent(), "Component should be populated when clusterNodeRequest is true"); assertEquals("RUNNING", entity.getComponent().getState()); } + + @Test + public void testGetConnectorParameterContextReturnsEntityWhenContextBound() { + final String connectorId = "connector-id"; + final String processGroupId = "process-group-id"; + final String parameterContextId = "parameter-context-id"; + + final ConnectorDAO connectorDAO = mock(ConnectorDAO.class); + final DtoFactory dtoFactory = mock(DtoFactory.class); + final RevisionManager revisionManager = mock(RevisionManager.class); + final EntityFactory entityFactory = new EntityFactory(); + serviceFacade.setConnectorDAO(connectorDAO); + serviceFacade.setDtoFactory(dtoFactory); + serviceFacade.setRevisionManager(revisionManager); + serviceFacade.setEntityFactory(entityFactory); + + final ConnectorNode connectorNode = mock(ConnectorNode.class); + final FrameworkFlowContext flowContext = mock(FrameworkFlowContext.class); + final ProcessGroup managedProcessGroup = mock(ProcessGroup.class); + final ProcessGroup targetProcessGroup = mock(ProcessGroup.class); + final ParameterContext parameterContext = mock(ParameterContext.class); + + when(connectorDAO.getConnector(connectorId, ConnectorSyncMode.LOCAL_ONLY)).thenReturn(connectorNode); + when(connectorNode.getActiveFlowContext()).thenReturn(flowContext); + when(flowContext.getManagedProcessGroup()).thenReturn(managedProcessGroup); + when(managedProcessGroup.findProcessGroup(processGroupId)).thenReturn(targetProcessGroup); + when(targetProcessGroup.getParameterContext()).thenReturn(parameterContext); + when(parameterContext.getIdentifier()).thenReturn(parameterContextId); + + final ParameterContextDTO parameterContextDto = new ParameterContextDTO(); + parameterContextDto.setId(parameterContextId); + parameterContextDto.setName("context-name"); + when(dtoFactory.createParameterContextDto(eq(parameterContext), eq(revisionManager), eq(true), any(ParameterContextLookup.class))) + .thenReturn(parameterContextDto); + when(dtoFactory.createPermissionsDto(eq(parameterContext), any())).thenReturn(null); + when(dtoFactory.createRevisionDTO(any(Revision.class))).thenReturn(new RevisionDTO()); + when(revisionManager.getRevision(parameterContextId)).thenReturn(new Revision(1L, null, parameterContextId)); + + final ParameterContextEntity entity = serviceFacade.getConnectorParameterContext(connectorId, processGroupId); + + assertNotNull(entity); + assertEquals(parameterContextId, entity.getId()); + + final ArgumentCaptor lookupCaptor = ArgumentCaptor.forClass(ParameterContextLookup.class); + verify(dtoFactory).createParameterContextDto(eq(parameterContext), eq(revisionManager), eq(true), lookupCaptor.capture()); + assertNotNull(lookupCaptor.getValue()); + assertNull(lookupCaptor.getValue().getParameterContext("any-id")); + assertFalse(lookupCaptor.getValue().hasParameterContext("any-id")); + } + + @Test + public void testGetConnectorParameterContextReturnsNullWhenNoBoundContext() { + final String connectorId = "connector-id"; + final String processGroupId = "process-group-id"; + + final ConnectorDAO connectorDAO = mock(ConnectorDAO.class); + final DtoFactory dtoFactory = mock(DtoFactory.class); + serviceFacade.setConnectorDAO(connectorDAO); + serviceFacade.setDtoFactory(dtoFactory); + + final ConnectorNode connectorNode = mock(ConnectorNode.class); + final FrameworkFlowContext flowContext = mock(FrameworkFlowContext.class); + final ProcessGroup managedProcessGroup = mock(ProcessGroup.class); + final ProcessGroup targetProcessGroup = mock(ProcessGroup.class); + + when(connectorDAO.getConnector(connectorId, ConnectorSyncMode.LOCAL_ONLY)).thenReturn(connectorNode); + when(connectorNode.getActiveFlowContext()).thenReturn(flowContext); + when(flowContext.getManagedProcessGroup()).thenReturn(managedProcessGroup); + when(managedProcessGroup.findProcessGroup(processGroupId)).thenReturn(targetProcessGroup); + when(targetProcessGroup.getParameterContext()).thenReturn(null); + + final ParameterContextEntity entity = serviceFacade.getConnectorParameterContext(connectorId, processGroupId); + + assertNull(entity); + Mockito.verifyNoInteractions(dtoFactory); + } + + @Test + public void testGetConnectorParameterContextThrowsWhenProcessGroupNotFound() { + final String connectorId = "connector-id"; + final String processGroupId = "missing-process-group"; + + final ConnectorDAO connectorDAO = mock(ConnectorDAO.class); + serviceFacade.setConnectorDAO(connectorDAO); + + final ConnectorNode connectorNode = mock(ConnectorNode.class); + final FrameworkFlowContext flowContext = mock(FrameworkFlowContext.class); + final ProcessGroup managedProcessGroup = mock(ProcessGroup.class); + + when(connectorDAO.getConnector(connectorId, ConnectorSyncMode.LOCAL_ONLY)).thenReturn(connectorNode); + when(connectorNode.getActiveFlowContext()).thenReturn(flowContext); + when(flowContext.getManagedProcessGroup()).thenReturn(managedProcessGroup); + when(managedProcessGroup.findProcessGroup(processGroupId)).thenReturn(null); + + assertThrows(ResourceNotFoundException.class, () -> serviceFacade.getConnectorParameterContext(connectorId, processGroupId)); + } } diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java index aac8836719bf..2fcac1d5dcdc 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/TestConnectorResource.java @@ -32,6 +32,8 @@ import org.apache.nifi.web.api.dto.AllowableValueDTO; import org.apache.nifi.web.api.dto.ComponentStateDTO; import org.apache.nifi.web.api.dto.ConnectorDTO; +import org.apache.nifi.web.api.dto.ParameterContextDTO; +import org.apache.nifi.web.api.dto.ParameterDTO; import org.apache.nifi.web.api.dto.RevisionDTO; import org.apache.nifi.web.api.dto.flow.ProcessGroupFlowDTO; import org.apache.nifi.web.api.entity.AllowableValueEntity; @@ -41,6 +43,8 @@ import org.apache.nifi.web.api.entity.ConnectorRunStatusEntity; import org.apache.nifi.web.api.entity.ControllerServiceEntity; import org.apache.nifi.web.api.entity.ControllerServicesEntity; +import org.apache.nifi.web.api.entity.ParameterContextEntity; +import org.apache.nifi.web.api.entity.ParameterEntity; import org.apache.nifi.web.api.entity.ProcessGroupFlowEntity; import org.apache.nifi.web.api.entity.SecretsEntity; import org.apache.nifi.web.api.request.ClientIdParameter; @@ -492,6 +496,43 @@ public void testGetControllerServicesFromConnectorProcessGroupNotAuthorized() { verify(serviceFacade, never()).getConnectorControllerServices(anyString(), anyString(), eq(true), eq(false), eq(true)); } + @Test + public void testGetParameterContextForConnectorProcessGroup() { + final ParameterContextEntity responseEntity = createParameterContextEntity(); + when(serviceFacade.getConnectorParameterContext(CONNECTOR_ID, PROCESS_GROUP_ID)).thenReturn(responseEntity); + + try (Response response = connectorResource.getParameterContextForConnectorProcessGroup(CONNECTOR_ID, PROCESS_GROUP_ID)) { + assertEquals(200, response.getStatus()); + assertEquals(responseEntity, response.getEntity()); + } + + verify(serviceFacade).authorizeAccess(any(AuthorizeAccess.class)); + verify(serviceFacade).getConnectorParameterContext(CONNECTOR_ID, PROCESS_GROUP_ID); + } + + @Test + public void testGetParameterContextForConnectorProcessGroupReturnsNoContentWhenUnbound() { + when(serviceFacade.getConnectorParameterContext(CONNECTOR_ID, PROCESS_GROUP_ID)).thenReturn(null); + + try (Response response = connectorResource.getParameterContextForConnectorProcessGroup(CONNECTOR_ID, PROCESS_GROUP_ID)) { + assertEquals(204, response.getStatus()); + } + + verify(serviceFacade).authorizeAccess(any(AuthorizeAccess.class)); + verify(serviceFacade).getConnectorParameterContext(CONNECTOR_ID, PROCESS_GROUP_ID); + } + + @Test + public void testGetParameterContextForConnectorProcessGroupNotAuthorized() { + doThrow(AccessDeniedException.class).when(serviceFacade).authorizeAccess(any(AuthorizeAccess.class)); + + assertThrows(AccessDeniedException.class, () -> + connectorResource.getParameterContextForConnectorProcessGroup(CONNECTOR_ID, PROCESS_GROUP_ID)); + + verify(serviceFacade).authorizeAccess(any(AuthorizeAccess.class)); + verify(serviceFacade, never()).getConnectorParameterContext(anyString(), anyString()); + } + @Test public void testInitiateDrain() { final ConnectorEntity requestEntity = createConnectorEntity(); @@ -619,6 +660,28 @@ private ProcessGroupFlowEntity createProcessGroupFlowEntity() { return entity; } + private ParameterContextEntity createParameterContextEntity() { + final ParameterContextEntity entity = new ParameterContextEntity(); + entity.setId("test-parameter-context-id"); + + final ParameterContextDTO dto = new ParameterContextDTO(); + dto.setId("test-parameter-context-id"); + dto.setName("Test Parameter Context"); + + final ParameterDTO parameterDto = new ParameterDTO(); + parameterDto.setName("test-parameter"); + parameterDto.setValue("test-value"); + parameterDto.setSensitive(false); + + final ParameterEntity parameterEntity = new ParameterEntity(); + parameterEntity.setParameter(parameterDto); + parameterEntity.setCanWrite(false); + + dto.setParameters(Set.of(parameterEntity)); + entity.setComponent(dto); + return entity; + } + @Test public void testCreateConnectorWithValidClientSpecifiedUuid() { final String uppercaseUuid = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"; diff --git a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java index 96eac7bdf2fd..4adb7afb8ac3 100644 --- a/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java +++ b/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-api/src/test/java/org/apache/nifi/web/api/dto/DtoFactoryTest.java @@ -42,11 +42,17 @@ import org.apache.nifi.nar.NarState; import org.apache.nifi.nar.StandardExtensionDiscoveringManager; import org.apache.nifi.nar.SystemBundle; +import org.apache.nifi.parameter.Parameter; +import org.apache.nifi.parameter.ParameterContext; +import org.apache.nifi.parameter.ParameterContextLookup; +import org.apache.nifi.parameter.ParameterReferenceManager; import org.apache.nifi.processor.Relationship; import org.apache.nifi.registry.flow.FlowRegistryClientNode; import org.apache.nifi.registry.flow.diff.DifferenceType; import org.apache.nifi.registry.flow.diff.FlowDifference; import org.apache.nifi.web.api.entity.AllowableValueEntity; +import org.apache.nifi.web.api.entity.ParameterContextReferenceEntity; +import org.apache.nifi.web.revision.RevisionManager; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -68,8 +74,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; public class DtoFactoryTest { @@ -752,4 +761,244 @@ void testCreateAssetReferenceDtoWhenContentFileMissing() { assertNotNull(dto.getMissingContent()); assertTrue(dto.getMissingContent()); } + + @Test + void testCreateParameterDtoResolvesSourceContextWhenParameterContextIdIsNull() { + final String contextId = "context-1"; + final ParameterContext parameterContext = createMockParameterContext(contextId, "context-1-name", Collections.emptyList()); + + final Parameter parameter = new Parameter.Builder() + .name("param-name") + .value("param-value") + .build(); + + final ParameterContextLookup lookup = mock(ParameterContextLookup.class); + + final DtoFactory dtoFactory = newDtoFactoryForParameters(); + final ParameterDTO dto = dtoFactory.createParameterDto(parameterContext, parameter, mock(RevisionManager.class), lookup); + + assertEquals("param-name", dto.getName()); + assertEquals("param-value", dto.getValue()); + assertFalse(dto.getInherited()); + + final ParameterContextReferenceEntity reference = dto.getParameterContext(); + assertNotNull(reference); + assertEquals(contextId, reference.getId()); + + verify(lookup, never()).getParameterContext(anyString()); + } + + @Test + void testCreateParameterDtoResolvesSourceContextWhenParameterContextIdMatchesCurrent() { + final String contextId = "context-1"; + final ParameterContext parameterContext = createMockParameterContext(contextId, "context-1-name", Collections.emptyList()); + + final Parameter parameter = new Parameter.Builder() + .name("param-name") + .value("param-value") + .parameterContextId(contextId) + .build(); + + final ParameterContextLookup lookup = mock(ParameterContextLookup.class); + + final DtoFactory dtoFactory = newDtoFactoryForParameters(); + final ParameterDTO dto = dtoFactory.createParameterDto(parameterContext, parameter, mock(RevisionManager.class), lookup); + + assertFalse(dto.getInherited()); + assertEquals(contextId, dto.getParameterContext().getId()); + + verify(lookup, never()).getParameterContext(anyString()); + } + + @Test + void testCreateParameterDtoResolvesSourceContextFromInheritedGraph() { + final String childId = "context-child"; + final String parentId = "context-parent"; + + final ParameterContext parentContext = createMockParameterContext(parentId, "parent", Collections.emptyList()); + final ParameterContext childContext = createMockParameterContext(childId, "child", List.of(parentContext)); + + final Parameter parameter = new Parameter.Builder() + .name("param-name") + .value("param-value") + .parameterContextId(parentId) + .build(); + + final ParameterContextLookup lookup = mock(ParameterContextLookup.class); + + final DtoFactory dtoFactory = newDtoFactoryForParameters(); + final ParameterDTO dto = dtoFactory.createParameterDto(childContext, parameter, mock(RevisionManager.class), lookup); + + assertTrue(dto.getInherited()); + assertEquals(parentId, dto.getParameterContext().getId()); + + verify(lookup, never()).getParameterContext(anyString()); + } + + @Test + void testCreateParameterDtoResolvesSourceContextFromTransitiveInheritedGraph() { + final String childId = "context-child"; + final String parentId = "context-parent"; + final String grandparentId = "context-grandparent"; + + final ParameterContext grandparentContext = createMockParameterContext(grandparentId, "grandparent", Collections.emptyList()); + final ParameterContext parentContext = createMockParameterContext(parentId, "parent", List.of(grandparentContext)); + final ParameterContext childContext = createMockParameterContext(childId, "child", List.of(parentContext)); + + final Parameter parameter = new Parameter.Builder() + .name("param-name") + .value("param-value") + .parameterContextId(grandparentId) + .build(); + + final ParameterContextLookup lookup = mock(ParameterContextLookup.class); + + final DtoFactory dtoFactory = newDtoFactoryForParameters(); + final ParameterDTO dto = dtoFactory.createParameterDto(childContext, parameter, mock(RevisionManager.class), lookup); + + assertTrue(dto.getInherited()); + assertEquals(grandparentId, dto.getParameterContext().getId()); + + verify(lookup, never()).getParameterContext(anyString()); + } + + @Test + void testCreateParameterDtoFallsBackToLookupWhenSourceNotReachableInGraph() { + final String contextId = "context-1"; + final String externalId = "context-external"; + + final ParameterContext parameterContext = createMockParameterContext(contextId, "context-1-name", Collections.emptyList()); + final ParameterContext externalContext = createMockParameterContext(externalId, "context-external-name", Collections.emptyList()); + + final Parameter parameter = new Parameter.Builder() + .name("param-name") + .value("param-value") + .parameterContextId(externalId) + .build(); + + final ParameterContextLookup lookup = mock(ParameterContextLookup.class); + when(lookup.getParameterContext(externalId)).thenReturn(externalContext); + + final DtoFactory dtoFactory = newDtoFactoryForParameters(); + final ParameterDTO dto = dtoFactory.createParameterDto(parameterContext, parameter, mock(RevisionManager.class), lookup); + + assertTrue(dto.getInherited()); + assertEquals(externalId, dto.getParameterContext().getId()); + + verify(lookup).getParameterContext(externalId); + } + + @Test + void testCreateParameterDtoFallsBackToCurrentContextWhenSourceNotReachableInGraphAndLookupIsEmpty() { + final String contextId = "context-1"; + final String externalId = "context-external"; + + final ParameterContext parameterContext = createMockParameterContext(contextId, "context-1-name", Collections.emptyList()); + + final Parameter parameter = new Parameter.Builder() + .name("param-name") + .value("param-value") + .parameterContextId(externalId) + .build(); + + final DtoFactory dtoFactory = newDtoFactoryForParameters(); + final ParameterDTO dto = dtoFactory.createParameterDto(parameterContext, parameter, mock(RevisionManager.class), ParameterContextLookup.EMPTY); + + assertFalse(dto.getInherited()); + assertEquals(contextId, dto.getParameterContext().getId()); + } + + @Test + void testCreateParameterDtoResolvesSourceContextFromDiamondInheritanceGraph() { + final String contextAId = "context-a"; + final String contextBId = "context-b"; + final String contextCId = "context-c"; + final String contextDId = "context-d"; + + final ParameterContext contextD = createMockParameterContext(contextDId, "context-d-name", Collections.emptyList()); + final ParameterContext contextB = createMockParameterContext(contextBId, "context-b-name", List.of(contextD)); + final ParameterContext contextC = createMockParameterContext(contextCId, "context-c-name", List.of(contextD)); + final ParameterContext contextA = createMockParameterContext(contextAId, "context-a-name", List.of(contextB, contextC)); + + final Parameter parameter = new Parameter.Builder() + .name("param-name") + .value("param-value") + .parameterContextId(contextDId) + .build(); + + final DtoFactory dtoFactory = newDtoFactoryForParameters(); + final ParameterDTO dto = dtoFactory.createParameterDto(contextA, parameter, mock(RevisionManager.class), ParameterContextLookup.EMPTY); + + assertTrue(dto.getInherited()); + assertEquals(contextDId, dto.getParameterContext().getId()); + } + + @Test + void testCreateParameterDtoInheritanceGraphHandlesCycles() { + final String childId = "context-child"; + final String parentId = "context-parent"; + final String missingId = "context-missing"; + + final ParameterContext parentContext = mock(ParameterContext.class); + final ParameterContext childContext = mock(ParameterContext.class); + configureBaseParameterContext(childContext, childId, "child"); + configureBaseParameterContext(parentContext, parentId, "parent"); + when(childContext.getInheritedParameterContexts()).thenReturn(List.of(parentContext)); + when(parentContext.getInheritedParameterContexts()).thenReturn(List.of(childContext)); + + final Parameter parameter = new Parameter.Builder() + .name("param-name") + .value("param-value") + .parameterContextId(missingId) + .build(); + + final ParameterContext fallbackContext = createMockParameterContext(missingId, "missing", Collections.emptyList()); + final ParameterContextLookup lookup = mock(ParameterContextLookup.class); + when(lookup.getParameterContext(missingId)).thenReturn(fallbackContext); + + final DtoFactory dtoFactory = newDtoFactoryForParameters(); + final ParameterDTO dto = dtoFactory.createParameterDto(childContext, parameter, mock(RevisionManager.class), lookup); + + assertTrue(dto.getInherited()); + assertEquals(missingId, dto.getParameterContext().getId()); + + verify(lookup).getParameterContext(missingId); + } + + @Test + void testCreateParameterDtoSensitiveValueIsMasked() { + final String contextId = "context-1"; + final ParameterContext parameterContext = createMockParameterContext(contextId, "context-1-name", Collections.emptyList()); + + final Parameter parameter = new Parameter.Builder() + .name("sensitive-param") + .value("plaintext-secret") + .sensitive(true) + .build(); + + final DtoFactory dtoFactory = newDtoFactoryForParameters(); + final ParameterDTO dto = dtoFactory.createParameterDto(parameterContext, parameter, mock(RevisionManager.class), mock(ParameterContextLookup.class)); + + assertTrue(dto.getSensitive()); + assertEquals(DtoFactory.SENSITIVE_VALUE_MASK, dto.getValue()); + } + + private static DtoFactory newDtoFactoryForParameters() { + final DtoFactory dtoFactory = new DtoFactory(); + dtoFactory.setEntityFactory(new EntityFactory()); + return dtoFactory; + } + + private static ParameterContext createMockParameterContext(final String id, final String name, final List inherited) { + final ParameterContext context = mock(ParameterContext.class); + configureBaseParameterContext(context, id, name); + when(context.getInheritedParameterContexts()).thenReturn(inherited); + return context; + } + + private static void configureBaseParameterContext(final ParameterContext context, final String id, final String name) { + when(context.getIdentifier()).thenReturn(id); + when(context.getName()).thenReturn(name); + when(context.getParameterReferenceManager()).thenReturn(ParameterReferenceManager.EMPTY); + } } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts index f12852f76ed2..991c2e13bf7e 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts @@ -17,12 +17,13 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { HttpClient } from '@angular/common/http'; import { Client } from '../../../service/client.service'; import { ClusterConnectionService } from '../../../service/cluster-connection.service'; import { ConnectorsResponse, CreateConnectorRequest } from '../state'; import { ConnectorEntity } from '@nifi/shared'; -import { SearchResultsEntity } from '../../../state/shared'; +import { ParameterContextEntity, SearchResultsEntity } from '../../../state/shared'; import { DropRequestEntity } from '../../../state/empty-queue'; @Injectable({ providedIn: 'root' }) @@ -123,6 +124,18 @@ export class ConnectorService { ); } + getConnectorParameterContext( + connectorId: string, + processGroupId: string + ): Observable { + return this.httpClient + .get( + `${ConnectorService.API}/connectors/${connectorId}/flow/process-groups/${processGroupId}/parameter-context`, + { observe: 'response' } + ) + .pipe(map((response) => (response.status === 204 ? null : response.body))); + } + searchConnector(connectorId: string, query: string): Observable { return this.httpClient.get( `${ConnectorService.API}/connectors/${connectorId}/search-results`, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/bind-connector-parameter-context.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/bind-connector-parameter-context.ts new file mode 100644 index 000000000000..b81c356e4f7e --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/bind-connector-parameter-context.ts @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { ParameterContextEntity } from '../../../../state/shared'; +import { selectConnectorParameterContext } from './connector-canvas.selectors'; + +/** + * Subscribe to the connector-scoped parameter context for the current process group + * and forward updates to a dialog while it is open. The dialog applies the value to + * the property table so parameter references can resolve inline. The + * `supportsParameters` flag is true when a parameter context is bound; callers that + * need the flag (e.g. EditControllerService) can plumb it onto their instance. + * + * Shared between the connector canvas and controller-services effects so the + * read-only dialog binding stays consistent across both pages. + */ +export function bindConnectorParameterContext( + store: Store, + teardown$: Observable, + apply: (parameterContext: ParameterContextEntity | null, supportsParameters: boolean) => void +): void { + store + .select(selectConnectorParameterContext) + .pipe(takeUntil(teardown$)) + .subscribe((parameterContext) => apply(parameterContext, parameterContext != null)); +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts index 23ba9fb5f2e9..680b2efc1fc1 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts @@ -17,8 +17,8 @@ import { createAction, props } from '@ngrx/store'; import { ComponentType } from '@nifi/shared'; -import { BreadcrumbEntity } from '../../../../state/shared'; -import { ErrorContext } from '../../../../state/error'; +import { BreadcrumbEntity, ParameterContextEntity } from '../../../../state/shared'; +import { ErrorContext, ErrorContextKey } from '../../../../state/error'; /** * Selected component for route-based selection @@ -93,6 +93,29 @@ export const startConnectorCanvasPolling = createAction('[Connector Canvas] Star */ export const stopConnectorCanvasPolling = createAction('[Connector Canvas] Stop Connector Canvas Polling'); +/** + * Load the parameter context bound to the current process group within the connector. + * + * `errorContext` identifies which page-scoped error banner should surface a failure. + * The triggering effect (loadConnectorParameterContextOnLoadSuccess$) sets this based + * on whether the load was kicked off by the canvas or the controller-services page, + * so a failure renders on the banner the user is actually looking at. + */ +export const loadConnectorParameterContext = createAction( + '[Connector Canvas] Load Connector Parameter Context', + props<{ connectorId: string; processGroupId: string; errorContext: ErrorContextKey }>() +); + +export const loadConnectorParameterContextSuccess = createAction( + '[Connector Canvas] Load Connector Parameter Context Success', + props<{ parameterContext: ParameterContextEntity | null }>() +); + +export const loadConnectorParameterContextFailure = createAction( + '[Connector Canvas] Load Connector Parameter Context Failure', + props<{ errorContext: ErrorContext }>() +); + export const enterProcessGroup = createAction( '[Connector Canvas] Enter Process Group', props<{ request: { id: string } }>() diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts index 797cccb38c42..67c59d342f09 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts @@ -20,9 +20,12 @@ import { provideMockActions } from '@ngrx/effects/testing'; import { Action } from '@ngrx/store'; import { Router } from '@angular/router'; import { firstValueFrom, Observable, of, Subject, throwError } from 'rxjs'; +import { toArray } from 'rxjs/operators'; import { HttpErrorResponse } from '@angular/common/http'; import { ComponentType, ComponentTypeNamePipe } from '@nifi/shared'; import { MatDialog } from '@angular/material/dialog'; +import { ParameterContextEntity } from '../../../../state/shared'; +import { createParameterContextFixture } from '../../testing/parameter-context-fixture'; import { ConnectorCanvasEffects } from './connector-canvas.effects'; import { ConnectorService } from '../../service/connector.service'; import { ErrorHelper } from '../../../../service/error-helper.service'; @@ -36,21 +39,28 @@ import { loadConnectorFlowComplete, loadConnectorFlowFailure, loadConnectorFlowSuccess, + loadConnectorParameterContext, + loadConnectorParameterContextFailure, + loadConnectorParameterContextSuccess, navigateToControllerService, navigateToControllerServices, navigateToProvenanceForComponent, navigateToQueueListing, navigateWithoutTransform, reloadConnectorFlow, + resetConnectorCanvasState, selectComponents, startConnectorCanvasPolling, stopConnectorCanvasPolling, viewComponentConfiguration } from './connector-canvas.actions'; +import { loadConnectorControllerServicesSuccess } from '../connector-controller-services/connector-controller-services.actions'; +import * as ErrorActions from '../../../../state/error/error.actions'; import { queueEmptied } from '../../../../state/empty-queue/empty-queue.actions'; import { selectConnectorId, selectConnectorIdFromRoute, + selectConnectorParameterContext, selectLoadingStatus, selectParentProcessGroupId, selectProcessGroupId, @@ -70,6 +80,9 @@ describe('ConnectorCanvasEffects', () => { processGroupIdFromRoute?: string | null; documentVisibility?: DocumentVisibility; loadingStatus?: 'pending' | 'loading' | 'success' | 'error'; + parameterContext?: ParameterContextEntity | null; + parameterContextResponse?: ParameterContextEntity | null; + parameterContextError?: unknown; } = {} ) { let actions$: Observable; @@ -85,9 +98,20 @@ describe('ConnectorCanvasEffects', () => { // Mock services const mockConnectorService = { - getConnectorFlow: vi.fn() + getConnectorFlow: vi.fn(), + getConnectorParameterContext: vi.fn() }; + if (options.parameterContextError !== undefined) { + mockConnectorService.getConnectorParameterContext.mockReturnValue( + throwError(() => options.parameterContextError) + ); + } else { + mockConnectorService.getConnectorParameterContext.mockReturnValue( + of(options.parameterContextResponse ?? null) + ); + } + const mockErrorHelper = { getErrorString: vi.fn().mockReturnValue('Error message'), handleLoadingError: vi.fn() @@ -97,8 +121,18 @@ describe('ConnectorCanvasEffects', () => { navigate: vi.fn().mockResolvedValue(true) }; + const afterClosed$ = new Subject(); + const mockDialogRef = { + componentInstance: {} as { + parameterContext?: ParameterContextEntity | null; + supportsParameters?: boolean; + goToParameter?: (parameter: string) => void; + [key: string]: any; + }, + afterClosed: () => afterClosed$.asObservable() + }; const mockDialog = { - open: vi.fn().mockReturnValue({ componentInstance: {} }) + open: vi.fn().mockReturnValue(mockDialogRef) }; await TestBed.configureTestingModule({ @@ -117,6 +151,10 @@ describe('ConnectorCanvasEffects', () => { { selector: selectDocumentVisibilityState, value: { documentVisibility, changedTimestamp: 0 } + }, + { + selector: selectConnectorParameterContext, + value: options.parameterContext ?? null } ] }), @@ -140,7 +178,9 @@ describe('ConnectorCanvasEffects', () => { mockConnectorService, mockErrorHelper, mockRouter, - mockDialog + mockDialog, + mockDialogRef, + afterClosed$ }; } @@ -567,6 +607,46 @@ describe('ConnectorCanvasEffects', () => { expect(config.data.entity.operatePermissions).toBeDefined(); expect(config.data.entity.operatePermissions.canWrite).toBe(false); }); + + it('wires parameter context onto the EditProcessor dialog when one is bound', async () => { + const parameterContext = createParameterContextFixture({ + component: { id: 'pc-1', name: 'Bound PC', parameters: [] } + }); + const { effects, actions$, mockDialogRef } = await setup({ parameterContext }); + + actions$( + of( + viewComponentConfiguration({ + request: { entity: baseEntity, componentType: ComponentType.Processor } + }) + ) + ); + await firstValueFrom(effects.viewComponentConfiguration$); + + expect(mockDialogRef.componentInstance.parameterContext).toBe(parameterContext); + // Read-only mode in nifi-frontend deliberately does NOT set supportsParameters + // on EditProcessor (EditProcessor lacks that input). Pin this behavior. + expect(mockDialogRef.componentInstance.supportsParameters).toBeUndefined(); + // The property table hides "Go to Parameter" when this callback is undefined, + // while still rendering parameter values in the value tip. + expect(mockDialogRef.componentInstance.goToParameter).toBeUndefined(); + }); + + it('leaves parameterContext undefined on the EditProcessor dialog when no PC is bound', async () => { + const { effects, actions$, mockDialogRef } = await setup({ parameterContext: null }); + + actions$( + of( + viewComponentConfiguration({ + request: { entity: baseEntity, componentType: ComponentType.Processor } + }) + ) + ); + await firstValueFrom(effects.viewComponentConfiguration$); + + expect(mockDialogRef.componentInstance.parameterContext).toBeUndefined(); + expect(mockDialogRef.componentInstance.goToParameter).toBeUndefined(); + }); }); describe('navigateToProvenanceForComponent$', () => { @@ -1080,4 +1160,294 @@ describe('ConnectorCanvasEffects', () => { expect(emitted).toEqual([reloadConnectorFlow()]); }); }); + + describe('loadConnectorParameterContextOnLoadSuccess$', () => { + function flowSuccessAction(connectorId: string, processGroupId: string | null) { + return loadConnectorFlowSuccess({ + connectorId, + processGroupId, + parentProcessGroupId: null, + breadcrumb: null, + labels: [], + funnels: [], + inputPorts: [], + outputPorts: [], + remoteProcessGroups: [], + processGroups: [], + processors: [], + connections: [] + }); + } + + function controllerServicesSuccessAction(connectorId: string, processGroupId: string) { + return loadConnectorControllerServicesSuccess({ + response: { + connectorId, + processGroupId, + controllerServices: [], + breadcrumb: null, + loadedTimestamp: '2024-01-01T00:00:00.000Z' + } + }); + } + + it('should dispatch loadConnectorParameterContext scoped to CONNECTOR_CANVAS after flow load success', async () => { + const { effects, actions$ } = await setup(); + actions$(of(flowSuccessAction('connector-123', 'pg-abc'))); + + const result = await firstValueFrom(effects.loadConnectorParameterContextOnLoadSuccess$); + expect(result).toEqual( + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }) + ); + }); + + it('should dispatch loadConnectorParameterContext scoped to CONTROLLER_SERVICES after controller services load success (deep link)', async () => { + const { effects, actions$ } = await setup(); + actions$(of(controllerServicesSuccessAction('connector-123', 'pg-abc'))); + + const result = await firstValueFrom(effects.loadConnectorParameterContextOnLoadSuccess$); + expect(result).toEqual( + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONTROLLER_SERVICES + }) + ); + }); + + it('should not dispatch when processGroupId is null', async () => { + const { effects, actions$ } = await setup(); + actions$(of(flowSuccessAction('connector-123', null))); + + const emitted = await firstValueFrom(effects.loadConnectorParameterContextOnLoadSuccess$.pipe(toArray())); + + expect(emitted).toEqual([]); + }); + + it('should dispatch only once when polling refires loadConnectorFlowSuccess for the same process group', async () => { + const { effects, actions$ } = await setup(); + actions$( + of( + flowSuccessAction('connector-123', 'pg-abc'), + flowSuccessAction('connector-123', 'pg-abc'), + flowSuccessAction('connector-123', 'pg-abc') + ) + ); + + const emitted = await firstValueFrom(effects.loadConnectorParameterContextOnLoadSuccess$.pipe(toArray())); + + expect(emitted).toEqual([ + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }) + ]); + }); + + it('should dedupe canvas → controller-services transition within the same process group', async () => { + const { effects, actions$ } = await setup(); + actions$( + of( + flowSuccessAction('connector-123', 'pg-abc'), + controllerServicesSuccessAction('connector-123', 'pg-abc') + ) + ); + + const emitted = await firstValueFrom(effects.loadConnectorParameterContextOnLoadSuccess$.pipe(toArray())); + + expect(emitted).toEqual([ + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }) + ]); + }); + + it('should dispatch again when the process group changes', async () => { + const { effects, actions$ } = await setup(); + actions$( + of( + flowSuccessAction('connector-123', 'pg-abc'), + flowSuccessAction('connector-123', 'pg-abc'), + flowSuccessAction('connector-123', 'pg-xyz') + ) + ); + + const emitted = await firstValueFrom(effects.loadConnectorParameterContextOnLoadSuccess$.pipe(toArray())); + + expect(emitted).toEqual([ + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }), + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-xyz', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }) + ]); + }); + + it('should dispatch again when the connector changes even if the process group id is the same', async () => { + const { effects, actions$ } = await setup(); + actions$(of(flowSuccessAction('connector-123', 'pg-abc'), flowSuccessAction('connector-456', 'pg-abc'))); + + const emitted = await firstValueFrom(effects.loadConnectorParameterContextOnLoadSuccess$.pipe(toArray())); + + expect(emitted).toEqual([ + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }), + loadConnectorParameterContext({ + connectorId: 'connector-456', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }) + ]); + }); + + it('should re-arm distinctUntilChanged after resetConnectorCanvasState so the same connector/PG can re-fetch', async () => { + // NgRx effects are app-singleton-scoped, so the inner distinctUntilChanged must be + // rebuilt when the canvas tears down (component ngOnDestroy → resetConnectorCanvasState). + // Without this, navigating away from a connector and back into the same one would + // silently suppress the parameter-context re-fetch. + const { effects, actions$ } = await setup(); + actions$( + of( + flowSuccessAction('connector-123', 'pg-abc'), + resetConnectorCanvasState(), + flowSuccessAction('connector-123', 'pg-abc') + ) + ); + + const emitted = await firstValueFrom(effects.loadConnectorParameterContextOnLoadSuccess$.pipe(toArray())); + + expect(emitted).toEqual([ + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }), + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }) + ]); + }); + }); + + describe('loadConnectorParameterContext$', () => { + const parameterContext = createParameterContextFixture(); + + it('should dispatch success with the parameter context returned by the service', async () => { + const { effects, actions$, mockConnectorService } = await setup({ + parameterContextResponse: parameterContext + }); + actions$( + of( + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }) + ) + ); + + const result = await firstValueFrom(effects.loadConnectorParameterContext$); + + expect(mockConnectorService.getConnectorParameterContext).toHaveBeenCalledWith('connector-123', 'pg-abc'); + expect(result).toEqual(loadConnectorParameterContextSuccess({ parameterContext })); + }); + + it('should dispatch success with null when the service returns null (HTTP 204)', async () => { + const { effects, actions$ } = await setup({ parameterContextResponse: null }); + actions$( + of( + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }) + ) + ); + + const result = await firstValueFrom(effects.loadConnectorParameterContext$); + expect(result).toEqual(loadConnectorParameterContextSuccess({ parameterContext: null })); + }); + + it('should dispatch failure using the errorContext carried on the action', async () => { + const errorResponse = new HttpErrorResponse({ error: 'boom', status: 500, statusText: 'ISE' }); + const { effects, actions$ } = await setup({ parameterContextError: errorResponse }); + actions$( + of( + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }) + ) + ); + + const result = await firstValueFrom(effects.loadConnectorParameterContext$); + + expect(result).toEqual( + loadConnectorParameterContextFailure({ + errorContext: { + errors: ['Error message'], + context: ErrorContextKey.CONNECTOR_CANVAS + } + }) + ); + }); + + it('should propagate CONTROLLER_SERVICES error context for CS-triggered loads', async () => { + const errorResponse = new HttpErrorResponse({ error: 'boom', status: 500, statusText: 'ISE' }); + const { effects, actions$ } = await setup({ parameterContextError: errorResponse }); + actions$( + of( + loadConnectorParameterContext({ + connectorId: 'connector-123', + processGroupId: 'pg-abc', + errorContext: ErrorContextKey.CONTROLLER_SERVICES + }) + ) + ); + + const result = await firstValueFrom(effects.loadConnectorParameterContext$); + + expect(result).toEqual( + loadConnectorParameterContextFailure({ + errorContext: { + errors: ['Error message'], + context: ErrorContextKey.CONTROLLER_SERVICES + } + }) + ); + }); + }); + + describe('loadConnectorParameterContextFailure$', () => { + it('should dispatch addBannerError with the failure error context', async () => { + const { effects, actions$ } = await setup(); + const errorContext = { + errors: ['Unable to load parameter context'], + context: ErrorContextKey.CONNECTORS + }; + actions$(of(loadConnectorParameterContextFailure({ errorContext }))); + + const result = await firstValueFrom(effects.loadConnectorParameterContextFailure$); + + expect(result).toEqual(ErrorActions.addBannerError({ errorContext })); + }); + }); }); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts index d636a0190b47..82361e1e0b50 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts @@ -22,7 +22,18 @@ import { concatLatestFrom } from '@ngrx/operators'; import { Store } from '@ngrx/store'; import { Router } from '@angular/router'; import { asyncScheduler, interval, NEVER, Observable, of } from 'rxjs'; -import { catchError, filter, map, switchMap, take, takeUntil, tap, throttleTime } from 'rxjs/operators'; +import { + catchError, + distinctUntilChanged, + filter, + map, + startWith, + switchMap, + take, + takeUntil, + tap, + throttleTime +} from 'rxjs/operators'; import { ComponentType, ComponentTypeNamePipe, LARGE_DIALOG, MEDIUM_DIALOG, NiFiCommon, XL_DIALOG } from '@nifi/shared'; import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors'; import { selectPrioritizerTypes } from '../../../../state/extension-types/extension-types.selectors'; @@ -34,6 +45,7 @@ import { MatDialog } from '@angular/material/dialog'; import { ConnectorService } from '../../service/connector.service'; import { ErrorHelper } from '../../../../service/error-helper.service'; import { ErrorContextKey } from '../../../../state/error'; +import * as ErrorActions from '../../../../state/error/error.actions'; import { BackNavigation } from '../../../../state/navigation'; import { EditComponentDialogRequest, EditConnectionDialogRequest } from '../../../../state/shared'; import { EditProcessor } from '../../../../ui/common/component-dialogs/edit-processor/edit-processor.component'; @@ -43,6 +55,8 @@ import { EditLabel } from '../../../../ui/common/component-dialogs/edit-label/ed import { EditProcessGroup } from '../../../../ui/common/component-dialogs/edit-process-group/edit-process-group.component'; import { EditRemoteProcessGroup } from '../../../../ui/common/component-dialogs/edit-remote-process-group/edit-remote-process-group.component'; import * as ConnectorCanvasActions from './connector-canvas.actions'; +import * as ConnectorControllerServicesActions from '../connector-controller-services/connector-controller-services.actions'; +import { bindConnectorParameterContext } from './bind-connector-parameter-context'; import { SelectedComponent } from './connector-canvas.actions'; import * as EmptyQueueActions from '../../../../state/empty-queue/empty-queue.actions'; import { @@ -162,6 +176,107 @@ export class ConnectorCanvasEffects { ) ); + /** + * Request the parameter context bound to the loaded process group whenever the + * connector canvas or controller-services page reports a successful load. The + * backend returns 204 No Content when no bound context exists, which surfaces as + * a null parameter context on the canvas state. + * + * The controller-services route is a sibling of the canvas route, not a child, so + * deep-linking directly to `/connectors/:id/canvas/:pgId/controller-services` + * never activates the canvas component and thus never emits + * {@link loadConnectorFlowSuccess}. Listening to both trigger actions in a single + * pipeline keeps that deep-link case covered. {@link distinctUntilChanged} keyed + * on connectorId + processGroupId dedupes the polling refire on the canvas page + * and the canvas → controller-services transition within the same process group; + * navigating to a different process group (or to a different connector with the + * same process group id) changes the key and triggers a fresh fetch. + * + * NgRx effects are application-level singletons, so the inner `distinctUntilChanged` + * would otherwise accumulate the last (connectorId, processGroupId) pair across the + * entire app lifetime — silently suppressing the re-fetch when a user navigates + * away from a connector and back into the same one. Wrapping the inner pipeline in + * `switchMap` over `resetConnectorCanvasState` (which fires on canvas ngOnDestroy) + * tears down and rebuilds the inner subscription, clearing the stale "previous" + * reference so the next load-success emission is always treated as a first + * emission. `startWith(null)` is required so the inner pipeline is subscribed on + * app startup without waiting for the first reset. + */ + loadConnectorParameterContextOnLoadSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(ConnectorCanvasActions.resetConnectorCanvasState), + startWith(null), + switchMap(() => + this.actions$.pipe( + ofType( + ConnectorCanvasActions.loadConnectorFlowSuccess, + ConnectorControllerServicesActions.loadConnectorControllerServicesSuccess + ), + map((action) => { + if (action.type === ConnectorCanvasActions.loadConnectorFlowSuccess.type) { + return { + connectorId: action.connectorId, + processGroupId: action.processGroupId, + errorContext: ErrorContextKey.CONNECTOR_CANVAS + }; + } + return { + connectorId: action.response.connectorId, + processGroupId: action.response.processGroupId, + errorContext: ErrorContextKey.CONTROLLER_SERVICES + }; + }), + filter( + ( + target + ): target is { connectorId: string; processGroupId: string; errorContext: ErrorContextKey } => + target.processGroupId !== null && target.processGroupId !== undefined + ), + distinctUntilChanged( + (previous, current) => + previous.connectorId === current.connectorId && + previous.processGroupId === current.processGroupId + ), + map((target) => ConnectorCanvasActions.loadConnectorParameterContext(target)) + ) + ) + ) + ); + + loadConnectorParameterContext$ = createEffect(() => + this.actions$.pipe( + ofType(ConnectorCanvasActions.loadConnectorParameterContext), + switchMap((action) => + this.connectorService.getConnectorParameterContext(action.connectorId, action.processGroupId).pipe( + map((parameterContext) => + ConnectorCanvasActions.loadConnectorParameterContextSuccess({ parameterContext }) + ), + catchError((error) => + of( + ConnectorCanvasActions.loadConnectorParameterContextFailure({ + errorContext: { + errors: [this.errorHelper.getErrorString(error)], + context: action.errorContext + } + }) + ) + ) + ) + ) + ) + ); + + /** + * Surface parameter context load failures via a banner so the user sees that + * parameter references in the canvas property tables will not resolve. + */ + loadConnectorParameterContextFailure$ = createEffect(() => + this.actions$.pipe( + ofType(ConnectorCanvasActions.loadConnectorParameterContextFailure), + map((action) => ErrorActions.addBannerError({ errorContext: action.errorContext })) + ) + ); + /** * Translate a reload request into a fresh {@link loadConnectorFlow} for the * connector and process group currently mounted on the canvas. @@ -583,12 +698,19 @@ export class ConnectorCanvasEffects { instance.propertyVerificationResults$ = this.store.select(selectPropertyVerificationResults); instance.propertyVerificationStatus$ = this.store.select(selectPropertyVerificationStatus); - // provide no-op stubs for callbacks not needed in read-only mode + // Provide no-op stubs for callbacks not needed in read-only mode. `goToParameter` is + // intentionally left undefined so the property table hides the "Go to Parameter" affordance + // for the connector canvas, while parameter values still render in the value tip. instance.createNewProperty = () => NEVER; instance.createNewService = () => NEVER; instance.convertToParameter = () => NEVER; - instance.goToParameter = () => undefined; instance.goToService = () => undefined; + + // EditProcessor only exposes `parameterContext`; the underlying property table + // disables parameter affordances on its own when parameterContext is undefined. + bindConnectorParameterContext(this.store, dialogRef.afterClosed(), (parameterContext) => { + instance.parameterContext = parameterContext ?? undefined; + }); } private openReadOnlyConnectionDialog(request: EditComponentDialogRequest): void { diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts index 1a8ad6b5cd88..ab4d41c1186d 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts @@ -19,6 +19,7 @@ import { connectorCanvasReducer } from './connector-canvas.reducer'; import { ConnectorCanvasState, initialConnectorCanvasState } from './index'; import * as ConnectorCanvasActions from './connector-canvas.actions'; import { ErrorContextKey } from '../../../../state/error'; +import { createParameterContextFixture } from '../../testing/parameter-context-fixture'; describe('connectorCanvasReducer', () => { function createPopulatedState(overrides: Partial = {}): ConnectorCanvasState { @@ -95,6 +96,30 @@ describe('connectorCanvasReducer', () => { expect(result.remoteProcessGroups).toEqual(previousState.remoteProcessGroups); expect(result.processGroups).toEqual(previousState.processGroups); }); + + it('should clear component data when navigating to a different connector with the same process group id', () => { + const previousState = createPopulatedState({ + connectorId: 'connector-1', + processGroupId: 'pg-current' + }); + + const result = connectorCanvasReducer( + previousState, + ConnectorCanvasActions.loadConnectorFlow({ connectorId: 'connector-2', processGroupId: 'pg-current' }) + ); + + expect(result.connectorId).toBe('connector-2'); + expect(result.processGroupId).toBe('pg-current'); + expect(result.labels).toEqual([]); + expect(result.funnels).toEqual([]); + expect(result.inputPorts).toEqual([]); + expect(result.outputPorts).toEqual([]); + expect(result.remoteProcessGroups).toEqual([]); + expect(result.processGroups).toEqual([]); + expect(result.processors).toEqual([]); + expect(result.connections).toEqual([]); + expect(result.parameterContext).toBeNull(); + }); }); describe('loadConnectorFlowSuccess', () => { @@ -201,4 +226,67 @@ describe('connectorCanvasReducer', () => { expect(result.skipTransform).toBe(true); }); }); + + describe('parameter context', () => { + const sampleParameterContext = createParameterContextFixture(); + + it('should clear parameter context when navigating to a different process group', () => { + const previousState = createPopulatedState({ + processGroupId: 'pg-current', + parameterContext: sampleParameterContext + }); + + const result = connectorCanvasReducer( + previousState, + ConnectorCanvasActions.loadConnectorFlow({ connectorId: 'connector-1', processGroupId: 'pg-other' }) + ); + + expect(result.parameterContext).toBeNull(); + }); + + it('should preserve parameter context when refreshing the same process group', () => { + const previousState = createPopulatedState({ + processGroupId: 'pg-current', + parameterContext: sampleParameterContext + }); + + const result = connectorCanvasReducer( + previousState, + ConnectorCanvasActions.loadConnectorFlow({ connectorId: 'connector-1', processGroupId: 'pg-current' }) + ); + + expect(result.parameterContext).toBe(sampleParameterContext); + }); + + it('should store the parameter context on load success', () => { + const result = connectorCanvasReducer( + createPopulatedState(), + ConnectorCanvasActions.loadConnectorParameterContextSuccess({ + parameterContext: sampleParameterContext + }) + ); + + expect(result.parameterContext).toBe(sampleParameterContext); + }); + + it('should store a null parameter context on load success', () => { + const result = connectorCanvasReducer( + createPopulatedState({ parameterContext: sampleParameterContext }), + ConnectorCanvasActions.loadConnectorParameterContextSuccess({ parameterContext: null }) + ); + + expect(result.parameterContext).toBeNull(); + }); + + it('should reset parameter context on load failure', () => { + const result = connectorCanvasReducer( + createPopulatedState({ parameterContext: sampleParameterContext }), + ConnectorCanvasActions.loadConnectorParameterContextFailure({ + errorContext: { context: ErrorContextKey.CONNECTORS, errors: ['boom'] } + }) + ); + + expect(result.parameterContext).toBeNull(); + }); + }); }); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.ts index 2c87b1b58a82..723c396ad35e 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.ts @@ -22,12 +22,13 @@ import * as ConnectorCanvasActions from './connector-canvas.actions'; export const connectorCanvasReducer = createReducer( initialConnectorCanvasState, - on(ConnectorCanvasActions.loadConnectorFlow, (state, { processGroupId }) => ({ + on(ConnectorCanvasActions.loadConnectorFlow, (state, { connectorId, processGroupId }) => ({ ...state, + connectorId, processGroupId, loadingStatus: 'loading' as const, error: null, - ...(processGroupId !== state.processGroupId + ...(connectorId !== state.connectorId || processGroupId !== state.processGroupId ? { labels: [], funnels: [], @@ -36,7 +37,8 @@ export const connectorCanvasReducer = createReducer( remoteProcessGroups: [], processGroups: [], processors: [], - connections: [] + connections: [], + parameterContext: null } : {}) })), @@ -96,5 +98,15 @@ export const connectorCanvasReducer = createReducer( on(ConnectorCanvasActions.setSkipTransform, (state, { skipTransform }) => ({ ...state, skipTransform + })), + + on(ConnectorCanvasActions.loadConnectorParameterContextSuccess, (state, { parameterContext }) => ({ + ...state, + parameterContext + })), + + on(ConnectorCanvasActions.loadConnectorParameterContextFailure, (state) => ({ + ...state, + parameterContext: null })) ); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts index c8e6f9252bd1..40cfb3b9e19c 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts @@ -80,6 +80,11 @@ export const selectBreadcrumbs = createSelector(selectConnectorCanvasState, (sta export const selectSkipTransform = createSelector(selectConnectorCanvasState, (state) => state.skipTransform); +export const selectConnectorParameterContext = createSelector( + selectConnectorCanvasState, + (state) => state.parameterContext +); + // Entity-by-id factory selectors for provenance eligibility checks export const selectProcessor = (id: string) => createSelector(selectProcessors, (processors) => processors.find((p: any) => p.id === id)); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts index c5a6206e1354..1bdefea534a9 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { BreadcrumbEntity, RegistryClientEntity } from '../../../../state/shared'; +import { BreadcrumbEntity, ParameterContextEntity, RegistryClientEntity } from '../../../../state/shared'; export const connectorCanvasFeatureKey = 'connectorCanvas'; @@ -36,6 +36,7 @@ export interface ConnectorCanvasState { skipTransform: boolean; loadingStatus: 'pending' | 'loading' | 'success' | 'error'; error: string | null; + parameterContext: ParameterContextEntity | null; } export const initialConnectorCanvasState: ConnectorCanvasState = { @@ -54,5 +55,6 @@ export const initialConnectorCanvasState: ConnectorCanvasState = { registryClients: [], skipTransform: false, loadingStatus: 'pending', - error: null + error: null, + parameterContext: null }; diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-controller-services/connector-controller-services.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-controller-services/connector-controller-services.effects.spec.ts index 4c51ddb936d7..4b13e76b0821 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-controller-services/connector-controller-services.effects.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-controller-services/connector-controller-services.effects.spec.ts @@ -18,9 +18,10 @@ import { TestBed } from '@angular/core/testing'; import { provideMockActions } from '@ngrx/effects/testing'; import { Action } from '@ngrx/store'; +import { provideMockStore } from '@ngrx/store/testing'; import { Router } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; -import { firstValueFrom, Observable, of, throwError } from 'rxjs'; +import { firstValueFrom, Observable, Subject, of, throwError } from 'rxjs'; import { ConnectorControllerServicesEffects } from './connector-controller-services.effects'; import { loadConnectorControllerServices, @@ -29,11 +30,13 @@ import { openViewControllerServiceDialog, selectConnectorControllerService } from './connector-controller-services.actions'; +import { selectConnectorParameterContext } from '../connector-canvas/connector-canvas.selectors'; import { ConnectorService } from '../../service/connector.service'; import { ErrorHelper } from '../../../../service/error-helper.service'; import { ErrorContextKey } from '../../../../state/error'; import * as ErrorActions from '../../../../state/error/error.actions'; -import { ControllerServiceEntity } from '../../../../state/shared'; +import { ControllerServiceEntity, ParameterContextEntity } from '../../../../state/shared'; +import { createParameterContextFixture } from '../../testing/parameter-context-fixture'; import { EditControllerService } from '../../../../ui/common/controller-service/edit-controller-service/edit-controller-service.component'; function buildService(overrides: Partial = {}): ControllerServiceEntity { @@ -46,7 +49,11 @@ function buildService(overrides: Partial = {}): Control } describe('ConnectorControllerServicesEffects', () => { - async function setup() { + interface SetupOptions { + parameterContext?: ParameterContextEntity | null; + } + + async function setup(options: SetupOptions = {}) { let actions$: Observable; const mockConnectorService = { @@ -62,14 +69,34 @@ describe('ConnectorControllerServicesEffects', () => { navigate: vi.fn().mockResolvedValue(true) }; + const afterClosed$ = new Subject(); + const mockDialogInstance = { + createNewService: undefined as any, + goToParameter: undefined as any, + convertToParameter: undefined as any, + goToService: undefined as any, + parameterContext: undefined as any, + supportsParameters: true as any + }; const mockDialog = { - open: vi.fn().mockReturnValue({ componentInstance: {} }) + open: vi.fn().mockReturnValue({ + componentInstance: mockDialogInstance, + afterClosed: () => afterClosed$.asObservable() + }) }; await TestBed.configureTestingModule({ providers: [ ConnectorControllerServicesEffects, provideMockActions(() => actions$), + provideMockStore({ + selectors: [ + { + selector: selectConnectorParameterContext, + value: options.parameterContext ?? null + } + ] + }), { provide: ConnectorService, useValue: mockConnectorService }, { provide: ErrorHelper, useValue: mockErrorHelper }, { provide: Router, useValue: mockRouter }, @@ -82,6 +109,8 @@ describe('ConnectorControllerServicesEffects', () => { mockConnectorService, mockRouter, mockDialog, + mockDialogInstance, + afterClosed$, actions$: (stream: Observable) => { actions$ = stream; } @@ -198,5 +227,34 @@ describe('ConnectorControllerServicesEffects', () => { expect(config.data.controllerService).toBe(service); expect(config.data.controllerService.permissions.canWrite).toBe(true); }); + + it('should wire the bound parameter context onto the EditControllerService dialog instance', async () => { + const parameterContext = createParameterContextFixture({ + component: { id: 'pc-1', name: 'Bound PC', parameters: [] } + }); + const { effects, actions$, mockDialogInstance } = await setup({ parameterContext }); + + actions$(of(openViewControllerServiceDialog({ controllerService: buildService() }))); + + await firstValueFrom(effects.openViewControllerServiceDialog$); + + expect(mockDialogInstance.parameterContext).toBe(parameterContext); + expect(mockDialogInstance.supportsParameters).toBe(true); + // Read-only mode should not wire a navigation callback. The property table hides + // "Go to Parameter" when this is undefined, while still rendering parameter values. + expect(mockDialogInstance.goToParameter).toBeUndefined(); + }); + + it('should set supportsParameters to false when no parameter context is bound', async () => { + const { effects, actions$, mockDialogInstance } = await setup({ parameterContext: null }); + + actions$(of(openViewControllerServiceDialog({ controllerService: buildService() }))); + + await firstValueFrom(effects.openViewControllerServiceDialog$); + + expect(mockDialogInstance.parameterContext).toBeUndefined(); + expect(mockDialogInstance.supportsParameters).toBe(false); + expect(mockDialogInstance.goToParameter).toBeUndefined(); + }); }); }); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-controller-services/connector-controller-services.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-controller-services/connector-controller-services.effects.ts index c505f6210ec2..cfe655e33d9d 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-controller-services/connector-controller-services.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-controller-services/connector-controller-services.effects.ts @@ -19,8 +19,9 @@ import { Injectable, inject } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Router } from '@angular/router'; import { MatDialog } from '@angular/material/dialog'; -import { combineLatest, of } from 'rxjs'; +import { NEVER, combineLatest, of } from 'rxjs'; import { catchError, map, switchMap, tap } from 'rxjs/operators'; +import { Store } from '@ngrx/store'; import { XL_DIALOG } from '@nifi/shared'; import { ConnectorService } from '../../service/connector.service'; import { ErrorContextKey } from '../../../../state/error'; @@ -29,12 +30,14 @@ import { ErrorHelper } from '../../../../service/error-helper.service'; import { EditControllerService } from '../../../../ui/common/controller-service/edit-controller-service/edit-controller-service.component'; import { EditControllerServiceDialogRequest } from '../../../../state/shared'; import * as ConnectorControllerServicesActions from './connector-controller-services.actions'; +import { bindConnectorParameterContext } from '../connector-canvas/bind-connector-parameter-context'; @Injectable() export class ConnectorControllerServicesEffects { private actions$ = inject(Actions); private router = inject(Router); private dialog = inject(MatDialog); + private store = inject(Store); private connectorService = inject(ConnectorService); private errorHelper = inject(ErrorHelper); @@ -131,12 +134,30 @@ export class ConnectorControllerServicesEffects { readonly: true }; - this.dialog.open(EditControllerService, { + const dialogRef = this.dialog.open(EditControllerService, { ...XL_DIALOG, autoFocus: 'dialog', data: dialogRequest, id: action.controllerService.id }); + + const instance = dialogRef.componentInstance; + // Read-only mode: stub callbacks the property table needs but never invokes. + // `goToParameter` is intentionally left undefined so the property table hides + // the "Go to Parameter" affordance for the connector canvas, while parameter + // values still render in the value tip. + instance.createNewService = () => NEVER; + instance.convertToParameter = () => NEVER; + instance.goToService = () => undefined; + + bindConnectorParameterContext( + this.store, + dialogRef.afterClosed(), + (parameterContext, supportsParameters) => { + instance.parameterContext = parameterContext ?? undefined; + instance.supportsParameters = supportsParameters; + } + ); }) ), { dispatch: false } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/testing/parameter-context-fixture.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/testing/parameter-context-fixture.ts new file mode 100644 index 000000000000..a659292e888f --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/testing/parameter-context-fixture.ts @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ParameterContextEntity } from '../../../state/shared'; + +/** + * Test fixture factory for a minimal ParameterContextEntity. Centralizes the + * `as unknown as ParameterContextEntity` cast so individual specs only need to + * declare the fields they care about. Overrides shallow-merge onto the default + * fixture, and an explicit `component` override fully replaces the default + * component shape. + */ +export function createParameterContextFixture( + overrides: Partial> & { component?: any } = {} +): ParameterContextEntity { + const { component, ...rest } = overrides; + return { + id: 'pc-1', + component: component ?? { id: 'pc-1', name: 'Test PC', parameters: [] }, + ...rest + } as unknown as ParameterContextEntity; +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.ts index ba01b98e1c58..1a8e832521d6 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.ts @@ -127,7 +127,7 @@ export class EditProcessor extends TabbedDialog { @Input() createNewProperty!: (existingProperties: string[], allowsSensitive: boolean) => Observable; @Input() createNewService!: (request: InlineServiceCreationRequest) => Observable; @Input() parameterContext: ParameterContextEntity | undefined; - @Input() goToParameter!: (parameter: string) => void; + @Input() goToParameter?: (parameter: string) => void; @Input() convertToParameter!: ( name: string, sensitive: boolean, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.ts index 77c980f19dd4..643331b2417a 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.ts @@ -100,7 +100,7 @@ export class EditControllerService extends TabbedDialog { @Input() createNewProperty!: (existingProperties: string[], allowsSensitive: boolean) => Observable; @Input() createNewService!: (request: InlineServiceCreationRequest) => Observable; @Input() parameterContext: ParameterContextEntity | undefined; - @Input() goToParameter!: (parameter: string) => void; + @Input() goToParameter?: (parameter: string) => void; @Input() convertToParameter!: ( name: string, sensitive: boolean, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.spec.ts index bcc1ebbd29fe..20779e9a0f02 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.spec.ts @@ -18,6 +18,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { PropertyTable } from './property-table.component'; +import { PropertyItem } from './property-item'; +import { ParameterContextEntity } from '../../../state/shared'; describe('PropertyTable', () => { let component: PropertyTable; @@ -35,4 +37,77 @@ describe('PropertyTable', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + describe('optional goToParameter callback', () => { + const mockItem: PropertyItem = { + property: 'group.id', + value: '#{some-param}', + descriptor: { + name: 'group.id', + displayName: 'Group ID', + description: '', + required: true, + sensitive: false, + dynamic: false, + supportsEl: true, + expressionLanguageScope: 'Environment variables defined at JVM level and system properties', + dependencies: [] + }, + id: 3, + triggerEdit: false, + deleted: false, + added: false, + dirty: false, + savedValue: '#{some-param}', + type: 'required' + }; + + const readableParameterContext: ParameterContextEntity = { + id: 'ctx-1', + uri: '/parameter-contexts/ctx-1', + revision: { version: 0 }, + permissions: { canRead: true, canWrite: false } + }; + + it('should no-op goToParameterClicked when goToParameter callback is not supplied', () => { + expect(component.goToParameter).toBeUndefined(); + expect(() => component.goToParameterClicked(mockItem)).not.toThrow(); + }); + + it('should invoke goToParameter callback when supplied and item.value is non-null', () => { + const goToParameterSpy = vi.fn(); + component.goToParameter = goToParameterSpy; + + component.goToParameterClicked(mockItem); + + expect(goToParameterSpy).toHaveBeenCalledWith('#{some-param}'); + }); + + it('should not invoke goToParameter callback when item.value is null', () => { + const goToParameterSpy = vi.fn(); + component.goToParameter = goToParameterSpy; + + const itemWithoutValue: PropertyItem = { ...mockItem, value: null }; + component.goToParameterClicked(itemWithoutValue); + + expect(goToParameterSpy).not.toHaveBeenCalled(); + }); + + it('canGoToParameter returns false when goToParameter callback is not supplied, even with a readable parameterContext and a matching item.value', () => { + component.parameterContext = readableParameterContext; + // goToParameter intentionally left unset — the connector canvas read-only + // dialogs leave it undefined so the "Go to Parameter" affordance is hidden + // while parameter values still render in the value tip. + + expect(component.goToParameter).toBeUndefined(); + expect(component.canGoToParameter(mockItem)).toBe(false); + }); + + it('canGoToParameter returns true when parameterContext, goToParameter, and a matching value are all present', () => { + component.parameterContext = readableParameterContext; + component.goToParameter = vi.fn(); + + expect(component.canGoToParameter(mockItem)).toBe(true); + }); + }); }); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.ts index 5b435cd50a40..4abc9dcc8310 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.ts @@ -96,7 +96,7 @@ export class PropertyTable implements AfterViewInit, ControlValueAccessor { @Input() createNewProperty!: (existingProperties: string[], allowsSensitive: boolean) => Observable; @Input() createNewService!: (request: InlineServiceCreationRequest) => Observable; @Input() parameterContext: ParameterContextEntity | undefined; - @Input() goToParameter!: (parameter: string) => void; + @Input() goToParameter?: (parameter: string) => void; @Input() convertToParameter!: ( name: string, sensitive: boolean, @@ -520,7 +520,7 @@ export class PropertyTable implements AfterViewInit, ControlValueAccessor { // TODO - currently parameter context route does not support navigating // directly to a specific parameter so the parameter context link // is not item specific. - if (this.parameterContext && item.value) { + if (this.parameterContext && this.goToParameter && item.value) { return this.parameterContext.permissions.canRead && PropertyTable.PARAM_REF_REGEX.test(item.value); } @@ -528,7 +528,9 @@ export class PropertyTable implements AfterViewInit, ControlValueAccessor { } goToParameterClicked(item: PropertyItem): void { - // @ts-ignore + if (!this.goToParameter || item.value == null) { + return; + } this.goToParameter(item.value); } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/tooltips/property-value-tip/property-value-tip.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/tooltips/property-value-tip/property-value-tip.component.html index a98907f3eab0..888e142d8d4f 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/tooltips/property-value-tip/property-value-tip.component.html +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/tooltips/property-value-tip/property-value-tip.component.html @@ -25,9 +25,15 @@ {{ param.name }} -
- {{ param.value }} -
+ @if (param.value === null || param.value === undefined) { +
No value set
+ } @else if (param.value === '') { +
Empty string set
+ } @else { +
+ {{ param.value }} +
+ } } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/tooltips/property-value-tip/property-value-tip.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/tooltips/property-value-tip/property-value-tip.component.spec.ts index 1de49dc63c26..1605397606b3 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/tooltips/property-value-tip/property-value-tip.component.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/tooltips/property-value-tip/property-value-tip.component.spec.ts @@ -176,4 +176,92 @@ describe('PropertyValueTip', () => { expect(component.parameterReferences.length).toBe(0); }); }); + + describe('parameter value rendering', () => { + function buildDescriptor(overrides: Partial = {}) { + return { + name: 'prop', + displayName: 'Prop', + description: 'desc', + required: false, + sensitive: false, + dynamic: false, + supportsEl: true, + expressionLanguageScope: '', + dependencies: [], + ...overrides + }; + } + + function getParameterValueCellText(): string { + const cells = fixture.nativeElement.querySelectorAll('table td'); + return cells[1]?.textContent?.trim() ?? ''; + } + + it('renders "No value set" when the referenced parameter value is null', () => { + const data: PropertyValueTipInput = { + property: { + property: 'prop', + value: '#{PARAM_A}', + descriptor: buildDescriptor() + }, + parameters: [{ parameter: { name: 'PARAM_A', description: '', sensitive: false, value: null } }] + }; + + fixture.componentRef.setInput('data', data); + fixture.detectChanges(); + + expect(getParameterValueCellText()).toBe('No value set'); + }); + + it('renders "No value set" when the referenced parameter value is undefined', () => { + const data: PropertyValueTipInput = { + property: { + property: 'prop', + value: '#{PARAM_A}', + descriptor: buildDescriptor() + }, + parameters: [ + { parameter: { name: 'PARAM_A', description: '', sensitive: false, value: undefined as any } } + ] + }; + + fixture.componentRef.setInput('data', data); + fixture.detectChanges(); + + expect(getParameterValueCellText()).toBe('No value set'); + }); + + it('renders "Empty string set" when the referenced parameter value is an empty string', () => { + const data: PropertyValueTipInput = { + property: { + property: 'prop', + value: '#{PARAM_A}', + descriptor: buildDescriptor() + }, + parameters: [{ parameter: { name: 'PARAM_A', description: '', sensitive: false, value: '' } }] + }; + + fixture.componentRef.setInput('data', data); + fixture.detectChanges(); + + expect(getParameterValueCellText()).toBe('Empty string set'); + }); + + it('renders the value when the referenced parameter has a non-empty value', () => { + const data: PropertyValueTipInput = { + property: { + property: 'prop', + value: '#{PARAM_A}', + descriptor: buildDescriptor() + }, + parameters: [{ parameter: { name: 'PARAM_A', description: '', sensitive: false, value: 'hello' } }] + }; + + fixture.componentRef.setInput('data', data); + fixture.detectChanges(); + + expect(getParameterValueCellText()).toBe('hello'); + }); + }); }); diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/codemirror/autocomplete/parameter-tip/parameter-tip.component.html b/nifi-frontend/src/main/frontend/libs/shared/src/components/codemirror/autocomplete/parameter-tip/parameter-tip.component.html index 1c8a1e1d660d..4970fde93e34 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/codemirror/autocomplete/parameter-tip/parameter-tip.component.html +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/codemirror/autocomplete/parameter-tip/parameter-tip.component.html @@ -20,7 +20,7 @@
{{ parameter.name }}
@if (!parameter.sensitive) { - @if (parameter.value === null) { + @if (parameter.value === null || parameter.value === undefined) {
No value set
} @else if (parameter.value === '') {
Empty string set
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/codemirror/autocomplete/parameter-tip/parameter-tip.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/codemirror/autocomplete/parameter-tip/parameter-tip.component.spec.ts index c66a22bcfffb..701002a3bf83 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/codemirror/autocomplete/parameter-tip/parameter-tip.component.spec.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/codemirror/autocomplete/parameter-tip/parameter-tip.component.spec.ts @@ -18,6 +18,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ParameterTip } from './parameter-tip.component'; +import { ParameterTipInput } from '../../../../types'; describe('ParameterTip', () => { let component: ParameterTip; @@ -29,10 +30,52 @@ describe('ParameterTip', () => { }); fixture = TestBed.createComponent(ParameterTip); component = fixture.componentInstance; - fixture.detectChanges(); }); + function setData(value: string | null | undefined, sensitive = false): void { + const data: ParameterTipInput = { + parameter: { + name: 'PARAM_A', + description: 'desc', + sensitive, + value: value as string | null + } + }; + component.data = data; + fixture.detectChanges(); + } + + function getValueText(): string { + const el: HTMLElement = fixture.nativeElement; + const unset = el.querySelector('.unset'); + if (unset) { + return unset.textContent?.trim() ?? ''; + } + const fontMono = el.querySelector('.font-mono'); + return fontMono?.textContent?.trim() ?? ''; + } + it('should create', () => { expect(component).toBeTruthy(); }); + + it('renders "No value set" when the parameter value is null', () => { + setData(null); + expect(getValueText()).toBe('No value set'); + }); + + it('renders "No value set" when the parameter value is undefined', () => { + setData(undefined); + expect(getValueText()).toBe('No value set'); + }); + + it('renders "Empty string set" when the parameter value is an empty string', () => { + setData(''); + expect(getValueText()).toBe('Empty string set'); + }); + + it('renders the value when the parameter has a non-empty value', () => { + setData('hello'); + expect(getValueText()).toBe('hello'); + }); });