PhoenixKitCatalogue.Catalogue.SupplierFields (PhoenixKitCatalogue v0.18.0)

Copy Markdown View Source

Admin-defined EXTRA fields on a supplier row — "incoterm", "carton quantity", "certification expiry" — added without a migration.

Same doctrine as attribute-set extras (PhoenixKitCatalogue.Catalogue.AttributeSets), one blueprint narrower: entities owns the field definitions, the catalogue owns the values.

The blueprint contract

name:      "catalogue_supplier_fields"   (immutable, singleton)
settings:  "managed_by"  => "catalogue_supplier"
           "locked_keys" => ["scope"]
           "catalogue_supplier" => %{"scope" => "supplier_info"}
records:   NONE  this blueprint is a field-definition carrier
           only. Values live on the supplier rows themselves.

A singleton: every phoenix_kit_cat_item_supplier_info row in the install shares one field set, so a field added while editing one item appears on every item's suppliers.

Why the values are NOT entity data records

Supplier rows are per-item, high-count and relational, and they carry money. phoenix_kit_cat_item_supplier_info keeps its typed columns — unit_cost NUMERIC(14,4), the is_primary partial unique index, real date validity columns, the {supplier_uuid, supplier_source} federated reference of ADR-0001. Entities casts numbers through Float.parse/1 and enforces nothing at the database level, so moving those would trade exact money for JSON floats and silently drop "one primary supplier per item".

The extras ride metadata["custom_fields"] instead — namespaced so an admin-invented key can never collide with a future system key on the same JSONB column. This is the fallback the attribute-sets design doc named explicitly: "keep catalogue tables with a JSONB extras column."

Owner string

"catalogue_supplier", deliberately NOT "catalogue". AttributeSets enumerates its sets with Managed.owner(&1) == "catalogue"; sharing the owner would make this blueprint show up as an attribute set and put it under the set deletion guard. A distinct owner keeps both registries clean and gives this blueprint its own guard.

Enablement

Requires the entities module. Writes return {:error, :entities_disabled} when it is off; reads degrade quietly ([], %{}, nil) so the supplier UI renders without extras rather than crashing mid-toggle.

Summary

Functions

Adds a field: :label required, :type one of field_types/0, select additionally needs a non-empty :options. The key is derived from the label and is stable — stored values reference it, so it never changes afterwards.

The supplier-fields blueprint, or nil when entities is off or it has never been provisioned. A read — never creates.

One built-in field definition by key, or nil.

The catalogue's own supplier field definitions, in entities format. Always available — these do not depend on the entities module being enabled, only on its field machinery being compiled in.

Casts a built-in field's raw form input through the entities pipeline. Returns {:ok, term} — a %Decimal{} for unit_cost, or nil when cleared — or {:error, :invalid_value}.

Casts raw form input against the defined fields, through the same entities pipeline the full form uses (FormBuilder.cast_field/2): "12.5" coerces to 12.5, "" clears, a select value outside its options is refused.

Registers the supplier-fields deletion guard with entities. Ships as a supervision child via PhoenixKitCatalogue.children/0.

Refuses deletion of the blueprint while it still defines fields — supplier rows store values keyed by those definitions, and dropping the blueprint would strand every one of them unreadable. An empty blueprint is disposable; the module re-provisions it on next use.

True when the feature is live: the entities module is enabled AND its package carries the Managed API. Mirrors AttributeSets.enabled?/0 — UI branches on this to decide whether to render the extras at all.

The blueprint, provisioning it on first use. Idempotent: a concurrent create loses the unique-name race and is re-read rather than reported as an error.

One field definition by key, or nil.

Extra-field types the supplier field editor offers.

The defined field definitions (entities fields_definition entries, string keys). [] when entities is off or nothing is defined — so every render site can iterate unconditionally.

The blueprint's owner key, as stored in settings["managed_by"].

Merges cast values into a row's metadata, under the namespaced key. Returns the metadata map to hand to the changeset. A nil values leaves metadata untouched, so a caller that didn't collect extras cannot blank the ones already stored.

Removes a field definition. Values already stored under its key are left in place — harmless and invisible, and they come back if a field with the same key is re-added. Same doctrine as entities' own field removal and attribute-set extras.

Updates a field: :label renames the display text, :options replaces a select's choices. The KEY and the TYPE are immutable — stored values were cast for that type and are addressed by that key.

One stored value by field key, or nil.

