Skip to content

Commit

Permalink
Add has_many ingredients relation to element
Browse files Browse the repository at this point in the history
  • Loading branch information
tvdeyen committed Jun 28, 2021
1 parent d35e875 commit ca0e05e
Show file tree
Hide file tree
Showing 8 changed files with 329 additions and 7 deletions.
3 changes: 3 additions & 0 deletions app/models/alchemy/element.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

require_dependency "alchemy/element/definitions"
require_dependency "alchemy/element/element_contents"
require_dependency "alchemy/element/element_ingredients"
require_dependency "alchemy/element/element_essences"
require_dependency "alchemy/element/presenters"

Expand All @@ -39,6 +40,7 @@ class Element < BaseRecord
"nestable_elements",
"contents",
"hint",
"ingredients",
"taggable",
"compact",
"message",
Expand Down Expand Up @@ -117,6 +119,7 @@ class Element < BaseRecord
include Definitions
include ElementContents
include ElementEssences
include ElementIngredients
include Presenters

# class methods
Expand Down
100 changes: 100 additions & 0 deletions app/models/alchemy/element/element_ingredients.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# frozen_string_literal: true

module Alchemy
class Element < BaseRecord
# Methods concerning ingredients for elements
#
module ElementIngredients
extend ActiveSupport::Concern

included do
attr_accessor :autogenerate_ingredients

has_many :ingredients,
class_name: "Alchemy::Ingredient",
inverse_of: :element,
dependent: :destroy

before_create :build_ingredients,
unless: -> { autogenerate_ingredients == false }
end

# Find first ingredient from element by given role.
def ingredient_by_role(role)
ingredients.detect { |ingredient| ingredient.role == role.to_s }
end

# Find first ingredient from element by given type.
def ingredient_by_type(type)
ingredients_by_type(type).first
end

# All ingredients from element by given type.
def ingredients_by_type(type)
ingredients.select do |ingredient|
ingredient.type == Ingredient.normalize_type(type)
end
end

# Copy current ingredient's ingredients to given target element
def copy_ingredients_to(element)
ingredients.map do |ingredient|
Ingredient.copy(ingredient, element_id: element.id)
end
end

# Returns all element ingredient definitions from the +elements.yml+ file
def ingredient_definitions
definition.fetch(:ingredients, [])
end

# Returns the definition for given ingredient role
def ingredient_definition_for(role)
if ingredient_definitions.blank?
log_warning "Element #{name} is missing the ingredient definition for #{role}"
nil
else
ingredient_definitions.find { |d| d[:role] == role.to_s }
end
end

# Returns an array of all Richtext ingredients ids from elements
#
# This is used to re-initialize the TinyMCE editor in the element editor.
#
def richtext_ingredients_ids
ids = ingredients.select(&:has_tinymce?).collect(&:id)
expanded_nested_elements = nested_elements.expanded
if expanded_nested_elements.present?
ids += expanded_nested_elements.collect(&:richtext_ingredients_ids)
end
ids.flatten
end

# Has any of the ingredients validations defined?
def has_validations?
ingredients.any?(&:has_validations?)
end

# All element ingredients where the validation has failed.
def ingredients_with_errors
ingredients.select(&:validation_failed?)
end

# True if the element has a ingredient for given name
# that has a non blank value.
def has_value_for?(role)
ingredient_by_role(role)&.value.present?
end

private

# Builds ingredients for this element as described in the +elements.yml+
def build_ingredients
self.ingredients = ingredient_definitions.map do |attributes|
Ingredient.build(role: attributes[:role], element: self)
end
end
end
end
end
57 changes: 55 additions & 2 deletions app/models/alchemy/ingredient.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,39 @@ class DefinitionError < StandardError; end
self.abstract_class = true
self.table_name = "alchemy_ingredients"

belongs_to :element, class_name: "Alchemy::Element"
belongs_to :element, class_name: "Alchemy::Element", inverse_of: :ingredients
belongs_to :related_object, polymorphic: true, optional: true

validates :type, presence: true
validates :role, presence: true

class << self
# Builds concrete ingredient class as described in the +elements.yml+
def build(attributes = {})
element = attributes[:element]
raise ArgumentError, "No element given. Please pass element in attributes." if element.nil?
raise ArgumentError, "No role given. Please pass role in attributes." if attributes[:role].nil?

definition = element.ingredient_definition_for(attributes[:role])
if definition.nil?
raise DefinitionError,
"No definition found for #{attributes[:role]}. Please define #{attributes[:role]} on #{element[:name]}."
end

ingredient_class = Ingredient.ingredient_class_by_type(definition[:type])
ingredient_class.new(
type: Ingredient.normalize_type(definition[:type]),
value: definition[:default],
role: definition[:role],
element: element,
)
end

# Creates concrete ingredient class as described in the +elements.yml+
def create(attributes = {})
build(attributes).tap(&:save)
end

