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(#{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/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.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/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/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/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 new file mode 100644 index 00000000..ffcdf615 --- /dev/null +++ b/packages/live_ui/test/live_ui/needs_you_section_test.exs @@ -0,0 +1,260 @@ +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 "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, %{ + 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 "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, %{ + 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, diff --git a/test/ash_ui/phase_31_canonical_conversion_test.exs b/test/ash_ui/phase_31_canonical_conversion_test.exs index b23190a0..a888b595 100644 --- a/test/ash_ui/phase_31_canonical_conversion_test.exs +++ b/test/ash_ui/phase_31_canonical_conversion_test.exs @@ -39,7 +39,12 @@ defmodule AshUI.Phase31CanonicalConversionTest do {:redline_inline, :redline_and_code, %{segments: [%{state: :insert, text: "new"}]}, :redline}, {:code_block_syntax_highlighted, :redline_and_code, %{language: :elixir, tokens: [%{type: :keyword, text: "def"}]}, :code}, - {:list_repeat, :composition_behavior, %{repeat_binding: :rows, row_fields: [:id]}, :repeat} + {:list_repeat, :composition_behavior, %{repeat_binding: :rows, row_fields: [:id]}, :repeat}, + {:needs_you_section, :workflow_progress_and_status, + %{title: "Needs you", empty_state_text: "All clear", max_visible: 3}, :section}, + {:blocker_row, :row_and_artifact, + %{row_id: "br-1", ask_text: "Review spec", scope_label: "doc: spec.md", severity: :warn}, + :blocker} ] describe "Section 31.3 - canonical conversion and validation" do diff --git a/test/ash_ui/phase_31_runtime_adapter_test.exs b/test/ash_ui/phase_31_runtime_adapter_test.exs index 56f5dc6b..4095debe 100644 --- a/test/ash_ui/phase_31_runtime_adapter_test.exs +++ b/test/ash_ui/phase_31_runtime_adapter_test.exs @@ -62,6 +62,53 @@ defmodule AshUI.Phase31RuntimeAdapterTest do end end + describe "Section 31.4b - needs_you_section + blocker_row adapter dispatch" do + test "LiveUIAdapter renders needs_you_section fallback preserving section semantics" do + assert {:ok, canonical} = + IUR.new(:needs_you_section, + id: "nys-adapter-test", + props: %{title: "Needs you", empty_state_text: "All clear", max_visible: 5} + ) + |> 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,