├── test ├── fixtures │ ├── bassist │ │ └── play.erb │ ├── song │ │ ├── with_html.erb │ │ ├── ivar.erb │ │ ├── show.erb │ │ ├── with_block.erb │ │ ├── with_locals.erb │ │ └── with_erb.erb │ ├── concepts │ │ └── record │ │ │ └── views │ │ │ ├── song.erb │ │ │ ├── show.erb │ │ │ └── layout.erb │ ├── inherit_views_test │ │ ├── tapper │ │ │ ├── play.erb │ │ │ └── tap.erb │ │ └── popper │ │ │ └── tap.erb │ ├── song_with_layout │ │ ├── show.erb │ │ ├── show_with_layout.erb │ │ ├── happy.erb │ │ └── merry.erb │ ├── partials │ │ ├── _show.html.erb │ │ └── _show.xml.erb │ ├── partial_test │ │ └── with_partial │ │ │ └── show.erb │ ├── cell_test │ │ └── song │ │ │ └── show_with_block.erb │ ├── comment │ │ ├── show │ │ │ └── show.erb │ │ └── layout │ │ │ └── show.erb │ ├── templates_caching_test │ │ └── song │ │ │ └── show.erb │ └── url_helper_test │ │ └── song │ │ ├── with_block.erb │ │ ├── with_capture.erb │ │ ├── with_link_to.erb │ │ ├── with_form_for_block.erb │ │ ├── with_content_tag.erb │ │ └── edit.erb ├── test_helper.rb ├── cell_test.rb ├── twin_test.rb ├── cell_benchmark.rb ├── templates_test.rb ├── context_test.rb ├── partial_test.rb ├── inspect_test.rb ├── property_test.rb ├── builder_test.rb ├── testing_test.rb ├── layout_test.rb ├── render_test.rb ├── concept_test.rb ├── cache_test.rb ├── public_test.rb └── prefixes_test.rb ├── lib ├── cells.rb ├── cell │ ├── version.rb │ ├── abstract.rb │ ├── development.rb │ ├── self_contained.rb │ ├── builder.rb │ ├── twin.rb │ ├── partial.rb │ ├── util.rb │ ├── concept.rb │ ├── inspect.rb │ ├── templates.rb │ ├── prefixes.rb │ ├── escaped.rb │ ├── option.rb │ ├── layout.rb │ ├── collection.rb │ ├── testing.rb │ ├── caching.rb │ └── view_model.rb ├── tasks │ └── cells.rake └── cell.rb ├── .gitignore ├── Gemfile ├── TODO.md ├── .github └── workflows │ └── ci.yml ├── Rakefile ├── cells.gemspec ├── README.md └── CHANGES.md /test/fixtures/bassist/play.erb: -------------------------------------------------------------------------------- 1 | Doo -------------------------------------------------------------------------------- /test/fixtures/song/with_html.erb: -------------------------------------------------------------------------------- 1 |

Yew!

-------------------------------------------------------------------------------- /test/fixtures/song/ivar.erb: -------------------------------------------------------------------------------- 1 | <%= @title %> 2 | -------------------------------------------------------------------------------- /test/fixtures/song/show.erb: -------------------------------------------------------------------------------- 1 | <%= title %> 2 | -------------------------------------------------------------------------------- /test/fixtures/concepts/record/views/song.erb: -------------------------------------------------------------------------------- 1 | Lalala -------------------------------------------------------------------------------- /lib/cells.rb: -------------------------------------------------------------------------------- 1 | require "cell/version" 2 | require "cell" 3 | -------------------------------------------------------------------------------- /test/fixtures/inherit_views_test/tapper/play.erb: -------------------------------------------------------------------------------- 1 | Dooom! -------------------------------------------------------------------------------- /test/fixtures/song/with_block.erb: -------------------------------------------------------------------------------- 1 | Yo! <%= yield %> 2 | -------------------------------------------------------------------------------- /test/fixtures/song_with_layout/show.erb: -------------------------------------------------------------------------------- 1 | <%= title %> 2 | -------------------------------------------------------------------------------- /test/fixtures/song_with_layout/show_with_layout.erb: -------------------------------------------------------------------------------- 1 | Friday -------------------------------------------------------------------------------- /test/fixtures/inherit_views_test/tapper/tap.erb: -------------------------------------------------------------------------------- 1 | Tap tap tap! -------------------------------------------------------------------------------- /test/fixtures/partials/_show.html.erb: -------------------------------------------------------------------------------- 1 | I Am Wrong And I Am Right -------------------------------------------------------------------------------- /lib/cell/version.rb: -------------------------------------------------------------------------------- 1 | module Cell 2 | VERSION = "4.5.0" 3 | end 4 | -------------------------------------------------------------------------------- /test/fixtures/concepts/record/views/show.erb: -------------------------------------------------------------------------------- 1 | Party on, <%= model %>! -------------------------------------------------------------------------------- /test/fixtures/partial_test/with_partial/show.erb: -------------------------------------------------------------------------------- 1 | Adenosine Breakdown -------------------------------------------------------------------------------- /test/fixtures/song_with_layout/happy.erb: -------------------------------------------------------------------------------- 1 | <%= "Happy #{yield}!" %> -------------------------------------------------------------------------------- /test/fixtures/cell_test/song/show_with_block.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /test/fixtures/comment/show/show.erb: -------------------------------------------------------------------------------- 1 | $show.erb, <%= context.inspect %> 2 | -------------------------------------------------------------------------------- /test/fixtures/concepts/record/views/layout.erb: -------------------------------------------------------------------------------- 1 |

<%= yield %>

