User-defined project workflow statuses, configured through the
optional phoenix_kit_entities module and cemented locally when a
project starts.
Two layers:
- Catalog (entities). A shared
project_statusentity (auto-seeded with a default vocabulary) and/or full custom per-project entities the user owns. Templates and not-yet-started projects read this catalog live, so edits to the vocabulary flow straight through. - Cemented (local). When a project starts, its chosen catalog
statuses are snapshotted into
phoenix_kit_project_statuses(PhoenixKitProjects.Schemas.ProjectStatus). The running project then uses its own frozen, independently-editable copy — later catalog edits don't touch it. Mirrors the module's template→instance philosophy.
The selected status is addressed by its slug (current_status_slug
on the project), a stable identity that resolves against the live
catalog before start and the cemented local rows after.
Optional dependency
phoenix_kit_entities is optional. Every public function degrades
gracefully when it's absent or disabled: reads return []/nil,
provisioning returns {:error, :entities_not_available}, and
cement_project_statuses/2 becomes a no-op. The guard scaffolding
(@compile {:no_warn_undefined, …} + safe_call/2 + available?/0)
mirrors PhoenixKitProjects.Translations.
Summary
Types
A normalized status row, identical whether it came from the live catalog
or a cemented local row. uuid is the source row's uuid (entity_data
uuid pre-start, phoenix_kit_project_statuses uuid post-start); slug
is the stable cross-boundary identity.
Functions
Adds a cemented status row to a started project.
Is the entities-backed status feature usable right now? True when the
optional phoenix_kit_entities plugin is loaded AND enabled at runtime.
Snapshots a project's chosen catalog statuses into local
phoenix_kit_project_statuses rows. Called inside start_project/2's
transaction. Idempotent: a no-op if the project already has cemented
rows. A no-op (returns :ok) when entities is unavailable — a started
project simply has no workflow statuses until the module is wired up.
Creates a new default status entity, seeded with the default
vocabulary, and returns {:ok, entity} (or {:error, :entities_not_available}).
The currently-selected status for a project as a normalized map, or
nil when unset / unresolvable (e.g. the slug points at a trashed
catalog row, or entities is unavailable).
Base name for generated default status entities ("project_statuses").
The default seeded status vocabulary (for tests / inspection).
Provisions a dedicated custom status entity for project (named
project_status_<uuid>), seeds it with the defaults as a starting
point, points the project's status_entity_uuid at it, and returns
{:ok, project} with the updated project.
Fetches a cemented status row by uuid, scoped to a project.
The admin-chosen global default status entity uuid (the
projects_default_status_entity_uuid setting), or nil when unset. This
is what a project's "Shared default" resolves to. Set on the projects
Settings page; nothing is auto-created.
The global default for status-title translation display (setting, default true).
Live catalog statuses for a given entity uuid (normalized). [] when
entities is unavailable.
Cemented local status rows for a project, ordered by position.
Entities selectable as a project's status source, grouped for a picker:
[{"Status lists", [{name, uuid}]}, {"Other entities", [{name, uuid}]}].
Status-tagged catalogs (the shared entity + per-project opt-ins, marked
settings["source"] = "phoenix_kit_projects") come first; every other
entity follows, since any entity's records can serve as statuses (record
title = label). Empty groups are omitted. [] when entities is
unavailable.
Per-project custom status entity name: project_status_<32 hex> (the
full project UUID with hyphens stripped — 47 chars, under the 50-char
entity-name limit, and collision-free unlike a UUIDv7 prefix).
Clear + re-copy the chosen catalog into local rows, atomically. Used when
a started project switches its status source (from the show-page picker).
Existing local edits are intentionally discarded — the user chose a
different list. Returns the (possibly slug-reconciled) project, or
{:error, changeset} if the reconciling write fails. No-op (returns
{:ok, project}) when entities is unavailable.
Deletes a cemented status row. If the deleted row was the project's
currently-selected status, current_status_slug is cleared too (and a
:project_status_changed broadcast fires) so the selection never dangles
at a slug with no matching row. Slugs are unique per project, so deleting
the row removes the only match.
Resolves which catalog entity a project/template draws from: its own
status_entity_uuid (per-project choice), else the admin-chosen global
default (the projects_default_status_entity_uuid setting, picked on the
projects Settings page). Returns {:ok, uuid} or {:error, :no_status_entity}
when neither is set — nothing is auto-provisioned.
Counts projects/templates currently sourcing their status list from the
given catalog entity. This is the callback a host registers via
config :phoenix_kit_entities, reverse_references: [{"project_status", &PhoenixKitProjects.Statuses.reverse_reference_count/1}] to power the
entities admin's "Used by N" hint. Started projects no longer reference
the catalog (they're cemented), which is the intended semantics.
Sets a project's current workflow status by slug (or clears it with
nil). Validates the slug against the project's resolved status list
before writing. Delegates the write to
PhoenixKitProjects.Projects.set_current_status_slug/2 so the single
PubSub broadcast fires. Returns {:ok, project} or {:error, reason}.
Sets (or clears with nil) the global default status entity.
Points a project at entity_uuid as its status source (nil = the shared
default). For an already-started project this re-cements immediately
— the chosen entity's statuses are snapshotted into fresh local rows
(replacing any existing cemented rows), per the "cement on selection"
rule — so existing/running projects get a usable, frozen status set.
Unstarted projects just record the choice and cement at start as usual.
Statuses from the shared catalog entity if it already exists — does
NOT provision it (unlike resolve_catalog_entity_uuid/1). Used by the
list view's status filter, which shouldn't seed the entity as a side
effect of rendering. [] when the shared entity hasn't been created
yet or entities is unavailable.
True once a project has started (has a started_at).
The status list for a project — cemented local rows once it has started,
otherwise the live catalog list it draws from. [] when entities is
unavailable and the project hasn't started.
Batched current-status lookup for a list of projects. Returns
%{project_uuid => status() | nil}. Groups started projects by a single
local query and unstarted projects by their resolved catalog entity (one
list_by_entity per distinct entity) to avoid an N+1. Empty map when
entities is unavailable.
Updates a cemented status row.
Updates a project (arbitrary form attrs) and, when a started project's
status source changed, re-cements its local rows — all in one transaction.
This is the atomic edit-form entry point: update_project/2 followed by a
separate re-cement would leave a failure window where the project points at
a new entity with stale local rows. Returns the (possibly slug-reconciled)
project. Unstarted projects just record the choice and cement at start.
Whether status titles display in the viewer's content locale for this
project: the per-project override if set, else the global
projects_use_status_translations setting (default true). Translations
are always captured; this only gates display.
Types
@type status() :: %{ uuid: String.t() | nil, label: String.t(), slug: String.t(), color: String.t() | nil, position: integer() }
A normalized status row, identical whether it came from the live catalog
or a cemented local row. uuid is the source row's uuid (entity_data
uuid pre-start, phoenix_kit_project_statuses uuid post-start); slug
is the stable cross-boundary identity.
Functions
@spec add_project_status(PhoenixKitProjects.Schemas.Project.t(), map()) :: {:ok, PhoenixKitProjects.Schemas.ProjectStatus.t()} | {:error, Ecto.Changeset.t()}
Adds a cemented status row to a started project.
@spec available?() :: boolean()
Is the entities-backed status feature usable right now? True when the
optional phoenix_kit_entities plugin is loaded AND enabled at runtime.
@spec cement_project_statuses( PhoenixKitProjects.Schemas.Project.t(), keyword() ) :: :ok
Snapshots a project's chosen catalog statuses into local
phoenix_kit_project_statuses rows. Called inside start_project/2's
transaction. Idempotent: a no-op if the project already has cemented
rows. A no-op (returns :ok) when entities is unavailable — a started
project simply has no workflow statuses until the module is wired up.
Runs inside the caller's transaction, so a raised error rolls the whole start back.
Creates a new default status entity, seeded with the default
vocabulary, and returns {:ok, entity} (or {:error, :entities_not_available}).
Named project_statuses; if that's already taken it auto-increments —
project_statuses_2, project_statuses_3, … — so generating a default
again always produces a fresh, independent list rather than reusing the
existing one.
@spec current_status(PhoenixKitProjects.Schemas.Project.t()) :: status() | nil
The currently-selected status for a project as a normalized map, or
nil when unset / unresolvable (e.g. the slug points at a trashed
catalog row, or entities is unavailable).
@spec default_entity_base() :: String.t()
Base name for generated default status entities ("project_statuses").
@spec default_statuses() :: [map()]
The default seeded status vocabulary (for tests / inspection).
@spec ensure_project_status_entity( PhoenixKitProjects.Schemas.Project.t(), keyword() ) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, term()}
Provisions a dedicated custom status entity for project (named
project_status_<uuid>), seeds it with the defaults as a starting
point, points the project's status_entity_uuid at it, and returns
{:ok, project} with the updated project.
This is the per-project opt-in. The user fully owns the resulting entity afterward (rename, restructure fields, edit statuses) in the entities admin. No-op-with-error when entities is unavailable.
@spec get_project_status(PhoenixKitProjects.Schemas.Project.t(), String.t()) :: PhoenixKitProjects.Schemas.ProjectStatus.t() | nil
Fetches a cemented status row by uuid, scoped to a project.
@spec global_default_status_entity_uuid() :: String.t() | nil
The admin-chosen global default status entity uuid (the
projects_default_status_entity_uuid setting), or nil when unset. This
is what a project's "Shared default" resolves to. Set on the projects
Settings page; nothing is auto-created.
@spec global_use_status_translations?() :: boolean()
The global default for status-title translation display (setting, default true).
Live catalog statuses for a given entity uuid (normalized). [] when
entities is unavailable.
@spec list_project_statuses(PhoenixKitProjects.Schemas.Project.t()) :: [status()]
Cemented local status rows for a project, ordered by position.
Entities selectable as a project's status source, grouped for a picker:
[{"Status lists", [{name, uuid}]}, {"Other entities", [{name, uuid}]}].
Status-tagged catalogs (the shared entity + per-project opt-ins, marked
settings["source"] = "phoenix_kit_projects") come first; every other
entity follows, since any entity's records can serve as statuses (record
title = label). Empty groups are omitted. [] when entities is
unavailable.
@spec per_project_entity_name(PhoenixKitProjects.Schemas.Project.t()) :: String.t()
Per-project custom status entity name: project_status_<32 hex> (the
full project UUID with hyphens stripped — 47 chars, under the 50-char
entity-name limit, and collision-free unlike a UUIDv7 prefix).
@spec recement_project_statuses(PhoenixKitProjects.Schemas.Project.t()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, term()}
Clear + re-copy the chosen catalog into local rows, atomically. Used when
a started project switches its status source (from the show-page picker).
Existing local edits are intentionally discarded — the user chose a
different list. Returns the (possibly slug-reconciled) project, or
{:error, changeset} if the reconciling write fails. No-op (returns
{:ok, project}) when entities is unavailable.
@spec remove_project_status(PhoenixKitProjects.Schemas.ProjectStatus.t()) :: {:ok, PhoenixKitProjects.Schemas.ProjectStatus.t()} | {:error, Ecto.Changeset.t()}
Deletes a cemented status row. If the deleted row was the project's
currently-selected status, current_status_slug is cleared too (and a
:project_status_changed broadcast fires) so the selection never dangles
at a slug with no matching row. Slugs are unique per project, so deleting
the row removes the only match.
@spec resolve_catalog_entity_uuid(PhoenixKitProjects.Schemas.Project.t()) :: {:ok, String.t()} | {:error, term()}
Resolves which catalog entity a project/template draws from: its own
status_entity_uuid (per-project choice), else the admin-chosen global
default (the projects_default_status_entity_uuid setting, picked on the
projects Settings page). Returns {:ok, uuid} or {:error, :no_status_entity}
when neither is set — nothing is auto-provisioned.
@spec reverse_reference_count(String.t()) :: non_neg_integer()
Counts projects/templates currently sourcing their status list from the
given catalog entity. This is the callback a host registers via
config :phoenix_kit_entities, reverse_references: [{"project_status", &PhoenixKitProjects.Statuses.reverse_reference_count/1}] to power the
entities admin's "Used by N" hint. Started projects no longer reference
the catalog (they're cemented), which is the intended semantics.
@spec set_current_status( PhoenixKitProjects.Schemas.Project.t(), String.t() | nil, keyword() ) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, term()}
Sets a project's current workflow status by slug (or clears it with
nil). Validates the slug against the project's resolved status list
before writing. Delegates the write to
PhoenixKitProjects.Projects.set_current_status_slug/2 so the single
PubSub broadcast fires. Returns {:ok, project} or {:error, reason}.
Sets (or clears with nil) the global default status entity.
@spec set_status_entity( PhoenixKitProjects.Schemas.Project.t(), String.t() | nil, keyword() ) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, term()}
Points a project at entity_uuid as its status source (nil = the shared
default). For an already-started project this re-cements immediately
— the chosen entity's statuses are snapshotted into fresh local rows
(replacing any existing cemented rows), per the "cement on selection"
rule — so existing/running projects get a usable, frozen status set.
Unstarted projects just record the choice and cement at start as usual.
@spec started?(PhoenixKitProjects.Schemas.Project.t()) :: boolean()
True once a project has started (has a started_at).
@spec statuses_for(PhoenixKitProjects.Schemas.Project.t()) :: [status()]
The status list for a project — cemented local rows once it has started,
otherwise the live catalog list it draws from. [] when entities is
unavailable and the project hasn't started.
@spec statuses_for_projects([PhoenixKitProjects.Schemas.Project.t()]) :: %{ optional(String.t()) => status() | nil }
Batched current-status lookup for a list of projects. Returns
%{project_uuid => status() | nil}. Groups started projects by a single
local query and unstarted projects by their resolved catalog entity (one
list_by_entity per distinct entity) to avoid an N+1. Empty map when
entities is unavailable.
@spec update_project_status_row(PhoenixKitProjects.Schemas.ProjectStatus.t(), map()) :: {:ok, PhoenixKitProjects.Schemas.ProjectStatus.t()} | {:error, Ecto.Changeset.t()}
Updates a cemented status row.
@spec update_project_with_statuses(PhoenixKitProjects.Schemas.Project.t(), map()) :: {:ok, PhoenixKitProjects.Schemas.Project.t()} | {:error, term()}
Updates a project (arbitrary form attrs) and, when a started project's
status source changed, re-cements its local rows — all in one transaction.
This is the atomic edit-form entry point: update_project/2 followed by a
separate re-cement would leave a failure window where the project points at
a new entity with stale local rows. Returns the (possibly slug-reconciled)
project. Unstarted projects just record the choice and cement at start.
@spec use_status_translations?(PhoenixKitProjects.Schemas.Project.t()) :: boolean()
Whether status titles display in the viewer's content locale for this
project: the per-project override if set, else the global
projects_use_status_translations setting (default true). Translations
are always captured; this only gates display.
@spec use_status_translations?(PhoenixKitProjects.Schemas.Project.t(), boolean()) :: boolean()