Add shared automation and WebUI editing primitives

This commit is contained in:
2026-07-31 02:48:56 +02:00
parent f0898fcdee
commit 5b55f59a92
42 changed files with 3788 additions and 554 deletions
+9
View File
@@ -29,6 +29,10 @@ of module capabilities.
## Action Definition
An `ActionDefinition` describes something a human or system actor can request.
The versioned runtime DTOs and provider protocol live in
`govoplan_core.core.automation`; domain modules implement the protocol and
Workflow resolves providers through module capabilities rather than importing
their implementations.
Recommended fields:
@@ -110,6 +114,11 @@ Automation should use explicit failure states:
These states should be visible in workflow, task, and admin diagnostics.
The contract names these states explicitly as `ActionExecutionState`, alongside
`pending`, `running`, and `completed`. A provider returns observed effects even
for partial failures; the runner, not the provider, owns durable attempts,
recovery decisions, and workflow advancement.
## Boundary
Core may own stable DTOs, registry contracts, and generic audit/event hooks.
+5
View File
@@ -189,6 +189,11 @@ python -m celery -A govoplan_core.celery_app:celery beat --loglevel INFO
| `FILE_STORAGE_S3_ACCESS_KEY_ID` | empty | Secret; inject through deployment environment. |
| `FILE_STORAGE_S3_SECRET_ACCESS_KEY` | empty | Secret; inject through deployment environment. |
| `FILE_STORAGE_S3_BUCKET` | `files` | Managed-file object bucket. |
| `FILE_STORAGE_S3_DEPLOYMENT_MANAGED` | `false` | Reserved for the installer-owned `http://garage:3900` service. It does not authorize arbitrary internal or external S3 endpoints. |
| `FILE_ARCHIVE_MAX_ENTRIES` | `10000` | Maximum declared entries accepted during archive preview and confirmation. |
| `FILE_ARCHIVE_MAX_EXPANDED_BYTES` | `2147483648` | Maximum actual expanded bytes across selected archive content. |
| `FILE_ARCHIVE_MAX_EXPANSION_RATIO` | `100` | Maximum expanded-to-compressed archive ratio. |
| `FILE_ARCHIVE_PREVIEW_TTL_SECONDS` | `1800` | Lifetime of the sealed actor/archive/destination-bound preview token. |
Legacy `S3_*` settings remain for older storage paths but new deployments should
prefer `FILE_STORAGE_*`.
+19
View File
@@ -245,6 +245,25 @@ instead of reproducing their behavior.
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
`DismissibleAlert`. They never fall back to `window.alert`.
### DUE-012: Rich HTML Editing Contract
Decision: modules that edit persisted HTML use the central
`WysiwygEditor` exported from `@govoplan/core-webui/wysiwyg`.
- The dedicated subpath is intentional: the editor and its engine remain a
shared Core contract without adding their code to module combinations that
never consume rich-text editing.
- Consumers provide controlled HTML and domain-specific token labels. The
editor owns visual/source switching, formatting, links, images, safe URL
handling, and atomic inline token rendering; it does not own template
semantics or persistence.
- Existing HTML outside the supported visual subset opens in source mode.
Rendering the value must not rewrite it, and users receive an explicit
warning before choosing the visual surface.
- Domain placeholders remain their original serialized text. Atomic token
presentation is an editing aid only, so backend renderers and existing
templates do not need a new storage format.
#### FieldLabel Omission Register
Every Core field surface that intentionally does not render `FieldLabel` is
+210
View File
@@ -21,6 +21,172 @@ AutomationInvocationKind = Literal[
]
AutomationSubjectKind = Literal["delegated_user", "service_account"]
AUTOMATION_PRINCIPAL_CONTRACT_VERSION = "1"
ACTION_EFFECT_CONTRACT_VERSION = "1"
ActionRiskLevel = Literal["low", "moderate", "high", "critical"]
ActionReversibility = Literal[
"reversible",
"compensatable",
"corrective_only",
"irreversible",
]
ActionExecutionState = Literal[
"pending",
"running",
"completed",
"blocked",
"retryable",
"quarantined",
"manual_required",
"compensation_required",
]
EffectOperation = Literal[
"created",
"changed",
"deleted",
"sent",
"notified",
"locked",
"retained",
"external",
]
@dataclass(frozen=True, slots=True)
class EffectDefinition:
effect_key: str
owner_module: str
operation: EffectOperation
description: str
resource_types: tuple[str, ...] = ()
visibility_classification: str = "internal"
audit_event_types: tuple[str, ...] = ()
compensation_hint: str | None = None
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.effect_key, "Effect key")
_require_text(self.owner_module, "Effect owner module")
_require_text(self.description, "Effect description")
@dataclass(frozen=True, slots=True)
class ActionDefinition:
action_key: str
owner_module: str
description: str
input_schema_ref: str
required_scopes: tuple[str, ...] = ()
required_capabilities: tuple[str, ...] = ()
policy_checks: tuple[str, ...] = ()
risk_level: ActionRiskLevel = "moderate"
reversibility: ActionReversibility = "corrective_only"
expected_effect_keys: tuple[str, ...] = ()
idempotency_strategy: str = "caller_supplied"
audit_event_types: tuple[str, ...] = ()
preview_required: bool = True
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.action_key, "Action key")
_require_text(self.owner_module, "Action owner module")
_require_text(self.description, "Action description")
_require_text(self.input_schema_ref, "Action input schema reference")
_require_text(self.idempotency_strategy, "Action idempotency strategy")
if any(not value.strip() for value in self.required_scopes):
raise ValueError("Action scopes must not be empty")
if any(not value.strip() for value in self.required_capabilities):
raise ValueError("Action capabilities must not be empty")
if any(not value.strip() for value in self.expected_effect_keys):
raise ValueError("Expected effect keys must not be empty")
@dataclass(frozen=True, slots=True)
class ActionExecutionRequest:
tenant_id: str
action_key: str
input: Mapping[str, object]
idempotency_key: str
invocation: AutomationInvocation
actor_ref: str | None = None
preview_ref: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.tenant_id, "Action tenant id")
_require_text(self.action_key, "Action key")
_require_text(self.idempotency_key, "Action idempotency key")
@dataclass(frozen=True, slots=True)
class EffectPreview:
effect_key: str
summary: str
resource_refs: tuple[str, ...] = ()
external_system_refs: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ActionPreview:
action_key: str
allowed: bool
summary: str
risk_level: ActionRiskLevel
reversibility: ActionReversibility
effects: tuple[EffectPreview, ...] = ()
blockers: tuple[str, ...] = ()
policy_provenance: tuple[Mapping[str, object], ...] = ()
preview_ref: str | None = None
@dataclass(frozen=True, slots=True)
class ObservedEffect:
effect_key: str
operation: EffectOperation
resource_ref: str | None = None
external_system_ref: str | None = None
audit_event_ref: str | None = None
summary: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ActionExecutionResult:
state: ActionExecutionState
output: Mapping[str, object] = field(default_factory=dict)
observed_effects: tuple[ObservedEffect, ...] = ()
error: str | None = None
retry_after: datetime | None = None
manual_instructions: str | None = None
compensation_action_key: str | None = None
audit_event_refs: tuple[str, ...] = ()
@runtime_checkable
class ActionEffectProvider(Protocol):
def action_definitions(self) -> tuple[ActionDefinition, ...]: ...
def effect_definitions(self) -> tuple[EffectDefinition, ...]: ...
def preview_action(
self,
session: object,
principal: object,
*,
request: ActionExecutionRequest,
) -> ActionPreview: ...
def execute_action(
self,
session: object,
principal: object,
*,
request: ActionExecutionRequest,
) -> ActionExecutionResult: ...
@dataclass(frozen=True, slots=True)
@@ -170,14 +336,58 @@ def automation_principal_provider(
)
def action_effect_provider(
registry: object | None,
capability_name: str,
) -> ActionEffectProvider | None:
name = capability_name.strip()
if (
not name
or registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(name)
):
return None
capability = registry.capability(name)
return (
capability
if isinstance(capability, ActionEffectProvider)
else None
)
def _validate_contract_version(value: str) -> None:
if value != ACTION_EFFECT_CONTRACT_VERSION:
raise ValueError("Unsupported action/effect contract version")
def _require_text(value: str, label: str) -> None:
if not value.strip():
raise ValueError(f"{label} is required")
__all__ = [
"ACTION_EFFECT_CONTRACT_VERSION",
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
"ActionDefinition",
"ActionEffectProvider",
"ActionExecutionRequest",
"ActionExecutionResult",
"ActionExecutionState",
"AutomationInvocation",
"AutomationInvocationKind",
"AutomationPrincipalProvider",
"AutomationPrincipalRequest",
"AutomationPrincipalResolution",
"AutomationSubjectKind",
"ActionPreview",
"ActionReversibility",
"ActionRiskLevel",
"EffectDefinition",
"EffectOperation",
"EffectPreview",
"ObservedEffect",
"action_effect_provider",
"automation_principal_provider",
]
@@ -301,6 +301,18 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
maintenance_required=True,
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
),
ConfigurationFieldSafety(
key="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
label="Installer-managed Garage trust",
owner_module="files",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Reserved for the exact installer-owned http://garage:3900 service; it must never authorize another S3 endpoint.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
label="Structured connector response limit",
+47 -2
View File
@@ -239,10 +239,30 @@ def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, co
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
deployment_managed_raw = _clean(env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")).lower()
if deployment_managed_raw and deployment_managed_raw not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"Managed S3 trust must be an explicit boolean.",
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false, or let the supported installer manage Garage.",
)
deployment_managed = _truthy(deployment_managed_raw)
if storage_backend == "local":
if deployment_managed:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"Managed S3 trust cannot be enabled for local file storage.",
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false.",
)
_validate_local_file_storage(env, runtime, collector)
elif storage_backend == "s3":
_validate_s3_file_storage(env, collector)
_validate_s3_file_storage(
env,
collector,
deployment_managed=deployment_managed,
)
else:
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
@@ -254,10 +274,25 @@ def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfil
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
def _validate_s3_file_storage(
env: Mapping[str, str],
collector: _ConfigIssueCollector,
*,
deployment_managed: bool,
) -> None:
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
if not _clean(env.get(key)):
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
if (
deployment_managed
and _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL")) != "http://garage:3900"
):
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_URL",
"Installer-managed S3 trust is restricted to http://garage:3900.",
"Use the exact managed Garage endpoint or disable FILE_STORAGE_S3_DEPLOYMENT_MANAGED.",
)
def _validate_outbound_connector_policy(
@@ -373,6 +408,11 @@ AUTH_COOKIE_DOMAIN=
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=false
@@ -433,6 +473,11 @@ FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=true
"""
+14
View File
@@ -56,9 +56,22 @@ class ViewResolver(Protocol):
account_id: str,
group_ids: Iterable[str] = (),
workflow_view_id: str | None = None,
workflow_revision_id: str | None = None,
workflow_surface_ids: Iterable[str] = (),
) -> EffectiveView: ...
def views_resolver(registry: object | None) -> ViewResolver | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_VIEWS_RESOLVER)
):
return None
capability = registry.capability(CAPABILITY_VIEWS_RESOLVER)
return capability if isinstance(capability, ViewResolver) else None
def module_view_surface_id(module_id: str) -> str:
return f"{module_id}.module"
@@ -92,4 +105,5 @@ __all__ = [
"navigation_view_surface_id",
"route_view_surface_id",
"validate_view_surface_id",
"views_resolver",
]
+32 -1
View File
@@ -2,8 +2,9 @@ from __future__ import annotations
import base64
import hashlib
import json
from functools import lru_cache
from typing import Protocol, runtime_checkable
from typing import Any, Mapping, Protocol, runtime_checkable
from cryptography.fernet import Fernet, InvalidToken
@@ -18,6 +19,10 @@ class SecretDecryptionError(RuntimeError):
pass
class TransientPayloadError(RuntimeError):
pass
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
@@ -72,3 +77,29 @@ def decrypt_secret(value: str | None) -> str | None:
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise SecretDecryptionError("Stored secret cannot be decrypted with the configured master key") from exc
def seal_transient_payload(payload: Mapping[str, Any]) -> str:
"""Seal a short-lived JSON object without persisting server-side state."""
encoded = json.dumps(
dict(payload),
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return _fernet().encrypt(encoded).decode("utf-8")
def open_transient_payload(token: str, *, ttl_seconds: int) -> dict[str, Any]:
"""Open a sealed JSON object and enforce its maximum age."""
if ttl_seconds <= 0:
raise ValueError("Transient payload TTL must be positive")
try:
encoded = _fernet().decrypt(token.encode("utf-8"), ttl=ttl_seconds)
payload = json.loads(encoded.decode("utf-8"))
except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc:
raise TransientPayloadError("Transient payload is invalid or expired") from exc
if not isinstance(payload, dict):
raise TransientPayloadError("Transient payload must contain a JSON object")
return payload
+20
View File
@@ -74,8 +74,28 @@ class Settings(BaseSettings):
file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID")
file_storage_s3_secret_access_key: str | None = Field(default=None, alias="FILE_STORAGE_S3_SECRET_ACCESS_KEY")
file_storage_s3_bucket: str | None = Field(default="files", alias="FILE_STORAGE_S3_BUCKET")
file_storage_s3_deployment_managed: bool = Field(
default=False,
alias="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
)
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
file_archive_max_entries: int = Field(default=10_000, ge=1, alias="FILE_ARCHIVE_MAX_ENTRIES")
file_archive_max_expanded_bytes: int = Field(
default=2 * 1024 * 1024 * 1024,
ge=1,
alias="FILE_ARCHIVE_MAX_EXPANDED_BYTES",
)
file_archive_max_expansion_ratio: int = Field(
default=100,
ge=1,
alias="FILE_ARCHIVE_MAX_EXPANSION_RATIO",
)
file_archive_preview_ttl_seconds: int = Field(
default=30 * 60,
ge=60,
alias="FILE_ARCHIVE_PREVIEW_TTL_SECONDS",
)
auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")
+144
View File
@@ -3,11 +3,20 @@ from __future__ import annotations
import unittest
from govoplan_core.core.automation import (
ActionDefinition,
ActionEffectProvider,
ActionExecutionRequest,
ActionExecutionResult,
ActionPreview,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
AutomationInvocation,
AutomationPrincipalProvider,
AutomationPrincipalRequest,
AutomationPrincipalResolution,
EffectDefinition,
EffectPreview,
ObservedEffect,
action_effect_provider,
automation_principal_provider,
)
from govoplan_core.core.modules import ModuleContext, ModuleManifest
@@ -24,6 +33,66 @@ class _Provider:
)
class _ActionProvider:
action = ActionDefinition(
action_key="postbox.message.deliver",
owner_module="postbox",
description="Deliver one governed Postbox message.",
input_schema_ref="schema:postbox.message.deliver@1",
required_scopes=("postbox:message:write",),
required_capabilities=("postbox.delivery",),
risk_level="high",
reversibility="compensatable",
expected_effect_keys=("postbox.message.created",),
audit_event_types=("postbox.message.delivered",),
)
effect = EffectDefinition(
effect_key="postbox.message.created",
owner_module="postbox",
operation="created",
description="A durable Postbox message was created.",
resource_types=("postbox_message",),
)
def action_definitions(self):
return (self.action,)
def effect_definitions(self):
return (self.effect,)
def preview_action(self, session, principal, *, request):
del session, principal
return ActionPreview(
action_key=request.action_key,
allowed=True,
summary="One Postbox message will be created.",
risk_level=self.action.risk_level,
reversibility=self.action.reversibility,
effects=(
EffectPreview(
effect_key=self.effect.effect_key,
summary="Create message.",
),
),
preview_ref="preview:1",
)
def execute_action(self, session, principal, *, request):
del session, principal
return ActionExecutionResult(
state="completed",
output={"message_ref": "postbox-message:1"},
observed_effects=(
ObservedEffect(
effect_key=self.effect.effect_key,
operation="created",
resource_ref="postbox-message:1",
),
),
audit_event_refs=("audit-event:1",),
)
class AutomationContractTests(unittest.TestCase):
def test_principal_request_has_explicit_subject_contracts(self) -> None:
service = AutomationPrincipalRequest.service_account(
@@ -91,6 +160,81 @@ class AutomationContractTests(unittest.TestCase):
self.assertTrue(result.allowed)
self.assertEqual(("dataflow:pipeline:run",), result.granted_scopes)
def test_action_effect_provider_is_previewable_and_idempotent_by_contract(
self,
) -> None:
provider = _ActionProvider()
self.assertIsInstance(provider, ActionEffectProvider)
request = ActionExecutionRequest(
tenant_id="tenant-1",
action_key=provider.action.action_key,
input={"postbox_ref": "postbox:1"},
idempotency_key="workflow:instance-1:step-2:attempt-1",
invocation=AutomationInvocation(
kind="workflow",
trigger_ref="workflow-instance:1",
),
)
preview = provider.preview_action(object(), object(), request=request)
result = provider.execute_action(object(), object(), request=request)
self.assertTrue(preview.allowed)
self.assertEqual("compensatable", preview.reversibility)
self.assertEqual("completed", result.state)
self.assertEqual(
"postbox-message:1",
result.observed_effects[0].resource_ref,
)
def test_action_effect_provider_is_resolved_by_capability_name(self) -> None:
provider = _ActionProvider()
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="action_contract_test",
name="Action contract test",
version="test",
capability_factories={
"postbox.actions": lambda context: provider,
"invalid.actions": lambda context: object(),
},
)
)
registry.configure_capability_context(
ModuleContext(registry=registry, settings=object())
)
self.assertIs(
provider,
action_effect_provider(registry, "postbox.actions"),
)
self.assertIsNone(
action_effect_provider(registry, "invalid.actions")
)
self.assertIsNone(
action_effect_provider(registry, "missing.actions")
)
def test_action_contract_rejects_unversioned_or_incomplete_definitions(
self,
) -> None:
with self.assertRaisesRegex(ValueError, "input schema"):
ActionDefinition(
action_key="invalid",
owner_module="test",
description="Invalid action",
input_schema_ref="",
)
with self.assertRaisesRegex(ValueError, "contract version"):
EffectDefinition(
effect_key="test.effect",
owner_module="test",
operation="changed",
description="Test effect",
contract_version="2",
)
if __name__ == "__main__":
unittest.main()
+63
View File
@@ -10,6 +10,25 @@ from govoplan_core.settings import Settings
class InstallConfigTests(unittest.TestCase):
def test_archive_preview_safety_defaults_are_validated(self) -> None:
defaults = Settings()
self.assertEqual(defaults.file_archive_max_entries, 10_000)
self.assertEqual(
defaults.file_archive_max_expanded_bytes,
2 * 1024 * 1024 * 1024,
)
self.assertEqual(defaults.file_archive_max_expansion_ratio, 100)
self.assertEqual(defaults.file_archive_preview_ttl_seconds, 30 * 60)
for values in (
{"FILE_ARCHIVE_MAX_ENTRIES": "0"},
{"FILE_ARCHIVE_MAX_EXPANDED_BYTES": "0"},
{"FILE_ARCHIVE_MAX_EXPANSION_RATIO": "0"},
{"FILE_ARCHIVE_PREVIEW_TTL_SECONDS": "59"},
):
with self.subTest(values=values), self.assertRaises(ValidationError):
Settings(**values)
def test_calendar_outbox_retention_setting_is_validated(self) -> None:
self.assertEqual(Settings().calendar_outbox_terminal_retention_days, 90)
self.assertEqual(
@@ -128,6 +147,45 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("GOVOPLAN_TRUSTED_HOSTS", error_keys)
self.assertIn("FORWARDED_ALLOW_IPS", error_keys)
def test_managed_s3_trust_is_limited_to_installer_garage(self) -> None:
base = {
"APP_ENV": "development",
"FILE_STORAGE_BACKEND": "s3",
"FILE_STORAGE_S3_REGION": "garage",
"FILE_STORAGE_S3_ACCESS_KEY_ID": "access",
"FILE_STORAGE_S3_SECRET_ACCESS_KEY": "secret",
"FILE_STORAGE_S3_BUCKET": "files",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED": "true",
}
accepted = validate_runtime_configuration(
{
**base,
"FILE_STORAGE_S3_ENDPOINT_URL": "http://garage:3900",
},
profile="development",
)
rejected = validate_runtime_configuration(
{
**base,
"FILE_STORAGE_S3_ENDPOINT_URL": "http://other-s3:3900",
},
profile="development",
)
self.assertNotIn(
"FILE_STORAGE_S3_ENDPOINT_URL",
{issue.key for issue in accepted.errors},
)
self.assertIn(
"FILE_STORAGE_S3_ENDPOINT_URL",
{issue.key for issue in rejected.errors},
)
self.assertTrue(
Settings(
FILE_STORAGE_S3_DEPLOYMENT_MANAGED="true"
).file_storage_s3_deployment_managed
)
def test_connector_deployment_allowlists_require_exact_names_and_absolute_paths(self) -> None:
result = validate_runtime_configuration(
{
@@ -156,6 +214,7 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=govoplan.example.org", self_hosted)
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", self_hosted)
self.assertIn("FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false", self_hosted)
self.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", production_like)
@@ -166,6 +225,10 @@ class InstallConfigTests(unittest.TestCase):
self.assertIn("GOVOPLAN_TRUSTED_HOSTS=127.0.0.1,localhost,testserver", production_like)
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=0", production_like)
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", production_like)
self.assertIn(
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false",
production_like,
)
if __name__ == "__main__":
+5
View File
@@ -76,6 +76,11 @@ class PolicyContractTests(unittest.TestCase):
self.assertEqual(database_url.secret_handling, "env_only")
self.assertEqual(database_url.storage, "environment")
managed_garage = catalog["FILE_STORAGE_S3_DEPLOYMENT_MANAGED"]
self.assertFalse(managed_garage.ui_managed)
self.assertEqual("files", managed_garage.owner_module)
self.assertEqual("high", managed_garage.risk)
self.assertIs(classify_configuration_field("MASTER_KEY_B64"), catalog["MASTER_KEY_B64"])
self.assertIsNone(classify_configuration_field("missing.setting"))
self.assertNotIn("DATABASE_URL", {item.key for item in configuration_safety_catalog(include_env_only=False)})
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import unittest
from govoplan_core.security.secrets import (
TransientPayloadError,
open_transient_payload,
seal_transient_payload,
)
class TransientPayloadTests(unittest.TestCase):
def test_round_trip_preserves_json_object(self) -> None:
token = seal_transient_payload(
{
"purpose": "test",
"tenant_id": "tenant-1",
"selected": ["one", "two"],
}
)
self.assertEqual(
{
"purpose": "test",
"tenant_id": "tenant-1",
"selected": ["one", "two"],
},
open_transient_payload(token, ttl_seconds=60),
)
def test_tampered_payload_is_rejected(self) -> None:
token = seal_transient_payload({"purpose": "test"})
replacement = "A" if token[-1] != "A" else "B"
with self.assertRaisesRegex(
TransientPayloadError, "invalid or expired"
):
open_transient_payload(f"{token[:-1]}{replacement}", ttl_seconds=60)
def test_non_positive_ttl_is_rejected(self) -> None:
token = seal_transient_payload({"purpose": "test"})
with self.assertRaisesRegex(ValueError, "TTL must be positive"):
open_transient_payload(token, ttl_seconds=0)
if __name__ == "__main__":
unittest.main()
+908 -293
View File
File diff suppressed because it is too large Load Diff
+27 -17
View File
@@ -18,6 +18,10 @@
"./definition-graph": {
"types": "./src/definitionGraph.ts",
"import": "./src/definitionGraph.ts"
},
"./wysiwyg": {
"types": "./src/wysiwyg.ts",
"import": "./src/wysiwyg.ts"
}
},
"scripts": {
@@ -29,7 +33,7 @@
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
"test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs",
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js",
"test:data-grid-actions": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/data-grid-actions.test.js && node .component-test-build/tests/data-grid-sizing.test.js",
"test:dialog-focus": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/dialog-focus.test.js && node scripts/test-dialog-focus-structure.mjs",
"test:explorer-tree": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/explorer-tree.test.js",
"test:icon-button": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/icon-button.test.js",
@@ -39,53 +43,59 @@
"test:metric-card": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/metric-card.test.js",
"test:people-picker": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/people-picker.test.js",
"test:resource-access": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/resource-access-explanation.test.js",
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js"
"test:selection-list": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/selection-list.test.js",
"test:wysiwyg-editor": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/wysiwyg-editor-utils.test.js"
},
"dependencies": {
"@govoplan/access-webui": "file:../../govoplan-access/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
"@govoplan/files-webui": "file:../../govoplan-files/webui",
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
"@govoplan/search-webui": "file:../../govoplan-search/webui",
"@govoplan/views-webui": "file:../../govoplan-views/webui",
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui"
"@govoplan/workflow-webui": "file:../../govoplan-workflow/webui",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-image": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"@tiptap/react": "^3.29.2",
"@tiptap/starter-kit": "^3.29.2"
},
"devDependencies": {
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "7.18.2",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router": "8.3.0",
"read-excel-file": "^9.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
"vite": "^7.3.6"
},
"peerDependencies": {
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8"
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"allowScripts": {
"esbuild@0.25.12": true
"esbuild@0.28.1": true
}
}
+21 -12
View File
@@ -14,6 +14,10 @@
"./app": {
"types": "./src/app.ts",
"import": "./src/app.ts"
},
"./wysiwyg": {
"types": "./src/wysiwyg.ts",
"import": "./src/wysiwyg.ts"
}
},
"scripts": {
@@ -34,26 +38,31 @@
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.11",
"@govoplan/organizations-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-organizations.git#v0.1.8",
"@govoplan/ops-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-ops.git#v0.1.8",
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.8"
"@govoplan/policy-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-policy.git#v0.1.8",
"@tiptap/core": "^3.29.2",
"@tiptap/extension-image": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"@tiptap/react": "^3.29.2",
"@tiptap/starter-kit": "^3.29.2"
},
"devDependencies": {
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router": "8.3.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"typescript": "^5.7.2",
"vite": "^6.0.6"
"vite": "^7.3.6"
},
"peerDependencies": {
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"allowScripts": {
"esbuild@0.25.12": true
"esbuild@0.28.1": true
}
}
+112 -8
View File
@@ -1,4 +1,4 @@
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { Navigate, Route, Routes, useLocation } from "react-router";
import { lazy, useEffect, useMemo, useState } from "react";
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
@@ -11,7 +11,11 @@ import { PermissionBoundary } from "./components/AccessBoundary";
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
import { PlatformModulesProvider } from "./platform/ModuleContext";
import { PlatformViewProvider } from "./platform/ViewContext";
import { PLATFORM_VIEW_CHANGED_EVENT } from "./platform/views";
import {
PLATFORM_VIEW_CHANGED_EVENT,
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
type WorkflowViewChangedEventDetail
} from "./platform/views";
import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
@@ -45,7 +49,9 @@ export default function App() {
const [backendReachable, setBackendReachable] = useState(true);
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
const [reloginMessage, setReloginMessage] = useState("");
const [viewProjection, setViewProjection] = useState<EffectiveViewProjection | null>(null);
const [baseViewProjection, setBaseViewProjection] = useState<EffectiveViewProjection | null>(null);
const [workflowViewProjection, setWorkflowViewProjection] = useState<EffectiveViewProjection | null>(null);
const viewProjection = workflowViewProjection ?? baseViewProjection;
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
@@ -65,7 +71,8 @@ export default function App() {
useEffect(() => {
if (!auth || !viewsRuntime) {
setViewProjection(null);
setBaseViewProjection(null);
setWorkflowViewProjection(null);
return;
}
const currentAuth = auth;
@@ -74,17 +81,22 @@ export default function App() {
async function loadEffectiveView() {
try {
const projection = await currentRuntime.loadEffectiveView(settings, currentAuth);
if (!cancelled) setViewProjection(projection);
if (!cancelled) setBaseViewProjection(projection);
} catch (error) {
if (!cancelled) setViewProjection(null);
if (!cancelled) setBaseViewProjection(null);
console.error("Failed to load the effective View", error);
}
}
function reloadNormalView() {
setWorkflowViewProjection(null);
clearStoredWorkflowView(currentAuth);
void loadEffectiveView();
}
void loadEffectiveView();
window.addEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
window.addEventListener(PLATFORM_VIEW_CHANGED_EVENT, reloadNormalView);
return () => {
cancelled = true;
window.removeEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
window.removeEventListener(PLATFORM_VIEW_CHANGED_EVENT, reloadNormalView);
};
}, [
auth?.user?.id,
@@ -96,6 +108,36 @@ export default function App() {
viewsRuntime
]);
useEffect(() => {
if (!auth || !viewsRuntime) {
setWorkflowViewProjection(null);
return;
}
const currentAuth = auth;
setWorkflowViewProjection(loadStoredWorkflowView(currentAuth));
function applyWorkflowView(event: Event) {
const detail = (event as CustomEvent<WorkflowViewChangedEventDetail>).detail;
const projection = detail?.projection ?? null;
setWorkflowViewProjection(projection);
storeWorkflowView(currentAuth, projection, detail?.contextId);
}
window.addEventListener(
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
applyWorkflowView
);
return () => {
window.removeEventListener(
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
applyWorkflowView
);
};
}, [
auth?.user?.id,
auth?.active_tenant?.id,
auth?.tenant.id,
viewsRuntime
]);
function updateSettings(next: ApiSettings) {
setSettings(next);
saveApiSettings(next);
@@ -662,3 +704,65 @@ function mergeWebModules(localModules: PlatformWebModule[], remoteModules: Platf
})];
}
const WORKFLOW_VIEW_SESSION_TTL_MS = 12 * 60 * 60 * 1000;
type StoredWorkflowView = {
projection: EffectiveViewProjection;
contextId?: string | null;
expiresAt: number;
};
function workflowViewStorageKey(auth: AuthInfo): string {
const tenant = auth.active_tenant ?? auth.tenant;
return `govoplan:workflow-view:${tenant.id}:${auth.user.account_id}`;
}
function loadStoredWorkflowView(
auth: AuthInfo
): EffectiveViewProjection | null {
try {
const key = workflowViewStorageKey(auth);
const raw = window.sessionStorage.getItem(key);
if (!raw) return null;
const stored = JSON.parse(raw) as Partial<StoredWorkflowView>;
if (
typeof stored.expiresAt !== "number"
|| stored.expiresAt <= Date.now()
|| !stored.projection
|| !Array.isArray(stored.projection.visibleSurfaceIds)
) {
window.sessionStorage.removeItem(key);
return null;
}
return stored.projection;
} catch {
return null;
}
}
function storeWorkflowView(
auth: AuthInfo,
projection: EffectiveViewProjection | null,
contextId?: string | null
): void {
try {
const key = workflowViewStorageKey(auth);
if (!projection) {
window.sessionStorage.removeItem(key);
return;
}
const stored: StoredWorkflowView = {
projection,
contextId,
expiresAt: Date.now() + WORKFLOW_VIEW_SESSION_TTL_MS
};
window.sessionStorage.setItem(key, JSON.stringify(stored));
} catch {
// Session persistence is optional; the in-memory projection still applies.
}
}
function clearStoredWorkflowView(auth: AuthInfo): void {
storeWorkflowView(auth, null);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from "react";
import { Navigate } from "react-router-dom";
import { Navigate } from "react-router";
import type { AuthInfo } from "../types";
import { isApiError } from "../api/client";
import DismissibleAlert from "./DismissibleAlert";
@@ -3,7 +3,7 @@ import {
useState,
type ReactNode
} from "react";
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { adminErrorMessage } from "./admin/adminUtils";
export type DashboardWidgetDataState<T> = {
+118
View File
@@ -0,0 +1,118 @@
import type { CSSProperties, ReactNode } from "react";
import { LockKeyhole } from "lucide-react";
export type StageRailTone =
| "neutral"
| "success"
| "warning"
| "danger"
| "active"
| "partial";
export type StageRailItem = {
id: string;
label: string;
detail?: string;
statusLabel?: string;
title?: string;
icon?: ReactNode;
tone?: StageRailTone;
connectorTone?: StageRailTone;
current?: boolean;
locked?: boolean;
lockedLabel?: string;
};
export type StageRailProps = {
items: StageRailItem[];
ariaLabel: string;
className?: string;
onSelect?: (id: string) => void;
};
const toneColors: Record<StageRailTone, string> = {
neutral: "var(--line-dark)",
success: "var(--green)",
warning: "var(--amber)",
danger: "var(--red)",
active: "var(--blue)",
partial: "var(--stage-rail-partial, #8069aa)"
};
export default function StageRail({
items,
ariaLabel,
className = "",
onSelect
}: StageRailProps) {
return (
<nav
className={`stage-rail${className ? ` ${className}` : ""}`}
aria-label={ariaLabel}
>
<div className="stage-rail-track">
{items.map((item, index) => {
const tone = item.tone ?? "neutral";
const next = items[index + 1];
const nextTone = next?.connectorTone ?? next?.tone ?? tone;
const style = {
"--stage-rail-color": toneColors[tone],
"--stage-rail-line-color": toneColors[item.connectorTone ?? tone],
"--stage-rail-line-next-color": toneColors[nextTone]
} as CSSProperties;
const content = (
<>
<span className="stage-rail-icon">{item.icon}</span>
<span className="stage-rail-copy">
<strong>
<span>{item.label}</span>
{item.locked ? (
<LockKeyhole
size={12}
aria-label={item.lockedLabel ?? "Locked"}
/>
) : null}
</strong>
{item.detail ? <small>{item.detail}</small> : null}
{item.statusLabel ? (
<small className="stage-rail-status">{item.statusLabel}</small>
) : null}
</span>
</>
);
return (
<div
className="stage-rail-group"
data-current={item.current || undefined}
data-tone={tone}
key={item.id}
style={style}
>
{onSelect ? (
<button
type="button"
className="stage-rail-item"
onClick={() => onSelect(item.id)}
aria-current={item.current ? "step" : undefined}
title={item.title}
>
{content}
</button>
) : (
<div
className="stage-rail-item"
aria-current={item.current ? "step" : undefined}
title={item.title}
>
{content}
</div>
)}
{next ? <span className="stage-rail-line" aria-hidden="true" /> : null}
</div>
);
})}
</div>
</nav>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useNavigate, type NavigateFunction, type NavigateOptions, type To } from "react-router-dom";
import { useNavigate, type NavigateFunction, type NavigateOptions, type To } from "react-router";
import Button from "./Button";
import Dialog from "./Dialog";
import DismissibleAlert from "./DismissibleAlert";
+767
View File
@@ -0,0 +1,767 @@
import {
mergeAttributes,
Node,
nodeInputRule,
nodePasteRule,
type Editor
} from "@tiptap/core";
import Image from "@tiptap/extension-image";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import {
Bold,
Code2,
ImagePlus,
Italic,
Link2,
Link2Off,
List,
ListOrdered,
Quote,
Redo2,
RemoveFormatting,
Strikethrough,
Underline,
Undo2
} from "lucide-react";
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type CSSProperties,
type ChangeEvent,
type MouseEvent
} from "react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
import Button from "./Button";
import Dialog from "./Dialog";
import FormField from "./FormField";
import IconButton from "./IconButton";
import SegmentedControl from "./SegmentedControl";
import { normalizeWysiwygImageUrl, normalizeWysiwygLinkUrl } from "./wysiwygEditorUrls";
export type WysiwygEditorMode = "visual" | "source";
export type WysiwygEditorToken = {
value: string;
label?: string;
};
export type WysiwygEditorHandle = {
focus: () => void;
insertText: (value: string) => boolean;
insertToken: (token: WysiwygEditorToken | string) => boolean;
};
export type WysiwygEditorLabels = {
editor: string;
visual: string;
source: string;
blockStyle: string;
paragraph: string;
heading1: string;
heading2: string;
heading3: string;
undo: string;
redo: string;
bold: string;
italic: string;
underline: string;
strike: string;
inlineCodeLabel: string;
bulletList: string;
numberedList: string;
quote: string;
insertLink: string;
removeLink: string;
insertImage: string;
clearFormatting: string;
linkText: string;
linkUrlLabel: string;
imageUrlLabel: string;
alternativeText: string;
apply: string;
cancel: string;
invalidLink: string;
invalidImage: string;
sourceWarning: string;
};
export type WysiwygEditorProps = {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
defaultMode?: WysiwygEditorMode;
allowSourceMode?: boolean;
ariaLabel?: string;
className?: string;
minHeight?: number | string;
sourceRows?: number;
labels?: Partial<WysiwygEditorLabels>;
onFocus?: () => void;
};
type BlockStyle = "paragraph" | "heading-1" | "heading-2" | "heading-3";
const INLINE_TOKEN_NAME = "govoplanInlineToken";
const TOKEN_INPUT_PATTERN = /((?:\{\{[^{}\r\n]+\}\}|\$\{[^{}\r\n]+\}))$/;
const TOKEN_PASTE_PATTERN = /(?:\{\{[^{}\r\n]+\}\}|\$\{[^{}\r\n]+\})/g;
const TOKEN_SCAN_PATTERN = /(?:\{\{[^{}\r\n]+\}\}|\$\{[^{}\r\n]+\})/g;
const DEFAULT_LABELS: WysiwygEditorLabels = {
editor: "i18n:govoplan-core.rich_text_editor.b56157a6",
visual: "i18n:govoplan-core.visual.770d690e",
source: "i18n:govoplan-core.html_source.1d4100b2",
blockStyle: "i18n:govoplan-core.block_style.c050e2eb",
paragraph: "i18n:govoplan-core.paragraph.05058e0b",
heading1: "i18n:govoplan-core.heading_1.fdc7d47a",
heading2: "i18n:govoplan-core.heading_2.6542d386",
heading3: "i18n:govoplan-core.heading_3.9694d18f",
undo: "i18n:govoplan-core.undo.39fc7212",
redo: "i18n:govoplan-core.redo.471b94d4",
bold: "i18n:govoplan-core.bold.19e07430",
italic: "i18n:govoplan-core.italic.1616e2e5",
underline: "i18n:govoplan-core.underline.39773aa3",
strike: "i18n:govoplan-core.strikethrough.a93b9a68",
inlineCodeLabel: "i18n:govoplan-core.inline_code.6f3a4263",
bulletList: "i18n:govoplan-core.bullet_list.660d9fbe",
numberedList: "i18n:govoplan-core.numbered_list.8294ea4f",
quote: "i18n:govoplan-core.block_quote.b3399cd3",
insertLink: "i18n:govoplan-core.insert_link.4d4ac9a1",
removeLink: "i18n:govoplan-core.remove_link.8d48d1f9",
insertImage: "i18n:govoplan-core.insert_image.c8141eb5",
clearFormatting: "i18n:govoplan-core.clear_formatting.6c8a26c4",
linkText: "i18n:govoplan-core.link_text.502f872d",
linkUrlLabel: "i18n:govoplan-core.link_url.08738f6e",
imageUrlLabel: "i18n:govoplan-core.image_url.35c80f96",
alternativeText: "i18n:govoplan-core.alternative_text.8d87ae1b",
apply: "i18n:govoplan-core.apply.cfea419c",
cancel: "i18n:govoplan-core.cancel.77dfd213",
invalidLink: "i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137",
invalidImage: "i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c",
sourceWarning: "i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c"
};
const InlineToken = Node.create({
name: INLINE_TOKEN_NAME,
group: "inline",
inline: true,
atom: true,
selectable: true,
addAttributes() {
return {
value: { default: "" },
label: { default: "" }
};
},
parseHTML() {
return [{
tag: "span[data-govoplan-token]",
getAttrs: (element) => {
if (!(element instanceof HTMLElement)) return false;
const value = element.dataset.govoplanToken || "";
if (!value) return false;
return {
value,
label: element.dataset.govoplanTokenLabel || tokenLabel(value)
};
}
}];
},
renderHTML({ node, HTMLAttributes }) {
const value = String(node.attrs.value || "");
const label = String(node.attrs.label || tokenLabel(value));
return [
"span",
mergeAttributes(HTMLAttributes, {
class: "wysiwyg-inline-token",
"data-govoplan-token": value,
"data-govoplan-token-label": label,
contenteditable: "false"
}),
label
];
},
addInputRules() {
return [nodeInputRule({
find: TOKEN_INPUT_PATTERN,
type: this.type,
getAttributes: (match) => tokenAttributes(match[1] || match[0])
})];
},
addPasteRules() {
return [nodePasteRule({
find: TOKEN_PASTE_PATTERN,
type: this.type,
getAttributes: (match) => tokenAttributes(match[0])
})];
}
});
const WysiwygEditor = forwardRef<WysiwygEditorHandle, WysiwygEditorProps>(function WysiwygEditor({
value,
onChange,
disabled = false,
defaultMode = "visual",
allowSourceMode = true,
ariaLabel,
className = "",
minHeight = 300,
sourceRows = 16,
labels,
onFocus
}, forwardedRef) {
const { translateText } = usePlatformLanguage();
const translatedLabels = useMemo(
() => translateLabels({ ...DEFAULT_LABELS, ...labels }, translateText),
[labels, translateText]
);
const translatedAriaLabel = translateText(ariaLabel || translatedLabels.editor);
const [mode, setMode] = useState<WysiwygEditorMode>(() => {
if (!allowSourceMode) return "visual";
if (defaultMode === "source" || hasUnsupportedVisualMarkup(value)) return "source";
return "visual";
});
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkUrl, setLinkUrl] = useState("");
const [linkText, setLinkText] = useState("");
const [linkError, setLinkError] = useState("");
const [imageDialogOpen, setImageDialogOpen] = useState(false);
const [imageUrl, setImageUrl] = useState("");
const [imageAlt, setImageAlt] = useState("");
const [imageError, setImageError] = useState("");
const [editingImage, setEditingImage] = useState(false);
const sourceRef = useRef<HTMLTextAreaElement | null>(null);
const onChangeRef = useRef(onChange);
const onFocusRef = useRef(onFocus);
const valueRef = useRef(value);
const appliedValueRef = useRef(value);
const modeWasChosenRef = useRef(false);
const linkSelectionRef = useRef({ from: 0, to: 0 });
onChangeRef.current = onChange;
onFocusRef.current = onFocus;
valueRef.current = value;
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3, 4, 5, 6] },
link: {
openOnClick: false,
autolink: true,
defaultProtocol: "https",
HTMLAttributes: {
rel: "noopener noreferrer",
target: "_blank"
}
}
}),
Image.configure({ allowBase64: true }),
InlineToken
],
content: decorateTokensForEditor(value),
editable: !disabled,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": translatedAriaLabel,
"aria-multiline": "true",
class: "wysiwyg-prosemirror",
role: "textbox"
}
},
onFocus: () => onFocusRef.current?.(),
onUpdate: ({ editor: currentEditor }) => {
const nextValue = editorHtmlValue(currentEditor);
appliedValueRef.current = nextValue;
onChangeRef.current(nextValue);
}
});
const toolbarState = useEditorState({
editor,
selector: ({ editor: currentEditor }) => ({
blockStyle: activeBlockStyle(currentEditor),
bold: currentEditor.isActive("bold"),
italic: currentEditor.isActive("italic"),
underline: currentEditor.isActive("underline"),
strike: currentEditor.isActive("strike"),
code: currentEditor.isActive("code"),
bulletList: currentEditor.isActive("bulletList"),
numberedList: currentEditor.isActive("orderedList"),
quote: currentEditor.isActive("blockquote"),
link: currentEditor.isActive("link"),
canUndo: currentEditor.can().chain().undo().run(),
canRedo: currentEditor.can().chain().redo().run()
})
});
const unsupportedSource = useMemo(() => hasUnsupportedVisualMarkup(value), [value]);
const editorStyle = {
"--wysiwyg-editor-min-height": typeof minHeight === "number" ? `${minHeight}px` : minHeight
} as CSSProperties;
useEffect(() => {
editor.setEditable(!disabled);
}, [disabled, editor]);
useEffect(() => {
editor.setOptions({
editorProps: {
attributes: {
"aria-label": translatedAriaLabel,
"aria-multiline": "true",
class: "wysiwyg-prosemirror",
role: "textbox"
}
}
});
}, [editor, translatedAriaLabel]);
useEffect(() => {
if (value === appliedValueRef.current) return;
appliedValueRef.current = value;
if (mode === "visual") {
editor.commands.setContent(decorateTokensForEditor(value), { emitUpdate: false });
}
}, [editor, mode, value]);
useEffect(() => {
if (
!allowSourceMode
|| mode !== "visual"
|| modeWasChosenRef.current
|| !hasUnsupportedVisualMarkup(value)
) return;
setMode("source");
}, [allowSourceMode, mode, value]);
useImperativeHandle(forwardedRef, () => ({
focus() {
if (mode === "source") sourceRef.current?.focus();
else editor.commands.focus();
},
insertText(text) {
if (disabled || !text) return false;
if (mode === "source") return insertIntoSource(text, valueRef.current, sourceRef.current, onChangeRef.current);
return editor.chain().focus().insertContent(text).run();
},
insertToken(token) {
if (disabled) return false;
const normalized = typeof token === "string" ? tokenAttributes(token) : tokenAttributes(token.value, token.label);
if (!normalized.value) return false;
if (mode === "source") {
return insertIntoSource(normalized.value, valueRef.current, sourceRef.current, onChangeRef.current);
}
return editor.chain().focus().insertContent({
type: INLINE_TOKEN_NAME,
attrs: normalized
}).run();
}
}), [disabled, editor, mode]);
function selectMode(nextMode: WysiwygEditorMode) {
if (nextMode === mode || (!allowSourceMode && nextMode === "source")) return;
modeWasChosenRef.current = true;
if (nextMode === "visual") {
appliedValueRef.current = valueRef.current;
editor.commands.setContent(decorateTokensForEditor(valueRef.current), { emitUpdate: false });
setMode("visual");
window.requestAnimationFrame(() => editor.commands.focus("end"));
return;
}
const nextValue = editorHtmlValue(editor);
appliedValueRef.current = nextValue;
if (nextValue !== valueRef.current) onChangeRef.current(nextValue);
setMode("source");
window.requestAnimationFrame(() => sourceRef.current?.focus());
}
function changeSource(event: ChangeEvent<HTMLTextAreaElement>) {
const nextValue = event.target.value;
appliedValueRef.current = nextValue;
onChangeRef.current(nextValue);
}
function changeBlockStyle(nextStyle: BlockStyle) {
if (nextStyle === "paragraph") {
editor.chain().focus().setParagraph().run();
return;
}
const level = Number(nextStyle.slice(-1)) as 1 | 2 | 3;
editor.chain().focus().setHeading({ level }).run();
}
function openLinkDialog() {
if (toolbarState.link) editor.chain().focus().extendMarkRange("link").run();
const { from, to } = editor.state.selection;
const attributes = editor.getAttributes("link");
linkSelectionRef.current = { from, to };
setLinkUrl(String(attributes.href || ""));
setLinkText(from === to ? "" : editor.state.doc.textBetween(from, to, " "));
setLinkError("");
setLinkDialogOpen(true);
}
function closeLinkDialog() {
setLinkDialogOpen(false);
setLinkError("");
}
function applyLink() {
const href = normalizeWysiwygLinkUrl(linkUrl);
if (!href) {
setLinkError(translatedLabels.invalidLink);
return;
}
const { from, to } = linkSelectionRef.current;
if (from === to) {
editor.chain().focus().setTextSelection(from).insertContent({
type: "text",
text: linkText.trim() || href,
marks: [{ type: "link", attrs: { href } }]
}).run();
} else {
editor.chain().focus().setTextSelection({ from, to }).setLink({ href }).run();
}
closeLinkDialog();
}
function openImageDialog() {
const isEditing = editor.isActive("image");
const attributes = isEditing ? editor.getAttributes("image") : {};
setEditingImage(isEditing);
setImageUrl(String(attributes.src || ""));
setImageAlt(String(attributes.alt || ""));
setImageError("");
setImageDialogOpen(true);
}
function closeImageDialog() {
setImageDialogOpen(false);
setImageError("");
}
function applyImage() {
const src = normalizeWysiwygImageUrl(imageUrl);
if (!src) {
setImageError(translatedLabels.invalidImage);
return;
}
const attributes = { src, alt: imageAlt.trim() || undefined };
if (editingImage) editor.chain().focus().updateAttributes("image", attributes).run();
else editor.chain().focus().setImage(attributes).run();
closeImageDialog();
}
function preserveSelection(event: MouseEvent<HTMLButtonElement>) {
event.preventDefault();
}
const rootClassName = [
"wysiwyg-editor",
disabled ? "is-disabled" : "",
mode === "source" ? "is-source-mode" : "",
className
].filter(Boolean).join(" ");
return (
<div className={rootClassName} style={editorStyle} role="group" aria-label={translatedAriaLabel}>
<div className="wysiwyg-editor-header">
{mode === "visual" && (
<div className="wysiwyg-editor-toolbar" role="toolbar" aria-label={translatedLabels.editor}>
<div className="wysiwyg-toolbar-group">
<IconButton
variant="ghost"
className="wysiwyg-toolbar-button"
label={translatedLabels.undo}
icon={<Undo2 />}
disabled={disabled || !toolbarState.canUndo}
onMouseDown={preserveSelection}
onClick={() => editor.chain().focus().undo().run()}
/>
<IconButton
variant="ghost"
className="wysiwyg-toolbar-button"
label={translatedLabels.redo}
icon={<Redo2 />}
disabled={disabled || !toolbarState.canRedo}
onMouseDown={preserveSelection}
onClick={() => editor.chain().focus().redo().run()}
/>
</div>
<label className="wysiwyg-block-style">
<span className="sr-only">{translatedLabels.blockStyle}</span>
<select
aria-label={translatedLabels.blockStyle}
value={toolbarState.blockStyle}
disabled={disabled}
onChange={(event) => changeBlockStyle(event.target.value as BlockStyle)}
>
<option value="paragraph">{translatedLabels.paragraph}</option>
<option value="heading-1">{translatedLabels.heading1}</option>
<option value="heading-2">{translatedLabels.heading2}</option>
<option value="heading-3">{translatedLabels.heading3}</option>
</select>
</label>
<div className="wysiwyg-toolbar-group">
<ToolbarButton active={toolbarState.bold} disabled={disabled} label={translatedLabels.bold} icon={<Bold />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBold().run()} />
<ToolbarButton active={toolbarState.italic} disabled={disabled} label={translatedLabels.italic} icon={<Italic />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleItalic().run()} />
<ToolbarButton active={toolbarState.underline} disabled={disabled} label={translatedLabels.underline} icon={<Underline />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleUnderline().run()} />
<ToolbarButton active={toolbarState.strike} disabled={disabled} label={translatedLabels.strike} icon={<Strikethrough />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleStrike().run()} />
<ToolbarButton active={toolbarState.code} disabled={disabled} label={translatedLabels.inlineCodeLabel} icon={<Code2 />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleCode().run()} />
</div>
<div className="wysiwyg-toolbar-group">
<ToolbarButton active={toolbarState.bulletList} disabled={disabled} label={translatedLabels.bulletList} icon={<List />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBulletList().run()} />
<ToolbarButton active={toolbarState.numberedList} disabled={disabled} label={translatedLabels.numberedList} icon={<ListOrdered />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleOrderedList().run()} />
<ToolbarButton active={toolbarState.quote} disabled={disabled} label={translatedLabels.quote} icon={<Quote />} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().toggleBlockquote().run()} />
</div>
<div className="wysiwyg-toolbar-group">
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.insertLink} icon={<Link2 />} disabled={disabled} onMouseDown={preserveSelection} onClick={openLinkDialog} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.removeLink} icon={<Link2Off />} disabled={disabled || !toolbarState.link} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().unsetLink().run()} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.insertImage} icon={<ImagePlus />} disabled={disabled} onMouseDown={preserveSelection} onClick={openImageDialog} />
<IconButton variant="ghost" className="wysiwyg-toolbar-button" label={translatedLabels.clearFormatting} icon={<RemoveFormatting />} disabled={disabled} onMouseDown={preserveSelection} onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()} />
</div>
</div>
)}
{allowSourceMode && (
<SegmentedControl
className="wysiwyg-editor-mode"
size="content"
width="inline"
ariaLabel={translatedLabels.editor}
value={mode}
onChange={selectMode}
options={[
{ id: "visual", label: translatedLabels.visual },
{ id: "source", label: translatedLabels.source }
]}
/>
)}
</div>
{mode === "visual" ? (
<EditorContent editor={editor} className="wysiwyg-editor-content" data-i18n-skip="true" />
) : (
<>
{unsupportedSource && <p className="wysiwyg-editor-source-note">{translatedLabels.sourceWarning}</p>}
<textarea
ref={sourceRef}
className="wysiwyg-editor-source"
rows={sourceRows}
value={value}
disabled={disabled}
aria-label={translatedLabels.source}
onFocus={() => onFocusRef.current?.()}
onChange={changeSource}
/>
</>
)}
<Dialog
open={linkDialogOpen}
className="wysiwyg-editor-dialog"
title={translatedLabels.insertLink}
onClose={closeLinkDialog}
footer={(
<>
<Button onClick={closeLinkDialog}>{translatedLabels.cancel}</Button>
<Button variant="primary" onClick={applyLink}>{translatedLabels.apply}</Button>
</>
)}
>
<div className="form-grid">
{linkSelectionRef.current.from === linkSelectionRef.current.to && (
<FormField label={translatedLabels.linkText}>
<input value={linkText} onChange={(event) => setLinkText(event.target.value)} />
</FormField>
)}
<FormField label={translatedLabels.linkUrlLabel}>
<input value={linkUrl} inputMode="url" onChange={(event) => setLinkUrl(event.target.value)} />
</FormField>
{linkError && <p className="wysiwyg-editor-dialog-error" role="alert">{linkError}</p>}
</div>
</Dialog>
<Dialog
open={imageDialogOpen}
className="wysiwyg-editor-dialog"
title={translatedLabels.insertImage}
onClose={closeImageDialog}
footer={(
<>
<Button onClick={closeImageDialog}>{translatedLabels.cancel}</Button>
<Button variant="primary" onClick={applyImage}>{translatedLabels.apply}</Button>
</>
)}
>
<div className="form-grid">
<FormField label={translatedLabels.imageUrlLabel}>
<input value={imageUrl} inputMode="url" onChange={(event) => setImageUrl(event.target.value)} />
</FormField>
<FormField label={translatedLabels.alternativeText}>
<input value={imageAlt} onChange={(event) => setImageAlt(event.target.value)} />
</FormField>
{imageError && <p className="wysiwyg-editor-dialog-error" role="alert">{imageError}</p>}
</div>
</Dialog>
</div>
);
});
export default WysiwygEditor;
function ToolbarButton({
active,
label,
icon,
disabled,
onMouseDown,
onClick
}: {
active: boolean;
label: string;
icon: React.ReactNode;
disabled: boolean;
onMouseDown: (event: MouseEvent<HTMLButtonElement>) => void;
onClick: () => void;
}) {
return (
<IconButton
variant="ghost"
className={`wysiwyg-toolbar-button ${active ? "is-active" : ""}`}
label={label}
icon={icon}
disabled={disabled}
aria-pressed={active}
onMouseDown={onMouseDown}
onClick={onClick}
/>
);
}
function activeBlockStyle(editor: Editor): BlockStyle {
if (editor.isActive("heading", { level: 1 })) return "heading-1";
if (editor.isActive("heading", { level: 2 })) return "heading-2";
if (editor.isActive("heading", { level: 3 })) return "heading-3";
return "paragraph";
}
function editorHtmlValue(editor: Editor): string {
if (editor.isEmpty) return "";
return restoreTokensFromEditor(editor.getHTML());
}
function tokenAttributes(value: string, label?: string): WysiwygEditorToken {
const normalizedValue = value.trim();
return {
value: normalizedValue,
label: label?.trim() || tokenLabel(normalizedValue)
};
}
function tokenLabel(value: string): string {
if ((value.startsWith("{{") && value.endsWith("}}")) || (value.startsWith("${") && value.endsWith("}"))) {
return value.slice(2, value.startsWith("{{") ? -2 : -1).trim();
}
return value;
}
function insertIntoSource(
insertedValue: string,
currentValue: string,
element: HTMLTextAreaElement | null,
onChange: (value: string) => void
): boolean {
const start = element?.selectionStart ?? currentValue.length;
const end = element?.selectionEnd ?? currentValue.length;
const nextValue = `${currentValue.slice(0, start)}${insertedValue}${currentValue.slice(end)}`;
onChange(nextValue);
window.requestAnimationFrame(() => {
element?.focus();
const cursor = start + insertedValue.length;
element?.setSelectionRange(cursor, cursor);
});
return true;
}
function decorateTokensForEditor(html: string): string {
if (!html || typeof DOMParser === "undefined") return html;
const document = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
const textNodes: Text[] = [];
let current = walker.nextNode();
while (current) {
const textNode = current as Text;
if (!textNode.parentElement?.closest("[data-govoplan-token], script, style")) textNodes.push(textNode);
current = walker.nextNode();
}
for (const textNode of textNodes) {
const text = textNode.nodeValue || "";
TOKEN_SCAN_PATTERN.lastIndex = 0;
if (!TOKEN_SCAN_PATTERN.test(text)) continue;
TOKEN_SCAN_PATTERN.lastIndex = 0;
const fragment = document.createDocumentFragment();
let offset = 0;
for (const match of text.matchAll(TOKEN_SCAN_PATTERN)) {
const index = match.index ?? 0;
if (index > offset) fragment.append(document.createTextNode(text.slice(offset, index)));
const value = match[0];
const token = document.createElement("span");
token.dataset.govoplanToken = value;
token.dataset.govoplanTokenLabel = tokenLabel(value);
token.textContent = tokenLabel(value);
fragment.append(token);
offset = index + value.length;
}
if (offset < text.length) fragment.append(document.createTextNode(text.slice(offset)));
textNode.replaceWith(fragment);
}
return document.body.innerHTML;
}
function restoreTokensFromEditor(html: string): string {
if (!html || typeof DOMParser === "undefined") return html;
const document = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
for (const token of document.body.querySelectorAll<HTMLElement>("[data-govoplan-token]")) {
token.replaceWith(document.createTextNode(token.dataset.govoplanToken || token.textContent || ""));
}
return document.body.innerHTML;
}
function hasUnsupportedVisualMarkup(html: string): boolean {
if (!html.trim() || typeof DOMParser === "undefined") return false;
const document = new DOMParser().parseFromString(`<body>${html}</body>`, "text/html");
const allowedTags = new Set([
"A", "BLOCKQUOTE", "BR", "CODE", "DEL", "EM", "H1", "H2", "H3", "H4", "H5", "H6",
"HR", "I", "IMG", "LI", "OL", "P", "PRE", "S", "STRIKE", "STRONG", "U", "UL"
]);
const allowedAttributes: Record<string, Set<string>> = {
A: new Set(["href", "rel", "target", "title"]),
IMG: new Set(["alt", "height", "src", "title", "width"])
};
for (const element of document.body.querySelectorAll("*")) {
if (!allowedTags.has(element.tagName)) return true;
const allowedForTag = allowedAttributes[element.tagName] ?? new Set<string>();
for (const attribute of Array.from(element.attributes)) {
if (!allowedForTag.has(attribute.name.toLowerCase())) return true;
}
}
return false;
}
function translateLabels(labels: WysiwygEditorLabels, translateText: (value: string) => string): WysiwygEditorLabels {
return Object.fromEntries(
Object.entries(labels).map(([key, value]) => [key, translateText(value)])
) as WysiwygEditorLabels;
}
+125 -210
View File
@@ -4,6 +4,22 @@ import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRi
import StatusBadge from "../StatusBadge";
import TableActionGroup from "./TableActionGroup";
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
import {
DATA_GRID_MAX_TRACK_WIDTH as BUFFER_MAX_WIDTH,
dataGridColumnPixelWidth as columnPixelWidth,
dataGridColumnResizeWeight,
dataGridColumnTrackWithMinimum as columnTrackWithMinimum,
dataGridLayoutSignature,
dataGridWidthsForLayout,
distributeDataGridResizeAmount as distributeResizeAmount,
effectiveDataGridColumnMaxWidth as effectiveColumnMaxWidth,
effectiveDataGridColumnMinWidth as effectiveColumnMinWidth,
fitDataGridColumns,
isFlexibleDataGridWidth as isFlexibleWidth,
roundDataGridWidths as roundWidthRecord,
totalDataGridColumnWidth as totalColumnWidth,
type DataGridResizeTarget as ResizeTarget
} from "./dataGridSizing";
export type DataGridSortDirection = "asc" | "desc";
export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date" | "list";
@@ -65,8 +81,15 @@ type TypedFilterOperator = "contains" | "eq" | "gt" | "gte" | "lt" | "lte" | "be
export type DataGridColumn<T> = {
id: string;
header: ReactNode;
/**
* Preferred track width. Pixel and percentage values are preferences;
* fractional values share residual container space by their declared weight.
* A `minmax()` lower pixel bound is also enforced during manual resizing.
*/
width?: number | string;
/** Hard lower bound. Header controls may require a larger accessible minimum. */
minWidth?: number;
/** Hard upper bound. Space left after all maxima is assigned to the neutral buffer track. */
maxWidth?: number;
resizable?: boolean;
/** @deprecated Kept for source compatibility. Resize space is now distributed across resizable columns. */
@@ -89,7 +112,11 @@ export type DataGridColumn<T> = {
type DataGridState = {
sort?: {columnId: string;direction: DataGridSortDirection;} | null;
filters?: Record<string, string>;
/** Current in-memory pixel layout. This is deliberately not persisted. */
widths?: Record<string, number>;
/** Pixel widths explicitly selected by a user with a resize handle. */
userWidths?: Record<string, number>;
layoutSignature?: string;
/** Legacy layout state; removed when persisted state is sanitized. */
fillColumnId?: string | null;
/** Width of the synthetic, handle-less coverage track. */
@@ -160,13 +187,6 @@ type FilterPosition = {
width: number;
};
type ResizeTarget = {
columnId: string;
startWidth: number;
minWidth: number;
maxWidth: number;
};
type ColumnResizeState = {
columnId: string;
startX: number;
@@ -191,12 +211,7 @@ type ColumnResizeState = {
const STORAGE_PREFIX = "govoplan.datagrid.";
const FILTER_POPOVER_WIDTH = 320;
const FILTER_POPOVER_MARGIN = 12;
const MIN_HEADER_LABEL_WIDTH = 72;
const SORT_CONTROL_RESERVE = 24;
const FILTER_CONTROL_RESERVE = 32;
const RESIZE_CONTROL_RESERVE = 20;
const BUFFER_COLUMN_ID = "__data_grid_resize_buffer__";
const BUFFER_MAX_WIDTH = 1_000_000;
export default function DataGrid<T>({
id,
@@ -225,15 +240,7 @@ export default function DataGrid<T>({
const effectiveResizeBehavior: DataGridResizeBehavior = resizeBehavior === "free" && hasRightStickyColumn ?
"cover" :
resizeBehavior;
const columnSignature = columns.map((column) => [
column.id,
column.width ?? "",
column.minWidth ?? "",
column.maxWidth ?? "",
column.resizable ? "r" : "f",
column.sticky ?? ""].
join(":")).join("|");
const layoutSignature = `${columnSignature}::${resolvedInitialFit}::${effectiveResizeBehavior}`;
const layoutSignature = dataGridLayoutSignature(columns, resolvedInitialFit, effectiveResizeBehavior);
const bufferInsertIndex = findBufferInsertIndex(columns);
const containerResizeColumns = columns.filter(isContainerResizeColumn);
@@ -248,7 +255,7 @@ export default function DataGrid<T>({
[externalQueryKey]
);
const [state, setState] = useState<DataGridState>(() => mergeExternalQuery(
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort),
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey, layoutSignature), normalizedInitialFilters), normalizedInitialSort),
normalizedExternalQuery
));
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
@@ -262,8 +269,6 @@ export default function DataGrid<T>({
const onQueryChangeRef = useRef(onQueryChange);
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
const lastQueryRef = useRef<DataGridQueryState | null>(null);
const lastLayoutSignatureRef = useRef<string | null>(null);
const lastContainerWidthRef = useRef<number | undefined>(undefined);
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
@@ -273,8 +278,13 @@ export default function DataGrid<T>({
}, [pagination, serverQueryMode]);
useEffect(() => {
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
}, [columns, effectiveResizeBehavior]);
setState((current) => sanitizePersistedColumnState(
columns,
current,
effectiveResizeBehavior,
layoutSignature
));
}, [columns, effectiveResizeBehavior, layoutSignature]);
useEffect(() => {
setState((current) => mergeInitialFilters(current, normalizedInitialFilters));
@@ -290,13 +300,13 @@ export default function DataGrid<T>({
useEffect(() => {
try {
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
window.localStorage.setItem(localStorageKey, JSON.stringify(persistedState(state, layoutSignature)));
} catch {
// Local storage is an enhancement only.
}}, [localStorageKey, state]);useEffect(() => {
}
}, [localStorageKey, state, layoutSignature]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const filtersFromUrl: Record<string, string> = {};
for (const column of columns) {
@@ -343,7 +353,7 @@ export default function DataGrid<T>({
useLayoutEffect(() => {
const element = scrollRegionRef.current;
if (!element) return undefined;
if (!element || resizeState) return undefined;
const scrollElement = element;
let animationFrame = 0;
@@ -352,43 +362,39 @@ export default function DataGrid<T>({
animationFrame = window.requestAnimationFrame(() => {
const nextContainerWidth = Math.round(scrollElement.clientWidth);
if (nextContainerWidth <= 0) return;
const previousContainerWidth = lastContainerWidthRef.current;
const layoutChanged = lastLayoutSignatureRef.current !== layoutSignature;
setState((current) => {
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, current.widths, measuredWidths);
const renderedWidth = totalColumnWidth(columns, baseWidths) + Math.max(0, current.bufferWidth ?? 0);
let delta = 0;
if (layoutChanged || previousContainerWidth === undefined) {
const mustFitInitially = effectiveResizeBehavior !== "free" || resolvedInitialFit === "container";
if (!mustFitInitially) return current;
delta = nextContainerWidth - renderedWidth;
} else {
if (effectiveResizeBehavior === "free") return current;
// Preserve the user's amount of overflow while the container itself
// grows or shrinks. This is the reversible pre-pagination behavior.
delta = nextContainerWidth - previousContainerWidth;
}
if (Math.abs(delta) < 0.5) {
if (!layoutChanged && previousContainerWidth !== undefined) return current;
// Freeze the initially rendered tracks even when CSS already made
// them fit exactly. Otherwise an fr track would resize itself before
// the observer runs and the same container delta would be applied a
// second time by the reconciliation algorithm.
const signatureMatches = current.layoutSignature === layoutSignature;
const userWidths = signatureMatches ? current.userWidths ?? {} : {};
const mustFit = effectiveResizeBehavior !== "free" || resolvedInitialFit === "container";
if (!mustFit) {
if (signatureMatches && current.widths === undefined && current.bufferWidth === undefined) return current;
return {
...current,
widths: roundWidthRecord(baseWidths),
widths: undefined,
userWidths,
layoutSignature,
fillColumnId: undefined,
bufferWidth: normalizeBufferWidth(current.bufferWidth ?? 0, containerResizeColumns.length === 0)
bufferWidth: undefined
};
}
return applyContainerResize(columns, current, baseWidths, delta);
});
lastContainerWidthRef.current = nextContainerWidth;
lastLayoutSignatureRef.current = layoutSignature;
const layout = fitDataGridColumns(columns, nextContainerWidth, measuredWidths, userWidths);
const nextBufferWidth = normalizeBufferWidth(layout.bufferWidth, containerResizeColumns.length === 0);
if (
signatureMatches
&& shallowEqualNumberRecords(current.widths ?? {}, layout.widths)
&& nextBufferWidth === current.bufferWidth
) return current;
return {
...current,
widths: layout.widths,
userWidths,
layoutSignature,
fillColumnId: undefined,
bufferWidth: nextBufferWidth
};
});
});
}
@@ -407,7 +413,7 @@ export default function DataGrid<T>({
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [columns, layoutSignature, effectiveResizeBehavior, resolvedInitialFit]);
}, [columns, layoutSignature, effectiveResizeBehavior, resolvedInitialFit, measuredWidths, resizeState]);
useEffect(() => {
if (!resizeState) return;
@@ -459,13 +465,23 @@ export default function DataGrid<T>({
setState((current) => ({
...current,
widths: roundWidthRecord(nextWidths),
userWidths: {
...(current.layoutSignature === layoutSignature ? current.userWidths ?? {} : {}),
[activeResize.columnId]: Math.round(nextWidths[activeResize.columnId] * 100) / 100
},
layoutSignature,
fillColumnId: undefined,
bufferWidth: normalizeBufferWidth(nextBufferWidth, containerResizeColumns.length === 0)
}));
}
function onUp() {
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
setState((current) => sanitizePersistedColumnState(
columns,
current,
effectiveResizeBehavior,
layoutSignature
));
setResizeState(null);
}
@@ -475,7 +491,7 @@ export default function DataGrid<T>({
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, [resizeState, columns, effectiveResizeBehavior, containerResizeColumns.length]);
}, [resizeState, columns, effectiveResizeBehavior, containerResizeColumns.length, layoutSignature]);
useEffect(() => {
if (!openFilterColumnId) return undefined;
@@ -1228,16 +1244,33 @@ function translateHeaderLabel(node: ReactNode, translateText: (value: string) =>
return String(node);
}
function loadState(key: string): DataGridState {
function loadState(key: string, layoutSignature: string): DataGridState {
try {
const value = window.localStorage.getItem(key);
if (!value) return {};
return JSON.parse(value) as DataGridState;
if (!value) return { layoutSignature };
const parsed = JSON.parse(value) as DataGridState;
const userWidths = dataGridWidthsForLayout(parsed.layoutSignature, layoutSignature, parsed.widths);
return {
sort: parsed.sort,
filters: parsed.filters,
widths: userWidths,
userWidths,
layoutSignature
};
} catch {
return {};
return { layoutSignature };
}
}
function persistedState(state: DataGridState, layoutSignature: string): DataGridState {
return {
sort: state.sort,
filters: state.filters,
widths: state.userWidths,
layoutSignature
};
}
function normalizeInitialFilters(filters: Record<string, string | string[]>): Record<string, string> {
return Object.fromEntries(
Object.entries(filters).map(([columnId, value]) => [
@@ -1326,11 +1359,13 @@ function humanizeListValue(value: string): string {
function sanitizePersistedColumnState<T>(
columns: DataGridColumn<T>[],
state: DataGridState,
resizeBehavior: DataGridResizeBehavior)
resizeBehavior: DataGridResizeBehavior,
layoutSignature: string)
: DataGridState {
const columnMap = new Map(columns.map((column) => [column.id, column]));
const nextWidths = Object.fromEntries(
Object.entries(state.widths ?? {}).flatMap(([columnId, width]) => {
const signatureMatches = state.layoutSignature === layoutSignature;
const sanitizeWidths = (widths?: Record<string, number>) => Object.fromEntries(
Object.entries(widths ?? {}).flatMap(([columnId, width]) => {
const column = columnMap.get(columnId);
if (!column || !Number.isFinite(width)) return [];
const minimum = effectiveColumnMinWidth(column);
@@ -1338,18 +1373,25 @@ resizeBehavior: DataGridResizeBehavior)
return [[columnId, Math.min(maximum, Math.max(minimum, width))]];
})
);
const nextWidths = signatureMatches ? sanitizeWidths(state.widths) : {};
const nextUserWidths = signatureMatches ? sanitizeWidths(state.userWidths) : {};
const normalizedWidths = Object.keys(nextWidths).length > 0 ? roundWidthRecord(nextWidths) : undefined;
const normalizedUserWidths = Object.keys(nextUserWidths).length > 0 ? roundWidthRecord(nextUserWidths) : undefined;
const hasContainerResizeColumn = columns.some(isContainerResizeColumn);
const normalizedBufferWidth = resizeBehavior === "free" ?
const normalizedBufferWidth = resizeBehavior === "free" || !signatureMatches ?
undefined :
normalizeBufferWidth(state.bufferWidth ?? 0, !hasContainerResizeColumn);
const widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, normalizedWidths ?? {});
const userWidthsChanged = !shallowEqualNumberRecords(state.userWidths ?? {}, normalizedUserWidths ?? {});
const bufferChanged = normalizedBufferWidth !== state.bufferWidth;
const fillChanged = state.fillColumnId !== undefined;
if (!widthsChanged && !bufferChanged && !fillChanged) return state;
const signatureChanged = state.layoutSignature !== layoutSignature;
if (!widthsChanged && !userWidthsChanged && !bufferChanged && !fillChanged && !signatureChanged) return state;
return {
...state,
widths: normalizedWidths,
userWidths: normalizedUserWidths,
layoutSignature,
fillColumnId: undefined,
bufferWidth: normalizedBufferWidth
};
@@ -1364,24 +1406,6 @@ function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number): stri
return `minmax(${minimum}px, 1fr)`;
}
function columnTrackWithMinimum(width: string, minimum: number): string {
const normalized = width.trim();
const minmaxMatch = normalized.match(/^minmax\(\s*([^,]+),\s*(.+)\)$/i);
if (minmaxMatch) {
const parsedMinimum = parsePixelWidth(minmaxMatch[1]);
const minimumTrack = parsedMinimum === null ? `${minimum}px` : `${Math.max(minimum, parsedMinimum)}px`;
return `minmax(${minimumTrack}, ${minmaxMatch[2]})`;
}
const parsed = parsePixelWidth(normalized);
if (parsed !== null && /^\s*\d+(?:\.\d+)?px\s*$/i.test(normalized)) {
return `${Math.max(minimum, parsed)}px`;
}
if (normalized === "auto" || normalized.includes("fr")) {
return `minmax(${minimum}px, ${normalized})`;
}
return normalized;
}
function findBufferInsertIndex<T>(columns: DataGridColumn<T>[]): number {
let index = columns.length;
while (index > 0 && columns[index - 1].sticky === "end") index -= 1;
@@ -1389,7 +1413,7 @@ function findBufferInsertIndex<T>(columns: DataGridColumn<T>[]): number {
}
function isContainerResizeColumn<T>(column: DataGridColumn<T>): boolean {
return Boolean(column.resizable && !column.sticky);
return isFlexibleWidth(column.width, column.fill);
}
function isResizeCompensationColumn<T>(column: DataGridColumn<T>): boolean {
@@ -1402,48 +1426,8 @@ function resizeTargetForColumn<T>(column: DataGridColumn<T>, baseWidths: Record<
columnId: column.id,
startWidth: baseWidths[column.id] ?? columnPixelWidth(column),
minWidth: minimum,
maxWidth: effectiveColumnMaxWidth(column, minimum)
};
}
function effectiveColumnMinWidth<T>(column: DataGridColumn<T>): number {
const affordanceWidth = MIN_HEADER_LABEL_WIDTH + (
column.sortable ? SORT_CONTROL_RESERVE : 0) + (
column.filterable ? FILTER_CONTROL_RESERVE : 0) + (
column.resizable ? RESIZE_CONTROL_RESERVE : 0);
return Math.max(column.minWidth ?? 0, affordanceWidth);
}
function effectiveColumnMaxWidth<T>(column: DataGridColumn<T>, minimum = effectiveColumnMinWidth(column)): number {
if (column.maxWidth !== undefined) return Math.max(minimum, column.maxWidth);
return Math.max(minimum, isFlexibleWidth(column.width) ? BUFFER_MAX_WIDTH : 2000);
}
function totalColumnWidth<T>(columns: DataGridColumn<T>[], widths: Record<string, number>): number {
return columns.reduce((total, column) => total + (widths[column.id] ?? columnPixelWidth(column)), 0);
}
function applyContainerResize<T>(
columns: DataGridColumn<T>[],
state: DataGridState,
baseWidths: Record<string, number>,
totalDelta: number)
: DataGridState {
const targets = columns.filter(isContainerResizeColumn).map((column) => resizeTargetForColumn(column, baseWidths));
const bufferStartWidth = Math.max(0, state.bufferWidth ?? 0);
const distribution = distributeWithFallback(totalDelta, targets, {
columnId: BUFFER_COLUMN_ID,
startWidth: bufferStartWidth,
minWidth: 0,
maxWidth: BUFFER_MAX_WIDTH
});
const nextWidths = { ...baseWidths };
const applied = applyResizeAmounts(nextWidths, bufferStartWidth, distribution.amounts);
return {
...state,
widths: roundWidthRecord(nextWidths),
fillColumnId: undefined,
bufferWidth: normalizeBufferWidth(applied.bufferWidth, targets.length === 0)
maxWidth: effectiveColumnMaxWidth(column, minimum),
weight: dataGridColumnResizeWeight(column)
};
}
@@ -1473,47 +1457,6 @@ fallback?: ResizeTarget)
};
}
function distributeResizeAmount(
requestedAmount: number,
targets: ResizeTarget[])
: {applied: number;amounts: Record<string, number>;} {
const amounts: Record<string, number> = Object.fromEntries(targets.map((target) => [target.columnId, 0]));
const direction = Math.sign(requestedAmount);
let remaining = Math.abs(requestedAmount);
if (direction === 0 || remaining <= 0.01) return { applied: 0, amounts };
let activeTargets = targets.filter((target) => resizeCapacity(target, direction) > 0.01);
while (remaining > 0.01 && activeTargets.length > 0) {
const share = remaining / activeTargets.length;
let appliedThisRound = 0;
const nextTargets: ResizeTarget[] = [];
for (const target of activeTargets) {
const used = Math.abs(amounts[target.columnId] ?? 0);
const available = Math.max(0, resizeCapacity(target, direction) - used);
const applied = Math.min(share, available);
amounts[target.columnId] = (amounts[target.columnId] ?? 0) + applied * direction;
appliedThisRound += applied;
if (available - applied > 0.01) nextTargets.push(target);
}
if (appliedThisRound <= 0.01) break;
remaining -= appliedThisRound;
activeTargets = nextTargets;
}
return {
applied: (Math.abs(requestedAmount) - remaining) * direction,
amounts
};
}
function resizeCapacity(target: ResizeTarget, direction: number): number {
return direction > 0 ?
Math.max(0, target.maxWidth - target.startWidth) :
Math.max(0, target.startWidth - target.minWidth);
}
function applyResizeAmounts(
widths: Record<string, number>,
bufferWidth: number,
@@ -1531,12 +1474,6 @@ function normalizeBufferWidth(width: number, preserveZero: boolean): number | un
return normalized > 0.01 || preserveZero ? normalized : undefined;
}
function roundWidthRecord(widths: Record<string, number>): Record<string, number> {
return Object.fromEntries(
Object.entries(widths).map(([columnId, width]) => [columnId, Math.round(width * 100) / 100])
);
}
function measuredColumnWidths<T>(
columns: DataGridColumn<T>[],
elements: Record<string, HTMLDivElement | null>,
@@ -1547,40 +1484,18 @@ measuredWidths?: Record<string, number>)
for (const column of columns) {
const element = elements[column.id];
const measured = element ? Math.round(element.getBoundingClientRect().width) : undefined;
result[column.id] = measured && measured > 0 ? measured : columnPixelWidth(column, savedWidths?.[column.id], measuredWidths?.[column.id]);
result[column.id] = columnPixelWidth(
column,
savedWidths?.[column.id],
measured && measured > 0 ? measured : measuredWidths?.[column.id]
);
}
return result;
}
function isFlexibleColumn<T>(column: DataGridColumn<T>, savedWidth?: number): boolean {
if (savedWidth && !isFlexibleWidth(column.width)) return false;
return isFlexibleWidth(column.width);
}
function isFlexibleWidth(width?: string | number): boolean {
if (width === undefined) return true;
if (typeof width === "number") return false;
const normalized = width.toLowerCase();
return normalized.includes("fr") || normalized.includes("minmax") || normalized.includes("auto");
}
function columnPixelWidth<T>(column: DataGridColumn<T>, savedWidth?: number, measuredWidth?: number): number {
const minimum = effectiveColumnMinWidth(column);
const maximum = effectiveColumnMaxWidth(column, minimum);
const configured = measuredWidth ??
savedWidth ?? (
typeof column.width === "number" ? column.width : parsePixelWidth(column.width)) ??
minimum;
return Math.min(maximum, Math.max(minimum, configured));
}
function parsePixelWidth(width?: string | number): number | null {
if (typeof width === "number") return width;
if (!width) return null;
const pxMatch = width.match(/(\d+(?:\.\d+)?)px/);
if (!pxMatch) return null;
const parsed = Number.parseFloat(pxMatch[1]);
return Number.isFinite(parsed) ? parsed : null;
if (savedWidth && !isFlexibleWidth(column.width, column.fill)) return false;
return isFlexibleWidth(column.width, column.fill);
}
function computeStickyOffsets<T>(columns: DataGridColumn<T>[], savedWidths?: Record<string, number>, measuredWidths?: Record<string, number>): Record<number, number> {
@@ -0,0 +1,303 @@
export type DataGridSizingColumn = {
id: string;
width?: number | string;
minWidth?: number;
maxWidth?: number;
resizable?: boolean;
/** @deprecated Retained for compatibility with older column definitions. */
fill?: boolean;
sortable?: boolean;
filterable?: boolean;
sticky?: "start" | "end";
};
export type DataGridResizeTarget = {
columnId: string;
startWidth: number;
minWidth: number;
maxWidth: number;
weight?: number;
};
export type DataGridColumnLayout = {
widths: Record<string, number>;
bufferWidth: number;
overflowWidth: number;
};
const MIN_HEADER_LABEL_WIDTH = 72;
const SORT_CONTROL_RESERVE = 24;
const FILTER_CONTROL_RESERVE = 32;
const RESIZE_CONTROL_RESERVE = 20;
export const DATA_GRID_MAX_TRACK_WIDTH = 1_000_000;
export function dataGridLayoutSignature(
columns: DataGridSizingColumn[],
initialFit: string,
resizeBehavior: string
): string {
const columnSignature = columns.map((column) => [
column.id,
column.width ?? "",
column.minWidth ?? "",
column.maxWidth ?? "",
column.resizable ? "r" : "f",
column.fill ? "fill" : "",
column.sticky ?? ""
].join(":")).join("|");
return `${columnSignature}::${initialFit}::${resizeBehavior}`;
}
export function dataGridWidthsForLayout(
persistedSignature: string | undefined,
currentSignature: string,
persistedWidths: Record<string, number> | undefined
): Record<string, number> | undefined {
return persistedSignature === currentSignature ? persistedWidths : undefined;
}
export function effectiveDataGridColumnMinWidth(column: DataGridSizingColumn): number {
const affordanceWidth = MIN_HEADER_LABEL_WIDTH
+ (column.sortable ? SORT_CONTROL_RESERVE : 0)
+ (column.filterable ? FILTER_CONTROL_RESERVE : 0)
+ (column.resizable ? RESIZE_CONTROL_RESERVE : 0);
const declaredTrackMinimum = minmaxParts(column.width)?.minimum;
const trackMinimum = declaredTrackMinimum ? parsePixelTrack(declaredTrackMinimum) ?? 0 : 0;
return Math.max(column.minWidth ?? 0, trackMinimum, affordanceWidth);
}
export function effectiveDataGridColumnMaxWidth(
column: DataGridSizingColumn,
minimum = effectiveDataGridColumnMinWidth(column)
): number {
const declaredMaximum = column.maxWidth ?? parsePixelTrack(minmaxParts(column.width)?.preferred);
if (declaredMaximum !== null && declaredMaximum !== undefined) {
return Math.max(minimum, declaredMaximum);
}
return Math.max(minimum, isFlexibleDataGridWidth(column.width, column.fill) ? DATA_GRID_MAX_TRACK_WIDTH : 2000);
}
export function isFlexibleDataGridWidth(width?: string | number, fill = false): boolean {
if (fill || width === undefined) return true;
if (typeof width === "number") return false;
const normalized = width.trim().toLowerCase();
return normalized === "auto"
|| parseFractionTrack(normalized) !== null
|| parsePercentageTrack(normalized) !== null
|| minmaxParts(normalized) !== null;
}
export function dataGridColumnResizeWeight(column: DataGridSizingColumn): number {
if (column.fill) return 1;
const width = column.width;
if (typeof width !== "string") return 1;
const preferred = minmaxParts(width)?.preferred ?? width;
return parseFractionTrack(preferred) ?? parsePercentageTrack(preferred) ?? 1;
}
export function preferredDataGridColumnWidth(
column: DataGridSizingColumn,
containerWidth: number,
measuredWidth?: number
): number {
const minimum = effectiveDataGridColumnMinWidth(column);
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
const width = column.width;
let preferred: number | null = null;
if (typeof width === "number") {
preferred = width;
} else if (typeof width === "string") {
const track = minmaxParts(width)?.preferred ?? width;
preferred = parsePixelTrack(track);
const percentage = parsePercentageTrack(track);
if (preferred === null && percentage !== null) preferred = containerWidth * percentage / 100;
if (preferred === null && track.trim().toLowerCase() === "auto") preferred = measuredWidth ?? minimum;
}
if (preferred === null) {
preferred = isFlexibleDataGridWidth(width, column.fill) ? minimum : measuredWidth ?? minimum;
}
return clampWidth(preferred, minimum, maximum);
}
export function fitDataGridColumns(
columns: DataGridSizingColumn[],
containerWidth: number,
measuredWidths: Record<string, number> = {},
userWidths: Record<string, number> = {}
): DataGridColumnLayout {
const safeContainerWidth = Math.max(0, containerWidth);
const widths: Record<string, number> = {};
for (const column of columns) {
const minimum = effectiveDataGridColumnMinWidth(column);
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
const override = userWidths[column.id];
widths[column.id] = override !== undefined && Number.isFinite(override)
? clampWidth(override, minimum, maximum)
: preferredDataGridColumnWidth(column, safeContainerWidth, measuredWidths[column.id]);
}
const preferredTotal = totalDataGridColumnWidth(columns, widths);
const requestedAmount = safeContainerWidth - preferredTotal;
const targets = columns
.filter((column) => userWidths[column.id] === undefined)
.filter((column) => requestedAmount >= 0
? dataGridColumnGrowthWeight(column) > 0
: isFlexibleDataGridWidth(column.width, column.fill) || Boolean(column.resizable))
.map((column): DataGridResizeTarget => ({
columnId: column.id,
startWidth: widths[column.id],
minWidth: effectiveDataGridColumnMinWidth(column),
maxWidth: effectiveDataGridColumnMaxWidth(column),
weight: requestedAmount >= 0
? dataGridColumnGrowthWeight(column)
: dataGridColumnResizeWeight(column)
}));
const distribution = distributeDataGridResizeAmount(requestedAmount, targets);
for (const [columnId, amount] of Object.entries(distribution.amounts)) {
widths[columnId] += amount;
}
const remaining = requestedAmount - distribution.applied;
return {
widths: roundDataGridWidths(widths),
bufferWidth: Math.round(Math.max(0, remaining) * 100) / 100,
overflowWidth: Math.round(Math.max(0, -remaining) * 100) / 100
};
}
export function distributeDataGridResizeAmount(
requestedAmount: number,
targets: DataGridResizeTarget[]
): { applied: number; amounts: Record<string, number> } {
const amounts: Record<string, number> = Object.fromEntries(targets.map((target) => [target.columnId, 0]));
const direction = Math.sign(requestedAmount);
let remaining = Math.abs(requestedAmount);
if (direction === 0 || remaining <= 0.01) return { applied: 0, amounts };
let activeTargets = targets.filter((target) => resizeCapacity(target, direction) > 0.01);
while (remaining > 0.01 && activeTargets.length > 0) {
const totalWeight = activeTargets.reduce((total, target) => total + normalizedWeight(target.weight), 0);
let appliedThisRound = 0;
const nextTargets: DataGridResizeTarget[] = [];
for (const target of activeTargets) {
const used = Math.abs(amounts[target.columnId] ?? 0);
const available = Math.max(0, resizeCapacity(target, direction) - used);
const share = remaining * normalizedWeight(target.weight) / totalWeight;
const applied = Math.min(share, available);
amounts[target.columnId] = (amounts[target.columnId] ?? 0) + applied * direction;
appliedThisRound += applied;
if (available - applied > 0.01) nextTargets.push(target);
}
if (appliedThisRound <= 0.01) break;
remaining -= appliedThisRound;
activeTargets = nextTargets;
}
return {
applied: (Math.abs(requestedAmount) - remaining) * direction,
amounts
};
}
export function dataGridColumnPixelWidth(
column: DataGridSizingColumn,
savedWidth?: number,
measuredWidth?: number,
containerWidth = 0
): number {
const minimum = effectiveDataGridColumnMinWidth(column);
const maximum = effectiveDataGridColumnMaxWidth(column, minimum);
const preferred = savedWidth
?? measuredWidth
?? preferredDataGridColumnWidth(column, containerWidth, measuredWidth);
return clampWidth(preferred, minimum, maximum);
}
export function dataGridColumnTrackWithMinimum(width: string, minimum: number): string {
const normalized = width.trim();
const parts = minmaxParts(normalized);
if (parts) {
const parsedMinimum = parsePixelTrack(parts.minimum);
const minimumTrack = parsedMinimum === null ? `${minimum}px` : `${Math.max(minimum, parsedMinimum)}px`;
return `minmax(${minimumTrack}, ${parts.preferred})`;
}
const parsed = parsePixelTrack(normalized);
if (parsed !== null) return `${Math.max(minimum, parsed)}px`;
if (normalized.toLowerCase() === "auto" || parseFractionTrack(normalized) !== null) {
return `minmax(${minimum}px, ${normalized})`;
}
return normalized;
}
export function totalDataGridColumnWidth(
columns: DataGridSizingColumn[],
widths: Record<string, number>
): number {
return columns.reduce(
(total, column) => total + (widths[column.id] ?? dataGridColumnPixelWidth(column)),
0
);
}
export function roundDataGridWidths(widths: Record<string, number>): Record<string, number> {
return Object.fromEntries(
Object.entries(widths).map(([columnId, width]) => [columnId, Math.round(width * 100) / 100])
);
}
function minmaxParts(width?: string | number): { minimum: string; preferred: string } | null {
if (typeof width !== "string") return null;
const match = width.trim().match(/^minmax\(\s*([^,]+),\s*(.+)\)$/i);
return match ? { minimum: match[1].trim(), preferred: match[2].trim() } : null;
}
function dataGridColumnGrowthWeight(column: DataGridSizingColumn): number {
if (column.fill || column.width === undefined) return 1;
if (typeof column.width !== "string") return 0;
const preferred = minmaxParts(column.width)?.preferred ?? column.width;
return parseFractionTrack(preferred) ?? 0;
}
function parsePixelTrack(track?: string): number | null {
if (!track) return null;
const match = track.trim().match(/^(\d+(?:\.\d+)?)px$/i);
if (!match) return null;
const value = Number.parseFloat(match[1]);
return Number.isFinite(value) ? value : null;
}
function parsePercentageTrack(track?: string): number | null {
if (!track) return null;
const match = track.trim().match(/^(\d+(?:\.\d+)?)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
return Number.isFinite(value) && value >= 0 ? value : null;
}
function parseFractionTrack(track?: string): number | null {
if (!track) return null;
const match = track.trim().match(/^(\d+(?:\.\d+)?)?fr$/i);
if (!match) return null;
const value = match[1] ? Number.parseFloat(match[1]) : 1;
return Number.isFinite(value) && value > 0 ? value : null;
}
function resizeCapacity(target: DataGridResizeTarget, direction: number): number {
return direction > 0
? Math.max(0, target.maxWidth - target.startWidth)
: Math.max(0, target.startWidth - target.minWidth);
}
function normalizedWeight(weight?: number): number {
return Number.isFinite(weight) && (weight ?? 0) > 0 ? weight as number : 1;
}
function clampWidth(width: number, minimum: number, maximum: number): number {
return Math.min(maximum, Math.max(minimum, width));
}
+45
View File
@@ -0,0 +1,45 @@
const EMAIL_ADDRESS_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const DOMAIN_PATTERN = /^(?:www\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d+)?(?:[/?#].*)?$/i;
const DISALLOWED_URL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f\s]/;
const RASTER_DATA_IMAGE_PATTERN = /^data:image\/(?:gif|jpeg|png|webp);base64,[a-z0-9+/=]+$/i;
export function normalizeWysiwygLinkUrl(value: string): string | null {
const candidate = value.trim();
if (!candidate || DISALLOWED_URL_CHARACTER_PATTERN.test(candidate)) return null;
if (candidate.startsWith("#") || isRelativeUrl(candidate)) return candidate;
if (EMAIL_ADDRESS_PATTERN.test(candidate)) return `mailto:${candidate}`;
if (candidate.startsWith("//")) return normalizeAbsoluteUrl(`https:${candidate}`, LINK_PROTOCOLS);
if (DOMAIN_PATTERN.test(candidate)) return normalizeAbsoluteUrl(`https://${candidate}`, LINK_PROTOCOLS);
return normalizeAbsoluteUrl(candidate, LINK_PROTOCOLS);
}
export function normalizeWysiwygImageUrl(value: string): string | null {
const candidate = value.trim();
if (!candidate) return null;
if (RASTER_DATA_IMAGE_PATTERN.test(candidate)) return candidate;
if (DISALLOWED_URL_CHARACTER_PATTERN.test(candidate)) return null;
if (/^cid:[^<>"']+$/i.test(candidate)) return candidate;
if (isRelativeUrl(candidate)) return candidate;
if (candidate.startsWith("//")) return normalizeAbsoluteUrl(`https:${candidate}`, IMAGE_PROTOCOLS);
if (DOMAIN_PATTERN.test(candidate)) return normalizeAbsoluteUrl(`https://${candidate}`, IMAGE_PROTOCOLS);
return normalizeAbsoluteUrl(candidate, IMAGE_PROTOCOLS);
}
const LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"]);
const IMAGE_PROTOCOLS = new Set(["http:", "https:"]);
function isRelativeUrl(value: string): boolean {
return value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("?");
}
function normalizeAbsoluteUrl(value: string, allowedProtocols: Set<string>): string | null {
try {
const parsed = new URL(value);
if (!allowedProtocols.has(parsed.protocol.toLowerCase())) return null;
if ((parsed.protocol === "http:" || parsed.protocol === "https:") && !parsed.hostname) return null;
return parsed.href;
} catch {
return null;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useSearchParams } from "react-router";
import type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
import Card from "../../components/Card";
import FormField from "../../components/FormField";
+60
View File
@@ -587,6 +587,36 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
"i18n:govoplan-core.your_session_has_expired_sign_in_again_to_contin.4cc6a1b3": "Your session has expired. Sign in again to continue.",
"i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8": "YYYY-MM-DD",
"i18n:govoplan-core.rich_text_editor.b56157a6": "Rich text editor",
"i18n:govoplan-core.visual.770d690e": "Visual",
"i18n:govoplan-core.html_source.1d4100b2": "HTML source",
"i18n:govoplan-core.block_style.c050e2eb": "Block style",
"i18n:govoplan-core.paragraph.05058e0b": "Paragraph",
"i18n:govoplan-core.heading_1.fdc7d47a": "Heading 1",
"i18n:govoplan-core.heading_2.6542d386": "Heading 2",
"i18n:govoplan-core.heading_3.9694d18f": "Heading 3",
"i18n:govoplan-core.undo.39fc7212": "Undo",
"i18n:govoplan-core.redo.471b94d4": "Redo",
"i18n:govoplan-core.bold.19e07430": "Bold",
"i18n:govoplan-core.italic.1616e2e5": "Italic",
"i18n:govoplan-core.underline.39773aa3": "Underline",
"i18n:govoplan-core.strikethrough.a93b9a68": "Strikethrough",
"i18n:govoplan-core.inline_code.6f3a4263": "Inline code",
"i18n:govoplan-core.bullet_list.660d9fbe": "Bullet list",
"i18n:govoplan-core.numbered_list.8294ea4f": "Numbered list",
"i18n:govoplan-core.block_quote.b3399cd3": "Block quote",
"i18n:govoplan-core.insert_link.4d4ac9a1": "Insert link",
"i18n:govoplan-core.remove_link.8d48d1f9": "Remove link",
"i18n:govoplan-core.insert_image.c8141eb5": "Insert image",
"i18n:govoplan-core.clear_formatting.6c8a26c4": "Clear formatting",
"i18n:govoplan-core.link_text.502f872d": "Link text",
"i18n:govoplan-core.link_url.08738f6e": "Link URL",
"i18n:govoplan-core.image_url.35c80f96": "Image URL",
"i18n:govoplan-core.alternative_text.8d87ae1b": "Alternative text",
"i18n:govoplan-core.apply.cfea419c": "Apply",
"i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Enter a valid HTTP, HTTPS, mail or phone link.",
"i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c": "Enter a valid HTTP, HTTPS, CID or raster data image.",
"i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c": "This HTML uses markup outside the visual editor. Use HTML source mode to preserve it.",
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive"
},
"de": {
@@ -1175,6 +1205,36 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-core.your_profile_personal_webui_preferences_and_loca.beda6d56": "Your profile, personal WebUI preferences and local browser connection settings. Tenant-wide administration lives in Admin.",
"i18n:govoplan-core.your_session_has_expired_sign_in_again_to_contin.4cc6a1b3": "Your session has expired. Sign in again to continue.",
"i18n:govoplan-core.yyyy_mm_dd.d3f8f7b8": "YYYY-MM-DD",
"i18n:govoplan-core.rich_text_editor.b56157a6": "Rich-Text-Editor",
"i18n:govoplan-core.visual.770d690e": "Visuell",
"i18n:govoplan-core.html_source.1d4100b2": "HTML-Quelltext",
"i18n:govoplan-core.block_style.c050e2eb": "Absatzformat",
"i18n:govoplan-core.paragraph.05058e0b": "Absatz",
"i18n:govoplan-core.heading_1.fdc7d47a": "Überschrift 1",
"i18n:govoplan-core.heading_2.6542d386": "Überschrift 2",
"i18n:govoplan-core.heading_3.9694d18f": "Überschrift 3",
"i18n:govoplan-core.undo.39fc7212": "Rückgängig",
"i18n:govoplan-core.redo.471b94d4": "Wiederholen",
"i18n:govoplan-core.bold.19e07430": "Fett",
"i18n:govoplan-core.italic.1616e2e5": "Kursiv",
"i18n:govoplan-core.underline.39773aa3": "Unterstrichen",
"i18n:govoplan-core.strikethrough.a93b9a68": "Durchgestrichen",
"i18n:govoplan-core.inline_code.6f3a4263": "Inline-Code",
"i18n:govoplan-core.bullet_list.660d9fbe": "Aufzählung",
"i18n:govoplan-core.numbered_list.8294ea4f": "Nummerierte Liste",
"i18n:govoplan-core.block_quote.b3399cd3": "Blockzitat",
"i18n:govoplan-core.insert_link.4d4ac9a1": "Link einfügen",
"i18n:govoplan-core.remove_link.8d48d1f9": "Link entfernen",
"i18n:govoplan-core.insert_image.c8141eb5": "Bild einfügen",
"i18n:govoplan-core.clear_formatting.6c8a26c4": "Formatierung entfernen",
"i18n:govoplan-core.link_text.502f872d": "Linktext",
"i18n:govoplan-core.link_url.08738f6e": "Link-URL",
"i18n:govoplan-core.image_url.35c80f96": "Bild-URL",
"i18n:govoplan-core.alternative_text.8d87ae1b": "Alternativtext",
"i18n:govoplan-core.apply.cfea419c": "Anwenden",
"i18n:govoplan-core.enter_a_valid_http_https_mail_or_phone_link.45447137": "Geben Sie einen gültigen HTTP-, HTTPS-, E-Mail- oder Telefon-Link ein.",
"i18n:govoplan-core.enter_a_valid_http_https_cid_or_raster_data_image.2cec4b3c": "Geben Sie eine gültige HTTP-, HTTPS-, CID- oder Rasterdaten-Bildadresse ein.",
"i18n:govoplan-core.this_html_uses_markup_outside_the_visual_editor.5adb2a3c": "Dieses HTML verwendet Markup außerhalb des visuellen Editors. Verwenden Sie den HTML-Quelltextmodus, um es zu erhalten.",
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive"
}
};
+6
View File
@@ -156,6 +156,12 @@ export type { SegmentedControlOption, SegmentedControlProps, SegmentedControlSiz
export { default as SelectionList, SelectionListItem } from "./components/SelectionList";
export type { SelectionListItemProps, SelectionListProps } from "./components/SelectionList";
export { default as StatusBadge } from "./components/StatusBadge";
export { default as StageRail } from "./components/StageRail";
export type {
StageRailItem,
StageRailProps,
StageRailTone
} from "./components/StageRail";
export { default as Stepper } from "./components/Stepper";
export { default as ToggleSwitch } from "./components/ToggleSwitch";
export { UnsavedChangesProvider, useGuardedNavigate, useRegisterUnsavedChanges, useUnsavedChanges, useUnsavedDraftGuard } from "./components/UnsavedChangesGuard";
+1 -1
View File
@@ -1,4 +1,4 @@
import { useLocation } from "react-router-dom";
import { useLocation } from "react-router";
import type { ApiSettings, AuthInfo, AuthUpdate, PlatformNavItem } from "../types";
import IconRail from "./IconRail";
import Titlebar from "./Titlebar";
+1 -1
View File
@@ -1,4 +1,4 @@
import { Link } from "react-router-dom";
import { Link } from "react-router";
import { ChevronRight } from "lucide-react";
import { usePlatformLanguage } from "../i18n/LanguageContext";
+1 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import { useLocation } from "react-router";
import { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
import packageInfo from "../../package.json";
import Button from "../components/Button";
+1 -1
View File
@@ -1,5 +1,5 @@
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
import { NavLink, useLocation } from "react-router-dom";
import { NavLink, useLocation } from "react-router";
import { useEffect, useMemo, useState, type MouseEvent } from "react";
import type { AuthInfo, PlatformNavItem } from "../types";
import { hasAnyScope, hasScope } from "../utils/permissions";
+1 -1
View File
@@ -1,6 +1,6 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { BrowserRouter } from "react-router";
import App from "./app";
ReactDOM.createRoot(document.getElementById("root")!).render(
+21
View File
@@ -6,6 +6,13 @@ import type {
} from "../types";
export const PLATFORM_VIEW_CHANGED_EVENT = "govoplan:view-changed";
export const PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT =
"govoplan:workflow-view-changed";
export type WorkflowViewChangedEventDetail = {
projection: EffectiveViewProjection | null;
contextId?: string | null;
};
export function moduleViewSurfaceId(moduleId: string): string {
return `${moduleId}.module`;
@@ -115,6 +122,20 @@ export function dispatchPlatformViewChanged(): void {
}
}
export function dispatchWorkflowViewChanged(
projection: EffectiveViewProjection | null,
contextId?: string | null
): void {
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent<WorkflowViewChangedEventDetail>(
PLATFORM_WORKFLOW_VIEW_CHANGED_EVENT,
{ detail: { projection, contextId } }
)
);
}
}
function addSurface(
target: Map<string, PlatformViewSurface>,
surface: PlatformViewSurface
+347
View File
@@ -3670,3 +3670,350 @@ a.dashboard-contribution-row:hover {
grid-template-columns: 1fr;
}
}
.stage-rail {
max-width: 100%;
overflow-x: auto;
}
.stage-rail-track {
display: flex;
align-items: flex-start;
width: max-content;
min-width: max-content;
margin: 0 auto;
}
.stage-rail-group {
display: flex;
align-items: flex-start;
}
.stage-rail-item {
display: grid;
min-width: 96px;
justify-items: center;
gap: 6px;
padding: 3px 5px;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
text-align: center;
}
button.stage-rail-item {
cursor: pointer;
}
button.stage-rail-item:hover .stage-rail-icon,
button.stage-rail-item:focus-visible .stage-rail-icon,
.stage-rail-group[data-current="true"] .stage-rail-icon {
background: color-mix(in srgb, var(--stage-rail-color) 15%, var(--surface));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--stage-rail-color) 12%, transparent);
}
button.stage-rail-item:focus-visible {
outline: none;
}
.stage-rail-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid var(--stage-rail-color);
border-radius: 50%;
background: color-mix(in srgb, var(--stage-rail-color) 8%, var(--surface));
color: color-mix(in srgb, var(--stage-rail-color) 82%, var(--text));
transition: background .16s ease, box-shadow .16s ease;
}
.stage-rail-copy {
display: grid;
min-width: 0;
justify-items: center;
}
.stage-rail-copy strong {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
color: var(--text-strong);
font-size: 12px;
white-space: nowrap;
}
.stage-rail-copy small {
display: inline-flex;
min-height: 14px;
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 11px;
white-space: nowrap;
}
.stage-rail-status {
color: color-mix(in srgb, var(--stage-rail-color) 72%, var(--text)) !important;
}
.stage-rail-line {
width: 42px;
height: 2px;
flex: 0 0 auto;
margin: 21px 2px 0;
background: linear-gradient(
to right,
var(--stage-rail-line-color, var(--stage-rail-color)),
var(--stage-rail-line-next-color, var(--stage-rail-color))
);
opacity: .82;
}
.wysiwyg-editor {
min-width: 0;
overflow: hidden;
border: var(--border-line);
border-radius: 5px;
background: var(--surface);
box-shadow: var(--shadow-control-inset);
transition: border-color .16s ease, box-shadow .16s ease;
}
.wysiwyg-editor:focus-within {
border-color: var(--input-border-focus);
box-shadow: var(--focus-ring), var(--shadow-control-inset);
}
.wysiwyg-editor-header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
padding: 6px;
border-bottom: var(--border-line);
background: var(--surface-muted);
}
.wysiwyg-editor-toolbar {
display: flex;
min-width: 0;
flex: 1 1 auto;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.wysiwyg-toolbar-group {
display: inline-flex;
align-items: center;
gap: 2px;
padding-right: 4px;
border-right: var(--border-line);
}
.wysiwyg-toolbar-group:last-child {
padding-right: 0;
border-right: 0;
}
.wysiwyg-toolbar-button.icon-button.btn {
width: 32px;
height: 32px;
min-width: 32px;
padding: 0;
border-color: transparent;
box-shadow: none;
}
.wysiwyg-toolbar-button.icon-button.btn:hover:not(:disabled),
.wysiwyg-toolbar-button.icon-button.btn:focus-visible,
.wysiwyg-toolbar-button.icon-button.btn.is-active {
border-color: var(--line-dark);
background: var(--line);
color: var(--text-strong);
}
.wysiwyg-toolbar-button.icon-button.btn.is-active {
box-shadow: inset 0 1px 3px rgba(var(--shadow-color-rgb), .12);
}
.wysiwyg-block-style {
flex: 0 0 auto;
}
.wysiwyg-block-style select {
width: 126px;
min-height: 32px;
padding: 5px 28px 5px 8px;
font-size: 13px;
}
.wysiwyg-editor-mode {
flex: 0 0 auto;
}
.wysiwyg-editor-mode .segmented-control-option {
min-height: 32px;
padding: 5px 9px;
}
.wysiwyg-editor-content {
min-height: var(--wysiwyg-editor-min-height);
background: var(--surface);
cursor: text;
}
.wysiwyg-prosemirror {
box-sizing: border-box;
min-height: var(--wysiwyg-editor-min-height);
padding: 12px 14px;
color: var(--text);
line-height: 1.5;
outline: none;
overflow-wrap: anywhere;
}
.wysiwyg-prosemirror > :first-child {
margin-top: 0;
}
.wysiwyg-prosemirror > :last-child {
margin-bottom: 0;
}
.wysiwyg-prosemirror p,
.wysiwyg-prosemirror blockquote,
.wysiwyg-prosemirror pre,
.wysiwyg-prosemirror ul,
.wysiwyg-prosemirror ol {
margin: 0 0 10px;
}
.wysiwyg-prosemirror h1,
.wysiwyg-prosemirror h2,
.wysiwyg-prosemirror h3 {
margin: 14px 0 8px;
letter-spacing: 0;
}
.wysiwyg-prosemirror h1 {
font-size: 24px;
}
.wysiwyg-prosemirror h2 {
font-size: 20px;
}
.wysiwyg-prosemirror h3 {
font-size: 17px;
}
.wysiwyg-prosemirror blockquote {
padding-left: 12px;
border-left: 3px solid var(--line-dark);
color: var(--text-subtle);
}
.wysiwyg-prosemirror pre {
overflow: auto;
padding: 10px 12px;
border-radius: 4px;
background: var(--code-bg);
color: var(--code-text);
}
.wysiwyg-prosemirror a {
color: var(--primary);
text-decoration: underline;
}
.wysiwyg-prosemirror img {
display: block;
max-width: 100%;
height: auto;
margin: 10px 0;
}
.wysiwyg-prosemirror img.ProseMirror-selectednode {
outline: 2px solid var(--primary-border);
outline-offset: 2px;
}
.wysiwyg-inline-token {
display: inline-flex;
max-width: 100%;
align-items: center;
margin: 0 2px;
padding: 1px 6px;
border: 1px solid var(--primary-border);
border-radius: 4px;
background: var(--primary-soft);
color: var(--info-text-deep);
font-family: var(--font-mono, monospace);
font-size: .88em;
line-height: 1.35;
vertical-align: baseline;
white-space: nowrap;
}
.wysiwyg-inline-token.ProseMirror-selectednode {
background: var(--primary-soft-strong);
box-shadow: var(--primary-focus-ring);
}
.wysiwyg-editor.is-disabled .wysiwyg-editor-content {
background: var(--control-disabled-bg);
cursor: default;
}
.wysiwyg-editor.is-disabled .wysiwyg-prosemirror {
color: var(--control-disabled-text);
}
.wysiwyg-editor-source {
display: block;
min-height: var(--wysiwyg-editor-min-height);
border: 0;
border-radius: 0;
box-shadow: none;
font-family: var(--font-mono, monospace);
font-size: 13px;
line-height: 1.5;
}
.wysiwyg-editor-source:focus {
outline: none;
box-shadow: none;
}
.wysiwyg-editor-source-note {
margin: 0;
padding: 8px 12px;
border-bottom: 1px solid var(--warning-border-soft);
background: var(--warning-muted-bg);
color: var(--warning-text);
font-size: 12px;
}
.dialog-panel.wysiwyg-editor-dialog {
width: min(520px, calc(100vw - 32px));
}
.wysiwyg-editor-dialog-error {
margin: 0;
color: var(--danger-text-strong);
font-size: 13px;
}
@media (max-width: 760px) {
.wysiwyg-editor-header {
display: grid;
}
.wysiwyg-editor-mode {
justify-self: end;
}
}
+8
View File
@@ -409,6 +409,14 @@ export type ViewsRuntimeUiCapability = {
settings: ApiSettings,
viewId: string | null
) => Promise<EffectiveViewProjection>;
resolveWorkflowView: (
settings: ApiSettings,
context: {
viewId: string;
revisionId?: string | null;
visibleSurfaceIds?: string[];
}
) => Promise<EffectiveViewProjection>;
Selector?: ComponentType<ViewSelectorProps>;
};
+8
View File
@@ -0,0 +1,8 @@
export { default as WysiwygEditor } from "./components/WysiwygEditor";
export type {
WysiwygEditorHandle,
WysiwygEditorLabels,
WysiwygEditorMode,
WysiwygEditorProps,
WysiwygEditorToken
} from "./components/WysiwygEditor";
+164
View File
@@ -0,0 +1,164 @@
import {
dataGridLayoutSignature,
dataGridWidthsForLayout,
distributeDataGridResizeAmount,
effectiveDataGridColumnMinWidth,
fitDataGridColumns,
type DataGridSizingColumn
} from "../src/components/table/dataGridSizing";
function assertEqual<T>(actual: T, expected: T, message: string): void {
if (actual !== expected) throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`);
}
function assertWidths(
actual: Record<string, number>,
expected: Record<string, number>,
message: string
): void {
for (const [columnId, width] of Object.entries(expected)) {
assertEqual(actual[columnId], width, `${message} (${columnId})`);
}
}
const minmaxColumn: DataGridSizingColumn = {
id: "recipients",
width: "minmax(320px, 1.4fr)",
resizable: true,
sortable: true,
filterable: true
};
assertEqual(
effectiveDataGridColumnMinWidth(minmaxColumn),
320,
"a minmax lower bound remains the resize minimum"
);
const weightedColumns: DataGridSizingColumn[] = [
{ id: "fixed", width: 100 },
{ id: "primary", width: "minmax(100px, 1fr)", minWidth: 100, maxWidth: 500 },
{ id: "secondary", width: "minmax(100px, 2fr)", minWidth: 100, maxWidth: 500 }
];
const weightedLayout = fitDataGridColumns(weightedColumns, 600);
assertWidths(
weightedLayout.widths,
{ fixed: 100, primary: 200, secondary: 300 },
"fraction tracks receive residual width by declared weight"
);
assertEqual(weightedLayout.bufferWidth, 0, "fraction tracks cover the available container");
const boundedLayout = fitDataGridColumns([
{ id: "fixed", width: 100 },
{ id: "primary", width: "minmax(100px, 1fr)", minWidth: 100, maxWidth: 150 },
{ id: "secondary", width: "minmax(100px, 2fr)", minWidth: 100, maxWidth: 200 }
], 600);
assertWidths(
boundedLayout.widths,
{ fixed: 100, primary: 150, secondary: 200 },
"fraction tracks stop at their declared maxima"
);
assertEqual(boundedLayout.bufferWidth, 150, "space beyond column maxima becomes the synthetic buffer");
const overflowLayout = fitDataGridColumns([
{ id: "fixed", width: 200 },
{ id: "primary", width: "minmax(320px, 1fr)", resizable: true },
{ id: "secondary", width: "minmax(260px, 1fr)", resizable: true }
], 600);
assertWidths(
overflowLayout.widths,
{ fixed: 200, primary: 320, secondary: 260 },
"columns never shrink below declared minima"
);
assertEqual(overflowLayout.overflowWidth, 180, "minimum-width excess remains horizontal overflow");
const percentageLayout = fitDataGridColumns([
{ id: "fixed", width: 100 },
{ id: "primary", width: "30%", minWidth: 100 },
{ id: "secondary", width: "20%", minWidth: 100 }
], 1000);
assertWidths(
percentageLayout.widths,
{ fixed: 100, primary: 300, secondary: 200 },
"percentage tracks retain their preferred container share"
);
assertEqual(percentageLayout.bufferWidth, 400, "unused percentage space remains a neutral buffer");
const overriddenLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 450 });
assertWidths(
overriddenLayout.widths,
{ fixed: 100, primary: 450, secondary: 250 },
"a user-resized track stays fixed while responsive peers absorb container changes"
);
const overriddenOverflowLayout = fitDataGridColumns(weightedColumns, 500, {}, { primary: 450 });
assertWidths(
overriddenOverflowLayout.widths,
{ fixed: 100, primary: 450, secondary: 100 },
"a narrow viewport preserves the user override without squashing peers below their minima"
);
assertEqual(
overriddenOverflowLayout.overflowWidth,
150,
"a user override that cannot fit becomes horizontal overflow"
);
const clampedOverrideLayout = fitDataGridColumns(weightedColumns, 800, {}, { primary: 900 });
assertEqual(
clampedOverrideLayout.widths.primary,
500,
"user overrides remain bounded by the column maximum"
);
const redistributedGrowth = distributeDataGridResizeAmount(300, [
{ columnId: "bounded", startWidth: 100, minWidth: 100, maxWidth: 150, weight: 1 },
{ columnId: "remaining", startWidth: 100, minWidth: 100, maxWidth: 500, weight: 1 }
]);
assertWidths(
redistributedGrowth.amounts,
{ bounded: 50, remaining: 250 },
"unused growth is redistributed after a peer reaches its maximum"
);
assertEqual(redistributedGrowth.applied, 300, "bounded weighted growth applies the complete available amount");
const boundedShrink = distributeDataGridResizeAmount(-300, [
{ columnId: "primary", startWidth: 300, minWidth: 200, maxWidth: 500, weight: 1 },
{ columnId: "secondary", startWidth: 300, minWidth: 250, maxWidth: 500, weight: 1 }
]);
assertWidths(
boundedShrink.amounts,
{ primary: -100, secondary: -50 },
"shrink distribution stops every peer at its minimum"
);
assertEqual(boundedShrink.applied, -150, "unavailable shrink remains unapplied instead of squashing columns");
const legacyFillLayout = fitDataGridColumns([
{ id: "fixed", width: 100 },
{ id: "fill", width: 200, minWidth: 120, maxWidth: 450, fill: true }
], 500);
assertWidths(
legacyFillLayout.widths,
{ fixed: 100, fill: 400 },
"legacy fill columns remain stable under the explicit sizing contract"
);
const originalSignature = dataGridLayoutSignature(weightedColumns, "container", "cover");
const changedSignature = dataGridLayoutSignature(
weightedColumns.map((column) => column.id === "primary" ? { ...column, maxWidth: 420 } : column),
"container",
"cover"
);
assertEqual(
dataGridWidthsForLayout(originalSignature, originalSignature, { primary: 450 })?.primary,
450,
"matching layout signatures restore user widths"
);
assertEqual(
dataGridWidthsForLayout(originalSignature, changedSignature, { primary: 450 }),
undefined,
"stale layout signatures discard user widths"
);
assertEqual(
dataGridLayoutSignature(weightedColumns, "container", "free") === originalSignature,
false,
"free and cover layouts keep independent user overrides"
);
+22
View File
@@ -0,0 +1,22 @@
import { normalizeWysiwygImageUrl, normalizeWysiwygLinkUrl } from "../src/components/wysiwygEditorUrls";
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}: expected ${String(expected)}, received ${String(actual)}`);
}
}
assertEqual(normalizeWysiwygLinkUrl("example.org/path"), "https://example.org/path", "domain links gain HTTPS");
assertEqual(normalizeWysiwygLinkUrl("person@example.org"), "mailto:person@example.org", "email addresses gain mailto");
assertEqual(normalizeWysiwygLinkUrl("#details"), "#details", "fragments remain unchanged");
assertEqual(normalizeWysiwygLinkUrl("/documents/12"), "/documents/12", "relative links remain unchanged");
assertEqual(normalizeWysiwygLinkUrl("javascript:alert(1)"), null, "script links are rejected");
assertEqual(normalizeWysiwygLinkUrl("data:text/html;base64,SGVsbG8="), null, "active data links are rejected");
assertEqual(normalizeWysiwygLinkUrl("https://example.org/a b"), null, "links with raw spaces are rejected");
assertEqual(normalizeWysiwygImageUrl("images.example.org/banner.png"), "https://images.example.org/banner.png", "image domains gain HTTPS");
assertEqual(normalizeWysiwygImageUrl("cid:campaign-banner"), "cid:campaign-banner", "CID images remain unchanged");
assertEqual(normalizeWysiwygImageUrl("data:image/png;base64,iVBORw0KGgo="), "data:image/png;base64,iVBORw0KGgo=", "raster data images are accepted");
assertEqual(normalizeWysiwygImageUrl("data:image/svg+xml;base64,PHN2Zz4="), null, "active SVG data images are rejected");
assertEqual(normalizeWysiwygImageUrl("javascript:alert(1)"), null, "script image sources are rejected");
+4
View File
@@ -19,6 +19,7 @@
},
"include": [
"tests/data-grid-actions.test.tsx",
"tests/data-grid-sizing.test.ts",
"tests/dialog-focus.test.tsx",
"tests/explorer-tree.test.tsx",
"tests/icon-button.test.tsx",
@@ -27,6 +28,7 @@
"tests/people-picker.test.tsx",
"tests/resource-access-explanation.test.tsx",
"tests/selection-list.test.tsx",
"tests/wysiwyg-editor-utils.test.ts",
"src/components/CredentialPanel.tsx",
"src/components/email/EmailAddressInput.tsx",
"src/components/PasswordField.tsx",
@@ -49,7 +51,9 @@
"src/components/IconButton.tsx",
"src/components/admin/AdminIconButton.tsx",
"src/components/ToggleSwitch.tsx",
"src/components/wysiwygEditorUrls.ts",
"src/components/table/DataGrid.tsx",
"src/components/table/dataGridSizing.ts",
"src/components/table/TableActionGroup.tsx"
]
}
+3
View File
@@ -28,6 +28,9 @@
],
"@govoplan/core-webui/app": [
"./src/app.ts"
],
"@govoplan/core-webui/wysiwyg": [
"./src/wysiwyg.ts"
]
}
},
+81 -1
View File
@@ -63,6 +63,69 @@ function availableWebModuleSpecifiers(): string[] {
return configuredWebModulePackages().filter(packageInstalled);
}
function deferredVendorChunk(id: string): string | undefined {
const normalized = id.replaceAll("\\", "/");
if (!normalized.includes("/node_modules/")) return undefined;
if (
normalized.includes("/@tiptap/core/")
|| normalized.includes("/@tiptap/react/")
) {
return "rich-text-core";
}
if (normalized.includes("/@tiptap/")) {
return "rich-text-extensions";
}
if (
normalized.includes("/prosemirror-")
|| normalized.includes("/linkifyjs/")
|| normalized.includes("/orderedmap/")
|| normalized.includes("/rope-sequence/")
) {
return "rich-text-engine";
}
if (normalized.includes("/bpmn-js-properties-panel/")) {
return "bpmn-properties-provider";
}
if (normalized.includes("/@bpmn-io/properties-panel/")) {
return "bpmn-properties-ui";
}
if (normalized.includes("/@codemirror/view/")) {
return "bpmn-expression-view";
}
if (normalized.includes("/@codemirror/state/")) {
return "bpmn-expression-state";
}
if (normalized.includes("/@codemirror/language/")) {
return "bpmn-expression-language";
}
if (normalized.includes("/@codemirror/")) {
return "bpmn-expression-tools";
}
if (normalized.includes("/@lezer/")) {
return "bpmn-expression-parser";
}
if (
normalized.includes("/@bpmn-io/feel-editor/")
|| normalized.includes("/@bpmn-io/feelers-editor/")
) {
return "bpmn-expression-editors";
}
if (normalized.includes("/focus-trap/")) {
return "bpmn-properties-focus";
}
if (normalized.includes("/bpmn-js/")) return "bpmn-modeler";
if (normalized.includes("/diagram-js/")) return "bpmn-diagram-engine";
if (
normalized.includes("/bpmn-moddle/")
|| normalized.includes("/moddle/")
|| normalized.includes("/moddle-xml/")
|| normalized.includes("/saxen/")
) {
return "bpmn-xml";
}
return undefined;
}
function govoplanInstalledModulesPlugin(): Plugin {
const specifierByModuleVirtualId = new Map(
availableWebModuleSpecifiers().map((specifier) => [
@@ -113,21 +176,38 @@ export default defineConfig({
},
build: {
manifest: true,
rollupOptions: {
output: {
manualChunks: deferredVendorChunk,
// Keep dependencies of deferred BPMN packages in their lazy graph. The
// legacy Rollup behavior merged those dependencies into manual chunks
// and hoisted the properties-panel runtime into the application entry.
onlyExplicitManualChunks: true
}
},
chunkSizeWarningLimit: 500
},
resolve: {
preserveSymlinks: false,
dedupe: [
"@tiptap/core",
"@tiptap/pm",
"@tiptap/react",
"@xyflow/react",
"@bpmn-io/properties-panel",
"bpmn-js",
"bpmn-js-properties-panel",
"bpmn-moddle",
"lucide-react",
"react",
"react-dom",
"react-router-dom",
"react-router",
"read-excel-file"
],
alias: [
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/definition-graph", replacement: fileURLToPath(new URL("./src/definitionGraph.ts", import.meta.url)) },
{ find: "@govoplan/core-webui/wysiwyg", replacement: fileURLToPath(new URL("./src/wysiwyg.ts", import.meta.url)) },
{ find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) }
]
},