Current section

Files

Jump to
observer_web lib web components core.ex
Raw

lib/web/components/core.ex

defmodule Observer.Web.Components.Core do
@moduledoc """
Provides core UI components.
"""
use Observer.Web, :html
alias Phoenix.HTML.Form
@doc ~S"""
Renders a table with generic metric log styling.
## Examples
<.table_tracing id="users" rows={@users}>
<:col :let={user} label="id"><%= user.id %></:col>
<:col :let={user} label="username"><%= user.username %></:col>
</.table_tracing>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :transition, :boolean, default: false
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col slots"
slot :col, required: true do
attr :label, :string
end
def table_tracing(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<div class="px-4 sm:overflow-visible sm:px-0 ">
<div id={"#{@id}-table"} class="block" phx-hook="ScrollBottom">
<table class="items-center w-full border-collapse ">
<thead class="text-xs text-left align-middle leading-6 text-blueGray-500 uppercase sticky top-0 z-10">
<tr>
<th :for={col <- @col} class="p-1 pb-1 pr-6 font-semibold font-normal">
{col[:label]}
</th>
</tr>
</thead>
<tbody
id={"#{@id}-tbody"}
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
class=" relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700 dark:text-zinc-100"
>
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class="group bg-white dark:bg-gray-800 "
phx-mounted={
@transition &&
JS.transition(
{"first:ease-in duration-300", "first:opacity-0 first:p-0 first:h-0",
"first:opacity-100"},
time: 100
)
}
>
<td
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={[
"relative p-0 text-base-content/90 max-w-md ",
@row_click && "hover:cursor-pointer"
]}
>
<div class="block px-1 py-1 pr-6 text-xs font-mono ">
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 dark:group-hover:bg-zinc-700 sm:rounded-l-xl" />
<span class={[
"relative",
i == 0 && "whitespace-nowrap font-semibold text-zinc-900 dark:text-zinc-100"
]}>
{render_slot(col, @row_item.(row))}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
"""
end
@doc ~S"""
Renders a value that stays plain text while it is short and collapses behind
a click-to-expand summary when it exceeds `max_chars`, following the same
pattern as the tracing results content.
## Examples
<.truncated value={row.name} />
<.truncated value={table.owner_label} max_chars={30} />
"""
attr :value, :string, required: true
attr :max_chars, :integer, default: 40
def truncated(assigns) do
~H"""
<span :if={String.length(@value) <= @max_chars}>{@value}</span>
<details :if={String.length(@value) > @max_chars}>
<summary class="cursor-pointer whitespace-nowrap" title={@value}>
{String.slice(@value, 0, @max_chars)}…
</summary>
<div class="mt-2 max-w-md whitespace-pre-wrap break-all">{@value}</div>
</details>
"""
end
@doc ~S"""
Renders a disclosure row: a single truncated line preceded by a triangle marker that is
filled when there is more content to expand and hollow (and non-interactive) when the line
is all there is - so a list of rows reads at a glance which entries hide detail.
Used by the Logs pillar; any table listing collapsible rows can adopt it.
## Examples
<.disclosure id="log-entry-1" expandable?={entry.expandable?} title={entry.summary}>
<:summary>{entry.summary}</:summary>
<pre>{entry.content}</pre>
</.disclosure>
"""
attr :id, :string, required: true
attr :expandable?, :boolean, default: true
attr :summary_class, :any, default: nil
attr :title, :string, default: nil
slot :summary, required: true
slot :inner_block
def disclosure(assigns) do
~H"""
<div :if={not @expandable?} id={@id} class="flex items-start gap-1">
<span class="shrink-0 w-4 text-center select-none text-gray-300 dark:text-gray-600">
â–·
</span>
<span class={["min-w-0 flex-1 truncate", @summary_class]} title={@title}>
{render_slot(@summary)}
</span>
</div>
<details :if={@expandable?} id={@id} class="group">
<summary class="flex items-start gap-1 cursor-pointer list-none [&::-webkit-details-marker]:hidden">
<span class="shrink-0 w-4 text-center select-none inline-block text-gray-700 dark:text-gray-200 transition-transform group-open:rotate-90">
â–¶
</span>
<span class={["min-w-0 flex-1 truncate", @summary_class]} title={@title}>
{render_slot(@summary)}
</span>
</summary>
<div class="pl-5">
{render_slot(@inner_block)}
</div>
</details>
"""
end
@doc ~S"""
Renders a table with process styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id"><%= user.id %></:col>
<:col :let={user} label="username"><%= user.username %></:col>
</.table>
"""
attr :id, :string, required: true
attr :title, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :transition, :boolean, default: false
attr :title_bg_color, :string, default: "standard"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col slots"
slot :col, required: true do
attr :label, :string
end
def table_process(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<div class="px-4 sm:overflow-visible sm:px-0 rounded bg-white dark:bg-gray-800 border border-solid border-blueGray-100">
<div id={"#{@id}-table"} class="block max-h-[600px]">
<table class="items-center w-full border-collapse">
<div class={[
"text-center text-sm font-mono font-semibold rounded-t px-6 py-1",
title_bg_color(@title_bg_color)
]}>
{@title}
</div>
<tbody
id={"#{@id}-tbody"}
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
class=" relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700 dark:text-zinc-200"
>
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class="group hover:bg-zinc-50"
phx-mounted={
@transition &&
JS.transition(
{"first:ease-in duration-300", "first:opacity-0 first:p-0 first:h-0",
"first:opacity-100"},
time: 100
)
}
>
<td
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
>
<div class="block px-1 py-1 pr-6 text-xs font-mono ">
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 dark:group-hover:bg-zinc-700 sm:rounded-l-xl" />
<span class={[
"relative",
i == 0 && "font-semibold text-zinc-900 dark:text-zinc-300"
]}>
{render_slot(col, @row_item.(row))}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
"""
end
defp title_bg_color("liveview"), do: "bg-green-200 dark:bg-green-900"
defp title_bg_color(_any), do: "bg-zinc-200 dark:bg-zinc-500"
@doc ~S"""
Renders a table with generic metrics styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id"><%= user.id %></:col>
<:col :let={user} label="username"><%= user.username %></:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :transition, :boolean, default: false
attr :h_max_size, :string, default: "h-64"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
def table_metrics(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<div class="px-4 sm:overflow-visible sm:px-0">
<div
id={"#{@id}-table"}
class={["block overflow-y-auto", "#{@h_max_size}"]}
phx-hook="ScrollBottom"
>
<table class="items-center w-full border-collapse">
<thead class="text-xs text-left align-middle leading-6 bg-white bg-blueGray-50 dark:bg-gray-800 text-blueGray-500 dark:text-blueGray-700 uppercase sticky top-0 z-10">
<tr>
<th :for={col <- @col} class="p-1 pb-1 pr-6 font-semibold font-normal">
{col[:label]}
</th>
</tr>
</thead>
<tbody
id={@id}
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700 dark:text-neutral-400"
>
<tr
:for={row <- @rows}
id={@row_id && @row_id.(row)}
class="group hover:bg-zinc-50"
phx-mounted={
@transition &&
JS.transition(
{"last:ease-in duration-300", "last:opacity-0 last:p-0 last:h-0",
"last:opacity-100"},
time: 300
)
}
>
<td
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
>
<div class="block px-1 py-1 pr-6 text-xs font-mono ">
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 dark:group-hover:bg-zinc-700 sm:rounded-l-xl" />
<span class={[
"relative",
i == 0 && "font-semibold text-zinc-900 dark:text-zinc-100"
]}>
{render_slot(col, @row_item.(row))}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
"""
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information. Unsupported types, such as hidden and radio,
are best written directly in your templates.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values:
~w(checkbox color date datetime-local email file month number password
range search select tel text textarea time url week select-undefined-class text-custom-search)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
slot :inner_block
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div phx-feedback-for={@name}>
<label class="flex items-center bg-white dark:bg-gray-800 gap-4 text-sm leading-6 text-zinc-600">
<input type="hidden" name={@name} value="false" />
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class="rounded border-zinc-300 focus:ring-0"
{@rest}
/> {@label}
</label>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>{@label}</.label>
<select
id={@id}
name={@name}
class="mt-2 block bg-white dark:bg-gray-800 w-full rounded-md border border-gray-300 shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Form.options_for_select(@options, @value)}
</select>
</div>
"""
end
def input(%{type: "select-undefined-class"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>{@label}</.label>
<select id={@id} name={@name} multiple={@multiple} {@rest}>
<option :if={@prompt} value="">{@prompt}</option>
{Form.options_for_select(@options, @value)}
</select>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>{@label}</.label>
<textarea
id={@id}
name={@name}
class={[
"mt-2 block bg-white dark:bg-gray-800 w-full rounded-lg focus:ring-0 sm:text-sm sm:leading-6",
"min-h-[6rem] phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
><%= Form.normalize_value("textarea", @value) %></textarea>
</div>
"""
end
def input(%{type: "text-custom-search"} = assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>{@label}</.label>
<input
type="text"
name={@name}
id={@id}
value={Form.normalize_value(@type, @value)}
class={[
"ml-2 mr-2 h-2 bg-white dark:bg-gray-800 block rounded-lg focus:ring-0 sm:text-sm sm:leading-6",
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
/>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>{@label}</.label>
<input
type={@type}
name={@name}
id={@id}
value={Form.normalize_value(@type, @value)}
class={[
"mt-2 block bg-white dark:bg-gray-800 w-full rounded-lg focus:ring-0 sm:text-sm sm:leading-6",
"phx-no-feedback:border-zinc-300 phx-no-feedback:focus:border-zinc-400",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
/>
</div>
"""
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, _opts}) do
msg
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
@doc """
Renders a label.
"""
attr :for, :string, default: nil
slot :inner_block, required: true
def label(assigns) do
~H"""
<label for={@for} class="block text-sm font-semibold leading-6">
{render_slot(@inner_block)}
</label>
"""
end
end