Skip to content

Commit

Permalink
Introduce a elements finder class
Browse files Browse the repository at this point in the history
This class abstracts elements finding logic that we currently have in `render_elements` helper and `Page#find_elements` method. But with pure active record finders and a clean testable interface. It now returns an `ActiveRecord::Relation` that later materializes in the view.

The sort_by option and show hidden elements options have been removed. As this uses a very opinionated way of loading and sorting elements that only few people use - if at all.

Please implement your own elements finder in your project instead and pass this as options[:finder] to those methods.
  • Loading branch information
tvdeyen committed Feb 21, 2019
1 parent 3a817f7 commit 8532524
Show file tree
Hide file tree
Showing 3 changed files with 330 additions and 0 deletions.
110 changes: 110 additions & 0 deletions lib/alchemy/elements_finder.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# 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])
.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
1 change: 1 addition & 0 deletions lib/alchemy_cms.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ module Alchemy
require_relative 'alchemy/configuration_methods'
require_relative 'alchemy/controller_actions'
require_relative 'alchemy/deprecation'
require_relative 'alchemy/elements_finder'
require_relative 'alchemy/errors'
require_relative 'alchemy/essence'
require_relative 'alchemy/filetypes'
Expand Down
219 changes: 219 additions & 0 deletions spec/libraries/elements_finder_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe Alchemy::ElementsFinder do
let(:finder) { described_class.new(options) }
let(:options) { {} }

describe '#elements' do
subject { finder.elements }

let(:page) { create(:alchemy_page, :public) }
let!(:visible_element) { create(:alchemy_element, public: true, page: page) }
let!(:hidden_element) { create(:alchemy_element, public: false, page: page) }

context 'without page given' do
it do
expect { subject }.to raise_error(ArgumentError)
end
end

context 'with page object given' do
subject { finder.elements(page: page) }

it 'returns all public elements from page' do
is_expected.to eq([visible_element])
end

it 'does not return trashed elements' do
visible_element.remove_from_list
is_expected.to eq([])
end

context 'with fixed elements present' do
let!(:fixed_element) { create(:alchemy_element, :fixed, page: page) }

it 'does not include fixed elements' do
is_expected.to_not include(fixed_element)
end

context 'with options[:fixed] set to true' do
let(:options) do
{ fixed: true }
end

it 'includes only fixed elements' do
is_expected.to eq([fixed_element])
end
end
end

context 'with nested elements present' do
let!(:nested_element) { create(:alchemy_element, :nested, page: page) }

it 'does not include nested elements' do
is_expected.to_not include(nested_element)
end
end

context 'with options[:only] given' do
let(:options) do
{ only: 'article' }
end

it "returns only the elements with that name" do
is_expected.to eq([visible_element])
end
end

context 'with options[:except] given' do
let(:options) do
{ except: 'article' }
end

it "does not return the elements with that name" do
is_expected.to eq([])
end
end

context 'with options[:offset] given' do
let(:options) do
{ offset: 2 }
end

let!(:visible_element_2) { create(:alchemy_element, public: true, page: page) }
let!(:visible_element_3) { create(:alchemy_element, public: true, page: page) }

it "returns elements beginning from that offset" do
is_expected.to eq([visible_element_3])
end
end

context 'with options[:count] given' do
let(:options) do
{ count: 1 }
end

let!(:visible_element_2) { create(:alchemy_element, public: true, page: page) }

it "returns elements beginning from that offset" do
is_expected.to eq([visible_element])
end
end

context 'with options[:reverse] given' do
let(:options) do
{ reverse: true }
end

let!(:visible_element_2) { create(:alchemy_element, public: true, page: page) }

it "returns elements in reverse order" do
is_expected.to eq([visible_element_2, visible_element])
end
end

context 'with options[:random] given' do
let(:options) do
{ random: true }
end

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

it "returns elements in random order" do
expect_any_instance_of(ActiveRecord::Relation).to \
receive(:reorder).with(random_function).and_call_original
subject
end
end
end

context 'with page layout name given as options[:from_page]' do
subject { finder.elements(page: 'standard') }

let(:page) { create(:alchemy_page, :public, page_layout: 'standard') }
let!(:visible_element) { create(:alchemy_element, public: true, page: page) }
let!(:hidden_element) { create(:alchemy_element, public: false, page: page) }

let(:page_2) { create(:alchemy_page, :public, page_layout: 'standard') }
let!(:visible_element_2) { create(:alchemy_element, public: true, page: page_2) }
let!(:hidden_element_2) { create(:alchemy_element, public: false, page: page_2) }

it 'returns all public elements from all pages with given page layout' do
is_expected.to eq([visible_element, visible_element_2])
end
end

context 'with fallback options given' do
subject { finder.elements(page: page) }

let(:options) do
{
fallback: {
for: 'download',
from: page_2
}
}
end

context 'and no element from that kind on current page' do
let(:page) { create(:alchemy_page, :public, page_layout: 'standard') }

context 'but element of that kind on fallback page' do
let(:page_2) { create(:alchemy_page, :public, page_layout: 'standard') }
let!(:visible_element_2) { create(:alchemy_element, name: 'download', public: true, page: page_2) }

it 'loads elements from fallback page' do
is_expected.to eq([visible_element_2])
end
end

context 'with fallback element defined' do
let(:options) do
{
fallback: {
for: 'download',
with: 'header',
from: page_2
}
}
end

let(:page_2) { create(:alchemy_page, :public, page_layout: 'standard') }
let!(:visible_element_2) { create(:alchemy_element, name: 'header', public: true, page: page_2) }

it 'loads fallback element from fallback page' do
is_expected.to eq([visible_element_2])
end
end

context 'with fallback page defined as pagelayout name' do
let(:options) do
{
fallback: {
for: 'download',
with: 'text',
from: 'everything'
}
}
end

let(:page_2) { create(:alchemy_page, :public, page_layout: 'everything') }
let!(:visible_element_2) { create(:alchemy_element, name: 'text', public: true, page: page_2) }

it 'loads fallback element from fallback page' do
is_expected.to eq([visible_element_2])
end
end
end
end
end
end

0 comments on commit 8532524

Please sign in to comment.