The custom-field values carried by one supplier-info row, as a %{key => value} map. Always a map — a row written before any field existed reads as %{}.

Functions

add_field(attrs, opts \\ [])

@spec add_field(
  map(),
  keyword()
) :: {:ok, struct()} | {:error, term()}

Adds a field: :label required, :type one of field_types/0, select additionally needs a non-empty :options. The key is derived from the label and is stable — stored values reference it, so it never changes afterwards.

blueprint(opts \\ [])

@spec blueprint(keyword()) :: struct() | nil

The supplier-fields blueprint, or nil when entities is off or it has never been provisioned. A read — never creates.

builtin_field(key)

@spec builtin_field(String.t()) :: map() | nil

One built-in field definition by key, or nil.

builtin_fields()

@spec builtin_fields() :: [map()]

The catalogue's own supplier field definitions, in entities format. Always available — these do not depend on the entities module being enabled, only on its field machinery being compiled in.

cast_builtin(key, raw)

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

Casts a built-in field's raw form input through the entities pipeline. Returns {:ok, term} — a %Decimal{} for unit_cost, or nil when cleared — or {:error, :invalid_value}.

Unlike the admin-defined extras, the result is destined for a typed COLUMN, so the caller hands it straight to the changeset.

cast_values(raw, opts \\ [])

@spec cast_values(
  map() | nil,
  keyword()
) :: {:ok, map() | nil} | {:error, term()}

Casts raw form input against the defined fields, through the same entities pipeline the full form uses (FormBuilder.cast_field/2): "12.5" coerces to 12.5, "" clears, a select value outside its options is refused.

Unknown keys return {:error, :unknown_field} and invalid content {:error, :invalid_value} — never a silent junk write. nil in means "not submitted", and passes through untouched.

child_spec(opts)

@spec child_spec(keyword()) :: Supervisor.child_spec()

Registers the supplier-fields deletion guard with entities. Ships as a supervision child via PhoenixKitCatalogue.children/0.

deletion_guard(arg1)

@spec deletion_guard(struct()) :: :ok | {:error, :supplier_fields_defined}

Refuses deletion of the blueprint while it still defines fields — supplier rows store values keyed by those definitions, and dropping the blueprint would strand every one of them unreadable. An empty blueprint is disposable; the module re-provisions it on next use.

enabled?()

@spec enabled?() :: boolean()

True when the feature is live: the entities module is enabled AND its package carries the Managed API. Mirrors AttributeSets.enabled?/0 — UI branches on this to decide whether to render the extras at all.

ensure_blueprint(opts \\ [])

@spec ensure_blueprint(keyword()) :: {:ok, struct()} | {:error, term()}

The blueprint, provisioning it on first use. Idempotent: a concurrent create loses the unique-name race and is re-read rather than reported as an error.

field(key, opts \\ [])

@spec field(
  String.t(),
  keyword()
) :: map() | nil

One field definition by key, or nil.

field_types()

@spec field_types() :: [String.t()]

Extra-field types the supplier field editor offers.

fields(opts \\ [])

@spec fields(keyword()) :: [map()]

The defined field definitions (entities fields_definition entries, string keys). [] when entities is off or nothing is defined — so every render site can iterate unconditionally.

owner()

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

The blueprint's owner key, as stored in settings["managed_by"].

put_values(metadata, values)

@spec put_values(map() | nil, map() | nil) :: map()

Merges cast values into a row's metadata, under the namespaced key. Returns the metadata map to hand to the changeset. A nil values leaves metadata untouched, so a caller that didn't collect extras cannot blank the ones already stored.

remove_field(key, opts \\ [])

@spec remove_field(
  String.t(),
  keyword()
) :: {:ok, struct()} | {:error, term()}

Removes a field definition. Values already stored under its key are left in place — harmless and invisible, and they come back if a field with the same key is re-added. Same doctrine as entities' own field removal and attribute-set extras.

update_field(key, attrs, opts \\ [])

@spec update_field(String.t(), map(), keyword()) :: {:ok, struct()} | {:error, term()}

Updates a field: :label renames the display text, :options replaces a select's choices. The KEY and the TYPE are immutable — stored values were cast for that type and are addressed by that key.

value(info, key)

@spec value(struct() | nil, String.t()) :: term()

One stored value by field key, or nil.

values(arg1)

@spec values(struct() | nil) :: map()

The custom-field values carried by one supplier-info row, as a %{key => value} map. Always a map — a row written before any field existed reads as %{}.