AlchemyCMS/alchemy_cms

View on GitHub
app/models/alchemy/element.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
# frozen_string_literal: true

# == Schema Information
#
# Table name: alchemy_elements
#
#  id                :integer          not null, primary key
#  name              :string
#  position          :integer
#  page_version_id   :integer          not null
#  public            :boolean          default(TRUE)
#  fixed             :boolean          default(FALSE)
#  folded            :boolean          default(FALSE)
#  unique            :boolean          default(FALSE)
#  created_at        :datetime         not null
#  updated_at        :datetime         not null
#  creator_id        :integer
#  updater_id        :integer
#  cached_tag_list   :text
#  parent_element_id :integer
#

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

module Alchemy
  class Element < BaseRecord
    NAME_REGEXP = /\A[a-z0-9_-]+\z/

    include Alchemy::Logger
    include Alchemy::Taggable
    include Alchemy::Hints

    FORBIDDEN_DEFINITION_ATTRIBUTES = [
      "amount",
      "autogenerate",
      "compact",
      "deprecated",
      "hint",
      "ingredients",
      "message",
      "nestable_elements",
      "taggable",
      "warning"
    ].freeze

    # All Elements that share the same page version and parent element and are fixed or not are considered a list.
    #
    # If parent_element_id is nil (typical case for a simple page),
    # then all elements on that page are still in one list,
    # because acts_as_list correctly creates this statement:
    #
    #   WHERE page_version_id = 1 and fixed = FALSE AND parent_element_id = NULL
    #
    acts_as_list scope: [:page_version_id, :fixed, :parent_element_id]

    stampable stamper_class_name: Alchemy.user_class.name

    before_destroy :delete_all_nested_elements

    has_many :all_nested_elements,
      -> { order(:position) },
      class_name: "Alchemy::Element",
      foreign_key: :parent_element_id,
      dependent: :destroy

    has_many :nested_elements,
      -> { order(:position).published },
      class_name: "Alchemy::Element",
      foreign_key: :parent_element_id,
      dependent: :destroy,
      inverse_of: :parent_element

    belongs_to :page_version, touch: true, inverse_of: :elements
    has_one :page, through: :page_version

    # A nested element belongs to a parent element.
    belongs_to :parent_element,
      class_name: "Alchemy::Element",
      optional: true,
      touch: true,
      inverse_of: :nested_elements

    has_and_belongs_to_many :touchable_pages, -> { distinct },
      class_name: "Alchemy::Page",
      join_table: ElementToPage.table_name

    validates_presence_of :name, on: :create
    validates_format_of :name, on: :create, with: NAME_REGEXP

    attr_accessor :autogenerate_nested_elements
    after_create :generate_nested_elements, unless: -> { autogenerate_nested_elements == false }

    after_update :touch_touchable_pages

    scope :published, -> { where(public: true) }
    scope :hidden, -> { where(public: false) }
    scope :not_restricted, -> { joins(:page).merge(Page.not_restricted) }
    scope :named, ->(names) { where(name: names) }
    scope :excluded, ->(names) { where.not(name: names) }
    scope :fixed, -> { where(fixed: true) }
    scope :unfixed, -> { where(fixed: false) }
    scope :from_current_site, -> { where(Language.table_name => {site_id: Current.site}).joins(page: "language") }
    scope :folded, -> { where(folded: true) }
    scope :expanded, -> { where(folded: false) }
    scope :not_nested, -> { where(parent_element_id: nil) }

    delegate :restricted?, to: :page, allow_nil: true

    # Concerns
    include Definitions
    include ElementIngredients
    include Presenters

    # class methods
    class << self
      # Builds a new element as described in +/config/alchemy/elements.yml+
      #
      # - Returns a new Alchemy::Element object if no name is given in attributes,
      #   because the definition can not be found w/o name
      # - Raises Alchemy::ElementDefinitionError if no definition for given attributes[:name]
      #   could be found
      #
      def new(attributes = {})
        return super if attributes[:name].blank?

        element_attributes = attributes.to_h.merge(name: attributes[:name].split("#").first)
        element_definition = Element.definition_by_name(element_attributes[:name])
        if element_definition.nil?
          raise(ElementDefinitionError, attributes)
        end

        super(element_definition.merge(element_attributes).except(*FORBIDDEN_DEFINITION_ATTRIBUTES))
      end

      # This methods does a copy of source and all its ingredients.
      #
      # == Options
      #
      # You can pass a differences Hash as second option to update attributes for the copy.
      #
      # == Example
      #
      #   @copy = Alchemy::Element.copy(@element, {public: false})
      #   @copy.public? # => false
      #
      def copy(source_element, differences = {})
        Alchemy::DuplicateElement.new(source_element).call(differences)
      end

      def all_from_clipboard(clipboard)
        return none if clipboard.nil?

        where(id: clipboard.collect { |e| e["id"] })
      end

      # All elements in clipboard that could be placed on page
      #
      def all_from_clipboard_for_page(clipboard, page)
        return none if clipboard.nil? || page.nil?

        all_from_clipboard(clipboard).where(name: page.available_element_names)
      end

      # All elements in clipboard that could be placed as a child of `parent_element`
      def all_from_clipboard_for_parent_element(clipboard, parent_element)
        return none if clipboard.nil? || parent_element.nil?

        all_from_clipboard(clipboard).where(name: parent_element.definition["nestable_elements"])
      end
    end

    # Heavily unoptimized naive way to get all parent ids
    def parent_element_ids
      ids ||= []
      parent = parent_element
      while parent
        ids.push parent.id
        parent = parent.parent_element
      end
      ids
    end

    # Returns next public element from same page.
    #
    # Pass an element name to get next of this kind.
    #
    def next(name = nil)
      elements = page.elements.published.where("position > ?", position)
      select_element(elements, name, :asc)
    end

    # Returns previous public element from same page.
    #
    # Pass an element name to get previous of this kind.
    #
    def prev(name = nil)
      elements = page.elements.published.where("position < ?", position)
      select_element(elements, name, :desc)
    end

    # Stores the page into +touchable_pages+ (Pages that have to be touched after updating the element).
    def store_page(page)
      return true if page.nil?

      unless touchable_pages.include? page
        touchable_pages << page
      end
    end

    # Returns true if the definition of this element has a taggable true value.
    def taggable?
      definition["taggable"] == true
    end

    # The opposite of folded?
    def expanded?
      !folded?
    end

    # Defined as compact element?
    def compact?
      definition["compact"] == true
    end

    # Defined as deprecated element?
    #
    # You can either set true or a String on your elements definition.
    #
    # == Passing true
    #
    #     - name: old_element
    #       deprecated: true
    #
    # The deprecation notice can be translated. Either as global notice for all deprecated elements.
    #
    #     en:
    #       alchemy:
    #         element_deprecation_notice: Foo baz widget is deprecated
    #
    # Or add a translation to your locale file for a per element notice.
    #
    #     en:
    #       alchemy:
    #         element_deprecation_notices:
    #           old_element: Foo baz widget is deprecated
    #
    # == Pass a String
    #
    #     - name: old_element
    #       deprecated: This element will be removed soon.
    #
    # @return Boolean
    def deprecated?
      !!definition["deprecated"]
    end

    # The element's view partial is dependent from its name
    #
    # == Define elements
    #
    # Elements are defined in the +config/alchemy/elements.yml+ file
    #
    #     - name: article
    #       ingredients:
    #       ...
    #
    # == Override the view
    #
    # Element partials live in +app/views/alchemy/elements+
    #
    def to_partial_path
      "alchemy/elements/#{name}"
    end

    # A collection of element names that can be nested inside this element.
    def nestable_elements
      definition.fetch("nestable_elements", [])
    end

    private

    def generate_nested_elements
      definition.fetch("autogenerate", []).each do |nestable_element|
        if nestable_elements.include?(nestable_element)
          Element.create(page_version: page_version, parent_element_id: id, name: nestable_element)
        else
          log_warning("Element '#{nestable_element}' not a nestable element for '#{name}'. Skipping!")
        end
      end
    end

    def select_element(elements, name, order)
      elements = elements.named(name) if name.present?
      elements.reorder(position: order).limit(1).first
    end

    # Updates all +touchable_pages+
    #
    # Called after_update
    #
    def touch_touchable_pages
      return unless respond_to?(:touchable_pages)

      touchable_pages.each(&:touch)
    end

    def delete_all_nested_elements
      deeply_nested_elements = descendent_elements(self).flatten
      DeleteElements.new(deeply_nested_elements).call
      nested_elements.reset
      all_nested_elements.reset
    end

    def descendent_elements(element)
      element.all_nested_elements + element.all_nested_elements.map do |nested_element|
        descendent_elements(nested_element)
      end
    end
  end
end