Skip to content

feat(workflows): trigger-picker UX overhaul (labels, filtering, groups, entity_types)#369

Open
mvkonchits-db wants to merge 6 commits into
mainfrom
feat/workflow-trigger-picker-ux
Open

feat(workflows): trigger-picker UX overhaul (labels, filtering, groups, entity_types)#369
mvkonchits-db wants to merge 6 commits into
mainfrom
feat/workflow-trigger-picker-ux

Conversation

@mvkonchits-db
Copy link
Copy Markdown
Contributor

Summary

Today's workflow trigger dropdown lists ~32 trigger types in a flat list, with for_* (Approval / wizard) and on_* (Process / after-effect) entries for the same action appearing as if they were duplicates. Authors also have no UI for entity_types, so workflows accidentally end up with [] (= "fires for everything").

This PR replaces the inline <Select> + Badge grid in workflow-designer.tsx with:

  • <TriggerPicker> — backed by a new GET /api/workflows/trigger-types endpoint, filters by the workflow's workflow_type, groups Process triggers (Lifecycle / Request flow / Validation gates / System & scheduled), hides for_approval_response behind a Show advanced triggers toggle, and shows the raw enum value in a tooltip on hover.
  • <EntityTypeMultiselect> — renders beneath the picker once a trigger is chosen, populated from the chosen trigger's supported entity_types. Persists into workflow.trigger.entity_types.

Critically, no TriggerType enum values are renamed — only display labels change. A new enum-pin test guarantees this.

What changed

Backend

  • New GET /api/workflows/trigger-types in src/backend/src/routes/workflows_routes.py (+178 lines). Returns one entry per TriggerType with {value, label, workflow_type, entity_types, is_advanced, group}.
  • Enum-pin test (test_trigger_enum_pin.py) — parametrized over every TriggerType member, asserts .value byte-identical to the wire-format contract.
  • Round-trip test (test_workflow_trigger_roundtrip.py) — 10 representative triggers: create → GET → PUT no-op → GET, assert trigger.type byte-identical.
  • Contract test (test_trigger_types_endpoint.py) — shape + invariant that every TriggerType is represented exactly once.

Frontend

  • Extended src/frontend/src/lib/workflow-labels.ts with TRIGGER_LABELS + getTriggerLabel() (the canonical user-approved labels — same strings the BE endpoint returns).
  • New trigger-picker.tsx (170 lines) + 7 tests on the exported partitionTriggers pure function.
  • New entity-type-multiselect.tsx (97 lines) + 6 tests.
  • workflow-designer.tsx swapped inline <Select> + Badge grid for the two new components.
  • Extended workflow-labels.test.ts with 34 label cases + fallback test.

Three enum members the user-approved label table missed

manual, on_certify, on_decertify weren't in the original label table; I added neutral fallbacks ("Manually triggered" / "After entity is certified" / "After entity is decertified") in both _TRIGGER_LABELS (backend) and TRIGGER_LABELS (frontend) so the every-enum-member-covered contract test passes. Flag for review if different copy is preferred. Group assignments: manualsystem_scheduled; on_certify / on_decertifylifecycle.

Safety contract

Layer Touched? Why safe
TriggerType enum values ("for_subscribe", …) No Stored in workflow.trigger.type and in customer webhook payloads — never changed.
workflow_steps.config / workflow.trigger.type DB columns No No migration.
/api/workflows* request/response shapes No Wire format unchanged.
Trigger dropdown labels Yes New getTriggerLabel(value) helper. Backend untouched.
Trigger dropdown filtering / grouping Yes Hides options BE would reject anyway.

