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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import com.ritense.case_.repository.CaseExternalPluginTabRepository
import com.ritense.case_.repository.CaseHeaderWidgetRepository
import com.ritense.case_.repository.CaseWidgetTabRepository
import com.ritense.case_.repository.CaseWidgetTabWidgetSpecificationFactory
import com.ritense.case_.repository.ExternalPluginCaseWidgetRepository
import com.ritense.case_.rest.CaseExternalPluginTabResource
import com.ritense.case_.rest.CaseHeaderWidgetManagementResource
import com.ritense.case_.rest.CaseHeaderWidgetResource
Expand All @@ -37,13 +38,15 @@ import com.ritense.case_.rest.MetrolineManagementResource
import com.ritense.case_.rest.dto.CaseWidgetTabWidgetDto
import com.ritense.case_.service.ActiveCaseDefinitionService
import com.ritense.case_.service.CaseExternalPluginTabService
import com.ritense.case_.service.CaseExternalPluginWidgetService
import com.ritense.case_.service.CaseHeaderWidgetExporter
import com.ritense.case_.service.CaseHeaderWidgetImporter
import com.ritense.case_.service.CaseHeaderWidgetService
import com.ritense.case_.service.CaseWidgetService
import com.ritense.case_.service.CaseWidgetTabExporter
import com.ritense.case_.service.CaseWidgetTabImporter
import com.ritense.case_.service.ExternalPluginCaseTabResolver
import com.ritense.case_.service.ExternalPluginCaseWidgetResolver
import com.ritense.case_.widget.CaseWidgetAnnotatedClassResolver
import com.ritense.case_.widget.CaseWidgetDataProvider
import com.ritense.case_.widget.CaseWidgetJacksonModule
Expand All @@ -53,6 +56,8 @@ import com.ritense.case_.widget.collection.CollectionCaseWidgetMapper
import com.ritense.case_.widget.custom.CustomCaseWidgetDataProvider
import com.ritense.case_.widget.custom.CustomCaseWidgetMapper
import com.ritense.case_.widget.divider.DividerCaseWidgetMapper
import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDataProvider
import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetMapper
import com.ritense.case_.widget.fields.FieldsCaseWidgetDataProvider
import com.ritense.case_.widget.fields.FieldsCaseWidgetMapper
import com.ritense.case_.widget.fieldsheader.FieldsCaseHeaderWidgetDataProvider
Expand All @@ -76,6 +81,7 @@ import com.ritense.document.service.DocumentService
import com.ritense.document.service.InternalCaseStatusService
import com.ritense.valtimo.contract.case_.CaseDefinitionChecker
import com.ritense.valtimo.contract.database.QueryDialectHelper
import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver
import com.ritense.valueresolver.ValueResolverService
import com.ritense.widget.map.geojson.GeoJsonMapper
import com.ritense.widget.map.geojson.Wgs84FeatureNormalizer
Expand Down Expand Up @@ -133,8 +139,9 @@ class CaseWidgetAutoConfiguration {
fun caseWidgetTabExporter(
objectMapper: ObjectMapper,
caseTabService: CaseTabService,
caseWidgetService: CaseWidgetService
) = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService)
caseWidgetService: CaseWidgetService,
externalPluginCaseWidgetResolver: Optional<ExternalPluginCaseWidgetResolver>,
) = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService, externalPluginCaseWidgetResolver)

@Bean
@ConditionalOnMissingBean(CaseWidgetTabImporter::class)
Expand All @@ -143,11 +150,13 @@ class CaseWidgetAutoConfiguration {
validator: Validator,
caseWidgetTabRepository: CaseWidgetTabRepository,
caseWidgetMappers: List<CaseWidgetMapper<*, *>>,
pluginConfigurationMappingResolvers: List<PluginConfigurationMappingResolver>,
) = CaseWidgetTabImporter(
objectMapper,
validator,
caseWidgetTabRepository,
caseWidgetMappers as List<CaseWidgetMapper<CaseWidgetTabWidget, CaseWidgetTabWidgetDto>>
caseWidgetMappers as List<CaseWidgetMapper<CaseWidgetTabWidget, CaseWidgetTabWidgetDto>>,
pluginConfigurationMappingResolvers,
)

