class Alchemy::Resource

Alchemy::Resource

Used to DRY up resource like structures in Alchemy’s admin backend. So far Language, User and Tag already uses this.

It provides convenience methods to create an admin interface without further knowledge about the model and the controller (it’s instantiated with controller_path at least and guesses the model accordingly)

For examples how to use in controllers see Alchemy::ResourcesController or inherit from it directly.

Naming Conventions

As Rails’ form helpers, path helpers, etc. and declarative authorization rely on controller_path even if the model class is named differently (or sits in another namespace) model and controller are handled separatly here. Therefore “resource” always refers to the controller_path whereas “model” refers to the model class.

Skip attributes

Usually you don’t want your users to see and edit all attributes provided by a model. Hence some default attributes, namely id, updated_at, created_at, creator_id and updater_id are not returned by Resource#attributes.

If you want to skip a different set of attributes just define a skipped_alchemy_resource_attributes class method in your model class that returns an array of strings.

Example

def self.skipped_alchemy_resource_attributes
  %w(id updated_at secret_token remote_ip)
end

Restrict attributes

Beside skipping certain attributes you can also restrict them. Restricted attributes can not be edited by the user but still be seen in the index view. No attributes are restricted by default.

Example

def self.restricted_alchemy_resource_attributes
  %w(synced_at remote_record_id)
end

Searchable attributes

By default all :text and :string based attributes are searchable in the admin interface. You can overwrite this behaviour by providing a set of attribute names that should be searchable instead.

Example

def self.searchable_alchemy_resource_attributes
  %w(remote_record_id firstname lastname age)
end

Resource relations

Alchemy::Resource can take care of ActiveRecord relations.

BelongsTo Relations

For belongs_to associations you will have to define a alchemy_resource_relations class method in your model class:

def self.alchemy_resource_relations
  {
    location: {attr_method: 'name', attr_type: 'string'},
    organizer: {attr_method: 'name', attr_type: 'string'}
  }
end

With this knowledge Resource#attributes will return location#name and organizer#name instead of location_id and organizer_id. Refer to Alchemy::ResourcesController for further details on usage.

Creation

Resource needs a controller_path at least. Without other arguments it will guess the model name from it and assume that the model doesn’t live in an engine. Moreover model and controller has to follow Rails’ naming convention:

Event -> EventsController

It will also strip “admin” automatically, so this is also valid:

Event -> Admin::EventsController

If your Resource and it’s controllers are part of an engine you need to provide Alchemy’s module_definition, so resource can provide the correct url_proxy. If you don’t declare it in Alchemy, you need at least provide the following hash (i.e. if your engine is named EventEngine):

resource = Resource.new(controller_path, {"engine_name" => "event_engine"})

If you don’t want to stick with these conventions you can separate model and controller by providing a model class (for example used by Alchemy’s Tags admin interface):

resource = Resource.new('/admin/tags', {"engine_name"=>"alchemy"}, Gutentag::Tag)

Constants

DEFAULT_SKIPPED_ASSOCIATIONS
DEFAULT_SKIPPED_ATTRIBUTES
SEARCHABLE_COLUMN_TYPES

Attributes

model[R]
model_associations[RW]
resource_relations[RW]

Public Class Methods

new(controller_path, module_definition = nil, custom_model = nil) click to toggle source
# File lib/alchemy/resource.rb, line 108
def initialize(controller_path, module_definition = nil, custom_model = nil)
  @controller_path = controller_path
  @module_definition = module_definition
  @model = custom_model || guess_model_from_controller_path
  if model.respond_to?(:alchemy_resource_relations)
    if !model.respond_to?(:reflect_on_all_associations)
      raise MissingActiveRecordAssociation
    end

    store_model_associations
    map_relations
  end
end

Public Instance Methods

attributes() click to toggle source
# File lib/alchemy/resource.rb, line 164
def attributes
  @_attributes ||= model.columns.collect do |col|
    next if skipped_attributes.include?(col.name)

    {
      name: col.name,
      type: resource_column_type(col),
      relation: resource_relation(col.name),
      enum: enum_values_collection_for_select(col.name)
    }.delete_if { |_k, v| v.blank? }
  end.compact
