diff --git a/alchemy-json_api.gemspec b/alchemy-json_api.gemspec index 2430ad4..33a3d49 100644 --- a/alchemy-json_api.gemspec +++ b/alchemy-json_api.gemspec @@ -24,4 +24,5 @@ Gem::Specification.new do |spec| spec.add_development_dependency "github_changelog_generator" spec.add_development_dependency "jsonapi-rspec" spec.add_development_dependency "rspec-rails" + spec.add_development_dependency "shoulda-matchers" end diff --git a/app/controllers/alchemy/json_api/pages_controller.rb b/app/controllers/alchemy/json_api/pages_controller.rb index 83de448..f611f78 100644 --- a/app/controllers/alchemy/json_api/pages_controller.rb +++ b/app/controllers/alchemy/json_api/pages_controller.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module Alchemy module JsonApi class PagesController < JsonApi::BaseController @@ -34,6 +35,7 @@ def load_page end def load_page_by_id + return unless params[:path] =~ /\A\d+\z/ page_scope.find_by(id: params[:path]) end @@ -48,15 +50,15 @@ def page_scope def page_scope_with_includes base_page_scope. with_language(Language.current). - preload(language: {nodes: [:parent, :page]}, all_elements: [:parent_element, :nested_elements, { contents: { essence: :ingredient_association } }]) + preload(language: { nodes: [:parent, :page] }, all_elements: { contents: { essence: :ingredient_association } }) end def base_page_scope # cancancan is not able to merge our complex AR scopes for logged in users - if can?(:edit_content, Page) - Page.all + if can?(:edit_content, ::Alchemy::Page) + ::Alchemy::JsonApi::Page.all else - Page.published + ::Alchemy::JsonApi::Page.published end end diff --git a/app/models/alchemy/json_api/element.rb b/app/models/alchemy/json_api/element.rb new file mode 100644 index 0000000..62e8611 --- /dev/null +++ b/app/models/alchemy/json_api/element.rb @@ -0,0 +1,33 @@ +module Alchemy + module JsonApi + class Element < BaseRecord + include Alchemy::Element::Definitions + include Alchemy::Element::ElementContents + + self.table_name = "alchemy_elements" + + belongs_to :page, class_name: "Alchemy::JsonApi::Page", inverse_of: :all_elements + has_many :contents, class_name: "Alchemy::Content", inverse_of: :element + + scope :available, -> { where(public: true).where.not(position: nil) } + + def parent_element + page.elements.detect do |element| + element.id == parent_element_id + end + end + + def nested_elements + @_nested_elements ||= begin + page.elements.select do |element| + element.parent_element_id == id + end + end + end + + def nested_element_ids + nested_elements.map(&:id) + end + end + end +end diff --git a/app/models/alchemy/json_api/page.rb b/app/models/alchemy/json_api/page.rb new file mode 100644 index 0000000..b3c5128 --- /dev/null +++ b/app/models/alchemy/json_api/page.rb @@ -0,0 +1,48 @@ +module Alchemy + module JsonApi + class Page < BaseRecord + self.table_name = "alchemy_pages" + + belongs_to :language, class_name: "Alchemy::Language" + + has_many :all_elements, + -> { available.order(:position) }, + class_name: "Alchemy::JsonApi::Element", + inverse_of: :page + + scope :published, -> { + where("#{table_name}.public_on <= :time AND " \ + "(#{table_name}.public_until IS NULL " \ + "OR #{table_name}.public_until >= :time)", time: Time.current) + } + + scope :contentpages, -> { where(layoutpage: false) } + scope :layoutpages, -> { where(layoutpage: true) } + scope :with_language, ->(language_id) { where(language_id: language_id) } + + # The top level public, non-fixed elements of this page that - if present - + # contains their nested_elements. + def elements + @_elements ||= all_elements.select do |element| + !element.fixed? || element.parent_element_id.nil? + end + end + + # The top level public, fixed elements of this page that - if present - + # contains their nested_elements. + def fixed_elements + @_fixed_elements ||= all_elements.select do |element| + element.fixed? || element.parent_element_id.nil? + end + end + + def element_ids + @_element_ids ||= elements.map(&:id) + end + + def fixed_element_ids + @_fixed_element_ids ||= fixed_elements.map(&:id) + end + end + end +end diff --git a/app/serializers/alchemy/json_api/page_serializer.rb b/app/serializers/alchemy/json_api/page_serializer.rb index d982d8e..fb6e668 100644 --- a/app/serializers/alchemy/json_api/page_serializer.rb +++ b/app/serializers/alchemy/json_api/page_serializer.rb @@ -4,6 +4,8 @@ module JsonApi class PageSerializer include JSONAPI::Serializer + ELEMENT_SERIALIZER = ::Alchemy::JsonApi::ElementSerializer + attributes( :name, :urlname, @@ -18,12 +20,17 @@ class PageSerializer belongs_to :language, record_type: :language, serializer: ::Alchemy::JsonApi::LanguageSerializer - has_many :elements, record_type: :element, serializer: ::Alchemy::JsonApi::ElementSerializer - has_many :fixed_elements, record_type: :element, serializer: ::Alchemy::JsonApi::ElementSerializer + # All public elements of this page regardless of if they are fixed or nested. + # Used for eager loading and should be used as the +include+ parameter of your query + has_many :all_elements, record_type: :element, serializer: ELEMENT_SERIALIZER + + # The top level public, non-fixed elements of this page that - if present - + # contains their nested_elements. + has_many :elements, record_type: :element, serializer: ELEMENT_SERIALIZER - has_many :all_elements, record_type: :element, serializer: ::Alchemy::JsonApi::ElementSerializer do |page| - page.all_elements.select { |e| e.public? && !e.trashed? } - end + # The top level public, fixed elements of this page that - if present - + # contains their nested_elements. + has_many :fixed_elements, record_type: :element, serializer: ELEMENT_SERIALIZER end end end diff --git a/spec/models/alchemy/json_api/element_spec.rb b/spec/models/alchemy/json_api/element_spec.rb new file mode 100644 index 0000000..7345056 --- /dev/null +++ b/spec/models/alchemy/json_api/element_spec.rb @@ -0,0 +1,63 @@ +require "rails_helper" +require "alchemy/test_support/factories" + +RSpec.describe Alchemy::JsonApi::Element, type: :model do + it { should belong_to(:page).class_name("Alchemy::JsonApi::Page") } + it { should have_many(:contents).class_name("Alchemy::Content") } + + describe "scopes" do + describe ".available" do + subject(:available) { described_class.available.map(&:id) } + + let!(:public_one) { FactoryBot.create(:alchemy_element, public: true) } + let!(:non_public) { FactoryBot.create(:alchemy_element, public: false) } + let!(:trashed) { FactoryBot.create(:alchemy_element, public: true).tap(&:trash!) } + + it "returns public available elements" do + # expecting the ids here because the factorys class is not our decorator class + expect(available).to include(public_one.id) + expect(available).to_not include(non_public.id) + expect(available).to_not include(trashed.id) + end + end + end + + describe "#parent_element" do + subject { nested_element.parent_element } + + let(:page) { FactoryBot.create(:alchemy_page) } + let!(:element) { FactoryBot.create(:alchemy_element, page: page) } + let!(:nested_element) { FactoryBot.create(:alchemy_element, page: page, parent_element: element) } + let!(:not_nested_element) { FactoryBot.create(:alchemy_element, page: page) } + + it "returns elements parent element" do + is_expected.to eq(element) + end + end + + describe "#nested_elements" do + subject { element.nested_elements } + + let(:page) { FactoryBot.create(:alchemy_page) } + let!(:element) { FactoryBot.create(:alchemy_element, page: page) } + let!(:nested_element) { FactoryBot.create(:alchemy_element, page: page, parent_element: element) } + let!(:not_nested_element) { FactoryBot.create(:alchemy_element, page: page) } + + it "returns all nested elements" do + is_expected.to eq([nested_element]) + end + end + + describe "#nested_element_ids" do + subject { element.nested_element_ids } + + let(:page) { FactoryBot.create(:alchemy_page) } + let!(:element) { FactoryBot.create(:alchemy_element, page: page) } + let!(:nested_element) { FactoryBot.create(:alchemy_element, page: page, parent_element: element) } + let!(:not_nested_element) { FactoryBot.create(:alchemy_element, page: page) } + + it "returns all nested element ids" do + is_expected.to eq([nested_element.id]) + end + end +end diff --git a/spec/models/alchemy/json_api/page_spec.rb b/spec/models/alchemy/json_api/page_spec.rb new file mode 100644 index 0000000..cd2a070 --- /dev/null +++ b/spec/models/alchemy/json_api/page_spec.rb @@ -0,0 +1,201 @@ +require "rails_helper" +require "alchemy/test_support/factories" + +RSpec.describe Alchemy::JsonApi::Page, type: :model do + it { should belong_to(:language).class_name("Alchemy::Language") } + it { should have_many(:all_elements).class_name("Alchemy::JsonApi::Element") } + + describe "scopes" do + describe ".published" do + subject(:published) { described_class.published.map(&:id) } + + let!(:public_one) { FactoryBot.create(:alchemy_page, :public) } + let!(:public_two) { FactoryBot.create(:alchemy_page, :public) } + let!(:non_public_page) { FactoryBot.create(:alchemy_page) } + + it "returns public available pages" do + # expecting the ids here because the factorys class is not our decorator class + expect(published).to include(public_one.id) + expect(published).to include(public_two.id) + expect(published).to_not include(non_public_page.id) + end + end + + describe ".contentpages" do + let!(:layoutpage) { FactoryBot.create(:alchemy_page, :layoutpage) } + let!(:contentpage) { FactoryBot.create(:alchemy_page) } + + it "should return contentpages" do + # expecting the attribute here because the factorys class is not our decorator class + expect(described_class.contentpages.map(&:layoutpage)).to eq([false, false]) # page plus root page + end + end + + describe ".layoutpages" do + let!(:layoutpage) { FactoryBot.create(:alchemy_page, :layoutpage) } + let!(:contentpage) { FactoryBot.create(:alchemy_page) } + + it "should return layoutpages" do + # expecting the attribute here because the factorys class is not our decorator class + expect(described_class.layoutpages.map(&:layoutpage)).to eq([true]) + end + end + + describe ".with_language" do + let(:english) { FactoryBot.create(:alchemy_language, :english) } + let(:german) { FactoryBot.create(:alchemy_language, :german) } + let!(:page_en) { FactoryBot.create(:alchemy_page, language: english) } + let!(:page_de) { FactoryBot.create(:alchemy_page, language: german) } + + it "should return layoutpages" do + # expecting the attribute here because the factorys class is not our decorator class + expect(described_class.with_language(german.id).map(&:language_id)).to eq([german.id, german.id]) # page plus root page + end + end + end + + describe "#all_elements" do + let(:page) { FactoryBot.create(:alchemy_page) } + let!(:element_1) { FactoryBot.create(:alchemy_element, page: page) } + let!(:element_2) { FactoryBot.create(:alchemy_element, page: page) } + let!(:element_3) { FactoryBot.create(:alchemy_element, page: page) } + + before do + element_3.move_to_top + end + + subject(:all_element_ids) do + described_class.find(page.id).all_elements.map(&:id) + end + + it "returns a ordered active record collection of elements on that page" do + expect(all_element_ids).to eq([element_3.id, element_1.id, element_2.id]) + end + + context "with nestable elements" do + let!(:nestable_element) do + FactoryBot.create(:alchemy_element, page: page) + end + + let!(:nested_element) do + FactoryBot.create(:alchemy_element, name: "slide", parent_element: nestable_element, page: page) + end + + it "contains nested elements of an element" do + expect(all_element_ids).to include(nested_element.id) + end + end + + context "with trashed elements" do + let!(:trashed_element) { FactoryBot.create(:alchemy_element, page: page).tap(&:trash!) } + + it "does not contain trashed elements" do + expect(all_element_ids).to_not include(trashed_element.id) + end + end + + context "with hidden elements" do + let!(:hidden_element) { FactoryBot.create(:alchemy_element, page: page, public: false) } + + it "does not contain hidden elements" do + expect(all_element_ids).to_not include(hidden_element.id) + end + end + + context "with fixed elements" do + let!(:fixed_element) { FactoryBot.create(:alchemy_element, page: page, fixed: true) } + + it "contains fixed elements" do + expect(all_element_ids).to include(fixed_element.id) + end + end + end + + describe "#elements" do + let(:page) { FactoryBot.create(:alchemy_page) } + let!(:element_1) { FactoryBot.create(:alchemy_element, page: page) } + let!(:element_2) { FactoryBot.create(:alchemy_element, page: page) } + let!(:element_3) { FactoryBot.create(:alchemy_element, page: page) } + + before do + element_3.move_to_top + end + + subject(:element_ids) { described_class.find(page.id).elements.map(&:id) } + + it "returns a ordered active record collection of elements on that page" do + expect(element_ids).to eq([element_3.id, element_1.id, element_2.id]) + end + + context "with nestable elements" do + let(:nestable_element) { FactoryBot.create(:alchemy_element, :with_nestable_elements) } + + before do + nestable_element.nested_elements << FactoryBot.create(:alchemy_element, name: "slide") + page.elements << nestable_element + end + + it "does not contain nested elements of an element" do + expect(nestable_element.nested_elements).to_not be_empty + expect(element_ids).to_not include(nestable_element.nested_elements.first.id) + end + end + + context "with trashed elements" do + let(:trashed_element) { FactoryBot.create(:alchemy_element, page: page) } + + before do + trashed_element.trash! + end + + it "does not contain trashed elements" do + expect(element_ids).to_not include(trashed_element.id) + end + end + + context "with hidden elements" do + let(:hidden_element) { FactoryBot.create(:alchemy_element, page: page, public: false) } + + it "does not contain hidden elements" do + expect(element_ids).to_not include(hidden_element.id) + end + end + end + + describe "#fixed_elements" do + let(:page) { FactoryBot.create(:alchemy_page) } + let!(:element_1) { FactoryBot.create(:alchemy_element, fixed: true, page: page) } + let!(:element_2) { FactoryBot.create(:alchemy_element, fixed: true, page: page) } + let!(:element_3) { FactoryBot.create(:alchemy_element, fixed: true, page: page) } + + before do + element_3.move_to_top + end + + subject(:fixed_elements) { described_class.find(page.id).fixed_elements.map(&:id) } + + it "returns a ordered active record collection of fixed elements on that page" do + expect(fixed_elements).to eq([element_3.id, element_1.id, element_2.id]) + end + + context "with trashed fixed elements" do + let(:trashed_element) { FactoryBot.create(:alchemy_element, page: page, fixed: true) } + + before do + trashed_element.trash! + end + + it "does not contain trashed fixed elements" do + expect(fixed_elements).to_not include(trashed_element.id) + end + end + + context "with hidden fixed elements" do + let(:hidden_element) { FactoryBot.create(:alchemy_element, page: page, fixed: true, public: false) } + + it "does not contain hidden fixed elements" do + expect(fixed_elements).to_not include(hidden_element.id) + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index edd7d29..71b5d52 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -9,6 +9,14 @@ # Add additional requires below this line. Rails is not loaded until this point! require "jsonapi/rspec" +require "shoulda-matchers" + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end # Requires supporting ruby files with custom matchers and macros, etc, in # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are diff --git a/spec/serializers/alchemy/json_api/page_serializer_spec.rb b/spec/serializers/alchemy/json_api/page_serializer_spec.rb index 2a1e48f..d71a5f5 100644 --- a/spec/serializers/alchemy/json_api/page_serializer_spec.rb +++ b/spec/serializers/alchemy/json_api/page_serializer_spec.rb @@ -3,7 +3,7 @@ require "alchemy/test_support/factories" RSpec.describe Alchemy::JsonApi::PageSerializer do - let(:page) do + let(:alchemy_page) do FactoryBot.create( :alchemy_page, urlname: "a-page", @@ -14,6 +14,7 @@ ) end let(:options) { {} } + let(:page) { Alchemy::JsonApi::Page.find(alchemy_page.id) } subject(:serializer) { described_class.new(page, options) } @@ -36,22 +37,20 @@ end describe "relationships" do - let(:element) { FactoryBot.create(:alchemy_element) } - let(:fixed_element) { FactoryBot.create(:alchemy_element, fixed: true) } - let(:trashed_element) { FactoryBot.create(:alchemy_element, :trashed) } - subject { serializer.serializable_hash[:data][:relationships] } + let!(:element) { FactoryBot.create(:alchemy_element, page: alchemy_page) } + let!(:fixed_element) { FactoryBot.create(:alchemy_element, page: alchemy_page, fixed: true) } + let!(:non_public_element) { FactoryBot.create(:alchemy_element, page: alchemy_page, public: false) } + let!(:trashed_element) { FactoryBot.create(:alchemy_element, page: alchemy_page).tap(&:trash!) } - before do - page.all_elements << element - page.all_elements << fixed_element - page.all_elements << trashed_element - trashed_element.trash! - end + subject { serializer.serializable_hash[:data][:relationships] } - it "has the right keys and values, and does not include trashed elements" do - expect(subject[:elements]).to eq(data: [{ id: element.id.to_s, type: :element }]) - expect(subject[:fixed_elements]).to eq(data: [{ id: fixed_element.id.to_s, type: :element }]) - expect(subject[:all_elements]).to eq(data: [{ id: element.id.to_s, type: :element }, { id: fixed_element.id.to_s, type: :element }]) + it "has the right keys and values, and does not include trashed or hidden elements" do + expect(subject[:elements]).to eq( + data: [ + { id: element.id.to_s, type: :element }, + { id: fixed_element.id.to_s, type: :element }, + ], + ) expect(subject[:language]).to eq(data: { id: page.language_id.to_s, type: :language }) end end