diff --git a/draper.gemspec b/draper.gemspec index 19a49162..ddf19c23 100644 --- a/draper.gemspec +++ b/draper.gemspec @@ -26,6 +26,7 @@ Gem::Specification.new do |s| s.add_development_dependency 'ammeter' s.add_development_dependency 'rake' s.add_development_dependency 'rspec-rails' + s.add_development_dependency 'rspec-activerecord-expectations', '~> 1.2.0' s.add_development_dependency 'minitest-rails' s.add_development_dependency 'capybara' s.add_development_dependency 'active_model_serializers', '>= 0.10' diff --git a/lib/draper.rb b/lib/draper.rb index 0e5dc697..5ef7529e 100644 --- a/lib/draper.rb +++ b/lib/draper.rb @@ -51,12 +51,6 @@ def self.setup_action_mailer(base) end end - def self.setup_orm(base) - base.class_eval do - include Draper::Decoratable - end - end - class UninferrableDecoratorError < NameError def initialize(klass) super("Could not infer a decorator for #{klass}.") diff --git a/lib/draper/decoratable.rb b/lib/draper/decoratable.rb index 5a3aee39..f8ecde81 100644 --- a/lib/draper/decoratable.rb +++ b/lib/draper/decoratable.rb @@ -1,4 +1,5 @@ require 'draper/decoratable/equality' +require 'draper/compatibility/broadcastable' module Draper # Provides shortcuts to decorate objects directly, so you can do @@ -11,6 +12,12 @@ module Decoratable extend ActiveSupport::Concern include Draper::Decoratable::Equality + autoload :CollectionProxy, 'draper/decoratable/collection_proxy' + + included do + prepend Draper::Compatibility::Broadcastable if defined? Turbo::Broadcastable + end + # Decorates the object using the inferred {#decorator_class}. # @param [Hash] options # see {Decorator#initialize} @@ -87,8 +94,6 @@ def decorator_class(called_on = self) def ===(other) super || (other.is_a?(Draper::Decorator) && super(other.object)) end - end - end end diff --git a/lib/draper/decoratable/collection_proxy.rb b/lib/draper/decoratable/collection_proxy.rb new file mode 100644 index 00000000..4a8873f5 --- /dev/null +++ b/lib/draper/decoratable/collection_proxy.rb @@ -0,0 +1,15 @@ +module Draper + module Decoratable + module CollectionProxy + # Decorates a collection of objects. Used at the end of a scope chain. + # + # @example + # company.products.popular.decorate + # @param [Hash] options + # see {Decorator.decorate_collection}. + def decorate(options = {}) + decorator_class.decorate_collection(load_target, options.reverse_merge(with: nil)) + end + end + end +end diff --git a/lib/draper/railtie.rb b/lib/draper/railtie.rb index 556c15a7..f5d25e0e 100644 --- a/lib/draper/railtie.rb +++ b/lib/draper/railtie.rb @@ -33,10 +33,14 @@ class Railtie < Rails::Railtie end initializer 'draper.setup_orm' do - [:active_record, :mongoid].each do |orm| - ActiveSupport.on_load orm do - Draper.setup_orm self - end + ActiveSupport.on_load :active_record do + include Draper::Decoratable + + ActiveRecord::Associations::CollectionProxy.include Draper::Decoratable::CollectionProxy + end + + ActiveSupport.on_load :mongoid do + include Draper::Decoratable end end diff --git a/spec/dummy/app/decorators/comment_decorator.rb b/spec/dummy/app/decorators/comment_decorator.rb new file mode 100644 index 00000000..15cd33bc --- /dev/null +++ b/spec/dummy/app/decorators/comment_decorator.rb @@ -0,0 +1,13 @@ +class CommentDecorator < Draper::Decorator + delegate_all + + # Define presentation-specific methods here. Helpers are accessed through + # `helpers` (aka `h`). You can override attributes, for example: + # + # def created_at + # helpers.content_tag :span, class: 'time' do + # object.created_at.strftime("%a %m/%d/%y") + # end + # end + +end diff --git a/spec/dummy/app/models/comment.rb b/spec/dummy/app/models/comment.rb new file mode 100644 index 00000000..8b86c56d --- /dev/null +++ b/spec/dummy/app/models/comment.rb @@ -0,0 +1,3 @@ +class Comment < ApplicationRecord + belongs_to :post +end diff --git a/spec/dummy/app/models/post.rb b/spec/dummy/app/models/post.rb index 59b1f954..7bf61aa0 100644 --- a/spec/dummy/app/models/post.rb +++ b/spec/dummy/app/models/post.rb @@ -1,3 +1,9 @@ +require 'turbo/broadcastable' if defined? Turbo::Broadcastable # HACK: looks weird, but works + class Post < ApplicationRecord # attr_accessible :title, :body + + has_many :comments + + broadcasts if defined? Turbo::Broadcastable end diff --git a/spec/dummy/db/migrate/20240907041839_create_comments.rb b/spec/dummy/db/migrate/20240907041839_create_comments.rb new file mode 100644 index 00000000..0ecb0a60 --- /dev/null +++ b/spec/dummy/db/migrate/20240907041839_create_comments.rb @@ -0,0 +1,9 @@ +class CreateComments < ActiveRecord::Migration[6.1] + def change + create_table :comments do |t| + t.references :post, foreign_key: true + + t.timestamps + end + end +end diff --git a/spec/dummy/db/schema.rb b/spec/dummy/db/schema.rb index 9aebf2c3..f7019585 100644 --- a/spec/dummy/db/schema.rb +++ b/spec/dummy/db/schema.rb @@ -1,21 +1,28 @@ -# encoding: UTF-8 # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# Note that this schema.rb definition is the authoritative source for your -# database schema. If you need to create the application database on another -# system, you should be using db:schema:load, not running all the migrations -# from scratch. The latter is a flawed and unsustainable approach (the more migrations -# you'll amass, the slower it'll run and the greater likelihood for issues). +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. # -# It's strongly recommended to check this file into your version control system. +# It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20121019115657) do +ActiveRecord::Schema.define(version: 2024_09_07_041839) do - create_table "posts", force: true do |t| + create_table "comments", force: :cascade do |t| + t.integer "post_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["post_id"], name: "index_comments_on_post_id" + end + + create_table "posts", force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false end + add_foreign_key "comments", "posts" end diff --git a/spec/dummy/spec/models/post_spec.rb b/spec/dummy/spec/models/post_spec.rb index 22a5fb61..808000ac 100644 --- a/spec/dummy/spec/models/post_spec.rb +++ b/spec/dummy/spec/models/post_spec.rb @@ -2,14 +2,63 @@ require 'shared_examples/decoratable' RSpec.describe Post do + let(:record) { described_class.create! } + it_behaves_like 'a decoratable model' it { should be_a ApplicationRecord } - describe '#to_global_id' do - let(:post) { Post.create } - subject { post.to_global_id } + describe 'broadcasts' do + let(:modification) { described_class.create! } + + it 'passes a decorated object for rendering' do + expect do + modification + end.to have_enqueued_job(Turbo::Streams::ActionBroadcastJob).with { |stream, action:, target:, **rendering| + expect(rendering[:locals]).to include :post + expect(rendering[:locals][:post]).to be_decorated + } + end + end if defined? Turbo::Broadcastable + + describe 'associations' do + context 'when decorated' do + subject { associated.decorate } + + let(:associated) { record.comments } + let(:persisted) { associated.create! [{}] * rand(0..2) } + let(:unsaved) { associated.build [{}] * rand(1..2) } + + before { persisted } # should exist + + it 'returns a decorated collection' do + is_expected.to match_array persisted + is_expected.to be_all &:decorated? + end + + it 'uses cached records' do + expect(associated).not_to be_loaded + + associated.load + + expect { subject.to_a }.to execute.exactly(0).queries + end + + it 'caches records' do + expect(associated).not_to be_loaded + + associated.decorate + + expect { subject.to_a; associated.load }.to execute.exactly(0).queries + end + + context 'with unsaved records' do + before { unsaved } # should exist - it { is_expected.to eq post.decorate.to_global_id } + it 'respects unsaved records' do + is_expected.to match_array persisted + unsaved + end + end + end end end diff --git a/spec/dummy/spec/spec_helper.rb b/spec/dummy/spec/spec_helper.rb index aa3282b2..367bac66 100644 --- a/spec/dummy/spec/spec_helper.rb +++ b/spec/dummy/spec/spec_helper.rb @@ -1,8 +1,11 @@ ENV['RAILS_ENV'] ||= 'test' require File.expand_path('../../config/environment', __FILE__) require 'rspec/rails' +require 'rspec/activerecord/expectations' RSpec.configure do |config| config.expect_with(:rspec) {|c| c.syntax = :expect} config.order = :random + + include RSpec::ActiveRecord::Expectations end