From 1bec92e9d1b2fb107c7367e80b52e71b295f8fdd Mon Sep 17 00:00:00 2001 From: Kinnell Shah Date: Tue, 24 Feb 2026 02:40:14 -0500 Subject: [PATCH] wip --- .rubocop.yml | 2 +- README.md | 108 +++++++ lib/enum_fields.rb | 9 + lib/enum_fields/enum_field.rb | 8 +- lib/enum_fields/namespace.rb | 17 + lib/enum_fields/registry.rb | 17 +- spec/enum_fields/registry.spec.rb | 514 ++++++++++++++++++++++++------ 7 files changed, 570 insertions(+), 105 deletions(-) create mode 100644 lib/enum_fields/namespace.rb diff --git a/.rubocop.yml b/.rubocop.yml index b411908..241e53d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.4 NewCops: enable DisplayCopNames: true SuggestExtensions: false diff --git a/README.md b/README.md index f025475..67956aa 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,114 @@ ticket.priority_metadata[:sla_hours] # 72 ticket.priority_metadata[:notify_manager] # false ``` +### Registry & Standalone Registration + +When `enum_field` is used in a model, definitions are automatically registered under a namespace derived from the model class name (e.g., `Campaign` becomes `campaign`). + +You can also register definitions directly, outside of models, using the `namespace` DSL: + +```ruby +# config/initializers/enum_fields.rb +EnumFields.namespace(:basic) do + enum_field :priority, { + low: { + value: "low", + label: "Low", + }, + medium: { + value: "medium", + label: "Medium", + }, + high: { + value: "high", + label: "High", + }, + } + + enum_field :status, { + active: { + value: "active", + label: "Active", + }, + inactive: { + value: "inactive", + label: "Inactive", + }, + } +end +``` + +Access the raw registry: + +```ruby +EnumFields.registry +# => { "basic" => { "priority" => { ... }, "status" => { ... } }, "campaign" => { ... } } +``` + +### Catalog + +`EnumFields.catalog` returns all registered definitions with namespaces sorted alphabetically, each field's entries as an array of metadata hashes (keys stripped): + +```ruby +EnumFields.catalog +# => { +# "basic" => { +# "priority" => [ +# { +# "value" => "low", +# "label" => "Low", +# }, +# { +# "value" => "medium", +# "label" => "Medium", +# }, +# { +# "value" => "high", +# "label" => "High", +# }, +# ], +# "status" => [ +# { +# "value" => "active", +# "label" => "Active", +# }, +# { +# "value" => "inactive", +# "label" => "Inactive", +# }, +# ], +# }, +# "campaign" => { +# "stage" => [ +# { +# "value" => "pending", +# "label" => "Pending", +# "icon" => "clock", +# "color" => "yellow", +# }, +# { +# "value" => "processing", +# "label" => "Processing", +# "icon" => "cog", +# "color" => "blue", +# }, +# { +# "value" => "shipped", +# "label" => "Shipped", +# "icon" => "truck", +# "color" => "green", +# }, +# { +# "value" => "delivered", +# "label" => "Delivered", +# "icon" => "check", +# "color" => "green", +# }, +# ], +# }, +# } +``` + ## Development After checking out the repo, run: diff --git a/lib/enum_fields.rb b/lib/enum_fields.rb index b4c18a8..c119599 100644 --- a/lib/enum_fields.rb +++ b/lib/enum_fields.rb @@ -18,6 +18,7 @@ module EnumFields autoload :Definition autoload :EnumField + autoload :Namespace autoload :Registry def self.registry @@ -28,6 +29,14 @@ def self.register(...) registry.register(...) end + def self.namespace(name, &) + Namespace.new(name).instance_eval(&) + end + + def self.catalog + registry.catalog + end + def self.clear_registry! @registry = nil end diff --git a/lib/enum_fields/enum_field.rb b/lib/enum_fields/enum_field.rb index d241d99..99eef78 100644 --- a/lib/enum_fields/enum_field.rb +++ b/lib/enum_fields/enum_field.rb @@ -31,11 +31,11 @@ def define! private def register! - EnumFields.register( - model_class: @model_class, + EnumFields.register({ + namespace: @model_class.name&.underscore || @model_class.object_id.to_s, accessor: @accessor, - definition: @definition.data - ) + definition: @definition.data, + }) end def store_definition! diff --git a/lib/enum_fields/namespace.rb b/lib/enum_fields/namespace.rb new file mode 100644 index 0000000..f41d521 --- /dev/null +++ b/lib/enum_fields/namespace.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module EnumFields + class Namespace + def initialize(namespace) + @namespace = namespace + end + + def enum_field(accessor, definition = {}) + EnumFields.register({ + namespace: @namespace, + accessor: accessor, + definition: definition, + }) + end + end +end diff --git a/lib/enum_fields/registry.rb b/lib/enum_fields/registry.rb index 4bd4f72..58dd27b 100644 --- a/lib/enum_fields/registry.rb +++ b/lib/enum_fields/registry.rb @@ -7,10 +7,19 @@ def initialize super(@store) end - def register(model_class:, accessor:, definition:) - key = model_class.name&.underscore || model_class.object_id.to_s - @store[key] ||= {}.with_indifferent_access - @store[key][accessor] = definition + def register(args = {}) + namespace = args.fetch(:namespace) { raise ArgumentError, "namespace is required" } + accessor = args.fetch(:accessor) { raise ArgumentError, "accessor is required" } + definition = args.fetch(:definition, {}) + + @store[namespace] ||= {}.with_indifferent_access + @store[namespace][accessor] = definition + end + + def catalog + sort_by { |key, _| key.to_s }.to_h.transform_values do |fields| + fields.transform_values(&:values) + end end end end diff --git a/spec/enum_fields/registry.spec.rb b/spec/enum_fields/registry.spec.rb index d9d8639..0b823de 100644 --- a/spec/enum_fields/registry.spec.rb +++ b/spec/enum_fields/registry.spec.rb @@ -5,15 +5,27 @@ RSpec.describe EnumFields::Registry do let(:status_definitions) do { - pending: { value: "pending", label: "Pending" }, - active: { value: "active", label: "Active" }, + pending: { + value: "pending", + label: "Pending", + }, + active: { + value: "active", + label: "Active", + }, } end let(:role_definitions) do { - admin: { value: "admin", label: "Admin" }, - member: { value: "member", label: "Member" }, + admin: { + value: "admin", + label: "Admin", + }, + member: { + value: "member", + label: "Member", + }, } end @@ -28,21 +40,15 @@ end describe ".register" do - let(:model_class) do - Class.new(MockActiveRecord::Base) do - include EnumFields - - def self.name - "User" - end - end - end - before do - EnumFields.register(model_class: model_class, accessor: :status, definition: status_definitions) + EnumFields.register({ + namespace: :user, + accessor: :status, + definition: status_definitions, + }) end - it "stores the definition under the snake_case model key" do + it "stores the definition under the namespace" do expect(EnumFields.registry[:user]).to be_present end @@ -54,104 +60,92 @@ def self.name expect(EnumFields.registry["user"]).to be_present end - context "with multiple fields on the same model" do + context "with multiple fields on the same namespace" do before do - EnumFields.register(model_class: model_class, accessor: :role, definition: role_definitions) + EnumFields.register({ + namespace: :user, + accessor: :role, + definition: role_definitions, + }) end - it "stores both fields under the same model key" do + it "stores both fields under the same namespace" do expect(EnumFields.registry[:user][:status]).to match(status_definitions) expect(EnumFields.registry[:user][:role]).to match(role_definitions) end end - context "with a multi-word model name" do - let(:model_class) do - Class.new(MockActiveRecord::Base) do - include EnumFields + context "without namespace" do + it "raises ArgumentError" do + expect { EnumFields.register({ accessor: :status, definition: status_definitions }) }.to raise_error(ArgumentError, "namespace is required") + end + end - def self.name - "UserNotification" - end - end + context "without accessor" do + it "raises ArgumentError" do + expect { EnumFields.register({ namespace: :user, definition: status_definitions }) }.to raise_error(ArgumentError, "accessor is required") end + end + context "without definition" do before do - EnumFields.register(model_class: model_class, accessor: :status, definition: status_definitions) + EnumFields.register({ namespace: :user, accessor: :status }) end - it "uses snake_case for the key" do - expect(EnumFields.registry[:user_notification]).to be_present + it "defaults definition to an empty hash" do + expect(EnumFields.registry[:user][:status]).to eq({}) end end - context "with a namespaced model" do - let(:model_class) do - Class.new(MockActiveRecord::Base) do - include EnumFields - - def self.name - "Admin::User" - end - end - end - + context "with keyword arguments" do before do - EnumFields.register(model_class: model_class, accessor: :status, definition: status_definitions) + EnumFields.register(namespace: :user, accessor: :role, definition: role_definitions) end - it "uses the underscored namespace path as key" do - expect(EnumFields.registry["admin/user"]).to be_present + it "works the same as the hash format" do + expect(EnumFields.registry[:user][:role]).to match(role_definitions) end end end describe "#to_h" do - let(:user_class) do - Class.new(MockActiveRecord::Base) do - include EnumFields - - def self.name - "User" - end - end - end - - let(:order_class) do - Class.new(MockActiveRecord::Base) do - include EnumFields - - def self.name - "Order" - end - end - end - context "with nothing registered" do it "returns an empty hash" do expect(EnumFields.registry.to_h).to eq({}) end end - context "with a single field on one model" do + context "with a single field on one namespace" do before do - EnumFields.register(model_class: user_class, accessor: :status, definition: status_definitions) + EnumFields.register({ + namespace: :user, + accessor: :status, + definition: status_definitions, + }) end - it "returns the model key with its field" do + it "returns the namespace with its field" do expect(EnumFields.registry.to_h).to match({ "user" => { "status" => status_definitions }, }) end end - context "with two fields on the same model" do + context "with two fields on the same namespace" do before do - EnumFields.register(model_class: user_class, accessor: :status, definition: status_definitions) - EnumFields.register(model_class: user_class, accessor: :role, definition: role_definitions) + EnumFields.register({ + namespace: :user, + accessor: :status, + definition: status_definitions, + }) + EnumFields.register({ + namespace: :user, + accessor: :role, + definition: role_definitions, + }) end - it "returns the model key with both fields" do + it "returns the namespace with both fields" do expect(EnumFields.registry.to_h).to match({ "user" => { "status" => status_definitions, @@ -161,21 +155,41 @@ def self.name end end - context "with multiple models" do + context "with multiple namespaces" do let(:order_status_definitions) do { - pending: { value: "pending", label: "Pending" }, - shipped: { value: "shipped", label: "Shipped" }, + pending: { + value: "pending", + label: "Pending", + }, + shipped: { + value: "shipped", + label: "Shipped", + }, } end before do - EnumFields.register(model_class: user_class, accessor: :status, definition: status_definitions) - EnumFields.register(model_class: user_class, accessor: :role, definition: role_definitions) - EnumFields.register(model_class: order_class, accessor: :status, definition: order_status_definitions) + EnumFields.register({ + namespace: :user, + accessor: :status, + definition: status_definitions, + }) + + EnumFields.register({ + namespace: :user, + accessor: :role, + definition: role_definitions, + }) + + EnumFields.register({ + namespace: :order, + accessor: :status, + definition: order_status_definitions, + }) end - it "returns all models with their fields" do + it "returns all namespaces with their fields" do expect(EnumFields.registry.to_h).to match({ "user" => { "status" => status_definitions, @@ -216,7 +230,14 @@ def self.name User.enum_field :status, status_definitions User.enum_field :role, role_definitions - Order.enum_field :status, { pending: { value: "pending" }, shipped: { value: "shipped" } } + Order.enum_field :status, { + pending: { + value: "pending", + }, + shipped: { + value: "shipped", + }, + } end it "registers fields when enum_field is called" do @@ -237,24 +258,18 @@ def self.name expect(EnumFields.registry[:user][:status][:active][:value]).to eq("active") end - it "lists all registered model keys" do + it "lists all registered namespace keys" do expect(EnumFields.registry.keys).to contain_exactly("user", "order") end end describe ".clear_registry!" do - let(:model_class) do - Class.new(MockActiveRecord::Base) do - include EnumFields - - def self.name - "User" - end - end - end - before do - EnumFields.register(model_class: model_class, accessor: :status, definition: status_definitions) + EnumFields.register({ + namespace: :user, + accessor: :status, + definition: status_definitions, + }) end it "clears the registry via the module method" do @@ -282,12 +297,319 @@ def self.name end end - it "falls back to object_id as the key" do - EnumFields.register(model_class: model_class, accessor: :status, definition: status_definitions) + it "derives namespace from object_id for anonymous classes" do + EnumFields::EnumField.define( + model_class: model_class, + accessor: :status, + definition: status_definitions, + options: {} + ) + + expected_namespace = model_class.object_id.to_s + expect(EnumFields.registry[expected_namespace]).to be_present + expect(EnumFields.registry[expected_namespace][:status]).to be_present + end + end + + describe ".namespace" do + context "with a single field" do + before do + EnumFields.namespace(:basic) do + enum_field :status, { + active: { + value: "active", + label: "Active", + }, + inactive: { + value: "inactive", + label: "Inactive", + }, + } + end + end + + it "registers the field under the given namespace" do + expect(EnumFields.registry[:basic][:status]).to be_present + end - expected_key = model_class.object_id.to_s - expect(EnumFields.registry[expected_key]).to be_present - expect(EnumFields.registry[expected_key][:status]).to match(status_definitions) + it "stores the correct definition data" do + expect(EnumFields.registry[:basic][:status][:active][:value]).to eq("active") + end + end + + context "with multiple fields" do + before do + EnumFields.namespace(:basic) do + enum_field :status, { + active: { + value: "active", + label: "Active", + }, + inactive: { + value: "inactive", + label: "Inactive", + }, + } + enum_field :priority, { + low: { + value: "low", + label: "Low", + }, + high: { + value: "high", + label: "High", + }, + } + end + end + + it "registers all fields under the same namespace" do + expect(EnumFields.registry[:basic].keys).to contain_exactly("status", "priority") + end + end + + context "with multiple namespace blocks" do + before do + EnumFields.namespace(:basic) do + enum_field :status, { + active: { + value: "active", + label: "Active", + }, + } + end + + EnumFields.namespace(:settings) do + enum_field :page_size, { + "50" => { + value: 50, + label: "50", + }, + "100" => { + value: 100, + label: "100", + }, + } + end + end + + it "registers fields under separate namespaces" do + expect(EnumFields.registry[:basic][:status]).to be_present + expect(EnumFields.registry[:settings][:page_size]).to be_present + end + end + + context "called multiple times for the same namespace" do + before do + EnumFields.namespace(:basic) do + enum_field :status, { + active: { + value: "active", + label: "Active", + }, + } + end + + EnumFields.namespace(:basic) do + enum_field :priority, { + low: { + value: "low", + label: "Low", + }, + } + end + end + + it "merges fields into the same namespace" do + expect(EnumFields.registry[:basic].keys).to contain_exactly("status", "priority") + end + end + + it "appears in the catalog" do + EnumFields.namespace(:basic) do + enum_field :priority, { + low: { + value: "low", + label: "Low", + }, + high: { + value: "high", + label: "High", + }, + } + end + + expect(EnumFields.catalog["basic"]).to eq({ + "priority" => [ + { + "value" => "low", + "label" => "Low", + }, + { + "value" => "high", + "label" => "High", + }, + ], + }) + end + end + + describe ".catalog" do + context "with nothing registered" do + it "returns an empty hash" do + expect(EnumFields.catalog).to eq({}) + end + end + + context "with a single namespace" do + before do + EnumFields.register({ + namespace: :user, + accessor: :status, + definition: status_definitions, + }) + end + + it "returns full metadata arrays grouped by namespace and accessor" do + expect(EnumFields.catalog).to eq({ + "user" => { + "status" => [ + { + "value" => "pending", + "label" => "Pending", + }, + { + "value" => "active", + "label" => "Active", + }, + ], + }, + }) + end + end + + context "with multiple namespaces" do + let(:order_status_definitions) do + { + pending: { + value: "pending", + label: "Pending", + }, + shipped: { + value: "shipped", + label: "Shipped", + }, + } + end + + before do + EnumFields.register({ + namespace: :user, + accessor: :status, + definition: status_definitions, + }) + EnumFields.register({ + namespace: :user, + accessor: :role, + definition: role_definitions, + }) + EnumFields.register({ + namespace: :order, + accessor: :status, + definition: order_status_definitions, + }) + end + + it "returns metadata for all namespaces" do + expect(EnumFields.catalog).to eq({ + "order" => { + "status" => [ + { + "value" => "pending", + "label" => "Pending", + }, + { + "value" => "shipped", + "label" => "Shipped", + }, + ], + }, + "user" => { + "status" => [ + { + "value" => "pending", + "label" => "Pending", + }, + { + "value" => "active", + "label" => "Active", + }, + ], + "role" => [ + { + "value" => "admin", + "label" => "Admin", + }, + { + "value" => "member", + "label" => "Member", + }, + ], + }, + }) + end + + it "sorts namespaces alphabetically" do + expect(EnumFields.catalog.keys).to eq(%w[order user]) + end + + it "preserves field registration order within namespaces" do + expect(EnumFields.catalog["user"].keys).to eq(%w[status role]) + end + end + + context "with a :basic namespace" do + before do + EnumFields.register({ + namespace: :basic, + accessor: :priority, + definition: { + low: { + value: "low", + label: "Low", + }, + high: { + value: "high", + label: "High", + }, + }, + }) + + EnumFields.register({ + namespace: :user, + accessor: :status, + definition: status_definitions, + }) + end + + it "sorts :basic alongside other namespaces" do + expect(EnumFields.catalog.keys).to eq(%w[basic user]) + end + + it "returns metadata for the :basic namespace" do + expect(EnumFields.catalog["basic"]).to eq({ + "priority" => [ + { + "value" => "low", + "label" => "Low", + }, + { + "value" => "high", + "label" => "High", + }, + ], + }) + end end end end