From f9942da98e53ccda099962af18f0209824339001 Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Mon, 18 May 2026 02:16:21 -0500 Subject: [PATCH 1/2] feat(live_ui): add needs_you_section + blocker_row canonical widgets (Wave 3.7-B, DRAFT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new canonical widgets implementing the full 4-stage pipeline (IUR constructor → unified_ui catalog → live_ui renderer clause → Phoenix.Component module). needs_you_section (workflow_progress_and_status family): - Attention band listing operator-facing blockers - Renders a scrollable list of blocker_row children with title, empty state, and overflow "show N more" button - Accepts :title, :empty_state_text, :max_visible opts; children are blocker_row Elements blocker_row (row_and_artifact family): - Single actionable row: actor avatar + ask text + scope label + jump arrow - Unique LiveComponent IDs via "blocker-row-#{row_id}" fallback to prevent duplicate-ID crash when multiple rows render in one section - :actor attribute uses nil default (not %{}) because merge_attribute drops empty maps silently Stage 1: UnifiedIUR.Widgets.Components — :needs_you_section added to @workflow_kinds, :blocker_row added to @row_artifact_kinds; constructors with normalize_opts + maybe_put + build_component Stage 2: UnifiedUi.WidgetComponents catalog — two entries appended Stage 3: LiveUi.Renderer — dedicated clauses inserted above @component_kinds generic fallback Stage 4: LiveUi.Widgets.{NeedsYouSection,BlockerRow} Phoenix.Component modules; both registered in LiveUi.Widgets.Navigation @modules Tests: 32 new tests (17 unified_iur constructor, 15 live_ui render); 4 hardcoded catalog tests updated to include new kinds. Open questions for Pascal review: Q1: avatar in blocker_row — use :actor struct or :initials/:label opt? Q2: scroll/overflow strategy — CSS-only clamp or max_visible JS? Q3: severity tokens — map to :warning/:error/:info or keep as string? Q4: scope_intent atom vs string? Q5: needs_you_section family — :workflow or :layer_callout? Q6: blocker_row — separate kind vs sub-element of needs_you_section? (implemented here as separate top-level widget per spec proposal) Co-Authored-By: Claude Sonnet 4.6 --- packages/live_ui/lib/live_ui/renderer.ex | 63 +++++ .../lib/live_ui/widgets/blocker_row.ex | 86 ++++++ .../live_ui/lib/live_ui/widgets/navigation.ex | 4 +- .../lib/live_ui/widgets/needs_you_section.ex | 77 ++++++ .../test/live_ui/needs_you_section_test.exs | 248 ++++++++++++++++++ .../unified_iur/lib/unified_iur/fixtures.ex | 24 ++ .../lib/unified_iur/widgets/components.ex | 49 +++- .../unified_iur/widgets/components_test.exs | 9 +- .../widgets/needs_you_section_test.exs | 171 ++++++++++++ .../lib/unified_ui/widget_components.ex | 14 + .../widget_components_catalog_test.exs | 5 +- 11 files changed, 743 insertions(+), 7 deletions(-) create mode 100644 packages/live_ui/lib/live_ui/widgets/blocker_row.ex create mode 100644 packages/live_ui/lib/live_ui/widgets/needs_you_section.ex create mode 100644 packages/live_ui/test/live_ui/needs_you_section_test.exs create mode 100644 packages/unified_iur/test/unified_iur/widgets/needs_you_section_test.exs diff --git a/packages/live_ui/lib/live_ui/renderer.ex b/packages/live_ui/lib/live_ui/renderer.ex index 14f6072e..a6a81edf 100644 --- a/packages/live_ui/lib/live_ui/renderer.ex +++ b/packages/live_ui/lib/live_ui/renderer.ex @@ -239,6 +239,69 @@ defmodule LiveUi.Renderer do """ end + # NOTE: `:needs_you_section` and `:blocker_row` are members of `@component_kinds` + # (via `@workflow_kinds` and `@row_artifact_kinds`). Place these dedicated clauses + # BEFORE the generic `@component_kinds` fallback to avoid shadowing. + def render(%{element: %Element{kind: :needs_you_section}} = assigns) do + interaction_attrs = interaction_event_attrs(assigns.element, Map.get(assigns, :event_target)) + + assigns = + assign(assigns, :interaction_attrs, interaction_attrs) + |> assign(:style_attrs, merge_global_attrs(style_rest(assigns.element), interaction_attrs)) + + ~H""" + + """ + end + + def render(%{element: %Element{kind: :blocker_row}} = assigns) do + interaction_attrs = interaction_event_attrs(assigns.element, Map.get(assigns, :event_target)) + + assigns = + assign(assigns, :interaction_attrs, interaction_attrs) + |> assign(:style_attrs, merge_global_attrs(style_rest(assigns.element), interaction_attrs)) + + # Use element id, falling back to a prefixed row_id so multiple rows in the + # same section don't collide on the LiveComponent id requirement. + row_id_attr = string_value(get_in(assigns.element.attributes, [:blocker, :row_id]), "row") + component_id = element_id(assigns.element, "blocker-row-#{row_id_attr}") + assigns = assign(assigns, :component_id, component_id) + + ~H""" + + """ + end + # NOTE: `:command_palette` is a member of `@layer_callout_kinds` (and therefore # of `@component_kinds`), so the generic fallback below would shadow any later # `:command_palette` clause. Keep this specific clause BEFORE the generic diff --git a/packages/live_ui/lib/live_ui/widgets/blocker_row.ex b/packages/live_ui/lib/live_ui/widgets/blocker_row.ex new file mode 100644 index 00000000..73da382b --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/blocker_row.ex @@ -0,0 +1,86 @@ +defmodule LiveUi.Widgets.BlockerRow do + @moduledoc """ + Single actionable blocker row for use inside `needs_you_section`. + + Renders as a ` + """ + end +end diff --git a/packages/live_ui/lib/live_ui/widgets/navigation.ex b/packages/live_ui/lib/live_ui/widgets/navigation.ex index 090bc122..98e4589c 100644 --- a/packages/live_ui/lib/live_ui/widgets/navigation.ex +++ b/packages/live_ui/lib/live_ui/widgets/navigation.ex @@ -6,7 +6,9 @@ defmodule LiveUi.Widgets.Navigation do @modules [ LiveUi.Widgets.Menu, LiveUi.Widgets.Tabs, - LiveUi.Widgets.CommandPalette + LiveUi.Widgets.CommandPalette, + LiveUi.Widgets.NeedsYouSection, + LiveUi.Widgets.BlockerRow ] @spec modules() :: [module()] diff --git a/packages/live_ui/lib/live_ui/widgets/needs_you_section.ex b/packages/live_ui/lib/live_ui/widgets/needs_you_section.ex new file mode 100644 index 00000000..a70f482b --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/needs_you_section.ex @@ -0,0 +1,77 @@ +defmodule LiveUi.Widgets.NeedsYouSection do + @moduledoc """ + Attention-needed band listing operator-facing blockers. + + Renders a titled section with a list of `blocker_row` children, an empty-state + message when no items are present, and an overflow affordance when items exceed + `max_visible`. Each row is rendered via `LiveUi.Renderer.render/1` so the parent + decides row composition. + + ARIA: normal `
` with `

` heading semantics. The count badge is + `aria-hidden` — it is visual sugar on top of the heading, not an independent + landmark. + + Open questions flagged in PR for Pascal review: + - Section family: `:workflow_progress_and_status` (current) vs `:layer_shell_and_callout`? + - `:expanded?` state: internal to widget or external (parent-driven)? + """ + + use LiveUi.Component, + family: :workflow, + name: :needs_you_section, + events: [:click] + + LiveUi.Component.common_attrs() + attr(:title, :string, default: "Needs you") + attr(:empty_state_text, :string, default: "You're all caught up.") + attr(:max_visible, :integer, default: 5) + attr(:items, :list, default: []) + attr(:event_target, :any, default: nil) + + @impl true + def render(assigns) do + visible = Enum.take(assigns.items, assigns.max_visible) + overflow = length(assigns.items) - assigns.max_visible + assigns = assign(assigns, :visible_items, visible) |> assign(:overflow_count, overflow) + + ~H""" +
+
+

{@title}

+ <%= if @items != [] do %> + + <% end %> +
+ + <%= if @items == [] do %> +

{@empty_state_text}

+ <% else %> +
    + <%= for item <- @visible_items do %> +
  • + +
  • + <% end %> +
+ <%= if @overflow_count > 0 do %> + + <% end %> + <% end %> +
+ """ + end +end diff --git a/packages/live_ui/test/live_ui/needs_you_section_test.exs b/packages/live_ui/test/live_ui/needs_you_section_test.exs new file mode 100644 index 00000000..683b2637 --- /dev/null +++ b/packages/live_ui/test/live_ui/needs_you_section_test.exs @@ -0,0 +1,248 @@ +defmodule LiveUi.NeedsYouSectionTest do + use ExUnit.Case, async: true + + import Phoenix.LiveViewTest + + alias LiveUi.Component + alias UnifiedIUR.Widgets.Components + + describe "NeedsYouSection widget component" do + test "has mountable component boundary" do + metadata = Component.metadata(LiveUi.Widgets.NeedsYouSection) + + assert metadata.mountable? + assert metadata.component_module == LiveUi.Widgets.NeedsYouSection.Component + assert metadata.name == :needs_you_section + end + + test "renders with needs-you-section widget marker" do + html = + render_component(&LiveUi.Widgets.NeedsYouSection.component/1, %{ + id: "attention-band", + items: [], + event_target: nil + }) + + assert html =~ ~s(data-live-ui-widget="needs-you-section") + assert html =~ "Needs you" + assert html =~ "You're all caught up." + end + + test "renders empty-state message when no items" do + html = + render_component(&LiveUi.Widgets.NeedsYouSection.component/1, %{ + id: "attention-band", + items: [], + event_target: nil + }) + + assert html =~ "You're all caught up." + refute html =~ " + Components.blocker_row( + row_id: "overflow-#{i}", + ask_text: "Act #{i}", + scope_label: "doc: #{i}", + actor: %{initials: "T#{i}", actor_name: "Tester #{i}"} + ) + end) + + section_element = + Components.needs_you_section(rows, id: "overflow-band", max_visible: 2) + + html = render_component(&LiveUi.Renderer.render/1, %{element: section_element}) + + assert html =~ "show 2 more" + assert html =~ "live-ui-needs-you__more" + end + + test "no more button when items fit within max_visible" do + rows = + Enum.map(1..3, fn i -> + Components.blocker_row( + row_id: "fit-#{i}", + ask_text: "Act #{i}", + scope_label: "doc: #{i}", + actor: %{initials: "T#{i}", actor_name: "Tester #{i}"} + ) + end) + + section_element = + Components.needs_you_section(rows, id: "no-overflow-band", max_visible: 5) + + html = render_component(&LiveUi.Renderer.render/1, %{element: section_element}) + + refute html =~ "show" + refute html =~ "live-ui-needs-you__more" + end + end + + describe "BlockerRow widget component" do + test "has mountable component boundary" do + metadata = Component.metadata(LiveUi.Widgets.BlockerRow) + + assert metadata.mountable? + assert metadata.component_module == LiveUi.Widgets.BlockerRow.Component + assert metadata.name == :blocker_row + end + + test "renders with blocker-row widget marker" do + html = + render_component(&LiveUi.Widgets.BlockerRow.component/1, %{ + id: "row-1", + row_id: "br-abc", + ask_text: "Review the plan", + scope_label: "doc: master-plan.md", + severity: "info", + actor: %{initials: "PC", actor_name: "Pascal"} + }) + + assert html =~ ~s(data-live-ui-widget="blocker-row") + assert html =~ ~s(data-row-id="br-abc") + assert html =~ ~s(data-severity="info") + end + + test "renders ask_text and scope_label in body" do + html = + render_component(&LiveUi.Widgets.BlockerRow.component/1, %{ + id: "row-2", + row_id: "br-body", + ask_text: "Needs a decision on X", + scope_label: "repo: ariston-ui", + severity: "info", + actor: %{initials: "MJ", actor_name: "Matt"} + }) + + assert html =~ "Needs a decision on X" + assert html =~ "repo: ariston-ui" + end + + test "includes aria-label from ask_text and scope_label" do + html = + render_component(&LiveUi.Widgets.BlockerRow.component/1, %{ + id: "row-3", + row_id: "br-aria", + ask_text: "Review spec", + scope_label: "doc: spec.md", + severity: "warn", + actor: %{initials: "PC", actor_name: "Pascal"} + }) + + assert html =~ ~s(aria-label="Review spec — doc: spec.md") + end + + test "applies severity modifier class" do + html = + render_component(&LiveUi.Widgets.BlockerRow.component/1, %{ + id: "row-4", + row_id: "br-crit", + ask_text: "Blocking", + scope_label: "repo: metagraph", + severity: "critical", + actor: %{initials: "MJ", actor_name: "Matt"} + }) + + assert html =~ "live-ui-blocker-row--critical" + assert html =~ ~s(data-severity="critical") + end + + test "renders initials when no image_source" do + html = + render_component(&LiveUi.Widgets.BlockerRow.component/1, %{ + id: "row-5", + row_id: "br-init", + ask_text: "Act", + scope_label: "doc: x", + severity: "info", + actor: %{initials: "AB", actor_name: "Alice"} + }) + + assert html =~ "AB" + refute html =~ " maybe_put(:title, option(opts, :title, "Needs you")) + |> maybe_put( + :empty_state_text, + option(opts, :empty_state_text, "You're all caught up.") + ) + |> maybe_put(:max_visible, option(opts, :max_visible, 5)) + }, + Map.put(opts, :children, items) + ) + end + + @spec blocker_row(opts()) :: Element.t() + def blocker_row(opts \\ []) do + opts = normalize_opts(opts) + + build_component( + :blocker_row, + :row_and_artifact, + %{ + blocker: + %{} + |> maybe_put(:row_id, option(opts, :row_id)) + |> maybe_put(:ask_text, option(opts, :ask_text, "")) + |> maybe_put(:scope_label, option(opts, :scope_label, "")) + |> maybe_put(:scope_intent, option(opts, :scope_intent, "jump_to_blocker")) + |> maybe_put(:scope_value, option(opts, :scope_value)) + |> maybe_put(:severity, option(opts, :severity, :info)), + actor: option(opts, :actor) + }, + opts + ) + end + defp build_component(kind, family, kind_attributes, opts) do opts = normalize_opts(opts) diff --git a/packages/unified_iur/test/unified_iur/widgets/components_test.exs b/packages/unified_iur/test/unified_iur/widgets/components_test.exs index fe650c6e..0e2138b9 100644 --- a/packages/unified_iur/test/unified_iur/widgets/components_test.exs +++ b/packages/unified_iur/test/unified_iur/widgets/components_test.exs @@ -23,14 +23,19 @@ defmodule UnifiedIUR.Widgets.ComponentsTest do :mode_nav ] - assert Components.row_artifact_kinds() == [:list_item_multi_column, :artifact_row] + assert Components.row_artifact_kinds() == [ + :list_item_multi_column, + :artifact_row, + :blocker_row + ] assert Components.workflow_kinds() == [ :pipeline_stepper_horizontal, :segmented_progress_bar, :workflow_stage_list_vertical, :meter_thin, - :unread_badge + :unread_badge, + :needs_you_section ] assert Components.layer_callout_kinds() == [ diff --git a/packages/unified_iur/test/unified_iur/widgets/needs_you_section_test.exs b/packages/unified_iur/test/unified_iur/widgets/needs_you_section_test.exs new file mode 100644 index 00000000..01fb5b73 --- /dev/null +++ b/packages/unified_iur/test/unified_iur/widgets/needs_you_section_test.exs @@ -0,0 +1,171 @@ +defmodule UnifiedIUR.Widgets.NeedsYouSectionTest do + use ExUnit.Case, async: true + + alias UnifiedIUR.Element + alias UnifiedIUR.Widgets.Components + + describe "needs_you_section/2" do + test "builds element with correct kind and family" do + section = Components.needs_you_section([]) + + assert %Element{kind: :needs_you_section} = section + + assert section.attributes.component == %{ + family: :workflow_progress_and_status, + kind: :needs_you_section + } + end + + test "applies default section attributes" do + section = Components.needs_you_section([]) + + assert section.attributes.section == %{ + title: "Needs you", + empty_state_text: "You're all caught up.", + max_visible: 5 + } + end + + test "accepts custom title, empty_state_text, and max_visible" do + section = + Components.needs_you_section([], + title: "Review required", + empty_state_text: "Nothing here.", + max_visible: 3 + ) + + assert section.attributes.section == %{ + title: "Review required", + empty_state_text: "Nothing here.", + max_visible: 3 + } + end + + test "accepts blocker_row children and wraps them in default slot" do + row1 = + Components.blocker_row(row_id: "br-1", ask_text: "Decide X", scope_label: "doc: plan") + + row2 = Components.blocker_row(row_id: "br-2", ask_text: "Review Y", scope_label: "repo: ui") + + section = Components.needs_you_section([row1, row2]) + + assert length(section.children) == 2 + assert Enum.all?(section.children, &(&1.slot == :default)) + assert Enum.all?(section.children, &match?(%{element: %Element{kind: :blocker_row}}, &1)) + end + + test "accepts id and accessibility opts" do + section = + Components.needs_you_section([], + id: "attention-band", + accessibility_label: "Needs your attention" + ) + + assert section.id == "attention-band" + assert section.attributes.accessibility == %{label: "Needs your attention"} + end + + test "empty items list is valid" do + assert %Element{kind: :needs_you_section, children: []} = Components.needs_you_section([]) + end + end + + describe "blocker_row/1" do + test "builds element with correct kind and family" do + row = Components.blocker_row(row_id: "r1", ask_text: "Act on this", scope_label: "doc: x") + + assert %Element{kind: :blocker_row} = row + assert row.attributes.component == %{family: :row_and_artifact, kind: :blocker_row} + end + + test "captures required blocker attributes" do + row = + Components.blocker_row( + row_id: "br-abc", + ask_text: "Please review the plan", + scope_label: "doc: master-plan.md" + ) + + assert row.attributes.blocker.row_id == "br-abc" + assert row.attributes.blocker.ask_text == "Please review the plan" + assert row.attributes.blocker.scope_label == "doc: master-plan.md" + end + + test "defaults severity to :info and scope_intent to jump_to_blocker" do + row = Components.blocker_row(row_id: "r1", ask_text: "Act", scope_label: "x") + + assert row.attributes.blocker.severity == :info + assert row.attributes.blocker.scope_intent == "jump_to_blocker" + end + + test "accepts severity :warn" do + row = + Components.blocker_row( + row_id: "r2", + ask_text: "Urgent", + scope_label: "doc: z", + severity: :warn + ) + + assert row.attributes.blocker.severity == :warn + end + + test "accepts severity :critical" do + row = + Components.blocker_row( + row_id: "r3", + ask_text: "Critical", + scope_label: "doc: z", + severity: :critical + ) + + assert row.attributes.blocker.severity == :critical + end + + test "accepts scope_value override" do + row = + Components.blocker_row( + row_id: "r4", + ask_text: "Act", + scope_label: "thread: discussion", + scope_value: "thread-uuid-123" + ) + + assert row.attributes.blocker.scope_value == "thread-uuid-123" + end + + test "accepts actor map with initials and actor_name" do + row = + Components.blocker_row( + row_id: "r5", + ask_text: "Act", + scope_label: "doc: plan", + actor: %{initials: "PC", actor_name: "Pascal", image_source: nil} + ) + + assert row.attributes.actor == %{initials: "PC", actor_name: "Pascal", image_source: nil} + end + + test "omits actor attribute when not provided" do + row = Components.blocker_row(row_id: "r6", ask_text: "Act", scope_label: "x") + + refute Map.has_key?(row.attributes, :actor) + end + end + + describe "kind registration" do + test "needs_you_section is in workflow_kinds" do + assert :needs_you_section in Components.workflow_kinds() + end + + test "blocker_row is in row_artifact_kinds" do + assert :blocker_row in Components.row_artifact_kinds() + end + + test "both kinds are in the combined kinds/0 list" do + all_kinds = Components.kinds() + assert :needs_you_section in all_kinds + assert :blocker_row in all_kinds + end + end +end diff --git a/packages/unified_ui/lib/unified_ui/widget_components.ex b/packages/unified_ui/lib/unified_ui/widget_components.ex index 513bd4c0..025871d0 100644 --- a/packages/unified_ui/lib/unified_ui/widget_components.ex +++ b/packages/unified_ui/lib/unified_ui/widget_components.ex @@ -212,6 +212,20 @@ defmodule UnifiedUi.WidgetComponents do summary: "Keyboard-driven command palette overlay with open state, filterable items, and children.", aliases: [] + }, + %{ + kind: :needs_you_section, + family: :workflow_progress_and_status, + summary: + "Attention band listing operator-facing blockers; renders a list of blocker_row children with overflow handling.", + aliases: [] + }, + %{ + kind: :blocker_row, + family: :row_and_artifact, + summary: + "Single actionable row in a needs_you_section: actor avatar + ask text + scope label + jump action.", + aliases: [] } ] diff --git a/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs b/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs index 87320a31..e60986d7 100644 --- a/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs +++ b/packages/unified_ui/test/unified_ui/widget_components_catalog_test.exs @@ -28,13 +28,14 @@ defmodule UnifiedUi.WidgetComponentsCatalogTest do :chat_composer, :mode_nav ], - row_and_artifact: [:list_item_multi_column, :artifact_row], + row_and_artifact: [:list_item_multi_column, :artifact_row, :blocker_row], workflow_progress_and_status: [ :pipeline_stepper_horizontal, :segmented_progress_bar, :workflow_stage_list_vertical, :meter_thin, - :unread_badge + :unread_badge, + :needs_you_section ], layer_shell_and_callout: [ :sticky_frosted_header, From 57ff77e61f0841798a6ce46c42963c2c4aecbd85 Mon Sep 17 00:00:00 2001 From: Matt de Courcelle Date: Mon, 18 May 2026 12:49:12 -0500 Subject: [PATCH 2/2] align needs_you_section + blocker_row canonical integration footprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply registry-threading discipline per the updated widget skill (project-ariston PR #62 + memory entry [[new-canonical-widget-needs- registry-threading]]). Mirrors Pascal's a902750 (PresenceDot) pattern. Touch points (delta): widgets aggregation + both adapters + multi-layer tests — for both kinds. - widgets.ex: add :workflow + :row_and_artifact to @type family + families/0; add workflow_modules/0 + row_and_artifact_modules/0; wire into modules/0 - widgets/workflow.ex: new per-family aggregation module (NeedsYouSection) - widgets/row_and_artifact.ex: new per-family aggregation module (BlockerRow) - widgets/navigation.ex: remove NeedsYouSection + BlockerRow (moved to correct per-family modules) - iur_adapter.ex: add base_attributes/2 clauses for :needs_you_section (:workflow_progress_and_status family, :section attrs) and :blocker_row (:row_and_artifact family, :blocker + :actor attrs) - live_ui_adapter.ex: add generate_heex/2 fallback clauses for both kinds - needs_you_section_test.exs: family-exposure tests for both kinds - renderer_test.exs: renderer clause tests for needs_you_section (with items + empty state) and blocker_row - phase_31_canonical_conversion_test.exs: both kinds added to @component_samples - phase_31_runtime_adapter_test.exs: adapter dispatch tests for both kinds Co-Authored-By: Claude Sonnet 4 --- lib/ash_ui/rendering/iur_adapter.ex | 36 ++++++++ lib/ash_ui/rendering/live_ui_adapter.ex | 82 +++++++++++++++++++ packages/live_ui/lib/live_ui/widgets.ex | 33 +++++++- .../live_ui/lib/live_ui/widgets/navigation.ex | 4 +- .../lib/live_ui/widgets/row_and_artifact.ex | 12 +++ .../live_ui/lib/live_ui/widgets/workflow.ex | 12 +++ .../test/live_ui/needs_you_section_test.exs | 12 +++ .../live_ui/test/live_ui/renderer_test.exs | 58 ++++++++++++- .../phase_31_canonical_conversion_test.exs | 7 +- test/ash_ui/phase_31_runtime_adapter_test.exs | 47 +++++++++++ 10 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex create mode 100644 packages/live_ui/lib/live_ui/widgets/workflow.ex diff --git a/lib/ash_ui/rendering/iur_adapter.ex b/lib/ash_ui/rendering/iur_adapter.ex index 370f6977..74cd88e3 100644 --- a/lib/ash_ui/rendering/iur_adapter.ex +++ b/lib/ash_ui/rendering/iur_adapter.ex @@ -625,6 +625,42 @@ defmodule AshUI.Rendering.IURAdapter do defp base_attributes(:canvas, props), do: %{canvas: Map.drop(props, attachment_prop_keys())} defp base_attributes(:form_builder, props), do: %{form: Map.drop(props, attachment_prop_keys())} defp base_attributes(:field_group, props), do: %{group: Map.drop(props, attachment_prop_keys())} + + defp base_attributes(:needs_you_section = kind, props) do + component_attributes( + kind, + :workflow_progress_and_status, + %{ + section: + compact_map(%{ + title: first_present(props, [:title, :label]), + empty_state_text: first_present(props, [:empty_state_text]), + max_visible: fetch(props, :max_visible) + }) + }, + props + ) + end + + defp base_attributes(:blocker_row = kind, props) do + component_attributes( + kind, + :row_and_artifact, + %{ + blocker: + compact_map(%{ + row_id: first_present(props, [:row_id, :id]), + ask_text: first_present(props, [:ask_text, :text, :label]), + scope_label: first_present(props, [:scope_label, :scope]), + scope_intent: first_present(props, [:scope_intent]) || :jump_to_blocker, + severity: normalize_existing_atom(first_present(props, [:severity]) || :info) + }), + actor: normalize_map(fetch(props, :actor)) + }, + props + ) + end + defp base_attributes(kind, props), do: %{kind => Map.drop(props, attachment_prop_keys())} defp style_attributes(props) do diff --git a/lib/ash_ui/rendering/live_ui_adapter.ex b/lib/ash_ui/rendering/live_ui_adapter.ex index 7f4fbe0a..ff1a66be 100644 --- a/lib/ash_ui/rendering/live_ui_adapter.ex +++ b/lib/ash_ui/rendering/live_ui_adapter.ex @@ -549,6 +549,88 @@ defmodule AshUI.Rendering.LiveUIAdapter do """ end + defp generate_heex(%{"type" => "needs_you_section"} = iur, opts) do + props = iur["props"] || %{} + section = normalize_item(prop(props, "section", %{})) + title = escaped_text_prop(section, ["title", "label"], "Needs you") + empty_state_text = escaped_text_prop(section, "empty_state_text", "You're all caught up.") + max_visible = numeric_value(section, "max_visible", 5) + children = List.wrap(iur["children"] || []) + + visible_children = Enum.take(children, max_visible) + overflow_count = length(children) - max_visible + + rows_html = generate_children(visible_children, opts) + + overflow_html = + if overflow_count > 0 do + ~s() + else + "" + end + + item_count = length(children) + + count_html = + if children != [] do + "(#{item_count})" + else + "" + end + + list_or_empty_html = + if children == [] do + ~s(

#{empty_state_text}

) + else + ~s(
    #{rows_html}
#{overflow_html}) + end + + """ +
+
+

#{title}

#{count_html} +
+ #{list_or_empty_html} +
+ """ + end + + defp generate_heex(%{"type" => "blocker_row"} = iur, _opts) do + props = iur["props"] || %{} + blocker = normalize_item(prop(props, "blocker", %{})) + actor = normalize_item(prop(props, "actor", %{})) + + ask_text = escaped_text_prop(blocker, ["ask_text", "text", "label"], "") + scope_label = escaped_text_prop(blocker, ["scope_label", "scope"], "") + severity = escaped_text_prop(blocker, "severity", "info") + row_id = html_attr(prop(blocker, "row_id", prop(iur, "id", ""))) + + actor_initials = escaped_text_prop(actor, ["initials"], "") + actor_name = escaped_text_prop(actor, ["actor_name", "name"], "") + actor_image = prop(actor, "image_source") + + aria_label = "#{ask_text} — #{scope_label}" + + avatar_html = + if actor_image do + ~s() + else + ~s() + end + + """ + + """ + end + defp generate_heex(%{"type" => "artifact_row"} = iur, opts) do props = iur["props"] || %{} title = escaped_text_prop(props, ["title", "label"], "Artifact") diff --git a/packages/live_ui/lib/live_ui/widgets.ex b/packages/live_ui/lib/live_ui/widgets.ex index 6ef7843b..2923a177 100644 --- a/packages/live_ui/lib/live_ui/widgets.ex +++ b/packages/live_ui/lib/live_ui/widgets.ex @@ -13,19 +13,38 @@ defmodule LiveUi.Widgets do | :data | :operational | :display + | :workflow + | :row_and_artifact @type widget_module :: module() @spec families() :: [family()] def families do - [:content, :input, :navigation, :feedback, :layout, :overlay, :data, :operational, :display] + [ + :content, + :input, + :navigation, + :feedback, + :layout, + :overlay, + :data, + :operational, + :display, + :workflow, + :row_and_artifact + ] end @spec modules() :: [widget_module()] def modules do foundational_modules() ++ input_modules() ++ - navigation_modules() ++ advanced_modules() ++ overlay_modules() ++ display_modules() + navigation_modules() ++ + advanced_modules() ++ + overlay_modules() ++ + display_modules() ++ + workflow_modules() ++ + row_and_artifact_modules() end @spec metadata() :: [LiveUi.Component.Metadata.t()] @@ -63,6 +82,16 @@ defmodule LiveUi.Widgets do LiveUi.Widgets.Display.modules() end + @spec workflow_modules() :: [widget_module()] + def workflow_modules do + LiveUi.Widgets.Workflow.modules() + end + + @spec row_and_artifact_modules() :: [widget_module()] + def row_and_artifact_modules do + LiveUi.Widgets.RowAndArtifact.modules() + end + @spec namespace() :: module() def namespace, do: __MODULE__ end diff --git a/packages/live_ui/lib/live_ui/widgets/navigation.ex b/packages/live_ui/lib/live_ui/widgets/navigation.ex index 98e4589c..090bc122 100644 --- a/packages/live_ui/lib/live_ui/widgets/navigation.ex +++ b/packages/live_ui/lib/live_ui/widgets/navigation.ex @@ -6,9 +6,7 @@ defmodule LiveUi.Widgets.Navigation do @modules [ LiveUi.Widgets.Menu, LiveUi.Widgets.Tabs, - LiveUi.Widgets.CommandPalette, - LiveUi.Widgets.NeedsYouSection, - LiveUi.Widgets.BlockerRow + LiveUi.Widgets.CommandPalette ] @spec modules() :: [module()] diff --git a/packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex b/packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex new file mode 100644 index 00000000..f8a75cc1 --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/row_and_artifact.ex @@ -0,0 +1,12 @@ +defmodule LiveUi.Widgets.RowAndArtifact do + @moduledoc """ + Reference surface for row and artifact widgets. + """ + + @modules [ + LiveUi.Widgets.BlockerRow + ] + + @spec modules() :: [module()] + def modules, do: @modules +end diff --git a/packages/live_ui/lib/live_ui/widgets/workflow.ex b/packages/live_ui/lib/live_ui/widgets/workflow.ex new file mode 100644 index 00000000..77c40387 --- /dev/null +++ b/packages/live_ui/lib/live_ui/widgets/workflow.ex @@ -0,0 +1,12 @@ +defmodule LiveUi.Widgets.Workflow do + @moduledoc """ + Reference surface for workflow-oriented widgets. + """ + + @modules [ + LiveUi.Widgets.NeedsYouSection + ] + + @spec modules() :: [module()] + def modules, do: @modules +end diff --git a/packages/live_ui/test/live_ui/needs_you_section_test.exs b/packages/live_ui/test/live_ui/needs_you_section_test.exs index 683b2637..ffcdf615 100644 --- a/packages/live_ui/test/live_ui/needs_you_section_test.exs +++ b/packages/live_ui/test/live_ui/needs_you_section_test.exs @@ -15,6 +15,12 @@ defmodule LiveUi.NeedsYouSectionTest do assert metadata.name == :needs_you_section end + test "is exposed through the workflow widget family" do + assert :workflow in LiveUi.Widgets.families() + assert LiveUi.Widgets.NeedsYouSection in LiveUi.Widgets.workflow_modules() + assert LiveUi.Widgets.NeedsYouSection in LiveUi.Widgets.modules() + end + test "renders with needs-you-section widget marker" do html = render_component(&LiveUi.Widgets.NeedsYouSection.component/1, %{ @@ -152,6 +158,12 @@ defmodule LiveUi.NeedsYouSectionTest do assert metadata.name == :blocker_row end + test "is exposed through the row_and_artifact widget family" do + assert :row_and_artifact in LiveUi.Widgets.families() + assert LiveUi.Widgets.BlockerRow in LiveUi.Widgets.row_and_artifact_modules() + assert LiveUi.Widgets.BlockerRow in LiveUi.Widgets.modules() + end + test "renders with blocker-row widget marker" do html = render_component(&LiveUi.Widgets.BlockerRow.component/1, %{ diff --git a/packages/live_ui/test/live_ui/renderer_test.exs b/packages/live_ui/test/live_ui/renderer_test.exs index b73fcf01..6c6804d3 100644 --- a/packages/live_ui/test/live_ui/renderer_test.exs +++ b/packages/live_ui/test/live_ui/renderer_test.exs @@ -4,7 +4,7 @@ defmodule LiveUi.RendererTest do import Phoenix.LiveViewTest alias UnifiedIUR.{Container, Element, Forms, Interaction, Layout} - alias UnifiedIUR.Widgets.{Foundational, Input, Navigation} + alias UnifiedIUR.Widgets.{Components, Foundational, Input, Navigation} test "renderer maps foundational canonical widgets and layouts into native components" do element = @@ -251,4 +251,60 @@ defmodule LiveUi.RendererTest do assert html =~ ~s(name="element_id" value="profile-name") assert html =~ ~s(name="widget" value="text_input") end + + test "renderer maps canonical needs_you_section through the native component boundary" do + blocker = + Components.blocker_row( + id: "br-renderer-test", + row_id: "br-1", + ask_text: "Review the proposal", + scope_label: "doc: ariston-proposal.md", + actor: %{initials: "PC", actor_name: "Pascal"} + ) + + element = Components.needs_you_section([blocker], id: "nys-renderer-test", title: "Needs you") + + html = render_component(&LiveUi.Renderer.render/1, %{element: element}) + + assert html =~ ~s(data-live-ui-widget-boundary="needs_you_section") + assert html =~ ~s(data-live-ui-widget="needs-you-section") + assert html =~ "Needs you" + assert html =~ "Review the proposal" + end + + test "renderer maps canonical needs_you_section empty state through the native component boundary" do + element = + Components.needs_you_section([], + id: "nys-empty-renderer-test", + empty_state_text: "All clear!" + ) + + html = render_component(&LiveUi.Renderer.render/1, %{element: element}) + + assert html =~ ~s(data-live-ui-widget-boundary="needs_you_section") + assert html =~ ~s(data-live-ui-widget="needs-you-section") + assert html =~ "All clear!" + refute html =~ " IURAdapter.to_canonical() + + assert canonical.kind == :needs_you_section + assert canonical.attributes.component.family == :workflow_progress_and_status + assert canonical.attributes.section.title == "Needs you" + assert canonical.attributes.section.empty_state_text == "All clear" + + assert {:ok, heex} = LiveUIAdapter.render(canonical, force_fallback: true) + assert heex =~ "ash-needs-you-section" + assert heex =~ "Needs you" + assert heex =~ "All clear" + end + + test "LiveUIAdapter renders blocker_row fallback preserving row semantics" do + assert {:ok, canonical} = + IUR.new(:blocker_row, + id: "br-adapter-test", + props: %{ + row_id: "br-a1", + ask_text: "Approve the plan", + scope_label: "repo: ariston-ui", + severity: :warn, + actor: %{initials: "PC", actor_name: "Pascal"} + } + ) + |> IURAdapter.to_canonical() + + assert canonical.kind == :blocker_row + assert canonical.attributes.component.family == :row_and_artifact + assert canonical.attributes.blocker.ask_text == "Approve the plan" + assert canonical.attributes.blocker.severity == :warn + + assert {:ok, heex} = LiveUIAdapter.render(canonical, force_fallback: true) + assert heex =~ "ash-blocker-row" + assert heex =~ "Approve the plan" + assert heex =~ "repo: ariston-ui" + assert heex =~ ~s(aria-label="Approve the plan — repo: ariston-ui") + end + end + defp canonical_component do assert {:ok, canonical} = IUR.new(:event_callout,