Test plan

  • Backend: hatch -e dev run pytest src/backend/src/tests/unit/ — 754 passed, incl. 48 new (enum-pin: 33, contract: 5, round-trip: 10)
  • Frontend: cd src/frontend && yarn test --run — 447 passed / 6 pre-existing skips
  • Type-check: cd src/frontend && yarn type-check — clean
  • Manual: Settings → Workflows → "+ New" (Process or Approval). Click the Trigger node — picker is now grouped, for_* triggers visible only on Approval workflows, for_approval_response hidden until Show advanced triggers toggled on. Select a trigger — Applies to multiselect appears beneath, populated from the trigger's supported entity types.
  • API: GET /api/workflows/trigger-types returns 32 entries.
  • Round-trip: Save + re-open a workflow — trigger.type and trigger.entity_types byte-identical (already covered by the new BE unit test).

This pull request and its description were written by Isaac.

Adds a parametrized test that asserts every TriggerType member keeps its
exact wire-format string (e.g. TriggerType.FOR_SUBSCRIBE.value ==
'for_subscribe'). Renaming any of these is a breaking change for every
stored workflow trigger config in customer databases — pinning catches
it at PR time instead of at upgrade time.

Also asserts that every enum member is represented in the pin table, so
new triggers get vetted before they ship.
The workflow trigger dropdown today shows ~25 trigger types in a flat
list, with for_* and on_* variants for the same action appearing as if
they were duplicates. Authors get confused which to pick, and the
entity_types scope is invisible — workflows end up with entity_types=[]
('fires for everything') by accident.

This commit adds the backend half of the fix: a single read-only
endpoint that returns the full UI catalog the workflow-authoring form
needs. Each entry carries:

  - label: user-approved display string
  - workflow_type: 'approval' (for_*) or 'process' (everything else),
    so the picker can filter by the workflow being authored
  - group: lifecycle / request_flow / validation_gates /
    system_scheduled, so the picker can render <SelectGroup>s instead
    of a flat list
  - entity_types: which entity types this trigger CAN fire for, used
    to populate a multiselect under the picker
  - is_advanced: true for for_approval_response only — gated behind
    an explicit 'show advanced' toggle

Authorization mirrors the other workflow read endpoints
(settings/READ_ONLY).

Tests:
  - Contract test: every TriggerType is represented exactly once;
    shape and value spaces are validated.
  - Round-trip test: create → get → update unchanged → get again,
    asserting trigger.type is byte-identical at every hop. Covers
    every for_* trigger plus one representative from each process
    category.
Extends the existing workflow-labels module with TRIGGER_LABELS — the
canonical, user-approved display string for every TriggerType. Mirrors
the backend _TRIGGER_LABELS dict in workflows_routes.py so the new
picker can fall back gracefully if the trigger-types endpoint is
unavailable, and so anywhere we render a trigger without an i18n
context (the legacy getTriggerTypeLabel needs a TFunction) gets the
same strings.

getTriggerLabel(value) is the new public helper. Unknown values
title-case the raw string for forward-compat with future enum
members.

Tests parametrize every TriggerType member against its canonical
label string, plus a fallback case for unknown triggers.
Replaces the flat ~25-entry trigger dropdown in the workflow designer
with two cooperating components:

  TriggerPicker
    - Fetches the catalog from GET /api/workflows/trigger-types.
    - Filters visible entries by the workflow being authored
      (workflow_type === 'approval' shows only for_*; 'process' shows
      everything else). Today the dropdown shows both halves, which
      reads as duplicates ('on_subscribe' vs 'for_subscribe').
    - Groups visible entries with <SelectGroup> headers — Lifecycle
      events / Request flow / Validation gates / System & scheduled.
    - Hides 'for_approval_response' behind an explicit 'Show advanced
      triggers' toggle (off by default). This trigger is a system
      hook that typical authors should never pick directly.
    - Hover-tooltip on every option surfaces the raw enum value
      (e.g. 'for_subscribe') so power users can still grep for it.
    - Pure filter/group logic is exported as partitionTriggers() and
      unit-tested directly (Radix <Select> is unreliable in jsdom,
      so we don't render the full component in tests).

  EntityTypeMultiselect
    - Rendered under the picker once a trigger is chosen.
    - Populated from the chosen trigger's entity_types (from the
      catalog). Today this control doesn't exist; workflows save with
      entity_types=[] ('fires for everything') by accident — observed
      live on FEVM Ontos with 4 on_request_access workflows differing
      only in entity_types (access_grant / project / role) that
      authors had no way to configure deliberately.
    - Auto-prefills when the trigger supports exactly one entity
      type, but still renders the (single, checked) row so the
      scope choice is visible/auditable.
    - For triggers with no supported entity types (scheduled,
      for_approval_response), renders a muted explanatory line
      instead of an empty box.

  workflow-designer.tsx
    - Swaps the inline <Select>+Badge grid for the two components
      above. Passes the workflow_type field through so the picker
      filters correctly when switching between approval and process
      authoring. Resets entity_types on trigger change so the
      multiselect re-prefills against the new supported set.
    - Falls back to the existing SUPPORTED_TRIGGER_ENTITY_MAP if
      the trigger-types endpoint hasn't returned yet, so the form
      renders something sensible on first paint.

