Skip to content

Commit

Permalink
Merge pull request #1544 from tvdeyen/elements-finder
Browse files Browse the repository at this point in the history
Introduces an Elements finder class
  • Loading branch information
tvdeyen authored Feb 22, 2019
2 parents 81fd64a + b2683c6 commit 6b4b8e7
Show file tree
Hide file tree
Showing 13 changed files with 481 additions and 326 deletions.
3 changes: 3 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ Layout/MultilineMethodCallBraceLayout:
Layout/MultilineMethodCallIndentation:
Enabled: false

Layout/MultilineOperationIndentation:
EnforcedStyle: indented

Layout/SpaceBeforeBlockBraces:
Enabled: false
StyleGuide: http://relaxed.ruby.style/#stylespacebeforeblockbraces
Expand Down
133 changes: 48 additions & 85 deletions app/helpers/alchemy/elements_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module ElementsHelper
include Alchemy::UrlHelper
include Alchemy::ElementsBlockHelper

# Renders all elements from current page
# Renders elements from given page
#
# == Examples:
#
Expand Down Expand Up @@ -45,53 +45,69 @@ module ElementsHelper
# with: 'contact_teaser'
# }) %>
#
# @param [Hash] options
# Additional options.
# === Custom elements finder:
#
# Having a custom element finder class:
#
# class MyCustomNewsArchive
# def elements(page:)
# news_page.elements.available.named('news').order(created_at: :desc)
# end
#
# private
#
# def news_page
# Alchemy::Page.where(page_layout: 'news-archive')
# end
# end
#
# In your view:
#
# <div class="news-archive">
# <%= render_elements finder: MyCustomNewsArchive.new %>
# </div>
#
# @option options [Alchemy::Page|String] :from_page (@page)
# The page the elements are rendered from. You can pass a page_layout String or a {Alchemy::Page} object.
# @option options [Array<String>|String] :only
# A list of element names only to be rendered.
# @option options [Array<String>|String] :except
# A list of element names not to be rendered.
# @option options [Number] :count
# The amount of elements to be rendered (begins with first element found)
# @option options [Array or String] :except ([])
# A list of element names not to be rendered.
# @option options [Number] :offset
# The offset to begin loading elements from
# @option options [Hash] :fallback
# Define elements that are rendered from another page.
# @option options [Alchemy::Page or String] :from_page (@page)
# The page the elements are rendered from. You can pass a page_layout String or a {Alchemy::Page} object.
# @option options [Array or String] :only ([])
# A list of element names only to be rendered.
# @option options [Boolean] :random
# @option options [Boolean] :random (false)
# Randomize the output of elements
# @option options [Boolean] :reverse
# @option options [Boolean] :reverse (false)
# Reverse the rendering order
# @option options [String] :sort_by
# The name of a {Alchemy::Content} to sort the elements by
# @option options [String] :separator
# A string that will be used to join the element partials. Default nil
# A string that will be used to join the element partials.
# @option options [Class] :finder (Alchemy::ElementsFinder)
# A class instance that will return elements that get rendered.
# Use this for your custom element loading logic in views.
#
def render_elements(options = {})
options = {
from_page: @page,
render_format: 'html',
reverse: false
render_format: 'html'
}.update(options)

pages = pages_holding_elements(options.delete(:from_page))

if pages.blank?
warning('No page to get elements from was found')
return
if options[:sort_by]
Alchemy::Deprecation.warn "options[:sort_by] has been removed without replacement. " /
"Please implement your own element sorting by passing a custom finder instance to options[:finder]."
end

elements = collect_elements_from_pages(pages, options)
finder = options[:finder] || Alchemy::ElementsFinder.new(options)
elements = finder.elements(page: options[:from_page])

if options[:sort_by].present?
elements = sort_elements_by_content(
elements,
options.delete(:sort_by),
options[:reverse]
)
buff = []
elements.each_with_index do |element, i|
buff << render_element(element, :view, options, i + 1)
end

