MishkaCmsCore.Dashboard.Widget (Mishka CMS Core v0.0.1)

Copy Markdown View Source

Admin dashboard widgets — the whole concern in one module: the validated descriptor every widget declares, collecting widgets (core + plugins), filtering them by the viewer's permission, arranging them by a per-user layout, and rendering one as a card.

A widget is a plugin. An extension ships a MishkaInstaller.Event.Hook on the "dashboard:widgets" event whose call/1 appends a descriptor built with builder/1; enabling/disabling and default ordering reuse the plugin lifecycle (priority, Event.start/Event.stop). This is the sibling of MishkaCmsCore.Settings' "register via hook" pattern — except widgets aren't cached, the hook is called at mount so an enabled/disabled plugin shows up immediately.

component is a {module, function} tuple (a function component, the default) or a LiveComponent module with type: :live_component — never a closure, so the descriptor passes safely through the hook payload (which is plain-map only).

Two audiences: developers register widgets (priority = default order, size = default grid width, default_hidden: true = available-but-off, config = the settings the widget lets a user change); an admin arranges and tunes their own.

A widget declares its priority twice and they are NOT the same number. The descriptor's priority is the board order and may be any integer — collect/1 sorts by it. The plugin's initial: %{priority: n} is mishka_installer's registration priority, which it validates as 1..100; a plugin declaring more than 100 is rejected outright and its widget never appears. Keep them mirrored where the range allows, and clamp the plugin one when it does not.

Arrangement lives in a layout map the page persists per user — drag order ("order"), hidden/added ids and per-widget width overrides ("spans", read through span/2). Settings do not. Each declared setting is its own per-user row in MishkaCmsCore.Settings, keyed by setting_key/2, written by set_config/4 and read by config/3. That is what lets the owning extension register its widgets' settings in ITS OWN settings tab (settings_definitions/1 + a settings:register plugin such as MishkaBlog.Dashboard.WidgetSettings) — a blog widget's "Posts to show" is documented under Blog, not buried in the dashboard's blob. See MishkaCmsCoreWeb.Live.Admin.Dashboard.Home.Index.

A config entry is a plain map — the descriptor travels through the hook payload, which is plain-map only — declaring ONE setting, in one of exactly three types:

config: [
  %{key: "count", type: :integer, label: "Posts to show", default: 6, min: 1, max: 12},
  %{key: "show_avatars", type: :boolean, label: "Show avatars", default: true},
  %{key: "sort", type: :select, label: "Sort by", default: "recent",
    options: [{"Most recent", "recent"}, {"Most viewed", "viewed"}]}
]

Anything else is skipped rather than raised on, so a descriptor from an extension can never take the board down. A widget declaring config should also name the settings tab those settings belong in — settings_category: "blog" — which is a DIFFERENT field from the descriptor's category (the widget's own grouping label) and falls back to it when unset.

Widgets share one visual language: card/1 renders the header (descriptor icon + title + an optional "View all" link), and widget bodies compose badge/1, stat/1, empty/1 and relative_time/1 so every extension's widgets look native. Data follows the tenant contract: load/1 receives the viewing user and derives its scope from scope/1 — a master (no site_id) sees every site's data, a tenant admin only its own site's. A widget with settings exports load/2 instead, the same user plus its effective config/2 map; the dashboard prefers load/2 when it is exported, so a widget without settings keeps load/1 untouched.

Summary

Functions

Puts a widget back on the dashboard (un-hides it or reveals a default-hidden one).

Adds a widget and places it at index among the shown widgets (a drop from the library).

Orders and filters widgets for display. With no layout, widgets keep their registered order and default_hidden ones are dropped. A layout of %{"order" => [id], "hidden" => [id], "added" => [id]} (the per-user preference) overrides: hidden ids are removed, added reveals a default-hidden widget, and what remains is sorted by order (unknown ids keep their position). The layout's "spans" key does not affect arrangement — span/2 reads it for widget width.

Permitted widgets the user has NOT placed on the dashboard — the "add a widget" choices.

A small tonal badge for statuses and actions. Tones are the activity feed's badge_tone atoms (:emerald, :sky, :amber, :rose, :indigo, :violet, anything else = slate).

