Add shared automation and WebUI editing primitives
This commit is contained in:
@@ -29,6 +29,10 @@ of module capabilities.
|
|||||||
## Action Definition
|
## Action Definition
|
||||||
|
|
||||||
An `ActionDefinition` describes something a human or system actor can request.
|
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:
|
Recommended fields:
|
||||||
|
|
||||||
@@ -110,6 +114,11 @@ Automation should use explicit failure states:
|
|||||||
|
|
||||||
These states should be visible in workflow, task, and admin diagnostics.
|
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
|
## Boundary
|
||||||
|
|
||||||
Core may own stable DTOs, registry contracts, and generic audit/event hooks.
|
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_ACCESS_KEY_ID` | empty | Secret; inject through deployment environment. |
|
||||||
| `FILE_STORAGE_S3_SECRET_ACCESS_KEY` | 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_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
|
Legacy `S3_*` settings remain for older storage paths but new deployments should
|
||||||
prefer `FILE_STORAGE_*`.
|
prefer `FILE_STORAGE_*`.
|
||||||
|
|||||||
@@ -245,6 +245,25 @@ instead of reproducing their behavior.
|
|||||||
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
- Feedback and confirmation use `Dialog`, `ConfirmDialog`, or
|
||||||
`DismissibleAlert`. They never fall back to `window.alert`.
|
`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
|
#### FieldLabel Omission Register
|
||||||
|
|
||||||
Every Core field surface that intentionally does not render `FieldLabel` is
|
Every Core field surface that intentionally does not render `FieldLabel` is
|
||||||
|
|||||||
@@ -21,6 +21,172 @@ AutomationInvocationKind = Literal[
|
|||||||
]
|
]
|
||||||
AutomationSubjectKind = Literal["delegated_user", "service_account"]
|
AutomationSubjectKind = Literal["delegated_user", "service_account"]
|
||||||
AUTOMATION_PRINCIPAL_CONTRACT_VERSION = "1"
|
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)
|
@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__ = [
|
__all__ = [
|
||||||
|
"ACTION_EFFECT_CONTRACT_VERSION",
|
||||||
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
|
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
|
||||||
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
|
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
|
||||||
|
"ActionDefinition",
|
||||||
|
"ActionEffectProvider",
|
||||||
|
"ActionExecutionRequest",
|
||||||
|
"ActionExecutionResult",
|
||||||
|
"ActionExecutionState",
|
||||||
"AutomationInvocation",
|
"AutomationInvocation",
|
||||||
"AutomationInvocationKind",
|
"AutomationInvocationKind",
|
||||||
"AutomationPrincipalProvider",
|
"AutomationPrincipalProvider",
|
||||||
"AutomationPrincipalRequest",
|
"AutomationPrincipalRequest",
|
||||||
"AutomationPrincipalResolution",
|
"AutomationPrincipalResolution",
|
||||||
"AutomationSubjectKind",
|
"AutomationSubjectKind",
|
||||||
|
"ActionPreview",
|
||||||
|
"ActionReversibility",
|
||||||
|
"ActionRiskLevel",
|
||||||
|
"EffectDefinition",
|
||||||
|
"EffectOperation",
|
||||||
|
"EffectPreview",
|
||||||
|
"ObservedEffect",
|
||||||
|
"action_effect_provider",
|
||||||
"automation_principal_provider",
|
"automation_principal_provider",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -301,6 +301,18 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
|||||||
maintenance_required=True,
|
maintenance_required=True,
|
||||||
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
|
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(
|
ConfigurationFieldSafety(
|
||||||
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
|
||||||
label="Structured connector response limit",
|
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:
|
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
|
||||||
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
|
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 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)
|
_validate_local_file_storage(env, runtime, collector)
|
||||||
elif storage_backend == "s3":
|
elif storage_backend == "s3":
|
||||||
_validate_s3_file_storage(env, collector)
|
_validate_s3_file_storage(
|
||||||
|
env,
|
||||||
|
collector,
|
||||||
|
deployment_managed=deployment_managed,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
|
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.")
|
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"):
|
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)):
|
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.")
|
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(
|
def _validate_outbound_connector_policy(
|
||||||
@@ -373,6 +408,11 @@ AUTH_COOKIE_DOMAIN=
|
|||||||
FILE_STORAGE_BACKEND=local
|
FILE_STORAGE_BACKEND=local
|
||||||
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
|
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
|
||||||
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
|
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_AUTO_MIGRATE_ENABLED=false
|
||||||
DEV_BOOTSTRAP_ENABLED=false
|
DEV_BOOTSTRAP_ENABLED=false
|
||||||
@@ -433,6 +473,11 @@ FORWARDED_ALLOW_IPS=127.0.0.1
|
|||||||
AUTH_COOKIE_SECURE=false
|
AUTH_COOKIE_SECURE=false
|
||||||
FILE_STORAGE_BACKEND=local
|
FILE_STORAGE_BACKEND=local
|
||||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
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_AUTO_MIGRATE_ENABLED=false
|
||||||
DEV_BOOTSTRAP_ENABLED=true
|
DEV_BOOTSTRAP_ENABLED=true
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -56,9 +56,22 @@ class ViewResolver(Protocol):
|
|||||||
account_id: str,
|
account_id: str,
|
||||||
group_ids: Iterable[str] = (),
|
group_ids: Iterable[str] = (),
|
||||||
workflow_view_id: str | None = None,
|
workflow_view_id: str | None = None,
|
||||||
|
workflow_revision_id: str | None = None,
|
||||||
|
workflow_surface_ids: Iterable[str] = (),
|
||||||
) -> EffectiveView: ...
|
) -> 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:
|
def module_view_surface_id(module_id: str) -> str:
|
||||||
return f"{module_id}.module"
|
return f"{module_id}.module"
|
||||||
|
|
||||||
@@ -92,4 +105,5 @@ __all__ = [
|
|||||||
"navigation_view_surface_id",
|
"navigation_view_surface_id",
|
||||||
"route_view_surface_id",
|
"route_view_surface_id",
|
||||||
"validate_view_surface_id",
|
"validate_view_surface_id",
|
||||||
|
"views_resolver",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
from functools import lru_cache
|
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
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
@@ -18,6 +19,10 @@ class SecretDecryptionError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TransientPayloadError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
|
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")
|
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
|
||||||
except InvalidToken as exc:
|
except InvalidToken as exc:
|
||||||
raise SecretDecryptionError("Stored secret cannot be decrypted with the configured master key") from 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_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_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_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_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_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_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")
|
auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")
|
||||||
|
|||||||
@@ -3,11 +3,20 @@ from __future__ import annotations
|
|||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from govoplan_core.core.automation import (
|
from govoplan_core.core.automation import (
|
||||||
|
ActionDefinition,
|
||||||
|
ActionEffectProvider,
|
||||||
|
ActionExecutionRequest,
|
||||||
|
ActionExecutionResult,
|
||||||
|
ActionPreview,
|
||||||
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
|
||||||
AutomationInvocation,
|
AutomationInvocation,
|
||||||
AutomationPrincipalProvider,
|
AutomationPrincipalProvider,
|
||||||
AutomationPrincipalRequest,
|
AutomationPrincipalRequest,
|
||||||
AutomationPrincipalResolution,
|
AutomationPrincipalResolution,
|
||||||
|
EffectDefinition,
|
||||||
|
EffectPreview,
|
||||||
|
ObservedEffect,
|
||||||
|
action_effect_provider,
|
||||||
automation_principal_provider,
|
automation_principal_provider,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
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):
|
class AutomationContractTests(unittest.TestCase):
|
||||||
def test_principal_request_has_explicit_subject_contracts(self) -> None:
|
def test_principal_request_has_explicit_subject_contracts(self) -> None:
|
||||||
service = AutomationPrincipalRequest.service_account(
|
service = AutomationPrincipalRequest.service_account(
|
||||||
@@ -91,6 +160,81 @@ class AutomationContractTests(unittest.TestCase):
|
|||||||
self.assertTrue(result.allowed)
|
self.assertTrue(result.allowed)
|
||||||
self.assertEqual(("dataflow:pipeline:run",), result.granted_scopes)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -10,6 +10,25 @@ from govoplan_core.settings import Settings
|
|||||||
|
|
||||||
|
|
||||||
class InstallConfigTests(unittest.TestCase):
|
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:
|
def test_calendar_outbox_retention_setting_is_validated(self) -> None:
|
||||||
self.assertEqual(Settings().calendar_outbox_terminal_retention_days, 90)
|
self.assertEqual(Settings().calendar_outbox_terminal_retention_days, 90)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -128,6 +147,45 @@ class InstallConfigTests(unittest.TestCase):
|
|||||||
self.assertIn("GOVOPLAN_TRUSTED_HOSTS", error_keys)
|
self.assertIn("GOVOPLAN_TRUSTED_HOSTS", error_keys)
|
||||||
self.assertIn("FORWARDED_ALLOW_IPS", 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:
|
def test_connector_deployment_allowlists_require_exact_names_and_absolute_paths(self) -> None:
|
||||||
result = validate_runtime_configuration(
|
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_TRUSTED_HOSTS=govoplan.example.org", self_hosted)
|
||||||
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
|
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=31536000", self_hosted)
|
||||||
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", 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.assertIn("GOVOPLAN_INSTALL_PROFILE=production-like", production_like)
|
||||||
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
self.assertNotIn("MASTER_KEY_B64=<generate", production_like)
|
||||||
self.assertIn("CALENDAR_OUTBOX_TERMINAL_RETENTION_DAYS=90", 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_TRUSTED_HOSTS=127.0.0.1,localhost,testserver", production_like)
|
||||||
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=0", production_like)
|
self.assertIn("GOVOPLAN_HTTP_HSTS_SECONDS=0", production_like)
|
||||||
self.assertIn("AUTH_LOGIN_THROTTLE_IDENTITY_LIMIT=10", 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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ class PolicyContractTests(unittest.TestCase):
|
|||||||
self.assertEqual(database_url.secret_handling, "env_only")
|
self.assertEqual(database_url.secret_handling, "env_only")
|
||||||
self.assertEqual(database_url.storage, "environment")
|
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.assertIs(classify_configuration_field("MASTER_KEY_B64"), catalog["MASTER_KEY_B64"])
|
||||||
self.assertIsNone(classify_configuration_field("missing.setting"))
|
self.assertIsNone(classify_configuration_field("missing.setting"))
|
||||||
self.assertNotIn("DATABASE_URL", {item.key for item in configuration_safety_catalog(include_env_only=False)})
|
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": {
|
"./definition-graph": {
|
||||||
"types": "./src/definitionGraph.ts",
|
"types": "./src/definitionGraph.ts",
|
||||||
"import": "./src/definitionGraph.ts"
|
"import": "./src/definitionGraph.ts"
|
||||||
|
},
|
||||||
|
"./wysiwyg": {
|
||||||
|
"types": "./src/wysiwyg.ts",
|
||||||
|
"import": "./src/wysiwyg.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -29,7 +33,7 @@
|
|||||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||||
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.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: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: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: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",
|
"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: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: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: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": {
|
"dependencies": {
|
||||||
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
"@govoplan/access-webui": "file:../../govoplan-access/webui",
|
||||||
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
|
||||||
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
"@govoplan/addresses-webui": "file:../../govoplan-addresses/webui",
|
||||||
|
"@govoplan/admin-webui": "file:../../govoplan-admin/webui",
|
||||||
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
"@govoplan/audit-webui": "file:../../govoplan-audit/webui",
|
||||||
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
"@govoplan/calendar-webui": "file:../../govoplan-calendar/webui",
|
||||||
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
"@govoplan/campaign-webui": "file:../../govoplan-campaign/webui",
|
||||||
|
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
||||||
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
|
"@govoplan/dataflow-webui": "file:../../govoplan-dataflow/webui",
|
||||||
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
|
"@govoplan/datasources-webui": "file:../../govoplan-datasources/webui",
|
||||||
"@govoplan/dashboard-webui": "file:../../govoplan-dashboard/webui",
|
|
||||||
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
"@govoplan/docs-webui": "file:../../govoplan-docs/webui",
|
||||||
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
"@govoplan/files-webui": "file:../../govoplan-files/webui",
|
||||||
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
"@govoplan/idm-webui": "file:../../govoplan-idm/webui",
|
||||||
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
"@govoplan/mail-webui": "file:../../govoplan-mail/webui",
|
||||||
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
"@govoplan/notifications-webui": "file:../../govoplan-notifications/webui",
|
||||||
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
|
||||||
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
"@govoplan/ops-webui": "file:../../govoplan-ops/webui",
|
||||||
|
"@govoplan/organizations-webui": "file:../../govoplan-organizations/webui",
|
||||||
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
"@govoplan/policy-webui": "file:../../govoplan-policy/webui",
|
||||||
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
"@govoplan/postbox-webui": "file:../../govoplan-postbox/webui",
|
||||||
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
"@govoplan/risk-compliance-webui": "file:../../govoplan-risk-compliance/webui",
|
||||||
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
"@govoplan/scheduling-webui": "file:../../govoplan-scheduling/webui",
|
||||||
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
"@govoplan/search-webui": "file:../../govoplan-search/webui",
|
||||||
"@govoplan/views-webui": "file:../../govoplan-views/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": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "7.18.2",
|
"react-router": "8.3.0",
|
||||||
"read-excel-file": "^9.2.0",
|
"read-excel-file": "^9.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": ">=7.18.2 <8"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"esbuild@0.25.12": true
|
"esbuild@0.28.1": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-12
@@ -14,6 +14,10 @@
|
|||||||
"./app": {
|
"./app": {
|
||||||
"types": "./src/app.ts",
|
"types": "./src/app.ts",
|
||||||
"import": "./src/app.ts"
|
"import": "./src/app.ts"
|
||||||
|
},
|
||||||
|
"./wysiwyg": {
|
||||||
|
"types": "./src/wysiwyg.ts",
|
||||||
|
"import": "./src/wysiwyg.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -34,26 +38,31 @@
|
|||||||
"@govoplan/campaign-webui": "git+ssh://git@git.add-ideas.de/GovOPlaN/govoplan-campaign.git#v0.1.11",
|
"@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/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/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": {
|
"devDependencies": {
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router": "8.3.0",
|
||||||
"@types/react": "^19.0.2",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.0.2",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.0.6"
|
"vite": "^7.3.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": "^19.0.0",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": ">=19.2.7 <20",
|
||||||
"react-router-dom": "^7.1.1"
|
"react-router": ">=8.3.0 <9"
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"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 { lazy, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
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 { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
|
||||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||||
import { PlatformViewProvider } from "./platform/ViewContext";
|
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 { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
||||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||||
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
||||||
@@ -45,7 +49,9 @@ export default function App() {
|
|||||||
const [backendReachable, setBackendReachable] = useState(true);
|
const [backendReachable, setBackendReachable] = useState(true);
|
||||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||||
const [reloginMessage, setReloginMessage] = useState("");
|
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 webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||||
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||||
@@ -65,7 +71,8 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth || !viewsRuntime) {
|
if (!auth || !viewsRuntime) {
|
||||||
setViewProjection(null);
|
setBaseViewProjection(null);
|
||||||
|
setWorkflowViewProjection(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentAuth = auth;
|
const currentAuth = auth;
|
||||||
@@ -74,17 +81,22 @@ export default function App() {
|
|||||||
async function loadEffectiveView() {
|
async function loadEffectiveView() {
|
||||||
try {
|
try {
|
||||||
const projection = await currentRuntime.loadEffectiveView(settings, currentAuth);
|
const projection = await currentRuntime.loadEffectiveView(settings, currentAuth);
|
||||||
if (!cancelled) setViewProjection(projection);
|
if (!cancelled) setBaseViewProjection(projection);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) setViewProjection(null);
|
if (!cancelled) setBaseViewProjection(null);
|
||||||
console.error("Failed to load the effective View", error);
|
console.error("Failed to load the effective View", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function reloadNormalView() {
|
||||||
|
setWorkflowViewProjection(null);
|
||||||
|
clearStoredWorkflowView(currentAuth);
|
||||||
|
void loadEffectiveView();
|
||||||
|
}
|
||||||
void loadEffectiveView();
|
void loadEffectiveView();
|
||||||
window.addEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
|
window.addEventListener(PLATFORM_VIEW_CHANGED_EVENT, reloadNormalView);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.removeEventListener(PLATFORM_VIEW_CHANGED_EVENT, loadEffectiveView);
|
window.removeEventListener(PLATFORM_VIEW_CHANGED_EVENT, reloadNormalView);
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
auth?.user?.id,
|
auth?.user?.id,
|
||||||
@@ -96,6 +108,36 @@ export default function App() {
|
|||||||
viewsRuntime
|
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) {
|
function updateSettings(next: ApiSettings) {
|
||||||
setSettings(next);
|
setSettings(next);
|
||||||
saveApiSettings(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 { useEffect, useState, type ReactNode } from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router";
|
||||||
import type { AuthInfo } from "../types";
|
import type { AuthInfo } from "../types";
|
||||||
import { isApiError } from "../api/client";
|
import { isApiError } from "../api/client";
|
||||||
import DismissibleAlert from "./DismissibleAlert";
|
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 === "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>;
|
if (state === "checking") return <div className="content-pad"><p className="muted">i18n:govoplan-core.checking_access.c5108c02</p></div>;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type ReactNode
|
type ReactNode
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router";
|
||||||
import { adminErrorMessage } from "./admin/adminUtils";
|
import { adminErrorMessage } from "./admin/adminUtils";
|
||||||
|
|
||||||
export type DashboardWidgetDataState<T> = {
|
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 { 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 Button from "./Button";
|
||||||
import Dialog from "./Dialog";
|
import Dialog from "./Dialog";
|
||||||
import DismissibleAlert from "./DismissibleAlert";
|
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 StatusBadge from "../StatusBadge";
|
||||||
import TableActionGroup from "./TableActionGroup";
|
import TableActionGroup from "./TableActionGroup";
|
||||||
import { usePlatformLanguage, i18nMessage } from "../../i18n/LanguageContext";
|
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 DataGridSortDirection = "asc" | "desc";
|
||||||
export type DataGridFilterType = "text" | "number" | "integer" | "boolean" | "date" | "list";
|
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> = {
|
export type DataGridColumn<T> = {
|
||||||
id: string;
|
id: string;
|
||||||
header: ReactNode;
|
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;
|
width?: number | string;
|
||||||
|
/** Hard lower bound. Header controls may require a larger accessible minimum. */
|
||||||
minWidth?: number;
|
minWidth?: number;
|
||||||
|
/** Hard upper bound. Space left after all maxima is assigned to the neutral buffer track. */
|
||||||
maxWidth?: number;
|
maxWidth?: number;
|
||||||
resizable?: boolean;
|
resizable?: boolean;
|
||||||
/** @deprecated Kept for source compatibility. Resize space is now distributed across resizable columns. */
|
/** @deprecated Kept for source compatibility. Resize space is now distributed across resizable columns. */
|
||||||
@@ -89,7 +112,11 @@ export type DataGridColumn<T> = {
|
|||||||
type DataGridState = {
|
type DataGridState = {
|
||||||
sort?: {columnId: string;direction: DataGridSortDirection;} | null;
|
sort?: {columnId: string;direction: DataGridSortDirection;} | null;
|
||||||
filters?: Record<string, string>;
|
filters?: Record<string, string>;
|
||||||
|
/** Current in-memory pixel layout. This is deliberately not persisted. */
|
||||||
widths?: Record<string, number>;
|
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. */
|
/** Legacy layout state; removed when persisted state is sanitized. */
|
||||||
fillColumnId?: string | null;
|
fillColumnId?: string | null;
|
||||||
/** Width of the synthetic, handle-less coverage track. */
|
/** Width of the synthetic, handle-less coverage track. */
|
||||||
@@ -160,13 +187,6 @@ type FilterPosition = {
|
|||||||
width: number;
|
width: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResizeTarget = {
|
|
||||||
columnId: string;
|
|
||||||
startWidth: number;
|
|
||||||
minWidth: number;
|
|
||||||
maxWidth: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ColumnResizeState = {
|
type ColumnResizeState = {
|
||||||
columnId: string;
|
columnId: string;
|
||||||
startX: number;
|
startX: number;
|
||||||
@@ -191,12 +211,7 @@ type ColumnResizeState = {
|
|||||||
const STORAGE_PREFIX = "govoplan.datagrid.";
|
const STORAGE_PREFIX = "govoplan.datagrid.";
|
||||||
const FILTER_POPOVER_WIDTH = 320;
|
const FILTER_POPOVER_WIDTH = 320;
|
||||||
const FILTER_POPOVER_MARGIN = 12;
|
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_COLUMN_ID = "__data_grid_resize_buffer__";
|
||||||
const BUFFER_MAX_WIDTH = 1_000_000;
|
|
||||||
|
|
||||||
export default function DataGrid<T>({
|
export default function DataGrid<T>({
|
||||||
id,
|
id,
|
||||||
@@ -225,15 +240,7 @@ export default function DataGrid<T>({
|
|||||||
const effectiveResizeBehavior: DataGridResizeBehavior = resizeBehavior === "free" && hasRightStickyColumn ?
|
const effectiveResizeBehavior: DataGridResizeBehavior = resizeBehavior === "free" && hasRightStickyColumn ?
|
||||||
"cover" :
|
"cover" :
|
||||||
resizeBehavior;
|
resizeBehavior;
|
||||||
const columnSignature = columns.map((column) => [
|
const layoutSignature = dataGridLayoutSignature(columns, resolvedInitialFit, effectiveResizeBehavior);
|
||||||
column.id,
|
|
||||||
column.width ?? "",
|
|
||||||
column.minWidth ?? "",
|
|
||||||
column.maxWidth ?? "",
|
|
||||||
column.resizable ? "r" : "f",
|
|
||||||
column.sticky ?? ""].
|
|
||||||
join(":")).join("|");
|
|
||||||
const layoutSignature = `${columnSignature}::${resolvedInitialFit}::${effectiveResizeBehavior}`;
|
|
||||||
const bufferInsertIndex = findBufferInsertIndex(columns);
|
const bufferInsertIndex = findBufferInsertIndex(columns);
|
||||||
const containerResizeColumns = columns.filter(isContainerResizeColumn);
|
const containerResizeColumns = columns.filter(isContainerResizeColumn);
|
||||||
|
|
||||||
@@ -248,7 +255,7 @@ export default function DataGrid<T>({
|
|||||||
[externalQueryKey]
|
[externalQueryKey]
|
||||||
);
|
);
|
||||||
const [state, setState] = useState<DataGridState>(() => mergeExternalQuery(
|
const [state, setState] = useState<DataGridState>(() => mergeExternalQuery(
|
||||||
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey), normalizedInitialFilters), normalizedInitialSort),
|
mergeInitialSort(mergeInitialFilters(loadState(localStorageKey, layoutSignature), normalizedInitialFilters), normalizedInitialSort),
|
||||||
normalizedExternalQuery
|
normalizedExternalQuery
|
||||||
));
|
));
|
||||||
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
|
const [resizeState, setResizeState] = useState<ColumnResizeState | null>(null);
|
||||||
@@ -262,8 +269,6 @@ export default function DataGrid<T>({
|
|||||||
const onQueryChangeRef = useRef(onQueryChange);
|
const onQueryChangeRef = useRef(onQueryChange);
|
||||||
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
|
const serverPaginationRef = useRef<DataGridServerPagination | null>(serverQueryMode ? pagination : null);
|
||||||
const lastQueryRef = useRef<DataGridQueryState | null>(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>>({});
|
const [measuredWidths, setMeasuredWidths] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
useEffect(() => {onQueryChangeRef.current = onQueryChange;}, [onQueryChange]);
|
||||||
@@ -273,8 +278,13 @@ export default function DataGrid<T>({
|
|||||||
}, [pagination, serverQueryMode]);
|
}, [pagination, serverQueryMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
|
setState((current) => sanitizePersistedColumnState(
|
||||||
}, [columns, effectiveResizeBehavior]);
|
columns,
|
||||||
|
current,
|
||||||
|
effectiveResizeBehavior,
|
||||||
|
layoutSignature
|
||||||
|
));
|
||||||
|
}, [columns, effectiveResizeBehavior, layoutSignature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState((current) => mergeInitialFilters(current, normalizedInitialFilters));
|
setState((current) => mergeInitialFilters(current, normalizedInitialFilters));
|
||||||
@@ -290,13 +300,13 @@ export default function DataGrid<T>({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(localStorageKey, JSON.stringify(state));
|
window.localStorage.setItem(localStorageKey, JSON.stringify(persistedState(state, layoutSignature)));
|
||||||
} catch {
|
} catch {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Local storage is an enhancement only.
|
// Local storage is an enhancement only.
|
||||||
}}, [localStorageKey, state]);useEffect(() => {
|
}
|
||||||
|
}, [localStorageKey, state, layoutSignature]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const filtersFromUrl: Record<string, string> = {};
|
const filtersFromUrl: Record<string, string> = {};
|
||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
@@ -343,7 +353,7 @@ export default function DataGrid<T>({
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const element = scrollRegionRef.current;
|
const element = scrollRegionRef.current;
|
||||||
if (!element) return undefined;
|
if (!element || resizeState) return undefined;
|
||||||
const scrollElement = element;
|
const scrollElement = element;
|
||||||
let animationFrame = 0;
|
let animationFrame = 0;
|
||||||
|
|
||||||
@@ -352,43 +362,39 @@ export default function DataGrid<T>({
|
|||||||
animationFrame = window.requestAnimationFrame(() => {
|
animationFrame = window.requestAnimationFrame(() => {
|
||||||
const nextContainerWidth = Math.round(scrollElement.clientWidth);
|
const nextContainerWidth = Math.round(scrollElement.clientWidth);
|
||||||
if (nextContainerWidth <= 0) return;
|
if (nextContainerWidth <= 0) return;
|
||||||
const previousContainerWidth = lastContainerWidthRef.current;
|
|
||||||
const layoutChanged = lastLayoutSignatureRef.current !== layoutSignature;
|
|
||||||
|
|
||||||
setState((current) => {
|
setState((current) => {
|
||||||
const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, current.widths, measuredWidths);
|
const signatureMatches = current.layoutSignature === layoutSignature;
|
||||||
const renderedWidth = totalColumnWidth(columns, baseWidths) + Math.max(0, current.bufferWidth ?? 0);
|
const userWidths = signatureMatches ? current.userWidths ?? {} : {};
|
||||||
let delta = 0;
|
const mustFit = effectiveResizeBehavior !== "free" || resolvedInitialFit === "container";
|
||||||
|
if (!mustFit) {
|
||||||
if (layoutChanged || previousContainerWidth === undefined) {
|
if (signatureMatches && current.widths === undefined && current.bufferWidth === undefined) return current;
|
||||||
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.
|
|
||||||
return {
|
return {
|
||||||
...current,
|
...current,
|
||||||
widths: roundWidthRecord(baseWidths),
|
widths: undefined,
|
||||||
|
userWidths,
|
||||||
|
layoutSignature,
|
||||||
fillColumnId: undefined,
|
fillColumnId: undefined,
|
||||||
bufferWidth: normalizeBufferWidth(current.bufferWidth ?? 0, containerResizeColumns.length === 0)
|
bufferWidth: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return applyContainerResize(columns, current, baseWidths, delta);
|
|
||||||
});
|
|
||||||
|
|
||||||
lastContainerWidthRef.current = nextContainerWidth;
|
const layout = fitDataGridColumns(columns, nextContainerWidth, measuredWidths, userWidths);
|
||||||
lastLayoutSignatureRef.current = layoutSignature;
|
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);
|
window.cancelAnimationFrame(animationFrame);
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
};
|
};
|
||||||
}, [columns, layoutSignature, effectiveResizeBehavior, resolvedInitialFit]);
|
}, [columns, layoutSignature, effectiveResizeBehavior, resolvedInitialFit, measuredWidths, resizeState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!resizeState) return;
|
if (!resizeState) return;
|
||||||
@@ -459,13 +465,23 @@ export default function DataGrid<T>({
|
|||||||
setState((current) => ({
|
setState((current) => ({
|
||||||
...current,
|
...current,
|
||||||
widths: roundWidthRecord(nextWidths),
|
widths: roundWidthRecord(nextWidths),
|
||||||
|
userWidths: {
|
||||||
|
...(current.layoutSignature === layoutSignature ? current.userWidths ?? {} : {}),
|
||||||
|
[activeResize.columnId]: Math.round(nextWidths[activeResize.columnId] * 100) / 100
|
||||||
|
},
|
||||||
|
layoutSignature,
|
||||||
fillColumnId: undefined,
|
fillColumnId: undefined,
|
||||||
bufferWidth: normalizeBufferWidth(nextBufferWidth, containerResizeColumns.length === 0)
|
bufferWidth: normalizeBufferWidth(nextBufferWidth, containerResizeColumns.length === 0)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUp() {
|
function onUp() {
|
||||||
setState((current) => sanitizePersistedColumnState(columns, current, effectiveResizeBehavior));
|
setState((current) => sanitizePersistedColumnState(
|
||||||
|
columns,
|
||||||
|
current,
|
||||||
|
effectiveResizeBehavior,
|
||||||
|
layoutSignature
|
||||||
|
));
|
||||||
setResizeState(null);
|
setResizeState(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +491,7 @@ export default function DataGrid<T>({
|
|||||||
window.removeEventListener("mousemove", onMove);
|
window.removeEventListener("mousemove", onMove);
|
||||||
window.removeEventListener("mouseup", onUp);
|
window.removeEventListener("mouseup", onUp);
|
||||||
};
|
};
|
||||||
}, [resizeState, columns, effectiveResizeBehavior, containerResizeColumns.length]);
|
}, [resizeState, columns, effectiveResizeBehavior, containerResizeColumns.length, layoutSignature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!openFilterColumnId) return undefined;
|
if (!openFilterColumnId) return undefined;
|
||||||
@@ -1228,16 +1244,33 @@ function translateHeaderLabel(node: ReactNode, translateText: (value: string) =>
|
|||||||
return String(node);
|
return String(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadState(key: string): DataGridState {
|
function loadState(key: string, layoutSignature: string): DataGridState {
|
||||||
try {
|
try {
|
||||||
const value = window.localStorage.getItem(key);
|
const value = window.localStorage.getItem(key);
|
||||||
if (!value) return {};
|
if (!value) return { layoutSignature };
|
||||||
return JSON.parse(value) as DataGridState;
|
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 {
|
} 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> {
|
function normalizeInitialFilters(filters: Record<string, string | string[]>): Record<string, string> {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(filters).map(([columnId, value]) => [
|
Object.entries(filters).map(([columnId, value]) => [
|
||||||
@@ -1326,11 +1359,13 @@ function humanizeListValue(value: string): string {
|
|||||||
function sanitizePersistedColumnState<T>(
|
function sanitizePersistedColumnState<T>(
|
||||||
columns: DataGridColumn<T>[],
|
columns: DataGridColumn<T>[],
|
||||||
state: DataGridState,
|
state: DataGridState,
|
||||||
resizeBehavior: DataGridResizeBehavior)
|
resizeBehavior: DataGridResizeBehavior,
|
||||||
|
layoutSignature: string)
|
||||||
: DataGridState {
|
: DataGridState {
|
||||||
const columnMap = new Map(columns.map((column) => [column.id, column]));
|
const columnMap = new Map(columns.map((column) => [column.id, column]));
|
||||||
const nextWidths = Object.fromEntries(
|
const signatureMatches = state.layoutSignature === layoutSignature;
|
||||||
Object.entries(state.widths ?? {}).flatMap(([columnId, width]) => {
|
const sanitizeWidths = (widths?: Record<string, number>) => Object.fromEntries(
|
||||||
|
Object.entries(widths ?? {}).flatMap(([columnId, width]) => {
|
||||||
const column = columnMap.get(columnId);
|
const column = columnMap.get(columnId);
|
||||||
if (!column || !Number.isFinite(width)) return [];
|
if (!column || !Number.isFinite(width)) return [];
|
||||||
const minimum = effectiveColumnMinWidth(column);
|
const minimum = effectiveColumnMinWidth(column);
|
||||||
@@ -1338,18 +1373,25 @@ resizeBehavior: DataGridResizeBehavior)
|
|||||||
return [[columnId, Math.min(maximum, Math.max(minimum, width))]];
|
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 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 hasContainerResizeColumn = columns.some(isContainerResizeColumn);
|
||||||
const normalizedBufferWidth = resizeBehavior === "free" ?
|
const normalizedBufferWidth = resizeBehavior === "free" || !signatureMatches ?
|
||||||
undefined :
|
undefined :
|
||||||
normalizeBufferWidth(state.bufferWidth ?? 0, !hasContainerResizeColumn);
|
normalizeBufferWidth(state.bufferWidth ?? 0, !hasContainerResizeColumn);
|
||||||
const widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, normalizedWidths ?? {});
|
const widthsChanged = !shallowEqualNumberRecords(state.widths ?? {}, normalizedWidths ?? {});
|
||||||
|
const userWidthsChanged = !shallowEqualNumberRecords(state.userWidths ?? {}, normalizedUserWidths ?? {});
|
||||||
const bufferChanged = normalizedBufferWidth !== state.bufferWidth;
|
const bufferChanged = normalizedBufferWidth !== state.bufferWidth;
|
||||||
const fillChanged = state.fillColumnId !== undefined;
|
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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
widths: normalizedWidths,
|
widths: normalizedWidths,
|
||||||
|
userWidths: normalizedUserWidths,
|
||||||
|
layoutSignature,
|
||||||
fillColumnId: undefined,
|
fillColumnId: undefined,
|
||||||
bufferWidth: normalizedBufferWidth
|
bufferWidth: normalizedBufferWidth
|
||||||
};
|
};
|
||||||
@@ -1364,24 +1406,6 @@ function widthForColumn<T>(column: DataGridColumn<T>, savedWidth?: number): stri
|
|||||||
return `minmax(${minimum}px, 1fr)`;
|
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 {
|
function findBufferInsertIndex<T>(columns: DataGridColumn<T>[]): number {
|
||||||
let index = columns.length;
|
let index = columns.length;
|
||||||
while (index > 0 && columns[index - 1].sticky === "end") index -= 1;
|
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 {
|
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 {
|
function isResizeCompensationColumn<T>(column: DataGridColumn<T>): boolean {
|
||||||
@@ -1402,48 +1426,8 @@ function resizeTargetForColumn<T>(column: DataGridColumn<T>, baseWidths: Record<
|
|||||||
columnId: column.id,
|
columnId: column.id,
|
||||||
startWidth: baseWidths[column.id] ?? columnPixelWidth(column),
|
startWidth: baseWidths[column.id] ?? columnPixelWidth(column),
|
||||||
minWidth: minimum,
|
minWidth: minimum,
|
||||||
maxWidth: effectiveColumnMaxWidth(column, minimum)
|
maxWidth: effectiveColumnMaxWidth(column, minimum),
|
||||||
};
|
weight: dataGridColumnResizeWeight(column)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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(
|
function applyResizeAmounts(
|
||||||
widths: Record<string, number>,
|
widths: Record<string, number>,
|
||||||
bufferWidth: number,
|
bufferWidth: number,
|
||||||
@@ -1531,12 +1474,6 @@ function normalizeBufferWidth(width: number, preserveZero: boolean): number | un
|
|||||||
return normalized > 0.01 || preserveZero ? normalized : undefined;
|
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>(
|
function measuredColumnWidths<T>(
|
||||||
columns: DataGridColumn<T>[],
|
columns: DataGridColumn<T>[],
|
||||||
elements: Record<string, HTMLDivElement | null>,
|
elements: Record<string, HTMLDivElement | null>,
|
||||||
@@ -1547,40 +1484,18 @@ measuredWidths?: Record<string, number>)
|
|||||||
for (const column of columns) {
|
for (const column of columns) {
|
||||||
const element = elements[column.id];
|
const element = elements[column.id];
|
||||||
const measured = element ? Math.round(element.getBoundingClientRect().width) : undefined;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFlexibleColumn<T>(column: DataGridColumn<T>, savedWidth?: number): boolean {
|
function isFlexibleColumn<T>(column: DataGridColumn<T>, savedWidth?: number): boolean {
|
||||||
if (savedWidth && !isFlexibleWidth(column.width)) return false;
|
if (savedWidth && !isFlexibleWidth(column.width, column.fill)) return false;
|
||||||
return isFlexibleWidth(column.width);
|
return isFlexibleWidth(column.width, column.fill);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeStickyOffsets<T>(columns: DataGridColumn<T>[], savedWidths?: Record<string, number>, measuredWidths?: Record<string, number>): Record<number, number> {
|
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 { 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 type { ApiSettings, AuthInfo, AuthUpdate, FilesConnectorsUiCapability, MailProfilesUiCapability, SettingsSectionContribution, SettingsSectionsUiCapability, UserUiPreferences, UserUiTheme } from "../../types";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import FormField from "../../components/FormField";
|
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_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.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.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"
|
"i18n:govoplan-core.zip_archive.5a2430dd": "ZIP archive"
|
||||||
},
|
},
|
||||||
"de": {
|
"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_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.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.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"
|
"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 { default as SelectionList, SelectionListItem } from "./components/SelectionList";
|
||||||
export type { SelectionListItemProps, SelectionListProps } from "./components/SelectionList";
|
export type { SelectionListItemProps, SelectionListProps } from "./components/SelectionList";
|
||||||
export { default as StatusBadge } from "./components/StatusBadge";
|
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 Stepper } from "./components/Stepper";
|
||||||
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
export { default as ToggleSwitch } from "./components/ToggleSwitch";
|
||||||
export { UnsavedChangesProvider, useGuardedNavigate, useRegisterUnsavedChanges, useUnsavedChanges, useUnsavedDraftGuard } from "./components/UnsavedChangesGuard";
|
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 type { ApiSettings, AuthInfo, AuthUpdate, PlatformNavItem } from "../types";
|
||||||
import IconRail from "./IconRail";
|
import IconRail from "./IconRail";
|
||||||
import Titlebar from "./Titlebar";
|
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 { ChevronRight } from "lucide-react";
|
||||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 { HelpCircle, Info, BookOpen, GitBranch } from "lucide-react";
|
||||||
import packageInfo from "../../package.json";
|
import packageInfo from "../../package.json";
|
||||||
import Button from "../components/Button";
|
import Button from "../components/Button";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { PanelLeftClose, PanelLeftOpen, Settings } from "lucide-react";
|
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 { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||||
import type { AuthInfo, PlatformNavItem } from "../types";
|
import type { AuthInfo, PlatformNavItem } from "../types";
|
||||||
import { hasAnyScope, hasScope } from "../utils/permissions";
|
import { hasAnyScope, hasScope } from "../utils/permissions";
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router";
|
||||||
import App from "./app";
|
import App from "./app";
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import type {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
export const PLATFORM_VIEW_CHANGED_EVENT = "govoplan:view-changed";
|
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 {
|
export function moduleViewSurfaceId(moduleId: string): string {
|
||||||
return `${moduleId}.module`;
|
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(
|
function addSurface(
|
||||||
target: Map<string, PlatformViewSurface>,
|
target: Map<string, PlatformViewSurface>,
|
||||||
surface: PlatformViewSurface
|
surface: PlatformViewSurface
|
||||||
|
|||||||
@@ -3670,3 +3670,350 @@ a.dashboard-contribution-row:hover {
|
|||||||
grid-template-columns: 1fr;
|
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,
|
settings: ApiSettings,
|
||||||
viewId: string | null
|
viewId: string | null
|
||||||
) => Promise<EffectiveViewProjection>;
|
) => Promise<EffectiveViewProjection>;
|
||||||
|
resolveWorkflowView: (
|
||||||
|
settings: ApiSettings,
|
||||||
|
context: {
|
||||||
|
viewId: string;
|
||||||
|
revisionId?: string | null;
|
||||||
|
visibleSurfaceIds?: string[];
|
||||||
|
}
|
||||||
|
) => Promise<EffectiveViewProjection>;
|
||||||
Selector?: ComponentType<ViewSelectorProps>;
|
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": [
|
"include": [
|
||||||
"tests/data-grid-actions.test.tsx",
|
"tests/data-grid-actions.test.tsx",
|
||||||
|
"tests/data-grid-sizing.test.ts",
|
||||||
"tests/dialog-focus.test.tsx",
|
"tests/dialog-focus.test.tsx",
|
||||||
"tests/explorer-tree.test.tsx",
|
"tests/explorer-tree.test.tsx",
|
||||||
"tests/icon-button.test.tsx",
|
"tests/icon-button.test.tsx",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"tests/people-picker.test.tsx",
|
"tests/people-picker.test.tsx",
|
||||||
"tests/resource-access-explanation.test.tsx",
|
"tests/resource-access-explanation.test.tsx",
|
||||||
"tests/selection-list.test.tsx",
|
"tests/selection-list.test.tsx",
|
||||||
|
"tests/wysiwyg-editor-utils.test.ts",
|
||||||
"src/components/CredentialPanel.tsx",
|
"src/components/CredentialPanel.tsx",
|
||||||
"src/components/email/EmailAddressInput.tsx",
|
"src/components/email/EmailAddressInput.tsx",
|
||||||
"src/components/PasswordField.tsx",
|
"src/components/PasswordField.tsx",
|
||||||
@@ -49,7 +51,9 @@
|
|||||||
"src/components/IconButton.tsx",
|
"src/components/IconButton.tsx",
|
||||||
"src/components/admin/AdminIconButton.tsx",
|
"src/components/admin/AdminIconButton.tsx",
|
||||||
"src/components/ToggleSwitch.tsx",
|
"src/components/ToggleSwitch.tsx",
|
||||||
|
"src/components/wysiwygEditorUrls.ts",
|
||||||
"src/components/table/DataGrid.tsx",
|
"src/components/table/DataGrid.tsx",
|
||||||
|
"src/components/table/dataGridSizing.ts",
|
||||||
"src/components/table/TableActionGroup.tsx"
|
"src/components/table/TableActionGroup.tsx"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
],
|
],
|
||||||
"@govoplan/core-webui/app": [
|
"@govoplan/core-webui/app": [
|
||||||
"./src/app.ts"
|
"./src/app.ts"
|
||||||
|
],
|
||||||
|
"@govoplan/core-webui/wysiwyg": [
|
||||||
|
"./src/wysiwyg.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+81
-1
@@ -63,6 +63,69 @@ function availableWebModuleSpecifiers(): string[] {
|
|||||||
return configuredWebModulePackages().filter(packageInstalled);
|
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 {
|
function govoplanInstalledModulesPlugin(): Plugin {
|
||||||
const specifierByModuleVirtualId = new Map(
|
const specifierByModuleVirtualId = new Map(
|
||||||
availableWebModuleSpecifiers().map((specifier) => [
|
availableWebModuleSpecifiers().map((specifier) => [
|
||||||
@@ -113,21 +176,38 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
manifest: true,
|
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
|
chunkSizeWarningLimit: 500
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
preserveSymlinks: false,
|
preserveSymlinks: false,
|
||||||
dedupe: [
|
dedupe: [
|
||||||
|
"@tiptap/core",
|
||||||
|
"@tiptap/pm",
|
||||||
|
"@tiptap/react",
|
||||||
"@xyflow/react",
|
"@xyflow/react",
|
||||||
|
"@bpmn-io/properties-panel",
|
||||||
|
"bpmn-js",
|
||||||
|
"bpmn-js-properties-panel",
|
||||||
|
"bpmn-moddle",
|
||||||
"lucide-react",
|
"lucide-react",
|
||||||
"react",
|
"react",
|
||||||
"react-dom",
|
"react-dom",
|
||||||
"react-router-dom",
|
"react-router",
|
||||||
"read-excel-file"
|
"read-excel-file"
|
||||||
],
|
],
|
||||||
alias: [
|
alias: [
|
||||||
{ find: "@govoplan/core-webui/app", replacement: fileURLToPath(new URL("./src/app.ts", import.meta.url)) },
|
{ 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/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)) }
|
{ find: "@govoplan/core-webui", replacement: fileURLToPath(new URL("./src/index.ts", import.meta.url)) }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user