Tests cover the partitioning logic (filter by workflow_type, advanced
toggle, group ordering, empty-group elision) and the multiselect
render/toggle/auto-prefill paths.
@mvkonchits-db mvkonchits-db requested a review from a team as a code owner May 13, 2026 14:34
Three user-testing follow-ups on top of PR #369:

1. Drop the "Show advanced triggers" Switch. for_approval_response was the
   only entry it gated, and the toggle confused users. The trigger now lists
   in the Request flow group like every other approval trigger. The
   is_advanced field is removed from the GET /api/workflows/trigger-types
   response and the contract test asserts it is absent.

2. Strip the redundant "(wizard)" suffix from every for_* label (and the
   "(advanced)" suffix from for_approval_response). After the workflow_type
   filter is applied the suffix added no information. Backend
   _TRIGGER_LABELS and frontend TRIGGER_LABELS are updated in lockstep
   (they must stay byte-identical); the label-table test in
   workflow-labels.test.ts is updated to match. Validation triggers keep
   their "(validation)" suffix — not flagged.

3. Pretty-print entity-type values in the multiselect (access_grant ->
   "Access grant", data_asset_review -> "Data asset review", etc.). The
   conversion is a pure helper prettyEntityTypeLabel exported alongside
   the component for testability. The wire format is unchanged — values
   passed to onChange remain snake_case, so workflow.trigger.entity_types
   round-trips byte-identically.

Tests:
  - Backend unit/: 782 passing (was 754 before, label/contract drift caught)
  - Frontend: 583 passing (added 5 helper tests, updated 6 label assertions)
  - Type-check: clean for changed files (knowledge-graph.tsx /
    slider.tsx errors are pre-existing and unrelated)

Co-authored-by: Isaac
…s_grant display override

UX feedback on PR #369 final round:

1. Trigger-picker field label "Type" → "Fires on" with helper
   "What action makes this workflow run". Label now lives inside
   <TriggerPicker> so the parent form doesn't need a duplicate.

2. Entity-type multiselect field label "Category" → "Applies to" with
   helper "Which kinds of objects this fires on". Label now lives inside
   <EntityTypeMultiselect> for the same reason.

3. prettyEntityTypeLabel: add a small OVERRIDES map and remap
   access_grant → "Data object". All other entity types still flow
   through the snake_case → Sentence case rule. Wire format is
   unchanged — only the visible label moves.

End result reads naturally in the picker:
  Fires on:    When a user requests access
  Applies to:  Data object / Project / App role

Tests:
- entity-type-multiselect.test.tsx: assert override case and new
  "Applies to" field label.
- trigger-picker.test.tsx: assert exported TRIGGER_PICKER_LABEL
  constant (Radix <Select> still hangs in jsdom, so we can't render
  the picker; the constant is the surface the component renders).

Co-authored-by: Isaac
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant