Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion guides/user/UG-0003-widget-types-properties-and-signals.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ authoring boundaries and normalize before renderer-facing output.
| Form control and composer | `runtime_form_shell`, `segmented_button_group`, `chat_composer`, `collection_picker`, `mode_nav` | `phoenix_form` -> `runtime_form_shell` |
| Row and artifact | `list_item_multi_column`, `artifact_row`, `thread_card` | none |
| Workflow, progress, and status | `pipeline_stepper_horizontal`, `segmented_progress_bar`, `workflow_stage_list_vertical`, `meter_thin`, `unread_badge`, `workflow_progress_status_card` | none |
| Layer shell and callout | `sticky_frosted_header`, `slide_over_panel`, `event_callout`, `top_strip`, `sidebar_shell`, `sidebar_section`, `sidebar_item`, `command_palette`, `right_rail`, `composer_query_preview` | none |
| Layer shell and callout | `sticky_frosted_header`, `slide_over_panel`, `event_callout`, `top_strip`, `sidebar_shell`, `sidebar_section`, `sidebar_item`, `command_palette`, `right_rail`, `composer_query_preview`, `propose_new_doc_card` | none |
| Redline and code | `redline_inline`, `code_block_syntax_highlighted` | none |
| Composition behavior | `list_repeat` | `repeat` -> `list_repeat`, `ui_relationship_repeat` -> `list_repeat` |

Expand Down Expand Up @@ -153,6 +153,14 @@ is not a rail by itself: place it inside `right_rail` when the picker belongs
in a secondary panel, and keep bundle-specific cards, drag hooks, and LiveView
event names in the renderer or host layer.

`propose_new_doc_card` is the canonical callout card for an agent-authored
document proposal. Author props such as `target_path`, `title`,
`body_md_preview`, `status`, optional `body_md`, `conversation_seed_md`,
`actor_handle`, `proposed_at`, `expanded?`, and `seed_expanded?`. Use
canonical `accept`, `reject`, `preview`, `body_toggled`, and
`conversation_seed_toggled` interactions for operator actions. Keep LiveView
event fields, route helpers, and product-specific copy in the host layer.

You can also author `custom:*` types. They are accepted as widget types, but the
shipped validation/runtime does not automatically give them built-in signal
semantics. Some explicitly supported custom surfaces do have dedicated fallback
Expand Down
43 changes: 43 additions & 0 deletions lib/ash_ui/rendering/iur_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,13 @@ defmodule AshUI.Rendering.IURAdapter do
|> Map.fetch!(:attributes)
end

defp base_attributes(:propose_new_doc_card, props) do
props
|> propose_new_doc_card_opts()
|> IURComponents.propose_new_doc_card()
|> Map.fetch!(:attributes)
end

defp base_attributes(:redline_inline = kind, props) do
component_attributes(
kind,
Expand Down Expand Up @@ -1113,6 +1120,42 @@ defmodule AshUI.Rendering.IURAdapter do
|> compact_map()
end

defp propose_new_doc_card_opts(props) do
proposal = props |> fetch(:propose_new_doc, %{}) |> normalize_map()

%{
id: first_present(props, [:_element_id, :id]),
target_path:
first_present(proposal, [:target_path]) || first_present(props, [:target_path]),
title: first_present(proposal, [:title]) || first_present(props, [:title, :label, :name]),
body_md_preview:
first_present(proposal, [:body_md_preview]) ||
first_present(props, [:body_md_preview, :preview, :summary]),
body_md: first_present(proposal, [:body_md]) || first_present(props, [:body_md, :body]),
status:
normalize_existing_atom(
first_present(proposal, [:status]) || first_present(props, [:status]) || :pending
),
conversation_seed_md:
first_present(proposal, [:conversation_seed_md]) ||
first_present(props, [:conversation_seed_md]),
actor_handle:
first_present(proposal, [:actor_handle]) || first_present(props, [:actor_handle]),
proposed_at:
first_present(proposal, [:proposed_at]) || first_present(props, [:proposed_at]),
actions:
first_present(proposal, [:actions]) ||
first_present(props, [:actions]) ||
[:accept, :reject, :preview],
accept_intent: first_present(props, [:accept_intent]),
reject_intent: first_present(props, [:reject_intent]),
preview_intent: first_present(props, [:preview_intent]),
interactions: fetch(props, :interactions),
interaction: fetch(props, :interaction)
}
|> compact_map()
end

defp collection_picker_opts(props) do
picker = props |> fetch(:collection_picker, %{}) |> normalize_map()

Expand Down
84 changes: 84 additions & 0 deletions lib/ash_ui/rendering/live_ui_adapter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,90 @@ defmodule AshUI.Rendering.LiveUIAdapter do
"""
end

defp generate_heex(%{"type" => "propose_new_doc_card"} = iur, _opts) do
props = iur["props"] || %{}
proposal = props |> prop("propose_new_doc", props) |> normalize_item()

target_path =
escaped_text_prop(proposal, "target_path", escaped_text_prop(props, "target_path", ""))

title =
escaped_text_prop(
proposal,
"title",
escaped_text_prop(props, ["title", "label"], "Document")
)

body_md_preview =
escaped_text_prop(
proposal,
"body_md_preview",
escaped_text_prop(proposal, "body_md", escaped_text_prop(props, "body_md_preview", ""))
)

body_md = escaped_text_prop(proposal, "body_md")
status = escaped_text_prop(proposal, "status", escaped_text_prop(props, "status", "pending"))
actor_handle = escaped_text_prop(proposal, "actor_handle")
proposed_at = escaped_text_prop(proposal, "proposed_at")
seed = escaped_text_prop(proposal, "conversation_seed_md")
iur_id = html_attr(iur["id"] || iur[:id] || "propose-new-doc-card")
body_id = "#{iur_id}-body"
seed_id = "#{iur_id}-conversation-seed"

full_body? = body_md not in [nil, ""] and body_md != body_md_preview

body_toggle =
if full_body? do
~s(<button type="button" class="ash-propose-new-doc-card__body-toggle" aria-expanded="false" aria-controls="#{body_id}">Show full draft</button>)
else
""
end

seed_html =
if seed do
"""
<section class="ash-propose-new-doc-card__conversation">
<button type="button" class="ash-propose-new-doc-card__conversation-toggle" aria-expanded="false" aria-controls="#{seed_id}">Conversation seed</button>
</section>
"""
else
""
end

decision_html =
if status == "pending" do
"""
<button type="button" class="ash-propose-new-doc-card__accept" aria-label="Accept proposed document #{title}">Accept</button>
<button type="button" class="ash-propose-new-doc-card__reject" aria-label="Reject proposed document #{title}">Reject</button>
"""
else
~s(<p class="ash-propose-new-doc-card__locked-message" role="status">This proposal is #{status}.</p>)
end

"""
<article id="#{iur_id}" class="#{css_classes(["ash-propose-new-doc-card", "ash-propose-new-doc-card--#{status}", prop_class(iur)])}" data-live-ui-widget="propose-new-doc-card" data-status="#{status}" data-target-path="#{target_path}" aria-label="Proposed document #{title}, #{status}"#{style_attr(prop_style(iur))}>
<header class="ash-propose-new-doc-card__header">
<div class="ash-propose-new-doc-card__identity">
<h3 class="ash-propose-new-doc-card__title">#{title}</h3>
#{if actor_handle, do: "<span class=\"ash-propose-new-doc-card__actor\">#{actor_handle}</span>", else: ""}
#{if proposed_at, do: "<time class=\"ash-propose-new-doc-card__proposed-at\" datetime=\"#{proposed_at}\">#{proposed_at}</time>", else: ""}
</div>
<span class="ash-propose-new-doc-card__status-badge ash-propose-new-doc-card__status-badge--#{status}" role="status">#{status}</span>
</header>
<p class="ash-propose-new-doc-card__target-path font-mono">#{target_path}</p>
<section id="#{body_id}" class="ash-propose-new-doc-card__body" aria-label="Draft preview for #{title}">
<pre class="ash-propose-new-doc-card__body-preview">#{body_md_preview}</pre>
</section>
#{body_toggle}
#{seed_html}
<footer class="ash-propose-new-doc-card__actions">
#{decision_html}
<button type="button" class="ash-propose-new-doc-card__preview" aria-label="Preview proposed document #{title}">Preview</button>
</footer>
</article>
"""
end

defp generate_heex(%{"type" => "list_item_multi_column"} = iur, opts) do
props = iur["props"] || %{}
active? = truthy_prop(props, "active?", truthy_prop(props, "active", false))
Expand Down
93 changes: 93 additions & 0 deletions packages/live_ui/lib/live_ui/renderer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,46 @@ defmodule LiveUi.Renderer do
"""
end

# NOTE: `:propose_new_doc_card` is a canonical layer-shell callout. Keep this
# native clause before the generic component fallback so decision actions keep
# canonical interaction transport.
def render(%{element: %Element{kind: :propose_new_doc_card}} = assigns) do
proposal = propose_new_doc_attributes(assigns.element)

assigns =
assigns
|> assign(:proposal, proposal)
|> assign(
:action_attrs,
propose_new_doc_action_attrs(assigns.element, Map.get(assigns, :event_target))
)
|> assign(:style_attrs, style_rest(assigns.element))

~H"""
<LiveUi.Widgets.ProposeNewDocCard.component
id={element_id(@element, "propose-new-doc-card")}
target_path={string_value(map_value(@proposal, :target_path), "")}
title={string_value(map_value(@proposal, :title), "")}
body_md_preview={string_value(map_value(@proposal, :body_md_preview, map_value(@proposal, :body_md)), "")}
body_md={string_optional(map_value(@proposal, :body_md))}
status={map_value(@proposal, :status, :pending)}
conversation_seed_md={string_optional(map_value(@proposal, :conversation_seed_md))}
actor_handle={string_optional(map_value(@proposal, :actor_handle))}
proposed_at={string_optional(map_value(@proposal, :proposed_at))}
expanded?={boolean_default(map_value(@proposal, :expanded?), false)}
seed_expanded?={boolean_default(map_value(@proposal, :seed_expanded?), false)}
accept_attrs={Map.get(@action_attrs, :accept, %{})}
reject_attrs={Map.get(@action_attrs, :reject, %{})}
preview_attrs={Map.get(@action_attrs, :preview, %{})}
tone={style_tone(@element)}
variant={theme_variant(@element)}
state={style_state(@element)}
class={style_class(@element)}
{@style_attrs}
/>
"""
end

def render(%{element: %Element{kind: kind}} = assigns) when kind in @component_kinds do
assigns = assign(assigns, :style_attrs, style_rest(assigns.element))

Expand Down Expand Up @@ -2674,6 +2714,59 @@ defmodule LiveUi.Renderer do

defp query_preview_interaction_attrs(_element, _event_target, _interaction, _query), do: %{}

defp propose_new_doc_attributes(%Element{} = element) do
element.attributes
|> Map.get(:propose_new_doc, Map.get(element.attributes, "propose_new_doc", %{}))
|> case do
proposal when is_map(proposal) -> proposal
proposal when is_list(proposal) -> Map.new(proposal)
_other -> %{}
end
end

defp propose_new_doc_action_attrs(%Element{} = element, event_target) do
%{
accept: propose_new_doc_interaction_attrs(element, event_target, :accept),
reject: propose_new_doc_interaction_attrs(element, event_target, :reject),
preview: propose_new_doc_interaction_attrs(element, event_target, :preview)
}
end

defp propose_new_doc_interaction_attrs(%Element{} = element, event_target, action)
when not is_nil(event_target) do
case propose_new_doc_interaction(element, action) do
%Interaction{} = interaction ->
%{
:"phx-click" => "canonical_interaction",
:"phx-target" => event_target,
:"phx-value-interaction" => encode_interaction(interaction),
:"phx-value-element_id" => element_id(element, "propose-new-doc-card"),
:"phx-value-widget" => "propose_new_doc_card",
:"phx-value-action" => Atom.to_string(action),
:"phx-value-target_path" =>
element |> propose_new_doc_attributes() |> map_value(:target_path, "") |> to_string()
}

_ ->
%{}
end
end

defp propose_new_doc_interaction_attrs(_element, _event_target, _action), do: %{}

defp propose_new_doc_interaction(%Element{} = element, action) do
element.attributes
|> Map.get(:interactions, [])
|> List.wrap()
|> Enum.find(fn
%Interaction{family: :command, payload: payload, intent: intent} ->
map_value(payload, :command) == action or intent == action or intent == to_string(action)

_other ->
false
end)
end

defp sidebar_section_attributes(%Element{} = element) do
element.attributes
|> Map.get(:section, Map.get(element.attributes, "section", %{}))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ defmodule LiveUi.Widgets.LayerShellAndCallout do
@modules [
LiveUi.Widgets.RightRail,
LiveUi.Widgets.SidebarSection,
LiveUi.Widgets.ComposerQueryPreview
LiveUi.Widgets.ComposerQueryPreview,
LiveUi.Widgets.ProposeNewDocCard
]

@spec modules() :: [module()]
Expand Down
Loading
Loading