2 | -------------------------------------------------------------------------------- /test/fixtures/partials/_show.xml.erb: -------------------------------------------------------------------------------- 1 | I Am Wrong And I Am Right -------------------------------------------------------------------------------- /test/fixtures/song/with_locals.erb: -------------------------------------------------------------------------------- 1 | <%= title %> 2 | <%= length %> 3 | -------------------------------------------------------------------------------- /test/fixtures/song_with_layout/merry.erb: -------------------------------------------------------------------------------- 1 | <%= "Merry #{what}, #{yield}" %> -------------------------------------------------------------------------------- /test/fixtures/templates_caching_test/song/show.erb: -------------------------------------------------------------------------------- 1 | The Great Mind Eraser -------------------------------------------------------------------------------- /test/fixtures/inherit_views_test/popper/tap.erb: -------------------------------------------------------------------------------- 1 | TTttttap I'm not good enough! -------------------------------------------------------------------------------- /test/fixtures/url_helper_test/song/with_block.erb: -------------------------------------------------------------------------------- 1 | Nice! 2 | <%= cap { "yeah" } %> -------------------------------------------------------------------------------- /test/fixtures/comment/layout/show.erb: -------------------------------------------------------------------------------- 1 | $layout.erb{<%= yield %>, <%= context.inspect %>} 2 | -------------------------------------------------------------------------------- /test/fixtures/song/with_erb.erb: -------------------------------------------------------------------------------- 1 | ERB: 2 | <%= content_tag(:span) do %> 3 | <%= title %> 4 | <% end %> -------------------------------------------------------------------------------- /test/fixtures/url_helper_test/song/with_capture.erb: -------------------------------------------------------------------------------- 1 | Nice! 2 | <%= capture do %> 3 | <%= "Great!" %> 4 | <% end %> -------------------------------------------------------------------------------- /test/fixtures/url_helper_test/song/with_link_to.erb: -------------------------------------------------------------------------------- 1 | <%= link_to songs_path do %> 2 | <%= image_tag "all.png" %> 3 | <% end %> 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | pkg 3 | *.gem 4 | .*~ 5 | .bundle 6 | *.lock 7 | test/dummy/log/ 8 | test/dummy/tmp/ 9 | /.rvmrc 10 | -------------------------------------------------------------------------------- /test/fixtures/url_helper_test/song/with_form_for_block.erb: -------------------------------------------------------------------------------- 1 | <%= form_for(OpenStruct.new, url: "/songs", as: :song) do |f| %> 2 | <%= f.text_field :id %> 3 | <% end %> 4 | -------------------------------------------------------------------------------- /lib/cell/abstract.rb: -------------------------------------------------------------------------------- 1 | module Cell::Abstract 2 | def abstract! 3 | @abstract = true 4 | end 5 | 6 | def abstract? 7 | @abstract if defined?(@abstract) 8 | end 9 | end -------------------------------------------------------------------------------- /lib/tasks/cells.rake: -------------------------------------------------------------------------------- 1 | require "rake/testtask" 2 | 3 | namespace "test" do 4 | Rake::TestTask.new(:cells) do |t| 5 | t.libs << "test" 6 | t.pattern = 'test/cells/**/*_test.rb' 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/fixtures/url_helper_test/song/with_content_tag.erb: -------------------------------------------------------------------------------- 1 | <%= content_tag :span do %> 2 | "Title:" 3 | <%= content_tag :div do %> 4 | <%= "Still Knee Deep" %> 5 | <% end %> 6 | <%- end -%> 7 | -------------------------------------------------------------------------------- /lib/cell/development.rb: -------------------------------------------------------------------------------- 1 | module Cell 2 | module Development 3 | def self.included(base) 4 | base.instance_eval do 5 | def templates 6 | Templates.new 7 | end 8 | end 9 | end 10 | end 11 | end -------------------------------------------------------------------------------- /test/fixtures/url_helper_test/song/edit.erb: -------------------------------------------------------------------------------- 1 | <%= form_tag "/songs" do %> 2 | <%= label_tag :title %> 3 | <%= text_field_tag :title %> 4 | 5 | <%= content_tag :ul do %> 6 | <%= content_tag :li, "Airplays: 1" %> 7 | <%= end %> 8 | <%= end %> 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "benchmark-ips" 4 | gem "minitest-line" 5 | 6 | gemspec 7 | 8 | case ENV["GEMS_SOURCE"] 9 | when "local" 10 | gem "cells-erb", path: "../cells-erb" 11 | # gem "erbse", path: "../erbse" 12 | when "github" 13 | gem "cells-erb", github: "trailblazer/cells-erb" 14 | end 15 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "cells" 3 | require "cells-erb" 4 | 5 | Cell::ViewModel.send(:include, Cell::Erb) if Cell.const_defined?(:Erb) # FIXME: should happen in inititalizer. 6 | 7 | MiniTest::Spec.class_eval do 8 | include Cell::Testing 9 | end 10 | 11 | class BassistCell < Cell::ViewModel 12 | self.view_paths = ['test/fixtures'] 13 | end 14 | -------------------------------------------------------------------------------- /lib/cell/self_contained.rb: -------------------------------------------------------------------------------- 1 | # Enforces the new trailblazer directory layout where cells (or concepts in general) are 2 | # fully self-contained in its own directory. 3 | module Cell::SelfContained 4 | def self_contained! 5 | extend Prefixes 6 | end 7 | 8 | module Prefixes 9 | def _local_prefixes 10 | super.collect { |prefix| "#{prefix}/views" } 11 | end 12 | end 13 | end -------------------------------------------------------------------------------- /lib/cell/builder.rb: -------------------------------------------------------------------------------- 1 | require "declarative/builder" 2 | 3 | module Cell 4 | module Builder 5 | def self.included(base) 6 | base.send :include, Declarative::Builder 7 | base.extend ClassMethods 8 | end 9 | 10 | module ClassMethods 11 | def build(*args) 12 | build!(self, *args).new(*args) # Declarative::Builder#build!. 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # 4.0 2 | 3 | * Add tests for with_assets config 4 | 5 | 6 | * Get rid of the annoying `ActionController` dependency that needs to be passed into each cell. We only need it for "contextual links", when people wanna link to the same page. Make them pass in a URL generator object as a normal argument instead. 7 | * Generated cells will be view models per default. 8 | * Introduce Composition as in Reform, Representable, etc, when passing in a hash. 9 | ```ruby 10 | include Composition 11 | property :id, on: :comment 12 | ``` -------------------------------------------------------------------------------- /test/cell_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class CellTest < MiniTest::Spec 4 | class SongCell < Cell::ViewModel 5 | self.view_paths = ['test/fixtures'] 6 | 7 | def show 8 | end 9 | 10 | def show_with_block(&block) 11 | render(&block) 12 | end 13 | end 14 | 15 | # #options 16 | it { _(SongCell.new(nil, genre: "Punkrock").send(:options)[:genre]).must_equal "Punkrock" } 17 | 18 | # #block 19 | it { _(SongCell.new(nil, genre: "Punkrock").(:show_with_block) { "hello" }).must_equal "hello\n" } 20 | end 21 | -------------------------------------------------------------------------------- /lib/cell/twin.rb: -------------------------------------------------------------------------------- 1 | require 'disposable/twin' 2 | 3 | module Cell 4 | module Twin 5 | def self.included(base) 6 | base.send :include, Disposable::Twin::Builder 7 | base.extend ClassMethods 8 | end 9 | 10 | module ClassMethods 11 | def twin(twin_class) 12 | super(twin_class) { |dfn| property dfn.name } # create readers to twin model. 13 | end 14 | end 15 | 16 | def initialize(model, options={}) 17 | super(build_twin(model, options), controller: options.delete(:controller)) 18 | end 19 | end 20 | end -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | strategy: 6 | fail-fast: false 7 | matrix: 8 | # Due to https://github.com/actions/runner/issues/849, we have to use quotes for '3.0' 9 | ruby: [2.5, 2.6, 2.7, '3.0', 3.1, head] 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: ${{ matrix.ruby }} 16 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 17 | - run: bundle exec rake 18 | -------------------------------------------------------------------------------- /lib/cell/partial.rb: -------------------------------------------------------------------------------- 1 | # Allows to render global partials, for example. 2 | # 3 | # render partial: "../views/shared/container" 4 | module Cell::ViewModel::Partial 5 | def process_options!(options) 6 | super 7 | return unless partial = options[:partial] 8 | 9 | parts = partial.split("/") 10 | view = parts.pop 11 | view = "_#{view}" 12 | view += ".#{options[:formats].first}" if options[:formats] 13 | prefixes = self.class.view_paths.collect { |path| ([path] + parts).join("/") } 14 | 15 | options.merge!(view: view, prefixes: prefixes) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/twin_test.rb: -------------------------------------------------------------------------------- 1 | # require 'test_helper' 2 | # require 'cell/twin' 3 | 4 | # class TwinTest < MiniTest::Spec 5 | # class SongCell < Cell::ViewModel 6 | # class Twin < Disposable::Twin 7 | # property :title 8 | # option :online? 9 | # end 10 | 11 | # include Cell::Twin 12 | # twin Twin 13 | 14 | # def show 15 | # "#{title} is #{online?}" 16 | # end 17 | 18 | # def title 19 | # super.downcase 20 | # end 21 | # end 22 | 23 | # let (:model) { OpenStruct.new(title: "Kenny") } 24 | 25 | # it { SongCell.new( model, :online? => true).call.must_equal "kenny is true" } 26 | # end 27 | -------------------------------------------------------------------------------- /lib/cell/util.rb: -------------------------------------------------------------------------------- 1 | module Cell::Util 2 | def util 3 | Inflector 4 | end 5 | 6 | class Inflector 7 | # copied from ActiveSupport. 8 | def self.underscore(constant) 9 | constant.gsub(/::/, '/'). 10 | gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2'). 11 | gsub(/([a-z\d])([A-Z])/,'\1_\2'). 12 | tr("-", "_"). 13 | downcase 14 | end 15 | 16 | # WARNING: this API might change. 17 | def self.constant_for(name) 18 | class_name = name.split("/").collect do |part| 19 | part.split('_').collect(&:capitalize).join 20 | end.join('::') 21 | 22 | Object.const_get(class_name, false) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/cell.rb: -------------------------------------------------------------------------------- 1 | require "tilt" 2 | require "uber/inheritable_attr" 3 | 4 | module Cell 5 | autoload :Testing, "cell/testing" 6 | 7 | class TemplateMissingError < RuntimeError 8 | def initialize(prefixes, view) 9 | super("Template missing: view: `#{view.to_s}` prefixes: #{prefixes.inspect}") 10 | end 11 | end # Error 12 | end 13 | 14 | require "cell/caching" 15 | require "cell/prefixes" 16 | require "cell/layout" 17 | require "cell/templates" 18 | require "cell/abstract" 19 | require "cell/util" 20 | require "cell/inspect" 21 | require "cell/view_model" 22 | require "cell/concept" 23 | require "cell/escaped" 24 | require "cell/builder" 25 | require "cell/collection" 26 | -------------------------------------------------------------------------------- /test/cell_benchmark.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'benchmark' 3 | 4 | Song = Struct.new(:title) 5 | 6 | 7 | class SongCell < Cell::ViewModel 8 | self.view_paths = ['test'] 9 | property :title 10 | 11 | def show 12 | render 13 | end 14 | end 15 | 16 | time = Benchmark.measure do 17 | Cell::ViewModel.cell(:song, nil, collection: 1000.times.collect { Song.new("Anarchy Camp") }) 18 | end 19 | 20 | puts time 21 | 22 | # 4.0 23 | # 0.310000 0.010000 0.320000 ( 0.320382) 24 | 25 | # no caching of templates, puts 26 | # 0.570000 0.030000 0.600000 ( 0.600160) 27 | 28 | # caching of templates 29 | # 0.090000 0.000000 0.090000 ( 0.085652) 30 | 31 | # wed, 17. 32 | # 0.120000 0.010000 0.130000 ( 0.127731) 33 | -------------------------------------------------------------------------------- /lib/cell/concept.rb: -------------------------------------------------------------------------------- 1 | require "cell/self_contained" 2 | 3 | module Cell 4 | # Cell::Concept is no longer under active development. Please switch to Trailblazer::Cell. 5 | class Concept < Cell::ViewModel 6 | abstract! 7 | self.view_paths = ["app/concepts"] 8 | extend SelfContained 9 | 10 | # TODO: this should be in Helper or something. this should be the only entry point from controller/view. 11 | class << self 12 | def class_from_cell_name(name) 13 | util.constant_for(name) 14 | end 15 | 16 | def controller_path 17 | @controller_path ||= util.underscore(name.sub(/(::Cell$|Cell::)/, '')) 18 | end 19 | end 20 | 21 | alias_method :concept, :cell 22 | 23 | self_contained! 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/cell/inspect.rb: -------------------------------------------------------------------------------- 1 | module Cell 2 | module Inspect 3 | def inspect 4 | if inspect_blacklist.any? 5 | build_inspect_s 6 | else 7 | super 8 | end 9 | end 10 | 11 | private 12 | 13 | def build_inspect_s 14 | ivars = Hash[self.instance_variables.map { |name| [name[1..-1], self.instance_variable_get(name)] }] 15 | 16 | ivars_s = ivars.map do |name, value| 17 | if inspect_blacklist.include?(name) 18 | "@#{name}=#<#{value.class.name}:#{value.object_id}>" 19 | else 20 | "<@#{name}=#{value.inspect}>" 21 | end 22 | end.join(', ') 23 | 24 | "#<#{self.class.name}:#{self.object_id} #{ivars_s}>" 25 | end 26 | 27 | def inspect_blacklist 28 | [] 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/cell/templates.rb: -------------------------------------------------------------------------------- 1 | module Cell 2 | # Gets cached in production. 3 | class Templates 4 | # prefixes could be instance variable as they will never change. 5 | def [](prefixes, view, options) 6 | find_template(prefixes, view, options) 7 | end 8 | 9 | private 10 | def cache 11 | @cache ||= Tilt::Cache.new 12 | end 13 | 14 | def find_template(prefixes, view, options) # options is not considered in cache key. 15 | cache.fetch(prefixes, view) do 16 | template_prefix = prefixes.find { |prefix| File.exist?("#{prefix}/#{view}") } 17 | return if template_prefix.nil? # We can safely return early. Tilt::Cache does not cache nils. 18 | template_class = options.delete(:template_class) 19 | template_class.new("#{template_prefix}/#{view}", options) # Tilt.new() 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/cell/prefixes.rb: -------------------------------------------------------------------------------- 1 | module Cell::Prefixes 2 | def self.included(includer) 3 | includer.extend(ClassMethods) 4 | end 5 | 6 | def _prefixes 7 | self.class.prefixes 8 | end 9 | 10 | # You're free to override those methods in case you want to alter our view inheritance. 11 | module ClassMethods 12 | def prefixes 13 | @prefixes ||= _prefixes 14 | end 15 | 16 | private 17 | def _prefixes 18 | return [] if abstract? 19 | _local_prefixes + superclass.prefixes 20 | end 21 | 22 | def _local_prefixes 23 | view_paths.collect { |path| "#{path}/#{controller_path}" } 24 | end 25 | 26 | # Instructs Cells to inherit views from a parent cell without having to inherit class code. 27 | def inherit_views(parent) 28 | define_method :_prefixes do 29 | super() + parent.prefixes 30 | end 31 | end 32 | end 33 | end -------------------------------------------------------------------------------- /lib/cell/escaped.rb: -------------------------------------------------------------------------------- 1 | module Cell::ViewModel::Escaped 2 | def self.included(includer) 3 | includer.extend Property 4 | end 5 | 6 | module Property 7 | def property(*names) 8 | super.tap do # super defines #title 9 | mod = Module.new do 10 | names.each do |name| 11 | define_method(name) do |options={}| 12 | value = super() # call the original #title. 13 | return value unless value.is_a?(String) 14 | return value if options[:escape] == false 15 | escape!(value) 16 | end 17 | end 18 | end 19 | include mod 20 | end 21 | end 22 | end # Property 23 | 24 | # Can be used as a helper in the cell, too. 25 | # Feel free to override and use a different escaping implementation. 26 | require "erb" 27 | def escape!(string) 28 | ::ERB::Util.html_escape(string) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | require 'rake/testtask' 5 | 6 | desc 'Default: run unit tests.' 7 | task :default => :test 8 | 9 | Rake::TestTask.new(:test) do |test| 10 | test.libs << 'test' 11 | test.pattern = 'test/*_test.rb' 12 | test.verbose = true 13 | # Ruby built-in warnings contain way too much noise to be useful. Consider turning them on again when the following issues are accepted in ruby: 14 | # * https://bugs.ruby-lang.org/issues/10967 (remove warning: private attribute?) 15 | # * https://bugs.ruby-lang.org/issues/12299 (customized warning handling) 16 | test.warning = false 17 | end 18 | 19 | # Rake::TestTask.new(:rails) do |test| 20 | # test.libs << 'test/rails' 21 | # test.test_files = FileList['test/rails4.2/*_test.rb'] 22 | # test.verbose = true 23 | # end 24 | 25 | # rails_task = Rake::Task["rails"] 26 | # test_task = Rake::Task["test"] 27 | # default_task.enhance { test_task.invoke } 28 | # default_task.enhance { rails_task.invoke } 29 | -------------------------------------------------------------------------------- /lib/cell/option.rb: -------------------------------------------------------------------------------- 1 | require "trailblazer/option" 2 | require "uber/callable" 3 | 4 | module Cell 5 | # Extend `Trailblazer::Option` to make static values as callables too. 6 | class Option < ::Trailblazer::Option 7 | def self.build(value) 8 | callable = case value 9 | when Proc, Symbol, Uber::Callable 10 | value 11 | else 12 | ->(*) { value } # Make non-callable value to callable. 13 | end 14 | 15 | super(callable) 16 | end 17 | end 18 | 19 | class Options < Hash 20 | # Evaluates every element and returns a hash. Accepts arbitrary arguments. 21 | def call(*args, **options, &block) 22 | Hash[ collect { |k,v| [k,v.(*args, **options, &block) ] } ] 23 | end 24 | end 25 | 26 | def self.Option(value) 27 | ::Cell::Option.build(value) 28 | end 29 | 30 | def self.Options(options) 31 | Options.new.tap do |hsh| 32 | options.each { |k,v| hsh[k] = Option(v) } 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/templates_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | 4 | class TemplatesTest < MiniTest::Spec 5 | Templates = Cell::Templates 6 | 7 | # existing. 8 | it { _(Templates.new[['test/fixtures/bassist'], 'play.erb', {template_class: Cell::Erb::Template}].file).must_equal 'test/fixtures/bassist/play.erb' } 9 | 10 | # not existing. 11 | it { assert_nil(Templates.new[['test/fixtures/bassist'], 'not-here.erb', {}]) } 12 | 13 | 14 | # different caches for different classes 15 | 16 | # same cache for subclasses 17 | 18 | end 19 | 20 | 21 | class TemplatesCachingTest < MiniTest::Spec 22 | class SongCell < Cell::ViewModel 23 | self.view_paths = ['test/fixtures'] 24 | # include Cell::Erb 25 | 26 | def show 27 | render 28 | end 29 | end 30 | 31 | # templates are cached once and forever. 32 | it do 33 | cell = cell("templates_caching_test/song") 34 | 35 | _(cell.call(:show)).must_equal 'The Great Mind Eraser' 36 | 37 | SongCell.templates.instance_eval do 38 | def create; raise; end 39 | end 40 | 41 | # cached, NO new tilt template. 42 | _(cell.call(:show)).must_equal 'The Great Mind Eraser' 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/context_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ContextTest < MiniTest::Spec 4 | class ParentCell < Cell::ViewModel 5 | def user 6 | context[:user] 7 | end 8 | 9 | def controller 10 | context[:controller] 11 | end 12 | end 13 | 14 | let (:model) { Object.new } 15 | let (:user) { Object.new } 16 | let (:controller) { Object.new } 17 | 18 | let (:parent) { ParentCell.(model, admin: true, context: { user: user, controller: controller }) } 19 | 20 | it do 21 | _(parent.model).must_equal model 22 | _(parent.controller).must_equal controller 23 | _(parent.user).must_equal user 24 | 25 | # nested cell 26 | child = parent.cell("context_test/parent", "") 27 | 28 | _(child.model).must_equal "" 29 | _(child.controller).must_equal controller 30 | _(child.user).must_equal user 31 | end 32 | 33 | # child can add to context 34 | it do 35 | child = parent.cell(ParentCell, nil, context: { "is_child?" => true }) 36 | 37 | assert_nil(parent.context["is_child?"]) 38 | 39 | assert_nil(child.model) 40 | _(child.controller).must_equal controller 41 | _(child.user).must_equal user 42 | _(child.context["is_child?"]).must_equal true 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/partial_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | require "cell/partial" 3 | 4 | class PartialTest < MiniTest::Spec 5 | class WithPartial < Cell::ViewModel 6 | self.view_paths = ['test/fixtures'] # doesn't exist. 7 | include ::Cell::Erb 8 | 9 | include Partial 10 | 11 | def show 12 | render partial: "../fixtures/partials/show.html" 13 | end 14 | 15 | def show_with_format 16 | render partial: "../fixtures/partials/show", formats: [:xml] 17 | end 18 | 19 | def show_without_partial 20 | render :show 21 | end 22 | end 23 | 24 | class WithPartialAndManyViewPaths < WithPartial 25 | self.view_paths << ['app/views'] 26 | end 27 | 28 | it { _(WithPartial.new(nil).show).must_equal "I Am Wrong And I Am Right" } 29 | it { _(WithPartial.new(nil).show_with_format).must_equal "I Am Wrong And I Am Right" } 30 | it { _(WithPartial.new(nil).show_without_partial).must_equal "Adenosine Breakdown" } 31 | 32 | it { _(WithPartialAndManyViewPaths.new(nil).show).must_equal "I Am Wrong And I Am Right" } 33 | it { _(WithPartialAndManyViewPaths.new(nil).show_with_format).must_equal "I Am Wrong And I Am Right" } 34 | it { _(WithPartialAndManyViewPaths.new(nil).show_without_partial).must_equal "Adenosine Breakdown" } 35 | end 36 | -------------------------------------------------------------------------------- /cells.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift lib unless $LOAD_PATH.include?(lib) 3 | 4 | require "cell/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "cells" 8 | spec.version = Cell::VERSION 9 | spec.authors = ["Nick Sutterer"] 10 | spec.email = ["apotonick@gmail.com"] 11 | spec.homepage = "https://github.com/apotonick/cells" 12 | spec.summary = "View Models for Ruby and Rails." 13 | spec.description = "View Models for Ruby and Rails, replacing helpers and partials while giving you a clean view architecture with proper encapsulation." 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split("\n") 17 | spec.test_files = `git ls-files -- {test}/*`.split("\n") 18 | spec.require_paths = ["lib"] 19 | spec.required_ruby_version = ">= 2.2.10" 20 | 21 | spec.add_dependency "declarative-builder", "~> 0.2.0" 22 | spec.add_dependency "trailblazer-option", "~> 0.1.0" 23 | spec.add_dependency "tilt", ">= 1.4", "< 3" 24 | spec.add_dependency "uber", "< 0.2.0" 25 | 26 | spec.add_development_dependency "capybara" 27 | spec.add_development_dependency "cells-erb", ">= 0.1.0" 28 | spec.add_development_dependency "minitest" 29 | spec.add_development_dependency "rake" 30 | end 31 | -------------------------------------------------------------------------------- /test/inspect_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class InspectTest < Minitest::Spec 4 | class FakeModel 5 | def initialize(title) 6 | @title = title 7 | end 8 | end 9 | 10 | def build_model 11 | InspectTest::FakeModel.new('Title') 12 | end 13 | # #inspect 14 | it do 15 | cell = Cell::ViewModel.(model_obj = build_model, options = { genre: "Djent" }) 16 | 17 | inspection_s = cell.inspect 18 | 19 | _(inspection_s).must_match '#" 41 | _(inspection_s).must_match "@options=#{options.inspect}" 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/property_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class PropertyTest < MiniTest::Spec 4 | class SongCell < Cell::ViewModel 5 | property :title 6 | 7 | def title 8 | super + "" 9 | end 10 | end 11 | 12 | let (:song) { Struct.new(:title).new("She Sells And Sand Sandwiches") } 13 | # ::property creates automatic accessor. 14 | it { _(SongCell.(song).title).must_equal "She Sells And Sand Sandwiches" } 15 | end 16 | 17 | 18 | class EscapedPropertyTest < MiniTest::Spec 19 | class SongCell < Cell::ViewModel 20 | include Escaped 21 | property :title 22 | property :artist 23 | property :copyright, :lyrics 24 | 25 | def title(*) 26 | "#{super}" # super + "" still escapes, but this is Rails. 27 | end 28 | 29 | def raw_title 30 | title(escape: false) 31 | end 32 | end 33 | 34 | let (:song) do 35 | Struct 36 | .new(:title, :artist, :copyright, :lyrics) 37 | .new("She Sells And Sand Sandwiches", Object, "Copy", "Words") 38 | end 39 | 40 | # ::property escapes, everywhere. 41 | it { _(SongCell.(song).title).must_equal "<b>She Sells And Sand Sandwiches" } 42 | it { _(SongCell.(song).copyright).must_equal "<a>Copy</a>" } 43 | it { _(SongCell.(song).lyrics).must_equal "<i>Words</i>" } 44 | # no escaping for non-strings. 45 | it { _(SongCell.(song).artist).must_equal Object } 46 | # no escaping when escape: false 47 | it { _(SongCell.(song).raw_title).must_equal "She Sells And Sand Sandwiches" } 48 | end 49 | -------------------------------------------------------------------------------- /test/builder_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class BuilderTest < MiniTest::Spec 4 | Song = Struct.new(:title) 5 | Hit = Struct.new(:title) 6 | 7 | class SongCell < Cell::ViewModel 8 | include Cell::Builder 9 | 10 | builds do |model, options| 11 | if model.is_a? Hit 12 | HitCell 13 | elsif options[:evergreen] 14 | EvergreenCell 15 | end 16 | end 17 | 18 | def options 19 | @options 20 | end 21 | 22 | def show 23 | "* #{title}" 24 | end 25 | 26 | property :title 27 | end 28 | 29 | class HitCell < SongCell 30 | def show 31 | "* **#{title}**" 32 | end 33 | end 34 | 35 | class EvergreenCell < SongCell 36 | end 37 | 38 | # the original class is used when no builder matches. 39 | it { _(SongCell.(Song.new("Nation States"), {})).must_be_instance_of SongCell } 40 | 41 | it do 42 | cell = SongCell.(Hit.new("New York"), {}) 43 | _(cell).must_be_instance_of HitCell 44 | _(cell.options).must_equal({}) 45 | end 46 | 47 | it do 48 | cell = SongCell.(Song.new("San Francisco"), evergreen: true) 49 | _(cell).must_be_instance_of EvergreenCell 50 | _(cell.options).must_equal({evergreen:true}) 51 | end 52 | 53 | # without arguments. 54 | it { _(SongCell.(Hit.new("Frenzy"))).must_be_instance_of HitCell } 55 | 56 | # with collection. 57 | it { _(SongCell.(collection: [Song.new("Nation States"), Hit.new("New York")]).()).must_equal "* Nation States* **New York**" } 58 | 59 | # with Concept 60 | class Track < Cell::Concept 61 | end 62 | it { _(Track.()).must_be_instance_of Track } 63 | end 64 | -------------------------------------------------------------------------------- /test/testing_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class TestCaseTest < MiniTest::Spec 4 | class SongCell < Cell::ViewModel 5 | def show 6 | "Give It All!" 7 | end 8 | end 9 | 10 | class Song 11 | class Cell < Cell::Concept 12 | end 13 | end 14 | 15 | let (:song) { Object.new } 16 | 17 | # #cell returns the instance 18 | describe "#cell" do 19 | subject { cell("test_case_test/song", song) } 20 | 21 | it { _(subject).must_be_instance_of SongCell } 22 | it { _(subject.model).must_equal song } 23 | 24 | it { _(cell("test_case_test/song", collection: [song, song]).()).must_equal "Give It All!Give It All!" } 25 | end 26 | 27 | 28 | describe "#concept" do 29 | subject { concept("test_case_test/song/cell", song) } 30 | 31 | it { _(subject).must_be_instance_of Song::Cell } 32 | it { _(subject.model).must_equal song } 33 | end 34 | end 35 | 36 | # capybara support 37 | require "capybara" 38 | 39 | class CapybaraTest < MiniTest::Spec 40 | class CapybaraCell < Cell::ViewModel 41 | def show 42 | "Grunt" 43 | end 44 | end 45 | 46 | describe "capybara support" do 47 | subject { cell("capybara_test/capybara", nil) } 48 | 49 | before { Cell::Testing.capybara = true } # yes, a global switch! 50 | after { Cell::Testing.capybara = false } 51 | 52 | it { _(subject.(:show).has_selector?('b')).must_equal true } 53 | 54 | it { _(cell("capybara_test/capybara", collection: [1, 2]).().has_selector?('b')).must_equal true } 55 | 56 | # FIXME: this kinda sucks, what if you want the string in a Capybara environment? 57 | it { _(subject.(:show).to_s).must_match "Grunt" } 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/cell/layout.rb: -------------------------------------------------------------------------------- 1 | module Cell 2 | class ViewModel 3 | # Set the layout per cell class. This is used in #render calls. Gets inherited to subclasses. 4 | module Layout 5 | def self.included(base) 6 | base.extend ClassMethods 7 | base.inheritable_attr :layout_name 8 | end 9 | 10 | module ClassMethods 11 | def layout(name) 12 | self.layout_name = name 13 | end 14 | end 15 | 16 | private 17 | def process_options!(options) 18 | options[:layout] ||= self.class.layout_name 19 | super 20 | end 21 | 22 | def render_to_string(options, &block) 23 | with_layout(options, super) 24 | end 25 | 26 | def with_layout(options, content) 27 | return content unless layout = options[:layout] 28 | 29 | render_layout(layout, options, content) 30 | end 31 | 32 | def render_layout(name, options, content) 33 | template = find_template(options.merge view: name) # we could also allow a different layout engine, etc. 34 | render_template(template, options) { content } 35 | end 36 | 37 | # Allows using a separate layout cell which will wrap the actual content. 38 | # Use like cell(..., layout: Cell::Layout) 39 | # 40 | # Note that still allows the `render layout: :application` option. 41 | module External 42 | def call(*) 43 | content = super 44 | Render.(content, model, @options[:layout], @options) 45 | end 46 | 47 | Render = ->(content, model, layout, options) do # WARNING: THIS IS NOT FINAL API. 48 | return content unless layout = layout # TODO: test when invoking cell without :layout. 49 | 50 | # DISCUSS: should we allow instances, too? we could cache the layout cell. 51 | layout.new(model, context: options[:context]).(&lambda { content }) 52 | end 53 | end # External 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/cell/collection.rb: -------------------------------------------------------------------------------- 1 | module Cell 2 | class Collection 3 | def initialize(ary, options, cell_class) 4 | options.delete(:collection) 5 | set_deprecated_options(options) # TODO: remove in 5.0. 6 | 7 | @ary = ary 8 | @options = options # these options are "final" and will be identical for all collection cells. 9 | @cell_class = cell_class 10 | end 11 | 12 | def set_deprecated_options(options) # TODO: remove in 5.0. 13 | self.method = options.delete(:method) if options.include?(:method) 14 | self.collection_join = options.delete(:collection_join) if options.include?(:collection_join) 15 | end 16 | 17 | module Call 18 | def call(state=:show) 19 | join(collection_join) { |cell, i| cell.(method || state) } 20 | end 21 | 22 | end 23 | include Call 24 | 25 | def to_s 26 | call 27 | end 28 | 29 | # Iterate collection and build a cell for each item. 30 | # The passed block receives that cell and the index. 31 | # Its return value is captured and joined. 32 | def join(separator="", &block) 33 | @ary.each_with_index.collect do |model, i| 34 | cell = @cell_class.build(model, @options) 35 | block_given? ? yield(cell, i) : cell 36 | end.join(separator) 37 | end 38 | 39 | module Layout 40 | def call(*) # WARNING: THIS IS NOT FINAL API. 41 | layout = @options.delete(:layout) # we could also override #initialize and that there? 42 | 43 | content = super # DISCUSS: that could come in via the pipeline argument. 44 | ViewModel::Layout::External::Render.(content, @ary, layout, @options) 45 | end 46 | end 47 | include Layout 48 | 49 | # TODO: remove in 5.0. 50 | private 51 | attr_accessor :collection_join, :method 52 | 53 | extend Gem::Deprecate 54 | deprecate :method=, "`call(method)` as documented here: http://trailblazer.to/gems/cells/api.html#collection", 2016, 7 55 | deprecate :collection_join=, "`join(\"
\")` as documented here: http://trailblazer.to/gems/cells/api.html#collection", 2016, 7 56 | end 57 | end 58 | 59 | # Collection#call 60 | # |> Header#call 61 | # |> Layout#call 62 | -------------------------------------------------------------------------------- /lib/cell/testing.rb: -------------------------------------------------------------------------------- 1 | require "uber/inheritable_attr" 2 | 3 | module Cell 4 | # Builder methods and Capybara support. 5 | # This gets included into Test::Unit, MiniTest::Spec, etc. 6 | module Testing 7 | def cell(name, *args) 8 | cell_for(ViewModel, name, *args) 9 | end 10 | 11 | def concept(name, *args) 12 | cell_for(Concept, name, *args) 13 | end 14 | 15 | private 16 | def cell_for(baseclass, name, model=nil, options={}) 17 | options[:context] ||= {} 18 | options[:context][:controller] = controller 19 | 20 | cell = baseclass.cell(name, model, options) 21 | 22 | cell.extend(Capybara) if Cell::Testing.capybara? # leaving this here as most people use Capybara. 23 | # apparently it's ok to only override ViewModel#call and capybararize the result. 24 | # when joining in a Collection, the joint will still be capybararized. 25 | cell 26 | end 27 | 28 | 29 | # Set this to true if you have Capybara loaded. Happens automatically in Cell::TestCase. 30 | def self.capybara=(value) 31 | @capybara = value 32 | end 33 | 34 | def self.capybara? 35 | @capybara if defined?(@capybara) 36 | end 37 | 38 | # Extends ViewModel#call by injecting Capybara support. 39 | module Capybara 40 | module ToS 41 | def to_s 42 | native.to_s 43 | end 44 | end 45 | 46 | def call(*) 47 | ::Capybara.string(super).extend(ToS) 48 | end 49 | end 50 | 51 | module ControllerFor 52 | # This method is provided by the cells-rails gem. 53 | def controller_for(controller_class) 54 | # raise "[Cells] Please install (or update?) the cells-rails gem." 55 | end 56 | end 57 | include ControllerFor 58 | 59 | def controller # FIXME: this won't allow us using let(:controller) in MiniTest. 60 | controller_for(self.class.controller_class) 61 | end 62 | 63 | def self.included(base) 64 | base.class_eval do 65 | extend Uber::InheritableAttr 66 | inheritable_attr :controller_class 67 | 68 | def self.controller(name) # DSL method for the test. 69 | self.controller_class = name 70 | end 71 | end 72 | end 73 | end # Testing 74 | end 75 | -------------------------------------------------------------------------------- /test/layout_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class SongWithLayoutCell < Cell::ViewModel 4 | self.view_paths = ['test/fixtures'] 5 | # include Cell::Erb 6 | 7 | def show 8 | render layout: :merry 9 | end 10 | 11 | def unknown 12 | render layout: :no_idea_what_u_mean 13 | end 14 | 15 | def what 16 | "Xmas" 17 | end 18 | 19 | def string 20 | "Right" 21 | end 22 | 23 | private 24 | def title 25 | "Papertiger" 26 | end 27 | end 28 | 29 | class SongWithLayoutOnClassCell < SongWithLayoutCell 30 | # inherit_views SongWithLayoutCell 31 | layout :merry 32 | 33 | def show 34 | render 35 | end 36 | 37 | def show_with_layout 38 | render layout: :happy 39 | end 40 | end 41 | 42 | class LayoutTest < MiniTest::Spec 43 | # render show.haml calling method. 44 | # same context as content view as layout call method. 45 | it { _(SongWithLayoutCell.new(nil).show).must_equal "Merry Xmas, Papertiger\n" } 46 | 47 | # raises exception when layout not found! 48 | 49 | it { assert_raises(Cell::TemplateMissingError) { SongWithLayoutCell.new(nil).unknown } } 50 | # assert message of exception. 51 | it { } 52 | 53 | # with ::layout. 54 | it { _(SongWithLayoutOnClassCell.new(nil).show).must_equal "Merry Xmas, Papertiger\n" } 55 | 56 | # with ::layout and :layout, :layout wins. 57 | it { _(SongWithLayoutOnClassCell.new(nil).show_with_layout).must_equal "Happy Friday!" } 58 | end 59 | 60 | module Comment 61 | class ShowCell < Cell::ViewModel 62 | self.view_paths = ['test/fixtures'] 63 | include Layout::External 64 | 65 | def show 66 | render + render 67 | end 68 | end 69 | 70 | class LayoutCell < Cell::ViewModel 71 | self.view_paths = ['test/fixtures'] 72 | end 73 | end 74 | 75 | class ExternalLayoutTest < Minitest::Spec 76 | it do 77 | _(Comment::ShowCell.new(nil, layout: Comment::LayoutCell, context: { beer: true }). 78 | ()).must_equal "$layout.erb{$show.erb, {:beer=>true}\n$show.erb, {:beer=>true}\n, {:beer=>true}} 79 | " 80 | end 81 | 82 | # collection :layout 83 | it do 84 | _(Cell::ViewModel.cell("comment/show", collection: [Object, Module], layout: Comment::LayoutCell).()). 85 | must_equal "$layout.erb{$show.erb, nil\n$show.erb, nil\n$show.erb, nil\n$show.erb, nil\n, nil} 86 | " 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /lib/cell/caching.rb: -------------------------------------------------------------------------------- 1 | require "cell/option" 2 | 3 | module Cell 4 | module Caching 5 | def self.included(includer) 6 | includer.class_eval do 7 | extend ClassMethods 8 | extend Uber::InheritableAttr 9 | inheritable_attr :version_procs 10 | inheritable_attr :conditional_procs 11 | inheritable_attr :cache_options 12 | 13 | self.version_procs = {} 14 | self.conditional_procs = {} 15 | self.cache_options = {} 16 | end 17 | end 18 | 19 | module ClassMethods 20 | def cache(state, *args, **kws, &block) 21 | conditional_procs[state] = Cell::Option(kws.delete(:if) || true) 22 | version_procs[state] = Cell::Option(args.first || block) 23 | cache_options[state] = Cell::Options(kws) 24 | end 25 | 26 | # Computes the complete, namespaced cache key for +state+. 27 | def state_cache_key(state, key_parts={}) 28 | expand_cache_key([controller_path, state, key_parts]) 29 | end 30 | 31 | def expire_cache_key_for(key, cache_store, *args) 32 | cache_store.delete(key, *args) 33 | end 34 | 35 | private 36 | 37 | def expand_cache_key(key) 38 | key.join("/") 39 | end 40 | end 41 | 42 | def render_state(state, *args, **kws) 43 | state = state.to_sym 44 | 45 | # Before Ruby 3.0, this wasn't necessary, but since cache filters don't receive kwargs as per the "old" (existing cells version) implementation, we can make it one array. 46 | cache_filter_args = args + [kws] 47 | 48 | return super(state, *args, **kws) unless cache?(state, *cache_filter_args) 49 | 50 | 51 | key = self.class.state_cache_key(state, self.class.version_procs[state].(*cache_filter_args, exec_context: self)) 52 | options = self.class.cache_options[state].(*cache_filter_args, exec_context: self) 53 | 54 | fetch_from_cache_for(key, options) { super(state, *args, **kws) } 55 | end 56 | 57 | def cache_store # we want to use DI to set a cache store in cell/rails. 58 | raise "No cache store has been set." 59 | end 60 | 61 | def cache?(state, *args) 62 | perform_caching? and state_cached?(state) and self.class.conditional_procs[state].(*args, exec_context: self) 63 | end 64 | 65 | private 66 | 67 | def perform_caching? 68 | true 69 | end 70 | 71 | def fetch_from_cache_for(key, options, &block) 72 | cache_store.fetch(key, options, &block) 73 | end 74 | 75 | def state_cached?(state) 76 | self.class.version_procs.has_key?(state) 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /test/render_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class SongCell < Cell::ViewModel 4 | self.view_paths = ['test/fixtures'] 5 | # include ::Cell::Erb 6 | 7 | def show 8 | render 9 | end 10 | 11 | def ivar 12 | @title = "Carnage" 13 | render 14 | end 15 | 16 | def unknown 17 | render 18 | end 19 | 20 | def string 21 | "Right" 22 | end 23 | 24 | # TODO: just pass hash. 25 | def with_locals 26 | render locals: {length: 280, title: "Shot Across The Bow"} 27 | end 28 | 29 | def with_erb 30 | render template_engine: :erb 31 | end 32 | 33 | def with_view_name 34 | @title = "Man Of Steel" 35 | render :ivar 36 | end 37 | 38 | def receiving_options(layout=:default) 39 | "#{layout}" 40 | end 41 | 42 | def with_html 43 | render 44 | end 45 | 46 | def send 47 | "send" 48 | end 49 | 50 | def with_block 51 | render { "Clean Sheets" + render(:with_html) } 52 | end 53 | 54 | private 55 | def title 56 | "Papertiger" 57 | end 58 | end 59 | 60 | class RenderTest < MiniTest::Spec 61 | # render show.haml calling method, implicit render. 62 | it { _(SongCell.new(nil).show).must_equal "Papertiger\n" } 63 | 64 | # render ivar.haml using instance variable. 65 | it { _(SongCell.new(nil).ivar).must_equal "Carnage\n" } 66 | 67 | # render string. 68 | it { _(SongCell.new(nil).string).must_equal "Right" } 69 | 70 | # #call renders :show 71 | it { _(SongCell.new(nil).call).must_equal "Papertiger\n" } 72 | 73 | # call(:form) renders :form 74 | it { _(SongCell.new(nil).call(:with_view_name)).must_equal "Man Of Steel\n" } 75 | 76 | # works with state called `send` 77 | it { _(SongCell.new(nil).call(:send)).must_equal "send" } 78 | 79 | # throws an exception when not found. 80 | it do 81 | exception = assert_raises(Cell::TemplateMissingError) { SongCell.new(nil).unknown } 82 | _(exception.message).must_equal "Template missing: view: `unknown.erb` prefixes: [\"test/fixtures/song\"]" 83 | end 84 | 85 | # allows locals 86 | it { _(SongCell.new(nil).with_locals).must_equal "Shot Across The Bow\n280\n" } 87 | 88 | # render :form is a shortcut. 89 | it { _(SongCell.new(nil).with_view_name).must_equal "Man Of Steel\n" } 90 | 91 | # :template_engine renders ERB. 92 | # it { SongCell.new(nil).with_erb.must_equal "ERB:\n\n Papertiger\n" } 93 | 94 | # view: "show.html" 95 | 96 | # allows passing in options DISCUSS: how to handle that in cache block/builder? 97 | it { _(SongCell.new(nil).receiving_options).must_equal "default" } 98 | it { _(SongCell.new(nil).receiving_options(:fancy)).must_equal "fancy" } 99 | it { _(SongCell.new(nil).call(:receiving_options, :fancy)).must_equal "fancy" } 100 | 101 | # doesn't escape HTML. 102 | it { _(SongCell.new(nil).call(:with_html)).must_equal "

