Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions backend/apps/dev/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,9 @@ apply from: "${rootProject.projectDir}/backend/gradle/valtimo-app.gradle"

// Generate TypeScript types for the frontend from the backend DTOs. Lives in the
// dev module because it needs the full runtime classpath and is a dev-only tool.
def generatedDir = rootProject.file("../frontend/projects/valtimo/shared/src/lib/generated")
def outDtsFile = rootProject.file("../frontend/projects/valtimo/shared/src/lib/generated/generated-backend-types.d.ts")
def outTsFile = rootProject.file("../frontend/projects/valtimo/shared/src/lib/generated/generated-backend-types.ts")
def generatedDir = rootProject.file("frontend/projects/valtimo/shared/src/lib/generated")
def outDtsFile = rootProject.file("frontend/projects/valtimo/shared/src/lib/generated/generated-backend-types.d.ts")
def outTsFile = rootProject.file("frontend/projects/valtimo/shared/src/lib/generated/generated-backend-types.ts")

tasks.named("generateTypeScript").configure {
dependsOn tasks.named("classes")
Expand All @@ -93,7 +93,17 @@ tasks.named("generateTypeScript").configure {

excludeClasses = ["com.ritense.search.domain.DisplayType"]
customTypeMappings = [
"com.ritense.search.domain.DisplayType:any"
"com.ritense.search.domain.DisplayType:any",
// Types with custom Jackson serializers that write plain strings.
"java.net.URI:string",
"org.semver4j.Semver:string",
'com.ritense.document.domain.Document$Id:string',
]
// Distinct TypeScript names for classes whose simple names collide.
customTypeNaming = [
'com.ritense.dashboard.domain.WidgetLayout:DashboardWidgetLayout',
'com.ritense.tab.domain.WidgetLayout:TabWidgetLayout',
'com.ritense.document.domain.DocumentDefinition$Id:DocumentDefinitionId',
]

doFirst {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ abstract class AbstractFormFlowLinkTaskProvider(
.singleResult()

val additionalProperties = mutableMapOf(
"processInstanceId" to task.getProcessInstanceId(),
"processInstanceBusinessKey" to processInstance.businessKey,
"taskInstanceId" to task.id
PROCESS_INSTANCE_ID to task.getProcessInstanceId(),
PROCESS_INSTANCE_BUSINESS_KEY to processInstance.businessKey,
TASK_INSTANCE_ID to task.id
)

try {
val document = AuthorizationContext.runWithoutAuthorization { documentService[processInstance.businessKey] }
if (document != null) {
additionalProperties["documentId"] = processInstance.businessKey
additionalProperties[DOCUMENT_ID] = processInstance.businessKey
}
} catch (e: DocumentNotFoundException) {
// we do nothing here, intentional
Expand All @@ -55,6 +55,16 @@ abstract class AbstractFormFlowLinkTaskProvider(

companion object {
const val FORM_FLOW_TASK_TYPE_KEY = "form-flow"

// The keys of the additional properties that are available to SpEL expressions in a form
// flow. These are also published through the form flow registry, so the editor can show
// which context data a definition can rely on.
const val PROCESS_INSTANCE_ID = "processInstanceId"
const val PROCESS_INSTANCE_BUSINESS_KEY = "processInstanceBusinessKey"
const val TASK_INSTANCE_ID = "taskInstanceId"
const val DOCUMENT_ID = "documentId"
const val PROCESS_DEFINITION_KEY = "processDefinitionKey"
const val DOCUMENT_DEFINITION_NAME = "documentDefinitionName"
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,9 @@ class FormFlowProcessLinkActivityHandler(
repositoryService.findProcessDefinitionById(processDefinitionId)!!
}

val additionalProperties = mutableMapOf<String, Any>("processDefinitionKey" to processDefinition.key)
documentId?.let { additionalProperties["documentId"] = it }
documentDefinitionName?.let { additionalProperties["documentDefinitionName"] = it }
val additionalProperties = mutableMapOf<String, Any>(PROCESS_DEFINITION_KEY to processDefinition.key)
documentId?.let { additionalProperties[DOCUMENT_ID] = it }
documentDefinitionName?.let { additionalProperties[DOCUMENT_DEFINITION_NAME] = it }
Comment on lines +106 to +108

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check the imports of the constants used at lines 106-108.
fd -t f 'FormFlowProcessLinkActivityHandler.kt' backend | while IFS= read -r file; do
  echo "== $file"
  sed -n '1,45p' "$file"
done

Repository: valtimo-platform/valtimo

Length of output: 2288


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="backend/form-flow/src/main/kotlin/com/ritense/formflow/FormFlowProcessLinkActivityHandler.kt"

echo "== file outline =="
ast-grep outline "$file" --match FormFlowProcessLinkActivityHandler --view expanded || true

echo "== relevant usages and class signature =="
sed -n '1,150p' "$file"

echo "== definitions/usages of FORM_FLOW_TASK_TYPE_KEY, PROCESS_DEFINITION_KEY, DOCUMENT_ID, DOCUMENT_DEFINITION_NAME =="
rg -n "FORM_FLOW_TASK_TYPE_KEY|PROCESS_DEFINITION_KEY|DOCUMENT_ID|DOCUMENT_DEFINITION_NAME" backend/form-flow -S

Repository: valtimo-platform/valtimo

Length of output: 9804


Use the qualified constants from the superclass.

AbstractFormFlowLinkTaskProvider defines FORM_FLOW_TASK_TYPE_KEY, PROCESS_DEFINITION_KEY, DOCUMENT_ID, and DOCUMENT_DEFINITION_NAME, but Kotlin does not import superclass companion members; call them through AbstractFormFlowLinkTaskProvider.* or add local imports/aliases.


ProcessLinkActivityResult(
processLink.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,13 @@ import com.ritense.formflow.repository.FormFlowStepRepository
import com.ritense.formflow.repository.MySqlFormFlowAdditionalPropertiesSearchRepository
import com.ritense.formflow.repository.PostgresFormFlowAdditionalPropertiesSearchRepository
import com.ritense.formflow.security.ValtimoFormFlowHttpSecurityConfigurer
import com.ritense.formflow.service.FormFlowRegistryService
import com.ritense.formflow.service.FormFlowService
import com.ritense.formflow.service.FormFlowSupportedProcessLinksHandler
import com.ritense.formflow.service.FormFlowValtimoService
import com.ritense.formflow.service.ObjectMapperConfigurer
import com.ritense.formflow.web.rest.FormFlowManagementResource
import com.ritense.formflow.web.rest.FormFlowRegistryResource
import com.ritense.formflow.web.rest.FormFlowResource
import com.ritense.formflow.web.rest.ProcessLinkFormFlowDefinitionResource
import com.ritense.outbox.OutboxService
Expand Down Expand Up @@ -213,6 +215,28 @@ class FormFlowAutoConfiguration {
)
}

@Bean
@ConditionalOnMissingBean(FormFlowRegistryService::class)
fun formFlowRegistryService(
formFlowStepTypeHandlers: List<FormFlowStepTypeHandler>,
stepPropertiesTypes: Collection<NamedType>,
applicationContext: ApplicationContext,
): FormFlowRegistryService {
return FormFlowRegistryService(
formFlowStepTypeHandlers,
stepPropertiesTypes,
applicationContext,
)
}

@Bean
@ConditionalOnMissingBean(FormFlowRegistryResource::class)
fun formFlowRegistryResource(
formFlowRegistryService: FormFlowRegistryService,
): FormFlowRegistryResource {
return FormFlowRegistryResource(formFlowRegistryService)
}

@Bean
@Order(270)
@ConditionalOnMissingBean(ValtimoFormFlowHttpSecurityConfigurer::class)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class ValtimoFormFlowHttpSecurityConfigurer : HttpSecurityConfigurer {
.requestMatchers(antMatcher(POST, "/api/v1/form-flow/instance/{formFlowId}/step/instance/{stepInstanceId}/to/step/instance/{targetStepInstanceId}")).authenticated()
.requestMatchers(antMatcher(GET, "/api/v1/form-flow/instance/{formFlowId}/breadcrumbs")).authenticated()
.requestMatchers(antMatcher(GET, "/api/management/v1/form-flow-definition/schema")).hasAuthority(ADMIN)
.requestMatchers(antMatcher(GET, "/api/management/v1/form-flow/registry")).hasAuthority(ADMIN)
.requestMatchers(antMatcher(GET, "/api/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form-flow-definition/process-link-option")).hasAuthority(ADMIN)
.requestMatchers(antMatcher(GET, "/api/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form-flow-definition")).hasAuthority(ADMIN)
.requestMatchers(antMatcher(GET, "/api/management/v1/case-definition/{caseDefinitionKey}/version/{versionTag}/form-flow-definition/{definitionKey}")).hasAuthority(ADMIN)
Expand Down

@marijnritense marijnritense Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just adding it as a comment here so you can respond. I don't think these are all things that need to happen, they're just things I'm noticing. I'll test more tomorrow.

  1. When creating the form flow, I get an error the start step does not yet exist. Since every form flow needs one, it makes sense to create the form flow with the first step.
  2. When the start step has been defined, I do not see an error (apart from a small ! at step-1, which also seems a bit blurry). Since I cannot click save and do not see any error, I don't know what is wrong.
Image
  1. When switching to the JSON editor, I would expect the step I just added (but couldn't save) to just show up correctly there, but with some fields not yet filled in.
  2. Can we also have validation for the JSON?
  3. Can we validate if any steps are unreachable?
  4. And if there's loops that can't be broken out of?
  5. I already mentioned this somewhere else as well, but it would be nice if we can split the expressions (for actions) into 1) selecting the bean, and then 2) selecting the method, and 3) filling in the arguments.
  6. As an enhancement, I think it would be nice to be able to visualize the flow.
  7. Expressions in conditions would also benefit from additional help. Knowing what "basic" fields are present, maybe based on the forms added we can show a full list of fields available for each step even. This obviously has moments where it can break, so it's important to then also add feedback about that.

Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/*
* Copyright 2015-2026 Ritense BV, the Netherlands.
*
* Licensed under EUPL, Version 1.2 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* 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.
*/

package com.ritense.formflow.service

import com.fasterxml.jackson.databind.jsontype.NamedType
import com.ritense.formflow.AbstractFormFlowLinkTaskProvider
import com.ritense.formflow.domain.definition.configuration.step.StepTypeProperties
import com.ritense.formflow.expression.FormFlowBean
import com.ritense.formflow.handler.FormFlowStepTypeHandler
import com.ritense.formflow.web.rest.dto.FormFlowAdditionalPropertyDto
import com.ritense.formflow.web.rest.dto.FormFlowExpressionBeanDto
import com.ritense.formflow.web.rest.dto.FormFlowExpressionMethodDto
import com.ritense.formflow.web.rest.dto.FormFlowExpressionParameterDto
import com.ritense.formflow.web.rest.dto.FormFlowRegistryDto
import com.ritense.formflow.web.rest.dto.FormFlowStepTypeDto
import com.ritense.formflow.web.rest.dto.FormFlowStepTypePropertyDto
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.ApplicationContext
import org.springframework.context.event.EventListener
import org.springframework.core.DefaultParameterNameDiscoverer
import org.springframework.util.ClassUtils
import java.lang.reflect.Method
import java.lang.reflect.Modifier

class FormFlowRegistryService(
private val stepTypeHandlers: List<FormFlowStepTypeHandler>,
private val stepPropertiesTypes: Collection<NamedType>,
private val applicationContext: ApplicationContext,
) {
private val parameterNameDiscoverer = DefaultParameterNameDiscoverer()
private val cacheLock = Any()

@Volatile
private var cachedRegistry: FormFlowRegistryDto? = null

/**
* Builds the registry once during startup, so requests never pay the discovery cost. All
* expression beans exist by the time [ApplicationReadyEvent] fires, matching when the SpEL
* expression beans are collected for form flow execution.
*/
@EventListener(ApplicationReadyEvent::class)
fun warmUp() {
getRegistry()
}

/**
* Returns the registry that describes what can be used in a form flow definition.
*
* Step types and expression beans are determined by the application classpath and Spring
* configuration, so they do not change while the application is running. The result is
* therefore built once and reused for subsequent calls.
*/
fun getRegistry(): FormFlowRegistryDto {
cachedRegistry?.let { return it }

return synchronized(cacheLock) {
cachedRegistry ?: createRegistry().also {
cachedRegistry = it
}
}
}

private fun createRegistry(): FormFlowRegistryDto {
return FormFlowRegistryDto(
stepTypes = createStepTypes(),
expressionBeans = createExpressionBeans(),
additionalProperties = ADDITIONAL_PROPERTIES,
)
}

private fun createStepTypes(): List<FormFlowStepTypeDto> {
val propertiesByTypeName = stepPropertiesTypes
.filter { StepTypeProperties::class.java.isAssignableFrom(it.type) }
.associate { it.name to it.type }

return stepTypeHandlers
.map { it.getType() }
.distinct()
.sorted()
.map { typeName ->
FormFlowStepTypeDto(
name = typeName,
properties = propertiesByTypeName[typeName]
?.let(::extractStepTypeProperties)
?: emptyList(),
)
}
}

private fun extractStepTypeProperties(clazz: Class<*>): List<FormFlowStepTypePropertyDto> {
return clazz.declaredFields
.filterNot { it.isSynthetic || Modifier.isStatic(it.modifiers) }
.map {
FormFlowStepTypePropertyDto(
name = it.name,
type = it.type.simpleName,
)
}
}

private fun createExpressionBeans(): List<FormFlowExpressionBeanDto> {
return applicationContext.getBeansWithAnnotation(FormFlowBean::class.java)
.map { (beanName, bean) ->
FormFlowExpressionBeanDto(
name = beanName,
methods = extractMethods(ClassUtils.getUserClass(bean)),
)
}
.sortedBy(FormFlowExpressionBeanDto::name)
}

private fun extractMethods(clazz: Class<*>): List<FormFlowExpressionMethodDto> {
return clazz.methods
.filterNot { it.isSynthetic || it.isBridge }
.filterNot { it.declaringClass == Any::class.java }
.filterNot { Modifier.isStatic(it.modifiers) }
.map { method ->
FormFlowExpressionMethodDto(
name = method.name,
parameters = extractParameters(method),
returnType = method.returnType.simpleName,
)
}
.sortedWith(
compareBy(
{ it.name },
{ it.parameters.size },
)
)
}

private fun extractParameters(method: Method): List<FormFlowExpressionParameterDto> {
val parameterNames = parameterNameDiscoverer.getParameterNames(method)

return method.parameters.mapIndexed { index, parameter ->
FormFlowExpressionParameterDto(
name = parameterNames?.getOrNull(index) ?: parameter.name,
type = parameter.type.simpleName,
)
}
}

companion object {
private const val CONTEXT_USER_TASK = "userTask"
private const val CONTEXT_START_EVENT = "startEvent"

/**
* The `additionalProperties` entries that form flow instances receive, as populated by
* [AbstractFormFlowLinkTaskProvider] and its process link activity handler.
*/
private val ADDITIONAL_PROPERTIES = listOf(
FormFlowAdditionalPropertyDto(
name = AbstractFormFlowLinkTaskProvider.PROCESS_INSTANCE_ID,
context = CONTEXT_USER_TASK,
alwaysPresent = true,
),
FormFlowAdditionalPropertyDto(
name = AbstractFormFlowLinkTaskProvider.PROCESS_INSTANCE_BUSINESS_KEY,
context = CONTEXT_USER_TASK,
alwaysPresent = true,
),
FormFlowAdditionalPropertyDto(
name = AbstractFormFlowLinkTaskProvider.TASK_INSTANCE_ID,
context = CONTEXT_USER_TASK,
alwaysPresent = true,
),
FormFlowAdditionalPropertyDto(
name = AbstractFormFlowLinkTaskProvider.DOCUMENT_ID,
context = CONTEXT_USER_TASK,
alwaysPresent = false,
),
FormFlowAdditionalPropertyDto(
name = AbstractFormFlowLinkTaskProvider.PROCESS_DEFINITION_KEY,
context = CONTEXT_START_EVENT,
alwaysPresent = true,
),
FormFlowAdditionalPropertyDto(
name = AbstractFormFlowLinkTaskProvider.DOCUMENT_ID,
context = CONTEXT_START_EVENT,
alwaysPresent = false,
),
FormFlowAdditionalPropertyDto(
name = AbstractFormFlowLinkTaskProvider.DOCUMENT_DEFINITION_NAME,
context = CONTEXT_START_EVENT,
alwaysPresent = false,
),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright 2015-2026 Ritense BV, the Netherlands.
*
* Licensed under EUPL, Version 1.2 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* 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.
*/

package com.ritense.formflow.web.rest

import com.ritense.formflow.service.FormFlowRegistryService
import com.ritense.formflow.web.rest.dto.FormFlowRegistryDto
import com.ritense.valtimo.contract.annotation.SkipComponentScan
import com.ritense.valtimo.contract.domain.ValtimoMediaType.APPLICATION_JSON_UTF8_VALUE
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@SkipComponentScan
@RequestMapping("/api/management", produces = [APPLICATION_JSON_UTF8_VALUE])
class FormFlowRegistryResource(
private val formFlowRegistryService: FormFlowRegistryService,
) {
@GetMapping("/v1/form-flow/registry")
fun getRegistry(): ResponseEntity<FormFlowRegistryDto> {
return ResponseEntity.ok(formFlowRegistryService.getRegistry())
}
}
Loading
Loading