Add shared automation and WebUI editing primitives
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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_*`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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)})
|
||||
|
||||
@@ -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()
|
||||
Generated
+908
-293
File diff suppressed because it is too large
Load Diff
+27
-17
@@ -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
@@ -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
@@ -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,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";
|
||||
@@ -57,4 +57,4 @@ export function ResourceAccessBoundary({ probe, resetKey, fallback, children }:
|
||||
if (state === "error") return <div className="content-pad"><DismissibleAlert tone="danger" dismissible={false}>{message || "i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf"}</DismissibleAlert></div>;
|
||||
if (state === "checking") return <div className="content-pad"><p className="muted">i18n:govoplan-core.checking_access.c5108c02</p></div>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,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,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,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,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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as WysiwygEditor } from "./components/WysiwygEditor";
|
||||
export type {
|
||||
WysiwygEditorHandle,
|
||||
WysiwygEditorLabels,
|
||||
WysiwygEditorMode,
|
||||
WysiwygEditorProps,
|
||||
WysiwygEditorToken
|
||||
} from "./components/WysiwygEditor";
|
||||
@@ -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"
|
||||
);
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
],
|
||||
"@govoplan/core-webui/app": [
|
||||
"./src/app.ts"
|
||||
],
|
||||
"@govoplan/core-webui/wysiwyg": [
|
||||
"./src/wysiwyg.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
+81
-1
@@ -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)) }
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user