end
editable_attributes() click to toggle source
# File lib/alchemy/resource.rb, line 198
def editable_attributes
  attributes.reject { |h| restricted_attributes.map(&:to_s).include?(h[:name].to_s) }
end
engine_name() click to toggle source
# File lib/alchemy/resource.rb, line 226
def engine_name
  @module_definition && @module_definition["engine_name"]
end
enum_values_collection_for_select(column_name) click to toggle source
# File lib/alchemy/resource.rb, line 177
def enum_values_collection_for_select(column_name)
  enum = model.defined_enums[column_name]
  return if enum.blank?

  enum.keys.map do |key|
    [
      ::I18n.t(key, scope: [
        :activerecord, :attributes, model.model_name.i18n_key, "#{column_name}_values"
      ], default: key.humanize),
      key
    ]
  end
end
help_text_for(attribute) click to toggle source

Returns a help text for resource’s form or nil if no help text is available

Example:

de:
  alchemy:
    resource_help_texts:
      my_resource_name:
        attribute_name: This is the fancy help text
# File lib/alchemy/resource.rb, line 240
def help_text_for(attribute)
  ::I18n.translate!(attribute[:name], scope: [:alchemy, :resource_help_texts, resource_name])
rescue ::I18n::MissingTranslationData
  nil
end
in_engine?() click to toggle source
# File lib/alchemy/resource.rb, line 222
def in_engine?
  !engine_name.nil?
end
model_association_names() click to toggle source

Returns an array of underscored association names

# File lib/alchemy/resource.rb, line 156
def model_association_names
  return unless model_associations

  model_associations.map do |assoc|
    assoc.name.to_sym
  end
end
namespace_for_scope() click to toggle source
# File lib/alchemy/resource.rb, line 148
def namespace_for_scope
  namespace_array = namespace_diff
  namespace_array.delete(engine_name) if in_engine?
  namespace_array.map(&:to_sym) # Rails >= 6.0.3.7 needs symbols in polymorphic routes
end
namespaced_resource_name() click to toggle source
# File lib/alchemy/resource.rb, line 134
def namespaced_resource_name
  @_namespaced_resource_name ||= begin
    namespaced_resources_name.to_s.singularize
  end.to_sym # Rails >= 6.0.3.7 needs symbols in polymorphic routes
end
namespaced_resources_name() click to toggle source
# File lib/alchemy/resource.rb, line 140
def namespaced_resources_name
  @_namespaced_resources_name ||= begin
    resource_name_array = resource_array.dup
    resource_name_array.delete(engine_name) if in_engine?
    resource_name_array.join("_")
  end.to_sym # Rails >= 6.0.3.7 needs symbols in polymorphic routes
end
resource_array() click to toggle source
# File lib/alchemy/resource.rb, line 122
def resource_array
  @_resource_array ||= controller_path_array.reject { |el| el == "admin" }
end
resource_name() click to toggle source
# File lib/alchemy/resource.rb, line 130
def resource_name
  @_resource_name ||= resources_name.singularize
end
resources_name() click to toggle source
# File lib/alchemy/resource.rb, line 126
def resources_name
  @_resources_name ||= resource_array.last
end
restricted_attributes() click to toggle source

Return attributes that should be viewable but not editable.

# File lib/alchemy/resource.rb, line 248
def restricted_attributes
  if model.respond_to?(:restricted_alchemy_resource_attributes)
    model.restricted_alchemy_resource_attributes
  else
    []
  end
end
search_field_name() click to toggle source

Search field input name

Joins all searchable attribute names into a Ransack compatible search query

# File lib/alchemy/resource.rb, line 218
def search_field_name
  searchable_attribute_names.join("_or_") + "_cont"
end
searchable_attribute_names() click to toggle source

Returns all attribute names that are searchable in the admin interface

# File lib/alchemy/resource.rb, line 204
def searchable_attribute_names
  if model.respond_to?(:searchable_alchemy_resource_attributes)
    model.searchable_alchemy_resource_attributes
  else
    attributes.select { |a| searchable_attribute?(a) }
      .concat(searchable_relation_attributes(attributes))
      .collect { |h| h[:name] }
  end
end
skipped_attributes() click to toggle source

Return attributes that should neither be viewable nor editable.

