Context for dashboard pages and their widget layouts.
Persistence follows the phoenix_kit_crm precedent: the dashboard row holds a
JSONB layout list of widget instances, read and written whole. The host app's
repo is reached via PhoenixKit.RepoHelper.repo/0 — this module never owns a
repo of its own.
Summary
Functions
Add a layout: named "Layout N" by default, dimensions copied from the
source_id layout (the active one), placements seeded by reflow+compact of
the source's resolved placements — so "+" doubles as duplicate-of-active.
Returns {:ok, dashboard, new_entry}.
Add a widget instance for widget_key to a dashboard's layout, seeded with the
widget type's default size and settings — the grid placement takes the first
FREE cell on the home layout, the pixel geometry stacks below the existing
widgets. Returns the updated dashboard.
Add a widget at an explicit grid cell on layout_id (a catalog drag-out drop) —
x/y 0-based, clamped into the layout; a spot overlapping another widget is
refused with {:error, :occupied} (the drag hook only offers free cells).
Logs dashboard.widget_added.
Add a widget at an explicit pixel position on the free canvas (a catalog
drag-out drop) — fx/fy clamped to the top-left; size is the widget type's
default seed. Logs dashboard.widget_added.
Clone a dashboard into a new personal dashboard owned by user_uuid —
copies the layout (with fresh instance ids) and config. Lets a user take a
private, editable copy of a shared/system dashboard.
Update a single widget instance's config — its :settings map, its selected
:view, and/or its :min_override flag (opt out of the recommended minimum
size: the resize floor drops to 1x1 and view-switch growth is skipped) — in
one write. Logs a dashboard.widget_configured activity.
Create a dashboard. The slug (derived from the title) is auto-uniquified per
owner — a blank or repeated title gets a -2, -3, … suffix instead of failing
the [owner_user_uuid, slug] unique constraint. A free slug is picked by query
(so nil-owner/system dashboards uniquify too, where Postgres treats NULLs as
distinct) and a constraint clash under concurrency is retried.
Delete a dashboard.
Delete a layout and every widget placement stored under its id (widgets are
dashboard-level — they live on in the other layouts, packing first-fit where
they had no explicit placement). The last layout can't be deleted
({:error, :last_layout}).
The design-space canvas height for a layout (gapless lattice).
The design-space canvas width for a layout (gapless lattice).
The id of the first (default/landing) layout.
Fetch one dashboard by uuid, or nil (nil too for a malformed id).
One layout entry by id, or nil.
Return the user's default personal dashboard, creating an empty one on first access. Host-facing helper for a "default dashboard" landing page — the built-in module lists dashboards rather than opening one, so nothing in this package calls it; it's public API for hosts that want the get-or-create-default pattern.
The column count of a layout (default entry's when the id is unknown).
The row count of a layout (default entry's when the id is unknown).
Show/hide a grid widget on layout_id (so a layout can drop non-essentials). A layout
tweak — not activity-logged.
The dashboard's grid layouts, ordered. A dashboard that has never persisted a layout list gets the default single "Layout 1" (64×36).
Dashboards visible to a user: their own personal ones, all shared/system ones,
and any role-scoped ones whose role_uuid is in role_uuids (the user's
roles — empty by default, so /1 keeps the personal + system behaviour).
Place a grid widget at an explicit cell — x (column) / y (row), 0-based —
on layout layout_id. The widget may go anywhere on the
grid (gaps are fine); x is clamped so the span stays within the columns, y
within the layout's rows. A spot overlapping another widget is refused with
{:error, :occupied} (the drag hook never offers one; this guards stale/
crafted events). A layout tweak — not activity-logged.
Free/pixel-canvas mode: place a widget at absolute pixels (fx, fy). Each
is clamped to [0, one screen past the furthest OTHER widget] (capped at the
absolute @free_max_pos): the canvas can grow by a screenful per move, but a
single crafted move_widget_to can't balloon it to 20000px for every viewer
of a shared dashboard (free_canvas_dims sizes the canvas to contain widgets).
Pixel geometry is embedded so it never disturbs the grid placement. A layout
tweak — not activity-logged.
Remove a widget instance by its instance id.
Rename a layout (blank names are ignored). Not activity-logged.
Re-pack a layout's grid to ordered_ids: every widget gets the first
free cell in that order (reflow + compact), so the ids' order becomes the
reading order. Unknown ids are filtered/deduped; unnamed widgets keep their
relative order after the named ones. A layout
tweak — not activity-logged.
Set a grid widget's w/h span on layout layout_id.
Clamped to the widget type's min/max — and to what actually FITS at the
widget's cell: it grows until blocked by a neighbouring widget or the grid
edge (Grid.fit_size/8), never onto another widget. A layout tweak — not
activity-logged.
Free/pixel-canvas mode: set a widget's absolute pixel size (fw, fh), each
clamped to [@free_min_px, @free_max_px]. A layout tweak — not activity-logged.
Whether a widget is currently hidden on layout_id (stored or derived). The read
counterpart to hide_widget/4 — host-facing (see that function's note).
The {item, placement} pairs to render for a layout — the single render
path. Every returned placement carries explicit cells (x/y) plus
w/h/hidden: stored cells render verbatim; placements without stored
cells for this layout (a widget added elsewhere, or pre-cells legacy data)
pack first-fit into the remaining free cells in pos order — pinned on
their first edit, no migration.
The resolved grid placement for one widget on layout_id — exactly what
resolve_items/3 renders for it, or nil if the widget isn't in the layout.
Use this (not Layout.placement/2) anywhere that must match what's on screen —
e.g. the Settings modal's inputs, so a save doesn't overwrite a derived
placement with the default.
Restack a pixel widget: "front" puts it above every other widget's z,
"back" below. Overlap is allowed on the free canvas — z-order makes it
deliberate. A layout tweak — not activity-logged.
Persist a new full layout (the hot path while editing the grid).
Set a layout's lattice dimensions (the builder's inputs, steppers, and the
"Fit this screen" button). Each axis clamps into
4..160 and never below the extent
widgets already occupy (shrinking never cuts into a placed widget — the
target is raised to fit instead). A layout tweak — not activity-logged.
Set a widget's view FOR ONE LAYOUT (stored on that layout's placement):
designing the phone layout means choosing how each widget looks on the
phone, without touching the other screens. Falls back to the instance
default (configure_widget/4's :view) where no override is stored —
see Layout.view/2. A presentation tweak — not activity-logged.
Subscribe the caller to a dashboard's live updates. Delivers
{:dashboard_updated, %Dashboard{}} on every edit and {:dashboard_deleted, uuid} when it's removed — the spine of live multi-session editing.
The PubSub topic carrying one dashboard's live updates.
Update a dashboard's metadata.
Replace a single widget instance's settings map. Thin convenience alias for
configure_widget/4 (the canonical config API) — kept as public API so host
code from earlier releases keeps working.
Whether a dashboard is visible to a user — the single scope-visibility rule,
mirroring list_for_user/2's WHERE clause. Both LiveViews use this so the list
page and the builder never disagree about access.
Functions
@spec add_layout(PhoenixKitDashboards.Schemas.Dashboard.t(), String.t(), keyword()) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t(), map()} | {:error, :stale | Ecto.Changeset.t()}
Add a layout: named "Layout N" by default, dimensions copied from the
source_id layout (the active one), placements seeded by reflow+compact of
the source's resolved placements — so "+" doubles as duplicate-of-active.
Returns {:ok, dashboard, new_entry}.
@spec add_widget( PhoenixKitDashboards.Schemas.Dashboard.t(), widget_key :: String.t(), keyword() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, term()}
Add a widget instance for widget_key to a dashboard's layout, seeded with the
widget type's default size and settings — the grid placement takes the first
FREE cell on the home layout, the pixel geometry stacks below the existing
widgets. Returns the updated dashboard.
@spec add_widget_at( PhoenixKitDashboards.Schemas.Dashboard.t(), widget_key :: String.t(), String.t(), integer(), integer(), keyword() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | :unknown_widget | :occupied | Ecto.Changeset.t()}
Add a widget at an explicit grid cell on layout_id (a catalog drag-out drop) —
x/y 0-based, clamped into the layout; a spot overlapping another widget is
refused with {:error, :occupied} (the drag hook only offers free cells).
Logs dashboard.widget_added.
@spec add_widget_px( PhoenixKitDashboards.Schemas.Dashboard.t(), widget_key :: String.t(), integer(), integer(), keyword() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :unknown_widget | Ecto.Changeset.t()}
Add a widget at an explicit pixel position on the free canvas (a catalog
drag-out drop) — fx/fy clamped to the top-left; size is the widget type's
default seed. Logs dashboard.widget_added.
@spec clone( PhoenixKitDashboards.Schemas.Dashboard.t(), user_uuid :: String.t(), keyword() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, Ecto.Changeset.t()}
Clone a dashboard into a new personal dashboard owned by user_uuid —
copies the layout (with fresh instance ids) and config. Lets a user take a
private, editable copy of a shared/system dashboard.
@spec configure_widget( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), %{ optional(:settings) => map(), optional(:view) => String.t() | nil, optional(:min_override) => boolean() }, keyword() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Update a single widget instance's config — its :settings map, its selected
:view, and/or its :min_override flag (opt out of the recommended minimum
size: the resize floor drops to 1x1 and view-switch growth is skipped) — in
one write. Logs a dashboard.widget_configured activity.
@spec create( map(), keyword() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, Ecto.Changeset.t()}
Create a dashboard. The slug (derived from the title) is auto-uniquified per
owner — a blank or repeated title gets a -2, -3, … suffix instead of failing
the [owner_user_uuid, slug] unique constraint. A free slug is picked by query
(so nil-owner/system dashboards uniquify too, where Postgres treats NULLs as
distinct) and a constraint clash under concurrency is retried.
@spec delete( PhoenixKitDashboards.Schemas.Dashboard.t(), keyword() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, Ecto.Changeset.t()}
Delete a dashboard.
@spec delete_layout(PhoenixKitDashboards.Schemas.Dashboard.t(), String.t()) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :last_layout | Ecto.Changeset.t()}
Delete a layout and every widget placement stored under its id (widgets are
dashboard-level — they live on in the other layouts, packing first-fit where
they had no explicit placement). The last layout can't be deleted
({:error, :last_layout}).
@spec design_height(PhoenixKitDashboards.Schemas.Dashboard.t(), String.t()) :: pos_integer()
The design-space canvas height for a layout (gapless lattice).
@spec design_width(PhoenixKitDashboards.Schemas.Dashboard.t(), String.t()) :: pos_integer()
The design-space canvas width for a layout (gapless lattice).
@spec first_layout_id(PhoenixKitDashboards.Schemas.Dashboard.t()) :: String.t()
The id of the first (default/landing) layout.
@spec get(String.t()) :: PhoenixKitDashboards.Schemas.Dashboard.t() | nil
Fetch one dashboard by uuid, or nil (nil too for a malformed id).
@spec get_layout(PhoenixKitDashboards.Schemas.Dashboard.t(), String.t()) :: map() | nil
One layout entry by id, or nil.
@spec get_or_create_default(user_uuid :: String.t()) :: PhoenixKitDashboards.Schemas.Dashboard.t() | nil
Return the user's default personal dashboard, creating an empty one on first access. Host-facing helper for a "default dashboard" landing page — the built-in module lists dashboards rather than opening one, so nothing in this package calls it; it's public API for hosts that want the get-or-create-default pattern.
@spec grid_cols(PhoenixKitDashboards.Schemas.Dashboard.t(), String.t()) :: pos_integer()
The column count of a layout (default entry's when the id is unknown).
@spec grid_rows(PhoenixKitDashboards.Schemas.Dashboard.t(), String.t()) :: pos_integer()
The row count of a layout (default entry's when the id is unknown).
@spec hide_widget( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), String.t(), boolean() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Show/hide a grid widget on layout_id (so a layout can drop non-essentials). A layout
tweak — not activity-logged.
Host-facing: the render path fully supports hidden widgets (they keep their
cells and the builder dims them; resolve_items/3 visible: true filters
them for the runtime), but the built-in builder ships no hide toggle yet — a
host wires this setter (with resolve_hidden?/3) to its own UI.
@spec layouts(PhoenixKitDashboards.Schemas.Dashboard.t()) :: [map()]
The dashboard's grid layouts, ordered. A dashboard that has never persisted a layout list gets the default single "Layout 1" (64×36).
@spec list_for_user(user_uuid :: String.t(), role_uuids :: [String.t()]) :: [ PhoenixKitDashboards.Schemas.Dashboard.t() ]
Dashboards visible to a user: their own personal ones, all shared/system ones,
and any role-scoped ones whose role_uuid is in role_uuids (the user's
roles — empty by default, so /1 keeps the personal + system behaviour).
@spec place_widget_grid( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), String.t(), integer(), integer() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | :occupied | Ecto.Changeset.t()}
Place a grid widget at an explicit cell — x (column) / y (row), 0-based —
on layout layout_id. The widget may go anywhere on the
grid (gaps are fine); x is clamped so the span stays within the columns, y
within the layout's rows. A spot overlapping another widget is refused with
{:error, :occupied} (the drag hook never offers one; this guards stale/
crafted events). A layout tweak — not activity-logged.
@spec place_widget_px( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), integer(), integer() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Free/pixel-canvas mode: place a widget at absolute pixels (fx, fy). Each
is clamped to [0, one screen past the furthest OTHER widget] (capped at the
absolute @free_max_pos): the canvas can grow by a screenful per move, but a
single crafted move_widget_to can't balloon it to 20000px for every viewer
of a shared dashboard (free_canvas_dims sizes the canvas to contain widgets).
Pixel geometry is embedded so it never disturbs the grid placement. A layout
tweak — not activity-logged.
@spec remove_widget( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), keyword() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Remove a widget instance by its instance id.
@spec rename_layout( PhoenixKitDashboards.Schemas.Dashboard.t(), String.t(), String.t() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Rename a layout (blank names are ignored). Not activity-logged.
@spec reorder_widgets(PhoenixKitDashboards.Schemas.Dashboard.t(), String.t(), [ String.t() ]) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Re-pack a layout's grid to ordered_ids: every widget gets the first
free cell in that order (reflow + compact), so the ids' order becomes the
reading order. Unknown ids are filtered/deduped; unnamed widgets keep their
relative order after the named ones. A layout
tweak — not activity-logged.
@spec resize_widget( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), String.t(), pos_integer(), pos_integer() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Set a grid widget's w/h span on layout layout_id.
Clamped to the widget type's min/max — and to what actually FITS at the
widget's cell: it grows until blocked by a neighbouring widget or the grid
edge (Grid.fit_size/8), never onto another widget. A layout tweak — not
activity-logged.
@spec resize_widget_px( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), integer(), integer() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Free/pixel-canvas mode: set a widget's absolute pixel size (fw, fh), each
clamped to [@free_min_px, @free_max_px]. A layout tweak — not activity-logged.
@spec resolve_items(PhoenixKitDashboards.Schemas.Dashboard.t(), String.t(), keyword()) :: [ {map(), map()} ]
The {item, placement} pairs to render for a layout — the single render
path. Every returned placement carries explicit cells (x/y) plus
w/h/hidden: stored cells render verbatim; placements without stored
cells for this layout (a widget added elsewhere, or pre-cells legacy data)
pack first-fit into the remaining free cells in pos order — pinned on
their first edit, no migration.
Ordered by reading order (y, then x); hidden widgets are included
(the builder dims them and they keep their cells; pass visible: true — or
filter placement["hidden"] — for the runtime).
@spec resolve_placement( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), String.t() ) :: map() | nil
The resolved grid placement for one widget on layout_id — exactly what
resolve_items/3 renders for it, or nil if the widget isn't in the layout.
Use this (not Layout.placement/2) anywhere that must match what's on screen —
e.g. the Settings modal's inputs, so a save doesn't overwrite a derived
placement with the default.
@spec restack_widget_px( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), String.t() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Restack a pixel widget: "front" puts it above every other widget's z,
"back" below. Overlap is allowed on the free canvas — z-order makes it
deliberate. A layout tweak — not activity-logged.
@spec save_layout(PhoenixKitDashboards.Schemas.Dashboard.t(), [map()]) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Persist a new full layout (the hot path while editing the grid).
@spec set_grid_dims( PhoenixKitDashboards.Schemas.Dashboard.t(), String.t(), integer(), integer() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Set a layout's lattice dimensions (the builder's inputs, steppers, and the
"Fit this screen" button). Each axis clamps into
4..160 and never below the extent
widgets already occupy (shrinking never cuts into a placed widget — the
target is raised to fit instead). A layout tweak — not activity-logged.
@spec set_layout_view( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), String.t(), String.t() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Set a widget's view FOR ONE LAYOUT (stored on that layout's placement):
designing the phone layout means choosing how each widget looks on the
phone, without touching the other screens. Falls back to the instance
default (configure_widget/4's :view) where no override is stored —
see Layout.view/2. A presentation tweak — not activity-logged.
Subscribe the caller to a dashboard's live updates. Delivers
{:dashboard_updated, %Dashboard{}} on every edit and {:dashboard_deleted, uuid} when it's removed — the spine of live multi-session editing.
The PubSub topic carrying one dashboard's live updates.
@spec update(PhoenixKitDashboards.Schemas.Dashboard.t(), map(), keyword()) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Update a dashboard's metadata.
@spec update_widget_settings( PhoenixKitDashboards.Schemas.Dashboard.t(), instance_id :: String.t(), map(), keyword() ) :: {:ok, PhoenixKitDashboards.Schemas.Dashboard.t()} | {:error, :stale | Ecto.Changeset.t()}
Replace a single widget instance's settings map. Thin convenience alias for
configure_widget/4 (the canonical config API) — kept as public API so host
code from earlier releases keeps working.
@spec visible_to?( PhoenixKitDashboards.Schemas.Dashboard.t(), user_uuid :: String.t() | nil, role_uuids :: [String.t()] ) :: boolean()
Whether a dashboard is visible to a user — the single scope-visibility rule,
mirroring list_for_user/2's WHERE clause. Both LiveViews use this so the list
page and the builder never disagree about access.