# Defines getters and setter methods for ingredient attributes
def ingredient_attributes(*attributes)
attributes.each do |name|
Expand All @@ -31,6 +57,33 @@ def related_object_alias(name)
alias_method name, :related_object
alias_method "#{name}=", :related_object=
end

# Returns an ingredient class by type
#
# Raises ArgumentError if there is no such class in the
# +Alchemy::Ingredients+ module namespace.
#
# If you add custom ingredient class,
# put them in the +Alchemy::Ingredients+ module namespace
#
# @param [String] The ingredient class name to constantize
# @return [Class]
def ingredient_class_by_type(ingredient_type)
Alchemy::Ingredients.const_get(ingredient_type.to_s.classify.demodulize)
end

# Modulize ingredient type
#
# Makes sure the passed ingredient type is in the +Alchemy::Ingredients+
# module namespace.
#
# If you add custom ingredient class,
# put them in the +Alchemy::Ingredients+ module namespace
# @param [String] Ingredient class name
# @return [String]
def normalize_type(ingredient_type)
"Alchemy::Ingredients::#{ingredient_type.to_s.classify.demodulize}"
end
end

# Compatibility method for access from element
Expand All @@ -53,7 +106,7 @@ def settings
def definition
return {} unless element

element.content_definition_for(role) || {}
element.ingredient_definition_for(role) || {}
end

# The first 30 characters of the value
Expand Down
7 changes: 3 additions & 4 deletions lib/alchemy/test_support/shared_ingredient_examples.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
require "shoulda-matchers"

RSpec.shared_examples_for "an alchemy ingredient" do
let(:element) { build(:alchemy_element) }
let(:element) { build(:alchemy_element, name: "element_with_ingredients") }

subject(:ingredient) do
described_class.new(
element: element,
type: described_class.name,
role: "headline",
)
end
Expand Down Expand Up @@ -45,8 +44,8 @@
context "with element" do
it do
is_expected.to eq({
name: "headline",
type: "EssenceText",
role: "headline",
type: "Text",
settings: {
linkable: true,
},
Expand Down
10 changes: 10 additions & 0 deletions spec/dummy/config/alchemy/elements.yml
Original file line number Diff line number Diff line change
Expand Up @@ -187,3 +187,13 @@
type: EssenceText
- name: text
type: EssenceRichtext

- name: element_with_ingredients
ingredients:
- role: headline
type: Text
default: Hello World
settings:
linkable: true
- role: text
type: Richtext
98 changes: 98 additions & 0 deletions spec/models/alchemy/element_ingredients_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

require "rails_helper"

RSpec.describe Alchemy::Element do
it { is_expected.to have_many(:ingredients) }

let(:element) { build(:alchemy_element, name: "element_with_ingredients") }

it "creates ingredients after creation" do
expect {
element.save!
}.to change { element.ingredients.count }.by(2)
end

describe "#ingredients_by_type" do
let(:element) { create(:alchemy_element, :with_ingredients) }
let(:expected_ingredients) { element.ingredients.texts }

context "with namespaced essence type" do
subject { element.ingredients_by_type("Alchemy::Text") }

it { is_expected.not_to be_empty }

it("should return the correct list of essences") { is_expected.to eq(expected_ingredients) }
end

context "without namespaced essence type" do
subject { element.ingredients_by_type("Text") }

it { is_expected.not_to be_empty }

it("should return the correct list of essences") { is_expected.to eq(expected_ingredients) }
end
end

describe "#ingredient_by_type" do
let!(:element) { create(:alchemy_element, :with_ingredients) }
let(:ingredient) { element.ingredients.first }

context "with namespaced essence type" do
it "should return ingredient by passing a essence type" do
expect(element.ingredient_by_type("Alchemy::Text")).to eq(ingredient)
end
end

context "without namespaced essence type" do
it "should return ingredient by passing a essence type" do
expect(element.ingredient_by_type("Text")).to eq(ingredient)
end
end
end

describe "#ingredient_by_role" do
let!(:element) { create(:alchemy_element, :with_ingredients) }
let(:ingredient) { element.ingredients.first }

context "with role existing" do
it "should return ingredient" do
expect(element.ingredient_by_role(:headline)).to eq(ingredient)
end
end

context "role not existing" do
it { expect(element.ingredient_by_role(:foo)).to be_nil }
end
end

describe "#has_value_for?" do
let!(:element) { create(:alchemy_element, :with_ingredients) }

context "with role existing" do
let(:ingredient) { element.ingredients.first }

context "with blank value" do
before do
expect(ingredient).to receive(:value) { nil }
end

it { expect(element.has_value_for?(:headline)).to be(false) }
end

context "with value present" do
before do
expect(ingredient).to receive(:value) { "Headline" }
end

it "should return ingredient" do
expect(element.has_value_for?(:headline)).to be(true)
end
end
end

context "role not existing" do
it { expect(element.has_value_for?(:foo)).to be(false) }
end
end
end
Loading

0 comments on commit ca0e05e

Please sign in to comment.