@Bean
Expand Down Expand Up @@ -197,6 +206,18 @@ class CaseWidgetAutoConfiguration {
caseExternalPluginTabService: CaseExternalPluginTabService
) = CaseExternalPluginTabResource(caseExternalPluginTabService)

@ConditionalOnMissingBean(CaseExternalPluginWidgetService::class)
@Bean
fun caseExternalPluginWidgetService(
externalPluginCaseWidgetRepository: ExternalPluginCaseWidgetRepository,
caseWidgetTabRepository: CaseWidgetTabRepository,
caseTabRepository: CaseTabRepository,
) = CaseExternalPluginWidgetService(
externalPluginCaseWidgetRepository,
caseWidgetTabRepository,
caseTabRepository,
)

@ConditionalOnMissingBean(CaseWidgetTabManagementResource::class)
@Bean
fun caseWidgetTabManagementResource(
Expand Down Expand Up @@ -258,6 +279,16 @@ class CaseWidgetAutoConfiguration {
valueResolverService: ValueResolverService,
) = CustomCaseWidgetDataProvider(valueResolverService)

@ConditionalOnMissingBean(ExternalPluginCaseWidgetMapper::class)
@Bean
fun externalPluginCaseWidgetMapper() = ExternalPluginCaseWidgetMapper()

@ConditionalOnMissingBean(ExternalPluginCaseWidgetDataProvider::class)
@Bean
fun externalPluginCaseWidgetDataProvider(
externalPluginCaseWidgetResolver: Optional<ExternalPluginCaseWidgetResolver>,
) = ExternalPluginCaseWidgetDataProvider(externalPluginCaseWidgetResolver)

@ConditionalOnMissingBean(HighlightCaseWidgetMapper::class)
@Bean
fun highlightCaseWidgetMapper() = HighlightCaseWidgetMapper()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* 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.case_.repository

import com.ritense.case_.domain.tab.CaseWidgetTabWidgetId
import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidget
import org.springframework.data.jpa.repository.JpaRepository
import java.util.UUID

/**
* Subtype-typed repository over the single-table-inheritance widget table. Hibernate adds the
* `case_widget_type = 'external-plugin'` discriminator filter automatically, so these queries only
* ever return external-plugin widgets. Backs the delete guard and dangling-repair panel, which need
* to find/rewrite widgets by their (queryable) configuration id.
*/
interface ExternalPluginCaseWidgetRepository :
JpaRepository<ExternalPluginCaseWidget, CaseWidgetTabWidgetId> {

fun findAllByExternalPluginConfigurationId(externalPluginConfigurationId: UUID): List<ExternalPluginCaseWidget>

fun existsByExternalPluginConfigurationId(externalPluginConfigurationId: UUID): Boolean
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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.case_.rest.dto

import java.util.UUID

/**
* Descriptor for an `external-plugin` case widget, returned from the existing widget-data endpoint
* (`GET .../widget-tab/{tabKey}/widget/{widgetKey}`). The frontend wrapper uses [bundleUrl] to
* render the plugin iframe and [context] to seed the bundle. The iframe never receives a token — the
* wrapper mints a downscoped user token separately. Mirrors [ExternalPluginTabContentDto].
*/
data class ExternalPluginWidgetContentDto(
val bundleUrl: String?,
val configurationId: UUID?,
val bundleKey: String?,
val context: ExternalPluginWidgetContext,
)

data class ExternalPluginWidgetContext(
val documentId: String,
val caseDefinitionKey: String,
val caseDefinitionVersionTag: String,
val pluginConfigurationId: String?,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/*
* 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.case_.service

import com.ritense.case.domain.CaseTab
import com.ritense.case.repository.CaseTabRepository
import com.ritense.case_.domain.tab.CaseWidgetTab
import com.ritense.case_.repository.CaseWidgetTabRepository
import com.ritense.case_.repository.ExternalPluginCaseWidgetRepository
import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidget
import com.ritense.valtimo.contract.annotation.SkipComponentScan
import com.ritense.valtimo.contract.case_.CaseDefinitionId
import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID

/**
* Query/mutation surface for `external-plugin` case widgets, consumed by the external-plugin module
* (which compile-depends on `case`). Mirrors [CaseExternalPluginTabService] for the widget surface:
* it lets the external-plugin dangling-repair resolver find/remap widgets by configuration id, and
* lets the delete guard find the widgets that reference a configuration.
*
* The case module has no plugin knowledge, so it never decides which widgets are *dangling* — it
* exposes every external-plugin widget (with its configuration id + plugin identity) and lets the
* external-plugin resolver filter on configuration existence.
*/
@Service
@SkipComponentScan
@Transactional
class CaseExternalPluginWidgetService(
private val externalPluginCaseWidgetRepository: ExternalPluginCaseWidgetRepository,
private val caseWidgetTabRepository: CaseWidgetTabRepository,
private val caseTabRepository: CaseTabRepository,
) {

/**
* All external-plugin widgets of a case definition, each with the configuration it references
* and the design-time plugin identity carried from a self-describing import.
*/
@Transactional(readOnly = true)
fun findExternalPluginWidgets(caseDefinitionId: CaseDefinitionId): List<CaseExternalPluginWidgetRef> =
widgetTabsFor(caseDefinitionId).flatMap { tab ->
tab.widgets.filterIsInstance<ExternalPluginCaseWidget>().map { widget ->
CaseExternalPluginWidgetRef(
caseDefinitionId = caseDefinitionId,
tabKey = tab.id.key,
widgetKey = widget.id.key,
configurationId = widget.externalPluginConfigurationId,
pluginDefinitionKey = widget.pluginDefinitionKey,
pluginDefinitionVersion = widget.pluginDefinitionVersion,
)
}
}

/**
* Re-points external-plugin widgets of a case definition whose current configuration id is a key
* in [mappings] to the mapped target id. Idempotent for widgets already resolved.
*/
@Transactional
fun remapConfiguration(caseDefinitionId: CaseDefinitionId, mappings: Map<UUID, UUID>) {
if (mappings.isEmpty()) return
widgetTabsFor(caseDefinitionId)
.flatMap { it.widgets.filterIsInstance<ExternalPluginCaseWidget>() }
.forEach { widget ->
val currentId = widget.externalPluginConfigurationId ?: return@forEach
val mappedId = mappings[currentId] ?: return@forEach
externalPluginCaseWidgetRepository.save(widget.withExternalPluginConfigurationId(mappedId))
}
}

/**
* Lists external-plugin widgets that reference a given configuration, across every case
* definition. Used by the external-plugin delete guard so a configuration backing a live widget
* cannot be deleted.
*/
@Transactional(readOnly = true)
fun findUsagesForConfiguration(configurationId: UUID): List<CaseExternalPluginWidgetUsage> =
externalPluginCaseWidgetRepository.findAllByExternalPluginConfigurationId(configurationId)
.mapNotNull { widget ->
val tabId = widget.id.caseWidgetTab?.id ?: return@mapNotNull null
val tab: CaseTab? = caseTabRepository.findByIdOrNull(tabId)
CaseExternalPluginWidgetUsage(
configurationId = configurationId,
caseDefinitionKey = tabId.caseDefinitionId.key,
caseDefinitionVersionTag = tabId.caseDefinitionId.versionTag.toString(),
tabKey = tabId.key,
tabName = tab?.name,
widgetKey = widget.id.key,
)
}

private fun widgetTabsFor(caseDefinitionId: CaseDefinitionId): List<CaseWidgetTab> =
caseWidgetTabRepository.findAll(byCaseDefinitionId(caseDefinitionId))

private fun byCaseDefinitionId(caseDefinitionId: CaseDefinitionId) =
Specification<CaseWidgetTab> { root, _, cb ->
cb.equal(root.get<Any>("id").get<Any>("caseDefinitionId"), caseDefinitionId)
}
}

/**
* One external-plugin widget of a case definition: the configuration it references (`null` when it
* imported dangling) plus the design-time plugin identity that keeps it identifiable in the repair
* panel. Consumed by the external-plugin dangling-repair resolver.
Comment on lines +117 to +120

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 | 🟡 Minor | ⚡ Quick win

Correct the dangling-ID description.

An imported dangling widget retains its original configuration UUID. CaseWidgetTabImporter.remapExternalPluginWidgets preserves an unmapped ID so the repair flow can map it later. State that configurationId is null only when no ID is configured.

*/
data class CaseExternalPluginWidgetRef(
val caseDefinitionId: CaseDefinitionId,
val tabKey: String,
val widgetKey: String,
val configurationId: UUID?,
val pluginDefinitionKey: String?,
val pluginDefinitionVersion: String?,
)

/**
* One external-plugin widget that references a configuration. Mapped to a `PluginUsageDto` by the
* external-plugin delete guard.
*/
data class CaseExternalPluginWidgetUsage(
val configurationId: UUID,
val caseDefinitionKey: String,
val caseDefinitionVersionTag: String,
val tabKey: String,
val tabName: String?,
val widgetKey: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,22 @@ package com.ritense.case_.service
import com.fasterxml.jackson.databind.ObjectMapper
import com.ritense.case.domain.CaseTabType
import com.ritense.case.service.CaseTabService
import com.ritense.case_.rest.dto.CaseWidgetTabDto
import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDto
import com.ritense.exporter.ExportFile
import com.ritense.exporter.ExportPrettyPrinter
import com.ritense.exporter.ExportResult
import com.ritense.exporter.Exporter
import com.ritense.exporter.request.DocumentDefinitionExportRequest
import org.springframework.transaction.annotation.Transactional
import java.util.Optional

@Transactional(readOnly = true)
class CaseWidgetTabExporter(
private val objectMapper: ObjectMapper,
private val caseTabService: CaseTabService,
private val caseWidgetService: CaseWidgetService
private val caseWidgetService: CaseWidgetService,
private val externalPluginCaseWidgetResolver: Optional<ExternalPluginCaseWidgetResolver> = Optional.empty(),
) : Exporter<DocumentDefinitionExportRequest> {

override fun supports() = DocumentDefinitionExportRequest::class.java
Expand All @@ -53,6 +57,7 @@ class CaseWidgetTabExporter(
caseTabs
.filter { it.type == CaseTabType.WIDGETS }
.map { caseWidgetService.getWidgetTab(it.id.caseDefinitionId, it.id.key)!! }
.map(::enrichExternalPluginWidgets)
)
)

Expand All @@ -61,6 +66,39 @@ class CaseWidgetTabExporter(
)
}

/**
* Stamps each `external-plugin` widget with its plugin definition (`pluginId`/version) so the
* export is self-describing — the widget stores only the configuration id, so the import preview
* can then identify the plugin even when the referenced configuration was deleted in the target.
* Mirrors `CaseTabExporter.toExportDto`. Widgets whose configuration can no longer be resolved
* (or when external-plugin isn't on the classpath) export unchanged, so unaffected case
* definitions round-trip byte-for-byte.
*/
private fun enrichExternalPluginWidgets(tab: CaseWidgetTabDto): CaseWidgetTabDto {
val resolver = externalPluginCaseWidgetResolver.orElse(null) ?: return tab
if (tab.widgets.none { it is ExternalPluginCaseWidgetDto }) return tab
return tab.copy(
widgets = tab.widgets.map { widget ->
if (widget !is ExternalPluginCaseWidgetDto) {
widget
} else {
val configurationId = widget.properties.configurationId
val definition = configurationId?.let { resolver.resolvePluginDefinition(it) }
if (definition == null) {
widget
} else {
widget.copy(
properties = widget.properties.copy(
pluginDefinitionKey = definition.pluginDefinitionKey,
pluginDefinitionVersion = definition.pluginDefinitionVersion,
)
)
}
}
}
)
}

companion object {
private const val PATH = "config/case/%s/%s/case/widget-tab/%s.case-widget-tab.json"
}
Expand Down
Loading
Loading