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 tier, the pixel geometry stacks below the existing
widgets. Returns the updated dashboard.
Add a widget at an explicit grid cell on bp (a catalog drag-out drop) —
x/y 0-based, clamped into the tier; 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. This is the page shown at the module's landing route.
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 bp (so smaller screens 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 bp. 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
clamped to >= 0 (top-left of the canvas). 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 bp.
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 bp (stored or derived).
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 bp — 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 the settings map of a single widget instance.
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 tier, 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 bp (a catalog drag-out drop) —
x/y 0-based, clamped into the tier; 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}, 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()
Return the user's default personal dashboard, creating an empty one on first access. This is the page shown at the module's landing route.
@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 bp (so smaller screens can drop non-essentials),
A layout tweak — not activity-logged.
@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 bp. 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
clamped to >= 0 (top-left of the canvas). 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 bp.
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 bp — 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 the settings map of a single widget instance.
@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.