Yew!

" } 103 | 104 | # render {} with block 105 | it { _(SongCell.new(nil).with_block).must_equal "Yo! Clean Sheets

Yew!

\n" } 106 | end 107 | 108 | # test inheritance 109 | 110 | # test view: :bla and :bla 111 | # with layout and locals. 112 | # with layout and :text 113 | 114 | # render with format (e.g. when using ERB for one view) 115 | # should we allow changing the format "per run", so a cell can do .js and .haml? or should that be configurable on class level? 116 | -------------------------------------------------------------------------------- /test/concept_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | Cell::Concept.class_eval do 4 | self.view_paths = ['test/fixtures/concepts'] 5 | end 6 | 7 | # Trailblazer style: 8 | module Record 9 | class Cell < ::Cell::Concept # cell("record") 10 | include ::Cell::Erb 11 | 12 | def show 13 | render # Party On, #{model} 14 | end 15 | 16 | # cell(:song, concept: :record) 17 | class Song < self # cell("record/cell/song") 18 | def show 19 | render view: :song#, layout: "layout" 20 | # TODO: test layout: .. in ViewModel 21 | end 22 | end 23 | 24 | class Hit < ::Cell::Concept 25 | inherit_views Record::Cell 26 | end 27 | 28 | 29 | def description 30 | "A Tribute To Rancid, with #{@options[:tracks]} songs! [#{context}]" 31 | end 32 | end 33 | end 34 | 35 | module Record 36 | module Cells 37 | class Cell < ::Cell::Concept 38 | class Song < ::Cell::Concept 39 | end 40 | end 41 | end 42 | end 43 | 44 | # app/cells/comment/views 45 | # app/cells/comment/form/views 46 | # app/cells/comment/views/form inherit_views Comment::Cell, render form/show 47 | 48 | 49 | class ConceptTest < MiniTest::Spec 50 | describe "::controller_path" do 51 | it { _(Record::Cell.new.class.controller_path).must_equal "record" } 52 | it { _(Record::Cell::Song.new.class.controller_path).must_equal "record/song" } 53 | it { _(Record::Cells::Cell.new.class.controller_path).must_equal "record/cells" } 54 | it { _(Record::Cells::Cell::Song.new.class.controller_path).must_equal "record/cells/song" } 55 | end 56 | 57 | 58 | describe "#_prefixes" do 59 | it { _(Record::Cell.new._prefixes).must_equal ["test/fixtures/concepts/record/views"] } 60 | it { _(Record::Cell::Song.new._prefixes).must_equal ["test/fixtures/concepts/record/song/views", "test/fixtures/concepts/record/views"] } 61 | it { _(Record::Cell::Hit.new._prefixes).must_equal ["test/fixtures/concepts/record/hit/views", "test/fixtures/concepts/record/views"] } # with inherit_views. 62 | end 63 | 64 | it { _(Record::Cell.new("Wayne").call(:show)).must_equal "Party on, Wayne!" } 65 | 66 | 67 | describe "::cell" do 68 | it { _(Cell::Concept.cell("record/cell")).must_be_instance_of( Record::Cell) } 69 | it { _(Cell::Concept.cell("record/cell/song")).must_be_instance_of Record::Cell::Song } 70 | # cell("song", concept: "record/compilation") # record/compilation/cell/song 71 | end 72 | 73 | describe "#render" do 74 | it { _(Cell::Concept.cell("record/cell/song").show).must_equal "Lalala" } 75 | end 76 | 77 | describe "#cell (in state)" do 78 | # test with controller, but remove tests when we don't need it anymore. 79 | it { _(Cell::Concept.cell("record/cell", nil, context: { controller: Object }).cell("record/cell", nil)).must_be_instance_of Record::Cell } 80 | it { _(Cell::Concept.cell("record/cell", nil, context: { controller: Object }).concept("record/cell", nil, tracks: 24).(:description)).must_equal "A Tribute To Rancid, with 24 songs! [{:controller=>Object}]" } 81 | # concept(.., collection: ..) 82 | it do 83 | _(Cell::Concept.cell("record/cell", nil, context: { controller: Object }). 84 | concept("record/cell", collection: [1,2], tracks: 24).(:description)).must_equal "A Tribute To Rancid, with 24 songs! [{:controller=>Object}]A Tribute To Rancid, with 24 songs! [{:controller=>Object}]" 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/cache_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CacheTest < Minitest::Spec 4 | STORE = Class.new(Hash) do 5 | def fetch(key, options, &block) 6 | self[key] || self[key] = yield 7 | end 8 | end.new 9 | 10 | module Cache 11 | def show(*) 12 | "#{@model}" 13 | end 14 | 15 | def cache_store 16 | STORE 17 | end 18 | 19 | def has_changed?(*) 20 | @model < 3 21 | end 22 | end 23 | 24 | Component = ->(*args, **kwargs, &block) { 25 | Class.new(Cell::ViewModel) do 26 | cache :show, *args, **kwargs, &block 27 | include Cache 28 | end 29 | } 30 | 31 | it "without any options" do 32 | WithoutOptions = Component.() 33 | 34 | _(WithoutOptions.new(1).()).must_equal("1") 35 | _(WithoutOptions.new(2).()).must_equal("1") 36 | end 37 | 38 | it "with specified version" do 39 | version = ->(options) { options[:version] } 40 | 41 | # Cache invalidation using version as a proc 42 | WithVersionArg = Component.(version) 43 | 44 | _(WithVersionArg.new(1).(:show, version: 1)).must_equal("1") 45 | _(WithVersionArg.new(2).(:show, version: 1)).must_equal("1") 46 | 47 | _(WithVersionArg.new(3).(:show, version: 2)).must_equal("3") 48 | 49 | # Cache invalidation using version as a block 50 | WithVersionBlock = Component.(&version) 51 | 52 | _(WithVersionBlock.new(1).(:show, version: 1)).must_equal("1") 53 | _(WithVersionBlock.new(2).(:show, version: 1)).must_equal("1") 54 | 55 | _(WithVersionBlock.new(3).(:show, version: 2)).must_equal("3") 56 | end 57 | 58 | it "with conditional" do 59 | WithConditional = Component.(if: :has_changed?) 60 | 61 | _(WithConditional.new(1).()).must_equal("1") 62 | _(WithConditional.new(2).()).must_equal("1") 63 | 64 | _(WithConditional.new(3).()).must_equal("3") 65 | end 66 | 67 | it "forwards remaining options to cache store" do 68 | WithOptions = Class.new(Cell::ViewModel) do 69 | cache :show, if: :has_changed?, expires_in: 10, tags: ->(*args) { Hash(args.first)[:tags] } 70 | ## We can use kwargs in the cache key filter 71 | # cache :new, expires_in: 10, tags: ->(*, my_tags:, **) { my_tags } # FIXME: allow this in Cells 5. 72 | include Cache 73 | 74 | CACHE_WITH_OPTIONS_STORE = Class.new(Hash) do 75 | def fetch(key, options) 76 | value = self[key] || self[key] = yield 77 | [value, options] 78 | end 79 | end.new 80 | 81 | def cache_store 82 | CACHE_WITH_OPTIONS_STORE 83 | end 84 | end 85 | 86 | _(WithOptions.new(1).()).must_equal(%{["1", {:expires_in=>10, :tags=>nil}]}) 87 | _(WithOptions.new(2).()).must_equal(%{["1", {:expires_in=>10, :tags=>nil}]}) 88 | _(WithOptions.new(2).(:show, tags: [:a, :b])).must_equal(%{["1", {:expires_in=>10, :tags=>[:a, :b]}]}) 89 | 90 | # FIXME: allow this in Cells 5. 91 | # _(WithOptions.new(2).(:new, my_tags: [:a, :b])).must_equal(%{["1", {:expires_in=>10, :tags=>[:a, :b]}]}) 92 | end 93 | 94 | it "forwards all arguments to renderer after cache hit" do 95 | SongCell = Class.new(Cell::ViewModel) do 96 | cache :show 97 | 98 | def show(type, title:, part:, **) 99 | "#{type} #{title} #{part}" 100 | end 101 | 102 | def cache_store 103 | STORE 104 | end 105 | end 106 | 107 | # cache miss for the first render 108 | _(SongCell.new.(:show, "Album", title: "IT", part: "1")).must_equal("Album IT 1") 109 | 110 | # cache hit for the second render 111 | _(SongCell.new.(:show, "Album", title: "IT", part: "1")).must_equal("Album IT 1") 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /test/public_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class PublicTest < MiniTest::Spec 4 | class SongCell < Cell::ViewModel 5 | def initialize(*args) 6 | @initialize_args = *args 7 | end 8 | attr_reader :initialize_args 9 | 10 | def show 11 | initialize_args.inspect 12 | end 13 | 14 | def detail 15 | "* #{initialize_args}" 16 | end 17 | end 18 | 19 | class Songs < Cell::Concept 20 | end 21 | 22 | # ViewModel.cell returns the cell instance. 23 | it { _(Cell::ViewModel.cell("public_test/song")).must_be_instance_of SongCell } 24 | it { _(Cell::ViewModel.cell(PublicTest::SongCell)).must_be_instance_of SongCell } 25 | 26 | # Concept.cell simply camelizes the string before constantizing. 27 | it { _(Cell::Concept.cell("public_test/songs")).must_be_instance_of Songs } 28 | 29 | it { _(Cell::Concept.cell(PublicTest::Songs)).must_be_instance_of Songs } 30 | 31 | # ViewModel.cell passes options to cell. 32 | it { _(Cell::ViewModel.cell("public_test/song", Object, genre: "Metal").initialize_args).must_equal [Object, {genre:"Metal"}] } 33 | 34 | # ViewModel.cell(collection: []) renders cells. 35 | it { _(Cell::ViewModel.cell("public_test/song", collection: [Object, Module]).to_s).must_equal '[Object, {}][Module, {}]' } 36 | 37 | # DISCUSS: should cell.() be the default? 38 | # ViewModel.cell(collection: []) renders cells with custom join. 39 | it do 40 | Gem::Deprecate::skip_during do 41 | _(Cell::ViewModel.cell("public_test/song", collection: [Object, Module]).join('
') do |cell| 42 | cell.() 43 | end).must_equal '[Object, {}]
[Module, {}]' 44 | end 45 | end 46 | 47 | # ViewModel.cell(collection: []) passes generic options to cell. 48 | it { _(Cell::ViewModel.cell("public_test/song", collection: [Object, Module], genre: 'Metal', context: { ready: true }).to_s).must_equal "[Object, {:genre=>\"Metal\", :context=>{:ready=>true}}][Module, {:genre=>\"Metal\", :context=>{:ready=>true}}]" } 49 | 50 | # ViewModel.cell(collection: [], method: :detail) invokes #detail instead of #show. 51 | # TODO: remove in 5.0. 52 | it do 53 | Gem::Deprecate::skip_during do 54 | _(Cell::ViewModel.cell("public_test/song", collection: [Object, Module], method: :detail).to_s).must_equal '* [Object, {}]* [Module, {}]' 55 | end 56 | end 57 | 58 | # ViewModel.cell(collection: []).() invokes #show. 59 | it { _(Cell::ViewModel.cell("public_test/song", collection: [Object, Module]).()).must_equal '[Object, {}][Module, {}]' } 60 | 61 | # ViewModel.cell(collection: []).(:detail) invokes #detail instead of #show. 62 | it { _(Cell::ViewModel.cell("public_test/song", collection: [Object, Module]).(:detail)).must_equal '* [Object, {}]* [Module, {}]' } 63 | 64 | # #cell(collection: [], genre: "Fusion").() doesn't change options hash. 65 | it do 66 | Cell::ViewModel.cell("public_test/song", options = { genre: "Fusion", collection: [Object] }).() 67 | _(options.to_s).must_equal "{:genre=>\"Fusion\", :collection=>[Object]}" 68 | end 69 | 70 | # it do 71 | # content = "" 72 | # Cell::ViewModel.cell("public_test/song", collection: [Object, Module]).each_with_index do |cell, i| 73 | # content += (i == 1 ? cell.(:detail) : cell.()) 74 | # end 75 | 76 | # content.must_equal '[Object, {}]* [Module, {}]' 77 | # end 78 | 79 | # cell(collection: []).join captures return value and joins it for you. 80 | it do 81 | _(Cell::ViewModel.cell("public_test/song", collection: [Object, Module]).join do |cell, i| 82 | i == 1 ? cell.(:detail) : cell.() 83 | end).must_equal '[Object, {}]* [Module, {}]' 84 | end 85 | 86 | # cell(collection: []).join("<") captures return value and joins it for you with join. 87 | it do 88 | _(Cell::ViewModel.cell("public_test/song", collection: [Object, Module]).join(">") do |cell, i| 89 | i == 1 ? cell.(:detail) : cell.() 90 | end).must_equal '[Object, {}]>* [Module, {}]' 91 | end 92 | 93 | # 'join' can be used without a block: 94 | it do 95 | _(Cell::ViewModel.cell( 96 | "public_test/song", collection: [Object, Module] 97 | ).join('---')).must_equal('[Object, {}]---[Module, {}]') 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /test/prefixes_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class BassistCell::FenderCell < Cell::ViewModel 4 | end 5 | 6 | class BassistCell::IbanezCell < BassistCell 7 | end 8 | 9 | class WannabeCell < BassistCell::IbanezCell 10 | end 11 | 12 | # engine: shopify 13 | # shopify/cart/cell 14 | 15 | class EngineCell < Cell::ViewModel 16 | self.view_paths << "/var/engine/app/cells" 17 | end 18 | class InheritingFromEngineCell < EngineCell 19 | end 20 | 21 | class PrefixesTest < MiniTest::Spec 22 | class SingerCell < Cell::ViewModel 23 | end 24 | 25 | class BackgroundVocalsCell < SingerCell 26 | end 27 | 28 | class ChorusCell < BackgroundVocalsCell 29 | end 30 | 31 | class GuitaristCell < SingerCell 32 | def self._local_prefixes 33 | ["stringer"] 34 | end 35 | end 36 | 37 | class BassistCell < SingerCell 38 | def self._local_prefixes 39 | super + ["basser"] 40 | end 41 | end 42 | 43 | 44 | describe "::controller_path" do 45 | it { _(::BassistCell.new(@controller).class.controller_path).must_equal "bassist" } 46 | it { _(SingerCell.new(@controller).class.controller_path).must_equal "prefixes_test/singer" } 47 | end 48 | 49 | describe "#_prefixes" do 50 | it { _(::BassistCell.new(@controller)._prefixes).must_equal ["test/fixtures/bassist"] } 51 | it { _(::BassistCell::FenderCell.new(@controller)._prefixes).must_equal ["app/cells/bassist_cell/fender"] } 52 | it { _(::BassistCell::IbanezCell.new(@controller)._prefixes).must_equal ["test/fixtures/bassist_cell/ibanez", "test/fixtures/bassist"] } 53 | 54 | it { _(SingerCell.new(@controller)._prefixes).must_equal ["app/cells/prefixes_test/singer"] } 55 | it { _(BackgroundVocalsCell.new(@controller)._prefixes).must_equal ["app/cells/prefixes_test/background_vocals", "app/cells/prefixes_test/singer"] } 56 | it { _(ChorusCell.new(@controller)._prefixes).must_equal ["app/cells/prefixes_test/chorus", "app/cells/prefixes_test/background_vocals", "app/cells/prefixes_test/singer"] } 57 | 58 | it { _(GuitaristCell.new(@controller)._prefixes).must_equal ["stringer", "app/cells/prefixes_test/singer"] } 59 | it { _(BassistCell.new(@controller)._prefixes).must_equal ["app/cells/prefixes_test/bassist", "basser", "app/cells/prefixes_test/singer"] } 60 | # it { DrummerCell.new(@controller)._prefixes.must_equal ["drummer", "stringer", "prefixes_test/singer"] } 61 | 62 | # multiple view_paths. 63 | it { _(EngineCell.prefixes).must_equal ["app/cells/engine", "/var/engine/app/cells/engine"] } 64 | it do 65 | _(InheritingFromEngineCell.prefixes).must_equal [ 66 | "app/cells/inheriting_from_engine", "/var/engine/app/cells/inheriting_from_engine", 67 | "app/cells/engine", "/var/engine/app/cells/engine"] 68 | end 69 | 70 | # ::_prefixes is cached. 71 | it do 72 | _(WannabeCell.prefixes).must_equal ["test/fixtures/wannabe", "test/fixtures/bassist_cell/ibanez", "test/fixtures/bassist"] 73 | WannabeCell.instance_eval { def _local_prefixes; ["more"] end } 74 | # _prefixes is cached. 75 | _(WannabeCell.prefixes).must_equal ["test/fixtures/wannabe", "test/fixtures/bassist_cell/ibanez", "test/fixtures/bassist"] 76 | # superclasses don't get disturbed. 77 | _(::BassistCell.prefixes).must_equal ["test/fixtures/bassist"] 78 | end 79 | end 80 | 81 | # it { Record::Cell.new(@controller).render_state(:show).must_equal "Rock on!" } 82 | end 83 | 84 | class InheritViewsTest < MiniTest::Spec 85 | class SlapperCell < Cell::ViewModel 86 | self.view_paths = ['test/fixtures'] # todo: REMOVE! 87 | include Cell::Erb 88 | 89 | inherit_views ::BassistCell 90 | 91 | def play 92 | render 93 | end 94 | end 95 | 96 | class FunkerCell < SlapperCell 97 | end 98 | 99 | it { _(SlapperCell.new(nil)._prefixes).must_equal ["test/fixtures/inherit_views_test/slapper", "test/fixtures/bassist"] } 100 | it { _(FunkerCell.new(nil)._prefixes).must_equal ["test/fixtures/inherit_views_test/funker", "test/fixtures/inherit_views_test/slapper", "test/fixtures/bassist"] } 101 | 102 | # test if normal cells inherit views. 103 | it { _(cell('inherit_views_test/slapper').play).must_equal 'Doo' } 104 | it { _(cell('inherit_views_test/funker').play).must_equal 'Doo' } 105 | 106 | 107 | # TapperCell 108 | class TapperCell < Cell::ViewModel 109 | self.view_paths = ['test/fixtures'] 110 | # include Cell::Erb 111 | 112 | def play 113 | render 114 | end 115 | 116 | def tap 117 | render 118 | end 119 | end 120 | 121 | class PopperCell < TapperCell 122 | end 123 | 124 | # Tapper renders its play 125 | it { _(cell('inherit_views_test/tapper').call(:play)).must_equal 'Dooom!' } 126 | # Tapper renders its tap 127 | it { _(cell('inherit_views_test/tapper').call(:tap)).must_equal 'Tap tap tap!' } 128 | 129 | # Popper renders Tapper's play 130 | it { _(cell('inherit_views_test/popper').call(:play)).must_equal 'Dooom!' } 131 | # Popper renders its tap 132 | it { _(cell('inherit_views_test/popper').call(:tap)).must_equal "TTttttap I'm not good enough!" } 133 | end 134 | -------------------------------------------------------------------------------- /lib/cell/view_model.rb: -------------------------------------------------------------------------------- 1 | require "uber/delegates" 2 | 3 | module Cell 4 | class ViewModel 5 | extend Abstract 6 | abstract! 7 | 8 | extend Uber::InheritableAttr 9 | extend Uber::Delegates 10 | 11 | inheritable_attr :view_paths 12 | self.view_paths = ["app/cells"] 13 | 14 | class << self 15 | def templates 16 | @templates ||= Templates.new # note: this is shared in subclasses. do we really want this? 17 | end 18 | end 19 | 20 | include Prefixes 21 | extend Util 22 | 23 | def self.controller_path 24 | @controller_path ||= util.underscore(name.sub(/Cell$/, '')) 25 | end 26 | 27 | attr_reader :model 28 | 29 | module Helpers 30 | # Constantizes name if needed, call builders and returns instance. 31 | def cell(name, *args, **kws, &block) # classic Rails fuzzy API. 32 | constant = name.is_a?(Class) ? name : class_from_cell_name(name) 33 | constant.(*args, **kws, &block) 34 | end 35 | end 36 | extend Helpers 37 | 38 | class << self 39 | def property(*names) 40 | delegates :model, *names # Uber::Delegates. 41 | end 42 | 43 | # Public entry point. Use this to instantiate cells with builders. 44 | # 45 | # SongCell.(@song) 46 | # SongCell.(collection: Song.all) 47 | def call(model=nil, options={}, &block) 48 | if model.is_a?(Hash) and array = model[:collection] 49 | return Collection.new(array, model.merge(options), self) 50 | end 51 | 52 | build(model, options) 53 | end 54 | 55 | alias build new # semi-public for Cell::Builder 56 | 57 | private 58 | def class_from_cell_name(name) 59 | util.constant_for("#{name}_cell") 60 | end 61 | end 62 | 63 | # Build nested cell in instance. 64 | def cell(name, model=nil, options={}) 65 | context = Context[options[:context], self.context] 66 | 67 | self.class.cell(name, model, options.merge(context: context)) 68 | end 69 | 70 | def initialize(model=nil, options={}) 71 | setup!(model, options) 72 | end 73 | 74 | def context 75 | @options[:context] 76 | end 77 | 78 | # DISCUSS: we could use the same mechanism as TRB::Skills here for speed at runtime? 79 | class Context# < Hash 80 | # Only dup&merge when :context was passed in parent.cell(context: ..) 81 | # Otherwise we can simply pass on the old context. 82 | def self.[](options, context) 83 | return context unless options 84 | context.dup.merge(options) # DISCUSS: should we create a real Context object here, to make it overridable? 85 | end 86 | end 87 | 88 | module Rendering 89 | # Invokes the passed method (defaults to :show) while respecting caching. 90 | # In Rails, the return value gets marked html_safe. 91 | def call(state=:show, *args, **kws, &block) 92 | content = render_state(state, *args, **kws, &block) 93 | content.to_s 94 | end 95 | 96 | # Since 4.1, you get the #show method for free. 97 | def show(&block) 98 | render(&block) 99 | end 100 | 101 | # render :show 102 | def render(options={}, &block) 103 | options = normalize_options(options) 104 | render_to_string(options, &block) 105 | end 106 | 107 | private 108 | def render_to_string(options, &block) 109 | template = find_template(options) 110 | render_template(template, options, &block) 111 | end 112 | 113 | def render_state(*args, **kws, &block) 114 | __send__(*args, **kws, &block) # Ruby 2.7+ 115 | end 116 | 117 | def render_template(template, options, &block) 118 | template.render(self, options[:locals], &block) # DISCUSS: hand locals to layout? 119 | end 120 | 121 | module RubyPre2_7_RenderState 122 | def render_state(*args, **kws, &block) 123 | args = args + [kws] if kws.any? 124 | __send__(*args, &block) 125 | end 126 | end 127 | end 128 | 129 | include Rendering 130 | include Rendering::RubyPre2_7_RenderState if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('2.7.0') 131 | include Inspect 132 | 133 | def to_s 134 | call 135 | end 136 | include Caching 137 | 138 | private 139 | attr_reader :options 140 | 141 | def setup!(model, options) 142 | @model = model 143 | @options = options 144 | # or: create_twin(model, options) 145 | end 146 | 147 | module TemplateFor 148 | def find_template(options) 149 | template_options = template_options_for(options) # imported by Erb, Haml, etc. 150 | # required options: :template_class, :suffix. everything else is passed to the template implementation. 151 | 152 | view = options[:view] 153 | prefixes = options[:prefixes] 154 | suffix = template_options.delete(:suffix) 155 | view = "#{view}.#{suffix}" 156 | 157 | template_for(prefixes, view, template_options) or raise TemplateMissingError.new(prefixes, view) 158 | end 159 | 160 | def template_for(prefixes, view, options) 161 | # we could also pass _prefixes when creating class.templates, because prefixes are never gonna change per instance. not too sure if i'm just assuming this or if people need that. 162 | # Note: options here is the template-relevant options, only. 163 | self.class.templates[prefixes, view, options] 164 | end 165 | end 166 | include TemplateFor 167 | 168 | def normalize_options(options) 169 | options = if options.is_a?(Hash) 170 | options[:view] ||= state_for_implicit_render(options) 171 | options 172 | else 173 | {view: options.to_s} 174 | end 175 | 176 | options[:prefixes] ||= _prefixes 177 | 178 | process_options!(options) 179 | options 180 | end 181 | 182 | # Overwrite #process_options in included feature modules, but don't forget to call +super+. 183 | module ProcessOptions 184 | def process_options!(options) 185 | end 186 | end 187 | include ProcessOptions 188 | 189 | # Computes the view name from the call stack in which `render` was invoked. 190 | def state_for_implicit_render(options) 191 | caller(3, 1)[0].match(/`(\w+)/)[1] 192 | end 193 | 194 | include Layout 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cells 2 | 3 | *View Components for Ruby and Rails.* 4 | 5 | [![Zulip Chat](https://badges.gitter.im/trailblazer/chat.svg)](https://trailblazer.zulipchat.com/login/) 6 | [![TRB Newsletter](https://img.shields.io/badge/TRB-newsletter-lightgrey.svg)](https://trailblazer.to/2.0/newsletter.html) 7 | [![Build 8 | Status](https://travis-ci.org/trailblazer/cells.svg)](https://travis-ci.org/trailblazer/cells) 9 | [![Gem Version](https://badge.fury.io/rb/cells.svg)](http://badge.fury.io/rb/cells) 10 | 11 | ## Overview 12 | 13 | Cells allow you to encapsulate parts of your UI into components into _view models_. View models, or cells, are simple ruby classes that can render templates. 14 | 15 | Nevertheless, a cell gives you more than just a template renderer. They allow proper OOP, polymorphic builders, [nesting](#nested-cells), view inheritance, using Rails helpers, [asset packaging](https://trailblazer.to/2.1/docs/cells.html#cells-rails-asset-pipeline) to bundle JS, CSS or images, simple distribution via gems or Rails engines, encapsulated testing, [caching](#caching), and [integrate with Trailblazer](https://github.com/trailblazer/trailblazer-cells). 16 | 17 | ## Full Documentation 18 | 19 | Cells is part of the Trailblazer framework. [Full documentation](https://trailblazer.to/2.1/docs/cells.html) is available on the project site. 20 | 21 | Cells is completely decoupled from Rails. However, Rails-specific functionality is to be found [here](https://trailblazer.to/2.1/docs/cells.html#cells-rails). 22 | 23 | ## Rendering Cells 24 | 25 | You can render cells anywhere and as many as you want, in views, controllers, composites, mailers, etc. 26 | 27 | Rendering a cell in Rails ironically happens via a helper. 28 | 29 | ```ruby 30 | <%= cell(:comment, @comment) %> 31 | ``` 32 | 33 | This boils down to the following invocation, that can be used to render cells in *any other Ruby* environment. 34 | 35 | ```ruby 36 | CommentCell.(@comment).() 37 | ``` 38 | 39 | You can also pass the cell class in explicitly: 40 | 41 | ```ruby 42 | <%= cell(CommentCell, @comment) %> 43 | ``` 44 | 45 | In Rails you have the same helper API for views and controllers. 46 | 47 | ```ruby 48 | class DashboardController < ApplicationController 49 | def dashboard 50 | @comments = cell(:comment, collection: Comment.recent) 51 | @traffic = cell(:report, TrafficReport.find(1)).() 52 | end 53 | ``` 54 | 55 | Usually, you'd pass in one or more objects you want the cell to present. That can be an ActiveRecord model, a ROM instance or any kind of PORO you fancy. 56 | 57 | ## Cell Class 58 | 59 | A cell is a light-weight class with one or multiple methods that render views. 60 | 61 | ```ruby 62 | class CommentCell < Cell::ViewModel 63 | property :body 64 | property :author 65 | 66 | def show 67 | render 68 | end 69 | 70 | private 71 | def author_link 72 | link_to "#{author.email}", author 73 | end 74 | end 75 | ``` 76 | 77 | Here, `show` is the only public method. By calling `render` it will invoke rendering for the `show` view. 78 | 79 | 80 | ## Logicless Views 81 | 82 | Views come packaged with the cell and can be ERB, Haml, or Slim. 83 | 84 | ```erb 85 |