# File lib/alchemy/resource.rb, line 258
def skipped_attributes
  if model.respond_to?(:skipped_alchemy_resource_attributes)
    model.skipped_alchemy_resource_attributes
  else
    DEFAULT_SKIPPED_ATTRIBUTES
  end
end
sorted_attributes() click to toggle source
# File lib/alchemy/resource.rb, line 191
def sorted_attributes
  @_sorted_attributes ||= attributes
    .sort_by { |attr| (attr[:name] == "name") ? 0 : 1 }
    .sort_by! { |attr| (attr[:type] == :boolean) ? 1 : 0 }
    .sort_by! { |attr| (attr[:name] == "updated_at") ? 1 : 0 }
end

Private Instance Methods

association_from_relation_name(name) click to toggle source

Returns activerecord association that has the given name

# File lib/alchemy/resource.rb, line 339
def association_from_relation_name(name)
  model_associations.detect { |a| a.name == name.to_sym }
end
controller_path_array() click to toggle source
# File lib/alchemy/resource.rb, line 292
def controller_path_array
  @controller_path.split("/")
end
guess_model_from_controller_path() click to toggle source
# File lib/alchemy/resource.rb, line 288
def guess_model_from_controller_path
  resource_array.join("/").classify.constantize
end
map_relations() click to toggle source

Expands the resource_relations hash with matching activerecord associations data.

# File lib/alchemy/resource.rb, line 318
def map_relations
  self.resource_relations = {}
  model.alchemy_resource_relations.each do |name, options|
    relation_name = name.to_s.gsub(/_id$/, "") # ensure that we don't have an id
    association = association_from_relation_name(relation_name)
    foreign_key = association.options[:foreign_key] || :"#{association.name}_id"
    collection = options[:collection] || resource_relation_class(association).all
    resource_relations[foreign_key] = options.merge(
      model_association: association,
      name: relation_name,
      collection: collection
    )
  end
end
namespace_diff() click to toggle source
# File lib/alchemy/resource.rb, line 296
def namespace_diff
  controller_path_array - resource_array
end
resource_column_type(col) click to toggle source
# File lib/alchemy/resource.rb, line 309
def resource_column_type(col)
  resource_relation_type(col.name) || (col.try(:array) ? :array : col.type)
end
resource_relation(column_name) click to toggle source
# File lib/alchemy/resource.rb, line 313
def resource_relation(column_name)
  resource_relations[column_name.to_sym] if resource_relations
end
resource_relation_class(association) click to toggle source
# File lib/alchemy/resource.rb, line 304
def resource_relation_class(association)
  class_name = association.options[:class_name] || association.name.to_s.classify
  class_name.constantize
end
resource_relation_type(column_name) click to toggle source
# File lib/alchemy/resource.rb, line 300
def resource_relation_type(column_name)
  resource_relation(column_name).try(:[], :attr_type)
end
searchable_attribute?(attribute) click to toggle source
# File lib/alchemy/resource.rb, line 268
def searchable_attribute?(attribute)
  SEARCHABLE_COLUMN_TYPES.include?(attribute[:type].to_sym) && !attribute.key?(:relation)
end
searchable_attribute_on_relation?(attribute) click to toggle source
# File lib/alchemy/resource.rb, line 272
def searchable_attribute_on_relation?(attribute)
  attribute.key?(:relation) &&
    SEARCHABLE_COLUMN_TYPES.include?(attribute[:relation][:attr_type].to_sym)
end
searchable_relation_attribute(attribute) click to toggle source
# File lib/alchemy/resource.rb, line 281
def searchable_relation_attribute(attribute)
  {
    name: "#{attribute[:relation][:model_association].name}_#{attribute[:relation][:attr_method]}",
    type: attribute[:relation][:attr_type]
  }
end
searchable_relation_attributes(attrs) click to toggle source
# File lib/alchemy/resource.rb, line 277
def searchable_relation_attributes(attrs)
  attrs.select { |a| searchable_attribute_on_relation?(a) }.map { |a| searchable_relation_attribute(a) }
end
store_model_associations() click to toggle source

Stores all activerecord associations in model_associations attribute

# File lib/alchemy/resource.rb, line 334
def store_model_associations
  self.model_associations = model.reflect_on_all_associations.delete_if { |a| DEFAULT_SKIPPED_ASSOCIATIONS.include?(a.name.to_s) }
end