diff options
| author | Saumit <justsaumit@protonmail.com> | 2025-09-27 02:14:26 +0530 |
|---|---|---|
| committer | Saumit <justsaumit@protonmail.com> | 2025-09-27 02:14:26 +0530 |
| commit | 82e03978b89938219958032efb1448cc76baa181 (patch) | |
| tree | 626f3e54d52ecd49be0ed3bee30abacc0453d081 /src/flagd-ui/lib/flagd_ui_web/components | |
Initial snapshot - OpenTelemetry demo 2.1.3 -f
Diffstat (limited to 'src/flagd-ui/lib/flagd_ui_web/components')
4 files changed, 650 insertions, 0 deletions
diff --git a/src/flagd-ui/lib/flagd_ui_web/components/core_components.ex b/src/flagd-ui/lib/flagd_ui_web/components/core_components.ex new file mode 100644 index 0000000..5792123 --- /dev/null +++ b/src/flagd-ui/lib/flagd_ui_web/components/core_components.ex @@ -0,0 +1,464 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +defmodule FlagdUiWeb.CoreComponents do + @moduledoc """ + Provides core UI components. + + At first glance, this module may seem daunting, but its goal is to provide + core building blocks for your application, such as tables, forms, and + inputs. The components consist mostly of markup and are well-documented + with doc strings and declarative assigns. You may customize and style + them in any way you want, based on your application growth and needs. + + The foundation for styling is Tailwind CSS, a utility-first CSS framework, + augmented with daisyUI, a Tailwind CSS plugin that provides UI components + and themes. Here are useful references: + + * [daisyUI](https://daisyui.com/docs/intro/) - a good place to get + started and see the available components. + + * [Tailwind CSS](https://tailwindcss.com) - the foundational framework + we build on. You will use it for layout, sizing, flexbox, grid, and + spacing. + + * [Heroicons](https://heroicons.com) - see `icon/1` for usage. + + * [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) - + the component system used by Phoenix. Some components, such as `<.link>` + and `<.form>`, are defined there. + + """ + use Phoenix.Component + use Gettext, backend: FlagdUiWeb.Gettext + + alias Phoenix.LiveView.JS + + @doc """ + Renders flash notices. + + ## Examples + + <.flash kind={:info} flash={@flash} /> + <.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash> + """ + attr :id, :string, doc: "the optional id of flash container" + attr :flash, :map, default: %{}, doc: "the map of flash messages to display" + attr :title, :string, default: nil + attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup" + attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container" + + slot :inner_block, doc: "the optional inner block that renders the flash message" + + def flash(assigns) do + assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end) + + ~H""" + <div + :if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)} + id={@id} + phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")} + role="alert" + class="toast toast-top toast-end z-50" + {@rest} + > + <div class={[ + "alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap", + @kind == :info && "alert-info", + @kind == :error && "alert-error" + ]}> + <.icon :if={@kind == :info} name="hero-information-circle-mini" class="size-5 shrink-0" /> + <.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="size-5 shrink-0" /> + <div> + <p :if={@title} class="font-semibold">{@title}</p> + <p>{msg}</p> + </div> + <div class="flex-1" /> + <button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}> + <.icon name="hero-x-mark-solid" class="size-5 opacity-40 group-hover:opacity-70" /> + </button> + </div> + </div> + """ + end + + @doc """ + Renders a button with navigation support. + + ## Examples + + <.button>Send!</.button> + <.button phx-click="go" variant="primary">Send!</.button> + <.button navigate={~p"/"}>Home</.button> + """ + attr :rest, :global, include: ~w(href navigate patch) + attr :variant, :string, values: ~w(primary) + slot :inner_block, required: true + + def button(%{rest: rest} = assigns) do + variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"} + assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) + + if rest[:href] || rest[:navigate] || rest[:patch] do + ~H""" + <.link class={["btn", @class]} {@rest}> + {render_slot(@inner_block)} + </.link> + """ + else + ~H""" + <button class={["btn", @class]} {@rest}> + {render_slot(@inner_block)} + </button> + """ + end + 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) + + 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) + + def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do + errors = if Phoenix.Component.used_input?(field), do: field.errors, else: [] + + assigns + |> assign(field: nil, id: assigns.id || field.id) + |> assign(:errors, Enum.map(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 -> + Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value]) + end) + + ~H""" + <fieldset class="fieldset mb-2"> + <label> + <input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} /> + <span class="fieldset-label"> + <input + type="checkbox" + id={@id} + name={@name} + value="true" + checked={@checked} + class="checkbox checkbox-sm" + {@rest} + />{@label} + </span> + </label> + <.error :for={msg <- @errors}>{msg}</.error> + </fieldset> + """ + end + + def input(%{type: "select"} = assigns) do + ~H""" + <fieldset class="fieldset mb-2"> + <label> + <span :if={@label} class="fieldset-label mb-1">{@label}</span> + <select + id={@id} + name={@name} + class={["w-full select", @errors != [] && "select-error"]} + multiple={@multiple} + {@rest} + > + <option :if={@prompt} value="">{@prompt}</option> + {Phoenix.HTML.Form.options_for_select(@options, @value)} + </select> + </label> + <.error :for={msg <- @errors}>{msg}</.error> + </fieldset> + """ + end + + def input(%{type: "textarea"} = assigns) do + ~H""" + <fieldset class="fieldset mb-2"> + <label> + <span :if={@label} class="fieldset-label mb-1">{@label}</span> + <textarea + id={@id} + name={@name} + class={["w-full textarea", @errors != [] && "textarea-error"]} + {@rest} + >{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea> + </label> + <.error :for={msg <- @errors}>{msg}</.error> + </fieldset> + """ + end + + # All other inputs text, datetime-local, url, password, etc. are handled here... + def input(assigns) do + ~H""" + <fieldset class="fieldset mb-2"> + <label> + <span :if={@label} class="fieldset-label mb-1">{@label}</span> + <input + type={@type} + name={@name} + id={@id} + value={Phoenix.HTML.Form.normalize_value(@type, @value)} + class={["w-full input", @errors != [] && "input-error"]} + {@rest} + /> + </label> + <.error :for={msg <- @errors}>{msg}</.error> + </fieldset> + """ + end + + # Helper used by inputs to generate form errors + defp error(assigns) do + ~H""" + <p class="mt-1.5 flex gap-2 items-center text-sm text-error"> + <.icon name="hero-exclamation-circle-mini" class="size-5" /> + {render_slot(@inner_block)} + </p> + """ + end + + @doc """ + Renders a header with title. + """ + attr :class, :string, default: nil + + slot :inner_block, required: true + slot :subtitle + slot :actions + + def header(assigns) do + ~H""" + <header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}> + <div> + <h1 class="text-lg font-semibold leading-8"> + {render_slot(@inner_block)} + </h1> + <p :if={@subtitle != []} class="text-sm text-base-content/70"> + {render_slot(@subtitle)} + </p> + </div> + <div class="flex-none">{render_slot(@actions)}</div> + </header> + """ + end + + @doc ~S""" + Renders a table with generic 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 :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 + + slot :action, doc: "the slot for showing user actions in the last table column" + + def table(assigns) do + assigns = + with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do + assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end) + end + + ~H""" + <table class="table table-zebra"> + <thead> + <tr> + <th :for={col <- @col}>{col[:label]}</th> + <th :if={@action != []}> + <span class="sr-only">{gettext("Actions")}</span> + </th> + </tr> + </thead> + <tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}> + <tr :for={row <- @rows} id={@row_id && @row_id.(row)}> + <td + :for={col <- @col} + phx-click={@row_click && @row_click.(row)} + class={@row_click && "hover:cursor-pointer"} + > + {render_slot(col, @row_item.(row))} + </td> + <td :if={@action != []} class="w-0 font-semibold"> + <div class="flex gap-4"> + <%= for action <- @action do %> + {render_slot(action, @row_item.(row))} + <% end %> + </div> + </td> + </tr> + </tbody> + </table> + """ + end + + @doc """ + Renders a data list. + + ## Examples + + <.list> + <:item title="Title">{@post.title}</:item> + <:item title="Views">{@post.views}</:item> + </.list> + """ + slot :item, required: true do + attr :title, :string, required: true + end + + def list(assigns) do + ~H""" + <ul class="list"> + <li :for={item <- @item} class="list-row"> + <div> + <div class="font-bold">{item.title}</div> + <div>{render_slot(item)}</div> + </div> + </li> + </ul> + """ + end + + @doc """ + Renders a [Heroicon](https://heroicons.com). + + Heroicons come in three styles – outline, solid, and mini. + By default, the outline style is used, but solid and mini may + be applied by using the `-solid` and `-mini` suffix. + + You can customize the size and colors of the icons by setting + width, height, and background color classes. + + Icons are extracted from the `deps/heroicons` directory and bundled within + your compiled app.css by the plugin in `assets/vendor/heroicons.js`. + + ## Examples + + <.icon name="hero-x-mark-solid" /> + <.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" /> + """ + attr :name, :string, required: true + attr :class, :string, default: "size-4" + + def icon(%{name: "hero-" <> _} = assigns) do + ~H""" + <span class={[@name, @class]} /> + """ + end + + ## JS Commands + + def show(js \\ %JS{}, selector) do + JS.show(js, + to: selector, + time: 300, + transition: + {"transition-all ease-out duration-300", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", + "opacity-100 translate-y-0 sm:scale-100"} + ) + end + + def hide(js \\ %JS{}, selector) do + JS.hide(js, + to: selector, + time: 200, + transition: + {"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100", + "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"} + ) + end + + @doc """ + Translates an error message using gettext. + """ + def translate_error({msg, opts}) do + # When using gettext, we typically pass the strings we want + # to translate as a static argument: + # + # # Translate the number of files with plural rules + # dngettext("errors", "1 file", "%{count} files", count) + # + # However the error messages in our forms and APIs are generated + # dynamically, so we need to translate them by calling Gettext + # with our gettext backend as first argument. Translations are + # available in the errors.po file (as we use the "errors" domain). + if count = opts[:count] do + Gettext.dngettext(FlagdUiWeb.Gettext, "errors", msg, msg, count, opts) + else + Gettext.dgettext(FlagdUiWeb.Gettext, "errors", msg, opts) + end + 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 +end diff --git a/src/flagd-ui/lib/flagd_ui_web/components/layouts.ex b/src/flagd-ui/lib/flagd_ui_web/components/layouts.ex new file mode 100644 index 0000000..164da11 --- /dev/null +++ b/src/flagd-ui/lib/flagd_ui_web/components/layouts.ex @@ -0,0 +1,111 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +defmodule FlagdUiWeb.Layouts do + @moduledoc """ + This module holds different layouts used by your application. + + See the `layouts` directory for all templates available. + The "root" layout is a skeleton rendered as part of the + application router. The "app" layout is rendered as component + in regular views and live views. + """ + use FlagdUiWeb, :html + + embed_templates "layouts/*" + + def app(assigns) do + ~H""" + <header class="navbar px-4 sm:px-6 lg:px-8"> + <div class="flex-1"> + <a href="/" class="flex-1 flex items-center gap-2"> + <span class="text-lg font-semibold">Flagd UI</span> + </a> + </div> + <div class="flex-none"> + <ul class="flex flex-column px-1 space-x-4 items-center"> + <li> + <.theme_toggle /> + </li> + </ul> + </div> + </header> + + <main class="px-4 py-20 sm:px-6 lg:px-8"> + <div class="mx-auto max-w-2xl space-y-4"> + {render_slot(@inner_block)} + </div> + </main> + + <.flash_group flash={@flash} /> + """ + end + + @doc """ + Shows the flash group with standard titles and content. + + ## Examples + + <.flash_group flash={@flash} /> + """ + attr :flash, :map, required: true, doc: "the map of flash messages" + attr :id, :string, default: "flash-group", doc: "the optional id of flash container" + + def flash_group(assigns) do + ~H""" + <div id={@id} aria-live="polite"> + <.flash kind={:info} flash={@flash} /> + <.flash kind={:error} flash={@flash} /> + + <.flash + id="client-error" + kind={:error} + title={gettext("We can't find the internet")} + phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")} + phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})} + hidden + > + {gettext("Attempting to reconnect")} + <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 motion-safe:animate-spin" /> + </.flash> + + <.flash + id="server-error" + kind={:error} + title={gettext("Something went wrong!")} + phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")} + phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})} + hidden + > + {gettext("Hang in there while we get back on track")} + <.icon name="hero-arrow-path" class="ml-1 h-3 w-3 motion-safe:animate-spin" /> + </.flash> + </div> + """ + end + + @doc """ + Provides dark vs light theme toggle based on themes defined in app.css. + + See <head> in root.html.heex which applies the theme before page load. + """ + def theme_toggle(assigns) do + ~H""" + <div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full"> + <div class="absolute w-[33%] h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-[33%] [[data-theme=dark]_&]:left-[66%] transition-[left]" /> + + <button phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "system"})} class="flex p-2"> + <.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" /> + </button> + + <button phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "light"})} class="flex p-2"> + <.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" /> + </button> + + <button phx-click={JS.dispatch("phx:set-theme", detail: %{theme: "dark"})} class="flex p-2"> + <.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" /> + </button> + </div> + """ + end +end diff --git a/src/flagd-ui/lib/flagd_ui_web/components/layouts/root.html.heex b/src/flagd-ui/lib/flagd_ui_web/components/layouts/root.html.heex new file mode 100644 index 0000000..010a552 --- /dev/null +++ b/src/flagd-ui/lib/flagd_ui_web/components/layouts/root.html.heex @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="csrf-token" content={get_csrf_token()} /> + <meta name="root-path" content={FlagdUiWeb.Endpoint.get_root_path()} /> + <.live_title default="Flagd-ui"> + {assigns[:page_title]} + </.live_title> + <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> + <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}> + </script> + <script> + (() => { + const setTheme = (theme) => { + if (theme === "system") { + localStorage.removeItem("phx:theme"); + document.documentElement.removeAttribute("data-theme"); + } else { + localStorage.setItem("phx:theme", theme); + document.documentElement.setAttribute("data-theme", theme); + } + }; + setTheme(localStorage.getItem("phx:theme") || "system"); + window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system")); + window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme)); + })(); + </script> + </head> + <body> + {@inner_content} + </body> +</html> diff --git a/src/flagd-ui/lib/flagd_ui_web/components/navbar.ex b/src/flagd-ui/lib/flagd_ui_web/components/navbar.ex new file mode 100644 index 0000000..b224f51 --- /dev/null +++ b/src/flagd-ui/lib/flagd_ui_web/components/navbar.ex @@ -0,0 +1,41 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +defmodule FlagdUiWeb.Components.Navbar do + use Phoenix.Component + use FlagdUiWeb, :live_view + + attr :mode, :string, default: "basic", doc: "the view currently displaying" + + def navbar(assigns) do + ~H""" + <nav class="bg-gray-800 p-4 sm:p-6"> + <div class="container mx-auto flex items-center justify-between"> + <a href="/feature" class="text-xl font-bold text-white"> + Flagd Configurator + </a> + <ul class="flex space-x-2 sm:space-x-4"> + <li> + <a href="/feature" class={classes("basic", @mode)}> + Basic + </a> + </li> + <li> + <a href="/feature/advanced" class={classes("advanced", @mode)}> + Advanced + </a> + </li> + </ul> + </div> + </nav> + """ + end + + defp classes(route, route), + do: + "rounded-md px-3 py-2 text-sm font-medium bg-blue-700 text-white underline underline-offset-4 transition-all duration-200" + + defp classes(_, _), + do: + "rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white transition-all duration-200" +end |