render_element_view_partials(elements, options)
buff.join(options[:separator]).html_safe
end

# This helper renders a {Alchemy::Element} partial.
Expand Down Expand Up @@ -231,73 +247,20 @@ def element_tags_attributes(element, options = {})
end

# Sort given elements by content.
#
# @deprecated
# @param [Array] elements - The elements you want to sort
# @param [String] content_name - The name of the content you want to sort by
# @param [Boolean] reverse - Reverse the sorted elements order
#
# @return [Array]
#
def sort_elements_by_content(elements, content_name, reverse = false)
Alchemy::Deprecation.warn "options[:sort_by] is deprecated. Please implement your own element sorting."
sorted_elements = elements.sort_by do |element|
content = element.content_by_name(content_name)
content ? content.ingredient.to_s : ''
end

reverse ? sorted_elements.reverse : sorted_elements
end

private

def pages_holding_elements(page)
case page
when String
Language.current.pages.where(
page_layout: page,
restricted: false
).to_a
when Page
page
end
end

def collect_elements_from_pages(page, options)
if page.is_a? Array
elements = page.collect { |p| p.find_elements(options) }.flatten
else
elements = page.find_elements(options)
end
if fallback_required?(elements, options)
elements += fallback_elements(options)
end
elements
end

def fallback_required?(elements, options)
options[:fallback] && elements.detect { |e| e.name == options[:fallback][:for] }.nil?
end

def fallback_elements(options)
fallback_options = options.delete(:fallback)
case fallback_options[:from]
when String
page = Language.current.pages.find_by(
page_layout: fallback_options[:from],
restricted: false
)
when Page
page = fallback_options[:from]
end
return [] if page.blank?
page.elements.not_trashed.named(fallback_options[:with].presence || fallback_options[:for])
end

def render_element_view_partials(elements, options = {})
buff = []
elements.each_with_index do |element, i|
buff << render_element(element, :view, options, i + 1)
end
buff.join(options[:separator]).html_safe
end
end
end
2 changes: 1 addition & 1 deletion app/models/alchemy/element.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class Element < BaseRecord
after_update :touch_touchable_pages

scope :trashed, -> { where(position: nil).order('updated_at DESC') }
scope :not_trashed, -> { where(Element.arel_table[:position].not_eq(nil)) }
scope :not_trashed, -> { where.not(position: nil) }
scope :published, -> { where(public: true) }
scope :not_restricted, -> { joins(:page).merge(Page.not_restricted) }
scope :available, -> { published.not_trashed }
Expand Down
32 changes: 32 additions & 0 deletions app/models/alchemy/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,38 @@ def new_name_for_copy(custom_name, source_name)
# Instance methods
#

# Returns elements from page.
#
# @option options [Array<String>|String] :only
# Returns only elements with given names
# @option options [Array<String>|String] :except
# Returns all elements except the ones with given names
# @option options [Integer] :count
# Limit the count of returned elements
# @option options [Integer] :offset
# Starts with an offset while returning elements
# @option options [Boolean] :include_hidden (false)
# Return hidden elements as well
# @option options [Boolean] :random (false)
# Return elements randomly shuffled
# @option options [Boolean] :reverse (false)
# Reverse the load order
# @option options [Class] :finder (Alchemy::ElementsFinder)
# A class that will return elements from page.
# Use this for your custom element loading logic.
#
# @return [ActiveRecord::Relation]
def find_elements(options = {}, show_non_public = false)
if show_non_public
Alchemy::Deprecation.warn "Passing true as second argument to page#find_elements to include" /
" invisible elements has been removed. Please implement your own ElementsFinder" /
" and pass it with options[:finder]."
end

finder = options[:finder] || Alchemy::ElementsFinder.new(options)
finder.elements(page: self)
end

# The page's view partial is dependent from its page layout
#
# == Define page layouts
Expand Down
37 changes: 0 additions & 37 deletions app/models/alchemy/page/page_elements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,43 +61,6 @@ def copy_elements(source, target)
end
end

