19 |
20 |
21 |
22 | New <%= singular_table_name %>
23 |
--------------------------------------------------------------------------------
/lib/perspectives/forms/text_field.rb:
--------------------------------------------------------------------------------
1 | module Perspectives::Forms
2 | class TextField < Base
3 | param :object, :field
4 |
5 | property(:param_key) { object.class.model_name.param_key }
6 | property(:human_name) { object.class.name.humanize }
7 | property(:field_id) { "#{param_key}_#{field}" }
8 | property(:field_param) do
9 | "#{param_key}[#{field.sub(/\?$/, '')}]"
10 | end
11 |
12 | property(:name) { object.class.human_attribute_name(field) }
13 | property(:value) { object.__send__(field) }
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | # Declare your gem's dependencies in perspectives.gemspec.
4 | # Bundler will treat runtime dependencies like base dependencies, and
5 | # development dependencies will be added by default to the :development group.
6 | gemspec
7 |
8 | # Declare any dependencies that are still in development here instead of in
9 | # your gemspec. These might include edge Rails or gems from your path or
10 | # Git. Remember to move these dependencies to your gemspec before releasing
11 | # your gem to rubygems.org.
12 |
13 | # To use debugger
14 | # gem 'debugger'
15 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/wrap_parameters.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # This file contains settings for ActionController::ParamsWrapper which
4 | # is enabled by default.
5 |
6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array.
7 | ActiveSupport.on_load(:action_controller) do
8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters)
9 | end
10 |
11 | # To enable root element in JSON for ActiveRecord objects.
12 | # ActiveSupport.on_load(:active_record) do
13 | # self.include_root_in_json = true
14 | # end
15 |
--------------------------------------------------------------------------------
/spec/dummy/README.rdoc:
--------------------------------------------------------------------------------
1 | == README
2 |
3 | This README would normally document whatever steps are necessary to get the
4 | application up and running.
5 |
6 | Things you may want to cover:
7 |
8 | * Ruby version
9 |
10 | * System dependencies
11 |
12 | * Configuration
13 |
14 | * Database creation
15 |
16 | * Database initialization
17 |
18 | * How to run the test suite
19 |
20 | * Services (job queues, cache servers, search engines, etc.)
21 |
22 | * Deployment instructions
23 |
24 | * ...
25 |
26 |
27 | Please feel free to use a different markup language if you do not plan to run
28 | rake doc:app.
29 |
--------------------------------------------------------------------------------
/spec/dummy/app/assets/stylesheets/application.css:
--------------------------------------------------------------------------------
1 | /*
2 | * This is a manifest file that'll be compiled into application.css, which will include all the files
3 | * listed below.
4 | *
5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6 | * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path.
7 | *
8 | * You're free to add application-wide styles to this file and they'll appear at the top of the
9 | * compiled file, but it's generally better to create a new file per style scope.
10 | *
11 | *= require_self
12 | *= require_tree .
13 | */
14 |
--------------------------------------------------------------------------------
/lib/generators/perspectives/templates/application.js:
--------------------------------------------------------------------------------
1 | // This is a manifest file that'll be compiled into including all the files listed below.
2 | // Add new JavaScript/Coffee code in separate files in this directory and they'll automatically
3 | // be included in the compiled file accessible from http://example.com/assets/application.js
4 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
5 | // the compiled file.
6 | //
7 | //= require mustache-0.8.1
8 | //= require perspectives
9 | //= require_tree ../../mustaches
10 | //= require perspectives_views
11 | //= require_tree .
12 |
13 | $(function() { $(document).perspectives('a', 'body') })
14 |
--------------------------------------------------------------------------------
/spec/dummy/app/assets/javascripts/application.js:
--------------------------------------------------------------------------------
1 | // This is a manifest file that'll be compiled into application.js, which will include all the files
2 | // listed below.
3 | //
4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts,
5 | // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path.
6 | //
7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
8 | // compiled file.
9 | //
10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details
11 | // about supported directives.
12 | //
13 | //= require_tree .
14 |
--------------------------------------------------------------------------------
/spec/dummy/config/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite version 3.x
2 | # gem install sqlite3
3 | #
4 | # Ensure the SQLite 3 gem is defined in your Gemfile
5 | # gem 'sqlite3'
6 | development:
7 | adapter: sqlite3
8 | database: db/development.sqlite3
9 | pool: 5
10 | timeout: 5000
11 |
12 | # Warning: The database defined as "test" will be erased and
13 | # re-generated from your development database when you run "rake".
14 | # Do not set this db to the same as development or production.
15 | test:
16 | adapter: sqlite3
17 | database: db/test.sqlite3
18 | pool: 5
19 | timeout: 5000
20 |
21 | production:
22 | adapter: sqlite3
23 | database: db/production.sqlite3
24 | pool: 5
25 | timeout: 5000
26 |
--------------------------------------------------------------------------------
/TODO.md:
--------------------------------------------------------------------------------
1 | # TODO
2 | * DONE Files should be a wrapped collection that knows how to render itself
3 | * DONE-ish What to do about flash? (maybe port from RailsGenius?)
4 | * DONE Need a generator that sets up the MustacheCompiler front-end stuff and requires the javascript library
5 | * DONE Generators
6 | * Need cache multiget for nested collections
7 | * Fallbacks for history api in other browsers (some kind of modernizr situation?)
8 | * Examples!
9 | * Tests in multiple ruby/rails versions
10 | * Ruby 1.8 / 1.9 / 2.1
11 | * Rails 2.3, 3.2, 4.0
12 | * Mat's idea of conditionally cached properties
13 | * Have to have a way for cache keys to not be aggregated together (performance optimization if you don't want to load a ton of records)
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/secret_token.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Your secret key is used for verifying the integrity of signed cookies.
4 | # If you change this key, all old signed cookies will become invalid!
5 |
6 | # Make sure the secret is at least 30 characters and all random,
7 | # no regular words or you'll be exposed to dictionary attacks.
8 | # You can use `rake secret` to generate a secure secret key.
9 |
10 | # Make sure your secret_key_base is kept private
11 | # if you're sharing your code publicly.
12 | Dummy::Application.config.secret_key_base = '9391b9284791e3e286b1654e24ba09632094224619c4b9a999b41420b4c7b5b315509c151625eecf0acf3f3c03f54ca78301a6c87eb31a8ae1f27733592209c6'
13 |
--------------------------------------------------------------------------------
/spec/dummy/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # Be sure to restart your server when you modify this file.
2 |
3 | # Add new inflection rules using the following format. Inflections
4 | # are locale specific, and you may define rules for as many different
5 | # locales as you wish. All of these examples are active by default:
6 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
7 | # inflect.plural /^(ox)$/i, '\1en'
8 | # inflect.singular /^(ox)en/i, '\1'
9 | # inflect.irregular 'person', 'people'
10 | # inflect.uncountable %w( fish sheep )
11 | # end
12 |
13 | # These inflection rules are supported but not enabled by default:
14 | # ActiveSupport::Inflector.inflections(:en) do |inflect|
15 | # inflect.acronym 'RESTful'
16 | # end
17 |
--------------------------------------------------------------------------------
/spec/dummy/config/locales/en.yml:
--------------------------------------------------------------------------------
1 | # Files in the config/locales directory are used for internationalization
2 | # and are automatically loaded by Rails. If you want to use locales other
3 | # than English, add the necessary files in this directory.
4 | #
5 | # To use the locales, use `I18n.t`:
6 | #
7 | # I18n.t 'hello'
8 | #
9 | # In views, this is aliased to just `t`:
10 | #
11 | # <%= t('hello') %>
12 | #
13 | # To use a different locale, set it with `I18n.locale`:
14 | #
15 | # I18n.locale = :es
16 | #
17 | # This would use the information in config/locales/es.yml.
18 | #
19 | # To learn more, please read the Rails Internationalization guide
20 | # available at http://guides.rubyonrails.org/i18n.html.
21 |
22 | en:
23 | hello: "Hello world"
24 |
--------------------------------------------------------------------------------
/lib/perspectives/base.rb:
--------------------------------------------------------------------------------
1 | require 'forwardable'
2 | require 'perspectives/templating'
3 | require 'perspectives/properties'
4 | require 'perspectives/memoization'
5 | require 'perspectives/params'
6 | require 'perspectives/context'
7 | require 'perspectives/rendering'
8 | require 'perspectives/caching'
9 |
10 | module Perspectives
11 | class Base
12 | include Templating
13 | include Properties
14 | include Memoization
15 | include Params
16 | include Context
17 | include Rendering
18 | include Caching
19 |
20 | class << self
21 | def inherited(base)
22 | base.__send__(:filename=, caller.first[/^(.*?.rb):\d/, 1])
23 | end
24 |
25 | private
26 |
27 | attr_accessor :filename
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/generators/perspectives/scaffold/templates/form.mustache:
--------------------------------------------------------------------------------
1 |
26 |
--------------------------------------------------------------------------------
/spec/lib/perspectives/properties_spec.rb:
--------------------------------------------------------------------------------
1 | require 'spec_helper'
2 |
3 | describe Perspectives::Properties do
4 | module ::Users
5 | class Properties < Perspectives::Base
6 | param :user
7 |
8 | property(:name) { user.name }
9 |
10 | nested 'profile'
11 | end
12 | end
13 |
14 | module ::Users
15 | class Profile < Perspectives::Base
16 | delegate_property :blog_url, to: :user
17 | end
18 | end
19 |
20 | let(:context) { {} }
21 | let(:name) { 'Andrew Warner' }
22 | let(:blog_url) { 'a-warner.github.io' }
23 | let(:user) { OpenStruct.new :name => name }
24 |
25 | let(:params) { {:user => user} }
26 |
27 | subject { ::Users::Properties.new(context, params) }
28 |
29 | its(:name) { should == 'Andrew Warner' }
30 | its(:profile) { should_not be_nil }
31 | end
32 |
--------------------------------------------------------------------------------
/perspectives.gemspec:
--------------------------------------------------------------------------------
1 | $:.push File.expand_path("../lib", __FILE__)
2 |
3 | # Maintain your gem's version:
4 | require "perspectives/version"
5 |
6 | # Describe your gem and declare its dependencies:
7 | Gem::Specification.new do |s|
8 | s.name = "perspectives"
9 | s.version = Perspectives::VERSION
10 | s.authors = ["Andrew Warner"]
11 | s.email = ["wwarner.andrew@gmail.com"]
12 | s.homepage = "https://github.com/RapGenius/perspectives"
13 | s.summary = "Render shared views on the client OR on the server"
14 | s.description = "Render shared views on the client OR on the server"
15 |
16 | s.files = Dir["{app,config,db,lib,vendor}/**/*", "MIT-LICENSE", "Rakefile", "README.rdoc"]
17 | s.test_files = Dir["spec/**/*"]
18 |
19 | s.add_development_dependency "rails", "~> 4.0.3"
20 | s.add_development_dependency "rspec"
21 | s.add_development_dependency "pry"
22 |
23 | s.add_dependency "mustache", "~> 0.99.5"
24 | s.add_dependency "activesupport"
25 | end
26 |
--------------------------------------------------------------------------------
/spec/dummy/config/application.rb:
--------------------------------------------------------------------------------
1 | require File.expand_path('../boot', __FILE__)
2 |
3 | require 'rails/all'
4 |
5 | Bundler.require(*Rails.groups)
6 | require "perspectives"
7 |
8 | module Dummy
9 | class Application < Rails::Application
10 | # Settings in config/environments/* take precedence over those specified here.
11 | # Application configuration should go into files in config/initializers
12 | # -- all .rb files in that directory are automatically loaded.
13 |
14 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone.
15 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC.
16 | # config.time_zone = 'Central Time (US & Canada)'
17 |
18 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded.
19 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s]
20 | # config.i18n.default_locale = :de
21 | end
22 | end
23 |
24 |
--------------------------------------------------------------------------------
/lib/perspectives/mustache_compiler.rb:
--------------------------------------------------------------------------------
1 | # somewhat ganked from https://github.com/railsware/smt_rails/blob/7d63a3d5c838881690d365f41f45b9082c2611c8/lib/smt_rails/tilt.rb
2 |
3 | require 'tilt'
4 |
5 | module Perspectives
6 | class MustacheCompiler < Tilt::Template
7 | self.default_mime_type = 'application/javascript'
8 |
9 | def prepare
10 | end
11 |
12 | def evaluate(scope, locals, &block)
13 | namespace = "this.#{Perspectives.template_namespace}"
14 |
15 | <<-MustacheTemplate
16 | (function() {
17 | #{namespace} || (#{namespace} = {});
18 | #{namespace}.views || (#{namespace}.views = {})
19 |
20 | var data = #{data.inspect}
21 |
22 | Mustache.parse(data)
23 |
24 | #{namespace}.views[#{scope.logical_path.inspect}] = function(object) {
25 | if (!object){ object = {}; }
26 | return Mustache.render(data, object)
27 | };
28 |
29 | }).call(this);
30 | MustacheTemplate
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/generators/perspectives/scaffold/templates/form.rb:
--------------------------------------------------------------------------------
1 | class <%= controller_class_name %>::Form < Perspectives::Base
2 | param :<%= singular_table_name %>
3 |
4 | property(:submit_to) do
5 | <%= singular_table_name %>.new_record? ? <%= plural_table_name %>_path : <%= singular_table_name %>_path(<%= singular_table_name %>)
6 | end
7 |
8 | property(:submit_method) { !<%= singular_table_name %>.new_record? && 'patch' }
9 |
10 | property(:errors) do
11 | errors = <%= singular_table_name %>.errors
12 | if errors.any?
13 | {
14 | error_count: errors.count,
15 | name: <%= singular_table_name %>.class.name.humanize,
16 | error_messages: object.errors.full_messages.map { |msg| {msg: msg } }
17 | }
18 | end
19 | end
20 |
21 | <% attributes.each do |attribute| -%>
22 | nested 'perspectives/forms/text_field',
23 | property: :<%= attribute.name %>_field,
24 | locals: { object: :<%= singular_table_name %>, field: '<%= attribute.name %>' }
25 |
26 | <% end -%>
27 | end
28 |
--------------------------------------------------------------------------------
/MIT-LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2014 YOURNAME
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/lib/perspectives/templating.rb:
--------------------------------------------------------------------------------
1 | module Perspectives
2 | module Templating
3 | def self.included(base)
4 | base.class_eval do
5 | extend ClassMethods
6 |
7 | delegate :_mustache, :_template_key, to: 'self.class'
8 | end
9 | end
10 |
11 | module ClassMethods
12 | def raise_on_context_miss?
13 | Perspectives.raise_on_context_miss?
14 | end
15 |
16 | def template_path
17 | Perspectives.template_path
18 | end
19 |
20 | def _mustache
21 | return @_mustache if defined?(@_mustache)
22 |
23 | klass = self
24 | @_mustache = Class.new(Mustache) do
25 | self.template_name = klass.to_s.underscore
26 | self.raise_on_context_miss = klass.raise_on_context_miss?
27 | self.template_path = klass.template_path
28 | end
29 | end
30 |
31 | def _template_key
32 | @_template_key ||=
33 | _mustache.template_file.
34 | sub(/^#{Regexp.escape(_mustache.template_path)}\//, '').
35 | chomp(".#{_mustache.template_extension}")
36 | end
37 | end
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/lib/perspectives/memoization.rb:
--------------------------------------------------------------------------------
1 | module Perspectives
2 | module Memoization
3 | def self.included(base)
4 | base.extend(ClassMethods)
5 | end
6 |
7 | module ClassMethods
8 | def property(name, *names, &block)
9 | super.tap { memoize_property(name) if names.empty? }
10 | end
11 |
12 | def memoize_property(prop_name)
13 | raise ArgumentError, "No method #{prop_name}" unless method_defined?(prop_name)
14 |
15 | original_property_method = "_unmemoized_#{prop_name}"
16 | raise ArgumentError, "Already memoized property #{prop_name.inspect}" if method_defined?(original_property_method)
17 |
18 | ivar = "@_memoized_#{prop_name.to_s.sub(/\?\Z/, '_query').sub(/!\Z/, '_bang')}"
19 | alias_method original_property_method, prop_name
20 |
21 | class_eval <<-CODE, __FILE__, __LINE__ + 1
22 | def #{prop_name} # def name
23 | return #{ivar} if defined?(#{ivar}) # return @_memoized_name if defined?(@_memoized_name)
24 | #{ivar} = #{original_property_method} # @_memoized_name = _unmemoized_name
25 | end
26 | CODE
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/spec/dummy/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | Dummy::Application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb.
3 |
4 | # In the development environment your application's code is reloaded on
5 | # every request. This slows down response time but is perfect for development
6 | # since you don't have to restart the web server when you make code changes.
7 | config.cache_classes = false
8 |
9 | # Do not eager load code on boot.
10 | config.eager_load = false
11 |
12 | # Show full error reports and disable caching.
13 | config.consider_all_requests_local = true
14 | config.action_controller.perform_caching = false
15 |
16 | # Don't care if the mailer can't send.
17 | config.action_mailer.raise_delivery_errors = false
18 |
19 | # Print deprecation notices to the Rails logger.
20 | config.active_support.deprecation = :log
21 |
22 | # Raise an error on page load if there are pending migrations
23 | config.active_record.migration_error = :page_load
24 |
25 | # Debug mode disables concatenation and preprocessing of assets.
26 | # This option may cause significant delays in view rendering with a large
27 | # number of complex assets.
28 | config.assets.debug = true
29 | end
30 |
--------------------------------------------------------------------------------
/lib/generators/perspectives/install.rb:
--------------------------------------------------------------------------------
1 | require 'rails/generators'
2 |
3 | module Perspectives
4 | module Generators
5 | class InstallGenerator < ::Rails::Generators::Base
6 | source_root File.expand_path("../templates", __FILE__)
7 | desc "Installs Perspectives and configures the Asset Pipeline"
8 |
9 | def add_assets
10 | js_manifest = 'app/assets/javascripts/application.js'
11 |
12 | if File.exist?(js_manifest)
13 | requirements = <<-REQS.strip
14 | //= require mustache-0.8.1
15 | //= require perspectives
16 | //= require perspectives_views
17 | //= require_tree ../../mustaches
18 | REQS
19 |
20 | gsub_file js_manifest, %r{^//= require turbolinks$}, ''
21 |
22 | insert_into_file js_manifest, "#{requirements}", :after => "jquery_ujs\n"
23 | insert_into_file js_manifest, "\n$(function() { $(document).perspectives('a', 'body') })\n", :after => "//= require_tree .\n"
24 | else
25 | copy_file "application.js", js_manifest
26 | end
27 | end
28 |
29 | def configure_directories
30 | %w(app/mustaches app/perspectives).each do |dir|
31 | empty_directory dir
32 | create_file File.join(dir, '.keep')
33 | end
34 | end
35 | end
36 | end
37 | end
38 |
39 |
--------------------------------------------------------------------------------
/lib/perspectives.rb:
--------------------------------------------------------------------------------
1 | require 'forwardable'
2 | require 'mustache'
3 | require 'active_support/core_ext/string/inflections'
4 | require 'active_support/core_ext/module/delegation'
5 | require 'active_support/core_ext/array/extract_options'
6 | require 'active_support/core_ext/hash/keys'
7 | require 'active_support/core_ext/class/attribute'
8 | require 'perspectives/collection'
9 | require 'perspectives/base'
10 | require 'perspectives/forms'
11 | require 'perspectives/configuration'
12 | require 'perspectives/mustache_compiler'
13 | require 'perspectives/railtie' if defined?(Rails) # TODO: older rails support!
14 |
15 | module Perspectives
16 | class << self
17 | def template_namespace
18 | 'Perspectives'
19 | end
20 |
21 | def configure
22 | yield(configuration)
23 | end
24 |
25 | delegate :cache, :caching?, :template_path, :raise_on_context_miss?, to: :configuration
26 | delegate :expand_cache_key, to: 'ActiveSupport::Cache'
27 |
28 | def resolve_partial_class_name(top_level_view_namespace, name)
29 | return name if name.is_a?(Class) && name < Perspectives::Base
30 |
31 | camelized = name.to_s.camelize
32 |
33 | [top_level_view_namespace, camelized].join('::').constantize
34 | rescue NameError
35 | camelized.constantize
36 | end
37 |
38 | private
39 |
40 | def configuration
41 | @configuration ||= Configuration.new
42 | end
43 | end
44 |
45 | configure do |c|
46 | c.raise_on_context_miss = true
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/spec/dummy/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
48 |
49 |
50 |
51 |
52 |
53 |
We're sorry, but something went wrong.
54 |
55 |
If you are the application owner check the logs for more information.
Maybe you tried to change something you didn't have access to.
55 |
56 |
If you are the application owner check the logs for more information.
57 |
58 |
59 |
--------------------------------------------------------------------------------
/spec/dummy/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
48 |
49 |
50 |
51 |
52 |
53 |
The page you were looking for doesn't exist.
54 |
You may have mistyped the address or the page may have moved.
55 |
56 |
If you are the application owner check the logs for more information.
57 |
58 |
59 |
--------------------------------------------------------------------------------
/lib/generators/perspectives/scaffold/scaffold_generator.rb:
--------------------------------------------------------------------------------
1 | require 'rails/generators'
2 | require 'rails/generators/resource_helpers'
3 |
4 | module Perspectives
5 | module Generators
6 | class ScaffoldGenerator < Rails::Generators::NamedBase
7 | include Rails::Generators::ResourceHelpers
8 |
9 | argument :attributes, type: :array, default: [], banner: "field:type field:type"
10 |
11 | source_root File.expand_path("../templates", __FILE__)
12 |
13 | def create_root_folders
14 | empty_directory mustache_path
15 | empty_directory perspectives_path
16 | end
17 |
18 | def copy_view_files
19 | available_views.each do |view|
20 | template "#{view}.mustache", mustache_path("#{view}.mustache")
21 | template "#{view}.rb", perspectives_path("#{view}.rb")
22 | end
23 | end
24 |
25 | # hook_for :form_builder, :as => :scaffold
26 |
27 | def copy_form_file
28 | filename = 'form.mustache'
29 | template filename, mustache_path(filename)
30 |
31 | filename = 'form.rb'
32 | template filename, perspectives_path(filename)
33 | end
34 |
35 | protected
36 |
37 | def available_views
38 | %w(index tiny show new edit)
39 | end
40 |
41 | def handler
42 | :perspectives
43 | end
44 |
45 | def mustache_path(filename = nil)
46 | File.join(*["app/mustaches", controller_file_path, filename].compact)
47 | end
48 |
49 | def perspectives_path(filename = nil)
50 | File.join(*["app/perspectives", controller_file_path, filename].compact)
51 | end
52 | end
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/spec/dummy/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | Dummy::Application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb.
3 |
4 | # The test environment is used exclusively to run your application's
5 | # test suite. You never need to work with it otherwise. Remember that
6 | # your test database is "scratch space" for the test suite and is wiped
7 | # and recreated between test runs. Don't rely on the data there!
8 | config.cache_classes = true
9 |
10 | # Do not eager load code on boot. This avoids loading your whole application
11 | # just for the purpose of running a single test. If you are using a tool that
12 | # preloads Rails for running tests, you may have to set it to true.
13 | config.eager_load = false
14 |
15 | # Configure static asset server for tests with Cache-Control for performance.
16 | config.serve_static_assets = true
17 | config.static_cache_control = "public, max-age=3600"
18 |
19 | # Show full error reports and disable caching.
20 | config.consider_all_requests_local = true
21 | config.action_controller.perform_caching = false
22 |
23 | # Raise exceptions instead of rendering exception templates.
24 | config.action_dispatch.show_exceptions = false
25 |
26 | # Disable request forgery protection in test environment.
27 | config.action_controller.allow_forgery_protection = false
28 |
29 | # Tell Action Mailer not to deliver emails to the real world.
30 | # The :test delivery method accumulates sent emails in the
31 | # ActionMailer::Base.deliveries array.
32 | config.action_mailer.delivery_method = :test
33 |
34 | # Print deprecation notices to the stderr.
35 | config.active_support.deprecation = :stderr
36 | end
37 |
--------------------------------------------------------------------------------
/lib/perspectives/railtie.rb:
--------------------------------------------------------------------------------
1 | require 'perspectives/controller_additions'
2 | require 'perspectives/responder'
3 | require 'perspectives/active_record'
4 | require 'generators/perspectives/install.rb'
5 | require 'generators/perspectives/scaffold/scaffold_generator.rb'
6 |
7 | module Perspectives
8 | class Railtie < Rails::Railtie
9 | if ::Rails.version.to_s >= "3.1"
10 | config.app_generators.template_engine :perspectives
11 | config.app_generators.templates << File.expand_path('../../generators/perspectives/templates', __FILE__)
12 | else
13 | config.generators.template_engine :perspectives
14 | config.generators.templates << File.expand_path('../../generators/perspectives/templates', __FILE__)
15 | end
16 |
17 | initializer 'perspectives.railtie' do |app|
18 | app.config.autoload_paths += ['app/perspectives']
19 | app.config.watchable_dirs['app/mustaches'] = [:mustache]
20 |
21 | app.config.assets.paths << File.expand_path('../../../vendor/assets/javascripts', __FILE__)
22 |
23 | Perspectives::Base.class_eval do
24 | include ActionView::Helpers
25 | include app.routes.url_helpers
26 | include ERB::Util
27 | include Perspectives::ActiveRecord
28 | end
29 |
30 | Perspectives.configure do |c|
31 | c.template_path = app.root.join('app', 'mustaches')
32 | end
33 |
34 | app.assets.register_engine '.mustache', Perspectives::MustacheCompiler
35 | app.config.assets.paths << Perspectives.template_path
36 |
37 | # TODO: probably bail if we're not in rails3/sprockets land...
38 | # TODO: probably cache asset version in prod?
39 | ActionController::Base.send(:include, Perspectives::ControllerAdditions)
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/spec/dummy/config/routes.rb:
--------------------------------------------------------------------------------
1 | Dummy::Application.routes.draw do
2 | # The priority is based upon order of creation: first created -> highest priority.
3 | # See how all your routes lay out with "rake routes".
4 |
5 | # You can have the root of your site routed with "root"
6 | # root 'welcome#index'
7 |
8 | # Example of regular route:
9 | # get 'products/:id' => 'catalog#view'
10 |
11 | # Example of named route that can be invoked with purchase_url(id: product.id)
12 | # get 'products/:id/purchase' => 'catalog#purchase', as: :purchase
13 |
14 | # Example resource route (maps HTTP verbs to controller actions automatically):
15 | # resources :products
16 |
17 | # Example resource route with options:
18 | # resources :products do
19 | # member do
20 | # get 'short'
21 | # post 'toggle'
22 | # end
23 | #
24 | # collection do
25 | # get 'sold'
26 | # end
27 | # end
28 |
29 | # Example resource route with sub-resources:
30 | # resources :products do
31 | # resources :comments, :sales
32 | # resource :seller
33 | # end
34 |
35 | # Example resource route with more complex sub-resources:
36 | # resources :products do
37 | # resources :comments
38 | # resources :sales do
39 | # get 'recent', on: :collection
40 | # end
41 | # end
42 |
43 | # Example resource route with concerns:
44 | # concern :toggleable do
45 | # post 'toggle'
46 | # end
47 | # resources :posts, concerns: :toggleable
48 | # resources :photos, concerns: :toggleable
49 |
50 | # Example resource route within a namespace:
51 | # namespace :admin do
52 | # # Directs /admin/products/* to Admin::ProductsController
53 | # # (app/controllers/admin/products_controller.rb)
54 | # resources :products
55 | # end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/perspectives/caching.rb:
--------------------------------------------------------------------------------
1 | module Perspectives
2 | module Caching
3 | def self.included(base)
4 | base.extend(ClassMethods)
5 |
6 | base.class_eval do
7 | class_attribute :_cache_key_additions_block
8 | delegate :_class_source_digest, :_mustache_source_digest, to: 'self.class'
9 | end
10 | end
11 |
12 | def render_html
13 | _with_cache('html') { super }
14 | end
15 |
16 | def to_json(options = {})
17 | _with_cache('json') { super }
18 | end
19 |
20 | private
21 |
22 | def _cache
23 | Perspectives.cache
24 | end
25 |
26 | def _expand_cache_key(*args, &block)
27 | Perspectives.expand_cache_key(*args, &block)
28 | end
29 |
30 | def _with_cache(*key_additions)
31 | return yield unless _caching?
32 |
33 | _cache.fetch(_expand_cache_key(_cache_key.concat(key_additions))) { yield }
34 | end
35 |
36 | def _cache_key
37 | return [] unless _caching?
38 |
39 | [].tap do |key|
40 | key << self.class.to_s
41 | key << _mustache_source_digest
42 | key << _class_source_digest
43 | key.concat(Array(instance_eval(&_cache_key_additions_block))) if _cache_key_additions_block
44 | key.concat _dependent_cache_keys
45 | end
46 | end
47 |
48 | def _caching?
49 | Perspectives.caching? && !!_cache_key_additions_block
50 | end
51 |
52 | def _dependent_cache_keys
53 | _nested_perspectives.each_with_object([]) do |property_name, key|
54 | perspectives = __send__(property_name)
55 |
56 | case perspectives
57 | when NilClass
58 | when Array
59 | key.concat(perspectives.map { |p| p.__send__(:_cache_key) }.flatten)
60 | else
61 | key.concat(perspectives.__send__(:_cache_key))
62 | end
63 | end
64 | end
65 |
66 | module ClassMethods
67 | def cache(&block)
68 | raise ArgumentError, "No block given" unless block_given?
69 |
70 | self._cache_key_additions_block = block
71 | end
72 |
73 | def _class_source_digest
74 | @_class_source_digest ||= Digest::MD5.hexdigest(File.read(filename))
75 | end
76 |
77 | def _mustache_source_digest
78 | @_mustache_source_digest ||= Digest::MD5.hexdigest(_mustache.template.source)
79 | end
80 | end
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | perspectives (0.0.2)
5 | activesupport
6 | mustache (~> 0.99.5)
7 |
8 | GEM
9 | remote: https://rubygems.org/
10 | specs:
11 | actionmailer (4.0.4)
12 | actionpack (= 4.0.4)
13 | mail (~> 2.5.4)
14 | actionpack (4.0.4)
15 | activesupport (= 4.0.4)
16 | builder (~> 3.1.0)
17 | erubis (~> 2.7.0)
18 | rack (~> 1.5.2)
19 | rack-test (~> 0.6.2)
20 | activemodel (4.0.4)
21 | activesupport (= 4.0.4)
22 | builder (~> 3.1.0)
23 | activerecord (4.0.4)
24 | activemodel (= 4.0.4)
25 | activerecord-deprecated_finders (~> 1.0.2)
26 | activesupport (= 4.0.4)
27 | arel (~> 4.0.0)
28 | activerecord-deprecated_finders (1.0.3)
29 | activesupport (4.0.4)
30 | i18n (~> 0.6, >= 0.6.9)
31 | minitest (~> 4.2)
32 | multi_json (~> 1.3)
33 | thread_safe (~> 0.1)
34 | tzinfo (~> 0.3.37)
35 | arel (4.0.2)
36 | atomic (1.1.15)
37 | builder (3.1.4)
38 | coderay (1.1.0)
39 | diff-lcs (1.2.5)
40 | erubis (2.7.0)
41 | hike (1.2.3)
42 | i18n (0.6.9)
43 | mail (2.5.4)
44 | mime-types (~> 1.16)
45 | treetop (~> 1.4.8)
46 | method_source (0.8.2)
47 | mime-types (1.25.1)
48 | minitest (4.7.5)
49 | multi_json (1.9.0)
50 | mustache (0.99.5)
51 | polyglot (0.3.4)
52 | pry (0.9.12.6)
53 | coderay (~> 1.0)
54 | method_source (~> 0.8)
55 | slop (~> 3.4)
56 | rack (1.5.2)
57 | rack-test (0.6.2)
58 | rack (>= 1.0)
59 | rails (4.0.4)
60 | actionmailer (= 4.0.4)
61 | actionpack (= 4.0.4)
62 | activerecord (= 4.0.4)
63 | activesupport (= 4.0.4)
64 | bundler (>= 1.3.0, < 2.0)
65 | railties (= 4.0.4)
66 | sprockets-rails (~> 2.0.0)
67 | railties (4.0.4)
68 | actionpack (= 4.0.4)
69 | activesupport (= 4.0.4)
70 | rake (>= 0.8.7)
71 | thor (>= 0.18.1, < 2.0)
72 | rake (10.1.1)
73 | rspec (2.14.1)
74 | rspec-core (~> 2.14.0)
75 | rspec-expectations (~> 2.14.0)
76 | rspec-mocks (~> 2.14.0)
77 | rspec-core (2.14.8)
78 | rspec-expectations (2.14.5)
79 | diff-lcs (>= 1.1.3, < 2.0)
80 | rspec-mocks (2.14.6)
81 | slop (3.5.0)
82 | sprockets (2.12.0)
83 | hike (~> 1.2)
84 | multi_json (~> 1.0)
85 | rack (~> 1.0)
86 | tilt (~> 1.1, != 1.3.0)
87 | sprockets-rails (2.0.1)
88 | actionpack (>= 3.0)
89 | activesupport (>= 3.0)
90 | sprockets (~> 2.8)
91 | thor (0.18.1)
92 | thread_safe (0.2.0)
93 | atomic (>= 1.1.7, < 2)
94 | tilt (1.4.1)
95 | treetop (1.4.15)
96 | polyglot
97 | polyglot (>= 0.3.1)
98 | tzinfo (0.3.39)
99 |
100 | PLATFORMS
101 | ruby
102 |
103 | DEPENDENCIES
104 | perspectives!
105 | pry
106 | rails (~> 4.0.3)
107 | rspec
108 |
--------------------------------------------------------------------------------
/lib/generators/perspectives/templates/rails/scaffold_controller/controller.rb:
--------------------------------------------------------------------------------
1 | <% if namespaced? -%>
2 | require_dependency "<%= namespaced_file_path %>/application_controller"
3 |
4 | <% end -%>
5 | <% module_namespacing do -%>
6 | class <%= controller_class_name %>Controller < ApplicationController
7 | before_action :set_<%= singular_table_name %>, only: [:show, :edit, :update, :destroy]
8 |
9 | perspectives_actions
10 |
11 | # GET <%= route_url %>
12 | def index
13 | respond_with(perspective('<%= plural_table_name %>/index', all_<%= plural_table_name %>: <%= orm_class.all(class_name) %>))
14 | end
15 |
16 | # GET <%= route_url %>/1
17 | def show
18 | respond_with(perspective('<%= plural_table_name %>/show', <%= singular_table_name %>: @<%= singular_table_name %>))
19 | end
20 |
21 | # GET <%= route_url %>/new
22 | def new
23 | respond_with(perspective('<%= plural_table_name %>/new', <%= singular_table_name %>: <%= orm_class.build(class_name) %>))
24 | end
25 |
26 | # GET <%= route_url %>/1/edit
27 | def edit
28 | respond_with(perspective('<%= plural_table_name %>/edit', <%= singular_table_name %>: @<%= singular_table_name %>))
29 | end
30 |
31 | # POST <%= route_url %>
32 | def create
33 | <%= singular_table_name %> = <%= orm_class.build(class_name, "#{singular_table_name}_params") %>
34 |
35 | if <%= orm_instance.save %>
36 | flash[:notice] = <%= "'#{human_name} was successfully created.'" %>
37 |
38 | respond_to do |format|
39 | format.html { redirect_to <%= singular_table_name %> }
40 | format.json { render json: perspective('<%= plural_table_name %>/show', <%= singular_table_name %>: <%= singular_table_name %>), status: :created, location: <%= singular_table_name %> }
41 | end
42 | else
43 | respond_with(perspective('<%= plural_table_name %>/new', <%= singular_table_name %>: <%= singular_table_name %>))
44 | end
45 | end
46 |
47 | # PATCH/PUT <%= route_url %>/1
48 | def update
49 | if @<%= orm_instance.update("#{singular_table_name}_params") %>
50 | flash[:notice] = <%= "'#{human_name} was successfully updated.'" %>
51 |
52 | respond_to do |format|
53 | format.html { redirect_to @<%= singular_table_name %> }
54 | format.json { render json: perspective('<%= plural_table_name %>/show', <%= singular_table_name %>: @<%= singular_table_name %>) }
55 | end
56 | else
57 | respond_with(perspective('<%= plural_table_name %>/edit', <%= singular_table_name %>: @<%= singular_table_name %>))
58 | end
59 | end
60 |
61 | # DELETE <%= route_url %>/1
62 | def destroy
63 | @<%= orm_instance.destroy %>
64 | redirect_to <%= index_helper %>_url, notice: <%= "'#{human_name} was successfully destroyed.'" %>
65 | end
66 |
67 | private
68 | # Use callbacks to share common setup or constraints between actions.
69 | def set_<%= singular_table_name %>
70 | @<%= singular_table_name %> = <%= orm_class.find(class_name, "params[:id]") %>
71 | end
72 |
73 | # Only allow a trusted parameter "white list" through.
74 | def <%= "#{singular_table_name}_params" %>
75 | <%- if attributes_names.empty? -%>
76 | params[<%= ":#{singular_table_name}" %>]
77 | <%- else -%>
78 | params.require(<%= ":#{singular_table_name}" %>).permit(<%= attributes_names.map { |name| ":#{name}" }.join(', ') %>)
79 | <%- end -%>
80 | end
81 | end
82 | <% end -%>
83 |
--------------------------------------------------------------------------------
/spec/dummy/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | Dummy::Application.configure do
2 | # Settings specified here will take precedence over those in config/application.rb.
3 |
4 | # Code is not reloaded between requests.
5 | config.cache_classes = true
6 |
7 | # Eager load code on boot. This eager loads most of Rails and
8 | # your application in memory, allowing both thread web servers
9 | # and those relying on copy on write to perform better.
10 | # Rake tasks automatically ignore this option for performance.
11 | config.eager_load = true
12 |
13 | # Full error reports are disabled and caching is turned on.
14 | config.consider_all_requests_local = false
15 | config.action_controller.perform_caching = true
16 |
17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application
18 | # Add `rack-cache` to your Gemfile before enabling this.
19 | # For large-scale production use, consider using a caching reverse proxy like nginx, varnish or squid.
20 | # config.action_dispatch.rack_cache = true
21 |
22 | # Disable Rails's static asset server (Apache or nginx will already do this).
23 | config.serve_static_assets = false
24 |
25 | # Compress JavaScripts and CSS.
26 | config.assets.js_compressor = :uglifier
27 | # config.assets.css_compressor = :sass
28 |
29 | # Do not fallback to assets pipeline if a precompiled asset is missed.
30 | config.assets.compile = false
31 |
32 | # Generate digests for assets URLs.
33 | config.assets.digest = true
34 |
35 | # Version of your assets, change this if you want to expire all your assets.
36 | config.assets.version = '1.0'
37 |
38 | # Specifies the header that your server uses for sending files.
39 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache
40 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx
41 |
42 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
43 | # config.force_ssl = true
44 |
45 | # Set to :debug to see everything in the log.
46 | config.log_level = :info
47 |
48 | # Prepend all log lines with the following tags.
49 | # config.log_tags = [ :subdomain, :uuid ]
50 |
51 | # Use a different logger for distributed setups.
52 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new)
53 |
54 | # Use a different cache store in production.
55 | # config.cache_store = :mem_cache_store
56 |
57 | # Enable serving of images, stylesheets, and JavaScripts from an asset server.
58 | # config.action_controller.asset_host = "http://assets.example.com"
59 |
60 | # Precompile additional assets.
61 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
62 | # config.assets.precompile += %w( search.js )
63 |
64 | # Ignore bad email addresses and do not raise email delivery errors.
65 | # Set this to true and configure the email server for immediate delivery to raise delivery errors.
66 | # config.action_mailer.raise_delivery_errors = false
67 |
68 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
69 | # the I18n.default_locale when a translation can not be found).
70 | config.i18n.fallbacks = true
71 |
72 | # Send deprecation notices to registered listeners.
73 | config.active_support.deprecation = :notify
74 |
75 | # Disable automatic flushing of the log to improve performance.
76 | # config.autoflush_log = false
77 |
78 | # Use default logging formatter so that PID and timestamp are not suppressed.
79 | config.log_formatter = ::Logger::Formatter.new
80 | end
81 |
--------------------------------------------------------------------------------
/lib/perspectives/properties.rb:
--------------------------------------------------------------------------------
1 | module Perspectives
2 | module Properties
3 | CantUseLambdas = Class.new(StandardError)
4 |
5 | def self.included(base)
6 | base.class_eval do
7 | extend ClassMethods
8 | class_attribute :_properties, :_nested_perspectives
9 |
10 | self._properties = []
11 | self._nested_perspectives = []
12 | end
13 | end
14 |
15 | private
16 |
17 | def _property_map
18 | _properties.each_with_object({}) do |p, h|
19 | h[p] = __send__(p)
20 |
21 | if h[p].is_a?(Proc)
22 | raise CantUseLambdas, "You cannot use the lambda mustache behavior if you want to render on the client...it's not portable!"
23 | end
24 | end
25 | end
26 |
27 | def _resolve_partial_class_name(name)
28 | Perspectives.resolve_partial_class_name(self.class.to_s.split('::').first, name)
29 | end
30 |
31 | module ClassMethods
32 | def property(name, *names, &block)
33 | unless names.empty?
34 | raise ArgumentError, "Can't define multiple properties and pass a block" if block_given?
35 | return names.push(name).each(&public_method(:property))
36 | end
37 |
38 | self._properties += [name]
39 |
40 | unless method_defined?(name)
41 | raise ArgumentError, "No method #{name} and no block given" unless block_given?
42 |
43 | define_method(name, &block)
44 | end
45 | end
46 |
47 | def nested(name, args = {}, &block)
48 | locals, options = args, {}
49 |
50 | if args[:locals]
51 | locals = args[:locals]
52 | options = args.except(:locals)
53 | end
54 |
55 | _setup_nested(name, locals, options, &block)
56 | end
57 |
58 | def nested_collection(name, *args, &block)
59 | options = args.extract_options!
60 | collection = options.fetch(:collection, args.first)
61 | raise ArgumentError, "You must either pass in a collection, or pass a collection option" unless collection
62 |
63 | _setup_nested(name, options.fetch(:locals, {}), options.merge!(:collection => collection), &block)
64 | end
65 |
66 | def delegate_property(*props)
67 | delegate *props
68 | opts = props.pop
69 |
70 | prop_names = props
71 |
72 | if opts[:prefix]
73 | prefix = opts[:prefix] == true ? opts[:to] : opts[:prefix]
74 | prop_names = prop_names.map { |n| "#{prefix}_#{n}" }
75 | end
76 |
77 | prop_names.each(&public_method(:property))
78 | end
79 |
80 | private
81 |
82 | def _setup_nested(name, locals, options, &block)
83 | name_str, name_sym = name.to_s, name.to_sym
84 |
85 | prop_name = options.fetch(:property, _default_property_name(name_str, options)).to_sym
86 |
87 | unless block_given? || method_defined?(prop_name)
88 | local_procs = locals.each_with_object({}) { |(k, v), h| h[k.to_sym] = v.respond_to?(:to_proc) ? v.to_proc : proc { v } }
89 | nested_klass_ivar = :"@_#{name_str.underscore.gsub('/', '__')}_klass"
90 |
91 | define_method(prop_name) do
92 | klass =
93 | if self.class.instance_variable_defined?(nested_klass_ivar)
94 | self.class.instance_variable_get(nested_klass_ivar)
95 | else
96 | self.class.instance_variable_set(nested_klass_ivar, _resolve_partial_class_name(name))
97 | end
98 |
99 | if options[:unless]
100 | return if instance_exec(self, &options[:unless])
101 | elsif options[:if]
102 | return unless instance_exec(self, &options[:if])
103 | end
104 |
105 | realized_locals = local_procs.each_with_object({}) { |(k, v), h| h[k] = instance_exec(self, &v) }
106 |
107 | if options.key?(:collection)
108 | collection = instance_exec(self, &options[:collection])
109 | return unless collection.present?
110 |
111 | as = options.fetch(:as, collection.first.class.base_class.name.downcase).to_sym
112 | Collection.new(collection.map { |o| klass.new(context, realized_locals.merge(as => o)) })
113 | else
114 | klass.new(context, realized_locals)
115 | end
116 | end
117 | end
118 |
119 | property(prop_name, &block)
120 | self._nested_perspectives += [prop_name]
121 | end
122 |
123 | def _default_property_name(name_str, options)
124 | name = name_str.split('/').last
125 | name = name.pluralize if options.key?(:collection)
126 | name
127 | end
128 | end
129 | end
130 | end
131 |
--------------------------------------------------------------------------------
/vendor/assets/javascripts/perspectives.js:
--------------------------------------------------------------------------------
1 | (function($, window, document, undefined) {
2 | window.Perspectives = window.Perspectives || {}
3 |
4 | var renderTemplateData = function(data) {
5 | var view = {}
6 |
7 | for(var key in data) {
8 | if (!data.hasOwnProperty(key)) continue
9 |
10 | if ($.isArray(data[key])) {
11 | view[key] = $.map(data[key], function(value) {
12 | var new_value = $.extend(!!'deep_copy', {}, value)
13 |
14 | if (value['_template_key']) {
15 | new_value['to_s'] = new_value['to_html'] = renderTemplateData(value)
16 | }
17 |
18 | return new_value
19 | })
20 |
21 | view[key].toString = function() { return $.map(this, function(value) { return value.to_html }).join('') }
22 | } else if (data[key] && typeof data[key] === 'object' && data[key]['_template_key']) {
23 | view[key] = renderTemplateData(data[key])
24 | } else {
25 | view[key] = data[key]
26 | }
27 | }
28 |
29 | view['to_s'] = function() { toString() }
30 |
31 | return Perspectives.views[data._template_key](view)
32 | }
33 |
34 | // pretty much ganked from pjax...
35 | var locationReplace = function(url) {
36 | window.history.replaceState(null, "", "#")
37 | window.location.replace(url)
38 | }
39 |
40 | var perspectivesVersion = function() {
41 | return $('meta').filter(function() {
42 | var name = $(this).attr('http-equiv')
43 | return name && name.toUpperCase() === 'X-PERSPECTIVES-VERSION'
44 | }).attr('content')
45 | }
46 |
47 | var renderResponse = function(options) {
48 | var $globalContainer = globalPerspectivesContainer(),
49 | $container = $(options.container).length ? $(options.container) : $globalContainer
50 | console.time('perspectives rendering')
51 |
52 | var version = perspectivesVersion() || ''
53 | if (version.length && version !== xhr.getResponseHeader('X-Perspectives-Version')) {
54 | locationReplace(options.href)
55 | return false
56 | }
57 |
58 | var $rendered = $(renderTemplateData(options.json))
59 |
60 | $container.html($rendered)
61 |
62 | if (!options.noPushState) {
63 | window.history.pushState({container: globalPerspectivesContainer().selector}, options.href, options.href)
64 | }
65 |
66 | $(document).trigger('perspectives:load', options.xhr)
67 |
68 | console.timeEnd('perspectives rendering')
69 | }
70 |
71 | var globalPerspectivesContainer = function() {
72 | return $('[data-global-perspectives-target]')
73 | }
74 |
75 | var handlePerspectivesClick = function(container) {
76 | var $this = $(this)
77 |
78 | navigate({
79 | href: this.href,
80 | container: $this.attr('data-perspectives-target'),
81 | fullPage: !!$this.attr('data-perspectives-full-page'),
82 | element: $this
83 | })
84 |
85 | return false
86 | }
87 |
88 | var navigate = function(options) {
89 | var $element = $(options.element || document)
90 |
91 | $.ajax({
92 | method: 'GET',
93 | url: options.href,
94 | dataType: 'json',
95 | headers: { 'x-perspectives-full-page': !!options.fullPage }
96 | }).success(function(json, status, xhr) {
97 | $element.trigger('perspectives:response', {
98 | json: json,
99 | status: status,
100 | xhr: xhr,
101 | href: options.href,
102 | container: options.container,
103 | noPushState: options.noPushState
104 | })
105 | })
106 | }
107 |
108 | $(document).on('perspectives:response', function(e, options) { renderResponse(options) })
109 |
110 | $(document).on('ajax:success', function(event, data, status, xhr) {
111 | if (!xhr.getResponseHeader('Content-Type').match(/json/i)) return
112 |
113 | var $form = $(event.target),
114 | $globalContainer = globalPerspectivesContainer(),
115 | href = xhr.getResponseHeader('Location') || $form.attr('action'),
116 | container = $form.attr('data-perspectives-target')
117 |
118 | $form.trigger('perspectives:response', {
119 | json: data,
120 | status: status,
121 | xhr: xhr,
122 | href: href,
123 | container: container
124 | })
125 |
126 | return false
127 | })
128 |
129 | $(window).on('popstate.perspectives', function(event) {
130 | var originalEvent = event.originalEvent
131 | if(originalEvent && originalEvent.state && originalEvent.state.container) {
132 | navigate({
133 | href: window.location.href,
134 | container: originalEvent.state.container,
135 | fullPage: true,
136 | noPushState: true
137 | })
138 | }
139 | })
140 |
141 | $.fn.perspectives = function(selector, container) {
142 | $(container).attr('data-global-perspectives-target', true)
143 |
144 | $(this).on('click', selector, function() {
145 | return handlePerspectivesClick.bind(this)(container)
146 | })
147 | }
148 |
149 | Perspectives.renderTemplateData = Perspectives.render = renderTemplateData
150 | Perspectives.navigate = navigate
151 | Perspectives.renderResponse = renderResponse
152 | })(jQuery, window, document)
153 |
--------------------------------------------------------------------------------
/lib/perspectives/controller_additions.rb:
--------------------------------------------------------------------------------
1 | module Perspectives
2 | module ControllerAdditions
3 | def self.included(base)
4 | base.before_filter :set_perspectives_version
5 | base.helper_method :assets_meta_tag
6 | base.class_attribute :perspectives_enabled_actions
7 | delegate :perspectives_enabled_actions, to: 'self.class'
8 | base.helper_method :perspective
9 |
10 | base.class_attribute :perspectives_wrapping
11 | base.perspectives_wrapping = []
12 |
13 | base.extend(ClassMethods)
14 |
15 | delegate 'resolve_perspective_class_name', to: 'self.class'
16 | end
17 |
18 | private
19 |
20 | unless defined?(ActionController::Responder)
21 | def respond_to(*mimes, &block)
22 | return super if block_given? || mimes.many? || !mimes.first.is_a?(Perspectives::Base)
23 |
24 | perspectives_object = mimes.first
25 | perspectives_object = wrap_perspective(perspectives_object) if wrap_perspective?
26 |
27 | super() do |format|
28 | format.html { render text: perspectives_object.to_html, layout: :default }
29 | format.json { render json: perspectives_object }
30 | end
31 | end
32 | end
33 |
34 | def perspective(name, params_or_options = {})
35 | if params_or_options.key?(:context) || params_or_options.key?(:params)
36 | params = params_or_options.fetch(:params, {})
37 | context = params_or_options.fetch(:context, default_context)
38 | else
39 | context = default_context
40 | params = params_or_options
41 | end
42 |
43 | resolve_perspective_class_name(name).new(context, params)
44 | end
45 |
46 | def respond_with(*resources, &block)
47 | return super unless wrap_perspective? && resources.first.is_a?(Perspectives::Base)
48 |
49 | wrapped = wrap_perspective(resources.shift)
50 |
51 | super(*resources.unshift(wrapped), &block)
52 | end
53 |
54 | def default_context
55 | {}
56 | end
57 |
58 | def assets_version
59 | Rails.application.assets.index.each_file.to_a.map { |f| File.new(f).mtime }.max.to_i
60 | end
61 |
62 | def assets_meta_tag
63 | view_context.content_tag(:meta, nil, :'http-equiv' => 'x-perspectives-version', content: assets_version)
64 | end
65 |
66 | def set_perspectives_version
67 | response.headers['X-Perspectives-Version'] = assets_version.to_s
68 | end
69 |
70 | def perspectives_enabled_action?
71 | action_enabled_by?(perspectives_enabled_actions)
72 | end
73 |
74 | def perspectives_wrapper
75 | return unless perspectives_enabled_action? && (request.headers['X-Perspectives-Full-Page'].to_s == 'true' || !request.xhr?)
76 |
77 | perspectives_wrapping.find do |_, options|
78 | next unless action_enabled_by?(options)
79 |
80 | if options[:unless].present?
81 | !options[:unless].call(self)
82 | elsif options[:if].present?
83 | options[:if].call(self)
84 | else
85 | true
86 | end
87 | end
88 | end
89 | alias_method :wrap_perspective?, :perspectives_wrapper
90 |
91 | def wrap_perspective(unwrapped_perspective)
92 | perspective_klass, options = *perspectives_wrapper
93 | perspective_klass.new(unwrapped_perspective.context, options[:args].call(self, unwrapped_perspective))
94 | end
95 |
96 | def action_enabled_by?(options)
97 | return false if options.nil?
98 |
99 | action = action_name.to_s
100 |
101 | if options[:except]
102 | !options[:except].include?(action)
103 | elsif options[:only]
104 | options[:only].include?(action)
105 | else
106 | true
107 | end
108 | end
109 |
110 | module ClassMethods
111 | def perspectives_actions(options = {})
112 | self.perspectives_enabled_actions = options.slice(:only, :except).each_with_object({}) do |(k, v), h|
113 | h[k] = Array(v).map(&:to_s)
114 | end
115 |
116 | respond_to :html, :json, options
117 | self.responder = Perspectives::Responder
118 | end
119 |
120 | def wrapped_with(perspective, options = {})
121 | perspective_klass = resolve_perspective_class_name(perspective)
122 |
123 | options[:only] = Array(options[:only]).map(&:to_s) if options[:only]
124 | options[:except] = Array(options[:except]).map(&:to_s) if options[:except]
125 |
126 | options[:if] ||= lambda { |c| c.params[perspective_klass.id_param].present? }
127 | options[:args] ||= lambda do |controller, perspective|
128 | {
129 | perspective_klass.active_record_klass.name.underscore => perspective_klass.active_record_klass.find(controller.params[perspective_klass.id_param]),
130 | options.fetch(:as, controller_name.underscore.singularize) => perspective
131 | }
132 | end
133 |
134 | self.perspectives_wrapping += [[perspective_klass, options]]
135 | end
136 |
137 | def resolve_perspective_class_name(name)
138 | Perspectives.resolve_partial_class_name(controller_name.camelize, name)
139 | end
140 | end
141 | end
142 | end
143 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Perspectives
2 |
3 | Render views on the client OR on the server. Perspectives breaks traditional Rails views into
4 | a logic-less Mustache template and a "Perspective", which allows you to render views either on
5 | the client or on the server. Building up a thick client that shares the rendering stack
6 | with the server allows sites to be SEO friendly and render HTML from deep links on the server
7 | for a great client experience, while also incrementally rendering parts of the page if the
8 | user already has the site loaded in a browser.
9 |
10 | Perspectives was debuted at [RailsConf 2014](https://www.youtube.com/watch?v=WAN_P1m76GQ).
11 |
12 | ## Getting Started
13 |
14 | In your Gemfile:
15 |
16 | ```ruby
17 | gem 'perspectives'
18 | ```
19 |
20 | Run the installer:
21 |
22 | ```sh
23 | $ rails generate perspectives:install
24 | ```
25 |
26 | Scaffold a resource if you want an example:
27 |
28 | ```sh
29 | $ rails generate scaffold post title:string body:text
30 | ```
31 |
32 | ## Usage
33 |
34 | ### Vanilla perspectives
35 |
36 | Perspectives live in `app/perspectives`. If you have a perspective called `app/perspectives/users/show.rb`,
37 | then it will render the corresponding template from `app/mustaches/users/show.mustache`. For example, the following
38 | perspective:
39 |
40 | ```ruby
41 | # app/perspectives/users/show.rb
42 | class Users::Show < Perspectives::Base
43 | property(:name) { 'Andrew' }
44 | end
45 | ```
46 |
47 | and template
48 |
49 | ```mustache
50 |
51 | Hello, {{name}}!
52 | ```
53 |
54 | would render "`Hello, Andrew!`". To render it yourself, you could write:
55 |
56 | ```ruby
57 | Users::Show.new.to_html
58 | Users::Show.new.to_json
59 | ```
60 |
61 | In order for a property to be available in the Mustache template, you have to explicitly mark it
62 | as a property in a perspective. For example:
63 |
64 | ```ruby
65 | class Users::Show < Perspectives::Base
66 | property(:name) { 'Andrew' } # declare a property
67 |
68 | def another_property
69 | 'something else'
70 | end
71 | property :another_property # mark a method as a property
72 | end
73 | ```
74 |
75 | If you expect certain inputs, you define those are "params". For example:
76 |
77 | ```ruby
78 | class Users::Show < Perspectives::Base
79 | param :user # expects to be passed a "user" object, available as "user"
80 | param :admin, allow_nil: true # can be optionally passed an "admin" param
81 | end
82 | ```
83 |
84 | All perspectives also get passed a "context" object when being created. For example, to
85 | initialize the above object, we might write:
86 |
87 | ```ruby
88 | user = User.find(params[:id])
89 | context = {current_user: current_user}
90 | Users::Show.new(context, user: user)
91 | ```
92 |
93 | Which would make a `current_user` method available in the perspective. (any key in the context
94 | hash automatically becomes a method on the perspective)
95 |
96 | When you render a perspective in a controller, the easiest way is to write:
97 |
98 | ```ruby
99 | class UsersController < ApplicationController
100 | perspectives_actions only: :show # sets up the responder
101 |
102 | def show
103 | user = User.find(param[:id])
104 |
105 | respond_with(perspective('users/show', user: user))
106 | end
107 | end
108 | ```
109 |
110 | The `respond_with` call is what figures out whether we want to return JSON or HTML to the client.
111 |
112 | The default context is just an empty hash; if you want to change that, you can override the
113 | `default_context` method in any controller, e.g.:
114 |
115 | ```ruby
116 | class ApplicationController < ActionController::Base
117 | def default_context
118 | {current_user: current_user}
119 | end
120 | end
121 | ```
122 |
123 | ### Nested Perspectives
124 |
125 | If you want to render a perspective from another perspective, it's simple! For example:
126 |
127 | ```ruby
128 | class Users::Show < Perspectives::Base
129 | param :user
130 |
131 | property(:name) { user.name }
132 |
133 | nested 'avatar', user: :user
134 | # will render Users::Avatar, passing "user" as a parameter, and make an "avatar"
135 | # method availabe in the mustache template
136 | end
137 | ```
138 |
139 | ```mustache
140 |
141 | {{{avatar}}}
142 | {{name}}
143 |
144 | ```
145 |
146 | What about rendering a collection? Also simple!
147 |
148 | ```ruby
149 | class Projects::Show < Perspectives::Base
150 | property :project
151 |
152 | property(:title) { project.title }
153 |
154 | nested_collection 'tasks/show',
155 | collection: proc { project.tasks },
156 | property: :tasks
157 |
158 | # makes a "tasks" property available which is the list of tasks
159 | end
160 | ```
161 |
162 | ```mustache
163 |
{{title}}
164 | {{{tasks}}}
165 | ```
166 |
167 | ### Macros
168 |
169 | Perspectives also provides some nice macros to remove repeat code. For example,
170 | `delegate_property` exposes a method from an object as a property:
171 |
172 | ```ruby
173 | class Users::Show < Perspectives::Base
174 | param :user
175 | delegate_property :name, :email, to: :user
176 | # makes name, email properties available
177 | end
178 | ```
179 |
180 | ### Caching
181 |
182 | Since Perspectives know about their dependent Perspectives via the `nested` and
183 | `nested_collection` macros above, russian doll caching is trivial. To set that up,
184 | just write:
185 |
186 | ```ruby
187 | class Users::Show < Perspectives::Base
188 | cache { user } # uses "user" as the cache key
189 | end
190 | ```
191 |
192 | The Perspective cache will expire if the `user` changes, OR if the `users.mustache` template
193 | changes, OR if the `Users::Show` perspective changes. (or if any `nested` Perspective changes)
194 |
195 | ### Client javascript
196 |
197 | Perspectives has basically the same javascript API as [PJAX](https://github.com/defunkt/jquery-pjax),
198 | and adds this line automatically to application.js if you use the `rails g perspectives:install`:
199 |
200 | ```javascript
201 | $(function() { $(document).perspectives('a', 'body') })
202 | ```
203 |
204 | That line says "intercept every click on 'a' tags", and request Perspectives JSON from
205 | the server. Then render the resulting template, and replace the content of `$('body')` with the
206 | result of rendering. If you want to use a different container, you could do something like:
207 |
208 | ```javascript
209 | $(function() { $(document).perspectives('a', '#mycontainer') })
210 | ```
211 |
212 | which would replace `$('#mycontainer')` instead of `$('body')`. If you did that, you would probably
213 | also want a line like this in your `application_controller.rb`:
214 |
215 | ```ruby
216 | layout lambda { |controller| !controller.request.xhr? && 'application' }
217 | ```
218 |
219 | which will not render the layout at all if the request is made via xhr.
220 |
221 | ### Render into different containers
222 |
223 | If you want to render a response into a container other than the default you set up, you can set
224 | `'data-perspectives-target'` on an 'a' tag or a form. For example:
225 |
226 | ```html
227 | Andrew Warner
228 | ```
229 |
230 | Which will render the response into the `$('#viewing-user')` element. This might remind you of the PJAX
231 | API.
232 |
233 | You can also set `data-perspectives-target` on a form, which will render the response from the server
234 | into the target element on `ajax:success`.
235 |
236 | ### Events
237 |
238 | More events TK, but, when perspectives receives a JSON response from the server, it triggers an event
239 | on the element (usually an anchor tag) which triggered the request, called "perspectives:response"
240 |
241 | You can listen to this event and handle it as follows:
242 |
243 | ```javascript
244 | $('a').on('perspectives:response', function(e, options) {
245 | // options has keys:
246 | // json: (the json response)
247 | // status: (response status)
248 | // xhr: (the xhr),
249 | // href: (requested href)
250 | // container: (the rendering container)
251 |
252 | // the default behavior of this event is
253 | Perspectives.renderResponse(options)
254 |
255 | // but you can do whatever you want
256 | // (don't forget to stopPropagation if you don't want the
257 | // default behavior to occur)
258 | })
259 | ```
260 |
261 | Perspectives also listens to the `'ajax:success'` event on forms, and renders the response from
262 | the server.
263 |
264 | ### Assets version
265 |
266 | Just like PJAX, Perspectives should re-render the entire page if the assets have changed in some
267 | material way. If you just deployed your site, for example, we want to force everyone to reload the
268 | entire page!
269 |
270 | To configure asset checking, just add the following to your application layout:
271 |
272 | ```erb
273 | <%= assets_meta_tag %>
274 | ```
275 |
276 | If you're using Rails, Perspectives will set a response header which is the mtime of the most
277 | recently updated asset file. Perspectives will do a full page reload if the assets have changed.
278 |
279 | ### More examples please!
280 |
281 | For a full example app, check out [Rails Genius](https://github.com/RapGenius/railsgenius),
282 | an app that I built to demonstrate Perspectives for Rails Conf. Rails Genius allows you to read
283 | and write inline annotations on RailsConf talk abstracts.
284 |
285 | (just like it's older sibling, [Rap Genius](http://rapgenius.com))
286 |
287 | ### Ruby version/framework support
288 |
289 | Right now, the easiest way to use perspectives is with Rails 3.2+ / Rails 4, and Ruby 1.9.3+.
290 |
291 | In theory, it should work with Rails 2, although that's not tested, and you have to do some more
292 | work to set everything up. For setup stuff, check out `lib/perspectives/railtie.rb` to see what gets
293 | set up in later version of Rails. The other big different is that, in Rails 2, you'll want to use
294 | `respond_to` instead of `respond_with` (although that part should "just work")
295 |
296 | ## Other benefits
297 |
298 | Besides shared rendering environments between the client and server, and easy-to-implement russian doll caching,
299 | Perspectives also force you to write views "the right way." Views in Perspectives world have a nice separation
300 | of concerns, where Mustache templates deal simply with laying out data in markup, and Perspectives deal with
301 | your business logic.
302 |
303 | This separation of concerns makes testing a whole lot easier than testing in ERB land, since Perspectives are
304 | just ruby objects. While you'd have to render an ERB template and inspect its output in order to testing it,
305 | Perspectives can just be created and individual logic sections tested.
306 |
307 | ## Philosophy
308 |
309 | The core idea behind perspectives if that, if we use Mustache templates for templating, we can
310 | render them either on the client or on the server. We can break the typical Rails ERB/HAML views
311 | into one template, written in Mustache, which doesn't allow arbitrary code, and a "perspective" object,
312 | written in Ruby, which holds the logic needed to generate a hash which can be used to render a Mustache
313 | template.
314 |
315 | Since the Mustache template never communicates directly with the perspective, when a client makes a
316 | request to our site, we can build the hash of properties with a perspective, and then either render it
317 | on the server in the case that the client is a web crawler or a user visiting the page for the first time,
318 | OR, in the case that the client already has a browser instance loaded up, we can simply return the JSON
319 | hash to the client and let them render or update the page however they want.
320 |
321 | If the user already has a page on the site loaded, then serving the JSON necessary to render a template
322 | is much better than rendering it on the server and sending back an HTML fragment. If the server sends back
323 | HTML, and the client wants to do something besides immediately render, then it would have to inspect the HTML
324 | fragment from the response and yank out the information it wants. HTML is too brittle to rely upon for those
325 | purposes! Instead, forcing the separation between the data needed to render a template and the layout/markup
326 | in the template itself means that we're automatically building a JSON API as we're building out our site.
327 |
328 | ## TODO
329 |
330 | There are some key things that are needed in order to make this library TRULY shine. The main thing is an
331 | easy-to-use javascript library on the client that can be used to create client-only behavior. (such as transitioning
332 | between pages, client-only behavior, etc) The ideally integration would be with some kind of existing library
333 | like backbone.js, ember.js, or angular.js. With a front end "shell" over the client-side rendering side of this, we
334 | could easily add client-only features without duplicating views and other business logic in the browser.
335 |
336 | ## License
337 |
338 | MIT
339 |
--------------------------------------------------------------------------------
/vendor/assets/javascripts/mustache-0.8.1.js:
--------------------------------------------------------------------------------
1 | /*!
2 | * mustache.js - Logic-less {{mustache}} templates with JavaScript
3 | * http://github.com/janl/mustache.js
4 | */
5 |
6 | /*global define: false*/
7 |
8 | (function (root, factory) {
9 | if (typeof exports === "object" && exports) {
10 | factory(exports); // CommonJS
11 | } else {
12 | var mustache = {};
13 | factory(mustache);
14 | if (typeof define === "function" && define.amd) {
15 | define(mustache); // AMD
16 | } else {
17 | root.Mustache = mustache; //