diff --git a/src/backend/src/routes/workflows_routes.py b/src/backend/src/routes/workflows_routes.py index 100739eb..4508db68 100644 --- a/src/backend/src/routes/workflows_routes.py +++ b/src/backend/src/routes/workflows_routes.py @@ -74,6 +74,176 @@ async def get_step_types( return manager.get_step_type_schemas() +# --------------------------------------------------------------------------- +# Trigger-type catalog +# --------------------------------------------------------------------------- +# +# Returns a UI-friendly catalog of every TriggerType enum member so the +# workflow-authoring form can: +# 1. Render human-readable labels (no more raw enum values in the picker). +# 2. Group triggers (lifecycle / request_flow / validation_gates / +# system_scheduled). +# 3. Show approval (for_*) vs process (on_* / before_* / scheduled / manual) +# based on the workflow being authored. +# 4. Pre-populate the entity_types multiselect with the entity types each +# trigger is wired for in the backend. +# +# Wire-format contract: every TriggerType member appears exactly once. +# `value` is the raw enum string (used as the FK in stored trigger configs). +# Labels here are the canonical source of truth; the FE getTriggerLabel() +# helper mirrors them so existing labels keep working if the endpoint is +# unavailable. +# +# entity_types mapping mirrors SUPPORTED_TRIGGER_ENTITY_MAP in the FE +# (src/frontend/src/lib/workflow-labels.ts) — keep them in sync. An empty +# list means "any entity type" (manual, scheduled, on_first_access). + +_TRIGGER_LABELS: Dict[str, str] = { + "for_subscribe": "When a user subscribes", + "on_subscribe": "After a subscription is created", + "for_request_access": "When a user requests access", + "on_request_access": "After an access request is submitted", + "for_request_review": "When a user requests review", + "on_request_review": "After a review request is submitted", + "for_request_publish": "When a user requests publish", + "on_request_publish": "After a publish request is submitted", + "for_request_certify": "When a user requests certification", + "on_request_certify": "After a certification request is submitted", + "for_request_status_change": "When a user requests status change", + "on_request_status_change": "After a status change request is submitted", + "for_approval_response": "Approval response dialog", + "before_create": "Before entity is created (validation)", + "before_update": "Before entity is updated (validation)", + "before_status_change": "Before status changes (validation)", + "on_create": "After entity is created", + "on_update": "After entity is updated", + "on_delete": "After entity is deleted", + "on_status_change": "After status changes", + "on_publish": "After entity is published", + "on_unpublish": "After entity is unpublished", + "on_revoke": "After access is revoked", + "on_expiring": "When access is about to expire", + "on_first_access": "First time a user accesses (consent)", + "on_unsubscribe": "After a user unsubscribes", + "on_job_success": "After a background job succeeds", + "on_job_failure": "After a background job fails", + "scheduled": "On a schedule (cron)", + # Fallbacks for enum members that are not part of the user-approved table. + # These keep the catalog 1:1 with the enum without expanding the table. + "manual": "Manually triggered", + "on_certify": "After entity is certified", + "on_decertify": "After entity is decertified", +} + +# Group assignment per the design brief. Anything not listed falls back to +# "lifecycle" for process workflows. +_TRIGGER_GROUPS_REQUEST_FLOW = { + "for_subscribe", "for_request_access", "for_request_review", + "for_request_publish", "for_request_certify", + "for_request_status_change", "for_approval_response", + "on_subscribe", "on_unsubscribe", + "on_request_access", "on_request_review", "on_request_publish", + "on_request_certify", "on_request_status_change", + "on_revoke", "on_expiring", "on_first_access", +} +_TRIGGER_GROUPS_LIFECYCLE = { + "on_create", "on_update", "on_delete", "on_status_change", + "on_publish", "on_unpublish", + # on_certify / on_decertify are lifecycle in spirit — they describe an + # entity transitioning state, not a request flow. + "on_certify", "on_decertify", +} +_TRIGGER_GROUPS_VALIDATION = { + "before_create", "before_update", "before_status_change", +} +_TRIGGER_GROUPS_SYSTEM_SCHEDULED = { + "on_job_success", "on_job_failure", "scheduled", "manual", +} + +# Entity types each trigger CAN fire for, derived from the dispatch sites in +# src/backend/src/common/workflow_triggers.py and the existing FE map +# (SUPPORTED_TRIGGER_ENTITY_MAP). Empty list = "any entity" (no constraint). +_TRIGGER_ENTITY_TYPES: Dict[str, List[str]] = { + # CRUD / lifecycle + "on_create": ["catalog", "schema", "table", "data_contract", "data_product", "domain"], + "on_update": ["data_contract", "data_product", "domain"], + "on_delete": ["data_contract", "data_product", "domain"], + "on_status_change": ["data_contract", "data_product", "data_asset_review"], + "on_publish": ["data_contract", "data_product"], + "on_unpublish": ["data_contract", "data_product"], + "on_certify": ["data_contract", "data_product"], + "on_decertify": ["data_contract", "data_product"], + # Validation gates + "before_create": ["catalog", "schema", "table"], + "before_update": ["data_contract"], + "before_status_change": ["data_contract", "data_product"], + # Request flow — process side + "on_request_review": ["data_contract", "data_product", "data_asset_review"], + "on_request_access": ["access_grant", "project", "role"], + "on_request_publish": ["data_contract", "data_product"], + "on_request_certify": ["data_contract", "data_product"], + "on_request_status_change": ["data_product"], + "on_subscribe": ["subscription", "data_product", "data_contract"], + "on_unsubscribe": ["subscription", "data_product", "data_contract"], + "on_revoke": ["access_grant"], + "on_expiring": ["access_grant"], + "on_first_access": ["user"], + # Request flow — approval (for_*) side. These mirror the matching on_* + # trigger's entity scope so the wizard targets the same kinds of objects. + "for_subscribe": ["data_product", "data_contract"], + "for_request_access": ["access_grant", "project", "role"], + "for_request_review": ["data_contract", "data_product", "data_asset_review"], + "for_request_publish": ["data_contract", "data_product"], + "for_request_certify": ["data_contract", "data_product"], + "for_request_status_change": ["data_product"], + "for_approval_response": [], # system trigger — any entity + # Background jobs + "on_job_success": ["job"], + "on_job_failure": ["job"], + # Scheduled / manual — no entity binding + "scheduled": [], + "manual": [], +} + + +def _trigger_group(value: str) -> str: + if value in _TRIGGER_GROUPS_REQUEST_FLOW: + return "request_flow" + if value in _TRIGGER_GROUPS_VALIDATION: + return "validation_gates" + if value in _TRIGGER_GROUPS_SYSTEM_SCHEDULED: + return "system_scheduled" + if value in _TRIGGER_GROUPS_LIFECYCLE: + return "lifecycle" + # Safe fallback — should never hit if mappings stay in sync with the enum. + return "lifecycle" + + +@router.get("/trigger-types", response_model=List[Dict[str, Any]]) +async def get_trigger_types( + request: Request, + _: bool = Depends(PermissionChecker('settings', FeatureAccessLevel.READ_ONLY)), +) -> List[Dict[str, Any]]: + """Get the UI catalog of all trigger types. + + Returns one entry per TriggerType enum member with the metadata the + workflow-authoring picker needs (label, group, workflow_type, + entity_types). See module-level comments for the contract. + """ + out: List[Dict[str, Any]] = [] + for tt in TriggerType: + value = tt.value + workflow_type = "approval" if value.startswith("for_") else "process" + out.append({ + "value": value, + "label": _TRIGGER_LABELS.get(value, value.replace("_", " ").title()), + "workflow_type": workflow_type, + "entity_types": list(_TRIGGER_ENTITY_TYPES.get(value, [])), + "group": _trigger_group(value), + }) + return out + + @router.get("/executions", response_model=WorkflowExecutionListResponse) async def list_executions( request: Request, diff --git a/src/backend/src/tests/unit/test_trigger_enum_pin.py b/src/backend/src/tests/unit/test_trigger_enum_pin.py new file mode 100644 index 00000000..2f6d8589 --- /dev/null +++ b/src/backend/src/tests/unit/test_trigger_enum_pin.py @@ -0,0 +1,84 @@ +"""Enum-pin test for TriggerType. + +The wire format of `trigger.type` in stored workflow definitions is the raw +string value of each TriggerType enum member. Renaming any of these values is +a breaking change for every existing workflow row in customer databases — so +this test pins the exact strings. + +If you genuinely need to add a new trigger, append it here. If you think you +need to rename an existing one, write a migration instead. +""" + +import pytest + +from src.models.process_workflows import TriggerType + + +# (Enum-member-name, expected wire value) — one tuple per TriggerType member. +# Keep this list in sync with the enum; the test below also asserts no enum +# member is missing from the list. +_EXPECTED_TRIGGER_VALUES = [ + ("ON_CREATE", "on_create"), + ("ON_UPDATE", "on_update"), + ("ON_DELETE", "on_delete"), + ("ON_STATUS_CHANGE", "on_status_change"), + ("SCHEDULED", "scheduled"), + ("MANUAL", "manual"), + ("BEFORE_CREATE", "before_create"), + ("BEFORE_UPDATE", "before_update"), + ("BEFORE_STATUS_CHANGE", "before_status_change"), + ("ON_REQUEST_REVIEW", "on_request_review"), + ("ON_REQUEST_ACCESS", "on_request_access"), + ("ON_REQUEST_PUBLISH", "on_request_publish"), + ("ON_REQUEST_STATUS_CHANGE", "on_request_status_change"), + ("ON_JOB_SUCCESS", "on_job_success"), + ("ON_JOB_FAILURE", "on_job_failure"), + ("ON_SUBSCRIBE", "on_subscribe"), + ("ON_UNSUBSCRIBE", "on_unsubscribe"), + ("ON_REQUEST_CERTIFY", "on_request_certify"), + ("ON_CERTIFY", "on_certify"), + ("ON_DECERTIFY", "on_decertify"), + ("ON_PUBLISH", "on_publish"), + ("ON_UNPUBLISH", "on_unpublish"), + ("ON_EXPIRING", "on_expiring"), + ("ON_REVOKE", "on_revoke"), + ("FOR_APPROVAL_RESPONSE", "for_approval_response"), + ("FOR_SUBSCRIBE", "for_subscribe"), + ("FOR_REQUEST_REVIEW", "for_request_review"), + ("FOR_REQUEST_ACCESS", "for_request_access"), + ("FOR_REQUEST_PUBLISH", "for_request_publish"), + ("FOR_REQUEST_CERTIFY", "for_request_certify"), + ("FOR_REQUEST_STATUS_CHANGE", "for_request_status_change"), + ("ON_FIRST_ACCESS", "on_first_access"), +] + + +@pytest.mark.parametrize(("member_name", "wire_value"), _EXPECTED_TRIGGER_VALUES) +def test_trigger_value_is_pinned(member_name: str, wire_value: str) -> None: + """Each TriggerType member must keep its exact wire-format string.""" + member = getattr(TriggerType, member_name) + assert member.value == wire_value, ( + f"TriggerType.{member_name}.value changed from '{wire_value}' to " + f"'{member.value}' — this is a breaking change for stored workflow " + f"trigger configs. Add a migration instead of renaming the enum value." + ) + + +def test_all_enum_members_are_pinned() -> None: + """Every TriggerType member must appear in the pin table. + + Catches the case where a new trigger is added to the enum but its wire + value is not vetted here. + """ + enum_names = {m.name for m in TriggerType} + pinned_names = {name for name, _ in _EXPECTED_TRIGGER_VALUES} + missing = enum_names - pinned_names + extra = pinned_names - enum_names + assert not missing, ( + f"New TriggerType members missing from the pin table: {sorted(missing)}. " + f"Add them to _EXPECTED_TRIGGER_VALUES in this file." + ) + assert not extra, ( + f"Pin table references TriggerType members that no longer exist: " + f"{sorted(extra)}. Did you delete the enum value?" + ) diff --git a/src/backend/src/tests/unit/test_trigger_types_endpoint.py b/src/backend/src/tests/unit/test_trigger_types_endpoint.py new file mode 100644 index 00000000..6fc2e682 --- /dev/null +++ b/src/backend/src/tests/unit/test_trigger_types_endpoint.py @@ -0,0 +1,91 @@ +"""Contract test for GET /api/workflows/trigger-types. + +Asserts the response shape and that every TriggerType enum member is +represented exactly once. The endpoint is the canonical catalog consumed by +the frontend workflow-authoring picker — drift here breaks the UX without +breaking type checks. +""" + +import asyncio +from typing import Any, Dict, List +from unittest.mock import MagicMock + +import pytest + +from src.models.process_workflows import TriggerType +from src.routes.workflows_routes import get_trigger_types + + +def _call_endpoint() -> List[Dict[str, Any]]: + """Invoke the async route handler directly, bypassing FastAPI auth.""" + # PermissionChecker is a Depends() — bypassed by calling the underlying + # coroutine directly with positional args. request is unused by the + # handler body, so a MagicMock is sufficient. + return asyncio.get_event_loop().run_until_complete( + get_trigger_types(request=MagicMock(), _=True) + ) + + +def test_every_trigger_type_represented_exactly_once() -> None: + payload = _call_endpoint() + values = [entry["value"] for entry in payload] + expected = {tt.value for tt in TriggerType} + assert set(values) == expected, ( + f"Trigger-types catalog drift: missing={expected - set(values)}, " + f"extra={set(values) - expected}" + ) + # No duplicates + assert len(values) == len(set(values)), ( + f"Duplicate entries in trigger-types catalog: {values}" + ) + assert len(values) == len(expected) + + +def test_response_shape() -> None: + payload = _call_endpoint() + required_keys = {"value", "label", "workflow_type", "entity_types", "group"} + for entry in payload: + assert required_keys.issubset(entry.keys()), ( + f"Trigger-types entry missing keys: {required_keys - entry.keys()} " + f"(entry={entry})" + ) + assert isinstance(entry["value"], str) + assert isinstance(entry["label"], str) and entry["label"] + assert entry["workflow_type"] in {"process", "approval"} + assert isinstance(entry["entity_types"], list) + for et in entry["entity_types"]: + assert isinstance(et, str) + assert entry["group"] in { + "lifecycle", "request_flow", "validation_gates", "system_scheduled", + } + + +def test_is_advanced_not_in_response() -> None: + """The is_advanced field was removed when the "Show advanced triggers" + toggle was dropped from the picker — for_approval_response is now shown + inline alongside the other approval triggers. Guard against it sneaking + back in. + """ + payload = _call_endpoint() + for entry in payload: + assert "is_advanced" not in entry, ( + f"is_advanced should not be returned anymore (entry={entry})" + ) + + +def test_for_triggers_are_approval_workflow_type() -> None: + """Every for_* trigger must be classified as approval, everything else as process.""" + payload = _call_endpoint() + for entry in payload: + if entry["value"].startswith("for_"): + assert entry["workflow_type"] == "approval", entry + else: + assert entry["workflow_type"] == "process", entry + + +def test_approval_triggers_are_in_request_flow_group() -> None: + """All for_* approval triggers should live under request_flow in the UI.""" + payload = _call_endpoint() + for entry in payload: + if entry["workflow_type"] == "approval": + assert entry["group"] == "request_flow", entry diff --git a/src/backend/src/tests/unit/test_workflow_trigger_roundtrip.py b/src/backend/src/tests/unit/test_workflow_trigger_roundtrip.py new file mode 100644 index 00000000..e4b8c663 --- /dev/null +++ b/src/backend/src/tests/unit/test_workflow_trigger_roundtrip.py @@ -0,0 +1,100 @@ +"""Round-trip test for workflow trigger values. + +Creates a workflow with a representative trigger, fetches it, updates it +unchanged, fetches again — and asserts the raw `trigger.type` string is +byte-identical at every hop. Guards against silent enum coercion or +case-normalisation that could break stored customer workflows. +""" + +import pytest + +from src.controller.workflows_manager import WorkflowsManager +from src.models.process_workflows import ( + EntityType, + ProcessWorkflowCreate, + ProcessWorkflowUpdate, + ScopeType, + TriggerType, + WorkflowScope, + WorkflowTrigger, + WorkflowType, +) + + +# Every for_* trigger (the new approval picker uses these) plus one +# representative from each process category (on_*, before_*, scheduled). +_TRIGGER_FIXTURES = [ + # (TriggerType, entity_types, workflow_type, schedule) + (TriggerType.FOR_SUBSCRIBE, [EntityType.DATA_PRODUCT], WorkflowType.APPROVAL, None), + (TriggerType.FOR_REQUEST_ACCESS, [EntityType.ACCESS_GRANT], WorkflowType.APPROVAL, None), + (TriggerType.FOR_REQUEST_REVIEW, [EntityType.DATA_PRODUCT], WorkflowType.APPROVAL, None), + (TriggerType.FOR_REQUEST_PUBLISH, [EntityType.DATA_CONTRACT], WorkflowType.APPROVAL, None), + (TriggerType.FOR_REQUEST_CERTIFY, [EntityType.DATA_PRODUCT], WorkflowType.APPROVAL, None), + (TriggerType.FOR_REQUEST_STATUS_CHANGE, [EntityType.DATA_PRODUCT], WorkflowType.APPROVAL, None), + (TriggerType.FOR_APPROVAL_RESPONSE, [], WorkflowType.APPROVAL, None), + (TriggerType.ON_CREATE, [EntityType.TABLE], WorkflowType.PROCESS, None), + (TriggerType.BEFORE_CREATE, [EntityType.TABLE], WorkflowType.PROCESS, None), + (TriggerType.SCHEDULED, [], WorkflowType.PROCESS, "0 9 * * *"), +] + + +@pytest.mark.parametrize( + ("trigger_type", "entity_types", "workflow_type", "schedule"), + _TRIGGER_FIXTURES, + ids=lambda v: v.value if hasattr(v, "value") else str(v), +) +def test_trigger_value_survives_create_get_update_get( + db_session, + trigger_type: TriggerType, + entity_types, + workflow_type: WorkflowType, + schedule, +) -> None: + manager = WorkflowsManager(db_session) + + trigger = WorkflowTrigger( + type=trigger_type, + entity_types=entity_types, + schedule=schedule, + ) + create_payload = ProcessWorkflowCreate( + name=f"roundtrip-{trigger_type.value}", + description="roundtrip test", + trigger=trigger, + scope=WorkflowScope(type=ScopeType.ALL), + workflow_type=workflow_type, + is_active=True, + steps=[], + ) + + # Create + created = manager.create_workflow(create_payload, created_by="test_user") + wf_id = created.id + assert created.trigger.type.value == trigger_type.value, ( + f"create dropped value: expected '{trigger_type.value}', got " + f"'{created.trigger.type.value}'" + ) + + # Get + fetched = manager.get_workflow(wf_id) + assert fetched is not None + assert fetched.trigger.type.value == trigger_type.value + assert [et.value for et in fetched.trigger.entity_types] == [ + et.value for et in entity_types + ] + + # Update (unchanged trigger) + update_payload = ProcessWorkflowUpdate( + trigger=fetched.trigger, + ) + updated = manager.update_workflow(wf_id, update_payload, updated_by="test_user") + assert updated is not None + assert updated.trigger.type.value == trigger_type.value + + # Get again + refetched = manager.get_workflow(wf_id) + assert refetched is not None + assert refetched.trigger.type.value == trigger_type.value, ( + f"refetched value drifted: expected '{trigger_type.value}', got " + f"'{refetched.trigger.type.value}'" + ) diff --git a/src/frontend/src/components/workflows/entity-type-multiselect.test.tsx b/src/frontend/src/components/workflows/entity-type-multiselect.test.tsx new file mode 100644 index 00000000..dc7ba5d4 --- /dev/null +++ b/src/frontend/src/components/workflows/entity-type-multiselect.test.tsx @@ -0,0 +1,167 @@ +/** + * Tests for . + * + * Renders the component directly — Checkbox is a much simpler Radix + * primitive than Select and works reliably in jsdom. We cover: + * - Rendering each supported entity type as a row (pretty-printed). + * - Auto-prefill when there is exactly one supported type. + * - Toggling persists the new array (wire format stays snake_case). + * - Empty supported set renders the muted "fires regardless of entity" + * placeholder instead of an empty box. + * - prettyEntityTypeLabel pure helper — display-only conversion. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { + EntityTypeMultiselect, + ENTITY_TYPE_MULTISELECT_LABEL, + prettyEntityTypeLabel, +} from './entity-type-multiselect'; + +describe('prettyEntityTypeLabel', () => { + it('converts single-word lowercase values to Sentence case', () => { + expect(prettyEntityTypeLabel('role')).toBe('Role'); + expect(prettyEntityTypeLabel('project')).toBe('Project'); + }); + + it('converts snake_case to Sentence case (only first letter capitalized)', () => { + expect(prettyEntityTypeLabel('data_product')).toBe('Data product'); + expect(prettyEntityTypeLabel('data_contract')).toBe('Data contract'); + }); + + it('applies display overrides for wire values that read as internal jargon', () => { + // access_grant is overridden — "Access grant" reads as internal jargon + // when surfaced as a "kind of object". "Data object" reads naturally + // next to "When a user requests access — Applies to: Data object". + expect(prettyEntityTypeLabel('access_grant')).toBe('Data object'); + }); + + it('handles multi-underscore values', () => { + expect(prettyEntityTypeLabel('data_asset_review')).toBe('Data asset review'); + }); + + it('returns an empty string unchanged', () => { + expect(prettyEntityTypeLabel('')).toBe(''); + }); + + it('leaves already-capitalized single tokens alone (idempotent for sentence-case input)', () => { + // Defensive: in case any caller hands us an already-pretty value. + expect(prettyEntityTypeLabel('Role')).toBe('Role'); + }); +}); + +describe('', () => { + it('renders one row per supported entity type with Sentence-case labels', () => { + render( + , + ); + // Pretty-printed labels are visible + expect(screen.getByText('Catalog')).toBeInTheDocument(); + expect(screen.getByText('Schema')).toBeInTheDocument(); + expect(screen.getByText('Table')).toBeInTheDocument(); + }); + + it('renders snake_case multi-word values pretty-printed (with overrides applied)', () => { + render( + , + ); + // access_grant is overridden to "Data object"; data_product follows + // the default snake_case → Sentence case rule. + expect(screen.getByText('Data object')).toBeInTheDocument(); + expect(screen.getByText('Data product')).toBeInTheDocument(); + }); + + it('renders the "Applies to" field label above the checkboxes', () => { + render( + , + ); + expect(screen.getByText(ENTITY_TYPE_MULTISELECT_LABEL)).toBeInTheDocument(); + }); + + it('renders the placeholder when the trigger fires regardless of entity', () => { + render( + , + ); + expect( + screen.getByText(/fires regardless of entity type/i), + ).toBeInTheDocument(); + }); + + it('auto-prefills the single supported type using the raw snake_case value', () => { + const onChange = vi.fn(); + render( + , + ); + // Wire format is preserved on the onChange callback — only the label + // is pretty-printed. + expect(onChange).toHaveBeenCalledWith(['access_grant']); + }); + + it('does not auto-prefill when there are multiple supported types', () => { + const onChange = vi.fn(); + render( + , + ); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('toggling an unchecked row adds the raw snake_case value to value[]', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText('Schema')); + // Wire format stays snake_case — the label is just display. + expect(onChange).toHaveBeenCalledWith(['catalog', 'schema']); + }); + + it('toggling a checked row removes it from value (wire format preserved)', () => { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByLabelText('Catalog')); + expect(onChange).toHaveBeenCalledWith(['schema']); + }); +}); diff --git a/src/frontend/src/components/workflows/entity-type-multiselect.tsx b/src/frontend/src/components/workflows/entity-type-multiselect.tsx new file mode 100644 index 00000000..b9dc5420 --- /dev/null +++ b/src/frontend/src/components/workflows/entity-type-multiselect.tsx @@ -0,0 +1,146 @@ +/** + * EntityTypeMultiselect — companion to . + * + * Renders the entity types the chosen trigger CAN fire for (from the + * trigger-types endpoint), as a Shadcn-style checkbox-multiselect. Selected + * values get persisted into `workflow.trigger.entity_types`, which the + * backend uses to scope dispatch (see ProcessWorkflowRepository.get_for_trigger). + * + * Today the trigger.entity_types field is invisible in the UI — workflows + * end up with `[]` (= "fires for everything") by accident. Surfacing this + * picker forces the author to make the scope choice deliberately. + * + * Design choices: + * - If the trigger supports exactly one entity type, we pre-select it but + * still show the (single, checked) row so the choice is visible. + * - If the trigger has NO supported entity types (e.g. scheduled, + * for_approval_response), we render a muted placeholder explaining it + * fires regardless of entity type — no checkboxes. + * - Selecting zero options is allowed, since the backend treats `[]` as + * "any entity" (back-compat with existing rows). + * - The wire format stays snake_case (`access_grant`, `data_product`, …) + * so we don't break round-trips. `prettyEntityTypeLabel` only affects + * what the user sees in the checkbox rows. + */ +import { useEffect } from 'react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; + +/** + * Display overrides for entity-type wire values. The default rule is + * snake_case → Sentence case, but some wire values read as internal + * jargon when surfaced to authors. Add entries here to override on a + * case-by-case basis without touching the wire format itself. + * + * access_grant → "Data object" + * (Reads naturally next to "When a user requests access — Applies to: …") + * + * Order matters: overrides win over the snake_case fallback. + */ +const PRETTY_ENTITY_TYPE_OVERRIDES: Record = { + access_grant: 'Data object', +}; + +/** + * Convert a snake_case entity-type wire value to Sentence case for display. + * + * "access_grant" → "Data object" (override) + * "data_product" → "Data product" + * "data_asset_review" → "Data asset review" + * "role" → "Role" + * "" → "" + * + * Pure / side-effect free / exported for tests. Display only — callers + * must keep using the raw `value` for any wire-format work (FK lookups, + * persisted state, etc.). + */ +export function prettyEntityTypeLabel(value: string): string { + if (!value) return value; + if (PRETTY_ENTITY_TYPE_OVERRIDES[value]) return PRETTY_ENTITY_TYPE_OVERRIDES[value]; + const spaced = value.replace(/_/g, ' '); + return spaced.charAt(0).toUpperCase() + spaced.slice(1); +} + +/** Field-label constants for the entity-type multiselect. Exported so + * the parent form and tests can reference the same source of truth. */ +export const ENTITY_TYPE_MULTISELECT_LABEL = 'Applies to'; +export const ENTITY_TYPE_MULTISELECT_HELPER = 'Which kinds of objects this fires on'; + +export interface EntityTypeMultiselectProps { + /** The currently-selected trigger value (for context only). */ + triggerType: string; + /** Currently persisted entity_types on the workflow trigger. */ + value: string[]; + /** Called when the selection changes. Pass the full new array. */ + onChange: (next: string[]) => void; + /** + * The entity types this trigger CAN fire for, from + * GET /api/workflows/trigger-types. Empty list ⇒ "any entity". + */ + supportedEntityTypes: string[]; +} + +export function EntityTypeMultiselect({ + triggerType, + value, + onChange, + supportedEntityTypes, +}: EntityTypeMultiselectProps) { + // Auto-default: if there is exactly one supported entity type and the + // user hasn't picked anything, prefill it. Keeps the picker visible so + // the choice is auditable, but spares the user a redundant click. + useEffect(() => { + if (supportedEntityTypes.length === 1 && value.length === 0) { + onChange([supportedEntityTypes[0]]); + } + // Intentionally only react to trigger changes — re-prefilling on every + // render would steal the user's "I really mean none" choice. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [triggerType, supportedEntityTypes.join('|')]); + + if (supportedEntityTypes.length === 0) { + return ( +
+ +

+ This trigger fires regardless of entity type. +

+
+ ); + } + + const toggle = (et: string) => { + if (value.includes(et)) { + onChange(value.filter((v) => v !== et)); + } else { + onChange([...value, et]); + } + }; + + return ( +
+ +

+ {ENTITY_TYPE_MULTISELECT_HELPER} +

+
+ {supportedEntityTypes.map((et) => { + const id = `entity-type-${et}`; + const checked = value.includes(et); + return ( +
+ toggle(et)} + /> + +
+ ); + })} +
+
+ ); +} diff --git a/src/frontend/src/components/workflows/trigger-picker.test.tsx b/src/frontend/src/components/workflows/trigger-picker.test.tsx new file mode 100644 index 00000000..aa97a9e5 --- /dev/null +++ b/src/frontend/src/components/workflows/trigger-picker.test.tsx @@ -0,0 +1,142 @@ +/** + * Tests for the trigger-picker filter/group logic. + * + * We test the exported pure function `partitionTriggers` rather than + * rendering the full component, because the underlying Radix hangs — see + // file header), so we assert on the exported label constant the + // component renders verbatim above the Select. + it('exposes the "Fires on" field label so the parent form does not need a duplicate', () => { + expect(TRIGGER_PICKER_LABEL).toBe('Fires on'); + }); +}); diff --git a/src/frontend/src/components/workflows/trigger-picker.tsx b/src/frontend/src/components/workflows/trigger-picker.tsx new file mode 100644 index 00000000..dbdbda6e --- /dev/null +++ b/src/frontend/src/components/workflows/trigger-picker.tsx @@ -0,0 +1,181 @@ +/** + * Trigger picker — drives the workflow trigger dropdown in the workflow + * authoring form. + * + * Why a dedicated component (vs inline ). + */ +import { useEffect, useMemo, useState } from 'react'; +import { useApi } from '@/hooks/use-api'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { getTriggerLabel } from '@/lib/workflow-labels'; + +/** Field-label constants for the trigger picker. Exported so the parent + * form and tests can reference the same source of truth. */ +export const TRIGGER_PICKER_LABEL = 'Fires on'; +export const TRIGGER_PICKER_HELPER = 'What action makes this workflow run'; + +/** + * Wire-format entry from GET /api/workflows/trigger-types. + * One per TriggerType enum member. + */ +export interface TriggerTypeOption { + value: string; + label: string; + workflow_type: 'approval' | 'process'; + entity_types: string[]; + group: 'lifecycle' | 'request_flow' | 'validation_gates' | 'system_scheduled'; +} + +/** + * Group ordering and human labels. Approval workflows only ever have + * request_flow, so this ordering is harmless for them. + */ +export const TRIGGER_GROUP_ORDER: Array = [ + 'lifecycle', + 'request_flow', + 'validation_gates', + 'system_scheduled', +]; + +export const TRIGGER_GROUP_LABELS: Record = { + lifecycle: 'Lifecycle events', + request_flow: 'Request flow', + validation_gates: 'Validation gates', + system_scheduled: 'System & scheduled', +}; + +/** + * Pure logic: filter the catalog to the workflow being authored and + * partition the visible entries into ordered groups. Exported for tests. + */ +export function partitionTriggers( + options: TriggerTypeOption[], + args: { + workflowType: 'approval' | 'process'; + }, +): Array<{ group: TriggerTypeOption['group']; label: string; items: TriggerTypeOption[] }> { + const visible = options.filter((o) => o.workflow_type === args.workflowType); + return TRIGGER_GROUP_ORDER + .map((g) => ({ + group: g, + label: TRIGGER_GROUP_LABELS[g], + items: visible.filter((o) => o.group === g), + })) + .filter((bucket) => bucket.items.length > 0); +} + +export interface TriggerPickerProps { + /** Currently selected trigger value (e.g. "on_create"). */ + value: string; + /** Called when the user picks a different trigger. */ + onChange: (value: string) => void; + /** Which half of the catalog to show. */ + workflowType: 'approval' | 'process'; + /** Optional: pre-loaded options (used in tests / SSR). */ + options?: TriggerTypeOption[]; +} + +export function TriggerPicker({ + value, + onChange, + workflowType, + options: optionsProp, +}: TriggerPickerProps) { + const { get } = useApi(); + const [options, setOptions] = useState(optionsProp ?? []); + + useEffect(() => { + if (optionsProp) { + setOptions(optionsProp); + return; + } + let cancelled = false; + (async () => { + try { + const res = await get('/api/workflows/trigger-types'); + if (!cancelled && res.data) { + setOptions(res.data); + } + } catch (err) { + // Soft-fail: the SelectItem fallback uses getTriggerLabel(), so + // the picker still renders something sensible if the API is down. + console.error('Failed to load trigger types:', err); + } + })(); + return () => { + cancelled = true; + }; + }, [get, optionsProp]); + + const groups = useMemo( + () => partitionTriggers(options, { workflowType }), + [options, workflowType], + ); + + return ( +
+ +

{TRIGGER_PICKER_HELPER}

+ +
+ ); +} diff --git a/src/frontend/src/components/workflows/workflow-designer.tsx b/src/frontend/src/components/workflows/workflow-designer.tsx index b5caa500..d0005355 100644 --- a/src/frontend/src/components/workflows/workflow-designer.tsx +++ b/src/frontend/src/components/workflows/workflow-designer.tsx @@ -1,6 +1,5 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; import ReactFlow, { Node, Edge, @@ -112,13 +111,12 @@ import type { HttpConnectionRef, WorkflowTypeValue, } from '@/types/process-workflow'; -import { - getTriggerTypeLabel, - getEntityTypeLabel, - ALL_TRIGGER_TYPES, +import { ALL_ENTITY_TYPES, isTriggerEntitySupported, } from '@/lib/workflow-labels'; +import { TriggerPicker, type TriggerTypeOption } from './trigger-picker'; +import { EntityTypeMultiselect } from './entity-type-multiselect'; // Node types registry (default = fallback for unknown step_type) const nodeTypes = { @@ -508,7 +506,6 @@ export default function WorkflowDesigner({ workflowId }: WorkflowDesignerProps) const { get, post, put } = useApi(); const { toast } = useToast(); - const { t } = useTranslation(['common']); const setStaticSegments = useBreadcrumbStore((state) => state.setStaticSegments); const setDynamicTitle = useBreadcrumbStore((state) => state.setDynamicTitle); @@ -521,6 +518,7 @@ export default function WorkflowDesigner({ workflowId }: WorkflowDesignerProps) const [compliancePolicies, setCompliancePolicies] = useState([]); const [availableRoles, setAvailableRoles] = useState<{ id: string; name: string; source: 'app' | 'business'; has_groups?: boolean; category?: string; description?: string }[]>([]); const [httpConnections, setHttpConnections] = useState([]); + const [triggerTypeOptions, setTriggerTypeOptions] = useState([]); const [showDiscardDialog, setShowDiscardDialog] = useState(false); // Form state @@ -706,6 +704,17 @@ export default function WorkflowDesigner({ workflowId }: WorkflowDesignerProps) } catch (error) { console.error('Failed to load step types:', error); } + + // Load trigger-type catalog (powers the grouped picker + entity-type + // multiselect — single source of truth, mirrors the backend enum). + try { + const triggerTypesResponse = await get('/api/workflows/trigger-types'); + if (triggerTypesResponse.data && Array.isArray(triggerTypesResponse.data)) { + setTriggerTypeOptions(triggerTypesResponse.data); + } + } catch (error) { + console.error('Failed to load trigger types:', error); + } // Load compliance policies for policy_check step selector try { @@ -1239,46 +1248,35 @@ export default function WorkflowDesigner({ workflowId }: WorkflowDesignerProps) // Trigger configuration <>
- - + { + setTriggerType(v as TriggerType); + // Reset entity_types when trigger changes so the + // multiselect re-prefills against the new + // supported set instead of carrying over a stale + // (and possibly unsupported) selection. + setEntityTypes([]); + }} + workflowType={workflowType === 'approval' ? 'approval' : 'process'} + options={triggerTypeOptions.length > 0 ? triggerTypeOptions : undefined} + />
- -
- {ALL_ENTITY_TYPES.map(et => ( - { - if (entityTypes.includes(et)) { - setEntityTypes(prev => prev.filter(e => e !== et)); - } else { - setEntityTypes(prev => [...prev, et]); - } - }} - > - {getEntityTypeLabel(et, t)} - - ))} -
+ setEntityTypes(next as EntityType[])} + supportedEntityTypes={ + triggerTypeOptions.find((o) => o.value === triggerType)?.entity_types + // Fallback to the static FE map when the catalog + // is still loading or unavailable. Keeps the + // multiselect non-empty so saved configs are + // visible during the first render. + ?? ALL_ENTITY_TYPES.filter((et) => isTriggerEntitySupported(triggerType, et)) + } + />
- {entityTypes.length > 0 && entityTypes.some(et => !isTriggerEntitySupported(triggerType, et)) && ( -
- Warning: This trigger–entity combination is not wired in the backend. The workflow will save but never fire automatically. -
- )} {(triggerType === 'on_status_change' || triggerType === 'before_status_change' || triggerType === 'on_request_status_change') && (
diff --git a/src/frontend/src/lib/workflow-labels.test.ts b/src/frontend/src/lib/workflow-labels.test.ts index 02e78fdc..8949cedd 100644 --- a/src/frontend/src/lib/workflow-labels.test.ts +++ b/src/frontend/src/lib/workflow-labels.test.ts @@ -1,8 +1,11 @@ /** * Unit tests for workflow-labels helpers. * - * Created alongside the for_* trigger entity-map update so the "trigger–entity - * combination is not wired" warning does not fire for valid wizard combos. + * Covers: + * - isTriggerEntitySupported + SUPPORTED_TRIGGER_ENTITY_MAP (PR #353 wizard wiring) + * - resolveRecipientDisplay (workflow recipient resolver) + * - ALL_TRIGGER_TYPES / ALL_ENTITY_TYPES sync invariants + * - getTriggerLabel + TRIGGER_LABELS (user-approved canonical labels) */ import { describe, it, expect } from 'vitest'; import { @@ -11,6 +14,8 @@ import { SUPPORTED_TRIGGER_ENTITY_MAP, ALL_TRIGGER_TYPES, ALL_ENTITY_TYPES, + getTriggerLabel, + TRIGGER_LABELS, } from './workflow-labels'; describe('isTriggerEntitySupported', () => { @@ -165,3 +170,58 @@ describe('workflow-labels', () => { }); }); }); + +describe('getTriggerLabel', () => { + // (value, expected) — one row per TriggerType, matching the backend + // _TRIGGER_LABELS dict and the user-approved table in the PR brief. + const expected: Array<[string, string]> = [ + ['for_subscribe', 'When a user subscribes'], + ['on_subscribe', 'After a subscription is created'], + ['for_request_access', 'When a user requests access'], + ['on_request_access', 'After an access request is submitted'], + ['for_request_review', 'When a user requests review'], + ['on_request_review', 'After a review request is submitted'], + ['for_request_publish', 'When a user requests publish'], + ['on_request_publish', 'After a publish request is submitted'], + ['for_request_certify', 'When a user requests certification'], + ['on_request_certify', 'After a certification request is submitted'], + ['for_request_status_change', 'When a user requests status change'], + ['on_request_status_change', 'After a status change request is submitted'], + ['for_approval_response', 'Approval response dialog'], + ['before_create', 'Before entity is created (validation)'], + ['before_update', 'Before entity is updated (validation)'], + ['before_status_change', 'Before status changes (validation)'], + ['on_create', 'After entity is created'], + ['on_update', 'After entity is updated'], + ['on_delete', 'After entity is deleted'], + ['on_status_change', 'After status changes'], + ['on_publish', 'After entity is published'], + ['on_unpublish', 'After entity is unpublished'], + ['on_revoke', 'After access is revoked'], + ['on_expiring', 'When access is about to expire'], + ['on_first_access', 'First time a user accesses (consent)'], + ['on_unsubscribe', 'After a user unsubscribes'], + ['on_job_success', 'After a background job succeeds'], + ['on_job_failure', 'After a background job fails'], + ['scheduled', 'On a schedule (cron)'], + ['manual', 'Manually triggered'], + ['on_certify', 'After entity is certified'], + ['on_decertify', 'After entity is decertified'], + ]; + + it.each(expected)('returns canonical label for %s', (value, label) => { + expect(getTriggerLabel(value)).toBe(label); + }); + + it('falls back to title-cased value for unknown triggers', () => { + // Forward-compat: if a new trigger is added to the enum before the + // table is updated, we should still render something reasonable. + expect(getTriggerLabel('on_brand_new_event')).toBe('On Brand New Event'); + }); + + it('every value in ALL_TRIGGER_TYPES has a canonical label', () => { + for (const value of ALL_TRIGGER_TYPES) { + expect(TRIGGER_LABELS[value]).toBeTruthy(); + } + }); +}); diff --git a/src/frontend/src/lib/workflow-labels.ts b/src/frontend/src/lib/workflow-labels.ts index 04053edb..43a078de 100644 --- a/src/frontend/src/lib/workflow-labels.ts +++ b/src/frontend/src/lib/workflow-labels.ts @@ -110,6 +110,64 @@ export function getTriggerTypeLabel(type: TriggerType, t: TFunction): string { return t(`common:workflows.triggerTypes.${type}`, { defaultValue: formatFallback(type) }); } +/** + * Canonical, user-approved labels for every TriggerType — the same strings + * the backend GET /api/workflows/trigger-types endpoint returns. Used by: + * + * - The new workflow trigger picker, as an offline fallback if the + * endpoint is unavailable. + * - Anywhere we render a trigger label without an i18n context (the + * legacy getTriggerTypeLabel needs a TFunction). + * + * Keep in sync with _TRIGGER_LABELS in + * src/backend/src/routes/workflows_routes.py. + */ +export const TRIGGER_LABELS: Record = { + for_subscribe: 'When a user subscribes', + on_subscribe: 'After a subscription is created', + for_request_access: 'When a user requests access', + on_request_access: 'After an access request is submitted', + for_request_review: 'When a user requests review', + on_request_review: 'After a review request is submitted', + for_request_publish: 'When a user requests publish', + on_request_publish: 'After a publish request is submitted', + for_request_certify: 'When a user requests certification', + on_request_certify: 'After a certification request is submitted', + for_request_status_change: 'When a user requests status change', + on_request_status_change: 'After a status change request is submitted', + for_approval_response: 'Approval response dialog', + before_create: 'Before entity is created (validation)', + before_update: 'Before entity is updated (validation)', + before_status_change: 'Before status changes (validation)', + on_create: 'After entity is created', + on_update: 'After entity is updated', + on_delete: 'After entity is deleted', + on_status_change: 'After status changes', + on_publish: 'After entity is published', + on_unpublish: 'After entity is unpublished', + on_revoke: 'After access is revoked', + on_expiring: 'When access is about to expire', + on_first_access: 'First time a user accesses (consent)', + on_unsubscribe: 'After a user unsubscribes', + on_job_success: 'After a background job succeeds', + on_job_failure: 'After a background job fails', + scheduled: 'On a schedule (cron)', + // Fallback labels for enum members not in the user-approved table. + manual: 'Manually triggered', + on_certify: 'After entity is certified', + on_decertify: 'After entity is decertified', +}; + +/** + * Get the user-facing label for a trigger value. Doesn't require i18n — + * uses the canonical TRIGGER_LABELS table that mirrors the backend + * trigger-types endpoint. Falls back to a Title-Cased version of the raw + * value if not found (handles future enum members gracefully). + */ +export function getTriggerLabel(value: string): string { + return TRIGGER_LABELS[value] ?? formatFallback(value); +} + /** * Get a human-readable label for an entity type. */