Add distribution audience contracts and module wiring

This commit is contained in:
2026-07-31 20:59:49 +02:00
parent 6ebb299d6c
commit 4cb334c912
8 changed files with 548 additions and 4 deletions
+75 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@@ -12,6 +12,7 @@ from govoplan_core.core.events import PlatformEvent
CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle" CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle"
CAPABILITY_DATAFLOW_RUN_WORKER = "dataflow.runWorker" CAPABILITY_DATAFLOW_RUN_WORKER = "dataflow.runWorker"
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher" CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher"
CAPABILITY_DATAFLOW_DATASET_OUTPUT = "dataflow.dataset_output"
class DataflowRunError(ValueError): class DataflowRunError(ValueError):
@@ -30,6 +31,66 @@ class DataflowRunUnavailableError(DataflowRunError):
pass pass
@dataclass(frozen=True, slots=True)
class DataflowDatasetDescriptor:
pipeline_ref: str
name: str
revision: int
definition_hash: str
status: str
description: str | None = None
updated_at: datetime | None = None
parameters: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DataflowDatasetRequest:
pipeline_ref: str
revision: int
parameters: Mapping[str, object] = field(default_factory=dict)
row_limit: int = 500
expected_definition_hash: str | None = None
expected_source_fingerprints: tuple[Mapping[str, object], ...] = ()
@dataclass(frozen=True, slots=True)
class DataflowDatasetResult:
pipeline_ref: str
revision: int
definition_hash: str
rows: tuple[Mapping[str, object], ...]
total_rows: int
truncated: bool
output_hash: str
executor_version: str
run_ref: str | None = None
source_fingerprints: tuple[Mapping[str, object], ...] = ()
diagnostics: tuple[Mapping[str, object], ...] = ()
generated_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DataflowDatasetOutputProvider(Protocol):
def list_outputs(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DataflowDatasetDescriptor]: ...
def read_output(
self,
session: object,
principal: object,
*,
request: DataflowDatasetRequest,
) -> DataflowDatasetResult: ...
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class DataflowPublicationTarget: class DataflowPublicationTarget:
target_datasource_ref: str | None = None target_datasource_ref: str | None = None
@@ -184,6 +245,13 @@ def dataflow_trigger_dispatcher(
) )
def dataflow_dataset_output(
registry: object | None,
) -> DataflowDatasetOutputProvider | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_DATASET_OUTPUT)
return capability if isinstance(capability, DataflowDatasetOutputProvider) else None
def _capability(registry: object | None, name: str) -> object | None: def _capability(registry: object | None, name: str) -> object | None:
if ( if (
registry is None registry is None
@@ -199,6 +267,11 @@ __all__ = [
"CAPABILITY_DATAFLOW_RUN_LIFECYCLE", "CAPABILITY_DATAFLOW_RUN_LIFECYCLE",
"CAPABILITY_DATAFLOW_RUN_WORKER", "CAPABILITY_DATAFLOW_RUN_WORKER",
"CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER", "CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER",
"CAPABILITY_DATAFLOW_DATASET_OUTPUT",
"DataflowDatasetDescriptor",
"DataflowDatasetOutputProvider",
"DataflowDatasetRequest",
"DataflowDatasetResult",
"DataflowPublicationTarget", "DataflowPublicationTarget",
"DataflowRunConflictError", "DataflowRunConflictError",
"DataflowRunDescriptor", "DataflowRunDescriptor",
@@ -212,4 +285,5 @@ __all__ = [
"dataflow_run_lifecycle", "dataflow_run_lifecycle",
"dataflow_run_worker", "dataflow_run_worker",
"dataflow_trigger_dispatcher", "dataflow_trigger_dispatcher",
"dataflow_dataset_output",
] ]
@@ -0,0 +1,402 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
CAPABILITY_DISTRIBUTION_LIST_SOURCE = "dist_lists.source"
CAPABILITY_DISTRIBUTION_LIST_EXPAND = "dist_lists.expand"
CAPABILITY_DISTRIBUTION_LIST_WRITER = "dist_lists.writer"
CAPABILITY_RECIPIENT_CHANNEL_FACTS = "addresses.channel_facts"
CAPABILITY_POLICY_DISTRIBUTION_CHANNELS = "policy.distribution_channels"
DistributionDefinitionKind = Literal["static", "parameterized", "dynamic", "template"]
DistributionEntryMode = Literal["include", "exclude", "override"]
DistributionEntryKind = Literal[
"address_contact",
"address_list",
"address_email",
"raw_email",
"raw_postal_address",
"internal_mail",
"portal",
"idm_identity",
"idm_group",
"organization_unit",
"function",
"effective_function_incumbent",
"dataflow_result",
"distribution_list",
]
DistributionChannel = Literal["email", "postal", "internal_mail", "portal"]
DistributionOutcome = Literal[
"usable",
"unresolved",
"invalid",
"suppressed",
"ambiguous",
"duplicate",
"policy_blocked",
"provider_unavailable",
"stale",
]
DistributionExplanationSeverity = Literal["info", "warning", "error"]
class DistributionListError(ValueError):
"""Stable base error for provider-neutral distribution-list operations."""
class DistributionListNotFoundError(DistributionListError):
pass
class DistributionListConflictError(DistributionListError):
pass
class DistributionListUnavailableError(DistributionListError):
pass
@dataclass(frozen=True, slots=True)
class DistributionSourceReference:
provider: str
resource_type: str
resource_id: str
revision: str | None = None
fingerprint: str | None = None
label: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExplanation:
code: str
message: str
severity: DistributionExplanationSeverity = "warning"
provider: str | None = None
source: DistributionSourceReference | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionParameterDefinition:
key: str
value_type: Literal[
"string",
"integer",
"number",
"boolean",
"date",
"datetime",
"string_list",
]
label: str | None = None
required: bool = False
default: object | None = None
allowed_values: tuple[object, ...] = ()
minimum: float | None = None
maximum: float | None = None
pattern: str | None = None
description: str | None = None
@dataclass(frozen=True, slots=True)
class DistributionListEntryRef:
id: str
kind: DistributionEntryKind
mode: DistributionEntryMode
source: DistributionSourceReference
label: str | None = None
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
effective_from: datetime | None = None
effective_until: datetime | None = None
order: int = 0
configuration: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionListSourceRef:
id: str
tenant_id: str
name: str
revision_id: str
revision: int
definition_hash: str
definition_kind: DistributionDefinitionKind = "static"
description: str | None = None
status: str = "active"
entry_count: int = 0
read_only: bool = False
stale: bool = False
parameters: tuple[DistributionParameterDefinition, ...] = ()
updated_at: datetime | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExpansionLimits:
max_entries: int = 500
max_results: int = 5_000
max_depth: int = 8
max_provider_results: int = 2_000
@dataclass(frozen=True, slots=True)
class DistributionExpansionRequest:
list_id: str
revision: int | None = None
effective_at: datetime | None = None
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
parameters: Mapping[str, object] = field(default_factory=dict)
preview: bool = False
freeze: bool = False
idempotency_key: str | None = None
limits: DistributionExpansionLimits = field(
default_factory=DistributionExpansionLimits
)
@dataclass(frozen=True, slots=True)
class DistributionChannelCandidate:
channel: DistributionChannel
target: str
target_key: str
status: DistributionOutcome = "usable"
contact_point_id: str | None = None
locale: str | None = None
preferred: bool = False
reason_code: str | None = None
explanation: str | None = None
source: DistributionSourceReference | None = None
decision_provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionRecipientRef:
recipient_key: str
display_name: str
status: DistributionOutcome
channels: tuple[DistributionChannelCandidate, ...] = ()
identity_id: str | None = None
account_id: str | None = None
contact_id: str | None = None
organization_unit_id: str | None = None
function_id: str | None = None
source_entry_ids: tuple[str, ...] = ()
explanations: tuple[DistributionExplanation, ...] = ()
attributes: Mapping[str, object] = field(default_factory=dict)
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionProviderEvidence:
provider: str
source: DistributionSourceReference
actual_revision: str | None = None
actual_fingerprint: str | None = None
stale: bool = False
generated_at: datetime | None = None
details: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionExpansionResult:
source: DistributionListSourceRef
request: DistributionExpansionRequest
recipients: tuple[DistributionRecipientRef, ...]
excluded: tuple[DistributionRecipientRef, ...] = ()
diagnostics: tuple[DistributionExplanation, ...] = ()
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
expansion_hash: str = ""
generated_at: datetime | None = None
snapshot_id: str | None = None
stale: bool = False
truncated: bool = False
@dataclass(frozen=True, slots=True)
class DistributionSnapshotRef:
id: str
tenant_id: str
list_id: str
revision_id: str
revision: int
expansion_hash: str
generated_at: datetime
effective_at: datetime
recipient_count: int
excluded_count: int
stale: bool
truncated: bool
request: Mapping[str, object] = field(default_factory=dict)
recipients: tuple[DistributionRecipientRef, ...] = ()
excluded: tuple[DistributionRecipientRef, ...] = ()
diagnostics: tuple[DistributionExplanation, ...] = ()
provider_evidence: tuple[DistributionProviderEvidence, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionWriteDecision:
list_id: str | None
operation: str
allowed: bool
reason_code: str
explanation: str
read_only: bool = False
required_scopes: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientChannelFactsRequest:
tenant_id: str
source: DistributionSourceReference
recipient_key: str
effective_at: datetime
purpose: str | None = None
requested_channels: tuple[DistributionChannel, ...] = ()
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class RecipientChannelFacts:
candidates: tuple[DistributionChannelCandidate, ...]
explanations: tuple[DistributionExplanation, ...] = ()
source_revision: str | None = None
source_fingerprint: str | None = None
provenance: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionChannelPolicyRequest:
tenant_id: str
list_id: str
purpose: str | None
effective_at: datetime
recipient: DistributionRecipientRef
candidate: DistributionChannelCandidate
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class DistributionChannelPolicyDecision:
allowed: bool
reason_code: str
explanation: str
source_path: tuple[Mapping[str, object], ...] = ()
requirements: tuple[str, ...] = ()
details: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class DistributionListSourceProvider(Protocol):
def list_sources(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> Sequence[DistributionListSourceRef]: ...
def get_source(
self,
session: object,
principal: object,
*,
list_id: str,
revision: int | None = None,
) -> DistributionListSourceRef | None: ...
@runtime_checkable
class DistributionListExpansionProvider(Protocol):
def expand(
self,
session: object,
principal: object,
*,
request: DistributionExpansionRequest,
) -> DistributionExpansionResult: ...
def get_snapshot(
self,
session: object,
principal: object,
*,
snapshot_id: str,
) -> DistributionSnapshotRef | None: ...
@runtime_checkable
class DistributionListWriter(Protocol):
def explain_write(
self,
session: object,
principal: object,
*,
list_id: str | None,
operation: str,
) -> DistributionWriteDecision: ...
@runtime_checkable
class RecipientChannelFactsProvider(Protocol):
def resolve_channel_facts(
self,
session: object,
principal: object,
*,
request: RecipientChannelFactsRequest,
) -> RecipientChannelFacts: ...
@runtime_checkable
class DistributionChannelPolicyProvider(Protocol):
def resolve_distribution_channel(
self,
session: object,
principal: object,
*,
request: DistributionChannelPolicyRequest,
) -> DistributionChannelPolicyDecision: ...
def distribution_list_source_provider(
registry: object | None,
) -> DistributionListSourceProvider | None:
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_SOURCE)
return capability if isinstance(capability, DistributionListSourceProvider) else None
def distribution_list_expansion_provider(
registry: object | None,
) -> DistributionListExpansionProvider | None:
capability = _capability(registry, CAPABILITY_DISTRIBUTION_LIST_EXPAND)
return (
capability
if isinstance(capability, DistributionListExpansionProvider)
else None
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(name)
):
return None
return registry.capability(name)
__all__ = [name for name in globals() if name.startswith("CAPABILITY_") or name.startswith("Distribution") or name.startswith("Recipient") or name.startswith("distribution_")]
+1 -1
View File
@@ -49,7 +49,7 @@ class Settings(BaseSettings):
default=( default=(
"tenancy,organizations,identity,idm,access,admin,dashboard,policy," "tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors," "audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
"datasources,dataflow,workflow_engine,workflow,views,search,risk_compliance," "datasources,dataflow,dist_lists,workflow_engine,workflow,views,search,risk_compliance,"
"postbox,notifications,docs,ops" "postbox,notifications,docs,ops"
), ),
alias="ENABLED_MODULES", alias="ENABLED_MODULES",
+41
View File
@@ -0,0 +1,41 @@
from __future__ import annotations
import unittest
from govoplan_core.core.distribution_lists import (
CAPABILITY_DISTRIBUTION_LIST_EXPAND,
DistributionExpansionLimits,
DistributionExpansionRequest,
DistributionListEntryRef,
DistributionSourceReference,
)
class DistributionListContractTests(unittest.TestCase):
def test_mixed_entry_and_bounded_request_are_provider_neutral(self) -> None:
source = DistributionSourceReference(
provider="addresses",
resource_type="address_list",
resource_id="list-1",
revision="4",
fingerprint="sha256:abc",
)
entry = DistributionListEntryRef(
id="entry-1",
kind="address_list",
mode="include",
source=source,
requested_channels=("email",),
)
request = DistributionExpansionRequest(
list_id="distribution-1",
limits=DistributionExpansionLimits(max_results=25),
)
self.assertEqual("address_list", entry.kind)
self.assertEqual(25, request.limits.max_results)
self.assertEqual("dist_lists.expand", CAPABILITY_DISTRIBUTION_LIST_EXPAND)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -17,6 +17,7 @@
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui", "@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui", "@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui", "@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
"@govoplan/dist-lists-webui": "file:../../govoplan-dist-lists/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui", "@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/idm-webui": "file:../../govoplan-idm/webui",
@@ -220,6 +221,23 @@
} }
} }
}, },
"../../govoplan-dist-lists/webui": {
"name": "@govoplan/dist-lists-webui",
"version": "0.1.14",
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
},
"../../govoplan-docs/webui": { "../../govoplan-docs/webui": {
"name": "@govoplan/docs-webui", "name": "@govoplan/docs-webui",
"version": "0.1.10", "version": "0.1.10",
@@ -1273,6 +1291,10 @@
"resolved": "../../govoplan-datasources/webui", "resolved": "../../govoplan-datasources/webui",
"link": true "link": true
}, },
"node_modules/@govoplan/dist-lists-webui": {
"resolved": "../../govoplan-dist-lists/webui",
"link": true
},
"node_modules/@govoplan/docs-webui": { "node_modules/@govoplan/docs-webui": {
"resolved": "../../govoplan-docs/webui", "resolved": "../../govoplan-docs/webui",
"link": true "link": true
+1
View File
@@ -56,6 +56,7 @@
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui", "@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui", "@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui", "@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
"@govoplan/dist-lists-webui": "file:../../govoplan-dist-lists/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui", "@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui", "@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui", "@govoplan/idm-webui": "file:../../govoplan-idm/webui",
+4 -1
View File
@@ -11,6 +11,7 @@ const packageByModule = {
dashboard: "@govoplan/dashboard-webui", dashboard: "@govoplan/dashboard-webui",
dataflow: "@govoplan/dataflow-webui", dataflow: "@govoplan/dataflow-webui",
datasources: "@govoplan/datasources-webui", datasources: "@govoplan/datasources-webui",
dist_lists: "@govoplan/dist-lists-webui",
docs: "@govoplan/docs-webui", docs: "@govoplan/docs-webui",
files: "@govoplan/files-webui", files: "@govoplan/files-webui",
idm: "@govoplan/idm-webui", idm: "@govoplan/idm-webui",
@@ -41,6 +42,8 @@ const cases = [
{ name: "dataflow-only", modules: ["dataflow"] }, { name: "dataflow-only", modules: ["dataflow"] },
{ name: "datasources-only", modules: ["datasources"] }, { name: "datasources-only", modules: ["datasources"] },
{ name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] }, { name: "dataflow-with-datasources", modules: ["datasources", "dataflow"] },
{ name: "distribution-lists-only", modules: ["dist_lists"] },
{ name: "distribution-lists-with-providers", modules: ["addresses", "policy", "organizations", "idm", "dataflow", "dist_lists"] },
{ name: "workflow-only", modules: ["workflow"] }, { name: "workflow-only", modules: ["workflow"] },
{ name: "workflow-with-dataflow", modules: ["datasources", "dataflow", "workflow"] }, { name: "workflow-with-dataflow", modules: ["datasources", "dataflow", "workflow"] },
{ name: "views-only", modules: ["views"] }, { name: "views-only", modules: ["views"] },
@@ -61,7 +64,7 @@ const cases = [
{ name: "search-only", modules: ["search"] }, { name: "search-only", modules: ["search"] },
{ name: "risk-compliance-only", modules: ["risk_compliance"] }, { name: "risk-compliance-only", modules: ["risk_compliance"] },
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] }, { name: "docs-and-ops", modules: ["access", "docs", "ops"] },
{ name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] } { name: "full-product", modules: ["access", "tenancy", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "dist_lists", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] }
]; ];
const npmExec = process.env.npm_execpath; const npmExec = process.env.npm_execpath;
+2 -1
View File
@@ -1,4 +1,4 @@
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react"; import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, ListTree, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules"; import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules";
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types"; import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types";
import { import {
@@ -70,6 +70,7 @@ const iconByName: Record<string, LucideIcon> = {
form: Form, form: Form,
inbox: Inbox, inbox: Inbox,
"layout-template": LayoutTemplate, "layout-template": LayoutTemplate,
"list-tree": ListTree,
mail: Mail, mail: Mail,
notifications: Bell, notifications: Bell,
organizations: Building2, organizations: Building2,