A project extension type — the catalog entry describing a capability that can be enabled per project (the hub's equivalent of a site module).
The provider contract (decoupled by design)
A provider module exposes extensions by defining a zero-arity
phoenix_kit_project_extensions/0 returning a list of plain maps — it
does NOT depend on phoenix_kit_projects; this module normalizes the maps
via from_map/2 (mirrors phoenix_kit_dashboards' widget contract):
# in phoenix_kit_crm.ex
def phoenix_kit_project_extensions do
[
%{
key: "crm_client",
name: "Client",
description: "Attach a CRM company + contacts to this project",
icon: "hero-building-office-2",
module_key: "crm",
tabs: [
%{key: "client", label: "Client",
lv: PhoenixKitCRM.Web.ProjectClientLive}
],
config_schema: [
%{key: "company_uuid", type: :string, label: "Company"}
]
}
]
endContributed tab LVs are rendered by the hub via live_render with the
projects embed-session contract (project_uuid / current_user_uuid /
locale / emit keys) — the LV must be mountable off-router
(:not_mounted_at_router, no handle_params/3), exactly like this
module's own embeddable LVs.
Forward-looking surface (declared now, dispatched incrementally)
Per the 2026-08-05 plan-review panel: the contract carries its FULL surface from day one so providers never need rework when later hub layers land —
feature_flags— per-project flags this extension owns (%{key, label, default, requires: [...]}); the hub's Features layer resolves them.permission_actions— action keys the extension's UI asksPhoenixKitProjects.Authz.can?/5about.notification_types— passthrough entries merged into this module'snotification_types/0(core's prefs-UI shape).on_enable/on_disable—{module, function}called with(project_uuid, config)after a successful toggle. Best-effort: failures are logged, never abort the toggle.data_retention—:keep(only supported value; documented Redmine semantics: disabling hides, never deletes; a future:purge_apivalue may name an explicit cleanup entry point).
Instances
Enablement rows are instance-keyed (instance_key, default "default");
v1 exposes toggle semantics (one instance per extension per project) but
the storage and lookups thread the instance key throughout, so
Basecamp-style multi-instance tools are a constraint relaxation, not a
migration.
Summary
Functions
Normalizes a provider's plain map into {:ok, %Extension{}} or
{:error, reason}. Accepts atom or string keys. Invalid tabs/callbacks
are dropped with a warning rather than failing the whole extension.
Types
@type t() :: %PhoenixKitProjects.Extensions.Extension{ category: String.t() | nil, config_schema: [map()], data_retention: :keep, default_enabled: boolean(), description: String.t() | nil, feature_flags: [map()], icon: String.t(), key: String.t(), module_key: String.t() | nil, name: String.t(), notification_types: [map()], on_disable: {module(), atom()} | nil, on_enable: {module(), atom()} | nil, permission: String.t() | nil, permission_actions: [atom() | String.t()], source: module() | nil, tabs: [tab()] }