New Comment

86 | <%= body %> 87 | 88 | By <%= author_link %> 89 | ``` 90 | 91 | The concept of "helpers" that get strangely copied from modules to the view does not exist in Cells anymore. 92 | 93 | Methods called in the view are directly called _on the cell instance_. You're free to use loops and deciders in views, even instance variables are allowed, but Cells tries to push you gently towards method invocations to access data in the view. 94 | 95 | ## File Structure 96 | 97 | In Rails, cells are placed in `app/cells` or `app/concepts/`. Every cell has their own directory where it keeps views, assets and code. 98 | 99 | ``` 100 | app 101 | ├── cells 102 | │ ├── comment_cell.rb 103 | │ ├── comment 104 | │ │ ├── show.haml 105 | │ │ ├── list.haml 106 | ``` 107 | 108 | The discussed `show` view would reside in `app/cells/comment/show.haml`. However, you can set [any set of view paths](#view-paths) you want. 109 | 110 | 111 | ## Invocation Styles 112 | 113 | In order to make a cell render, you have to call the rendering methods. While you could call the method directly, the preferred way is the _call style_. 114 | 115 | ```ruby 116 | cell(:comment, @song).() # calls CommentCell#show. 117 | cell(:comment, @song).(:index) # calls CommentCell#index. 118 | ``` 119 | 120 | The call style respects caching. 121 | 122 | Keep in mind that `cell(..)` really gives you the cell object. In case you want to reuse the cell, need setup logic, etc. that's completely up to you. 123 | 124 | ## Parameters 125 | 126 | You can pass in as many parameters as you need. Per convention, this is a hash. 127 | 128 | ```ruby 129 | cell(:comment, @song, volume: 99, genre: "Jazz Fusion") 130 | ``` 131 | 132 | Options can be accessed via the `@options` instance variable. 133 | 134 | Naturally, you may also pass arbitrary options into the call itself. Those will be simple method arguments. 135 | 136 | ```ruby 137 | cell(:comment, @song).(:show, volume: 99) 138 | ``` 139 | 140 | Then, the `show` method signature changes to `def show(options)`. 141 | 142 | 143 | ## Testing 144 | 145 | A huge benefit from "all this encapsulation" is that you can easily write tests for your components. The API does not change and everything is exactly as it would be in production. 146 | 147 | ```ruby 148 | html = CommentCell.(@comment).() 149 | Capybara.string(html).must_have_css "h3" 150 | ``` 151 | 152 | It is completely up to you how you test, whether it's RSpec, MiniTest or whatever. All the cell does is return HTML. 153 | 154 | [In Rails, there's support](https://trailblazer.to/2.1/docs/cells.html#cells-testing) for TestUnit, MiniTest and RSpec available, along with Capybara integration. 155 | 156 | ## Properties 157 | 158 | The cell's model is available via the `model` reader. You can have automatic readers to the model's fields by using `::property`. 159 | 160 | ```ruby 161 | class CommentCell < Cell::ViewModel 162 | property :author # delegates to model.author 163 | 164 | def author_link 165 | link_to author.name, author 166 | end 167 | end 168 | ``` 169 | 170 | ## HTML Escaping 171 | 172 | Cells per default does no HTML escaping, anywhere. Include `Escaped` to make property readers return escaped strings. 173 | 174 | ```ruby 175 | class CommentCell < Cell::ViewModel 176 | include Escaped 177 | 178 | property :title 179 | end 180 | 181 | song.title #=> "" 182 | Comment::Cell.(song).title #=> <script>Dangerous</script> 183 | ``` 184 | 185 | Properties and escaping are [documented here](https://trailblazer.to/2.1/docs/cells.html#cells-api-html-escaping). 186 | 187 | ## Installation 188 | 189 | Cells runs with any framework. 190 | 191 | ```ruby 192 | gem "cells" 193 | ``` 194 | 195 | For Rails, please use the [cells-rails](https://github.com/trailblazer/cells-rails) gem. It supports Rails >= 4.0. 196 | 197 | ```ruby 198 | gem "cells-rails" 199 | ``` 200 | 201 | Lower versions of Rails will still run with Cells, but you will get in trouble with the helpers. (Note: we use Cells in production with Rails 3.2 and Haml and it works great.) 202 | 203 | Various template engines are supported but need to be added to your Gemfile. 204 | 205 | * [cells-erb](https://github.com/trailblazer/cells-erb) 206 | * [cells-hamlit](https://github.com/trailblazer/cells-hamlit) We strongly recommend using [Hamlit](https://github.com/k0kubun/hamlit) as a Haml replacement. 207 | * [cells-haml](https://github.com/trailblazer/cells-haml) Make sure to bundle Haml 4.1: `gem "haml", github: "haml/haml", ref: "7c7c169"`. Use `cells-hamlit` instead. 208 | * [cells-slim](https://github.com/trailblazer/cells-slim) 209 | 210 | ```ruby 211 | gem "cells-erb" 212 | ``` 213 | 214 | In Rails, this is all you need to do. In other environments, you need to include the respective module into your cells. 215 | 216 | ```ruby 217 | class CommentCell < Cell::ViewModel 218 | include ::Cell::Erb # or Cell::Hamlit, or Cell::Haml, or Cell::Slim 219 | end 220 | ``` 221 | 222 | ## Namespaces 223 | 224 | Cells can be namespaced as well. 225 | 226 | ```ruby 227 | module Admin 228 | class CommentCell < Cell::ViewModel 229 | ``` 230 | 231 | Invocation in Rails would happen as follows. 232 | 233 | ```ruby 234 | cell("admin/comment", @comment).() 235 | ``` 236 | 237 | Views will be searched in `app/cells/admin/comment` per default. 238 | 239 | 240 | ## Rails Helper API 241 | 242 | Even in a non-Rails environment, Cells provides the Rails view API and allows using all Rails helpers. 243 | 244 | You have to include all helper modules into your cell class. You can then use `link_to`, `simple_form_for` or whatever you feel like. 245 | 246 | ```ruby 247 | class CommentCell < Cell::ViewModel 248 | include ActionView::Helpers::UrlHelper 249 | include ActionView::Helpers::CaptureHelper 250 | 251 | def author_link 252 | content_tag :div, link_to(author.name, author) 253 | end 254 | ``` 255 | 256 | As always, you can use helpers in cells and in views. 257 | 258 | You might run into problems with wrong escaping or missing URL helpers. This is not Cells' fault but Rails suboptimal way of implementing and interfacing their helpers. Please open the actionview gem helper code and try figuring out the problem yourself before bombarding us with issues because helper `xyz` doesn't work. 259 | 260 | 261 | ## View Paths 262 | 263 | In Rails, the view path is automatically set to `app/cells/` or `app/concepts/`. You can append or set view paths by using `::view_paths`. Of course, this works in any Ruby environment. 264 | 265 | ```ruby 266 | class CommentCell < Cell::ViewModel 267 | self.view_paths = "lib/views" 268 | end 269 | ``` 270 | 271 | ## Asset Packaging 272 | 273 | Cells can easily ship with their own JavaScript, CSS and more and be part of Rails' asset pipeline. Bundling assets into a cell allows you to implement super encapsulated widgets that are stand-alone. Asset pipeline is [documented here](https://trailblazer.to/2.1/docs/cells.html#cells-rails-asset-pipeline). 274 | 275 | ## Render API 276 | 277 | Unlike Rails, the `#render` method only provides a handful of options you gotta learn. 278 | 279 | ```ruby 280 | def show 281 | render 282 | end 283 | ``` 284 | 285 | Without options, this will render the state name, e.g. `show.erb`. 286 | 287 | You can provide a view name manually. The following calls are identical. 288 | 289 | ```ruby 290 | render :index 291 | render view: :index 292 | ``` 293 | 294 | If you need locals, pass them to `#render`. 295 | 296 | ```ruby 297 | render locals: {style: "border: solid;"} 298 | ``` 299 | 300 | ## Layouts 301 | 302 | Every view can be wrapped by a layout. Either pass it when rendering. 303 | 304 | ```ruby 305 | render layout: :default 306 | ``` 307 | 308 | Or configure it on the class-level. 309 | 310 | ```ruby 311 | class CommentCell < Cell::ViewModel 312 | layout :default 313 | ``` 314 | 315 | The layout is treated as a view and will be searched in the same directories. 316 | 317 | 318 | ## Nested Cells 319 | 320 | Cells love to render. You can render as many views as you need in a cell state or view. 321 | 322 | ```ruby 323 | <%= render :index %> 324 | ``` 325 | 326 | The `#render` method really just returns the rendered template string, allowing you all kind of modification. 327 | 328 | ```ruby 329 | def show 330 | render + render(:additional) 331 | end 332 | ``` 333 | 334 | You can even render other cells _within_ a cell using the exact same API. 335 | 336 | ```ruby 337 | def about 338 | cell(:profile, model.author).() 339 | end 340 | ``` 341 | 342 | This works both in cell views and on the instance, in states. 343 | 344 | 345 | ## View Inheritance 346 | 347 | You can not only inherit code across cell classes, but also views. This is extremely helpful if you want to override parts of your UI, only. It's [documented here](https://trailblazer.to/2.1/docs/cells.html#cells-api-view-inheritance). 348 | 349 | ## Collections 350 | 351 | In order to render collections, Cells comes with a shortcut. 352 | 353 | ```ruby 354 | comments = Comment.all #=> three comments. 355 | cell(:comment, collection: comments).() 356 | ``` 357 | 358 | This will invoke `cell(:comment, comment).()` three times and concatenate the rendered output automatically. 359 | 360 | Learn more [about collections here](https://trailblazer.to/2.1/docs/cells.html#cells-api-collection). 361 | 362 | 363 | ## Builder 364 | 365 | Builders allow instantiating different cell classes for different models and options. They introduce polymorphism into cells. 366 | 367 | ```ruby 368 | class CommentCell < Cell::ViewModel 369 | include ::Cell::Builder 370 | 371 | builds do |model, options| 372 | case model 373 | when Post; PostCell 374 | when Comment; CommentCell 375 | end 376 | end 377 | ``` 378 | 379 | The `#cell` helper takes care of instantiating the right cell class for you. 380 | 381 | ```ruby 382 | cell(:comment, Post.find(1)) #=> creates a PostCell. 383 | ``` 384 | 385 | Learn more [about builders here](https://trailblazer.to/2.1/docs/cells.html#cells-api-builder). 386 | 387 | ## Caching 388 | 389 | For every cell class you can define caching per state. Without any configuration the cell will run and render the state once. In following invocations, the cached fragment is returned. 390 | 391 | ```ruby 392 | class CommentCell < Cell::ViewModel 393 | cache :show 394 | # .. 395 | end 396 | ``` 397 | 398 | The `::cache` method will forward options to the caching engine. 399 | 400 | ```ruby 401 | cache :show, expires_in: 10.minutes 402 | ``` 403 | 404 | You can also compute your own cache key, use dynamic keys, cache tags, and conditionals using `:if`. Caching is documented [here](https://trailblazer.to/2.1/docs/cells.html#cells-api-caching) and in chapter 8 of the [Trailblazer book](http://leanpub.com/trailblazer). 405 | 406 | 407 | ## The Book 408 | 409 | Cells is part of the [Trailblazer project](https://github.com/apotonick/trailblazer). Please [buy my book](https://leanpub.com/trailblazer) to support the development and to learn all the cool stuff about Cells. The book discusses many use cases of Cells. 410 | 411 | 412 | ![](https://raw.githubusercontent.com/apotonick/trailblazer/master/doc/trb.jpg) 413 | 414 | 415 | * Basic view models, replacing helpers, and how to structure your view into cell components (chapter 2 and 4). 416 | * Advanced Cells API (chapter 4 and 6). 417 | * Testing Cells (chapter 4 and 6). 418 | * Cells Pagination with AJAX (chapter 6). 419 | * View Caching and Expiring (chapter 8). 420 | 421 | The book picks up where the README leaves off. Go grab a copy and support us - it talks about object- and view design and covers all aspects of the API. 422 | 423 | ## This is not Cells 3.x! 424 | 425 | Temporary note: This is the README and API for Cells 4. Many things have improved. If you want to upgrade, [follow this guide](https://github.com/apotonick/cells/wiki/From-Cells-3-to-Cells-4---Upgrading-Guide). When in trouble, join the [Zulip channel](https://trailblazer.zulipchat.com/login/). 426 | 427 | ## LICENSE 428 | 429 | Copyright (c) 2007-2020, Nick Sutterer 430 | 431 | Copyright (c) 2007-2008, Solide ICT by Peter Bex and Bob Leers 432 | 433 | Released under the MIT License. 434 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 4.5.0 2 | 3 | * Drop support for rubies prior 2.2.10 4 | 5 | ## 4.1.8 6 | 7 | * Fix ruby warning with undefined instance variable in codebase 8 | 9 | ## 4.1.7 10 | 11 | * `Collection#join` can now be called without a block. 12 | 13 | ## 4.1.6 14 | 15 | * Use `Declarative::Option` and `Declarative::Builder` instead of `uber`'s. This allows removing the `uber` version restriction. 16 | 17 | ## 4.1.5 18 | 19 | * Fix a bug where nested calls of `cell(name, context: {...})` would ignore the new context elements, resulting in the old context being passed on. By adding `Context::[]` the new elements are now properly merged into a **new context hash**. This means that adding elements to the child context won't leak up into the parent context anymore. 20 | 21 | ## 4.1.4 22 | 23 | * Upgrading to Uber 0.1 which handles builders a bit differently. 24 | 25 | ## 4.1.3 26 | 27 | * Load `Uber::InheritableAttr` in `Testing` to fix a bug in `cells-rspec`. 28 | 29 | ## 4.1.2 30 | 31 | * Testing with Rails 5 now works, by removing code the last piece of Rails-code (I know, it sounds bizarre). 32 | 33 | ## 4.1.1 34 | 35 | * Fix rendering of `Collection` where in some environments (Rails), the overridden `#call` method wouldn't work and strings would be escaped. 36 | 37 | ## 4.1.0 38 | 39 | ### API Fix/Changes 40 | 41 | * All Rails code removed. Make sure to use [Cells-rails](https://github.com/trailblazer/cells-rails) if you want the old Rails behavior. 42 | * The `builds` feature is now optional, you have to include `Builder` in your cell. 43 | ```ruby 44 | class CommentCell < Cell::ViewModel 45 | include Cell::Builder 46 | 47 | builds do |..| 48 | ``` 49 | 50 | * A basic, rendering `#show` method is now provided automatically. 51 | * `ViewModel#render` now accepts a block that can be `yield`ed in the view. 52 | * Passing a block to `ViewModel#call` changed. Use `tap` if you want the "old" behavior (which was never official or documented). 53 | ```ruby 54 | Comment::Cell.new(comment).().tap { |cell| } 55 | ``` 56 | The new behavior is to pass that block to your state method. You can pass it on to `render`, and then `yield` it in the template. 57 | 58 | ```ruby 59 | def show(&block) 60 | render &block # yield in show.haml 61 | end 62 | ``` 63 | 64 | Note that this happens automatically in the default `ViewModel#show` method. 65 | * `Concept#cell` now will resolve a concept cell (`Song::Cell`), and not the old-style suffix cell (`SongCell`). The same applies to `Concept#concept`. 66 | 67 | ```ruby 68 | concept("song/cell", song).cell("song/cell/composer") #=> resolves to Song::Cell::Composer 69 | ``` 70 | This decision has been made in regards of the upcoming Cells 5. It simplifies code dramatically, and we consider it unnatural to mix concept and suffix cells in applications. 71 | * In case you were using `@parent_controller`, this doesn't exist anymore (and was never documented, either). Use `context[:controller]`. 72 | * `::self_contained!` is no longer included into `ViewModel`. Please try using `Trailblazer::Cell` instead. If you still need it, here's how. 73 | 74 | ```ruby 75 | class SongCell < Cell::ViewModel 76 | extend SelfContained 77 | self_contained! 78 | ``` 79 | * `Cell::Concept` is deprecated and you should be using the excellent [`Trailblazer::Cell`](https://github.com/trailblazer/trailblazer-cells) class instead, because that's what a concept cell tries to be in an awkward way. The latter is usable without Trailblazer. 80 | 81 | We are hereby dropping support for `Cell::Concept` (it still works). 82 | 83 | * Deprecating `:collection_join` and `:method` for collections. 84 | 85 | ### Awesomeness 86 | 87 | * Introduced the concept of a context object that is being passed to all nested cells. This object is supposed to contain dependencies such as `current_user`, in Rails it contains the "parent_controller" under the `context[:controller]` key. 88 | 89 | Simple provide it as an option when rendering the cell. 90 | 91 | ```ruby 92 | cell(:song, song, context: { current_user: current_user }) 93 | ``` 94 | 95 | The `#context` method allows to access this very hash. 96 | 97 | ```ruby 98 | def role 99 | context[:current_user].admin? "Admin" : "A nobody" 100 | end 101 | ``` 102 | * The `cell` helper now allows to pass in a constant, too. 103 | 104 | ```ruby 105 | cell(Song::Cell, song) 106 | ``` 107 | * New API for `:collection`. If used in views, this happens automatically, but here's how it works now. 108 | 109 | ```ruby 110 | cell(:comment, collection: Comment.all).() # will invoke show. 111 | cell(:comment, collection: Comment.all).(:item) # will invoke item. 112 | cell(:comment, collection: Comment.all).join { |cell, i| cell.show(index: i) } 113 | ``` 114 | Basically, a `Collection` instance is returned that optionally allows to invoke each cell manually. 115 | * Layout cells can now be injected to wrap the original content. 116 | ```ruby 117 | cell(:comment, Comment.find(1), layout: LayoutCell) 118 | ``` 119 | 120 | The LayoutCell will be instantiated and the `show` state invoked. The content cell's content is passed as a block, allowing the layout's view to `yield`. 121 | 122 | This works with `:collection`, too. 123 | 124 | ## 4.0.5 125 | 126 | * Fix `Testing` so you can use Capybara matchers on `cell(:song, collection: [..])`. 127 | 128 | ## 4.0.4 129 | 130 | * `Escaped::property` now properly escapes all passed properties. Thanks @xzo and @jlogsdon! 131 | 132 | ## 4.0.3 133 | 134 | * `Cell::Partial` now does _append_ the global partial path to its `view_paths` instead of using `unshift` and thereby removing possible custom paths. 135 | * Adding `Cell::Translation` which allows using the `#t` helper. Thanks to @johnlane. 136 | * Performance improvement: when inflecting the view name (90% likely to be done) the `caller` is now limited to the data we need, saving memory. Thanks @timoschilling for implementing this. 137 | * In the `concept` helper, we no longer use `classify`, which means you can say `concept("comment/data")` and it will instantiate `Comment::Data` and not `Comment::Datum`. Thanks @firedev! 138 | 139 | ## 4.0.2 140 | 141 | * In Rails, include `ActionView::Helpers::FormHelper` into `ViewModel` so we already have (and pollute our cell with) `UrlHelper` and `FormTagHelper`. Helpers, so much fun. 142 | * Concept cells will now infer their name properly even if the string `Cell` appears twice. 143 | 144 | ## 4.0.1 145 | 146 | * Support forgery protection in `form_tag`. 147 | 148 | ## 4.0.0 149 | 150 | * **Rails Support:** Rails 4.0+ is fully supported, in older versions some form helpers do not work. Let us know how you fixed this. 151 | * **State args:** View models don't use state args. Options are passed into the constructor and saved there. That means that caching callbacks no longer receive arguments as everything is available via the instance itself. 152 | * `ViewModel.new(song: song)` won't automatically create a reader `#song`. You have to configure the cell to use a Struct twin {TODO: document} 153 | * **HTML Escaping:** Escaping only happens for defined `property`s when `Escaped` is included. 154 | * **Template Engines:** There's now _one_ template engine (e.g. ERB or HAML) per cell class. It can be set by including the respective module (e.g. `Cell::Erb`) into the cell class. This happens automatically in Rails. 155 | * **File Naming**. The default filename just uses the engine suffix, e.g. `show.haml`. If you have two different engine formats (e.g. `show.haml` and `show.erb`), use the `format:` option: `render format: :erb`. 156 | If you need to render a specific mime type, provide the filename: `render view: "show.html"`. 157 | * Builder blocks are no longer executed in controller context but in the context they were defined. This is to remove any dependencies to the controller. If you need e.g. `params`, pass them into the `#cell(..)` call. 158 | * Builders are now defined using `::builds`, not `::build`. 159 | 160 | ### Removed 161 | 162 | * `Cell::Rails` and `Cell::Base` got removed. Every cell is `ViewModel` or `Concept` now. 163 | * All methods from `AbstractController` are gone. This might give you trouble in case you were using `helper_method`. You don't need this anymore - every method included in the cell class is a "helper" in the view (it's one and the same method call). 164 | 165 | 166 | ## 4.0.0.rc2 167 | 168 | * Include `#protect_from_forgery?` into Rails cells. It returns false currently. 169 | * Fix `Concept#cell` which now instantiates a cell, not a concept cell. 170 | 171 | ## 4.0.0.rc1 172 | 173 | * Move delegations of `#url_options` etc. to the railtie, which makes it work. 174 | 175 | ## 4.0.0.beta6 176 | 177 | * Removed `ViewModel::template_engine`. This is now done explicitly by including `Cell::Erb`, etc. and happens automatically in a Rails environment. 178 | 179 | ## 4.0.0.beta5 180 | 181 | * Assets bundled in engine cells now work. 182 | * Directory change: Assets like `.css`, `.coffee` and `.js`, no longer have their own `assets/` directory but live inside the views directory of a cell. It turned out that two directories `views/` and `assets/` was too noisy for most users. If you think you have a valid point for re-introducing it, email me, it is not hard to implement. 183 | * When bundling your cell's assets into the asset pipeline, you have to specify the full name of your cell. The names will be constantized. 184 | 185 | ```ruby 186 | config.cells.with_assets = ["song/cell", "user_cell"] #=> Song::Cell, UserCell 187 | ``` 188 | * `ViewModel` is now completely decoupled from Rails and doesn't inherit from AbstractController anymore. 189 | * API change: The controller dependency is now a second-class citizen being passed into the cell via options. 190 | 191 | ```ruby 192 | Cell.new(model, {controller: ..}) 193 | ``` 194 | * Removing `actionpack` from gemspec. 195 | 196 | ## 4.0.0.beta4 197 | 198 | * Fixed a bug when rendering more than once with ERB, the output buffer was being reused. 199 | * API change: ViewModel::_prefixes now returns the "fully qualified" pathes including the view paths, prepended to the prefixes. This allows multiple view paths and basically fixes cells in engines. 200 | * The only public way to retrieve prefixes for a cell is `ViewModel::prefixes`. The result is cached. 201 | 202 | 203 | ## 4.0.0.beta3 204 | 205 | * Introduce `Cell::Testing` for Rspec and MiniTest. 206 | * Add ViewModel::OutputBuffer to be used in Erbse and soon in Haml. 207 | 208 | ## 3.11.2 209 | 210 | * `ViewModel#call` now accepts a block and yields `self` (the cell instance) to it. This is handy to use with `content_for`. 211 | ```ruby 212 | = cell(:song, Song.last).call(:show) do |cell| 213 | content_for :footer, cell.footer 214 | ``` 215 | 216 | ## 3.11.1 217 | 218 | * Override `ActionView::Helpers::UrlHelper#url_for` in Rails 4.x as it is troublesome. That removes the annoying 219 | `arguments passed to url_for can't be handled. Please require routes or provide your own implementation` 220 | exception when using simple_form, form_for, etc with a view model. 221 | 222 | 223 | ## 3.11.0 224 | 225 | * Deprecated `Cell::Rails::ViewModel`, please inherit: `class SongCell < Cell::ViewModel`. 226 | * `ViewModel#call` is now the prefered way to invoke the rendering flow. Without any argument, `call` will run `render_state(:show)`. Pass in any method name you want. 227 | * Added `Caching::Notifications`. 228 | * Added `cell(:song, collection: [song1, song2])` to render collections. This only works with ViewModel (and, of course, Concept, too). 229 | * Added `::inherit_views` to only inherit views whereas real class inheritance would inherit all the dark past of the class. 230 | * `::build_for` removed/privatized/changed. Use `Cell::Base::cell_for` instead. 231 | * `Base::_parent_prefixes` is no longer used, if you override that somewhere in your cells it will break. We have our own implementation for computing the controller's prefixes in `Cell::Base::Prefixes` (simpler). 232 | * `#expire_cell_state` doesn't take symbols anymore, only the real cell class name. 233 | * Remove `Cell::Base.setup_view_paths!` and `Cell::Base::DEFAULT_VIEW_PATHS` and the associated Railtie. I don't know why this code survived 3 major versions, if you wanna set you own view paths just use `Cell::Base.view_paths=`. 234 | * Add `Base::self_contained!`. 235 | * Add `Base::inherit_views`. 236 | 237 | ### Concept 238 | * `#concept` helper is mixed into all views as an alternative to `#cell` and `#render_cell`. Let us know if we should do that conditionally, only. 239 | * Concept cells look for layouts in their self-contained views directory. 240 | * Add generator for Concept cells: `rails g concept Comment` 241 | 242 | 243 | ## 3.10.1 244 | Allow packaging assets for Rails' asset pipeline into cells. This is still experimental but works great. I love it. 245 | 246 | ## 3.10.0 247 | 248 | * API CHANGE: Blocks passed to `::cache` and `::cache ... if: ` no longer receive the cell instance as the first argument. Instead, they're executed in cell instance context. Change your code like this: 249 | ```ruby 250 | cache :show do |cell, options| 251 | cell.version 252 | end 253 | # and 254 | cache :show, if: lambda {|cell, options| .. } 255 | ``` 256 | should become 257 | 258 | ```ruby 259 | cache :show do |options| 260 | version 261 | end 262 | # and 263 | cache :show, if: lambda {|options| .. } 264 | ``` 265 | 266 | Since the blocks are run in cell context, `self` will point to what was `cell` before. 267 | 268 | 269 | * `::cache` doesn't accept a `Proc` instance anymore, only blocks (was undocumented anyway). 270 | * Use [`uber` gem](https://github.com/apotonick/uber) for inheritable class attributes and dynamic options. 271 | 272 | ## 3.9.2 273 | 274 | * Autoload `Cell::Rails::ViewModel`. 275 | * Implement dynamic cache options by allowing lambdas that are executed at render-time - Thanks to @bibendi for this idea. 276 | 277 | ## 3.9.1 278 | 279 | * Runs with Rails 4.1 now. 280 | * Internal changes on `Layouts` to prepare 4.1 compat. 281 | 282 | ## 3.9.0 283 | 284 | * Cells in engines are now recognized under Rails 4.0. 285 | * Introducing @#cell@ and @#cell_for@ to instantiate cells in ActionController and ActionView. 286 | * Adding @Cell::Rails::ViewModel@ as a new "dialect" of working with cells. 287 | * Add @Cell::Base#process_args@ which is called in the initializer to handle arguments passed into the constructor. 288 | * Setting @controller in your @Cell::TestCase@ no longer get overridden by us. 289 | 290 | ## 3.8.8 291 | 292 | * Maintenance release. 293 | 294 | ## 3.8.7 295 | 296 | * Cells runs with Rails 4. 297 | 298 | ## 3.8.6 299 | 300 | * @cell/base@ can now be required without trouble. 301 | * Generated test files now respect namespaced cells. 302 | 303 | ## 3.8.5 304 | 305 | * Added @Cell::Rails::HelperAPI@ module to provide the entire Rails view "API" (quotes on purpose!) in cells running completely outside of Rails. This makes it possible to use gems like simple_form in any Ruby environment, especially interesting for people using Sinatra, webmachine, etc. 306 | * Moved @Caching.expire_cache_key@ to @Rails@. Use @Caching.expire_cache_key_for(key, cache_store, ..)@ if you want to expire caches outside of Rails. 307 | 308 | ## 3.8.4 309 | 310 | * Added @Cell::Rack@ for request-dependent Cells. This is also the new base class for @Cells::Rails@. 311 | * Removed deprecation warning from @TestCase#cell@ as it's signature is not deprecated. 312 | * Added the @base_cell_class@ config option to generator for specifying an alternative base class. 313 | 314 | ## 3.8.3 315 | 316 | * Added @Engines.existent_directories_for@ to prevent Rails 3.0 from crashing when it detects engines. 317 | 318 | ## 3.8.2 319 | 320 | * Engines should work in Rails 3.0 now, too. 321 | 322 | ## 3.8.1 323 | 324 | * Make it work with Rails 3.2 by removing deprecated stuff. 325 | 326 | ## 3.8.0 327 | 328 | * @Cell::Base@ got rid of the controller dependency. If you want the @ActionController@ instance around in your cell, use @Cell::Rails@ - this should be the default in a standard Rails setup. However, if you plan on using a Cell in a Rack middleware or don't need the controller, use @Cell::Base@. 329 | * New API (note that @controller@ isn't the first argument anymore): 330 | ** @Rails.create_cell_for(name, controller)@ 331 | ** @Rails.render_cell_for(name, state, controller, *args)@ 332 | * Moved builder methods to @Cell::Builder@ module. 333 | * @DEFAULT_VIEW_PATHS@ is now in @Cell::Base@. 334 | * Removed the monkey-patch that made state-args work in Rails <= 3.0.3. Upgrade to +3.0.4. 335 | 336 | ## 3.7.1 337 | 338 | * Works with Rails 3.2, too. Hopefully. 339 | 340 | ## 3.7.0 341 | 342 | h3. Changes 343 | * Cache settings using @Base.cache@ are now inherited. 344 | * Removed @opts. 345 | * Removed @#options@ in favor of state-args. If you still want the old behaviour, include the @Deprecations@ module in your cell. 346 | * The build process is now instantly delegated to Base.build_for on the concrete cell class. 347 | 348 | ## 3.6.8 349 | 350 | h3. Changes 351 | * Removed @opts. 352 | * Deprecated @#options@ in favour of state-args. 353 | 354 | ## 3.6.7 355 | 356 | h3. Changes 357 | * Added @view_assigns@ to TestCase. 358 | 359 | ## 3.6.6 360 | 361 | h3. Changes 362 | * Added the @:format@ option for @#render@ which should be used with caution. Sorry for that. 363 | * Removed the useless @layouts/@ view path from Cell::Base. 364 | 365 | ## 3.6.5 366 | 367 | h3. Bugfixes 368 | * `Cell::TestCase#invoke` now properly accepts state-args. 369 | 370 | h3. Changes 371 | * Added the `:if` option to `Base.cache` which allows adding a conditional proc or instance method to the cache definition. If it doesn't return true, caching for that state is skipped. 372 | 373 | 374 | ## 3.6.4 375 | 376 | h3. Bugfixes 377 | * Fixes @ArgumentError: wrong number of arguments (1 for 0)@ in @#render_cell@ for Ruby 1.8. 378 | 379 | 380 | ## 3.6.3 381 | 382 | h3. Bugfixes 383 | * [Rails 3.0] Helpers are now properly included (only once). Thanks to [paneq] for a fix. 384 | * `#url_options` in the Metal module is now delegated to `parent_controller` which propagates global URL setting like relative URLs to your cells. 385 | 386 | h3. Changes 387 | * `cells/test_case` is no longer required as it should be loaded automatically. 388 | 389 | 390 | ## 3.6.2 391 | 392 | h3. Bugfixes 393 | * Fixed cells.gemspec to allow Rails 3.x. 394 | 395 | ## 3.6.1 396 | 397 | h3. Changes 398 | * Added the @:format@ option allowing @#render@ to set different template types, e.g. @render :format => :json@. 399 | 400 | 401 | ## 3.6.0 402 | 403 | h3. Changes 404 | * Cells runs with Rails 3.0 and 3.1. 405 | 406 | 407 | ## 3.5.6 408 | 409 | h3. Changes 410 | * Added a generator for slim. Use it with `-e slim` when generating. 411 | 412 | 413 | ## 3.5.5 414 | 415 | h3. Bugfixes 416 | * The generator now places views of namespaced cells into the correct directory. E.g. `rails g Blog::Post display` puts views to `app/cells/blog/post/display.html.erb`. 417 | 418 | h3. Changes 419 | * Gem dependencies changed, we now require @actionpack@ and @railties@ >= 3.0.0 instead of @rails@. 420 | 421 | 422 | ## 3.5.4 423 | 424 | h3. Bugfixes 425 | * state-args work even if your state method receives optional arguments or default values, like @def show(user, age=18)@. 426 | 427 | h3. Changes 428 | 429 | * Cell::Base.view_paths is now setup in an initializer. If you do scary stuff with view_paths this might lead to scary problems. 430 | * Cells::DEFAULT_VIEW_PATHS is now Cell::Base::DEFAULT_VIEW_PATHS. Note that Cells will set its view_paths to DEFAULT_VIEW_PATHS at initialization time. If you want to alter the view_paths, use Base.append_view_path and friends in a separate initializer. 431 | 432 | 433 | ## 3.5.2 434 | 435 | h3. Bugfixes 436 | * Controller#render_cell now accepts multiple args as options. 437 | 438 | h3. Changes 439 | * Caching versioners now can accept state-args or options from the #render_cell call. This way, you don't have to access #options at all anymore. 440 | 441 | 442 | ## 3.5.1 443 | 444 | * No longer pass an explicit Proc but a versioner block to @Cell.Base.cache@. Example: @cache :show do "v1" end@ 445 | * Caching.cache_key_for now uses @ActiveSupport::Cache.expand_cache_key@. Consequently, a key which used to be like @"cells/director/count/a=1/b=2"@ now is @cells/director/count/a=1&b=2@ and so on. Be warned that this might break your home-made cache expiry. 446 | * Controller#expire_cell_state now expects the cell class as first arg. Example: @expire_cell_state(DirectorCell, :count)@ 447 | 448 | h3. Bugfixes 449 | * Passing options to @render :state@ in views finally works: @render({:state => :list_item}, item, i)@ 450 | 451 | 452 | ## 3.5.0 453 | 454 | h3. Changes 455 | * Deprecated @opts, use #options now. 456 | * Added state-args. State methods can now receive the options as method arguments. This should be the prefered way of parameter exchange with the outer world. 457 | * #params, #request, and #config is now delegated to @parent_controller. 458 | * The generator now is invoked as @rails g cell ...@ 459 | * The `--haml` option is no longer available. 460 | * The `-t` option now is compatible with the rest of rails generators, now it is used as alias for `--test-framework`. Use the `-e` option as an alias of `--template-engine` 461 | Thanks to Jorge Calás Lozano for patching this in the most reasonable manner i could imagine. 462 | * Privatized @#find_family_view_for_state@, @#render_view_for@, and all *ize methods in Cell::Rails. 463 | * New signature: @#render_view_for(state, *args)@ 464 | 465 | ## 3.4.4 466 | 467 | h3. Changes 468 | * Cells.setup now yields Cell::Base, so you can really call append_view_path and friends here. 469 | * added Cell::Base.build for streamlining the process of deciders around #render_cell, "see here":http://nicksda.apotomo.de/2010/12/pragmatic-rails-thoughts-on-views-inheritance-view-inheritance-and-rails-304 470 | * added TestCase#in_view to test helpers in a real cell view. 471 | 472 | 473 | ## 3.4.3 474 | 475 | h3. Changes 476 | * #render_cell now accepts a block which yields the cell instance before rendering. 477 | 478 | h3. Bugfixes 479 | * We no longer use TestTaskWithoutDescription in our rake tasks. 480 | --------------------------------------------------------------------------------