PhoenixKitDashboards.Dashboards (PhoenixKitDashboards v0.2.1)

Copy Markdown View Source

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

add_layout(dashboard, source_id, opts \\ [])

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_widget(dashboard, widget_key, opts \\ [])

@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.

add_widget_at(dashboard, widget_key, layout_id, x, y, opts \\ [])

@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.

add_widget_px(dashboard, widget_key, fx, fy, opts \\ [])

@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.

clone(source, user_uuid, opts \\ [])

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.

configure_widget(dashboard, instance_id, attrs, opts \\ [])

@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.

create(attrs, opts \\ [])

@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.

delete(dashboard, opts \\ [])

Delete a dashboard.

delete_layout(dashboard, id)

@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}).

design_height(dashboard, layout_id)

The design-space canvas height for a layout (gapless lattice).

design_width(dashboard, layout_id)

The design-space canvas width for a layout (gapless lattice).

first_layout_id(dashboard)

@spec first_layout_id(PhoenixKitDashboards.Schemas.Dashboard.t()) :: String.t()

The id of the first (default/landing) layout.

get(uuid)

Fetch one dashboard by uuid, or nil (nil too for a malformed id).

get_layout(dashboard, id)

@spec get_layout(PhoenixKitDashboards.Schemas.Dashboard.t(), String.t()) ::
  map() | nil

One layout entry by id, or nil.

get_or_create_default(user_uuid)

@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.

grid_cols(dashboard, layout_id)

The column count of a layout (default entry's when the id is unknown).

grid_rows(dashboard, layout_id)

The row count of a layout (default entry's when the id is unknown).

hide_widget(dashboard, instance_id, layout_id, hidden)

@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.

layouts(dashboard)

The dashboard's grid layouts, ordered. A dashboard that has never persisted a layout list gets the default single "Layout 1" (64×36).

list_for_user(user_uuid, role_uuids \\ [])

@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).

place_widget_grid(dashboard, instance_id, layout_id, x, y)

@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.

place_widget_px(dashboard, instance_id, fx, fy)

@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.

remove_widget(dashboard, instance_id, opts \\ [])

@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.

rename_layout(dashboard, id, name)

Rename a layout (blank names are ignored). Not activity-logged.

reorder_widgets(dashboard, layout_id, ordered_ids)

@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.

resize_widget(dashboard, instance_id, layout_id, w, h)

@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.

resize_widget_px(dashboard, instance_id, fw, fh)

@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.

resolve_hidden?(dashboard, id, layout_id)

@spec resolve_hidden?(
  PhoenixKitDashboards.Schemas.Dashboard.t(),
  instance_id :: String.t(),
  String.t()
) ::
  boolean()

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).

resolve_items(dashboard, layout_id, opts \\ [])

@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).

resolve_placement(dashboard, id, layout_id)

@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.

restack_widget_px(dashboard, instance_id, dir)

@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.

save_layout(dashboard, layout)

@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).

set_grid_dims(dashboard, layout_id, cols, rows)

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_layout_view(dashboard, instance_id, layout_id, view)

@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(uuid)

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

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.

topic(uuid)

@spec topic(String.t()) :: String.t()

The PubSub topic carrying one dashboard's live updates.

update(dashboard, attrs, opts \\ [])

Update a dashboard's metadata.

update_widget_settings(dashboard, instance_id, settings, opts \\ [])

@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.

visible_to?(dashboard, user_uuid, role_uuids \\ [])

@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.