Renders one widget as a card from its async state — :loading (a skeleton), {:error, _} (a placeholder), or {:ok, data} (the widget's render/1 fed the loaded data). The dashboard loads each widget's data off the render (a Task per widget), so no widget blocks first paint; a widget whose LOAD fails degrades to the placeholder instead of taking the board with it.

The card footer — a bottom-right "{label} →" link, drawn only when there is a link to follow (collect/1 clears one the viewer may not open). border: true adds the rule above it, set off from the body by a 16px margin (React's spacing); without it the link just clears the body by 14px. Spacing is explicit, not mt-auto: the board grid is items-start, so a card is only ever as tall as its content and there is no free space for auto to push against.

A widget card header — the tinted icon chip + title (+ optional subtitle) on the left, and an optional :right slot (a status pill/badge). tone colours the chip: :violet, :green, :gray, :amber, :red, :blue.

Tailwind col-span-* for the 6-column dashboard grid; collapses to full width under 980px.

Widgets the user may see — every plugin registered on the "dashboard:widgets" event, sorted by each descriptor's priority (low → high, id as tiebreaker), filtered by permission. A widget whose link leads to a page the user may not open (MishkaCmsCore.Sidebar.opens?/2) comes back with no link, so its card draws no footer. Returns [] when no plugin has registered yet.

A widget's effective settings for one viewer, %{"key" => value} — every declared setting read from its own per-user row (setting_key/2 through MishkaCmsCore.Settings.get_user/3), falling back to the value the widget declared. EVERY declared key is present, so a load/2 never has to guess a default, and every value is re-validated on the way out: a row hand-edited from the settings page, or one left behind by an older declaration, can't feed a widget something its own config doesn't allow. A widget that declares nothing — or a caller with no user — gets %{}.

The per-widget settings dropdown shown in customize mode beside span_control/1 — a gear that opens a panel of the widget's declared config, one input per setting. Nothing renders for a widget that declares none, or for a declaration of an unsupported type.

A consistent empty state for list widgets.

A sample %MishkaCmsCore.Dashboard.Widget{} populated from defaults + type-based fallbacks.

Removes a widget from the dashboard.

The user's saved dashboard layout (%{} when they've never customized).

Whether user may see this widget — its descriptor's permission, which is one name or a list of them where holding ANY is enough, checked the same way a setting definition's is (MishkaCmsCore.Settings.permitted?/2). This is the filter collect/1 applies, exposed because it is also the question the dashboard's own templates ask about a single card.

A status pill for card headers — tinted bg + colour, optional pulsing live dot.

Compact "how long ago" label for feed rows, in the reader's language — "now", "12m ago", "3h ago", then the day, "Jul 3".

Reorders the dashboard to the dragged id list (the client's DOM order).

Puts every one of a widget's settings back to the value it declared, for this user — what the dashboard's "Reset" does to settings now that they outlive the layout blob it clears. Writes each default through set_config/4, so a default is coerced and bounded like any other value.

Persists the user's dashboard layout.

The viewing user's data scope: nil = master (sees every site), a uuid = that tenant's own site only. Every widget's load derives its queries from this.

Saves one of a widget's settings FOR THIS USER, validated against the widget's own declaration: the posted value is coerced to the declared type — an integer parsed and clamped to min/max, a boolean, one of a select's declared options — and only then written to that setting's own per-user row (setting_key/2 through MishkaCmsCore.Settings.put_user/3, which namespaces the key with the user's id, so two admins can never see each other's value). A key the widget never declared, a type outside the supported three, or a value that can't be coerced writes nothing at all. This is the boundary: nothing reaches a widget's load/2 that its own declaration didn't describe.

Sets the user's width override for a widget, in grid columns (of 6): 2=⅓, 3=½, 4=⅔, 6=full.

The MishkaCmsCore.Settings key one of a widget's settings is stored under — "dashboard.widget.<widget id>.<setting key>". This is the single source of that shape: the extension plugin that REGISTERS the definition (settings_definitions/1) and the code that reads it (config/3) both derive the key here, so the two can never drift apart. The key carries no user — MishkaCmsCore.Settings.put_user/3 namespaces it per user on the way in.

This widget's declared settings as MishkaCmsCore.Settings definitions — what an extension's settings:register plugin appends so each widget's settings are documented in ITS OWN tab (settings_category, falling back to the descriptor's category) under one Dashboard widgets group, instead of living nowhere near the extension that owns them.

A widget's effective width in grid columns — the user's override or the registered size.

The per-widget width picker shown in customize mode — sets the card's grid width to a third, two thirds, or the full row (2/4/6 of the 6-column grid) via set_span. The active width is highlighted from the widget's current span.

A stat tile — icon + label over a large tabular number.

The PubSub topics whose broadcasts make this widget's load/1 stale — the dashboard subscribes to them and re-loads just that widget, so a board is live without polling.

Types

t()

@type t() :: %MishkaCmsCore.Dashboard.Widget{
  category: String.t() | nil,
  component: tuple(),
  config: list() | nil,
  default_hidden: boolean() | nil,
  description: String.t() | nil,
  icon: String.t() | nil,
  id: String.t(),
  link: String.t() | nil,
  permission: any() | nil,
  priority: integer() | nil,
  pubsub: list() | nil,
  settings_category: String.t() | nil,
  size: integer() | nil,
  title: String.t(),
  type: atom() | nil,
  variant: atom() | nil
}

Functions

add(layout, id)

@spec add(map(), String.t()) :: map()

Puts a widget back on the dashboard (un-hides it or reveals a default-hidden one).

add_at(layout, widgets, id, index)

@spec add_at(map(), [t()], String.t(), non_neg_integer()) :: map()

Adds a widget and places it at index among the shown widgets (a drop from the library).

arrange(widgets, layout \\ %{})

@spec arrange([t()], map()) :: [t()]

Orders and filters widgets for display. With no layout, widgets keep their registered order and default_hidden ones are dropped. A layout of %{"order" => [id], "hidden" => [id], "added" => [id]} (the per-user preference) overrides: hidden ids are removed, added reveals a default-hidden widget, and what remains is sorted by order (unknown ids keep their position). The layout's "spans" key does not affect arrangement — span/2 reads it for widget width.

available(widgets, layout \\ %{})

@spec available([t()], map()) :: [t()]

Permitted widgets the user has NOT placed on the dashboard — the "add a widget" choices.

badge(assigns)

A small tonal badge for statuses and actions. Tones are the activity feed's badge_tone atoms (:emerald, :sky, :amber, :rose, :indigo, :violet, anything else = slate).

Attributes

  • label (:string) (required)
  • tone (:atom) - Defaults to :slate.

builder(attrs_or_input, error \\ false)

card(assigns)

Renders one widget as a card from its async state — :loading (a skeleton), {:error, _} (a placeholder), or {:ok, data} (the widget's render/1 fed the loaded data). The dashboard loads each widget's data off the render (a Task per widget), so no widget blocks first paint; a widget whose LOAD fails degrades to the placeholder instead of taking the board with it.

A widget whose RENDER raises is not contained here, and render_loaded/2 says why.

card_footer(assigns)

The card footer — a bottom-right "{label} →" link, drawn only when there is a link to follow (collect/1 clears one the viewer may not open). border: true adds the rule above it, set off from the body by a 16px margin (React's spacing); without it the link just clears the body by 14px. Spacing is explicit, not mt-auto: the board grid is items-start, so a card is only ever as tall as its content and there is no free space for auto to push against.

Attributes

  • link (:string) - Defaults to nil.
  • label (:string) - Defaults to nil.
  • border (:boolean) - Defaults to false.

card_header(assigns)

A widget card header — the tinted icon chip + title (+ optional subtitle) on the left, and an optional :right slot (a status pill/badge). tone colours the chip: :violet, :green, :gray, :amber, :red, :blue.

Attributes

  • icon (:string) (required)
  • tone (:atom) - Defaults to :gray.
  • title (:string) (required)
  • subtitle (:string) - Defaults to nil.

Slots

  • right

col_span_class(n)

@spec col_span_class(pos_integer()) :: String.t()

Tailwind col-span-* for the 6-column dashboard grid; collapses to full width under 980px.

WHOLE CLASS NAMES, one clause per width. Written as "col-span-#{n} …" the span was invisible to Tailwind's scanner, and every rule it happened to have came from some unrelated file that spells the same class out — so the dashboard's column widths were held up by coincidence. mix admin_css compiles the admin from source text now; a class assembled at render time has nothing to find.

collect(user)

@spec collect(map() | nil) :: [t()]

Widgets the user may see — every plugin registered on the "dashboard:widgets" event, sorted by each descriptor's priority (low → high, id as tiebreaker), filtered by permission. A widget whose link leads to a page the user may not open (MishkaCmsCore.Sidebar.opens?/2) comes back with no link, so its card draws no footer. Returns [] when no plugin has registered yet.

Order is taken from the descriptor's own priority, not the plugin's registration order: the compiled hook chain is fixed at registration time from the priorities persisted in the event registry, so a source-level priority change would not re-order an already-registered plugin. Sorting the returned descriptors here keeps the default board order fully deterministic and source-driven.

config(widget, user, layout \\ %{})

@spec config(t(), map() | nil, map()) :: map()

A widget's effective settings for one viewer, %{"key" => value} — every declared setting read from its own per-user row (setting_key/2 through MishkaCmsCore.Settings.get_user/3), falling back to the value the widget declared. EVERY declared key is present, so a load/2 never has to guess a default, and every value is re-validated on the way out: a row hand-edited from the settings page, or one left behind by an older declaration, can't feed a widget something its own config doesn't allow. A widget that declares nothing — or a caller with no user — gets %{}.

layout is the LEGACY per-user dashboard blob (layout["config"][widget id]), where these values lived before they moved into Settings. It is read-only here and consulted only when the per-user row is unset, so an admin who tuned a card before this change keeps seeing their number; the first save through the gear (or the settings page) writes the real row and the blob stops mattering. Nothing is written back to it, and nothing new is ever written into it.

config_control(assigns)

The per-widget settings dropdown shown in customize mode beside span_control/1 — a gear that opens a panel of the widget's declared config, one input per setting. Nothing renders for a widget that declares none, or for a declaration of an unsupported type.

Opening is pure client state (JS.toggle, dismissed by a click away): whether a dropdown is open is not worth a round trip. The panel carries phx-update="ignore" so it belongs to the client once rendered — re-rendering the card (which every change does, the widget re-loads) would otherwise strip the toggled display back off and shut the panel mid-edit.

Each change posts save_widget_config with the widget id, which the page re-validates through set_config/4 before re-running just that widget's load.

A number field is bounded on the client by NumberBounds as well as on the server, because an ignored element is never patched: a value the server clamps would otherwise stay on screen for the rest of the session while the card showed the clamped one.

Attributes

  • id (:string) (required)
  • settings (:list) (required)
  • values (:map) - Defaults to %{}.

empty(assigns)

A consistent empty state for list widgets.

Attributes

  • icon (:string) - Defaults to "hero-inbox".
  • message (:string) (required)

enforce_keys()

enforce_keys(field)

example()

A sample %MishkaCmsCore.Dashboard.Widget{} populated from defaults + type-based fallbacks.

hide(layout, id)

@spec hide(map(), String.t()) :: map()

Removes a widget from the dashboard.

keys()

keys(field)

layout(user)

@spec layout(map()) :: map()

The user's saved dashboard layout (%{} when they've never customized).

permitted?(widget, user)

@spec permitted?(t(), map() | nil) :: boolean()

Whether user may see this widget — its descriptor's permission, which is one name or a list of them where holding ANY is enough, checked the same way a setting definition's is (MishkaCmsCore.Settings.permitted?/2). This is the filter collect/1 applies, exposed because it is also the question the dashboard's own templates ask about a single card.

pill(assigns)

A status pill for card headers — tinted bg + colour, optional pulsing live dot.

Attributes

  • label (:string) (required)
  • tone (:atom) - Defaults to :gray.
  • dot (:boolean) - Defaults to false.

relative_time(at)

@spec relative_time(DateTime.t() | term()) :: String.t()

Compact "how long ago" label for feed rows, in the reader's language — "now", "12m ago", "3h ago", then the day, "Jul 3".

reorder(layout, ids)

@spec reorder(map(), [String.t()]) :: map()

Reorders the dashboard to the dragged id list (the client's DOM order).

reset_config(user, widget)

@spec reset_config(map(), t()) :: :ok

Puts every one of a widget's settings back to the value it declared, for this user — what the dashboard's "Reset" does to settings now that they outlive the layout blob it clears. Writes each default through set_config/4, so a default is coerced and bounded like any other value.

save_layout(user, layout)

@spec save_layout(map(), map()) :: {:ok, map()} | {:error, term()}

Persists the user's dashboard layout.

scope(other)

@spec scope(map()) :: String.t() | nil

The viewing user's data scope: nil = master (sees every site), a uuid = that tenant's own site only. Every widget's load derives its queries from this.

The viewing user is REQUIRED. It used to be optional, and a missing one returned nil — the exact value a master returns — so every widget's scope_filter(query, nil) applied no filter and a caller that had lost track of its user quietly read every site's data instead of failing. A master is a user whose site_id is nil, never the absence of a user, and the two must not collapse into one value on the way into a query. Anything else raises: on a boundary like this a crash is the safe outcome and a silent full-fleet read is not.

set_config(arg1, widget, key, value)

@spec set_config(map(), t(), String.t(), term()) :: {:ok, term()} | :error

Saves one of a widget's settings FOR THIS USER, validated against the widget's own declaration: the posted value is coerced to the declared type — an integer parsed and clamped to min/max, a boolean, one of a select's declared options — and only then written to that setting's own per-user row (setting_key/2 through MishkaCmsCore.Settings.put_user/3, which namespaces the key with the user's id, so two admins can never see each other's value). A key the widget never declared, a type outside the supported three, or a value that can't be coerced writes nothing at all. This is the boundary: nothing reaches a widget's load/2 that its own declaration didn't describe.

Returns {:ok, stored_value}, or :error for anything refused and for a write that failed.

set_span(layout, id, cols)

@spec set_span(map(), String.t(), pos_integer()) :: map()

Sets the user's width override for a widget, in grid columns (of 6): 2=⅓, 3=½, 4=⅔, 6=full.

setting_key(id, key)

@spec setting_key(t() | String.t(), String.t()) :: String.t()

The MishkaCmsCore.Settings key one of a widget's settings is stored under — "dashboard.widget.<widget id>.<setting key>". This is the single source of that shape: the extension plugin that REGISTERS the definition (settings_definitions/1) and the code that reads it (config/3) both derive the key here, so the two can never drift apart. The key carries no user — MishkaCmsCore.Settings.put_user/3 namespaces it per user on the way in.

settings_definitions(widget)

@spec settings_definitions(t()) :: [MishkaCmsCore.Settings.definition()]

This widget's declared settings as MishkaCmsCore.Settings definitions — what an extension's settings:register plugin appends so each widget's settings are documented in ITS OWN tab (settings_category, falling back to the descriptor's category) under one Dashboard widgets group, instead of living nowhere near the extension that owns them.

Every definition is scope: :user and source: :plugin, and inherits the widget's permission, so an admin who cannot see the widget never sees its settings either. :user settings ARE editable on the settings page (it reads and writes them through Settings.get_user/3 / Settings.put_user/3) — the very rows the card's gear writes — so the two editors agree. The declared min/max travel with the definition, so a number typed on the settings page is bounded by MishkaCmsCore.Settings.cast/2 exactly as one picked in the card's gear is; config/3 still re-validates on the way out, which is what catches a row left behind by an older declaration.

A declaration of an unsupported type is skipped, like everywhere else.

span(widget, layout)

@spec span(t(), map()) :: pos_integer()

A widget's effective width in grid columns — the user's override or the registered size.

span_control(assigns)

The per-widget width picker shown in customize mode — sets the card's grid width to a third, two thirds, or the full row (2/4/6 of the 6-column grid) via set_span. The active width is highlighted from the widget's current span.

Attributes

  • id (:string) (required)
  • span (:integer) - Defaults to nil.

stat(assigns)

A stat tile — icon + label over a large tabular number.

Attributes

  • label (:string) (required)
  • value (:any) (required)
  • icon (:string) - Defaults to nil.
  • tone (:atom) - Defaults to :slate.

topics(map, user)

@spec topics(t(), map() | nil) :: [String.t()]

The PubSub topics whose broadcasts make this widget's load/1 stale — the dashboard subscribes to them and re-loads just that widget, so a board is live without polling.

Each pubsub entry is an Ash resource (its topics come from its own pub_sub prefix), a module exposing topics/1 (an activity Source), or a literal topic string. Scoped exactly like load/1: a master watches the global topics, a tenant admin also watches its own site's. A resource that doesn't publish contributes nothing.