7 Commits
Author SHA1 Message Date
zemion 8e5415dfe7 feat: explain external work hand-offs
Module Package Release / publish-packages (push) Successful in 11s
2026-08-22 02:14:35 +02:00
zemion d5214c641c fix: consume Workflow Engine through compatibility facade
Module Package Release / publish-packages (push) Successful in 14s
2026-08-21 16:13:27 +02:00
zemion 0fd744a2db feat: expose workflow semantic documentation subjects 2026-08-21 15:53:44 +02:00
zemion f49ad8eff4 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion 244c0ee4c1 feat: align workflow with shared UI foundations 2026-08-18 21:32:42 +02:00
zemion a071692e75 Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion 55bbd15ecc Adopt shared WebUI layout primitives 2026-08-18 10:42:55 +02:00
12 changed files with 1197 additions and 576 deletions
+3 -3
View File
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-workflow" name = "govoplan-workflow"
version = "0.1.18" version = "0.1.20"
description = "Optional visual authoring and inspection workspace for GovOPlaN Workflow Engine." description = "Optional visual authoring and inspection workspace for GovOPlaN Workflow Engine."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.18", "govoplan-core>=0.1.28",
"govoplan-workflow-engine>=0.1.18", "govoplan-workflow-engine>=0.1.19",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
+152 -3
View File
@@ -1,17 +1,28 @@
from __future__ import annotations from __future__ import annotations
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
FrontendRoute, FrontendRoute,
ModuleContext,
ModuleInterfaceProvider, ModuleInterfaceProvider,
ModuleInterfaceRequirement, ModuleInterfaceRequirement,
ModuleManifest, ModuleManifest,
NavItem, NavItem,
ProductAreaContribution,
ViewSurface, ViewSurface,
) )
from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
semantic_documentation_subject_capability,
)
from govoplan_workflow.backend.semantic_subjects import (
WorkflowSemanticDocumentationSubjectProvider,
)
from govoplan_workflow_engine.backend.manifest import ( from govoplan_workflow_engine.backend.manifest import (
ADMIN_SCOPE, ADMIN_SCOPE,
DEFINITION_READ_SCOPE, DEFINITION_READ_SCOPE,
@@ -24,7 +35,14 @@ from govoplan_workflow_engine.backend.manifest import (
MODULE_ID = "workflow" MODULE_ID = "workflow"
MODULE_NAME = "Workflow" MODULE_NAME = "Workflow"
MODULE_VERSION = "0.1.18" MODULE_VERSION = "0.1.20"
SEMANTIC_SUBJECT_CAPABILITY = semantic_documentation_subject_capability(MODULE_ID)
def _semantic_subjects(
context: ModuleContext,
) -> WorkflowSemanticDocumentationSubjectProvider:
return WorkflowSemanticDocumentationSubjectProvider(context.registry)
manifest = ModuleManifest( manifest = ModuleManifest(
@@ -32,9 +50,28 @@ manifest = ModuleManifest(
name=MODULE_NAME, name=MODULE_NAME,
version=MODULE_VERSION, version=MODULE_VERSION,
dependencies=("workflow_engine",), dependencies=("workflow_engine",),
optional_dependencies=("docs",),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION), ModuleInterfaceProvider(name="workflow.editor", version=MODULE_VERSION),
ModuleInterfaceProvider(
name=SEMANTIC_SUBJECT_CAPABILITY,
version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
),
), ),
capability_factories={
SEMANTIC_SUBJECT_CAPABILITY: _semantic_subjects,
},
capability_documentation={
SEMANTIC_SUBJECT_CAPABILITY: CapabilityDocumentation(
label="Workflow semantic-documentation subjects",
summary=(
"Lists currently authorized workflow definitions and stable "
"step lineages with documentation-safe review fingerprints."
),
contract_version=SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
documentation_types=("admin", "user"),
),
},
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
name="workflow.definition_graph", name="workflow.definition_graph",
@@ -81,6 +118,17 @@ manifest = ModuleManifest(
order=74, order=74,
), ),
), ),
product_areas=(
ProductAreaContribution(
id="work",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.work",
icon="list-checks",
description="i18n:govoplan-core.product_area.work_description",
surface_ids=("workflow.nav.workflow", "workflow.route.workflow"),
order=10,
),
),
view_surfaces=( view_surfaces=(
ViewSurface( ViewSurface(
id="workflow.widget.open-work", id="workflow.widget.open-work",
@@ -92,6 +140,99 @@ manifest = ModuleManifest(
), ),
), ),
documentation=( documentation=(
DocumentationTopic(
id="workflow.external-handoff-inspection",
title="Inspect and return to external work hand-offs",
summary=(
"Follow the exact linked module work while Workflow retains "
"its durable position and immutable reference."
),
body=(
"The runs dialog shows the current external hand-off state, exact "
"revision reference, safe linked-work action, timeout, and the "
"immutable Workflow evidence trail. Leaving the dialog or linked "
"Campaign page does not complete the step. Declared Campaign events "
"drive acceptance, reassignment, completion, rejection, and "
"cancellation without browser polling. A configured focused View "
"narrows the available surfaces while the step is active. If Views "
"is absent, the linked Campaign action remains available and the "
"dialog explains the reduced presentation. Missing optional Tasks or "
"Notifications is also shown without changing the hand-off outcome."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "workflow_designer", "campaign_manager"),
related_modules=(
"workflow_engine",
"campaigns",
"views",
"tasks",
"notifications",
),
conditions=(
DocumentationCondition(
any_scopes=(INSTANCE_READ_SCOPE, ADMIN_SCOPE),
),
),
metadata={
"kind": "workflow",
"help_contexts": [
"workflow.external-handoff",
"workflow.instances",
],
},
order=74,
),
DocumentationTopic(
id="workflow.semantic-documentation",
title="Document configured workflow and step meaning",
summary=(
"Attach tenant-owned semantic guidance to an authorized workflow "
"definition or stable step without changing execution."
),
body=(
"When Docs is installed, Workflow supplies documentation-safe "
"subjects for each definition the current actor may view and for "
"each step in its current immutable revision. Step identity survives "
"label and canvas-position changes. Removing and later recreating a "
"step with the same graph ID creates a new lineage, leaving prior "
"documentation explicitly orphaned. Relevant changes to step type, "
"label, hierarchy, safe configuration, or connected flow semantics "
"change the review fingerprint; layout-only movement does not. "
"Semantic prose can explain purpose, non-purpose, entry and exit "
"conditions, handoffs, exceptions, ownership, stewardship, and "
"examples, but it cannot alter validation, activation, policy, or "
"Workflow Engine execution. Workflow rechecks tenant scope and the "
"current per-definition governance decision for discovery and direct "
"resolution. If Docs is absent, editing and static help continue."
),
layer="configured",
documentation_types=("admin", "user"),
audience=(
"user",
"workflow_designer",
"process_owner",
"module_admin",
),
related_modules=("docs", "workflow_engine", "policy"),
links=(
DocumentationLink(
label="Semantic documentation authoring",
href="/docs/semantic",
kind="runtime",
),
DocumentationLink(
label="Workflow editor and engine boundary",
href="govoplan-workflow/docs/ENGINE_EDITOR_SPLIT.md",
kind="repository",
),
),
metadata={
"kind": "reference",
"help_contexts": ["workflow.semantic-documentation"],
},
order=75,
),
DocumentationTopic( DocumentationTopic(
id="workflow.editor", id="workflow.editor",
title="Workflow editor and inspection workspace", title="Workflow editor and inspection workspace",
@@ -126,8 +267,16 @@ manifest = ModuleManifest(
maturity="vertical_slice", maturity="vertical_slice",
documentation_ref="docs/ENGINE_EDITOR_SPLIT.md", documentation_ref="docs/ENGINE_EDITOR_SPLIT.md",
test_ref="tests/test_manifest.py", test_ref="tests/test_manifest.py",
known_limits=("The editor intentionally cannot execute definitions without Workflow Engine and target-tested adapters.",), known_limits=(
owned_concepts=("workflow editing surface", "workflow revision inspection", "workflow activation controls"), "The editor intentionally cannot execute definitions without Workflow Engine and target-tested adapters.",
"Semantic documentation requires Docs for tenant-authored content; static Workflow help remains available without it.",
),
owned_concepts=(
"workflow editing surface",
"workflow revision inspection",
"workflow activation controls",
"workflow semantic documentation subjects",
),
non_owned_concepts=("workflow definition persistence", "workflow instance execution", "domain action"), non_owned_concepts=("workflow definition persistence", "workflow instance execution", "domain action"),
security_docs=("docs/CONCEPT.md",), security_docs=("docs/CONCEPT.md",),
), ),
@@ -0,0 +1,428 @@
from __future__ import annotations
import hashlib
from collections.abc import Mapping
from dataclasses import dataclass
from urllib.parse import quote
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.semantic_documentation import (
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
SemanticDocumentationBreadcrumb,
SemanticDocumentationSubjectAnchor,
SemanticDocumentationSubjectDescriptor,
SemanticDocumentationSubjectPage,
SemanticDocumentationSubjectQuery,
SemanticDocumentationSubjectReference,
SemanticDocumentationSubjectResolution,
semantic_documentation_fingerprint,
)
from govoplan_core.security.redaction import redact_secret_values
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.governance import definition_decision
from govoplan_workflow.backend.service import (
get_definition_revision,
list_definition_revisions,
list_definitions,
)
SUBJECT_KIND = "workflow_definition"
ADMIN_SCOPE = "workflow:instance:admin"
DEFINITION_READ_SCOPE = "workflow:definition:read"
_MAX_SUBJECTS = 20_000
@dataclass(frozen=True, slots=True)
class _LineageState:
node_ids: Mapping[str, str]
historical_node_ids: frozenset[str]
class WorkflowSemanticDocumentationSubjectProvider:
provider_id = "workflow.semantic_subjects"
module_id = "workflow"
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
def __init__(self, registry: object | None) -> None:
self._registry = registry
def list_subjects(
self,
session: object,
principal: object,
*,
request: SemanticDocumentationSubjectQuery,
) -> SemanticDocumentationSubjectPage:
actor = _principal(principal, tenant_id=request.tenant_id)
if actor is None:
return SemanticDocumentationSubjectPage()
if request.subject_kinds and SUBJECT_KIND not in request.subject_kinds:
return SemanticDocumentationSubjectPage()
db = _session(session)
subjects: list[SemanticDocumentationSubjectDescriptor] = []
for definition in list_definitions(db, tenant_id=request.tenant_id):
if not self._can_view(definition, actor):
continue
revision = get_definition_revision(db, definition=definition)
lineage = _lineage_state(db, definition)
subjects.extend(
_descriptors(
definition,
revision,
lineage,
tenant_id=request.tenant_id,
)
)
if len(subjects) > _MAX_SUBJECTS:
raise ValueError(
"Workflow semantic subject limit exceeded; narrow the query."
)
query = request.query.casefold().strip()
if query:
subjects = [
item
for item in subjects
if query in _search_text(item).casefold()
]
offset = _cursor_offset(request.cursor)
selected = tuple(subjects[offset : offset + request.limit])
next_offset = offset + len(selected)
has_more = next_offset < len(subjects)
return SemanticDocumentationSubjectPage(
subjects=selected,
next_cursor=str(next_offset) if has_more else None,
has_more=has_more,
)
def resolve_subject(
self,
session: object,
principal: object,
*,
reference: SemanticDocumentationSubjectReference,
) -> SemanticDocumentationSubjectResolution | None:
actor = _principal(principal, tenant_id=reference.tenant_id)
if (
reference.module_id != self.module_id
or reference.subject_kind != SUBJECT_KIND
or actor is None
):
return None
db = _session(session)
definition = db.scalar(
select(WorkflowDefinition).where(
WorkflowDefinition.id == reference.subject_id,
or_(
WorkflowDefinition.tenant_id == reference.tenant_id,
WorkflowDefinition.tenant_id.is_(None),
),
)
)
if definition is None or not self._can_view(definition, actor):
return None
if definition.deleted_at is not None:
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="missing",
reason_code="definition_deleted",
)
revision = get_definition_revision(db, definition=definition)
lineage = _lineage_state(db, definition)
descriptor = next(
(
item
for item in _descriptors(
definition,
revision,
lineage,
tenant_id=reference.tenant_id,
)
if item.reference.stable_key == reference.stable_key
),
None,
)
if descriptor is None:
anchor = reference.anchor
reason = "subject_missing"
if anchor is not None and anchor.kind == "step":
reason = (
"step_deleted"
if anchor.id in lineage.historical_node_ids
else "step_missing"
)
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="missing",
reason_code=reason,
)
changed = any(
expected is not None and expected != actual
for expected, actual in (
(reference.observed_revision, descriptor.reference.observed_revision),
(
reference.observed_fingerprint,
descriptor.reference.observed_fingerprint,
),
)
)
return SemanticDocumentationSubjectResolution(
requested_reference=reference,
availability="changed" if changed else "available",
subject=descriptor,
)
def _can_view(
self,
definition: WorkflowDefinition,
principal: ApiPrincipal,
) -> bool:
return definition_decision(
definition,
principal=principal,
registry=self._registry,
action="view",
).allowed
def _descriptors(
definition: WorkflowDefinition,
revision: WorkflowDefinitionRevision,
lineage: _LineageState,
*,
tenant_id: str,
) -> tuple[SemanticDocumentationSubjectDescriptor, ...]:
route = f"/workflow?definition={quote(definition.id, safe='')}"
definition_fingerprint = semantic_documentation_fingerprint(
{
"revision": definition.current_revision,
"content_hash": revision.content_hash,
"name": definition.name,
"description": definition.description,
"status": definition.status,
"active_revision": definition.active_revision,
"scope_type": definition.scope_type,
"definition_kind": definition.definition_kind,
"allow_start": definition.allow_start,
"allow_reuse": definition.allow_reuse,
"allow_automation": definition.allow_automation,
}
)
result = [
SemanticDocumentationSubjectDescriptor(
reference=_reference(
definition,
tenant_id=tenant_id,
revision=str(definition.current_revision),
fingerprint=definition_fingerprint,
),
labels={"en": definition.name},
descriptions=(
{"en": definition.description} if definition.description else {}
),
route=route,
# The provider already applies the engine's READ-or-ADMIN and
# per-definition governance decision. The descriptor contract
# represents an AND-only scope list, so it cannot restate that
# disjunction without incorrectly excluding administrators.
required_scopes=(),
)
]
graph = revision.graph if isinstance(revision.graph, Mapping) else {}
nodes = tuple(
item for item in graph.get("nodes", ()) if isinstance(item, Mapping)
)
edges = tuple(
item for item in graph.get("edges", ()) if isinstance(item, Mapping)
)
node_by_id = {str(item.get("id")): item for item in nodes if item.get("id")}
for node_id, node in node_by_id.items():
identity = lineage.node_ids[node_id]
fingerprint = _node_fingerprint(node, edges)
label = str(node.get("label") or node.get("type") or node_id)[:300]
breadcrumbs = [
SemanticDocumentationBreadcrumb(
label=definition.name,
subject_kind=SUBJECT_KIND,
subject_id=definition.id,
)
]
parent_id = str(node.get("parent_id") or "")
parent = node_by_id.get(parent_id)
if parent is not None and parent_id in lineage.node_ids:
breadcrumbs.append(
SemanticDocumentationBreadcrumb(
label=str(parent.get("label") or parent_id)[:300],
subject_kind=SUBJECT_KIND,
subject_id=definition.id,
anchor=SemanticDocumentationSubjectAnchor(
kind="step",
id=lineage.node_ids[parent_id],
),
)
)
result.append(
SemanticDocumentationSubjectDescriptor(
reference=_reference(
definition,
tenant_id=tenant_id,
anchor=SemanticDocumentationSubjectAnchor(
kind="step", id=identity
),
revision=fingerprint,
fingerprint=fingerprint,
),
labels={"en": label},
descriptions={
"en": f"Configured {str(node.get('type') or 'workflow step')[:200]} step."
},
breadcrumbs=tuple(breadcrumbs),
route=route,
route_anchor=_route_anchor(node_id),
required_scopes=(),
)
)
return tuple(result)
def _reference(
definition: WorkflowDefinition,
*,
tenant_id: str,
revision: str,
fingerprint: str,
anchor: SemanticDocumentationSubjectAnchor | None = None,
) -> SemanticDocumentationSubjectReference:
return SemanticDocumentationSubjectReference(
module_id="workflow",
tenant_id=tenant_id,
subject_kind=SUBJECT_KIND,
subject_id=definition.id,
anchor=anchor,
observed_revision=revision,
observed_fingerprint=fingerprint,
)
def _lineage_state(
session: Session,
definition: WorkflowDefinition,
) -> _LineageState:
revisions = sorted(
list_definition_revisions(session, definition=definition),
key=lambda item: item.revision,
)
active: dict[str, str] = {}
historical: set[str] = set()
for revision in revisions:
graph = revision.graph if isinstance(revision.graph, Mapping) else {}
node_ids = {
str(item.get("id"))
for item in graph.get("nodes", ())
if isinstance(item, Mapping) and item.get("id")
}
active = {key: value for key, value in active.items() if key in node_ids}
for node_id in sorted(node_ids):
active.setdefault(node_id, _lineage_id(revision.id, node_id))
historical.add(active[node_id])
return _LineageState(
node_ids=active,
historical_node_ids=frozenset(historical),
)
def _node_fingerprint(
node: Mapping[str, object],
edges: tuple[Mapping[str, object], ...],
) -> str:
node_id = str(node.get("id") or "")
connected = [
{
"id": edge.get("id"),
"type": edge.get("type"),
"label": edge.get("label"),
"source": edge.get("source"),
"target": edge.get("target"),
"source_port": edge.get("source_port"),
"target_port": edge.get("target_port"),
"config": redact_secret_values(edge.get("config") or {}),
}
for edge in edges
if node_id in {str(edge.get("source") or ""), str(edge.get("target") or "")}
]
connected.sort(key=lambda item: str(item["id"] or ""))
return semantic_documentation_fingerprint(
{
"type": node.get("type"),
"label": node.get("label"),
"parent_id": node.get("parent_id"),
"process_id": node.get("process_id"),
"config": redact_secret_values(node.get("config") or {}),
"connections": connected,
}
)
def _lineage_id(revision_id: str, node_id: str) -> str:
digest = hashlib.sha256(f"{revision_id}\x1f{node_id}".encode()).hexdigest()
return f"step-{digest[:40]}"
def _route_anchor(node_id: str) -> str:
route_anchor = f"workflow-node-{node_id}"
if len(route_anchor) <= 255:
return route_anchor
digest = hashlib.sha256(node_id.encode()).hexdigest()
return f"workflow-node-{digest[:40]}"
def _search_text(item: SemanticDocumentationSubjectDescriptor) -> str:
return " ".join(
(
item.reference.subject_id,
*item.labels.values(),
*item.descriptions.values(),
*(breadcrumb.label for breadcrumb in item.breadcrumbs),
)
)
def _principal(
principal: object,
*,
tenant_id: str,
) -> ApiPrincipal | None:
if not isinstance(principal, ApiPrincipal) or principal.tenant_id != tenant_id:
return None
if not (
principal.has(DEFINITION_READ_SCOPE)
or principal.has(ADMIN_SCOPE)
):
return None
return principal
def _cursor_offset(value: str | None) -> int:
if value is None:
return 0
if not value.isdigit() or int(value) < 0:
raise ValueError("Workflow semantic subject cursor is invalid.")
return int(value)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Workflow semantic subjects require a SQLAlchemy session.")
return value
__all__ = [
"SUBJECT_KIND",
"WorkflowSemanticDocumentationSubjectProvider",
]
+27 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
import unittest import unittest
from govoplan_workflow.backend.manifest import get_manifest from govoplan_workflow.backend.manifest import get_manifest
@@ -22,12 +23,37 @@ class WorkflowManifestTests(unittest.TestCase):
self.assertEqual((), manifest.permissions) self.assertEqual((), manifest.permissions)
self.assertIsNone(manifest.route_factory) self.assertIsNone(manifest.route_factory)
self.assertIsNone(manifest.migration_spec) self.assertIsNone(manifest.migration_spec)
self.assertEqual({}, manifest.capability_factories) self.assertIn("documentation.semantic_subjects.workflow", manifest.capability_factories)
self.assertIn("docs", manifest.optional_dependencies)
self.assertIn(
"documentation.semantic_subjects.workflow",
{item.name for item in manifest.provides_interfaces},
)
self.assertIn(
"workflow.semantic-documentation",
{topic.id for topic in manifest.documentation},
)
self.assertEqual( self.assertEqual(
"@govoplan/workflow-webui", "@govoplan/workflow-webui",
manifest.frontend.package_name if manifest.frontend else None, manifest.frontend.package_name if manifest.frontend else None,
) )
def test_editor_exposes_semantic_help_without_replacing_static_help(self) -> None:
root = Path(__file__).parents[1]
page = (root / "webui/src/features/workflow/WorkflowPage.tsx").read_text()
inspector = (
root / "webui/src/features/workflow/WorkflowInspector.tsx"
).read_text()
self.assertIn("workflow.semantic-documentation", {
topic.id for topic in get_manifest().documentation
})
self.assertIn('"semantic"', page)
self.assertIn('"workflow_definition"', page)
self.assertIn('target="_blank"', page)
self.assertIn('target="_blank"', inspector)
self.assertIn("workflow-node-${nodeId}", inspector)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+293
View File
@@ -0,0 +1,293 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.semantic_documentation import (
SemanticDocumentationSubjectQuery,
)
from govoplan_core.db.base import Base
from govoplan_workflow.backend.semantic_subjects import (
WorkflowSemanticDocumentationSubjectProvider,
)
from govoplan_workflow_engine.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow_engine.backend.schemas import (
WorkflowDefinitionCreateRequest,
WorkflowDefinitionUpdateRequest,
WorkflowEdge,
WorkflowGraph,
WorkflowNode,
WorkflowPosition,
)
from govoplan_workflow_engine.backend.service import (
create_definition,
delete_definition,
update_definition,
)
def principal(
*,
tenant_id: str = "tenant-1",
scopes: frozenset[str] = frozenset({"workflow:definition:read"}),
) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="author-1",
membership_id="membership-1",
tenant_id=tenant_id,
scopes=scopes,
),
account=object(),
user=object(),
)
def graph(
*,
activity_label: str = "Review",
activity_x: float = 280,
include_activity: bool = True,
) -> WorkflowGraph:
nodes = [
WorkflowNode(
id="start",
type="workflow.start.manual",
label="Start",
position=WorkflowPosition(x=40, y=100),
),
]
if include_activity:
nodes.append(
WorkflowNode(
id="activity",
type="workflow.activity",
label=activity_label,
position=WorkflowPosition(x=activity_x, y=100),
config={
"instructions": "Check the submitted evidence.",
"api_secret": "must-not-leak",
},
)
)
nodes.append(
WorkflowNode(
id="complete",
type="workflow.end.completed",
label="Completed",
position=WorkflowPosition(x=520, y=100),
)
)
edges = []
if include_activity:
edges.extend(
(
WorkflowEdge(id="start-activity", source="start", target="activity"),
WorkflowEdge(
id="activity-complete",
source="activity",
target="complete",
),
)
)
else:
edges.append(
WorkflowEdge(id="start-complete", source="start", target="complete")
)
return WorkflowGraph(nodes=nodes, edges=edges)
class WorkflowSemanticSubjectTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=(
WorkflowDefinition.__table__,
WorkflowDefinitionRevision.__table__,
),
)
self.session = Session(self.engine)
self.principal = principal()
self.provider = WorkflowSemanticDocumentationSubjectProvider(None)
self.definition = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="author-1",
payload=WorkflowDefinitionCreateRequest(
name="Resident permit review",
description="Review an application before issuing the permit.",
graph=graph(),
),
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def subjects(self):
return self.provider.list_subjects(
self.session,
self.principal,
request=SemanticDocumentationSubjectQuery(
tenant_id="tenant-1",
limit=200,
),
).subjects
def activity(self):
return next(
item
for item in self.subjects()
if item.route_anchor == "workflow-node-activity"
)
def revise(self, updated_graph: WorkflowGraph) -> None:
update_definition(
self.session,
tenant_id="tenant-1",
definition_id=self.definition.id,
actor_id="author-1",
payload=WorkflowDefinitionUpdateRequest(
name=self.definition.name,
description=self.definition.description,
graph=updated_graph,
expected_revision=self.definition.current_revision,
),
)
self.session.commit()
def test_exposes_safe_definition_and_step_descriptors(self) -> None:
subjects = self.subjects()
self.assertEqual(4, len(subjects))
definition = next(item for item in subjects if item.reference.anchor is None)
activity = self.activity()
self.assertEqual("Resident permit review", definition.labels["en"])
self.assertEqual("step", activity.reference.anchor.kind)
self.assertTrue(activity.reference.anchor.id.startswith("step-"))
self.assertEqual("/workflow?definition=" + self.definition.id, activity.route)
payload = activity.to_dict()
self.assertNotIn("config", payload)
self.assertNotIn("must-not-leak", str(payload))
def test_lineage_survives_layout_and_label_changes_with_review_fingerprint(self) -> None:
before = self.activity().reference
self.revise(graph(activity_x=900))
after_layout = self.activity().reference
self.assertEqual(before.stable_key, after_layout.stable_key)
self.assertEqual(
before.observed_fingerprint,
after_layout.observed_fingerprint,
)
self.revise(graph(activity_x=900, activity_label="Assess evidence"))
after_label = self.activity().reference
self.assertEqual(before.stable_key, after_label.stable_key)
self.assertNotEqual(
before.observed_fingerprint,
after_label.observed_fingerprint,
)
resolution = self.provider.resolve_subject(
self.session,
self.principal,
reference=before,
)
self.assertEqual("changed", resolution.availability)
def test_deleted_and_recreated_step_id_gets_new_lineage(self) -> None:
old = self.activity().reference
self.revise(graph(include_activity=False))
deleted = self.provider.resolve_subject(
self.session,
self.principal,
reference=old,
)
self.assertEqual("missing", deleted.availability)
self.assertEqual("step_deleted", deleted.reason_code)
self.revise(graph(activity_label="Recreated review"))
recreated = self.activity().reference
self.assertNotEqual(old.stable_key, recreated.stable_key)
still_deleted = self.provider.resolve_subject(
self.session,
self.principal,
reference=old,
)
self.assertEqual("step_deleted", still_deleted.reason_code)
def test_resolution_rechecks_scope_tenant_governance_and_deletion(self) -> None:
reference = self.activity().reference
self.assertIsNone(
self.provider.resolve_subject(
self.session,
principal(scopes=frozenset()),
reference=reference,
)
)
self.assertIsNone(
self.provider.resolve_subject(
self.session,
principal(tenant_id="tenant-2"),
reference=reference,
)
)
admin = principal(scopes=frozenset({"workflow:instance:admin"}))
self.assertIsNotNone(
self.provider.resolve_subject(
self.session,
admin,
reference=reference,
)
)
delete_definition(
self.session,
tenant_id="tenant-1",
definition_id=self.definition.id,
actor_id="author-1",
)
self.session.commit()
deleted = self.provider.resolve_subject(
self.session,
self.principal,
reference=reference,
)
self.assertEqual("missing", deleted.availability)
self.assertEqual("definition_deleted", deleted.reason_code)
def test_query_filters_and_pages_without_docs_runtime(self) -> None:
page = self.provider.list_subjects(
self.session,
self.principal,
request=SemanticDocumentationSubjectQuery(
tenant_id="tenant-1",
query="review",
limit=1,
),
)
self.assertEqual(1, len(page.subjects))
self.assertTrue(page.has_more)
second = self.provider.list_subjects(
self.session,
self.principal,
request=SemanticDocumentationSubjectQuery(
tenant_id="tenant-1",
query="review",
limit=1,
cursor=page.next_cursor,
),
)
self.assertEqual(1, len(second.subjects))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/workflow-webui", "name": "@govoplan/workflow-webui",
"version": "0.1.18", "version": "0.1.20",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -15,6 +15,7 @@ import {
type Edge, type Edge,
type ReactFlowInstance type ReactFlowInstance
} from "@xyflow/react"; } from "@xyflow/react";
import { StatePanel } from "@govoplan/core-webui";
import { definitionConnectionError } from "@govoplan/core-webui/definition-graph"; import { definitionConnectionError } from "@govoplan/core-webui/definition-graph";
import type { import type {
WorkflowDiagnostic, WorkflowDiagnostic,
@@ -211,7 +212,7 @@ export default function WorkflowCanvas({
return ( return (
<div <div
className="workflow-canvas" className="definition-graph-canvas workflow-canvas"
onDragOver={(event) => { onDragOver={(event) => {
if ( if (
event.dataTransfer.types.includes( event.dataTransfer.types.includes(
@@ -320,7 +321,7 @@ export default function WorkflowCanvas({
<Controls showInteractive={false} /> <Controls showInteractive={false} />
</ReactFlow> </ReactFlow>
{!graph.nodes.length ? ( {!graph.nodes.length ? (
<div className="workflow-canvas-empty">Drop a start node here</div> <StatePanel className="definition-graph-canvas-empty" size="fill" description="Drop a start node here" />
) : null} ) : null}
</div> </div>
); );
@@ -1,10 +1,13 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Trash2 } from "lucide-react"; import { BookOpen, Trash2 } from "lucide-react";
import { import {
ActionToolbar,
Button, Button,
DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
FormField, FormField,
ReferenceMultiSelect, ReferenceMultiSelect,
StatePanel,
useViewSurfaces, useViewSurfaces,
type ReferenceOptionProvider type ReferenceOptionProvider
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
@@ -19,6 +22,8 @@ export default function WorkflowInspector({
edge, edge,
nodeLibrary, nodeLibrary,
readOnly, readOnly,
semanticDefinitionId,
semanticStepAvailable,
onChange, onChange,
onDelete, onDelete,
onEdgeChange, onEdgeChange,
@@ -28,6 +33,8 @@ export default function WorkflowInspector({
edge: WorkflowGraphEdge | null; edge: WorkflowGraphEdge | null;
nodeLibrary: WorkflowNodeType[]; nodeLibrary: WorkflowNodeType[];
readOnly: boolean; readOnly: boolean;
semanticDefinitionId: string | null;
semanticStepAvailable: boolean;
onChange: (node: WorkflowGraphNode) => void; onChange: (node: WorkflowGraphNode) => void;
onDelete: (nodeId: string) => void; onDelete: (nodeId: string) => void;
onEdgeChange: (edge: WorkflowGraphEdge) => void; onEdgeChange: (edge: WorkflowGraphEdge) => void;
@@ -87,7 +94,7 @@ export default function WorkflowInspector({
}; };
return ( return (
<aside className="workflow-inspector" aria-label="Flow inspector"> <aside className="workflow-inspector" aria-label="Flow inspector">
<div className="workflow-panel-heading"> <ActionToolbar surface="section-header" className="workflow-panel-heading">
<span> <span>
<strong>Flow</strong> <strong>Flow</strong>
<small>{edge.type.replace(/^bpmn\./, "")}</small> <small>{edge.type.replace(/^bpmn\./, "")}</small>
@@ -102,7 +109,7 @@ export default function WorkflowInspector({
> >
<Trash2 size={16} /> <Trash2 size={16} />
</Button> </Button>
</div> </ActionToolbar>
<div className="workflow-inspector-fields"> <div className="workflow-inspector-fields">
<FormField label="Flow type"> <FormField label="Flow type">
<select <select
@@ -184,8 +191,8 @@ export default function WorkflowInspector({
if (!node) { if (!node) {
return ( return (
<aside className="workflow-inspector" aria-label="Node inspector"> <aside className="workflow-inspector" aria-label="Node inspector">
<div className="workflow-panel-heading"><strong>Inspector</strong></div> <ActionToolbar surface="section-header" className="workflow-panel-heading"><strong>Inspector</strong></ActionToolbar>
<div className="workflow-inspector-empty">No node selected</div> <StatePanel size="compact" description="No node selected" />
</aside> </aside>
); );
} }
@@ -200,22 +207,41 @@ export default function WorkflowInspector({
return ( return (
<aside className="workflow-inspector" aria-label="Node inspector"> <aside className="workflow-inspector" aria-label="Node inspector">
<div className="workflow-panel-heading"> <ActionToolbar surface="section-header" className="workflow-panel-heading">
<span> <span>
<strong>Inspector</strong> <strong>Inspector</strong>
<small>{definition?.label ?? node.type}</small> <small>{definition?.label ?? node.type}</small>
</span> </span>
<Button <span className="workflow-inspector-actions">
variant="ghost" {semanticDefinitionId && semanticStepAvailable ? <>
className="workflow-inspector-delete" <DocumentationHelpLink
onClick={() => onDelete(node.id)} reference={{
disabled={readOnly} contextId: semanticHelpContext(semanticDefinitionId, node.id),
aria-label="Delete node" documentationType: "user"
title="Delete node" }}
> label="Open step meaning"
<Trash2 size={16} /> />
</Button> <a
</div> className="btn btn-secondary"
href={semanticAuthoringHref(semanticDefinitionId, node.id)}
target="_blank"
rel="noreferrer"
>
<BookOpen size={15} aria-hidden="true" /> Document meaning
</a>
</> : null}
<Button
variant="ghost"
className="workflow-inspector-delete"
onClick={() => onDelete(node.id)}
disabled={readOnly}
aria-label="Delete node"
title="Delete node"
>
<Trash2 size={16} />
</Button>
</span>
</ActionToolbar>
<div className="workflow-inspector-fields"> <div className="workflow-inspector-fields">
{error ? ( {error ? (
<DismissibleAlert tone="danger" resetKey={error}> <DismissibleAlert tone="danger" resetKey={error}>
@@ -330,3 +356,23 @@ function stringList(value: unknown): string[] {
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value); return Boolean(value) && typeof value === "object" && !Array.isArray(value);
} }
function semanticHelpContext(definitionId: string, nodeId: string): string {
return [
"semantic",
"workflow",
"workflow_definition",
definitionId,
`workflow-node-${nodeId}`
].join(".");
}
function semanticAuthoringHref(definitionId: string, nodeId: string): string {
const params = new URLSearchParams({
module: "workflow",
subjectKind: "workflow_definition",
subjectId: definitionId,
routeAnchor: `workflow-node-${nodeId}`
});
return `/docs/semantic?${params}`;
}
+6 -3
View File
@@ -48,6 +48,7 @@ import {
type Node, type Node,
type NodeProps type NodeProps
} from "@xyflow/react"; } from "@xyflow/react";
import { DefinitionNodeIcon } from "@govoplan/core-webui";
import type { WorkflowNodeType } from "../../api/workflow"; import type { WorkflowNodeType } from "../../api/workflow";
export type WorkflowFlowNodeData = { export type WorkflowFlowNodeData = {
@@ -103,6 +104,7 @@ const iconByName: Record<string, LucideIcon> = {
}; };
export default function WorkflowNode({ export default function WorkflowNode({
id,
data, data,
selected, selected,
isConnectable isConnectable
@@ -113,6 +115,7 @@ export default function WorkflowNode({
const outputPorts = data.definition.output_ports; const outputPorts = data.definition.output_ports;
return ( return (
<div <div
id={`workflow-node-${id}`}
className={[ className={[
"workflow-node", "workflow-node",
`workflow-node-${data.definition.category}`, `workflow-node-${data.definition.category}`,
@@ -127,13 +130,13 @@ export default function WorkflowNode({
id={port.id} id={port.id}
type="target" type="target"
position={Position.Left} position={Position.Left}
className="workflow-node-handle" className="definition-node-handle"
style={{ top: portPosition(index, inputPorts.length) }} style={{ top: portPosition(index, inputPorts.length) }}
isConnectable={isConnectable} isConnectable={isConnectable}
title={port.label} title={port.label}
/> />
))} ))}
<span className="workflow-node-icon"><Icon size={17} /></span> <DefinitionNodeIcon className="workflow-node-icon"><Icon size={17} /></DefinitionNodeIcon>
<span className="workflow-node-copy"> <span className="workflow-node-copy">
<strong>{data.label}</strong> <strong>{data.label}</strong>
<small>{data.definition.label}</small> <small>{data.definition.label}</small>
@@ -144,7 +147,7 @@ export default function WorkflowNode({
id={port.id} id={port.id}
type="source" type="source"
position={Position.Right} position={Position.Right}
className="workflow-node-handle" className="definition-node-handle"
style={{ top: portPosition(index, outputPorts.length) }} style={{ top: portPosition(index, outputPorts.length) }}
isConnectable={isConnectable} isConnectable={isConnectable}
title={port.label} title={port.label}
+172 -122
View File
@@ -8,13 +8,13 @@ import {
} from "react"; } from "react";
import { import {
Archive, Archive,
BookOpen,
CheckCircle2, CheckCircle2,
CopyPlus, CopyPlus,
Download, Download,
GitFork, GitFork,
ListChecks, ListChecks,
Plus, Plus,
RefreshCw,
RotateCcw, RotateCcw,
Save, Save,
Settings2, Settings2,
@@ -23,18 +23,32 @@ import {
} from "lucide-react"; } from "lucide-react";
import { ReactFlowProvider } from "@xyflow/react"; import { ReactFlowProvider } from "@xyflow/react";
import { useSearchParams } from "react-router"; import { useSearchParams } from "react-router";
import { import { ActionToolbar,
Button, Button,
ConfirmDialog, ConfirmDialog,
ContentGrid,
ContentSection,
Dialog, Dialog,
DocumentationHelpLink, DocumentationHelpLink,
DismissibleAlert, DismissibleAlert,
DefinitionPalette,
DefinitionPaletteGroup,
DefinitionPaletteItem,
FilterBar,
FloatingStatus,
FormField, FormField,
IconButton, IconButton,
LoadingFrame, LoadingFrame,
ReferenceSelect, ReferenceSelect,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatePanel,
StatusBadge, StatusBadge,
ToggleSwitch, ToggleSwitch,
WorkspaceActionBar,
WorkspaceFrame,
WorkspaceLayout,
hasScope, hasScope,
isApiError, isApiError,
useUnsavedChanges, useUnsavedChanges,
@@ -98,7 +112,7 @@ export default function WorkflowPage({
settings: ApiSettings; settings: ApiSettings;
auth: AuthInfo; auth: AuthInfo;
}) { }) {
const { requestNavigation } = useUnsavedChanges(); const { requestDiscard, requestNavigation } = useUnsavedChanges();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const requestedDefinitionId = searchParams.get("definition"); const requestedDefinitionId = searchParams.get("definition");
const requestedRunId = searchParams.get("run"); const requestedRunId = searchParams.get("run");
@@ -582,23 +596,24 @@ export default function WorkflowPage({
}; };
return ( return (
<main className="workflow-page"> <WorkspaceFrame as="main" height="viewport" surface="plain" className="workflow-page" label="Workflow workspace">
<div className="workflow-shell"> <WorkspaceLayout
<aside className="workflow-definition-panel"> variant="split"
<div className="workflow-panel-toolbar"> primarySize="compact"
<strong>Workflows</strong> surface="contained"
<span className="workflow-toolbar-actions"> primaryScrollable={false}
<DocumentationHelpLink contentScrollable={false}
reference={{ topicId: "workflow.editor", documentationType: "user" }} primaryLabel="Workflows"
label="Open Workflow documentation" contentLabel="Workflow editor"
/> contentClassName="workflow-workspace"
<IconButton primary={<>
label="Refresh" <WorkspaceActionBar
icon={<RefreshCw size={16} />} scope="collection-pane"
variant="ghost" variant="collection"
onClick={() => void reload(draft?.id)} refreshable
disabled={loading || working} reloadAction={{ onReload: () => void reload(draft?.id), loading: loading || working }}
/> contextActions={<>
<strong>Workflows</strong>
{hasScope(auth, "workflow:instance:admin") ? ( {hasScope(auth, "workflow:instance:admin") ? (
<IconButton <IconButton
label="Reconcile module standards" label="Reconcile module standards"
@@ -608,17 +623,21 @@ export default function WorkflowPage({
disabled={loading || working} disabled={loading || working}
/> />
) : null} ) : null}
<IconButton </>}
helpAction={<DocumentationHelpLink
reference={{ topicId: "workflow.editor", documentationType: "user" }}
label="Open Workflow documentation"
/>}
createAction={<IconButton
label="New workflow" label="New workflow"
icon={<Plus size={17} />} icon={<Plus size={17} />}
variant="primary" variant="primary"
onClick={createNew} onClick={createNew}
disabled={!canWrite} disabled={!canWrite}
disabledReason={!canWrite ? "Workflow definition write permission is required." : undefined} disabledReason={!canWrite ? "Workflow definition write permission is required." : undefined}
/> />}
</span> />
</div> <FilterBar surface="panel">
<div className="workflow-definition-search">
<input <input
type="search" type="search"
value={search} value={search}
@@ -626,53 +645,48 @@ export default function WorkflowPage({
placeholder="Search workflows" placeholder="Search workflows"
aria-label="Search workflows" aria-label="Search workflows"
/> />
</div> </FilterBar>
<LoadingFrame loading={loading} className="workflow-definition-list-frame"> <LoadingFrame loading={loading} className="workflow-definition-list-frame">
<div className="workflow-definition-list"> <SelectionList variant="navigation" label="Workflows">
{visibleDefinitions.map((definition) => ( {visibleDefinitions.map((definition) => (
<button <SelectionListItem
key={definition.id} key={definition.id}
type="button" selected={definition.id === draft?.id}
className={definition.id === draft?.id ? "is-selected" : ""}
onClick={() => selectDefinition(definition)} onClick={() => selectDefinition(definition)}
> >
<span> <SelectionListItemContent
<strong>{definition.name}</strong> title={definition.name}
<small> description={[
{definition.key} · revision {definition.current_revision} definition.key,
</small> `revision ${definition.current_revision}`,
<small> definition.governance.scope_type,
{definition.governance.scope_type} · {definition.governance.definition_kind} definition.governance.definition_kind,
</small> definition.standard
{definition.standard ? ( ? `${definition.standard.kind === "baseline" ? "Standard" : "Override"} · ${definition.standard.origin_module_id}${definition.standard.update_available ? " · update available" : ""}`
<small> : ""
{definition.standard.kind === "baseline" ].filter(Boolean).join(" · ")}
? `Standard · ${definition.standard.origin_module_id}` />
: `Override · ${definition.standard.origin_module_id}`}
{definition.standard.update_available ? " · update available" : ""}
</small>
) : null}
</span>
<StatusBadge <StatusBadge
status={definition.status} status={definition.status}
label={definition.status} label={definition.status}
/> />
</button> </SelectionListItem>
))} ))}
{!visibleDefinitions.length ? ( {!visibleDefinitions.length ? (
<div className="workflow-definition-empty"> <StatePanel size="compact" description="No workflow definitions" />
No workflow definitions
</div>
) : null} ) : null}
</div> </SelectionList>
</LoadingFrame> </LoadingFrame>
</aside> </>}
>
<section className="workflow-workspace">
{draft && displayedGraph ? ( {draft && displayedGraph ? (
<> <>
<div className="workflow-workspace-toolbar"> <WorkspaceActionBar
<span className="workflow-identity-fields"> scope="editor-pane"
variant="editor"
state={working ? "saving" : dirty && !draft.name.trim() ? "invalid" : dirty ? "dirty" : "clean"}
className="workflow-workspace-toolbar"
contextActions={<span className="workflow-identity-fields">
<input <input
className="workflow-name-input" className="workflow-name-input"
value={draft.name} value={draft.name}
@@ -694,8 +708,25 @@ export default function WorkflowPage({
placeholder="Description" placeholder="Description"
aria-label="Workflow description" aria-label="Workflow description"
/> />
</span> </span>}
<span className="workflow-command-bar"> helpAction={draft.id ? <>
<DocumentationHelpLink
reference={{
contextId: semanticHelpContext(draft.id),
documentationType: "user"
}}
label="Open workflow meaning"
/>
<a
className="btn btn-secondary"
href={semanticAuthoringHref(draft.id)}
target="_blank"
rel="noreferrer"
>
<BookOpen size={16} aria-hidden="true" /> Document meaning
</a>
</> : undefined}
primaryActions={<span className="workflow-command-bar">
{draft.id ? ( {draft.id ? (
<select <select
className="workflow-revision-select" className="workflow-revision-select"
@@ -839,26 +870,26 @@ export default function WorkflowPage({
<CheckCircle2 size={16} /> Activate <CheckCircle2 size={16} /> Activate
</Button> </Button>
) : null} ) : null}
<Button </span>}
variant="primary" destructiveActions={draft.id && draft.standard?.kind !== "baseline" ? (
onClick={() => void saveDraft()} <IconButton
disabled={ label="Delete workflow"
!canEdit || !dirty || working || readOnly icon={<Trash2 size={16} />}
} variant="danger"
> onClick={() => setDeleteOpen(true)}
<Save size={16} /> Save disabled={!canEdit || working}
</Button> />
{draft.id && draft.standard?.kind !== "baseline" ? ( ) : undefined}
<IconButton discardAction={{
label="Delete workflow" label: <><RotateCcw size={16} /> Discard</>,
icon={<Trash2 size={16} />} onClick: () => requestDiscard(() => void reload(draft.id))
variant="danger" }}
onClick={() => setDeleteOpen(true)} saveAction={{
disabled={!canEdit || working} label: <><Save size={16} /> Save</>,
/> onClick: () => void saveDraft(),
) : null} disabled: !canEdit || readOnly
</span> }}
</div> />
<div className="workflow-alerts"> <div className="workflow-alerts">
{error ? ( {error ? (
<DismissibleAlert tone="danger" resetKey={error}> <DismissibleAlert tone="danger" resetKey={error}>
@@ -872,21 +903,14 @@ export default function WorkflowPage({
) : null} ) : null}
</div> </div>
<div className="workflow-editor"> <div className="workflow-editor">
<aside className="workflow-palette"> <DefinitionPalette label="Node library" description="Drag nodes onto the canvas">
<div className="workflow-panel-heading">
<span>
<strong>Node library</strong>
<small>Drag nodes onto the canvas</small>
</span>
</div>
<div className="workflow-palette-items">
{paletteGroups.map((group) => ( {paletteGroups.map((group) => (
<section key={group.category} className="workflow-palette-group"> <DefinitionPaletteGroup key={group.category} label={group.label}>
<h3>{group.label}</h3>
{group.nodes.map((nodeType) => ( {group.nodes.map((nodeType) => (
<button <DefinitionPaletteItem
key={nodeType.type} key={nodeType.type}
type="button" icon={<GitFork size={16} />}
label={nodeType.label}
draggable={!graphReadOnly} draggable={!graphReadOnly}
disabled={graphReadOnly} disabled={graphReadOnly}
onDragStart={(event) => onDragStart={(event) =>
@@ -894,17 +918,12 @@ export default function WorkflowPage({
} }
onClick={() => addNodeFromPalette(nodeType.type)} onClick={() => addNodeFromPalette(nodeType.type)}
title={nodeType.description} title={nodeType.description}
> />
<GitFork size={16} />
<span>{nodeType.label}</span>
<Plus size={13} className="workflow-palette-add" />
</button>
))} ))}
</section> </DefinitionPaletteGroup>
))} ))}
</div> </DefinitionPalette>
</aside> <div className="definition-editor-surface workflow-editor-surface">
<div className="workflow-editor-surface">
<ReactFlowProvider> <ReactFlowProvider>
<WorkflowCanvas <WorkflowCanvas
graph={displayedGraph} graph={displayedGraph}
@@ -926,6 +945,15 @@ export default function WorkflowPage({
edge={selectedEdge} edge={selectedEdge}
nodeLibrary={nodeLibrary} nodeLibrary={nodeLibrary}
readOnly={graphReadOnly} readOnly={graphReadOnly}
semanticDefinitionId={draft.id || null}
semanticStepAvailable={Boolean(
draft.id
&& !historicalRevision
&& selectedNode
&& savedDraft?.graph.nodes.some(
(node) => node.id === selectedNode.id
)
)}
onChange={(node) => { onChange={(node) => {
if (!draft || graphReadOnly) return; if (!draft || graphReadOnly) return;
updateGraph(updateWorkflowGraphNode(draft.graph, node)); updateGraph(updateWorkflowGraphNode(draft.graph, node));
@@ -936,7 +964,7 @@ export default function WorkflowPage({
/> />
{diagnostics.length ? ( {diagnostics.length ? (
<div className="workflow-diagnostics"> <div className="workflow-diagnostics">
<div className="workflow-panel-heading"> <ActionToolbar surface="section-header" className="workflow-panel-heading">
<strong>Diagnostics</strong> <strong>Diagnostics</strong>
<StatusBadge <StatusBadge
status={ status={
@@ -946,7 +974,7 @@ export default function WorkflowPage({
} }
label={String(diagnostics.length)} label={String(diagnostics.length)}
/> />
</div> </ActionToolbar>
<div> <div>
{diagnostics.map((item, index) => ( {diagnostics.map((item, index) => (
<button <button
@@ -965,25 +993,21 @@ export default function WorkflowPage({
) : null} ) : null}
</div> </div>
</div> </div>
{working ? ( {working ? <FloatingStatus>Working...</FloatingStatus> : null}
<div className="workflow-working-indicator" role="status">
Working...
</div>
) : null}
</> </>
) : ( ) : (
<div className="workflow-workspace-empty"> <StatePanel
<GitFork size={34} /> size="fill"
<strong>No workflow selected</strong> icon={<GitFork size={34} />}
{canWrite ? ( title="No workflow selected"
actions={canWrite ? (
<Button variant="primary" onClick={createNew}> <Button variant="primary" onClick={createNew}>
<Plus size={16} /> New workflow <Plus size={16} /> New workflow
</Button> </Button>
) : null} ) : undefined}
</div> />
)} )}
</section> </WorkspaceLayout>
</div>
<ConfirmDialog <ConfirmDialog
open={deleteOpen} open={deleteOpen}
title="Delete workflow" title="Delete workflow"
@@ -1052,10 +1076,36 @@ export default function WorkflowPage({
} }
}} }}
/> />
</main> </WorkspaceFrame>
); );
} }
function semanticHelpContext(
definitionId: string,
routeAnchor?: string
): string {
return [
"semantic",
"workflow",
"workflow_definition",
definitionId,
routeAnchor
].filter(Boolean).join(".");
}
function semanticAuthoringHref(
definitionId: string,
routeAnchor?: string
): string {
const params = new URLSearchParams({
module: "workflow",
subjectKind: "workflow_definition",
subjectId: definitionId
});
if (routeAnchor) params.set("routeAnchor", routeAnchor);
return `/docs/semantic?${params}`;
}
function WorkflowStandardComparisonDialog({ function WorkflowStandardComparisonDialog({
open, open,
settings, settings,
@@ -1336,7 +1386,7 @@ function WorkflowDefinitionSettingsDialog({
))} ))}
</select> </select>
</FormField> </FormField>
<div className="workflow-definition-toggles"> <ContentGrid columns={2} gap="default" collapseAt="narrow">
<ToggleSwitch <ToggleSwitch
label="Visible to lower scopes" label="Visible to lower scopes"
checked={draft.inheritToLowerScopes} checked={draft.inheritToLowerScopes}
@@ -1361,7 +1411,7 @@ function WorkflowDefinitionSettingsDialog({
disabled={!editable || draft.executionMode === "guided"} disabled={!editable || draft.executionMode === "guided"}
onChange={(value) => onChange({ allowAutomation: value })} onChange={(value) => onChange({ allowAutomation: value })}
/> />
</div> </ContentGrid>
{draft.governance?.automation_runtime_reason ? ( {draft.governance?.automation_runtime_reason ? (
<DismissibleAlert <DismissibleAlert
tone="warning" tone="warning"
@@ -1371,7 +1421,7 @@ function WorkflowDefinitionSettingsDialog({
</DismissibleAlert> </DismissibleAlert>
) : null} ) : null}
{draft.governance?.derived_from_definition_id ? ( {draft.governance?.derived_from_definition_id ? (
<section className="workflow-provenance"> <ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="workflow-provenance">
<strong>Derived from</strong> <strong>Derived from</strong>
<span> <span>
{draft.governance.derived_from_definition_id} {draft.governance.derived_from_definition_id}
@@ -1379,10 +1429,10 @@ function WorkflowDefinitionSettingsDialog({
{draft.governance.derived_from_revision} {draft.governance.derived_from_revision}
</span> </span>
<code>{draft.governance.derived_from_hash}</code> <code>{draft.governance.derived_from_hash}</code>
</section> </ContentSection>
) : null} ) : null}
{provenance.length ? ( {provenance.length ? (
<section className="workflow-provenance"> <ContentSection spacing="none" surface="subtle" density="compact" layout="stack" className="workflow-provenance">
<strong>Effective Policy path</strong> <strong>Effective Policy path</strong>
{provenance.map((item, index) => ( {provenance.map((item, index) => (
<span key={`${String(item.path ?? item.scope_type)}-${index}`}> <span key={`${String(item.path ?? item.scope_type)}-${index}`}>
@@ -1392,7 +1442,7 @@ function WorkflowDefinitionSettingsDialog({
{!editable && draft.governance?.actions.edit?.reason ? ( {!editable && draft.governance?.actions.edit?.reason ? (
<small>{draft.governance.actions.edit.reason}</small> <small>{draft.governance.actions.edit.reason}</small>
) : null} ) : null}
</section> </ContentSection>
) : null} ) : null}
</div> </div>
) : null} ) : null}
@@ -15,11 +15,12 @@ import {
RotateCcw, RotateCcw,
XCircle XCircle
} from "lucide-react"; } from "lucide-react";
import { import { ActionToolbar,
Button, Button,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
ContentGrid,
FormField, FormField,
IconButton, IconButton,
LoadingFrame, LoadingFrame,
@@ -284,6 +285,13 @@ export default function WorkflowRunsDialog({
const actionUrl = typeof currentStep?.handoff.action_url === "string" const actionUrl = typeof currentStep?.handoff.action_url === "string"
? currentStep.handoff.action_url ? currentStep.handoff.action_url
: ""; : "";
const unavailableOptionalCapabilities = Array.isArray(
currentStep?.handoff.unavailable_optional_capabilities
)
? currentStep.handoff.unavailable_optional_capabilities.filter(
(value): value is string => typeof value === "string" && Boolean(value)
)
: [];
const close = () => { const close = () => {
dispatchWorkflowViewChanged(null); dispatchWorkflowViewChanged(null);
onClose(); onClose();
@@ -299,7 +307,7 @@ export default function WorkflowRunsDialog({
onClose={close} onClose={close}
footer={<Button onClick={close}>Close</Button>} footer={<Button onClick={close}>Close</Button>}
> >
<div className="workflow-runs-toolbar"> <ActionToolbar className="workflow-runs-toolbar">
<span> <span>
<strong>Workflow instances</strong> <strong>Workflow instances</strong>
<small>Revision-pinned runs and human handoffs</small> <small>Revision-pinned runs and human handoffs</small>
@@ -325,7 +333,7 @@ export default function WorkflowRunsDialog({
<Play size={16} /> Start <Play size={16} /> Start
</Button> </Button>
</span> </span>
</div> </ActionToolbar>
{error ? ( {error ? (
<DismissibleAlert tone="danger" resetKey={error}> <DismissibleAlert tone="danger" resetKey={error}>
{error} {error}
@@ -389,6 +397,15 @@ export default function WorkflowRunsDialog({
{selected.error} {selected.error}
</DismissibleAlert> </DismissibleAlert>
) : null} ) : null}
{selected.view_context && !viewsRuntime ? (
<DismissibleAlert
tone="warning"
resetKey={`view-unavailable:${selected.id}:${selected.current_step_id ?? "none"}`}
>
The focused View is unavailable. Workflow position is preserved;
use the linked module action or ask an administrator to enable Views.
</DismissibleAlert>
) : null}
{currentStep ? ( {currentStep ? (
<section className="workflow-run-handoff"> <section className="workflow-run-handoff">
<div> <div>
@@ -416,13 +433,22 @@ export default function WorkflowRunsDialog({
&& currentStep.handoff.instructions ? ( && currentStep.handoff.instructions ? (
<p>{currentStep.handoff.instructions}</p> <p>{currentStep.handoff.instructions}</p>
) : null} ) : null}
{unavailableOptionalCapabilities.length ? (
<DismissibleAlert
tone="info"
resetKey={`optional:${currentStep.id}:${unavailableOptionalCapabilities.join(",")}`}
>
Optional integration unavailable: {unavailableOptionalCapabilities.join(", ")}.
The hand-off remains valid and resumable.
</DismissibleAlert>
) : null}
{actionUrl ? ( {actionUrl ? (
<a href={actionUrl}> <a href={actionUrl}>
Open linked Dataflow result <ExternalLink size={14} /> Open linked work <ExternalLink size={14} />
</a> </a>
) : null} ) : null}
{allowedActions.some((action) => action !== "cancel") ? ( {allowedActions.some((action) => action !== "cancel") ? (
<div className="workflow-run-action-form"> <ContentGrid columns={2} gap="compact" collapseAt="narrow" className="workflow-run-action-form">
<FormField label="Comment"> <FormField label="Comment">
<textarea <textarea
value={comment} value={comment}
@@ -466,7 +492,7 @@ export default function WorkflowRunsDialog({
</Button> </Button>
))} ))}
</div> </div>
</div> </ContentGrid>
) : null} ) : null}
</section> </section>
) : null} ) : null}
+17 -418
View File
@@ -1,14 +1,3 @@
.workflow-page {
position: relative;
height: calc(100vh - 115px);
min-width: 0;
min-height: 0;
padding: 0;
overflow: hidden;
color: var(--text);
background: var(--bg);
}
.workflow-definition-dialog { .workflow-definition-dialog {
width: min(620px, calc(100vw - 32px)); width: min(620px, calc(100vw - 32px));
} }
@@ -109,21 +98,6 @@
width: 100%; width: 100%;
} }
.workflow-definition-toggles {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
}
.workflow-provenance {
display: grid;
gap: 6px;
padding: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
.workflow-provenance span, .workflow-provenance span,
.workflow-provenance small { .workflow-provenance small {
color: var(--muted); color: var(--muted);
@@ -136,34 +110,8 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@media (max-width: 640px) {
.workflow-definition-toggles {
grid-template-columns: 1fr;
}
}
.workflow-page *,
.workflow-page *::before,
.workflow-page *::after {
box-sizing: border-box;
}
.workflow-shell {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr);
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
border: var(--border-line);
background: var(--panel);
}
.workflow-definition-panel,
.workflow-workspace, .workflow-workspace,
.workflow-editor, .workflow-editor,
.workflow-editor-surface,
.workflow-canvas, .workflow-canvas,
.workflow-definition-list-frame, .workflow-definition-list-frame,
.workflow-inspector-column { .workflow-inspector-column {
@@ -171,31 +119,6 @@
min-height: 0; min-height: 0;
} }
.workflow-definition-panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: var(--border-line);
background: var(--panel-soft);
}
.workflow-panel-toolbar,
.workflow-workspace-toolbar,
.workflow-panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex: 0 0 auto;
border-bottom: var(--border-line);
background: var(--panel-header);
}
.workflow-panel-toolbar {
min-height: 52px;
padding: 8px 10px 8px 14px;
}
.workflow-toolbar-actions, .workflow-toolbar-actions,
.workflow-command-bar, .workflow-command-bar,
.workflow-identity-fields { .workflow-identity-fields {
@@ -204,98 +127,12 @@
gap: 7px; gap: 7px;
} }
.workflow-definition-search {
padding: 10px;
border-bottom: var(--border-line);
}
.workflow-definition-search input {
min-height: 34px;
padding: 7px 9px;
}
.workflow-definition-list-frame { .workflow-definition-list-frame {
flex: 1 1 auto; flex: 1 1 auto;
overflow: hidden; overflow: hidden;
} }
.workflow-definition-list {
height: 100%;
overflow: auto;
padding: 6px;
}
.workflow-definition-list > button {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
width: 100%;
min-height: 56px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: pointer;
padding: 8px 9px;
text-align: left;
}
.workflow-definition-list > button:hover,
.workflow-definition-list > button:focus-visible {
background: var(--primary-soft);
outline: none;
}
.workflow-definition-list > button.is-selected {
background: var(--primary-soft-strong);
box-shadow: inset 3px 0 0 var(--accent);
}
.workflow-definition-list > button > span:first-child {
min-width: 0;
}
.workflow-definition-list strong,
.workflow-definition-list small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-definition-list strong {
color: var(--text-strong);
font-size: 13px;
}
.workflow-definition-list small {
margin-top: 4px;
color: var(--muted);
font-size: 11px;
}
.workflow-definition-empty,
.workflow-inspector-empty {
display: grid;
min-height: 100px;
place-items: center;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.workflow-workspace {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.workflow-workspace-toolbar { .workflow-workspace-toolbar {
min-height: 58px; min-width: 0;
padding: 8px 10px;
} }
.workflow-identity-fields { .workflow-identity-fields {
@@ -364,17 +201,12 @@
display: none; display: none;
} }
.workflow-palette,
.workflow-inspector, .workflow-inspector,
.workflow-inspector-column { .workflow-inspector-column {
overflow: hidden; overflow: hidden;
background: var(--panel-soft); background: var(--panel-soft);
} }
.workflow-palette {
border-right: var(--border-line);
}
.workflow-inspector-column { .workflow-inspector-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -388,11 +220,6 @@
flex-direction: column; flex-direction: column;
} }
.workflow-panel-heading {
min-height: 44px;
padding: 8px 10px 8px 12px;
}
.workflow-panel-heading > span { .workflow-panel-heading > span {
min-width: 0; min-width: 0;
} }
@@ -416,132 +243,6 @@
white-space: nowrap; white-space: nowrap;
} }
.workflow-palette-items {
display: grid;
grid-auto-rows: max-content;
align-content: start;
height: calc(100% - 44px);
gap: 4px;
overflow-x: hidden;
overflow-y: auto;
padding: 8px;
scrollbar-gutter: stable;
}
.workflow-palette-group {
display: grid;
grid-auto-rows: max-content;
align-content: start;
gap: 2px;
}
.workflow-palette-group + .workflow-palette-group {
margin-top: 6px;
padding-top: 8px;
border-top: var(--border-line);
}
.workflow-palette-group h3 {
margin: 0;
color: var(--muted);
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
padding: 3px 8px;
text-transform: uppercase;
}
.workflow-palette-items button {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 14px;
align-items: center;
gap: 7px;
min-height: 38px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
cursor: grab;
font: inherit;
font-size: 12px;
padding: 7px 8px;
text-align: left;
}
.workflow-palette-items button:hover:not(:disabled),
.workflow-palette-items button:focus-visible:not(:disabled) {
background: var(--primary-soft);
color: var(--text-strong);
outline: none;
}
.workflow-palette-items button:disabled {
cursor: default;
opacity: .42;
}
.workflow-palette-add {
color: var(--accent);
}
.workflow-editor-surface {
position: relative;
overflow: hidden;
background: var(--bg);
}
.workflow-canvas {
position: absolute;
inset: 0;
}
.workflow-canvas .react-flow {
background: var(--bg);
}
.workflow-canvas .react-flow__background {
color: var(--line-dark);
}
.workflow-canvas .react-flow__controls,
.workflow-canvas .react-flow__minimap {
overflow: hidden;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel);
box-shadow: var(--shadow-xs);
}
.workflow-canvas .react-flow__controls-button {
border-bottom: var(--border-line);
background: var(--panel);
color: var(--text);
}
.workflow-canvas .react-flow__minimap-mask {
fill: color-mix(in srgb, var(--bg) 76%, transparent);
}
.workflow-canvas .react-flow__edge-path {
stroke: var(--line-dark);
stroke-width: 2;
}
.workflow-canvas .react-flow__edge.selected .react-flow__edge-path,
.workflow-canvas .react-flow__edge:hover .react-flow__edge-path {
stroke: var(--accent);
stroke-width: 3;
}
.workflow-canvas-empty {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--muted);
pointer-events: none;
}
.workflow-node { .workflow-node {
position: relative; position: relative;
display: flex; display: flex;
@@ -552,7 +253,7 @@
min-height: 56px; min-height: 56px;
box-sizing: border-box; box-sizing: border-box;
border: 1px solid var(--line-dark); border: 1px solid var(--line-dark);
border-radius: 6px; border-radius: var(--radius-compact);
background: var(--panel); background: var(--panel);
box-shadow: var(--shadow-xs); box-shadow: var(--shadow-xs);
padding: 8px 10px; padding: 8px 10px;
@@ -606,7 +307,7 @@
height: 42px; height: 42px;
flex-basis: 42px; flex-basis: 42px;
border: 2px solid var(--text-strong); border: 2px solid var(--text-strong);
border-radius: 50%; border-radius: var(--radius-round);
background: var(--panel); background: var(--panel);
} }
@@ -628,7 +329,7 @@
height: 42px; height: 42px;
flex-basis: 42px; flex-basis: 42px;
border: 2px solid var(--text-strong); border: 2px solid var(--text-strong);
border-radius: 2px; border-radius: var(--radius-hairline);
background: var(--panel); background: var(--panel);
transform: rotate(45deg); transform: rotate(45deg);
} }
@@ -671,17 +372,6 @@
border-color: var(--danger-text); border-color: var(--danger-text);
} }
.workflow-node-icon {
display: grid;
width: 28px;
height: 28px;
flex: 0 0 28px;
place-items: center;
border-radius: 5px;
background: var(--panel-soft);
color: var(--text-strong);
}
.workflow-node-copy { .workflow-node-copy {
min-width: 0; min-width: 0;
flex: 1 1 auto; flex: 1 1 auto;
@@ -706,21 +396,6 @@
font-size: 10px; font-size: 10px;
} }
.workflow-node-handle {
width: 13px;
height: 13px;
border: 3px solid var(--panel);
background: var(--line-dark);
}
.workflow-node-handle:hover,
.workflow-node-handle.connectingto,
.workflow-node-handle.valid {
width: 17px;
height: 17px;
background: var(--accent);
}
.workflow-edge-bpmn-messageFlow .react-flow__edge-path, .workflow-edge-bpmn-messageFlow .react-flow__edge-path,
.workflow-edge-bpmn-association .react-flow__edge-path, .workflow-edge-bpmn-association .react-flow__edge-path,
.workflow-edge-bpmn-conversationLink .react-flow__edge-path { .workflow-edge-bpmn-conversationLink .react-flow__edge-path {
@@ -776,6 +451,18 @@
color: var(--danger-text); color: var(--danger-text);
} }
.workflow-inspector-actions {
display: inline-flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 5px;
}
.workflow-inspector-actions .btn {
white-space: nowrap;
}
.workflow-diagnostics { .workflow-diagnostics {
display: flex; display: flex;
max-height: 38%; max-height: 38%;
@@ -821,39 +508,6 @@
font-size: 9px; font-size: 9px;
} }
.workflow-workspace-empty {
display: grid;
height: 100%;
place-content: center;
justify-items: center;
gap: 12px;
color: var(--muted);
}
.workflow-workspace-empty strong {
color: var(--text-strong);
}
.workflow-workspace-empty .btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.workflow-working-indicator {
position: absolute;
z-index: 20;
right: 12px;
bottom: 10px;
border: var(--border-line-dark);
border-radius: var(--radius-sm);
background: var(--panel);
box-shadow: var(--shadow-popover);
color: var(--text-strong);
font-size: 12px;
padding: 7px 10px;
}
.workflow-runs-dialog { .workflow-runs-dialog {
width: min(1120px, calc(100vw - 32px)); width: min(1120px, calc(100vw - 32px));
height: min(760px, calc(100vh - 32px)); height: min(760px, calc(100vh - 32px));
@@ -1041,12 +695,6 @@
font-size: 12px; font-size: 12px;
} }
.workflow-run-action-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.workflow-run-action-form textarea { .workflow-run-action-form textarea {
width: 100%; width: 100%;
resize: vertical; resize: vertical;
@@ -1106,11 +754,7 @@
text-align: center; text-align: center;
} }
@media (max-width: 1180px) { @media (max-width: 1280px) {
.workflow-shell {
grid-template-columns: 240px minmax(0, 1fr);
}
.workflow-workspace-toolbar { .workflow-workspace-toolbar {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
@@ -1127,10 +771,6 @@
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.workflow-shell {
grid-template-columns: 210px minmax(0, 1fr);
}
.workflow-editor { .workflow-editor {
grid-template-columns: 140px minmax(0, 1fr); grid-template-columns: 140px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(180px, 34%); grid-template-rows: minmax(0, 1fr) minmax(180px, 34%);
@@ -1149,16 +789,6 @@
height: calc(100vh - 94px); height: calc(100vh - 94px);
} }
.workflow-shell {
grid-template-columns: 1fr;
grid-template-rows: minmax(150px, 24%) minmax(0, 1fr);
}
.workflow-definition-panel {
border-right: 0;
border-bottom: var(--border-line);
}
.workflow-identity-fields, .workflow-identity-fields,
.workflow-command-bar { .workflow-command-bar {
flex-wrap: wrap; flex-wrap: wrap;
@@ -1175,33 +805,6 @@
grid-template-rows: auto minmax(0, 1fr) minmax(180px, 32%); grid-template-rows: auto minmax(0, 1fr) minmax(180px, 32%);
} }
.workflow-palette {
border-right: 0;
border-bottom: var(--border-line);
}
.workflow-palette .workflow-panel-heading {
display: none;
}
.workflow-palette-items {
display: flex;
overflow-x: auto;
}
.workflow-palette-group {
display: flex;
flex: 0 0 auto;
}
.workflow-palette-group h3 {
display: none;
}
.workflow-palette-items button {
min-width: 128px;
}
.workflow-runs-layout { .workflow-runs-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: minmax(120px, 28%) minmax(0, 1fr); grid-template-rows: minmax(120px, 28%) minmax(0, 1fr);
@@ -1212,10 +815,6 @@
border-bottom: var(--border-line); border-bottom: var(--border-line);
} }
.workflow-run-action-form {
grid-template-columns: 1fr;
}
.workflow-run-history > div > span, .workflow-run-history > div > span,
.workflow-run-events > div > span { .workflow-run-events > div > span {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;