summaryrefslogtreecommitdiff
path: root/src/flagd-ui/lib/flagd_ui_web/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/flagd-ui/lib/flagd_ui_web/components')
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/components/core_components.ex464
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/components/layouts.ex111
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/components/layouts/root.html.heex34
-rw-r--r--src/flagd-ui/lib/flagd_ui_web/components/navbar.ex41
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