From 6253bb0ae22f6805ef73e6804aff2aff1e87ce70 Mon Sep 17 00:00:00 2001 From: Ivo Zaal Date: Fri, 31 Jul 2026 15:18:02 +0200 Subject: [PATCH 1/2] Added case widget type for external plugin --- .../CaseWidgetAutoConfiguration.kt | 37 +++- .../ExternalPluginCaseWidgetRepository.kt | 36 ++++ .../dto/ExternalPluginWidgetContentDto.kt | 39 ++++ .../CaseExternalPluginWidgetService.kt | 142 +++++++++++++ .../case_/service/CaseWidgetTabExporter.kt | 40 +++- .../case_/service/CaseWidgetTabImporter.kt | 70 ++++++- .../ExternalPluginCaseWidgetResolver.kt | 51 +++++ .../ExternalPluginCaseWidget.kt | 104 +++++++++ .../ExternalPluginCaseWidgetDataProvider.kt | 69 ++++++ .../ExternalPluginCaseWidgetDto.kt | 53 +++++ .../ExternalPluginCaseWidgetMapper.kt | 64 ++++++ .../ExternalPluginWidgetProperties.kt | 46 ++++ .../service/CaseWidgetTabExporterTest.kt | 160 ++++++++++++++ ...CaseWidgetTabImporterExternalPluginTest.kt | 144 +++++++++++++ ...xternalPluginCaseWidgetDataProviderTest.kt | 109 ++++++++++ .../ExternalPluginCaseWidgetMapperTest.kt | 145 +++++++++++++ .../liquibase/13-32-0/13-32-0-master.xml | 1 + ...260731-add-external-plugin-case-widget.xml | 43 ++++ .../ExternalPluginAutoConfiguration.kt | 13 ++ .../ExternalPluginImportPreviewContributor.kt | 55 +++++ .../ExternalPluginCaseWidgetResolverImpl.kt | 54 +++++ ...ernalPluginConfigurationMappingResolver.kt | 43 +++- .../ExternalPluginHostUsageResolver.kt | 32 +++ ...ernalPluginImportPreviewContributorTest.kt | 123 +++++++++++ ...xternalPluginCaseWidgetResolverImplTest.kt | 125 +++++++++++ ...lPluginConfigurationMappingResolverTest.kt | 75 +++++++ .../ExternalPluginHostUsageResolverTest.kt | 56 +++++ .../plugin/web/rest/dto/PluginUsageDto.kt | 4 + ...missing-plugin-configurations.component.ts | 2 + .../case-management-widget-tab.component.html | 2 +- .../case-management-widget-tab.component.ts | 49 ++++- .../src/lib/services/tab.service.ts | 43 ++++ ...case-widget-external-plugin.component.html | 48 +++++ ...case-widget-external-plugin.component.scss | 44 ++++ .../case-widget-external-plugin.component.ts | 129 ++++++++++++ .../tab/widgets/widgets.component.ts | 11 +- .../models/external-plugin-widget.model.ts | 38 ++++ .../valtimo/case/src/lib/models/index.ts | 1 + ...-management-external-plugin.component.html | 72 +++++++ ...-management-external-plugin.component.scss | 40 ++++ ...et-management-external-plugin.component.ts | 198 ++++++++++++++++++ .../management-content/index.ts | 1 + .../constants/external-plugin-widget-token.ts | 52 +++++ .../valtimo/layout/src/lib/constants/index.ts | 1 + .../src/lib/models/widget-content.model.ts | 7 + .../src/lib/models/widget-editor.model.ts | 1 + .../src/lib/models/widget-wizard.model.ts | 10 +- .../layout/src/lib/models/widget.model.ts | 28 ++- .../src/lib/services/widget-wizard.service.ts | 14 +- .../valtimo/shared/assets/core/en.json | 15 +- .../valtimo/shared/assets/core/nl.json | 15 +- .../docs/external-plugin-system-plan.md | 91 +++++++- .../frontend/case-widget-metrics.html | 16 ++ .../frontend/case-widget-metrics.tsx | 129 ++++++++++++ .../case-summary/frontend/case-widget.html | 16 ++ .../case-summary/frontend/case-widget.tsx | 158 ++++++++++++++ .../sample-plugins/case-summary/manifest.json | 42 +++- 57 files changed, 3162 insertions(+), 44 deletions(-) create mode 100644 backend/case/src/main/kotlin/com/ritense/case_/repository/ExternalPluginCaseWidgetRepository.kt create mode 100644 backend/case/src/main/kotlin/com/ritense/case_/rest/dto/ExternalPluginWidgetContentDto.kt create mode 100644 backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginWidgetService.kt create mode 100644 backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseWidgetResolver.kt create mode 100644 backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt create mode 100644 backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProvider.kt create mode 100644 backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDto.kt create mode 100644 backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapper.kt create mode 100644 backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt create mode 100644 backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabExporterTest.kt create mode 100644 backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabImporterExternalPluginTest.kt create mode 100644 backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProviderTest.kt create mode 100644 backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapperTest.kt create mode 100644 backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml create mode 100644 backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImpl.kt create mode 100644 backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImplTest.kt create mode 100644 frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.html create mode 100644 frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss create mode 100644 frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts create mode 100644 frontend/projects/valtimo/case/src/lib/models/external-plugin-widget.model.ts create mode 100644 frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.html create mode 100644 frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.scss create mode 100644 frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.ts create mode 100644 frontend/projects/valtimo/layout/src/lib/constants/external-plugin-widget-token.ts create mode 100644 plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.html create mode 100644 plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.tsx create mode 100644 plugin-host/sample-plugins/case-summary/frontend/case-widget.html create mode 100644 plugin-host/sample-plugins/case-summary/frontend/case-widget.tsx diff --git a/backend/case/src/main/kotlin/com/ritense/case_/configuration/CaseWidgetAutoConfiguration.kt b/backend/case/src/main/kotlin/com/ritense/case_/configuration/CaseWidgetAutoConfiguration.kt index 330bfbbcc7..f5dba7f1ba 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/configuration/CaseWidgetAutoConfiguration.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/configuration/CaseWidgetAutoConfiguration.kt @@ -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 @@ -37,6 +38,7 @@ 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 @@ -44,6 +46,7 @@ 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 @@ -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 @@ -75,6 +80,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 @@ -132,8 +138,9 @@ class CaseWidgetAutoConfiguration { fun caseWidgetTabExporter( objectMapper: ObjectMapper, caseTabService: CaseTabService, - caseWidgetService: CaseWidgetService - ) = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService) + caseWidgetService: CaseWidgetService, + externalPluginCaseWidgetResolver: Optional, + ) = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService, externalPluginCaseWidgetResolver) @Bean @ConditionalOnMissingBean(CaseWidgetTabImporter::class) @@ -142,11 +149,13 @@ class CaseWidgetAutoConfiguration { validator: Validator, caseWidgetTabRepository: CaseWidgetTabRepository, caseWidgetMappers: List>, + pluginConfigurationMappingResolvers: List, ) = CaseWidgetTabImporter( objectMapper, validator, caseWidgetTabRepository, - caseWidgetMappers as List> + caseWidgetMappers as List>, + pluginConfigurationMappingResolvers, ) @Bean @@ -196,6 +205,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( @@ -257,6 +278,16 @@ class CaseWidgetAutoConfiguration { valueResolverService: ValueResolverService, ) = CustomCaseWidgetDataProvider(valueResolverService) + @ConditionalOnMissingBean(ExternalPluginCaseWidgetMapper::class) + @Bean + fun externalPluginCaseWidgetMapper() = ExternalPluginCaseWidgetMapper() + + @ConditionalOnMissingBean(ExternalPluginCaseWidgetDataProvider::class) + @Bean + fun externalPluginCaseWidgetDataProvider( + externalPluginCaseWidgetResolver: Optional, + ) = ExternalPluginCaseWidgetDataProvider(externalPluginCaseWidgetResolver) + @ConditionalOnMissingBean(HighlightCaseWidgetMapper::class) @Bean fun highlightCaseWidgetMapper() = HighlightCaseWidgetMapper() diff --git a/backend/case/src/main/kotlin/com/ritense/case_/repository/ExternalPluginCaseWidgetRepository.kt b/backend/case/src/main/kotlin/com/ritense/case_/repository/ExternalPluginCaseWidgetRepository.kt new file mode 100644 index 0000000000..d431b78d2a --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/repository/ExternalPluginCaseWidgetRepository.kt @@ -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 { + + fun findAllByExternalPluginConfigurationId(externalPluginConfigurationId: UUID): List + + fun existsByExternalPluginConfigurationId(externalPluginConfigurationId: UUID): Boolean +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/rest/dto/ExternalPluginWidgetContentDto.kt b/backend/case/src/main/kotlin/com/ritense/case_/rest/dto/ExternalPluginWidgetContentDto.kt new file mode 100644 index 0000000000..83538adb09 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/rest/dto/ExternalPluginWidgetContentDto.kt @@ -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?, +) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginWidgetService.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginWidgetService.kt new file mode 100644 index 0000000000..b5c3cfa7b2 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseExternalPluginWidgetService.kt @@ -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 = + widgetTabsFor(caseDefinitionId).flatMap { tab -> + tab.widgets.filterIsInstance().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) { + if (mappings.isEmpty()) return + widgetTabsFor(caseDefinitionId) + .flatMap { it.widgets.filterIsInstance() } + .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 = + 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 = + caseWidgetTabRepository.findAll(byCaseDefinitionId(caseDefinitionId)) + + private fun byCaseDefinitionId(caseDefinitionId: CaseDefinitionId) = + Specification { root, _, cb -> + cb.equal(root.get("id").get("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. + */ +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, +) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabExporter.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabExporter.kt index 4f6f4b9f03..0beb3948e0 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabExporter.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabExporter.kt @@ -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 = Optional.empty(), ) : Exporter { override fun supports() = DocumentDefinitionExportRequest::class.java @@ -53,6 +57,7 @@ class CaseWidgetTabExporter( caseTabs .filter { it.type == CaseTabType.WIDGETS } .map { caseWidgetService.getWidgetTab(it.id.caseDefinitionId, it.id.key)!! } + .map(::enrichExternalPluginWidgets) ) ) @@ -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" } diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabImporter.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabImporter.kt index 3c1c98f86c..cd0c325958 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabImporter.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/CaseWidgetTabImporter.kt @@ -25,6 +25,7 @@ import com.ritense.case_.repository.CaseWidgetTabRepository import com.ritense.case_.rest.dto.CaseWidgetTabDto import com.ritense.case_.rest.dto.CaseWidgetTabWidgetDto import com.ritense.case_.widget.CaseWidgetMapper +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDto import com.ritense.importer.ImportRequest import com.ritense.importer.Importer import com.ritense.importer.ValtimoImportTypes.Companion.CASE_TAB @@ -32,14 +33,17 @@ import com.ritense.importer.ValtimoImportTypes.Companion.CASE_WIDGET_TAB import com.ritense.importer.ValtimoImportTypes.Companion.DOCUMENT_DEFINITION import com.ritense.importer.ValtimoImportTypes.Companion.FORM import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver import com.ritense.valtimo.contract.validation.check import jakarta.validation.Validator +import java.util.UUID class CaseWidgetTabImporter( private val objectMapper: ObjectMapper, private val validator: Validator, private val caseWidgetTabRepository: CaseWidgetTabRepository, private val caseWidgetMappers: List>, + private val pluginConfigurationMappingResolvers: List = emptyList(), ) : Importer { override fun type() = CASE_WIDGET_TAB @@ -48,20 +52,42 @@ class CaseWidgetTabImporter( override fun supports(fileName: String) = fileName.matches(FILENAME_REGEX) override fun import(request: ImportRequest) { - return deploy(request.content.toString(Charsets.UTF_8), request.caseDefinitionId!!) + return deploy( + request.content.toString(Charsets.UTF_8), + request.caseDefinitionId!!, + request.pluginConfigurationMappings, + ) } - fun deploy(fileContent: String, caseDefinitionId: CaseDefinitionId) { + /** + * A case widget is not a process link, so it gets no detection from the process-link importer. + * Trigger an in-transaction recheck here — for external-plugin widgets this is what raises the + * configuration issue when a widget references a plugin configuration missing in this environment + * (mirrors [CaseTabImporter.afterImport]). + */ + override fun afterImport(request: ImportRequest) { + val caseDefinitionId = request.caseDefinitionId ?: return + pluginConfigurationMappingResolvers.forEach { it.recheckIssuesForCaseDefinition(caseDefinitionId) } + } + + @JvmOverloads + fun deploy( + fileContent: String, + caseDefinitionId: CaseDefinitionId, + pluginConfigurationMappings: Map? = null, + ) { val tabs = try { objectMapper.readValue(fileContent, object : TypeReference>() {}) } catch (e: Exception) { throw IllegalArgumentException("Failed to parse file content as valid case widget tabs: ${e.message}", e) } - validator.check(tabs) - tabs.forEach { it.validate(caseDefinitionId) } + val remappedTabs = tabs.map { remapExternalPluginWidgets(it, pluginConfigurationMappings) } + + validator.check(remappedTabs) + remappedTabs.forEach { it.validate(caseDefinitionId) } - val toSave = tabs.map { tab -> + val toSave = remappedTabs.map { tab -> CaseWidgetTab( CaseTabId( caseDefinitionId = caseDefinitionId, @@ -79,7 +105,39 @@ class CaseWidgetTabImporter( caseWidgetTabRepository.saveAll(toSave) } + /** + * Applies the import wizard's plugin-configuration mapping to each `external-plugin` widget. A + * non-null mapping value re-points the widget at a target configuration; a `null` mapping value + * (admin left it unmapped) or a config id absent from the map leaves the original — now dangling — + * id in place, exactly like [CaseTabImporter.remapExternalPluginContentKey]. Keeping the original + * id (rather than nulling the column) is what lets the repair panel offer a mapping from that + * source id later, and keeps dangling detection consistent with the tab surface (config id set + * but not resolvable in this environment). The widget's design-time plugin identity is carried + * separately in `plugin_definition_key`/`version` from the self-describing export. + */ + private fun remapExternalPluginWidgets( + tab: CaseWidgetTabDto, + mappings: Map?, + ): CaseWidgetTabDto { + if (mappings.isNullOrEmpty()) 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 originalConfigId = widget.properties.configurationId + val mappedConfigId = originalConfigId?.let { mappings[it] } ?: return@map widget + widget.copy( + properties = widget.properties.copy(configurationId = mappedConfigId) + ) + } + } + ) + } + private companion object { val FILENAME_REGEX = """/case/widget-tab/([^/]+)\.case-widget-tab\.json""".toRegex() } -} \ No newline at end of file +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseWidgetResolver.kt b/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseWidgetResolver.kt new file mode 100644 index 0000000000..1243b12480 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/service/ExternalPluginCaseWidgetResolver.kt @@ -0,0 +1,51 @@ +/* + * 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 java.util.UUID + +/** + * SPI implemented by the external-plugin module to resolve the absolute bundle URL for a plugin's + * `case-widget` bundle. Declared here (in `case`) so the dependency stays one-directional + * (external-plugin → case, no cycle) while the widget-data endpoint can still hand the frontend a + * resolved `bundleUrl`. The sibling of [ExternalPluginCaseTabResolver] for the widget surface; + * reuses [ExternalPluginTabDefinition] for the design-time plugin identity. + * + * The case module consumes this as an `Optional` so it builds and runs without the external-plugin + * module on the classpath. + */ +interface ExternalPluginCaseWidgetResolver { + + /** + * Resolves the absolute URL of the plugin configuration's `case-widget` bundle, or `null` if the + * configuration/definition/bundle cannot be found. + * + * @param configurationId the external-plugin configuration backing the widget + * @param bundleKey the bundle key when the plugin ships more than one `case-widget` bundle; + * `null` selects the sole `case-widget` bundle + */ + fun resolveBundleUrl(configurationId: UUID, bundleKey: String?): String? + + /** + * Resolves the plugin definition (`pluginId` + version) backing the configuration, or `null` + * when the configuration/definition can no longer be found. Used at export time so a + * `case-widget` export is self-describing: the widget stores only the configuration id, so + * embedding the resolved definition lets the import preview identify the plugin even when the + * referenced configuration was deleted in the target environment. + */ + fun resolvePluginDefinition(configurationId: UUID): ExternalPluginTabDefinition? +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt new file mode 100644 index 0000000000..8e9ec94905 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt @@ -0,0 +1,104 @@ +/* + * 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_.widget.externalplugin + +import com.ritense.case_.domain.tab.CaseWidgetTabWidget +import com.ritense.case_.domain.tab.CaseWidgetTabWidgetId +import com.ritense.valtimo.contract.annotation.AllOpen +import com.ritense.valtimo.contract.conditions.Condition +import com.ritense.widget.domain.WidgetAction +import com.ritense.widget.domain.WidgetColor +import jakarta.persistence.Column +import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Entity +import java.util.UUID + +/** + * `external-plugin` case-widget subtype: a card in a WIDGETS tab's grid rendered as a sandboxed + * iframe of an external plugin's `case-widget` bundle. Unlike the `custom` widget (a JSON + * `properties` column) the external-plugin config maps to dedicated, queryable columns so the delete + * guard and dangling-repair panel can find widgets by configuration id portably across the + * Postgres/MySQL dual database support. + * + * [externalPluginConfigurationId] is `null` for a widget that imported dangling (its configuration + * missing in this environment); [pluginDefinitionKey]/[pluginDefinitionVersion] then keep it + * identifiable in the repair panel. + */ +@AllOpen +@Entity +@DiscriminatorValue("external-plugin") +class ExternalPluginCaseWidget( + id: CaseWidgetTabWidgetId, + title: String, + icon: String? = null, + color: WidgetColor = WidgetColor.WHITE, + order: Int, + width: Int, + highContrast: Boolean, + isCompact: Boolean?, + actions: List, + displayConditions: List>, + + @Column(name = "external_plugin_configuration_id") + val externalPluginConfigurationId: UUID?, + + @Column(name = "bundle_key") + val bundleKey: String?, + + @Column(name = "plugin_definition_key") + val pluginDefinitionKey: String? = null, + + @Column(name = "plugin_definition_version") + val pluginDefinitionVersion: String? = null, +) : CaseWidgetTabWidget( + id, title, icon, color, order, width, highContrast, isCompact, actions, displayConditions +) { + override fun copy(id: CaseWidgetTabWidgetId) = ExternalPluginCaseWidget( + id = id, + title = title, + icon = icon, + color = color, + order = order, + width = width, + highContrast = highContrast, + isCompact = isCompact, + actions = actions, + displayConditions = displayConditions, + externalPluginConfigurationId = externalPluginConfigurationId, + bundleKey = bundleKey, + pluginDefinitionKey = pluginDefinitionKey, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + + /** Same widget, re-pointed at another external-plugin configuration (used by dangling repair). */ + fun withExternalPluginConfigurationId(configurationId: UUID?) = ExternalPluginCaseWidget( + id = id, + title = title, + icon = icon, + color = color, + order = order, + width = width, + highContrast = highContrast, + isCompact = isCompact, + actions = actions, + displayConditions = displayConditions, + externalPluginConfigurationId = configurationId, + bundleKey = bundleKey, + pluginDefinitionKey = pluginDefinitionKey, + pluginDefinitionVersion = pluginDefinitionVersion, + ) +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProvider.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProvider.kt new file mode 100644 index 0000000000..5eb82d57db --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProvider.kt @@ -0,0 +1,69 @@ +/* + * 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_.widget.externalplugin + +import com.ritense.case_.rest.dto.ExternalPluginWidgetContentDto +import com.ritense.case_.rest.dto.ExternalPluginWidgetContext +import com.ritense.case_.service.ExternalPluginCaseWidgetResolver +import com.ritense.case_.widget.CaseWidgetDataProvider +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.springframework.data.domain.Pageable +import java.util.Optional +import java.util.UUID + +/** + * Returns the iframe descriptor (bundle URL + context) for an `external-plugin` case widget, served + * through the existing widget-data endpoint. Unlike a first-party widget's data provider it returns + * no document business data — only what the frontend needs to render the sandboxed plugin iframe. + * + * Resolving the bundle URL lives here rather than in the mapper's `toDto` because only the data + * provider has the [documentId] needed to build the context; it also reuses the endpoint's + * per-widget PBAC check. When the resolver is absent (external-plugin not on the classpath) or the + * configuration is dangling/unresolvable, [ExternalPluginWidgetContentDto.bundleUrl] is `null` and + * the frontend shows an unavailable state — matching the case tab. + */ +class ExternalPluginCaseWidgetDataProvider( + private val resolver: Optional, +) : CaseWidgetDataProvider { + + override fun supports(widget: Any): Boolean = widget is ExternalPluginCaseWidget + + override fun getData( + documentId: UUID, + widget: Any, + pageable: Pageable, + caseDefinitionId: CaseDefinitionId + ): Any { + widget as ExternalPluginCaseWidget + val configurationId = widget.externalPluginConfigurationId + val bundleUrl = configurationId?.let { + resolver.orElse(null)?.resolveBundleUrl(it, widget.bundleKey) + } + + return ExternalPluginWidgetContentDto( + bundleUrl = bundleUrl, + configurationId = configurationId, + bundleKey = widget.bundleKey, + context = ExternalPluginWidgetContext( + documentId = documentId.toString(), + caseDefinitionKey = caseDefinitionId.key, + caseDefinitionVersionTag = caseDefinitionId.versionTag.toString(), + pluginConfigurationId = configurationId?.toString(), + ), + ) + } +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDto.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDto.kt new file mode 100644 index 0000000000..e412d292d5 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDto.kt @@ -0,0 +1,53 @@ +/* + * 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_.widget.externalplugin + +import com.fasterxml.jackson.annotation.JsonTypeName +import com.ritense.case_.rest.dto.CaseWidgetTabWidgetDto +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.conditions.Condition +import com.ritense.widget.domain.WidgetAction +import com.ritense.widget.domain.WidgetColor +import jakarta.validation.Valid + +@JsonTypeName("external-plugin") +data class ExternalPluginCaseWidgetDto( + override val key: String, + override val title: String, + override val icon: String?, + override val color: WidgetColor? = null, + override val width: Int, + override val highContrast: Boolean, + override val isCompact: Boolean?, + override val actions: List? = emptyList(), + override val displayConditions: List> = emptyList(), + @field:Valid val properties: ExternalPluginWidgetProperties, +) : CaseWidgetTabWidgetDto { + + /** + * A configured widget must reference a plugin configuration. The bundle key is intentionally + * optional — it is `null` when the plugin ships a single, key-less `case-widget` bundle (the + * resolver then picks the sole bundle). Choosing among several bundles is enforced client-side. + * A widget always carries a (possibly now-dangling) configuration id: the importer keeps the + * original id when a mapping is left unset, so this never rejects a repairable import. + */ + override fun validate(caseDefinitionId: CaseDefinitionId) { + require(properties.configurationId != null) { + "External-plugin widget '$key' must reference a plugin configuration." + } + } +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapper.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapper.kt new file mode 100644 index 0000000000..33f5d4b4f1 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapper.kt @@ -0,0 +1,64 @@ +/* + * 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_.widget.externalplugin + +import com.ritense.case_.domain.tab.CaseWidgetTabWidgetId +import com.ritense.case_.widget.CaseWidgetMapper +import com.ritense.widget.domain.resolveWidgetColor +import java.util.Collections.emptyList + +/** + * Bridges the DTO's nested [ExternalPluginWidgetProperties] and the entity's dedicated columns. + */ +class ExternalPluginCaseWidgetMapper : + CaseWidgetMapper { + + override fun toDto(entity: ExternalPluginCaseWidget) = ExternalPluginCaseWidgetDto( + key = entity.id.key, + title = entity.title, + icon = entity.icon, + color = entity.color, + width = entity.width, + highContrast = entity.highContrast, + isCompact = entity.isCompact, + actions = entity.actions, + displayConditions = entity.displayConditions, + properties = ExternalPluginWidgetProperties( + configurationId = entity.externalPluginConfigurationId, + bundleKey = entity.bundleKey, + pluginDefinitionKey = entity.pluginDefinitionKey, + pluginDefinitionVersion = entity.pluginDefinitionVersion, + ) + ) + + override fun toEntity(dto: ExternalPluginCaseWidgetDto, index: Int) = ExternalPluginCaseWidget( + id = CaseWidgetTabWidgetId(dto.key), + title = dto.title, + icon = dto.icon, + color = resolveWidgetColor(dto.color, dto.highContrast), + width = dto.width, + highContrast = dto.highContrast, + isCompact = dto.isCompact, + actions = dto.actions ?: emptyList(), + displayConditions = dto.displayConditions, + order = index, + externalPluginConfigurationId = dto.properties.configurationId, + bundleKey = dto.properties.bundleKey, + pluginDefinitionKey = dto.properties.pluginDefinitionKey, + pluginDefinitionVersion = dto.properties.pluginDefinitionVersion, + ) +} diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt new file mode 100644 index 0000000000..1eb9cb2841 --- /dev/null +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt @@ -0,0 +1,46 @@ +/* + * 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_.widget.externalplugin + +import com.fasterxml.jackson.annotation.JsonInclude +import java.util.UUID + +/** + * DTO-side configuration of an `external-plugin` case widget. Nested under `properties` (like the + * `custom` widget) for frontend consistency; the mapper unpacks it into the dedicated, + * queryable columns on `case_widget_tab_widget`. + * + * [configurationId] is the external-plugin configuration backing the widget (`null` for a widget + * that imported dangling — its configuration missing in this environment). [bundleKey] selects the + * `case-widget` bundle when the plugin ships more than one; `null` selects the sole bundle. + * [pluginDefinitionKey]/[pluginDefinitionVersion] are design-time plugin identity stamped on export + * so the import preview can identify the plugin without resolving the configuration. + */ +data class ExternalPluginWidgetProperties( + val configurationId: UUID?, + val bundleKey: String?, + + /** + * Only populated by the exporter (self-describing export). Omitted from JSON when absent so a + * widget that was never exported — and every other environment's data — round-trips cleanly. + */ + @get:JsonInclude(JsonInclude.Include.NON_NULL) + val pluginDefinitionKey: String? = null, + + @get:JsonInclude(JsonInclude.Include.NON_NULL) + val pluginDefinitionVersion: String? = null, +) diff --git a/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabExporterTest.kt b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabExporterTest.kt new file mode 100644 index 0000000000..89acce251f --- /dev/null +++ b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabExporterTest.kt @@ -0,0 +1,160 @@ +/* + * 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.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.ritense.case.domain.CaseTab +import com.ritense.case.domain.CaseTabId +import com.ritense.case.domain.CaseTabType +import com.ritense.case.service.CaseTabService +import com.ritense.case_.rest.dto.CaseWidgetTabDto +import com.ritense.case_.widget.custom.CustomCaseWidgetDto +import com.ritense.case_.widget.custom.CustomWidgetProperties +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDto +import com.ritense.case_.widget.externalplugin.ExternalPluginWidgetProperties +import com.ritense.exporter.request.DocumentDefinitionExportRequest +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +class CaseWidgetTabExporterTest { + + private val objectMapper = jacksonObjectMapper() + private val caseTabService = mock() + private val caseWidgetService = mock() + private val resolver = mock() + + private val caseDefinitionId = CaseDefinitionId("my-case", "1.0.0") + + @Test + fun `stamps the plugin id and version on external-plugin widgets in the export`() { + val configurationId = UUID.randomUUID() + val exporter = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService, Optional.of(resolver)) + + whenever(caseTabService.getCaseTabs(caseDefinitionId)).thenReturn(listOf(widgetsTab())) + whenever(caseWidgetService.getWidgetTab(caseDefinitionId, "widgets-tab")).thenReturn( + CaseWidgetTabDto( + caseDefinitionKey = "my-case", + caseDefinitionVersionTag = "1.0.0", + key = "widgets-tab", + widgets = listOf(externalPluginWidgetDto(configurationId), customWidgetDto()), + ) + ) + whenever(resolver.resolvePluginDefinition(configurationId)) + .thenReturn(ExternalPluginTabDefinition("case-summary", "0.1.0")) + + val result = exporter.export(DocumentDefinitionExportRequest("my-case", caseDefinitionId)) + + val exported = objectMapper.readTree(result.exportFiles.single().content) + val widgets = exported[0]["widgets"] + val externalWidget = widgets.single { it["type"].asText() == "external-plugin" } + assertThat(externalWidget["properties"]["configurationId"].asText()).isEqualTo(configurationId.toString()) + assertThat(externalWidget["properties"]["pluginDefinitionKey"].asText()).isEqualTo("case-summary") + assertThat(externalWidget["properties"]["pluginDefinitionVersion"].asText()).isEqualTo("0.1.0") + + // Non-external widgets are untouched — no plugin identity noise. + val customWidget = widgets.single { it["type"].asText() == "custom" } + assertThat(customWidget["properties"].has("pluginDefinitionKey")).isFalse() + } + + @Test + fun `leaves the widget unchanged when the resolver cannot resolve the plugin definition`() { + val configurationId = UUID.randomUUID() + val exporter = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService, Optional.of(resolver)) + + whenever(caseTabService.getCaseTabs(caseDefinitionId)).thenReturn(listOf(widgetsTab())) + whenever(caseWidgetService.getWidgetTab(caseDefinitionId, "widgets-tab")).thenReturn( + CaseWidgetTabDto( + caseDefinitionKey = "my-case", + caseDefinitionVersionTag = "1.0.0", + key = "widgets-tab", + widgets = listOf(externalPluginWidgetDto(configurationId)), + ) + ) + whenever(resolver.resolvePluginDefinition(configurationId)).thenReturn(null) + + val result = exporter.export(DocumentDefinitionExportRequest("my-case", caseDefinitionId)) + + val exported = objectMapper.readTree(result.exportFiles.single().content) + val externalWidget = exported[0]["widgets"].single() + assertThat(externalWidget["properties"].has("pluginDefinitionKey")).isFalse() + } + + @Test + fun `works without the external-plugin resolver on the classpath`() { + val configurationId = UUID.randomUUID() + val exporter = CaseWidgetTabExporter(objectMapper, caseTabService, caseWidgetService, Optional.empty()) + + whenever(caseTabService.getCaseTabs(caseDefinitionId)).thenReturn(listOf(widgetsTab())) + whenever(caseWidgetService.getWidgetTab(caseDefinitionId, "widgets-tab")).thenReturn( + CaseWidgetTabDto( + caseDefinitionKey = "my-case", + caseDefinitionVersionTag = "1.0.0", + key = "widgets-tab", + widgets = listOf(externalPluginWidgetDto(configurationId)), + ) + ) + + val result = exporter.export(DocumentDefinitionExportRequest("my-case", caseDefinitionId)) + + val exported = objectMapper.readTree(result.exportFiles.single().content) + val externalWidget = exported[0]["widgets"].single() + assertThat(externalWidget["properties"]["configurationId"].asText()).isEqualTo(configurationId.toString()) + assertThat(externalWidget["properties"].has("pluginDefinitionKey")).isFalse() + } + + private fun widgetsTab() = CaseTab( + id = CaseTabId(caseDefinitionId, "widgets-tab"), + name = "Widgets", + tabOrder = 0, + type = CaseTabType.WIDGETS, + contentKey = "widgets-tab", + ) + + private fun externalPluginWidgetDto(configurationId: UUID) = ExternalPluginCaseWidgetDto( + key = "summary-widget", + title = "Summary", + icon = null, + color = null, + width = 2, + highContrast = false, + isCompact = null, + actions = emptyList(), + displayConditions = emptyList(), + properties = ExternalPluginWidgetProperties( + configurationId = configurationId, + bundleKey = "summary-widget", + ), + ) + + private fun customWidgetDto() = CustomCaseWidgetDto( + key = "custom-widget", + title = "Custom", + icon = null, + color = null, + width = 2, + highContrast = false, + isCompact = null, + actions = emptyList(), + displayConditions = emptyList(), + properties = CustomWidgetProperties(componentKey = "my-component"), + ) +} diff --git a/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabImporterExternalPluginTest.kt b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabImporterExternalPluginTest.kt new file mode 100644 index 0000000000..6d70198ce3 --- /dev/null +++ b/backend/case/src/test/kotlin/com/ritense/case_/service/CaseWidgetTabImporterExternalPluginTest.kt @@ -0,0 +1,144 @@ +/* + * 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.fasterxml.jackson.databind.jsontype.NamedType +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.ritense.case_.domain.tab.CaseWidgetTab +import com.ritense.case_.domain.tab.CaseWidgetTabWidget +import com.ritense.case_.repository.CaseWidgetTabRepository +import com.ritense.case_.rest.dto.CaseWidgetTabWidgetDto +import com.ritense.case_.widget.CaseWidgetMapper +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidget +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetDto +import com.ritense.case_.widget.externalplugin.ExternalPluginCaseWidgetMapper +import com.ritense.importer.ImportRequest +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.valtimo.contract.plugin.PluginConfigurationMappingResolver +import jakarta.validation.Validation +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import java.util.UUID + +class CaseWidgetTabImporterExternalPluginTest { + + private val objectMapper = jacksonObjectMapper().apply { + registerSubtypes(NamedType(ExternalPluginCaseWidgetDto::class.java, "external-plugin")) + } + private val validator = Validation.buildDefaultValidatorFactory().validator + private val caseWidgetTabRepository = mock() + + @Suppress("UNCHECKED_CAST") + private val mappers = listOf(ExternalPluginCaseWidgetMapper()) + as List> + + private val mappingResolver = mock() + + private lateinit var importer: CaseWidgetTabImporter + + private val caseDefinitionId = CaseDefinitionId("my-case", "1.0.0") + + @BeforeEach + fun before() { + importer = CaseWidgetTabImporter( + objectMapper, + validator, + caseWidgetTabRepository, + mappers, + listOf(mappingResolver), + ) + } + + @Test + fun `import remaps a mapped external-plugin widget configuration id`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + + importer.import(request(json(sourceId), mapOf(sourceId to targetId))) + + val widget = savedExternalPluginWidget() + assertThat(widget.externalPluginConfigurationId).isEqualTo(targetId) + assertThat(widget.pluginDefinitionKey).isEqualTo("case-summary") + } + + @Test + fun `import leaves an unmapped external-plugin widget dangling on its original id`() { + val sourceId = UUID.randomUUID() + + importer.import(request(json(sourceId), mapOf(sourceId to null))) + + val widget = savedExternalPluginWidget() + assertThat(widget.externalPluginConfigurationId).isEqualTo(sourceId) + } + + @Test + fun `import leaves the id untouched when no mappings are given`() { + val sourceId = UUID.randomUUID() + + importer.import(request(json(sourceId), null)) + + val widget = savedExternalPluginWidget() + assertThat(widget.externalPluginConfigurationId).isEqualTo(sourceId) + } + + @Test + fun `afterImport rechecks configuration issues for the case definition`() { + importer.afterImport(request(json(UUID.randomUUID()), null)) + + verify(mappingResolver).recheckIssuesForCaseDefinition(caseDefinitionId) + } + + private fun savedExternalPluginWidget(): ExternalPluginCaseWidget { + val captor = argumentCaptor>() + verify(caseWidgetTabRepository).saveAll(captor.capture()) + return captor.firstValue.single().widgets.single() as ExternalPluginCaseWidget + } + + private fun request(content: String, mappings: Map?) = ImportRequest( + fileName = "config/case/my-case/1-0-0/case/widget-tab/my-case.case-widget-tab.json", + content = content.toByteArray(), + caseDefinitionId = caseDefinitionId, + pluginConfigurationMappings = mappings, + ) + + private fun json(configurationId: UUID) = """ + [ + { + "key": "widgets-tab", + "widgets": [ + { + "type": "external-plugin", + "key": "summary-widget", + "title": "Summary", + "width": 2, + "highContrast": false, + "properties": { + "configurationId": "$configurationId", + "bundleKey": "summary-widget", + "pluginDefinitionKey": "case-summary", + "pluginDefinitionVersion": "0.1.0" + } + } + ] + } + ] + """.trimIndent() +} diff --git a/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProviderTest.kt b/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProviderTest.kt new file mode 100644 index 0000000000..f283b9d138 --- /dev/null +++ b/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetDataProviderTest.kt @@ -0,0 +1,109 @@ +/* + * 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_.widget.externalplugin + +import com.ritense.case_.domain.tab.CaseWidgetTabWidgetId +import com.ritense.case_.rest.dto.ExternalPluginWidgetContentDto +import com.ritense.case_.service.ExternalPluginCaseWidgetResolver +import com.ritense.valtimo.contract.case_.CaseDefinitionId +import com.ritense.widget.domain.WidgetColor +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.data.domain.Pageable +import java.util.Optional +import java.util.UUID + +class ExternalPluginCaseWidgetDataProviderTest { + + private val caseDefinitionId = CaseDefinitionId("my-case", "1.0.0") + private val documentId = UUID.randomUUID() + + @Test + fun `supports only external-plugin widgets`() { + val provider = ExternalPluginCaseWidgetDataProvider(Optional.empty()) + + assertThat(provider.supports(widget(UUID.randomUUID()))).isTrue() + assertThat(provider.supports("not a widget")).isFalse() + } + + @Test + fun `getData resolves the bundle url and builds the context`() { + val configurationId = UUID.randomUUID() + val resolver = mock() + whenever(resolver.resolveBundleUrl(configurationId, "summary-widget")) + .thenReturn("http://host/plugins/case-summary/0.1.0/bundles/case-widget.html") + val provider = ExternalPluginCaseWidgetDataProvider(Optional.of(resolver)) + + val result = provider.getData(documentId, widget(configurationId), Pageable.unpaged(), caseDefinitionId) + + assertThat(result).isInstanceOf(ExternalPluginWidgetContentDto::class.java) + result as ExternalPluginWidgetContentDto + assertThat(result.bundleUrl).isEqualTo("http://host/plugins/case-summary/0.1.0/bundles/case-widget.html") + assertThat(result.configurationId).isEqualTo(configurationId) + assertThat(result.bundleKey).isEqualTo("summary-widget") + assertThat(result.context.documentId).isEqualTo(documentId.toString()) + assertThat(result.context.caseDefinitionKey).isEqualTo("my-case") + assertThat(result.context.caseDefinitionVersionTag).isEqualTo("1.0.0") + assertThat(result.context.pluginConfigurationId).isEqualTo(configurationId.toString()) + } + + @Test + fun `getData returns a null bundle url when the resolver is absent`() { + val configurationId = UUID.randomUUID() + val provider = ExternalPluginCaseWidgetDataProvider(Optional.empty()) + + val result = provider.getData(documentId, widget(configurationId), Pageable.unpaged(), caseDefinitionId) as ExternalPluginWidgetContentDto + + assertThat(result.bundleUrl).isNull() + assertThat(result.configurationId).isEqualTo(configurationId) + } + + @Test + fun `getData does not call the resolver for a dangling widget with no configuration`() { + val resolver = mock() + val provider = ExternalPluginCaseWidgetDataProvider(Optional.of(resolver)) + + val result = provider.getData(documentId, widget(null), Pageable.unpaged(), caseDefinitionId) as ExternalPluginWidgetContentDto + + assertThat(result.bundleUrl).isNull() + assertThat(result.configurationId).isNull() + assertThat(result.context.pluginConfigurationId).isNull() + verify(resolver, never()).resolveBundleUrl(any(), any()) + } + + private fun widget(configurationId: UUID?) = ExternalPluginCaseWidget( + id = CaseWidgetTabWidgetId("summary-widget"), + title = "Summary", + icon = null, + color = WidgetColor.WHITE, + order = 0, + width = 2, + highContrast = false, + isCompact = null, + actions = emptyList(), + displayConditions = emptyList(), + externalPluginConfigurationId = configurationId, + bundleKey = "summary-widget", + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ) +} diff --git a/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapperTest.kt b/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapperTest.kt new file mode 100644 index 0000000000..fbe421f427 --- /dev/null +++ b/backend/case/src/test/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidgetMapperTest.kt @@ -0,0 +1,145 @@ +/* + * 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_.widget.externalplugin + +import com.ritense.case_.domain.tab.CaseWidgetTabWidgetId +import com.ritense.widget.domain.WidgetColor +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.UUID + +class ExternalPluginCaseWidgetMapperTest { + + private val mapper = ExternalPluginCaseWidgetMapper() + + @Test + fun `toEntity unpacks the properties into dedicated columns and sets order from the index`() { + val configurationId = UUID.randomUUID() + val dto = ExternalPluginCaseWidgetDto( + key = "summary-widget", + title = "Summary", + icon = "mdi-account", + color = WidgetColor.BLUE, + width = 2, + highContrast = false, + isCompact = true, + actions = emptyList(), + displayConditions = emptyList(), + properties = ExternalPluginWidgetProperties( + configurationId = configurationId, + bundleKey = "summary-widget", + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ), + ) + + val entity = mapper.toEntity(dto, 3) + + assertThat(entity.id.key).isEqualTo("summary-widget") + assertThat(entity.order).isEqualTo(3) + assertThat(entity.externalPluginConfigurationId).isEqualTo(configurationId) + assertThat(entity.bundleKey).isEqualTo("summary-widget") + assertThat(entity.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entity.pluginDefinitionVersion).isEqualTo("0.1.0") + } + + @Test + fun `toEntity defaults color to WHITE when none is given`() { + val dto = baseDto(color = null) + + assertThat(mapper.toEntity(dto, 0).color).isEqualTo(WidgetColor.WHITE) + } + + @Test + fun `toDto packs the columns back into properties`() { + val configurationId = UUID.randomUUID() + val entity = ExternalPluginCaseWidget( + id = CaseWidgetTabWidgetId("summary-widget"), + title = "Summary", + icon = "mdi-account", + color = WidgetColor.BLUE, + order = 0, + width = 2, + highContrast = false, + isCompact = true, + actions = emptyList(), + displayConditions = emptyList(), + externalPluginConfigurationId = configurationId, + bundleKey = "summary-widget", + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ) + + val dto = mapper.toDto(entity) + + assertThat(dto.key).isEqualTo("summary-widget") + assertThat(dto.title).isEqualTo("Summary") + assertThat(dto.color).isEqualTo(WidgetColor.BLUE) + assertThat(dto.width).isEqualTo(2) + assertThat(dto.isCompact).isTrue() + assertThat(dto.properties.configurationId).isEqualTo(configurationId) + assertThat(dto.properties.bundleKey).isEqualTo("summary-widget") + assertThat(dto.properties.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(dto.properties.pluginDefinitionVersion).isEqualTo("0.1.0") + } + + @Test + fun `entity to dto to entity round-trips the config, bundle and plugin identity`() { + val configurationId = UUID.randomUUID() + val entity = ExternalPluginCaseWidget( + id = CaseWidgetTabWidgetId("summary-widget"), + title = "Summary", + icon = null, + color = WidgetColor.WHITE, + order = 5, + width = 4, + highContrast = true, + isCompact = null, + actions = emptyList(), + displayConditions = emptyList(), + externalPluginConfigurationId = configurationId, + bundleKey = "summary-widget", + pluginDefinitionKey = "case-summary", + pluginDefinitionVersion = "0.1.0", + ) + + val roundTripped = mapper.toEntity(mapper.toDto(entity), 5) + + assertThat(roundTripped.externalPluginConfigurationId).isEqualTo(configurationId) + assertThat(roundTripped.bundleKey).isEqualTo("summary-widget") + assertThat(roundTripped.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(roundTripped.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(roundTripped.width).isEqualTo(4) + assertThat(roundTripped.highContrast).isTrue() + } + + private fun baseDto(color: WidgetColor? = null) = ExternalPluginCaseWidgetDto( + key = "summary-widget", + title = "Summary", + icon = null, + color = color, + width = 2, + highContrast = false, + isCompact = null, + actions = emptyList(), + displayConditions = emptyList(), + properties = ExternalPluginWidgetProperties( + configurationId = UUID.randomUUID(), + bundleKey = "summary-widget", + ), + ) +} diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml index 86003558e5..c2c107a823 100644 --- a/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/13-32-0-master.xml @@ -31,5 +31,6 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liqui + diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml new file mode 100644 index 0000000000..5f0f4dc2b5 --- /dev/null +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt index 35fac0e165..dd5ac84053 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/autoconfigure/ExternalPluginAutoConfiguration.kt @@ -53,6 +53,7 @@ import com.ritense.externalplugin.security.ExternalPluginUserTokenKeyProvider import com.ritense.externalplugin.service.EndpointDescriptionService import com.ritense.externalplugin.service.ExternalPluginBundleUrlResolver import com.ritense.externalplugin.service.ExternalPluginCaseTabResolverImpl +import com.ritense.externalplugin.service.ExternalPluginCaseWidgetResolverImpl import com.ritense.externalplugin.service.ExternalPluginConfigurationMappingResolver import com.ritense.externalplugin.service.ExternalPluginConfigurationService import com.ritense.externalplugin.service.ExternalPluginDefinitionService @@ -141,6 +142,7 @@ class ExternalPluginAutoConfiguration { taskFormProcessLinkRepository: ExternalPluginTaskFormProcessLinkRepository, processDefinitionUsageMetaResolver: ProcessDefinitionUsageMetaResolver, caseExternalPluginTabService: java.util.Optional, + caseExternalPluginWidgetService: java.util.Optional, buildingBlockMappingUsageFinder: java.util.Optional, ) = ExternalPluginHostUsageResolver( definitionRepository, @@ -149,6 +151,7 @@ class ExternalPluginAutoConfiguration { taskFormProcessLinkRepository, processDefinitionUsageMetaResolver, caseExternalPluginTabService, + caseExternalPluginWidgetService, buildingBlockMappingUsageFinder, ) @@ -196,6 +199,14 @@ class ExternalPluginAutoConfiguration { definitionRepository: ExternalPluginDefinitionRepository, ) = ExternalPluginCaseTabResolverImpl(bundleUrlResolver, configurationRepository, definitionRepository) + @Bean + @ConditionalOnMissingBean(ExternalPluginCaseWidgetResolverImpl::class) + fun externalPluginCaseWidgetResolver( + bundleUrlResolver: ExternalPluginBundleUrlResolver, + configurationRepository: ExternalPluginConfigurationRepository, + definitionRepository: ExternalPluginDefinitionRepository, + ) = ExternalPluginCaseWidgetResolverImpl(bundleUrlResolver, configurationRepository, definitionRepository) + @Bean @ConditionalOnMissingBean(ExternalPluginMenuPageService::class) fun externalPluginMenuPageService( @@ -554,6 +565,7 @@ class ExternalPluginAutoConfiguration { configurationRepository: ExternalPluginConfigurationRepository, caseExternalPluginTabRepository: CaseExternalPluginTabRepository, caseTabRepository: CaseTabRepository, + caseExternalPluginWidgetService: com.ritense.case_.service.CaseExternalPluginWidgetService, processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, caseDefinitionChecker: CaseDefinitionChecker, applicationEventPublisher: ApplicationEventPublisher, @@ -563,6 +575,7 @@ class ExternalPluginAutoConfiguration { configurationRepository, caseExternalPluginTabRepository, caseTabRepository, + caseExternalPluginWidgetService, processDefinitionCaseDefinitionService, caseDefinitionChecker, applicationEventPublisher, diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt index 135f0ee4e5..8d3a760af8 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributor.kt @@ -44,6 +44,7 @@ class ExternalPluginImportPreviewContributor( when { PROCESS_LINK_REGEX.matches(fileName) -> result += contributeFromProcessLink(fileName, content) CASE_TAB_REGEX.matches(fileName) -> result += contributeFromCaseTab(fileName, content) + CASE_WIDGET_TAB_REGEX.matches(fileName) -> result += contributeFromCaseWidgetTab(fileName, content) } } return result @@ -135,6 +136,59 @@ class ExternalPluginImportPreviewContributor( return result } + /** + * A `*.case-widget-tab.json` holds a list of widget tabs; each tab's `widgets[]` may contain + * `external-plugin` widgets whose `properties.configurationId` references a plugin configuration. + * Emits one contribution per such widget, mirroring [contributeFromCaseTab]. Self-describing + * exports carry the widget's plugin key/version in `properties`, so the plugin stays identifiable + * even when the referenced configuration was deleted in the target; older exports fall back to + * resolving through the configuration. + */ + private fun contributeFromCaseWidgetTab(fileName: String, content: ByteArray): List { + val jsonTree = try { + objectMapper.readTree(content.toString(Charsets.UTF_8)) + } catch (_: Exception) { + return emptyList() + } + if (jsonTree !is ArrayNode) return emptyList() + + val result = mutableListOf() + for (tabNode in jsonTree) { + val tabKey = tabNode.path("key").asText(null) + val widgets = tabNode.path("widgets") + if (!widgets.isArray) continue + + for (widgetNode in widgets) { + val type = widgetNode.path("type").asText(null) ?: continue + if (type != "external-plugin") continue + + val properties = widgetNode.path("properties") + val configIdText = properties.path("configurationId").asText(null) ?: continue + val configId = configIdText.toUuidOrNull() ?: continue + val widgetKey = widgetNode.path("key").asText(tabKey ?: fileName) + + val configuration = configurationRepository.findById(configId).orElse(null) + val definition = configuration?.let { definitionRepository.findById(it.definitionId).orElse(null) } + val pluginDefinitionKey = properties.path("pluginDefinitionKey").asText(null) ?: definition?.pluginId + val pluginDefinitionVersion = properties.path("pluginDefinitionVersion").asText(null) ?: definition?.version + + result.add( + ImportPreviewContribution( + pluginConfigurationId = configId, + pluginDefinitionKey = pluginDefinitionKey, + pluginActionDefinitionKey = "case-widget", + processDefinitionKey = fileName, + activityId = if (tabKey != null) "$tabKey/$widgetKey" else widgetKey, + existsInTargetEnvironment = configuration != null, + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + ) + } + } + return result + } + private fun String.toUuidOrNull(): UUID? = try { UUID.fromString(this) } catch (_: IllegalArgumentException) { @@ -144,5 +198,6 @@ class ExternalPluginImportPreviewContributor( private companion object { val PROCESS_LINK_REGEX = """.*/?process-link/(?:.*/)?(.+)\.process-link\.json""".toRegex() val CASE_TAB_REGEX = """.*/?case/tab/([^/]+)\.case-tab\.json""".toRegex() + val CASE_WIDGET_TAB_REGEX = """.*/?case/widget-tab/([^/]+)\.case-widget-tab\.json""".toRegex() } } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImpl.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImpl.kt new file mode 100644 index 0000000000..f6d2d01269 --- /dev/null +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImpl.kt @@ -0,0 +1,54 @@ +/* + * 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.externalplugin.service + +import com.ritense.case_.service.ExternalPluginCaseWidgetResolver +import com.ritense.case_.service.ExternalPluginTabDefinition +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import com.ritense.valtimo.contract.annotation.SkipComponentScan +import org.springframework.stereotype.Service +import java.util.UUID + +/** + * external-plugin's implementation of the case-module [ExternalPluginCaseWidgetResolver] SPI. The + * widget sibling of [ExternalPluginCaseTabResolverImpl]: resolves a plugin configuration's + * `case-widget` bundle to its absolute URL by delegating to the shared + * [ExternalPluginBundleUrlResolver] with the `case-widget` bundle type, and the configuration's + * plugin definition (`pluginId`/version) for the self-describing widget export. + */ +@Service +@SkipComponentScan +class ExternalPluginCaseWidgetResolverImpl( + private val bundleUrlResolver: ExternalPluginBundleUrlResolver, + private val configurationRepository: ExternalPluginConfigurationRepository, + private val definitionRepository: ExternalPluginDefinitionRepository, +) : ExternalPluginCaseWidgetResolver { + + override fun resolveBundleUrl(configurationId: UUID, bundleKey: String?): String? = + bundleUrlResolver.resolve(configurationId, CASE_WIDGET_TYPE, bundleKey) + + override fun resolvePluginDefinition(configurationId: UUID): ExternalPluginTabDefinition? { + val configuration = configurationRepository.findById(configurationId).orElse(null) ?: return null + val definition = definitionRepository.findById(configuration.definitionId).orElse(null) ?: return null + return ExternalPluginTabDefinition(definition.pluginId, definition.version) + } + + companion object { + private const val CASE_WIDGET_TYPE = "case-widget" + } +} diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt index faa82a4281..cc93146859 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolver.kt @@ -22,6 +22,7 @@ import com.ritense.case.repository.CaseTabRepository import com.ritense.case.repository.CaseTabSpecificationHelper import com.ritense.case_.domain.tab.CaseExternalPluginTab import com.ritense.case_.repository.CaseExternalPluginTabRepository +import com.ritense.case_.service.CaseExternalPluginWidgetService import com.ritense.externalplugin.domain.ExternalPluginProcessLink import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink import com.ritense.externalplugin.processlink.ExternalPluginProcessLinkMapper @@ -59,6 +60,7 @@ open class ExternalPluginConfigurationMappingResolver( private val configurationRepository: ExternalPluginConfigurationRepository, private val caseExternalPluginTabRepository: CaseExternalPluginTabRepository, private val caseTabRepository: CaseTabRepository, + private val caseExternalPluginWidgetService: CaseExternalPluginWidgetService, private val processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService, private val caseDefinitionChecker: CaseDefinitionChecker, private val applicationEventPublisher: ApplicationEventPublisher, @@ -72,6 +74,7 @@ open class ExternalPluginConfigurationMappingResolver( resolveProcessLinks(processDefinitionIds, mappings) resolveTaskFormProcessLinks(processDefinitionIds, mappings) resolveCaseTabs(caseDefinitionId, mappings) + caseExternalPluginWidgetService.remapConfiguration(caseDefinitionId, mappings) checkForRemainingIssues(caseDefinitionId, processDefinitionIds) } @@ -110,8 +113,9 @@ open class ExternalPluginConfigurationMappingResolver( } val danglingTabConfigurations = danglingTabsFor(caseDefinitionId) + val danglingWidgetConfigurations = danglingWidgetsFor(caseDefinitionId) - return danglingLinks + danglingTaskFormLinks + danglingTabConfigurations + return danglingLinks + danglingTaskFormLinks + danglingTabConfigurations + danglingWidgetConfigurations } override fun recheckIssuesForProcessDefinition(processDefinitionId: String) { @@ -221,6 +225,32 @@ open class ExternalPluginConfigurationMappingResolver( } } + /** + * Dangling = external-plugin widgets whose referenced configuration cannot be resolved in this + * environment, grouped by the design-time plugin identity carried from a self-describing import + * (so the repair panel can offer a per-plugin chooser, exactly like a dangling tab). The source + * ids are the widgets' current (unresolvable) configuration ids — the same ids the repair maps + * from. + */ + private fun danglingWidgetsFor(caseDefinitionId: CaseDefinitionId): List { + val danglingWidgets = caseExternalPluginWidgetService.findExternalPluginWidgets(caseDefinitionId) + .filter { it.configurationId != null && !configurationRepository.existsById(it.configurationId) } + + if (danglingWidgets.isEmpty()) return emptyList() + + return danglingWidgets + .groupBy { it.pluginDefinitionKey to it.pluginDefinitionVersion } + .map { (keyAndVersion, widgets) -> + val (pluginDefinitionKey, pluginDefinitionVersion) = keyAndVersion + DanglingPluginConfigurationDto( + pluginDefinitionKey = pluginDefinitionKey, + sourcePluginConfigurationIds = widgets.mapNotNull { it.configurationId }.toSet(), + source = SOURCE_EXTERNAL, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + } + } + /** * Each external surface owns its own issue type and is judged independently, so one surface being * clean can never clear another surface's issue (the cross-surface clobber that a single shared @@ -230,6 +260,7 @@ open class ExternalPluginConfigurationMappingResolver( publishIssue(caseDefinitionId, PROCESS_LINK_ISSUE_TYPE, hasProcessLinkIssue(processDefinitionIds)) publishIssue(caseDefinitionId, TASK_FORM_ISSUE_TYPE, hasTaskFormIssue(processDefinitionIds)) publishIssue(caseDefinitionId, CASE_TAB_ISSUE_TYPE, danglingTabsFor(caseDefinitionId).isNotEmpty()) + publishIssue(caseDefinitionId, CASE_WIDGET_ISSUE_TYPE, danglingWidgetsFor(caseDefinitionId).isNotEmpty()) } private fun hasProcessLinkIssue(processDefinitionIds: List) = @@ -276,7 +307,13 @@ open class ExternalPluginConfigurationMappingResolver( val PROCESS_LINK_ISSUE_TYPE = ExternalPluginProcessLinkMapper.ISSUE_TYPE val TASK_FORM_ISSUE_TYPE = ExternalPluginTaskFormProcessLinkMapper.ISSUE_TYPE const val CASE_TAB_ISSUE_TYPE = "external-plugin-case-tab" - - private val ALL_ISSUE_TYPES = listOf(PROCESS_LINK_ISSUE_TYPE, TASK_FORM_ISSUE_TYPE, CASE_TAB_ISSUE_TYPE) + const val CASE_WIDGET_ISSUE_TYPE = "external-plugin-case-widget" + + private val ALL_ISSUE_TYPES = listOf( + PROCESS_LINK_ISSUE_TYPE, + TASK_FORM_ISSUE_TYPE, + CASE_TAB_ISSUE_TYPE, + CASE_WIDGET_ISSUE_TYPE, + ) } } diff --git a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt index 91ed1d81b6..149777fbba 100644 --- a/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt +++ b/backend/external-plugin/src/main/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolver.kt @@ -17,6 +17,7 @@ package com.ritense.externalplugin.service import com.ritense.case_.service.CaseExternalPluginTabService +import com.ritense.case_.service.CaseExternalPluginWidgetService import com.ritense.externalplugin.domain.ExternalPluginConfiguration import com.ritense.externalplugin.domain.ExternalPluginDefinition import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository @@ -50,6 +51,11 @@ class ExternalPluginHostUsageResolver( * normal GZAC deployment it is always present (external-plugin depends on case). */ private val caseExternalPluginTabService: Optional, + /** + * Optional for the same reason as the tab service: an `external-plugin` case widget references a + * configuration without a process link, so it too must block deletion. + */ + private val caseExternalPluginWidgetService: Optional, /** * Optional for the same reason: implemented by the building-block module, which external-plugin * cannot depend on directly (the SPI lives in `:backend:plugin`). Without it, building-block @@ -63,6 +69,7 @@ class ExternalPluginHostUsageResolver( val configurations = collectConfigurations(definitions) return buildUsageDtos(configurations) + buildCaseTabUsageDtos(configurations) + + buildCaseWidgetUsageDtos(configurations) + buildBuildingBlockMappingUsageDtos(configurations) + buildDefinitionReferenceUsageDtos(definitions) } @@ -72,6 +79,7 @@ class ExternalPluginHostUsageResolver( ?: return emptyList() return buildUsageDtos(listOf(configuration)) + buildCaseTabUsageDtos(listOf(configuration)) + + buildCaseWidgetUsageDtos(listOf(configuration)) + buildBuildingBlockMappingUsageDtos(listOf(configuration)) } @@ -97,6 +105,30 @@ class ExternalPluginHostUsageResolver( } } + /** + * An `external-plugin` case widget references the configuration without a process link, so it + * counts as a usage that blocks deletion (system-plan §12) — the widget sibling of + * [buildCaseTabUsageDtos]. + */ + private fun buildCaseWidgetUsageDtos(configurations: List): List { + val widgetService = caseExternalPluginWidgetService.orElse(null) ?: return emptyList() + if (configurations.isEmpty()) return emptyList() + return configurations.flatMap { configuration -> + widgetService.findUsagesForConfiguration(configuration.id).map { usage -> + PluginUsageDto( + configurationId = configuration.id, + configurationTitle = configuration.title, + parentType = PluginUsageParentType.CASE, + parentKey = usage.caseDefinitionKey, + parentVersionTag = usage.caseDefinitionVersionTag, + tabKey = usage.tabKey, + tabName = usage.tabName, + widgetKey = usage.widgetKey, + ) + } + } + } + private fun buildUsageDtos(configurations: List): List { if (configurations.isEmpty()) return emptyList() val configById = configurations.associateBy { it.id } diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt index aafe974966..d55e9999e3 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/preview/ExternalPluginImportPreviewContributorTest.kt @@ -259,6 +259,129 @@ class ExternalPluginImportPreviewContributorTest { assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) } + @Test + fun `contributes an entry for an external-plugin case widget with the resolved plugin key and version`() { + val configId = UUID.randomUUID() + val definitionId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn( + Optional.of( + ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "Config", + createdAt = Instant.now(), + ) + ) + ) + whenever(definitionRepository.findById(definitionId)).thenReturn( + Optional.of( + ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://localhost:1234", + status = ExternalPluginDefinitionStatus.AVAILABLE, + ) + ) + ) + + val json = """ + [ + { + "key": "widgets-tab", + "widgets": [ + { "type": "fields", "key": "some-fields" }, + { + "type": "external-plugin", + "key": "summary-widget", + "title": "Summary", + "width": 2, + "highContrast": false, + "properties": { "configurationId": "$configId", "bundleKey": "summary-widget" } + } + ] + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("config/case/my-doc/1-0-0/case/widget-tab/my-doc.case-widget-tab.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginConfigurationId).isEqualTo(configId) + assertThat(entry.pluginActionDefinitionKey).isEqualTo("case-widget") + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(entry.activityId).isEqualTo("widgets-tab/summary-widget") + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + assertThat(entry.existsInTargetEnvironment).isTrue() + } + + @Test + fun `a self-describing external-plugin case widget stays identifiable when its configuration was deleted`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + val json = """ + [ + { + "key": "widgets-tab", + "widgets": [ + { + "type": "external-plugin", + "key": "summary-widget", + "title": "Summary", + "width": 2, + "highContrast": false, + "properties": { + "configurationId": "$configId", + "bundleKey": "summary-widget", + "pluginDefinitionKey": "case-summary", + "pluginDefinitionVersion": "0.1.0" + } + } + ] + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("config/case/my-doc/1-0-0/case/widget-tab/my-doc.case-widget-tab.json" to json.toByteArray()) + ) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginConfigurationId).isEqualTo(configId) + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(entry.existsInTargetEnvironment).isFalse() + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + } + + @Test + fun `ignores non-external-plugin widgets in a case widget tab`() { + val json = """ + [ + { + "key": "widgets-tab", + "widgets": [ + { "type": "fields", "key": "some-fields" }, + { "type": "custom", "key": "some-custom", "properties": { "componentKey": "x" } } + ] + } + ] + """.trimIndent() + + val result = contributor.contributePreview( + mapOf("config/case/my-doc/1-0-0/case/widget-tab/my-doc.case-widget-tab.json" to json.toByteArray()) + ) + + assertThat(result).isEmpty() + } + @Test fun `ignores non-EXTERNAL_PLUGIN case tabs`() { val json = """ diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImplTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImplTest.kt new file mode 100644 index 0000000000..f1627e395f --- /dev/null +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginCaseWidgetResolverImplTest.kt @@ -0,0 +1,125 @@ +/* + * 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.externalplugin.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.node.ObjectNode +import com.ritense.externalplugin.domain.ExternalPluginConfiguration +import com.ritense.externalplugin.domain.ExternalPluginDefinition +import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus +import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository +import com.ritense.externalplugin.repository.ExternalPluginDefinitionRepository +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Optional +import java.util.UUID + +class ExternalPluginCaseWidgetResolverImplTest { + + private val objectMapper = ObjectMapper() + private val configurationRepository = mock() + private val definitionRepository = mock() + private val resolver = ExternalPluginCaseWidgetResolverImpl( + ExternalPluginBundleUrlResolver(configurationRepository, definitionRepository), + configurationRepository, + definitionRepository, + ) + + @Test + fun `resolves the bundle url for the sole case-widget bundle when no key is given`() { + val (configId, _) = stub( + """[ { "type":"config", "path":"/bundles/config.html" }, + { "type":"case-widget", "key":"summary-widget", "path":"/bundles/case-widget.html" } ]""" + ) + + val url = resolver.resolveBundleUrl(configId, null) + + assertThat(url).isEqualTo("http://host:8090/plugins/case-summary/0.1.0/bundles/case-widget.html") + } + + @Test + fun `resolves the bundle url for the matching key when multiple case-widget bundles exist`() { + val (configId, _) = stub( + """[ { "type":"case-widget", "key":"summary-widget", "path":"/bundles/summary.html" }, + { "type":"case-widget", "key":"details-widget", "path":"/bundles/details.html" } ]""" + ) + + val url = resolver.resolveBundleUrl(configId, "details-widget") + + assertThat(url).isEqualTo("http://host:8090/plugins/case-summary/0.1.0/bundles/details.html") + } + + @Test + fun `returns null when there is no case-widget bundle`() { + val (configId, _) = stub("""[ { "type":"case-tab", "key":"summary", "path":"/bundles/case-tab.html" } ]""") + + assertThat(resolver.resolveBundleUrl(configId, null)).isNull() + } + + @Test + fun `returns null when the configuration is unknown`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + assertThat(resolver.resolveBundleUrl(configId, null)).isNull() + } + + @Test + fun `resolvePluginDefinition returns the plugin id and version of the configuration's definition`() { + val (configId, _) = stub("""[ { "type":"case-widget", "key":"summary-widget", "path":"/bundles/case-widget.html" } ]""") + + val definition = resolver.resolvePluginDefinition(configId) + + assertThat(definition).isNotNull + assertThat(definition!!.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(definition.pluginDefinitionVersion).isEqualTo("0.1.0") + } + + @Test + fun `resolvePluginDefinition returns null when the configuration is unknown`() { + val configId = UUID.randomUUID() + whenever(configurationRepository.findById(configId)).thenReturn(Optional.empty()) + + assertThat(resolver.resolvePluginDefinition(configId)).isNull() + } + + private fun stub(bundlesJson: String): Pair { + val configId = UUID.randomUUID() + val definitionId = UUID.randomUUID() + val configuration = ExternalPluginConfiguration( + id = configId, + definitionId = definitionId, + title = "test", + ) + val manifest = objectMapper.createObjectNode() + .set("frontendBundles", objectMapper.readTree(bundlesJson)) + val definition = ExternalPluginDefinition( + id = definitionId, + pluginId = "case-summary", + version = "0.1.0", + hostId = UUID.randomUUID(), + baseUrl = "http://host:8090/plugins/case-summary", + status = ExternalPluginDefinitionStatus.AVAILABLE, + manifestJson = manifest, + ) + whenever(configurationRepository.findById(configId)).thenReturn(Optional.of(configuration)) + whenever(definitionRepository.findById(definitionId)).thenReturn(Optional.of(definition)) + return configId to definitionId + } +} diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt index 80a9e23d83..305020ca8c 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginConfigurationMappingResolverTest.kt @@ -22,6 +22,8 @@ import com.ritense.case.domain.CaseTabType import com.ritense.case.repository.CaseTabRepository import com.ritense.case_.domain.tab.CaseExternalPluginTab import com.ritense.case_.repository.CaseExternalPluginTabRepository +import com.ritense.case_.service.CaseExternalPluginWidgetRef +import com.ritense.case_.service.CaseExternalPluginWidgetService import com.ritense.externalplugin.domain.ExternalPluginProcessLink import com.ritense.externalplugin.domain.ExternalPluginTaskFormProcessLink import com.ritense.externalplugin.repository.ExternalPluginConfigurationRepository @@ -75,6 +77,9 @@ class ExternalPluginConfigurationMappingResolverTest { @Mock lateinit var caseTabRepository: CaseTabRepository + @Mock + lateinit var caseExternalPluginWidgetService: CaseExternalPluginWidgetService + @Mock lateinit var processDefinitionCaseDefinitionService: ProcessDefinitionCaseDefinitionService @@ -96,12 +101,14 @@ class ExternalPluginConfigurationMappingResolverTest { configurationRepository, caseExternalPluginTabRepository, caseTabRepository, + caseExternalPluginWidgetService, processDefinitionCaseDefinitionService, caseDefinitionChecker, applicationEventPublisher, ) lenient().whenever(taskFormProcessLinkRepository.findByProcessDefinitionId(any())).thenReturn(emptyList()) lenient().whenever(caseTabRepository.findAll(any>())).thenReturn(emptyList()) + lenient().whenever(caseExternalPluginWidgetService.findExternalPluginWidgets(any())).thenReturn(emptyList()) } @Test @@ -117,6 +124,7 @@ class ExternalPluginConfigurationMappingResolverTest { ExternalPluginConfigurationMappingResolver.PROCESS_LINK_ISSUE_TYPE, ExternalPluginConfigurationMappingResolver.TASK_FORM_ISSUE_TYPE, ExternalPluginConfigurationMappingResolver.CASE_TAB_ISSUE_TYPE, + ExternalPluginConfigurationMappingResolver.CASE_WIDGET_ISSUE_TYPE, ), ) } @@ -307,6 +315,60 @@ class ExternalPluginConfigurationMappingResolverTest { assertThat(result.single().pluginDefinitionVersion).isEqualTo("0.1.0") } + @Test + fun `resolve remaps external-plugin widgets through the widget service`() { + val sourceId = UUID.randomUUID() + val targetId = UUID.randomUUID() + stubProcessDefinitions() + + resolver.resolve(caseDefinitionId, mapOf(sourceId to targetId)) + + verify(caseExternalPluginWidgetService).remapConfiguration(caseDefinitionId, mapOf(sourceId to targetId)) + } + + @Test + fun `getDanglingPluginConfigurations includes dangling external-plugin widgets grouped by plugin identity`() { + val danglingConfigId1 = UUID.randomUUID() + val danglingConfigId2 = UUID.randomUUID() + val existingConfigId = UUID.randomUUID() + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + whenever(caseExternalPluginWidgetService.findExternalPluginWidgets(caseDefinitionId)).thenReturn( + listOf( + widgetRef(danglingConfigId1, "case-summary", "0.1.0"), + widgetRef(danglingConfigId2, "case-summary", "0.1.0"), + widgetRef(existingConfigId, "other-plugin", "1.0.0"), + ) + ) + whenever(configurationRepository.existsById(any())).thenAnswer { it.arguments[0] == existingConfigId } + + val result = resolver.getDanglingPluginConfigurations(caseDefinitionId) + + assertThat(result).hasSize(1) + val entry = result.single() + assertThat(entry.pluginDefinitionKey).isEqualTo("case-summary") + assertThat(entry.pluginDefinitionVersion).isEqualTo("0.1.0") + assertThat(entry.sourcePluginConfigurationIds).containsExactlyInAnyOrder(danglingConfigId1, danglingConfigId2) + assertThat(entry.source).isEqualTo(SOURCE_EXTERNAL) + } + + @Test + fun `recheckIssuesForCaseDefinition emits detected event for the case-widget surface when a widget is dangling`() { + val danglingConfigId = UUID.randomUUID() + stubProcessDefinitions("pd-1") + whenever(processLinkRepository.findByProcessDefinitionId("pd-1")).thenReturn(emptyList()) + whenever(caseExternalPluginWidgetService.findExternalPluginWidgets(caseDefinitionId)).thenReturn( + listOf(widgetRef(danglingConfigId, "case-summary", "0.1.0")) + ) + whenever(configurationRepository.existsById(danglingConfigId)).thenReturn(false) + + resolver.recheckIssuesForCaseDefinition(caseDefinitionId) + + verify(applicationEventPublisher).publishEvent( + CaseConfigurationIssueDetectedEvent(caseDefinitionId, ExternalPluginConfigurationMappingResolver.CASE_WIDGET_ISSUE_TYPE) + ) + } + @Test fun `recheckIssuesForProcessDefinition emits resolved event when all links are valid`() { val pdId = ProcessDefinitionId.of("pd-1") @@ -362,6 +424,19 @@ class ExternalPluginConfigurationMappingResolverTest { .thenReturn(links) } + private fun widgetRef( + configurationId: UUID?, + pluginDefinitionKey: String?, + pluginDefinitionVersion: String?, + ) = CaseExternalPluginWidgetRef( + caseDefinitionId = caseDefinitionId, + tabKey = "summary", + widgetKey = "summary-widget", + configurationId = configurationId, + pluginDefinitionKey = pluginDefinitionKey, + pluginDefinitionVersion = pluginDefinitionVersion, + ) + private fun processDefinitionCaseDefinition(processDefinitionId: String) = ProcessDefinitionCaseDefinition( id = ProcessDefinitionCaseDefinitionId( diff --git a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt index 0294d875b2..4ccb037154 100644 --- a/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt +++ b/backend/external-plugin/src/test/kotlin/com/ritense/externalplugin/service/ExternalPluginHostUsageResolverTest.kt @@ -16,6 +16,8 @@ package com.ritense.externalplugin.service +import com.ritense.case_.service.CaseExternalPluginWidgetService +import com.ritense.case_.service.CaseExternalPluginWidgetUsage import com.ritense.externalplugin.domain.ExternalPluginConfiguration import com.ritense.externalplugin.domain.ExternalPluginDefinition import com.ritense.externalplugin.domain.ExternalPluginDefinitionStatus @@ -77,6 +79,7 @@ class ExternalPluginHostUsageResolverTest { // the full resolution behaviour through it. ProcessDefinitionUsageMetaResolver(operatonRepositoryService, bpmnRepositoryService), java.util.Optional.empty(), + java.util.Optional.empty(), ) } @@ -644,6 +647,45 @@ class ExternalPluginHostUsageResolverTest { assertThat(usages[0].parentKey).isEqualTo("bezwaar") } + @Test + fun `configuration referenced only by an external-plugin widget still blocks deletion`() { + val configuration = configuration(definitionId = UUID.randomUUID(), title = "Summary Plugin") + val widgetService = mock() + val resolverWithWidgets = resolverWithWidgetService(widgetService) + + whenever(configurationRepository.findById(configuration.id)) + .thenReturn(java.util.Optional.of(configuration)) + whenever(processLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + whenever(taskFormProcessLinkRepository.findAllByExternalPluginConfigurationIdIn(setOf(configuration.id))) + .thenReturn(emptyList()) + whenever(widgetService.findUsagesForConfiguration(configuration.id)).thenReturn( + listOf( + CaseExternalPluginWidgetUsage( + configurationId = configuration.id, + caseDefinitionKey = "bezwaar", + caseDefinitionVersionTag = "1.0.1", + tabKey = "summary", + tabName = "Summary", + widgetKey = "summary-widget", + ), + ) + ) + + val usages = resolverWithWidgets.findUsagesForConfiguration(configuration.id) + + assertThat(usages).hasSize(1) + val usage = usages.single() + assertThat(usage.configurationId).isEqualTo(configuration.id) + assertThat(usage.parentType).isEqualTo(PluginUsageParentType.CASE) + assertThat(usage.parentKey).isEqualTo("bezwaar") + assertThat(usage.parentVersionTag).isEqualTo("1.0.1") + assertThat(usage.tabKey).isEqualTo("summary") + assertThat(usage.tabName).isEqualTo("Summary") + assertThat(usage.widgetKey).isEqualTo("summary-widget") + assertThat(usage.processDefinitionId).isNull() + } + private fun resolverWith(finder: BuildingBlockPluginMappingUsageFinder): ExternalPluginHostUsageResolver = ExternalPluginHostUsageResolver( definitionRepository, @@ -652,9 +694,23 @@ class ExternalPluginHostUsageResolverTest { taskFormProcessLinkRepository, ProcessDefinitionUsageMetaResolver(operatonRepositoryService, bpmnRepositoryService), java.util.Optional.empty(), + java.util.Optional.empty(), java.util.Optional.of(finder), ) + private fun resolverWithWidgetService( + widgetService: CaseExternalPluginWidgetService, + ): ExternalPluginHostUsageResolver = + ExternalPluginHostUsageResolver( + definitionRepository, + configurationRepository, + processLinkRepository, + taskFormProcessLinkRepository, + ProcessDefinitionUsageMetaResolver(operatonRepositoryService, bpmnRepositoryService), + java.util.Optional.empty(), + java.util.Optional.of(widgetService), + ) + private fun definition(hostId: UUID): ExternalPluginDefinition = ExternalPluginDefinition( id = UUID.randomUUID(), pluginId = "test-plugin", diff --git a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt index d1d7491b2b..65a5bc655a 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/plugin/web/rest/dto/PluginUsageDto.kt @@ -40,6 +40,9 @@ enum class PluginUsageParentType { * - **External-plugin case-tab usage**: [tabKey]/[tabName] are populated, [parentType] is `CASE`, * and the process-link fields are null. (A `case-tab` of an external plugin references the * configuration but has no process link.) + * - **External-plugin case-widget usage**: like the case-tab usage — [tabKey]/[tabName] identify the + * owning WIDGETS tab and [widgetKey] the widget within it. (An `external-plugin` widget references + * the configuration but has no process link.) * - **Building-block mapping usage**: a building block's `pluginConfigurationMappings` reference * the configuration; [buildingBlockKey] names the building block. On a call-activity link the * process-link fields are populated too; on a case-definition ↔ BB link only [parentKey]/ @@ -63,4 +66,5 @@ data class PluginUsageDto( val tabKey: String? = null, val tabName: String? = null, val buildingBlockKey: String? = null, + val widgetKey: String? = null, ) diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts index 8504dcfedd..58803dea64 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-general/components/case-management-missing-plugin-configurations/case-management-missing-plugin-configurations.component.ts @@ -67,6 +67,7 @@ const EMBEDDED_ISSUE_TYPE = 'plugin-process-link'; const EXTERNAL_ISSUE_TYPE = 'external-plugin-process-link'; const EXTERNAL_TASK_FORM_ISSUE_TYPE = 'external-plugin-task-form'; const EXTERNAL_CASE_TAB_ISSUE_TYPE = 'external-plugin-case-tab'; +const EXTERNAL_CASE_WIDGET_ISSUE_TYPE = 'external-plugin-case-widget'; @Component({ selector: 'valtimo-case-management-missing-plugin-configurations', @@ -89,6 +90,7 @@ export class CaseManagementMissingPluginConfigurationsComponent implements OnIni EXTERNAL_ISSUE_TYPE, EXTERNAL_TASK_FORM_ISSUE_TYPE, EXTERNAL_CASE_TAB_ISSUE_TYPE, + EXTERNAL_CASE_WIDGET_ISSUE_TYPE, ]); public readonly mappingRows$ = new BehaviorSubject([]); public readonly hasUnknownPluginConfigurations$ = new BehaviorSubject(false); diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.html b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.html index 995cf40265..56d12ebce0 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.html +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.html @@ -15,7 +15,7 @@ --> diff --git a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.ts b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.ts index 4a282a96bc..e83debe371 100644 --- a/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.ts +++ b/frontend/projects/valtimo/case-management/src/lib/components/case-management-detail/tabs/case-management-tabs/widget-tab/case-management-widget-tab/case-management-widget-tab.component.ts @@ -33,6 +33,8 @@ import { RenderInPageHeaderDirective, } from '@valtimo/components'; import { + EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN, + ExternalPluginWidgetConfigProvider, IWidgetManagementService, ManagementWidgetDetailsComponent, WIDGET_MANAGEMENT_SERVICE, @@ -43,9 +45,24 @@ import { import {CaseManagementParams, getCaseManagementRouteParams} from '@valtimo/shared'; import {ButtonModule, IconModule, IconService, TabsModule} from 'carbon-components-angular'; import moment from 'moment/moment'; -import {BehaviorSubject, combineLatest, filter, map, Observable, switchMap, tap} from 'rxjs'; +import { + BehaviorSubject, + catchError, + combineLatest, + filter, + map, + Observable, + of, + startWith, + switchMap, + tap, +} from 'rxjs'; -import {TabManagementService, CaseWidgetManagementApiService} from '../../../../../../services'; +import { + CaseWidgetManagementApiService, + TabManagementService, + TabService, +} from '../../../../../../services'; import {CaseManagementWidgetTabEditModalComponent} from '../case-management-widget-tab-edit-modal/case-management-widget-tab-edit-modal.component'; @Component({ @@ -67,6 +84,13 @@ import {CaseManagementWidgetTabEditModalComponent} from '../case-management-widg provide: WIDGET_MANAGEMENT_SERVICE, useClass: CaseWidgetManagementApiService, }, + { + provide: EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN, + useFactory: (tabService: TabService): ExternalPluginWidgetConfigProvider => ({ + getConfigOptions: () => tabService.getExternalPluginWidgetConfigs(), + }), + deps: [TabService], + }, ], }) export class CaseManagementWidgetTabComponent @@ -133,7 +157,8 @@ export class CaseManagementWidgetTabComponent ); public readonly compactMode$ = this.pageHeaderService.compactMode$; - public readonly AVAILABLE_WIDGET_TYPES = [ + + private readonly _baseWidgetTypes = [ WidgetType.FIELDS, WidgetType.COLLECTION, WidgetType.CUSTOM, @@ -146,6 +171,23 @@ export class CaseManagementWidgetTabComponent WidgetType.IMAGE, ]; + /** + * The selectable widget types. `external-plugin` is only offered when at least one activated + * plugin configuration exposes a `case-widget` bundle. Emits the base list synchronously (never + * `null`) so the wizard never shows the type before the availability check resolves. + */ + public readonly availableWidgetTypes$: Observable = this.tabService + .getExternalPluginWidgetConfigs() + .pipe( + map(configs => + configs.length > 0 + ? [...this._baseWidgetTypes, WidgetType.EXTERNAL_PLUGIN] + : this._baseWidgetTypes + ), + catchError(() => of(this._baseWidgetTypes)), + startWith(this._baseWidgetTypes) + ); + constructor( protected readonly widgetWizardService: WidgetWizardService, private readonly breadcrumbService: BreadcrumbService, @@ -153,6 +195,7 @@ export class CaseManagementWidgetTabComponent private readonly pageTitleService: PageTitleService, private readonly route: ActivatedRoute, private readonly tabManagementService: TabManagementService, + private readonly tabService: TabService, @Inject(WIDGET_MANAGEMENT_SERVICE) private readonly caseWidgetManagementApiService: IWidgetManagementService< CaseManagementParams & {key: string} diff --git a/frontend/projects/valtimo/case-management/src/lib/services/tab.service.ts b/frontend/projects/valtimo/case-management/src/lib/services/tab.service.ts index a6522b94a5..260d1019b1 100644 --- a/frontend/projects/valtimo/case-management/src/lib/services/tab.service.ts +++ b/frontend/projects/valtimo/case-management/src/lib/services/tab.service.ts @@ -24,6 +24,7 @@ import { getCaseManagementRouteParams, } from '@valtimo/shared'; import {FormDefinitionOption, FormService} from '@valtimo/form'; +import {ExternalPluginWidgetConfigOption} from '@valtimo/layout'; import {ExternalPluginService, getExternalPluginDisplayName} from '@valtimo/plugin'; import {ListItem} from 'carbon-components-angular'; import {BehaviorSubject, catchError, combineLatest, map, Observable, of, switchMap} from 'rxjs'; @@ -203,6 +204,48 @@ export class TabService { ); } + /** + * Activated external-plugin configurations that expose ≥1 `case-widget` bundle, grouped so the + * widget config editor can offer two combo boxes (configuration, then widget bundle). The widget + * `case-widget` counterpart of {@link getExternalPluginConfigs}. Degrades to an empty list when the + * external-plugin endpoints are unavailable (module absent). + */ + public getExternalPluginWidgetConfigs(): Observable { + return combineLatest([ + this.externalPluginService.getDefinitions(), + this.externalPluginService.getConfigurations(), + ]).pipe( + map(([definitions, configurations]) => { + const lang = this.translateService.currentLang; + const definitionById = new Map(definitions.map(definition => [definition.id, definition])); + + return configurations.reduce( + (options, configuration) => { + const definition = definitionById.get(configuration.definitionId); + if (!definition || definition.status !== 'AVAILABLE') return options; + + const caseWidgetBundles = (definition.manifest?.frontendBundles ?? []).filter( + bundle => bundle.type === 'case-widget' + ); + if (!caseWidgetBundles.length) return options; + + options.push({ + configId: configuration.id, + label: `${configuration.title} (${getExternalPluginDisplayName(definition, lang)})`, + bundles: caseWidgetBundles.map(bundle => ({ + key: bundle.key ?? null, + title: bundle.title ?? bundle.key ?? 'case-widget', + })), + }); + return options; + }, + [] + ); + }), + catchError(() => of([] as ExternalPluginWidgetConfigOption[])) + ); + } + public getFormDefinitions(route: ActivatedRoute): Observable { return getCaseManagementRouteParams(route).pipe( switchMap((params: CaseManagementParams) => { diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.html b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.html new file mode 100644 index 0000000000..14bd280486 --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.html @@ -0,0 +1,48 @@ + + +
+
+ +
+ +
+ {{ 'caseManagement.tabManagement.externalPlugin.loadError' | translate }} +
+ +
+ {{ 'caseManagement.tabManagement.externalPlugin.unavailable' | translate }} +
+ + +
+ +
+ + +
+
diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss new file mode 100644 index 0000000000..d8f97e44ca --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss @@ -0,0 +1,44 @@ +/*! + * 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. + */ + +.case-widget-external-plugin { + display: flex; + flex-direction: column; + width: 100%; + // The iframe has no intrinsic height (no host-side resize handling), so the card gets a sensible + // default. A one-off fixed box — no Carbon spacing token maps to a widget content height. + height: 320px; + + &__status { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + padding: var(--cds-spacing-05); + color: var(--cds-text-secondary); + } + + &__status--error { + color: var(--cds-text-error); + } + + valtimo-external-plugin-iframe { + display: block; + flex: 1 1 auto; + width: 100%; + height: 100%; + } +} diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts new file mode 100644 index 0000000000..85dace8f88 --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts @@ -0,0 +1,129 @@ +/* + * 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. + */ + +import {CommonModule} from '@angular/common'; +import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, signal} from '@angular/core'; +import {TranslateModule} from '@ngx-translate/core'; +import {ExternalPluginWidget, WidgetLayoutService} from '@valtimo/layout'; +import { + derivePluginDataUrl, + ExternalPluginIframeComponent, + ExternalPluginSessionService, +} from '@valtimo/plugin'; +import {LoadingModule} from 'carbon-components-angular'; +import { + BehaviorSubject, + combineLatest, + filter, + map, + Observable, + Subscription, + switchMap, + throwError, +} from 'rxjs'; +import {CaseTabService, CaseWidgetsApiService} from '../../../../../../services'; +import {ExternalPluginWidgetContent, ExternalPluginWidgetState} from '../../../../../../models'; + +@Component({ + selector: 'valtimo-case-widget-external-plugin', + templateUrl: './case-widget-external-plugin.component.html', + styleUrls: ['./case-widget-external-plugin.component.scss'], + standalone: true, + providers: [ExternalPluginSessionService], + imports: [CommonModule, LoadingModule, TranslateModule, ExternalPluginIframeComponent], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CaseWidgetExternalPluginComponent implements OnInit, OnDestroy { + @Input({required: true}) public set documentId(value: string) { + this._documentId$.next(value); + } + + @Input() public set widgetConfiguration(value: ExternalPluginWidget) { + if (!value) return; + this._widgetConfiguration$.next(value); + } + + @Input() public readonly widgetUuid: string; + + public readonly $state = signal('loading'); + public readonly $content = signal(null); + public readonly $pluginDataUrl = signal(null); + public readonly $iframeReady = signal(false); + + private readonly _documentId$ = new BehaviorSubject(null); + private readonly _widgetConfiguration$ = new BehaviorSubject(null); + private readonly _tabKey$: Observable = this.caseTabService.activeTabKey$; + + private readonly _subscriptions = new Subscription(); + + constructor( + private readonly caseTabService: CaseTabService, + private readonly caseWidgetsApiService: CaseWidgetsApiService, + private readonly widgetLayoutService: WidgetLayoutService, + protected readonly sessionService: ExternalPluginSessionService + ) {} + + public ngOnInit(): void { + this._subscriptions.add( + combineLatest([ + this._documentId$.pipe(filter((documentId): documentId is string => !!documentId)), + this._widgetConfiguration$.pipe( + filter((widget): widget is ExternalPluginWidget => !!widget) + ), + this._tabKey$, + ]) + .pipe( + switchMap(([documentId, widget, tabKey]) => + ( + this.caseWidgetsApiService.getWidgetData( + documentId, + tabKey, + widget.key + ) as unknown as Observable + ).pipe( + switchMap(content => + content?.bundleUrl && content.configurationId + ? this.sessionService + .startSession(content.configurationId) + .pipe(map(() => content)) + : throwError(() => new Error('bundle-unavailable')) + ) + ) + ) + ) + .subscribe({ + next: content => this.onLoaded(content), + error: error => + this.$state.set(error?.message === 'bundle-unavailable' ? 'unavailable' : 'error'), + }) + ); + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + public onIframeReady(): void { + this.$iframeReady.set(true); + if (this.widgetUuid) this.widgetLayoutService.setWidgetDataLoaded(this.widgetUuid); + } + + private onLoaded(content: ExternalPluginWidgetContent): void { + this.$content.set(content); + this.$pluginDataUrl.set(derivePluginDataUrl(content.bundleUrl)); + this.$state.set('ready'); + } +} diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/widgets.component.ts b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/widgets.component.ts index 0c59f1ac42..f875e75519 100644 --- a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/widgets.component.ts +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/widgets.component.ts @@ -21,15 +21,15 @@ import {CarbonListModule, WidgetLayout} from '@valtimo/components'; import {LoadingModule} from 'carbon-components-angular'; import {combineLatest, filter, map, Observable, shareReplay, startWith, switchMap} from 'rxjs'; import {CaseTabService, CaseWidgetsApiService} from '../../../../services'; -import {CaseWidgetsRes} from '../../../../models'; +import {CaseWidgetsRes, DocumentUpdatedSseEvent} from '../../../../models'; import { BasicWidget, - WidgetComponentMap, - WidgetContainerComponent, - WidgetType, DividerWidget, Widget, + WidgetComponentMap, + WidgetContainerComponent, WidgetGroup, + WidgetType, } from '@valtimo/layout'; import {CaseWidgetFieldComponent} from './components/field/case-widget-field.component'; import {CaseWidgetCustomComponent} from './components/custom/case-widget-custom.component'; @@ -41,7 +41,7 @@ import {CaseWidgetPersonCardComponent} from './components/person-card/case-widge import {CaseWidgetMetrolineComponent} from './components/metroline/case-widget-metroline.component'; import {CaseWidgetHighlightComponent} from './components/highlight/case-widget-highlight.component'; import {CaseWidgetImageComponent} from './components/image/case-widget-image.component'; -import {DocumentUpdatedSseEvent} from '../../../../models'; +import {CaseWidgetExternalPluginComponent} from './components/external-plugin/case-widget-external-plugin.component'; import {SseService} from '@valtimo/sse'; import {WidgetsService} from './widgets.service'; import {isEqual} from 'lodash-es'; @@ -115,6 +115,7 @@ export class CaseDetailWidgetsComponent implements OnInit, OnDestroy { [WidgetType.METROLINE]: CaseWidgetMetrolineComponent, [WidgetType.HIGHLIGHT]: CaseWidgetHighlightComponent, [WidgetType.IMAGE]: CaseWidgetImageComponent, + [WidgetType.EXTERNAL_PLUGIN]: CaseWidgetExternalPluginComponent, }; constructor( diff --git a/frontend/projects/valtimo/case/src/lib/models/external-plugin-widget.model.ts b/frontend/projects/valtimo/case/src/lib/models/external-plugin-widget.model.ts new file mode 100644 index 0000000000..20788ac668 --- /dev/null +++ b/frontend/projects/valtimo/case/src/lib/models/external-plugin-widget.model.ts @@ -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. + */ + +interface ExternalPluginWidgetContext { + documentId: string; + caseDefinitionKey: string; + caseDefinitionVersionTag: string; + pluginConfigurationId: string | null; +} + +/** + * Descriptor returned by the widget-data endpoint for an `external-plugin` widget. Mirrors the + * case-tab content but the configuration id may be `null` (a widget that imported dangling), in + * which case `bundleUrl` is `null` and the widget renders an unavailable state. + */ +interface ExternalPluginWidgetContent { + bundleUrl: string | null; + configurationId: string | null; + bundleKey: string | null; + context: ExternalPluginWidgetContext; +} + +type ExternalPluginWidgetState = 'loading' | 'ready' | 'error' | 'unavailable'; + +export {ExternalPluginWidgetContext, ExternalPluginWidgetContent, ExternalPluginWidgetState}; diff --git a/frontend/projects/valtimo/case/src/lib/models/index.ts b/frontend/projects/valtimo/case/src/lib/models/index.ts index 6234ef3f51..3ac711f26d 100644 --- a/frontend/projects/valtimo/case/src/lib/models/index.ts +++ b/frontend/projects/valtimo/case/src/lib/models/index.ts @@ -26,3 +26,4 @@ export * from './case-list-quick-search.model'; export * from './case-widget.model'; export * from './case-inspection.models'; export * from './external-plugin-tab.model'; +export * from './external-plugin-widget.model'; diff --git a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.html b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.html new file mode 100644 index 0000000000..0a959af1e4 --- /dev/null +++ b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.html @@ -0,0 +1,72 @@ + + +
+
+ + + + + + + + +
+ + + + + + + + + + + + +

+ {{ 'widgetTabManagement.content.externalPlugin.unavailableMessage' | translate }} +

+
+
diff --git a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.scss b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.scss new file mode 100644 index 0000000000..3fcff75749 --- /dev/null +++ b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.scss @@ -0,0 +1,40 @@ +/*! + * 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. + */ + +.valtimo-widget-management-external-plugin { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-05); + + ::ng-deep .cds--text-input, + ::ng-deep .cds--list-box, + ::ng-deep .cds--list-box__field { + background-color: var(--cds-layer-02); + } + + &__title-container { + display: flex; + gap: var(--cds-spacing-05); + } + + &__title-input { + max-width: 300px; + } + + &__unavailable { + color: var(--cds-text-secondary); + } +} diff --git a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.ts b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.ts new file mode 100644 index 0000000000..eb3383e154 --- /dev/null +++ b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.ts @@ -0,0 +1,198 @@ +/* + * 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. + */ +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + Inject, + OnDestroy, + OnInit, + Optional, + signal, +} from '@angular/core'; +import {AbstractControl, FormBuilder, ReactiveFormsModule, Validators} from '@angular/forms'; +import {TranslateModule} from '@ngx-translate/core'; +import {InputLabelModule, MdiIconSelectorComponent} from '@valtimo/components'; +import { + ComboBoxModule, + DropdownModule, + InputModule, + LayerModule, + ListItem, +} from 'carbon-components-angular'; +import {Subscription} from 'rxjs'; +import { + EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN, + ExternalPluginWidgetConfigOption, + ExternalPluginWidgetConfigProvider, +} from '../../../../constants'; +import {WidgetExternalPluginContent} from '../../../../models'; +import {WidgetWizardService} from '../../../../services'; + +@Component({ + templateUrl: './widget-management-external-plugin.component.html', + styleUrls: ['./widget-management-external-plugin.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + TranslateModule, + InputModule, + ReactiveFormsModule, + ComboBoxModule, + DropdownModule, + LayerModule, + MdiIconSelectorComponent, + InputLabelModule, + ], +}) +export class WidgetManagementExternalPluginComponent implements OnInit, OnDestroy { + public readonly form = this.fb.group({ + widgetTitle: this.fb.control(this.widgetWizardService.$widgetTitle(), Validators.required), + widgetIcon: this.fb.control(this.widgetWizardService.$widgetIcon()), + }); + + public get widgetTitle(): AbstractControl | null { + return this.form.get('widgetTitle'); + } + + public get widgetIcon(): AbstractControl | null { + return this.form.get('widgetIcon'); + } + + public readonly $tokenAvailable = signal(!!this.configProvider); + + public readonly $configOptions = signal([]); + + private readonly _$selectedConfigId = signal(null); + private readonly _$selectedBundleKey = signal(null); + + public readonly $configItems = computed(() => + this.$configOptions().map(option => ({ + content: option.label, + configId: option.configId, + selected: option.configId === this._$selectedConfigId(), + })) + ); + + private readonly _$selectedConfig = computed( + () => + this.$configOptions().find(option => option.configId === this._$selectedConfigId()) ?? null + ); + + public readonly $showBundleSelect = computed( + () => (this._$selectedConfig()?.bundles.length ?? 0) > 1 + ); + + public readonly $bundleItems = computed(() => + (this._$selectedConfig()?.bundles ?? []).map(bundle => ({ + content: bundle.title, + bundleKey: bundle.key, + selected: bundle.key === this._$selectedBundleKey(), + })) + ); + + private readonly _subscriptions = new Subscription(); + + constructor( + @Optional() + @Inject(EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN) + private readonly configProvider: ExternalPluginWidgetConfigProvider, + private readonly fb: FormBuilder, + private readonly widgetWizardService: WidgetWizardService + ) { + effect(() => + this.widgetWizardService.$widgetContentValid.set( + !!this._$selectedConfigId() && (!this.$showBundleSelect() || !!this._$selectedBundleKey()) + ) + ); + } + + public ngOnInit(): void { + this.openTitleSubscription(); + this.openIconSubscription(); + this.loadConfigOptions(); + this.prefill(); + } + + public ngOnDestroy(): void { + this._subscriptions.unsubscribe(); + } + + public onConfigSelected(item: (ListItem & {configId?: string}) | null): void { + const configId = item?.configId ?? null; + this._$selectedConfigId.set(configId); + this._$selectedBundleKey.set(null); + + const config = this._$selectedConfig(); + // A single-bundle configuration needs no second choice — resolve it now (a null bundle key means + // the plugin ships one key-less bundle, which the resolver selects on its own). + if (config && config.bundles.length === 1) { + this._$selectedBundleKey.set(config.bundles[0].key); + } + this.updateContent(); + } + + public onBundleSelected(item: (ListItem & {bundleKey?: string | null}) | null): void { + this._$selectedBundleKey.set(item?.bundleKey ?? null); + this.updateContent(); + } + + private updateContent(): void { + const configurationId = this._$selectedConfigId(); + if (!configurationId) { + this.widgetWizardService.$widgetContent.set(null); + return; + } + const bundleKey = this._$selectedBundleKey(); + this.widgetWizardService.$widgetContent.set({ + configurationId, + ...(bundleKey ? {bundleKey} : {}), + } as WidgetExternalPluginContent); + } + + private loadConfigOptions(): void { + if (!this.configProvider) return; + this._subscriptions.add( + this.configProvider.getConfigOptions().subscribe(options => this.$configOptions.set(options)) + ); + } + + private openTitleSubscription(): void { + this._subscriptions.add( + this.widgetTitle?.valueChanges.subscribe(title => + this.widgetWizardService.$widgetTitle.set(title) + ) + ); + } + + private openIconSubscription(): void { + this._subscriptions.add( + this.widgetIcon?.valueChanges.subscribe(icon => + this.widgetWizardService.$widgetIcon.set(icon) + ) + ); + } + + private prefill(): void { + const content = this.widgetWizardService.$widgetContent() as WidgetExternalPluginContent | null; + if (!content?.configurationId) return; + this._$selectedConfigId.set(content.configurationId); + this._$selectedBundleKey.set(content.bundleKey ?? null); + } +} diff --git a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/index.ts b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/index.ts index b84e0a3e95..806a3e09b7 100644 --- a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/index.ts +++ b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/index.ts @@ -18,6 +18,7 @@ export * from './fields/widget-management-fields.component'; export * from './table/widget-management-table.component'; export * from './collection/widget-management-collection.component'; export * from './custom/widget-management-custom.component'; +export * from './external-plugin/widget-management-external-plugin.component'; export * from './map/widget-management-map.component'; export * from './person-card/widget-management-person-card.component'; export * from './metroline/widget-management-metroline.component'; diff --git a/frontend/projects/valtimo/layout/src/lib/constants/external-plugin-widget-token.ts b/frontend/projects/valtimo/layout/src/lib/constants/external-plugin-widget-token.ts new file mode 100644 index 0000000000..5d5a084fdc --- /dev/null +++ b/frontend/projects/valtimo/layout/src/lib/constants/external-plugin-widget-token.ts @@ -0,0 +1,52 @@ +/* + * 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. + */ + +import {InjectionToken} from '@angular/core'; +import {Observable} from 'rxjs'; + +/** One `case-widget` bundle a plugin configuration exposes. */ +interface ExternalPluginWidgetBundleOption { + key: string | null; + title: string; +} + +/** An activated plugin configuration that exposes at least one `case-widget` bundle. */ +interface ExternalPluginWidgetConfigOption { + configId: string; + label: string; + bundles: ExternalPluginWidgetBundleOption[]; +} + +/** + * Supplies the external-plugin widget config editor with the selectable plugin configurations + + * their `case-widget` bundles. Implemented by an app that has `@valtimo/plugin` available (e.g. + * case-management), so `@valtimo/layout` needs no dependency on `@valtimo/plugin` — mirrors + * {@link CUSTOM_WIDGET_TOKEN}. When the token is absent the editor renders a disabled/empty state. + */ +interface ExternalPluginWidgetConfigProvider { + getConfigOptions(): Observable; +} + +const EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN = new InjectionToken( + 'Provides the selectable external-plugin configurations and their case-widget bundles.' +); + +export { + EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN, + ExternalPluginWidgetBundleOption, + ExternalPluginWidgetConfigOption, + ExternalPluginWidgetConfigProvider, +}; diff --git a/frontend/projects/valtimo/layout/src/lib/constants/index.ts b/frontend/projects/valtimo/layout/src/lib/constants/index.ts index 88d67c5796..888df35ab7 100644 --- a/frontend/projects/valtimo/layout/src/lib/constants/index.ts +++ b/frontend/projects/valtimo/layout/src/lib/constants/index.ts @@ -15,6 +15,7 @@ */ export * from './custom-widget-token'; +export * from './external-plugin-widget-token'; export * from './layout.test-ids'; export * from './widget.constants'; export * from './widget-management.constants'; diff --git a/frontend/projects/valtimo/layout/src/lib/models/widget-content.model.ts b/frontend/projects/valtimo/layout/src/lib/models/widget-content.model.ts index 4ae5e3f95c..02648ec623 100644 --- a/frontend/projects/valtimo/layout/src/lib/models/widget-content.model.ts +++ b/frontend/projects/valtimo/layout/src/lib/models/widget-content.model.ts @@ -86,6 +86,11 @@ interface WidgetCustomContent { componentValue: {[key: string]: string}; } +interface WidgetExternalPluginContent { + configurationId: string; + bundleKey?: string; +} + interface WidgetFormioContent { formDefinitionName: string; } @@ -213,6 +218,7 @@ type WidgetContentProperties = | WidgetTableContent | WidgetInteractiveTableContent | WidgetCustomContent + | WidgetExternalPluginContent | WidgetFormioContent | WidgetCollectionContent | WidgetMapContent @@ -225,6 +231,7 @@ type WidgetContentProperties = export { WidgetContentProperties, WidgetCustomContent, + WidgetExternalPluginContent, WidgetFieldsContent, WidgetFormioContent, WidgetTableContent, diff --git a/frontend/projects/valtimo/layout/src/lib/models/widget-editor.model.ts b/frontend/projects/valtimo/layout/src/lib/models/widget-editor.model.ts index b38d84ab06..d60b182812 100644 --- a/frontend/projects/valtimo/layout/src/lib/models/widget-editor.model.ts +++ b/frontend/projects/valtimo/layout/src/lib/models/widget-editor.model.ts @@ -35,6 +35,7 @@ const WidgetTypeTags: Record = { [WidgetType.METROLINE]: 'light-green' as TagType, [WidgetType.PERSON_CARD]: 'yellow' as TagType, [WidgetType.HIGHLIGHT]: 'periwinkle' as TagType, + [WidgetType.EXTERNAL_PLUGIN]: 'cool-gray' as TagType, }; export {WidgetManagementTab, WidgetTypeTags}; diff --git a/frontend/projects/valtimo/layout/src/lib/models/widget-wizard.model.ts b/frontend/projects/valtimo/layout/src/lib/models/widget-wizard.model.ts index fa36e49767..4293ca335d 100644 --- a/frontend/projects/valtimo/layout/src/lib/models/widget-wizard.model.ts +++ b/frontend/projects/valtimo/layout/src/lib/models/widget-wizard.model.ts @@ -19,12 +19,13 @@ import {Type} from '@angular/core'; import { WidgetManagementCollectionComponent, WidgetManagementCustomComponent, + WidgetManagementExternalPluginComponent, WidgetManagementFieldsComponent, WidgetManagementHighlightComponent, WidgetManagementImageComponent, WidgetManagementMapComponent, - WidgetManagementPersonCardComponent, WidgetManagementMetrolineComponent, + WidgetManagementPersonCardComponent, WidgetManagementTableComponent, } from '../components/widget-management/management-content'; import {WidgetManagementInteractiveTableComponent} from '../components/widget-management/management-content/interactive-table/widget-management-interactive-table.component'; @@ -160,6 +161,13 @@ const AVAILABLE_WIDGETS: WidgetTypeSelection[] = [ type: WidgetType.IMAGE, component: WidgetManagementImageComponent, }, + { + titleKey: 'widgetTabManagement.type.externalPlugin.title', + descriptionKey: 'widgetTabManagement.type.externalPlugin.description', + illustrationUrl: 'valtimo-layout/img/widget-management/types/angular.svg', + type: WidgetType.EXTERNAL_PLUGIN, + component: WidgetManagementExternalPluginComponent, + }, ]; const WIDGET_WIDTH_LABELS: {[key: number]: string} = { diff --git a/frontend/projects/valtimo/layout/src/lib/models/widget.model.ts b/frontend/projects/valtimo/layout/src/lib/models/widget.model.ts index 6de7dbca25..56e3c3a01c 100644 --- a/frontend/projects/valtimo/layout/src/lib/models/widget.model.ts +++ b/frontend/projects/valtimo/layout/src/lib/models/widget.model.ts @@ -19,13 +19,14 @@ import { WidgetCollectionContent, WidgetContentProperties, WidgetCustomContent, + WidgetExternalPluginContent, WidgetFieldsContent, WidgetHighlightContent, - WidgetInteractiveTableContent, WidgetImageContent, + WidgetInteractiveTableContent, WidgetMapContent, - WidgetPersonCardContent, WidgetMetrolineContent, + WidgetPersonCardContent, WidgetTableContent, } from './widget-content.model'; import {WidgetDisplayType} from './widget-display.model'; @@ -43,6 +44,7 @@ enum WidgetType { HIGHLIGHT = 'highlight', PERSON_CARD = 'person-card', IMAGE = 'image', + EXTERNAL_PLUGIN = 'external-plugin', } enum WidgetColor { @@ -174,6 +176,11 @@ interface ImageWidget extends BasicWidget { properties: WidgetImageContent; } +interface ExternalPluginWidget extends BasicWidget { + type: WidgetType.EXTERNAL_PLUGIN; + properties: WidgetExternalPluginContent; +} + type Widget = | FieldsWidget | CollectionWidget @@ -186,7 +193,8 @@ type Widget = | MapWidget | MetrolineWidget | HighlightWidget - | ImageWidget; + | ImageWidget + | ExternalPluginWidget; type WidgetWithUuid = Widget & { uuid: string; @@ -258,10 +266,15 @@ type OptionalWidgets = | WidgetType.PERSON_CARD | WidgetType.METROLINE | WidgetType.HIGHLIGHT - | WidgetType.IMAGE; - -type WidgetComponentMap = - Record, Type> & + | WidgetType.IMAGE + // Only the case surface renders this (as a sandboxed iframe); other surfaces (iko, the layout + // default) omit it, so it must be optional in the component map. + | WidgetType.EXTERNAL_PLUGIN; + +type WidgetComponentMap = Record< + Exclude, + Type +> & Partial>>; type WidgetContext = 'case' | 'iko'; @@ -288,6 +301,7 @@ export { CollectionWidget, CustomWidgetConfig, CustomWidget, + ExternalPluginWidget, TableWidget, InteractiveTableWidget, MapWidget, diff --git a/frontend/projects/valtimo/layout/src/lib/services/widget-wizard.service.ts b/frontend/projects/valtimo/layout/src/lib/services/widget-wizard.service.ts index 4e224f56ba..efd63e28c1 100644 --- a/frontend/projects/valtimo/layout/src/lib/services/widget-wizard.service.ts +++ b/frontend/projects/valtimo/layout/src/lib/services/widget-wizard.service.ts @@ -113,7 +113,12 @@ export class WidgetWizardService { const selectedType = this.$selectedWidget()?.type; return !selectedType ? false - : [WidgetType.COLLECTION, WidgetType.FIELDS, WidgetType.TABLE].includes(selectedType); + : [ + WidgetType.COLLECTION, + WidgetType.FIELDS, + WidgetType.TABLE, + WidgetType.EXTERNAL_PLUGIN, + ].includes(selectedType); }, }, [WidgetWizardStep.APPEARANCE]: { @@ -122,7 +127,12 @@ export class WidgetWizardService { const selectedType = this.$selectedWidget()?.type; return ( !!selectedType && - [WidgetType.FIELDS, WidgetType.COLLECTION, WidgetType.TABLE].includes(selectedType) + [ + WidgetType.FIELDS, + WidgetType.COLLECTION, + WidgetType.TABLE, + WidgetType.EXTERNAL_PLUGIN, + ].includes(selectedType) ); }, }, diff --git a/frontend/projects/valtimo/shared/assets/core/en.json b/frontend/projects/valtimo/shared/assets/core/en.json index 9b734d0d4d..3d370f9476 100644 --- a/frontend/projects/valtimo/shared/assets/core/en.json +++ b/frontend/projects/valtimo/shared/assets/core/en.json @@ -1010,7 +1010,8 @@ "invisible": "Not visible" }, "externalPlugin": { - "loadError": "Could not load the external plugin tab. The plugin host may be unreachable or the bundle is unavailable." + "loadError": "Could not load the external plugin tab. The plugin host may be unreachable or the bundle is unavailable.", + "unavailable": "This plugin widget is unavailable. Its configuration may be missing or the bundle could not be resolved." }, "titles": { "custom": "Create new tab with custom component", @@ -1313,6 +1314,10 @@ "title": "Image", "description": "Show an uploaded image" }, + "externalPlugin": { + "title": "External plugin", + "description": "Render a card from an external plugin's case-widget bundle" + }, "generic": { "title": "Generic widgets", "description": "Broadly applicable — wire them up to any data structure (fields, tables, collections). More configuration, but no limits on what they can show." @@ -1362,6 +1367,14 @@ "widgetTitleTooltip": "Title that displays at the top of the widget", "hideWhenEmpty": "Hide when empty", "column": "Column {{number}}", + "externalPlugin": { + "configLabel": "Plugin configuration", + "configPlaceholder": "Choose a plugin configuration", + "bundleLabel": "Widget", + "bundlePlaceholder": "Choose a widget", + "unavailableTitle": "External plugins unavailable", + "unavailableMessage": "No external plugin exposing a case-widget bundle is available." + }, "fields": { "add": "Add field", "addEnum": "Add enum value", diff --git a/frontend/projects/valtimo/shared/assets/core/nl.json b/frontend/projects/valtimo/shared/assets/core/nl.json index 61061b7744..1123dc895e 100644 --- a/frontend/projects/valtimo/shared/assets/core/nl.json +++ b/frontend/projects/valtimo/shared/assets/core/nl.json @@ -1017,7 +1017,8 @@ "invisible": "Niet zichtbaar" }, "externalPlugin": { - "loadError": "Het externe plugin-tabblad kon niet worden geladen. De plugin-host is mogelijk onbereikbaar of de bundle is niet beschikbaar." + "loadError": "Het externe plugin-tabblad kon niet worden geladen. De plugin-host is mogelijk onbereikbaar of de bundle is niet beschikbaar.", + "unavailable": "Deze pluginwidget is niet beschikbaar. Mogelijk ontbreekt de configuratie of kon de bundle niet worden gevonden." }, "titles": { "custom": "Nieuw tabblad aanmaken met aangepaste component", @@ -1332,6 +1333,10 @@ "title": "Afbeelding", "description": "Toon een geüploade afbeelding" }, + "externalPlugin": { + "title": "Externe plugin", + "description": "Toon een kaart uit de case-widget-bundle van een externe plugin" + }, "generic": { "title": "Generieke widgets", "description": "Breed toepasbaar — koppel ze aan elke gegevensstructuur (velden, tabellen, collecties). Meer configuratie, maar geen beperkingen op wat ze kunnen tonen." @@ -1384,6 +1389,14 @@ "widgetTitleTooltip": "Titel die bovenaan de widget wordt weergegeven", "hideWhenEmpty": "Verbergen indien leeg", "column": "Kolom {{number}}", + "externalPlugin": { + "configLabel": "Pluginconfiguratie", + "configPlaceholder": "Kies een pluginconfiguratie", + "bundleLabel": "Widget", + "bundlePlaceholder": "Kies een widget", + "unavailableTitle": "Externe plugins niet beschikbaar", + "unavailableMessage": "Er is geen externe plugin met een case-widget-bundle beschikbaar." + }, "fields": { "add": "Veld toevoegen", "addEnum": "Enum-waarde toevoegen", diff --git a/plugin-host/docs/external-plugin-system-plan.md b/plugin-host/docs/external-plugin-system-plan.md index 41b4cfcbc7..86d8296c49 100644 --- a/plugin-host/docs/external-plugin-system-plan.md +++ b/plugin-host/docs/external-plugin-system-plan.md @@ -909,7 +909,7 @@ there is no force override on any path. | Entity | Blocked when… | Surface | |--------|---------------|---------| | **ProcessLink** | never (BPMN authoring is the source of truth — the case definition is the gate) | — | -| **Configuration** (external) | any *fixed* `ProcessLink` (a `BUILDING_BLOCK` reference carries no configuration id and never blocks, §19) **or** any `EXTERNAL_PLUGIN` case tab references it | Server-side guard in `ExternalPluginConfigurationService.delete` throws `ExternalPluginConfigurationInUseException` (HTTP 409, `usages` payload). `ExternalPluginHostUsageResolver` folds in process-link usages **and** case-tab usages (via the case module's `CaseExternalPluginTabService.findUsagesForConfiguration`, §13.1). UI runs the pre-check and shows the read-only `PluginUsageModalComponent`, which renders both row kinds. No override. | +| **Configuration** (external) | any *fixed* `ProcessLink` (a `BUILDING_BLOCK` reference carries no configuration id and never blocks, §19), any `EXTERNAL_PLUGIN` case tab, **or** any `external-plugin` case widget references it | Server-side guard in `ExternalPluginConfigurationService.delete` throws `ExternalPluginConfigurationInUseException` (HTTP 409, `usages` payload). `ExternalPluginHostUsageResolver` folds in process-link usages, case-tab usages (via `CaseExternalPluginTabService.findUsagesForConfiguration`, §13.1) **and** case-widget usages (via `CaseExternalPluginWidgetService.findUsagesForConfiguration`, §13.7). UI runs the pre-check and shows the read-only `PluginUsageModalComponent`, which renders all row kinds. No override. | | **Configuration** (embedded) | any *fixed* `PluginProcessLink` references it | Server-side guard in `PluginService.deletePluginConfiguration` throws `PluginConfigurationInUseException` (HTTP 409, `usages` payload). Same UI flow and modal. | | **Definition** | any `Configuration` exists for it | Not directly user-deletable; cleared by the discovery cycle when the upstream host no longer lists the version **and** no configurations remain. | | **Host** | any `Configuration` under any definition on this host has at least one *fixed* `ProcessLink` referencing it | Server-side guard in `ExternalPluginHostService.delete` throws `ExternalPluginHostInUseException` (HTTP 409, `usages` payload). Host delete in the UI shows the same `PluginUsageModalComponent`. Deletion of an entire host with active configurations remains blocked: removing the host would orphan service tokens, push paths, and broker bindings for live configurations. | @@ -987,7 +987,7 @@ in one place (matching the external edit modal). `PluginUsageModalComponent` is translation keys. i18n lives under `pluginManagement.{deleteConfigurationModal, hostInUseModal, configurationInUseModal, usageModal}` in `en.json` / `nl.json`. -## 13. Iframe surfaces & user-scoped access ✅ (case tab, task form, menu page) +## 13. Iframe surfaces & user-scoped access ✅ (case tab, task form, menu page, case widget) A plugin's iframe surfaces need to call GZAC **on behalf of the logged-in user** (respect what the user can see/do), and the plugin **backend** may call GZAC either as the user or as the system. @@ -1286,18 +1286,84 @@ Structure: `/submit-task` `request()` handler completing via `gzacApi.asUser`, with `permissions.endpoints` granting `{ "method": "POST", "pattern": "/api/v1/task/*/complete" }`, the only bundle that needs it). +### 13.7 Case-widget surface (`external-plugin` case-widget subtype) ✅ + +Unlike the case *tab* (§13.1, a whole tab rendered as one iframe), a case *widget* is a **card inside +a `WIDGETS` tab's grid**, alongside the first-party widgets (fields, table, map, …). It is a new +**widget subtype** in the existing case-widget system — not a new tab type — rendered as the same +sandboxed `` and reusing the identical security model (parent-proxy, +downscoped user token, opaque origin) as the case tab. The bundle uses the same SDK surface: +`sdk.callValtimo`, `sdk.getPluginData`, `sdk.t`. No SDK / pack tool / host changes were needed +(`case-widget` was already a member of `FRONTEND_BUNDLE_TYPES` and the generic +`ExternalPluginBundleUrlResolver` already resolves it). + +Structure: + +- **Widget subtype.** `ExternalPluginCaseWidget` (`@DiscriminatorValue("external-plugin")`) extends + `CaseWidgetTabWidget` on the single-table-inheritance `case_widget_tab_widget` table. Unlike the + `custom` widget (a JSON `properties` column) the config maps to **dedicated, queryable columns** + (`external_plugin_configuration_id`, `bundle_key`, `plugin_definition_key`, + `plugin_definition_version`; changelog `13-32-0/20260731-add-external-plugin-case-widget.xml`) so the + delete guard and dangling-repair panel can find widgets by configuration id portably across the + Postgres/MySQL dual database support. The DTO nests these under `properties` (frontend-consistent, + like `custom`); `ExternalPluginCaseWidgetMapper` bridges the two. +- **Render descriptor.** Rendering + data reuse the existing widget endpoints — no new REST endpoint. + `GET .../widget-tab/{tabKey}` returns the widget DTO; `GET .../widget-tab/{tabKey}/widget/{widgetKey}` + runs the matching `CaseWidgetDataProvider`. `ExternalPluginCaseWidgetDataProvider` returns an + `ExternalPluginWidgetContentDto { bundleUrl, configurationId, bundleKey, context }` (context = + `{ documentId, caseDefinitionKey, caseDefinitionVersionTag, pluginConfigurationId }`). Resolving the + bundle URL lives in the data provider (not the mapper's `toDto`) because only it has the `documentId` + to build the context, and it reuses the endpoint's per-widget PBAC check (`CaseWidgetTabWidget` VIEW + with document context). `bundleUrl` is `null` when the resolver is absent or the configuration is + dangling — the frontend then shows an unavailable state, matching the tab. +- **Resolver SPI.** `ExternalPluginCaseWidgetResolver` (declared in `case`, implemented in + `external-plugin` as `ExternalPluginCaseWidgetResolverImpl`) delegates to the shared + `ExternalPluginBundleUrlResolver.resolve(configId, "case-widget", bundleKey)` and reuses the + `ExternalPluginTabDefinition` data shape — the widget sibling of `ExternalPluginCaseTabResolver` + (Optional, so `case` runs without external-plugin on the classpath). +- **Admin UX** (`@valtimo/layout` + `@valtimo/case-management`). A new `external-plugin` entry in the + widget wizard's `AVAILABLE_WIDGETS`, with a CONTENT-step editor (`WidgetManagementExternalPluginComponent`) + that offers two combo boxes — configuration, then `case-widget` bundle (auto-selected when the plugin + ships exactly one) — and writes `properties: { configurationId, bundleKey }`. Layout reads its config + options from an injected `EXTERNAL_PLUGIN_WIDGET_CONFIG_TOKEN` (like `CUSTOM_WIDGET_TOKEN`), so + `@valtimo/layout` keeps **no dependency on `@valtimo/plugin`**; case-management provides the token + (reusing the `getExternalPluginConfigs` pattern filtered to `case-widget`). The type appears in the + "add widget" picker only when at least one activated configuration exposes a `case-widget` bundle. The + generic WIDTH / DENSITY / APPEARANCE / DISPLAY_CONDITIONS wizard steps apply for full parity. +- **Runtime** (`@valtimo/case`). The case `widgetComponentMap` maps `external-plugin` → + `CaseWidgetExternalPluginComponent` (merged over the layout default; no layout registry change). It + provides a page-scoped `ExternalPluginSessionService`, fetches the descriptor via the widget-data + endpoint, mints the downscoped user token, and renders `` — the same + wiring as `CaseDetailExternalPluginTabComponent`. +- **Import / export parity + dangling repair.** See §20. The exporter stamps the plugin id/version on + each `external-plugin` widget (self-describing export); the importer remaps `configurationId` via + `pluginConfigurationMappings` (keeping the original, now-dangling id when a mapping is left unset, + like the tab) and triggers an in-transaction issue recheck. A dedicated issue type + `external-plugin-case-widget` flows through the existing dangling/mapping endpoints and repair UI + (`source: external`). `CaseExternalPluginWidgetService` (in `case`) backs the queries/mutations, + mirroring `CaseExternalPluginTabService`. +- **Delete guard.** `ExternalPluginHostUsageResolver` folds widget usages + (`CaseExternalPluginWidgetService.findUsagesForConfiguration`) into `findUsagesForConfiguration`/ + `findUsagesForHost`, so a configuration referenced by any widget blocks configuration/host deletion + (§12), surfaced in the read-only in-use modal (`PluginUsageDto` gains an optional `widgetKey`). +- **Sample** (`case-summary`). Ships **two** `case-widget` bundles — `summary-widget` + (`frontend/case-widget.tsx`, a compact card using `sdk.getPluginData` + `sdk.callValtimo` + `sdk.t`) + and `metrics-widget` (`frontend/case-widget-metrics.tsx`, a tiles card) — so the admin editor's second + combo box (the bundle picker) is exercised when a configuration exposes more than one bundle. + ## 14. Not yet implemented ⛔ - HTMX `render_page` (only the RPC-style `handle_request` for JSON data is implemented). -- Case **widgets** (the remaining iframe surface — the case **tab** (§13.1), **task form** (§13.6), - and **menu pages** (§13) are done). - DLQ for nacked or expired messages (today `nack(false,false)` drops, `x-expires` deletes the queue and its contents). +All iframe surfaces are now implemented: case **tab** (§13.1), **task form** (§13.6), +**menu pages** (§13) and case **widgets** (§13.7). + ## 15. Roadmap (priority order) -1. Remaining iframe surfaces: HTMX pages, case widgets (case **tab** §13.1, **task form** §13.6, - and **menu pages** §13 done). +1. Remaining iframe surfaces: HTMX pages (case **tab** §13.1, **task form** §13.6, + **menu pages** §13 and case **widgets** §13.7 done). 2. Cleanup: align async-vs-sync SDK docs. ## 16. Verification status @@ -2064,8 +2130,9 @@ later. `externalPluginConfigurationId`). One user-facing `Map` covers both plugin systems — source UUIDs are unambiguous across them. - **Preview.** `ExternalPluginImportPreviewContributor` (`backend/external-plugin/preview/`) scans - `*.process-link.json` for `external_plugin` / `external_plugin_task_form` links **and** - `*.case-tab.json` for `EXTERNAL_PLUGIN` tabs, emitting entries with `source: external`, + `*.process-link.json` for `external_plugin` / `external_plugin_task_form` links, + `*.case-tab.json` for `EXTERNAL_PLUGIN` tabs, **and** `*.case-widget-tab.json` for `external-plugin` + widgets (`pluginActionDefinitionKey: "case-widget"`), emitting entries with `source: external`, pluginId, version, and an existence check. `PluginConfigurationPreviewDto` carries the `source: embedded|external` discriminator (default `embedded`). - **Dangling handling.** An unmapped external link imports with a null configuration id; both @@ -2078,6 +2145,14 @@ later. the `case_external_plugin_tab` side row for imported tabs (the REST-driven create path derives the side row from `CaseTabCreatedEvent`, which a repository-level import does not fire — the importer therefore materialises it itself). +- **`external-plugin` case-widget import.** `CaseWidgetTabImporter` remaps each widget's + `properties.configurationId` through the same mappings (keeping the original, now-dangling id when + a mapping is left unset — like the tab, so the repair panel can offer a chooser from that source + id), and its `afterImport` triggers `recheckIssuesForCaseDefinition` on the mapping resolvers so a + dangling widget raises the `external-plugin-case-widget` issue at import time (widgets aren't + process links). The exporter stamps each widget's `pluginDefinitionKey`/version so the export is + self-describing. `CaseExternalPluginWidgetService` (in `case`) backs the resolver's dangling + detection + remap and the delete guard's usage lookup, mirroring `CaseExternalPluginTabService`. - **Wizard & repair UI** (`@valtimo/case-management`). The upload wizard's PLUGINS step and the missing-plugin-configurations repair component render external rows with options from `ExternalPluginService.getConfigurations()`, exact-version matches by default and diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.html b/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.html new file mode 100644 index 0000000000..c5ade00754 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.html @@ -0,0 +1,16 @@ + + + + + + Case Summary — Metrics Widget + + + +
+ + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.tsx b/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.tsx new file mode 100644 index 0000000000..a975050d92 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-widget-metrics.tsx @@ -0,0 +1,129 @@ +/* + * 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. + */ + +import React, {useEffect, useState} from "react"; +import {createRoot} from "react-dom/client"; +import {ValtimoPluginSDK} from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const cardStyle: React.CSSProperties = { + fontFamily: "IBM Plex Sans, sans-serif", + padding: "16px", + fontSize: "14px", + color: "#393939", +}; + +const titleStyle: React.CSSProperties = { + fontSize: "14px", + fontWeight: 600, + color: "#161616", + marginBottom: "8px", +}; +const mutedStyle: React.CSSProperties = {color: "#6f6f6f", marginBottom: "12px"}; +const errorStyle: React.CSSProperties = {color: "#da1e28"}; + +const tilesStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "1fr 1fr", + gap: "8px", +}; +const tileStyle: React.CSSProperties = { + background: "#f4f4f4", + padding: "12px", + borderRadius: "0", +}; +const tileLabelStyle: React.CSSProperties = {fontSize: "12px", color: "#6f6f6f"}; +const tileValueStyle: React.CSSProperties = {fontSize: "20px", fontWeight: 600, color: "#161616"}; + +interface SummaryData { + currency: string; + viewCount: number; +} + +type LoadState = + | {state: "loading"} + | {state: "error"; message: string} + | {state: "ready"; data: T}; + +/** The Angular parent has no host-side resize handling for widgets; still emit so future surfaces can use it. */ +function useResizeEmitter(deps: unknown[]): void { + useEffect(() => { + sdk.emit("resize", {height: document.documentElement.scrollHeight}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function shortId(documentId: string | null): string { + if (!documentId) return "—"; + return documentId.length > 8 ? `${documentId.slice(0, 8)}…` : documentId; +} + +function CaseMetricsWidget() { + const ctx = sdk.getContext() ?? {}; + const documentId = (ctx.documentId as string | undefined) ?? null; + + const [pluginData, setPluginData] = useState>({state: "loading"}); + + useEffect(() => { + sdk + .getPluginData("/summary") + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setPluginData({state: "ready", data: res.body as SummaryData}); + } else { + setPluginData({state: "error", message: sdk.t("caseMetricsWidget.error")}); + } + }) + .catch((err) => setPluginData({state: "error", message: String(err?.message ?? err)})); + }, []); + + useResizeEmitter([pluginData]); + + return ( +
+
{sdk.t("caseMetricsWidget.title")}
+
{sdk.t("caseMetricsWidget.hello")}
+ + {pluginData.state === "loading" &&
{sdk.t("caseMetricsWidget.loading")}
} + {pluginData.state === "error" &&
{pluginData.message}
} + {pluginData.state === "ready" && ( +
+
+
{sdk.t("caseMetricsWidget.viewCount")}
+
{pluginData.data.viewCount}
+
+
+
{sdk.t("caseMetricsWidget.currency")}
+
{pluginData.data.currency}
+
+
+
{sdk.t("caseMetricsWidget.documentId")}
+
{shortId(documentId)}
+
+
+ )} +
+ ); +} + +// Wait for the SDK to receive the manifest + init (context) before mounting, so sdk.t and +// sdk.getContext() are populated. +sdk.ready().then(() => { + sdk.emit("ready", {}); + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-widget.html b/plugin-host/sample-plugins/case-summary/frontend/case-widget.html new file mode 100644 index 0000000000..2ee27db552 --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-widget.html @@ -0,0 +1,16 @@ + + + + + + Case Summary — Widget + + + +
+ + + diff --git a/plugin-host/sample-plugins/case-summary/frontend/case-widget.tsx b/plugin-host/sample-plugins/case-summary/frontend/case-widget.tsx new file mode 100644 index 0000000000..687e49d84c --- /dev/null +++ b/plugin-host/sample-plugins/case-summary/frontend/case-widget.tsx @@ -0,0 +1,158 @@ +/* + * 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. + */ + +import React, {useEffect, useState} from "react"; +import {createRoot} from "react-dom/client"; +import {ValtimoPluginSDK} from "@valtimo/plugin-sdk/frontend"; + +const sdk = new ValtimoPluginSDK(); + +const cardStyle: React.CSSProperties = { + fontFamily: "IBM Plex Sans, sans-serif", + padding: "16px", + fontSize: "14px", + color: "#393939", +}; + +const titleRowStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "8px", +}; + +const titleStyle: React.CSSProperties = {fontSize: "14px", fontWeight: 600, color: "#161616"}; +const mutedStyle: React.CSSProperties = {color: "#6f6f6f"}; +const errorStyle: React.CSSProperties = {color: "#da1e28"}; +const badgeStyle: React.CSSProperties = { + fontSize: "12px", + color: "#6f6f6f", + background: "#e0e0e0", + borderRadius: "12px", + padding: "2px 10px", +}; +const rowStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + padding: "4px 0", + borderBottom: "1px solid #f4f4f4", +}; + +interface SummaryData { + message: string; + viewCount: number; +} + +type LoadState = + | {state: "loading"} + | {state: "error"; message: string} + | {state: "ready"; data: T}; + +/** The Angular parent has no host-side resize handling for widgets; still emit so future surfaces can use it. */ +function useResizeEmitter(deps: unknown[]): void { + useEffect(() => { + sdk.emit("resize", {height: document.documentElement.scrollHeight}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} + +function CaseWidget() { + const ctx = sdk.getContext() ?? {}; + const documentId = (ctx.documentId as string | undefined) ?? null; + + const [pluginData, setPluginData] = useState>({state: "loading"}); + const [definition, setDefinition] = useState>({state: "loading"}); + + // Plugin-served data (via the plugin's own handle_request handler over the /data route). + useEffect(() => { + sdk + .getPluginData("/summary") + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setPluginData({state: "ready", data: res.body as SummaryData}); + } else { + setPluginData({state: "error", message: sdk.t("caseWidget.error")}); + } + }) + .catch((err) => setPluginData({state: "error", message: String(err?.message ?? err)})); + }, []); + + // Valtimo case data, scoped to the logged-in user (GZAC via the downscoped user token). + useEffect(() => { + if (!documentId) { + setDefinition({state: "error", message: sdk.t("caseWidget.error")}); + return; + } + sdk + .callValtimo("GET", `/api/v1/document/${documentId}`) + .then((res) => { + if (res.status >= 200 && res.status < 300) { + setDefinition({state: "ready", data: describeDefinition(res.body as Record)}); + } else { + setDefinition({state: "error", message: sdk.t("caseWidget.error")}); + } + }) + .catch((err) => setDefinition({state: "error", message: String(err?.message ?? err)})); + }, [documentId]); + + useResizeEmitter([pluginData, definition]); + + return ( +
+
+ {sdk.t("caseWidget.title")} + {pluginData.state === "ready" && ( + + {sdk.t("caseWidget.viewCount")}: {pluginData.data.viewCount} + + )} +
+ +
{sdk.t("caseWidget.hello")}
+ + {pluginData.state === "loading" &&
{sdk.t("caseWidget.loading")}
} + {pluginData.state === "error" &&
{pluginData.message}
} + {pluginData.state === "ready" &&
{pluginData.data.message}
} + +
+ {sdk.t("caseWidget.definition")} + + {definition.state === "loading" && sdk.t("caseWidget.loading")} + {definition.state === "error" && {definition.message}} + {definition.state === "ready" && definition.data} + +
+
+ ); +} + +function describeDefinition(document: Record): string { + const definitionId = document.definitionId as + | {name?: string; blueprintId?: {blueprintVersionTag?: string}} + | undefined; + if (!definitionId) return "(unknown)"; + const name = definitionId.name ?? "unknown"; + const version = definitionId.blueprintId?.blueprintVersionTag; + return version ? `${name} v${version}` : name; +} + +// Wait for the SDK to receive the manifest + init (context) before mounting, so sdk.t and +// sdk.getContext() are populated. +sdk.ready().then(() => { + sdk.emit("ready", {}); + const root = createRoot(document.getElementById("root")!); + root.render(); +}); diff --git a/plugin-host/sample-plugins/case-summary/manifest.json b/plugin-host/sample-plugins/case-summary/manifest.json index 731f8acf69..782d3f57f2 100644 --- a/plugin-host/sample-plugins/case-summary/manifest.json +++ b/plugin-host/sample-plugins/case-summary/manifest.json @@ -85,7 +85,20 @@ "caseTab.external.todoTitle": "Todo", "caseTab.external.todoCompleted": "Completed", "caseTab.external.error": "Could not load external data.", - "caseTab.viewCount": "Tab views" + "caseTab.viewCount": "Tab views", + "caseWidget.title": "Case summary", + "caseWidget.hello": "This card is rendered by the case-summary plugin's case-widget bundle.", + "caseWidget.loading": "Loading…", + "caseWidget.definition": "Case definition", + "caseWidget.viewCount": "Tab views", + "caseWidget.error": "Could not load widget data.", + "caseMetricsWidget.title": "Case metrics", + "caseMetricsWidget.hello": "A second case-widget bundle from the same plugin, selected by its bundle key.", + "caseMetricsWidget.loading": "Loading…", + "caseMetricsWidget.viewCount": "Tab views", + "caseMetricsWidget.currency": "Currency", + "caseMetricsWidget.documentId": "Document id", + "caseMetricsWidget.error": "Could not load metrics data." }, "nl": { "name": "Zaaksamenvatting", @@ -165,7 +178,20 @@ "caseTab.external.todoTitle": "Todo", "caseTab.external.todoCompleted": "Voltooid", "caseTab.external.error": "Kon externe gegevens niet laden.", - "caseTab.viewCount": "Tabbladweergaven" + "caseTab.viewCount": "Tabbladweergaven", + "caseWidget.title": "Zaaksamenvatting", + "caseWidget.hello": "Deze kaart wordt weergegeven door de case-widget-bundle van de case-summary plugin.", + "caseWidget.loading": "Laden…", + "caseWidget.definition": "Zaaktype", + "caseWidget.viewCount": "Tabbladweergaven", + "caseWidget.error": "Kon widgetgegevens niet laden.", + "caseMetricsWidget.title": "Zaakmetrieken", + "caseMetricsWidget.hello": "Een tweede case-widget-bundle van dezelfde plugin, geselecteerd op zijn bundle-sleutel.", + "caseMetricsWidget.loading": "Laden…", + "caseMetricsWidget.viewCount": "Tabbladweergaven", + "caseMetricsWidget.currency": "Valuta", + "caseMetricsWidget.documentId": "Document-id", + "caseMetricsWidget.error": "Kon metriekgegevens niet laden." } }, "configurationSchema": { @@ -212,6 +238,18 @@ "title": "Case Details", "path": "/bundles/case-tab-details.html" }, + { + "type": "case-widget", + "key": "summary-widget", + "title": "Case Summary Widget", + "path": "/bundles/case-widget.html" + }, + { + "type": "case-widget", + "key": "metrics-widget", + "title": "Case Metrics Widget", + "path": "/bundles/case-widget-metrics.html" + }, { "type": "task-form", "key": "approve", From a49730ec462904f8384b27e5892a2278019900d2 Mon Sep 17 00:00:00 2001 From: Ivo Zaal Date: Fri, 7 Aug 2026 13:01:45 +0200 Subject: [PATCH 2/2] Fixed comments --- .../ExternalPluginCaseWidget.kt | 7 +- .../ExternalPluginWidgetProperties.kt | 5 +- ...260731-add-external-plugin-case-widget.xml | 5 +- ...ternalPluginConfigurationInUseException.kt | 2 +- .../ExternalPluginHostInUseException.kt | 4 +- ...ernalPluginConfigurationMappingResolver.kt | 7 +- .../service/ExternalPluginHostService.kt | 5 +- .../ExternalPluginHostInUseExceptionTest.kt | 2 +- ...case-widget-external-plugin.component.html | 85 +++++++++++++------ ...case-widget-external-plugin.component.scss | 62 +++++++++++++- .../case-widget-external-plugin.component.ts | 59 +++++++++++-- ...-management-external-plugin.component.html | 2 + .../src/lib/models/widget-wizard.model.ts | 4 +- .../plugin-usage-modal.component.html | 33 ++++--- .../plugin-usage-modal.component.ts | 10 ++- .../src/lib/models/external-plugin.model.ts | 5 +- .../lib/services/external-plugin.service.ts | 8 +- .../valtimo/shared/assets/core/en.json | 4 +- .../valtimo/shared/assets/core/nl.json | 4 +- 19 files changed, 236 insertions(+), 77 deletions(-) diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt index 8e9ec94905..3fbd3929cb 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginCaseWidget.kt @@ -34,9 +34,10 @@ import java.util.UUID * guard and dangling-repair panel can find widgets by configuration id portably across the * Postgres/MySQL dual database support. * - * [externalPluginConfigurationId] is `null` for a widget that imported dangling (its configuration - * missing in this environment); [pluginDefinitionKey]/[pluginDefinitionVersion] then keep it - * identifiable in the repair panel. + * A widget that imported dangling (its configuration missing in this environment) keeps the + * original, now-unresolvable [externalPluginConfigurationId] rather than being nulled, so one repair + * mapping can re-point every widget that shared the source id; + * [pluginDefinitionKey]/[pluginDefinitionVersion] keep it identifiable in the repair panel. */ @AllOpen @Entity diff --git a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt index 1eb9cb2841..ede63a240c 100644 --- a/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt +++ b/backend/case/src/main/kotlin/com/ritense/case_/widget/externalplugin/ExternalPluginWidgetProperties.kt @@ -24,8 +24,9 @@ import java.util.UUID * `custom` widget) for frontend consistency; the mapper unpacks it into the dedicated, * queryable columns on `case_widget_tab_widget`. * - * [configurationId] is the external-plugin configuration backing the widget (`null` for a widget - * that imported dangling — its configuration missing in this environment). [bundleKey] selects the + * [configurationId] is the external-plugin configuration backing the widget. A widget that imported + * dangling — its configuration missing in this environment — keeps the original, now-unresolvable id + * rather than being nulled. [bundleKey] selects the * `case-widget` bundle when the plugin ships more than one; `null` selects the sole bundle. * [pluginDefinitionKey]/[pluginDefinitionVersion] are design-time plugin identity stamped on export * so the import preview can identify the plugin without resolving the configuration. diff --git a/backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml b/backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml index 5f0f4dc2b5..7e51f0416f 100644 --- a/backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml +++ b/backend/core/src/main/resources/config/liquibase/13-32-0/20260731-add-external-plugin-case-widget.xml @@ -23,8 +23,9 @@ -
-
- -
+
+ @if ($widget(); as widget) { +
+
+ -
- {{ 'caseManagement.tabManagement.externalPlugin.loadError' | translate }} -
+ + {{ widget.title }} + +
-
- {{ 'caseManagement.tabManagement.externalPlugin.unavailable' | translate }} -
+ @if ( + widget.actions?.length === 1 && + widget.actions[0]?.processDefinitionKey && + (canCreateCamundaExecution$ | async) + ) { + + } - -
+ +
+ } + +
+
- - +
+ {{ 'caseManagement.tabManagement.externalPlugin.widgetLoadError' | translate }} +
+ +
+ {{ 'caseManagement.tabManagement.externalPlugin.unavailable' | translate }} +
+ + +
+ +
+ + +
+
diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss index d8f97e44ca..15e1ac0f27 100644 --- a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.scss @@ -18,9 +18,48 @@ display: flex; flex-direction: column; width: 100%; - // The iframe has no intrinsic height (no host-side resize handling), so the card gets a sensible - // default. A one-off fixed box — no Carbon spacing token maps to a widget content height. - height: 320px; + padding: 24px; + box-sizing: border-box; + color: var(--widget-text-color, var(--cds-text-primary)); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 auto; + } + + &__title-container { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + + valtimo-mdi-icon-viewer .mdi-icon-preview { + color: var(--widget-text-color, var(--cds-text-primary)); + } + } + + &__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--widget-text-color, var(--cds-text-primary)); + font-weight: 600; + font-size: 16px; + line-height: 24px; + } + + &__body { + display: flex; + flex-direction: column; + flex: 1 1 auto; + margin-top: 16px; + // The iframe has no intrinsic height (no host-side resize handling), so the plugin canvas gets a + // sensible default. A one-off fixed box — no Carbon spacing token maps to a widget content + // height. The card itself is sized from measured content, so the header adds on top of this. + height: 320px; + } &__status { display: flex; @@ -41,4 +80,21 @@ width: 100%; height: 100%; } + + &--compact { + padding: 12px; + + .case-widget-external-plugin__title { + font-size: 14px; + line-height: 20px; + } + + .case-widget-external-plugin__body { + margin-top: 8px; + } + + .case-widget-external-plugin__status { + padding: var(--cds-spacing-03); + } + } } diff --git a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts index 85dace8f88..67fccb5e42 100644 --- a/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts +++ b/frontend/projects/valtimo/case/src/lib/components/case-detail/tab/widgets/components/external-plugin/case-widget-external-plugin.component.ts @@ -17,13 +17,21 @@ import {CommonModule} from '@angular/common'; import {ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, signal} from '@angular/core'; import {TranslateModule} from '@ngx-translate/core'; -import {ExternalPluginWidget, WidgetLayoutService} from '@valtimo/layout'; +import {PermissionService} from '@valtimo/access-control'; +import {MdiIconViewerComponent} from '@valtimo/components'; +import {DocumentService} from '@valtimo/document'; +import { + ExternalPluginWidget, + WidgetAction, + WidgetActionButtonComponent, + WidgetLayoutService, +} from '@valtimo/layout'; import { derivePluginDataUrl, ExternalPluginIframeComponent, ExternalPluginSessionService, } from '@valtimo/plugin'; -import {LoadingModule} from 'carbon-components-angular'; +import {ButtonModule, LoadingModule} from 'carbon-components-angular'; import { BehaviorSubject, combineLatest, @@ -36,6 +44,8 @@ import { } from 'rxjs'; import {CaseTabService, CaseWidgetsApiService} from '../../../../../../services'; import {ExternalPluginWidgetContent, ExternalPluginWidgetState} from '../../../../../../models'; +import {WidgetsService} from '../../widgets.service'; +import {WidgetProcess} from '../widget-process/widget-process'; @Component({ selector: 'valtimo-case-widget-external-plugin', @@ -43,21 +53,33 @@ import {ExternalPluginWidgetContent, ExternalPluginWidgetState} from '../../../. styleUrls: ['./case-widget-external-plugin.component.scss'], standalone: true, providers: [ExternalPluginSessionService], - imports: [CommonModule, LoadingModule, TranslateModule, ExternalPluginIframeComponent], + imports: [ + ButtonModule, + CommonModule, + LoadingModule, + TranslateModule, + ExternalPluginIframeComponent, + MdiIconViewerComponent, + WidgetActionButtonComponent, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class CaseWidgetExternalPluginComponent implements OnInit, OnDestroy { +export class CaseWidgetExternalPluginComponent extends WidgetProcess implements OnInit, OnDestroy { @Input({required: true}) public set documentId(value: string) { + this.baseDocumentId = value; this._documentId$.next(value); } @Input() public set widgetConfiguration(value: ExternalPluginWidget) { if (!value) return; + this.$widget.set(value); + this.baseWidgetConfiguration = value; this._widgetConfiguration$.next(value); } @Input() public readonly widgetUuid: string; + public readonly $widget = signal(null); public readonly $state = signal('loading'); public readonly $content = signal(null); public readonly $pluginDataUrl = signal(null); @@ -70,11 +92,16 @@ export class CaseWidgetExternalPluginComponent implements OnInit, OnDestroy { private readonly _subscriptions = new Subscription(); constructor( + protected readonly documentService: DocumentService, + protected readonly permissionService: PermissionService, private readonly caseTabService: CaseTabService, private readonly caseWidgetsApiService: CaseWidgetsApiService, private readonly widgetLayoutService: WidgetLayoutService, + private readonly widgetsService: WidgetsService, protected readonly sessionService: ExternalPluginSessionService - ) {} + ) { + super(documentService, permissionService); + } public ngOnInit(): void { this._subscriptions.add( @@ -106,8 +133,7 @@ export class CaseWidgetExternalPluginComponent implements OnInit, OnDestroy { ) .subscribe({ next: content => this.onLoaded(content), - error: error => - this.$state.set(error?.message === 'bundle-unavailable' ? 'unavailable' : 'error'), + error: error => this.onFailed(error), }) ); } @@ -118,7 +144,12 @@ export class CaseWidgetExternalPluginComponent implements OnInit, OnDestroy { public onIframeReady(): void { this.$iframeReady.set(true); - if (this.widgetUuid) this.widgetLayoutService.setWidgetDataLoaded(this.widgetUuid); + this.markDataLoaded(); + } + + public onProcessStartClick(process: WidgetAction): void { + if (!process.processDefinitionKey) return; + this.widgetsService.startProcess(process.processDefinitionKey); } private onLoaded(content: ExternalPluginWidgetContent): void { @@ -126,4 +157,16 @@ export class CaseWidgetExternalPluginComponent implements OnInit, OnDestroy { this.$pluginDataUrl.set(derivePluginDataUrl(content.bundleUrl)); this.$state.set('ready'); } + + private onFailed(error: Error | null): void { + this.$state.set(error?.message === 'bundle-unavailable' ? 'unavailable' : 'error'); + // The widget will never reach iframe-ready, so release the container's loading state here — + // otherwise a tab (or divider group) holding only failed external-plugin widgets spins forever + // and keeps the error/unavailable message hidden. Mirrors what first-party widgets do on a 404. + this.markDataLoaded(); + } + + private markDataLoaded(): void { + if (this.widgetUuid) this.widgetLayoutService.setWidgetDataLoaded(this.widgetUuid); + } } diff --git a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.html b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.html index 0a959af1e4..652ff45942 100644 --- a/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.html +++ b/frontend/projects/valtimo/layout/src/lib/components/widget-management/management-content/external-plugin/widget-management-external-plugin.component.html @@ -37,6 +37,8 @@ >
+ + - +

{{ titleTranslationKey | translate }} @@ -53,7 +47,9 @@

@@ -68,7 +64,14 @@

{{ usage.activityId }}
-
+
+ {{ 'pluginManagement.usageModal.caseWidget' | translate }} + {{ usage.widgetKey }} +
+
{{ 'pluginManagement.usageModal.caseTab' | translate }}
{{ usage.parentKey }} - + {{ usage.parentVersionTag }} - +
{{ usage.processDefinitionName || usage.processDefinitionKey }}
diff --git a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts index a6574ba07c..00cc51453b 100644 --- a/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts +++ b/frontend/projects/valtimo/plugin-management/src/lib/components/plugin-usage-modal/plugin-usage-modal.component.ts @@ -28,9 +28,10 @@ import {ExternalPluginHostUsage, ExternalPluginHostUsageParentType} from '@valti /** * Read-only modal shown when an admin tries to delete an external plugin entity (a host or a - * configuration) that is still referenced by one or more BPMN process links. The list mirrors - * the `usages` payload the backend would attach to a 409 from the corresponding `DELETE` - * endpoint; only "Close" is offered — there is no force-delete. + * configuration) that is still referenced by one or more BPMN process links, case tabs, case + * widgets or building-block mappings. The list mirrors the `usages` payload the backend would + * attach to a 409 from the corresponding `DELETE` endpoint; only "Close" is offered — there is no + * force-delete. * * The parent supplies the heading + description translation keys so the same modal can be * reused for hosts and configurations (and any future entity with the same usage shape). @@ -68,6 +69,9 @@ export class PluginUsageModalComponent { if (usage.processLinkId) { return usage.processLinkId; } + if (usage.widgetKey) { + return `widget:${usage.parentKey}:${usage.tabKey}:${usage.widgetKey}`; + } if (usage.tabKey) { return `tab:${usage.parentKey}:${usage.tabKey}`; } diff --git a/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts b/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts index 955d1ff61a..5b4b33b02e 100644 --- a/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts +++ b/frontend/projects/valtimo/plugin/src/lib/models/external-plugin.model.ts @@ -268,9 +268,12 @@ interface ExternalPluginHostUsage { activityId: string | null; activityName: string | null; processLinkId: string | null; - // Populated only for an external-plugin case-tab usage. + // Populated for an external-plugin case-tab usage, and for an external-plugin case-widget usage + // (where they identify the owning WIDGETS tab). tabKey?: string | null; tabName?: string | null; + // Populated only for an external-plugin case-widget usage; names the widget within the tab. + widgetKey?: string | null; // Populated only for a building-block mapping usage (the BB's pluginConfigurationMappings // reference the configuration); names the building block holding the mapping. buildingBlockKey?: string | null; diff --git a/frontend/projects/valtimo/plugin/src/lib/services/external-plugin.service.ts b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin.service.ts index 127dad64fa..e509e51d97 100644 --- a/frontend/projects/valtimo/plugin/src/lib/services/external-plugin.service.ts +++ b/frontend/projects/valtimo/plugin/src/lib/services/external-plugin.service.ts @@ -64,7 +64,8 @@ export class ExternalPluginService { } /** - * Returns the BPMN process links currently referencing any configuration under the host. + * Returns what currently references any configuration under the host — BPMN process links, + * external-plugin case tabs and case widgets, and building-block mappings. * Empty list = safe to delete. A non-empty list is also what the backend will attach to a * 409 if the user tries to delete anyway, so the UI uses this proactively to disable the * delete action. @@ -142,8 +143,9 @@ export class ExternalPluginService { /** * Same shape as [getHostUsages] but scoped to a single configuration. Empty list = safe to - * delete. Non-empty = the configuration is referenced by one or more BPMN process links and - * `deleteConfiguration` would fail with a 409 carrying these same entries. + * delete. Non-empty = the configuration is referenced by one or more process links, case tabs, + * case widgets or building-block mappings, and `deleteConfiguration` would fail with a 409 + * carrying these same entries. */ public getConfigurationUsages( configurationId: string diff --git a/frontend/projects/valtimo/shared/assets/core/en.json b/frontend/projects/valtimo/shared/assets/core/en.json index 3d370f9476..ead3ee4b86 100644 --- a/frontend/projects/valtimo/shared/assets/core/en.json +++ b/frontend/projects/valtimo/shared/assets/core/en.json @@ -1011,6 +1011,7 @@ }, "externalPlugin": { "loadError": "Could not load the external plugin tab. The plugin host may be unreachable or the bundle is unavailable.", + "widgetLoadError": "Could not load the external plugin widget. The plugin host may be unreachable or the bundle is unavailable.", "unavailable": "This plugin widget is unavailable. Its configuration may be missing or the bundle could not be resolved." }, "titles": { @@ -1314,7 +1315,7 @@ "title": "Image", "description": "Show an uploaded image" }, - "externalPlugin": { + "external-plugin": { "title": "External plugin", "description": "Render a card from an external plugin's case-widget bundle" }, @@ -2377,6 +2378,7 @@ "GLOBAL": "Global" }, "caseTab": "Case tab", + "caseWidget": "Case widget", "buildingBlockMapping": "Building block plugin mapping" }, "compatibility": { diff --git a/frontend/projects/valtimo/shared/assets/core/nl.json b/frontend/projects/valtimo/shared/assets/core/nl.json index 1123dc895e..313a65c004 100644 --- a/frontend/projects/valtimo/shared/assets/core/nl.json +++ b/frontend/projects/valtimo/shared/assets/core/nl.json @@ -1018,6 +1018,7 @@ }, "externalPlugin": { "loadError": "Het externe plugin-tabblad kon niet worden geladen. De plugin-host is mogelijk onbereikbaar of de bundle is niet beschikbaar.", + "widgetLoadError": "De externe pluginwidget kon niet worden geladen. De plugin-host is mogelijk onbereikbaar of de bundle is niet beschikbaar.", "unavailable": "Deze pluginwidget is niet beschikbaar. Mogelijk ontbreekt de configuratie of kon de bundle niet worden gevonden." }, "titles": { @@ -1333,7 +1334,7 @@ "title": "Afbeelding", "description": "Toon een geüploade afbeelding" }, - "externalPlugin": { + "external-plugin": { "title": "Externe plugin", "description": "Toon een kaart uit de case-widget-bundle van een externe plugin" }, @@ -2404,6 +2405,7 @@ "GLOBAL": "Globaal" }, "caseTab": "Zaaktabblad", + "caseWidget": "Zaakwidget", "buildingBlockMapping": "Plugin-koppeling van bouwblok" }, "compatibility": {