# Finds elements of page.
#
# @param [Hash]
# options hash
# @param [Boolean] (false)
# Pass true, if you want to also have not published elements.
#
# @option options [Array] only
# Returns only elements with given names
# @option options [Array] except
# Returns all elements except the ones with given names
# @option options [Fixnum] count
# Limit the count of returned elements
# @option options [Fixnum] offset
# Starts with an offset while returning elements
# @option options [Boolean] random (false)
# Return elements randomly shuffled
#
# @return [ActiveRecord::Relation]
#
def find_elements(options = {}, show_non_public = false)
elements = self.elements
if options[:only].present?
elements = elements.named(options[:only])
elsif options[:except].present?
elements = elements.excluded(options[:except])
end
if options[:reverse_sort] || options[:reverse]
elements = elements.reverse_order
end
elements = elements.offset(options[:offset]).limit(options[:count])
if options[:random]
elements = elements.order("RAND()")
end
show_non_public ? elements : elements.published
end

# All available element definitions that can actually be placed on current page.
#
# It extracts all definitions that are unique or limited and already on page.
Expand Down
111 changes: 111 additions & 0 deletions lib/alchemy/elements_finder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# frozen_string_literal: true

require 'alchemy/logger'

module Alchemy
# Loads elements from given page
#
# Used by {Alchemy::Page#find_elements} and {Alchemy::ElementsHelper#render_elements} helper.
#
# If you need custom element loading logic in your views you can create your own finder class and
# tell the {Alchemy::ElementsHelper#render_elements} helper or {Alchemy::Page#find_elements}
# to use that finder instead of this one.
#
class ElementsFinder
# @option options [Array<String>|String] :only
# A list of element names to load only.
# @option options [Array<String>|String] :except
# A list of element names not to load.
# @option options [Boolean] :fixed (false)
# Return only fixed elements
# @option options [Integer] :count
# The amount of elements to load
# @option options [Integer] :offset
# The offset to begin loading elements from
# @option options [Boolean] :random (false)
# Randomize the output of elements
# @option options [Boolean] :reverse (false)
# Reverse the load order
# @option options [Hash] :fallback
# Define elements that are loaded from another page if no element was found on given page.
def initialize(options = {})
@options = options
end

# @param page [Alchemy::Page|String]
# The page the elements are loaded from. You can pass a page_layout String or a {Alchemy::Page} object.
# @return [ActiveRecord::Relation]
def elements(page:)
elements = find_elements(page)

if fallback_required?(elements)
elements = elements.merge(fallback_elements)
end

if options[:reverse]
elements = elements.reverse_order
end

if options[:random]
elements = elements.reorder(Arel.sql(random_function))
end

elements.offset(options[:offset]).limit(options[:count])
end

private

attr_reader :page, :options

def find_elements(page)
elements = Alchemy::Element
.where(page_id: page_ids(page))
.merge(Alchemy::Element.not_nested)
.where(fixed: !!options[:fixed])
.order(position: :asc)
.available

if options[:only]
elements = elements.named(options[:only])
end

if options[:except]
elements = elements.excluded(options[:except])
end

elements
end

def page_ids(page)
case page
when String
Alchemy::Language.current.pages.where(
page_layout: page,
restricted: false
).pluck("#{Alchemy::Page.table_name}.id")
when Alchemy::Page
page.id
end
end

def fallback_required?(elements)
options[:fallback] && elements
.where(Alchemy::Element.table_name => {name: options[:fallback][:for]})
.none?
end

def fallback_elements
find_elements(options[:fallback][:from])
.named(options[:fallback][:with] || options[:fallback][:for])
end

def random_function
case ActiveRecord::Base.connection_config[:adapter]
when 'postgresql', 'sqlite3'
then 'RANDOM()'
else
'RAND()'
end
end
end
end
Loading

0 comments on commit 6b4b8e7

Please sign in to comment.