Permalink
Browse files

first public version of logged exceptions plugin

git-svn-id: http://svn.techno-weenie.net/projects/plugins/exception_logger@1271 567b1171-46fb-0310-a4c9-b4bef9110e78
  • Loading branch information...
0 parents commit e33986d958396902eacc878ebd72e7539802f815 technoweenie committed Jun 24, 2006
4 README
@@ -0,0 +1,4 @@
+ExceptionLogger
+===============
+
+Description goes here
22 Rakefile
@@ -0,0 +1,22 @@
+require 'rake'
+require 'rake/testtask'
+require 'rake/rdoctask'
+
+desc 'Default: run unit tests.'
+task :default => :test
+
+desc 'Test the exception_logger plugin.'
+Rake::TestTask.new(:test) do |t|
+ t.libs << 'lib'
+ t.pattern = 'test/**/*_test.rb'
+ t.verbose = true
+end
+
+desc 'Generate documentation for the exception_logger plugin.'
+Rake::RDocTask.new(:rdoc) do |rdoc|
+ rdoc.rdoc_dir = 'rdoc'
+ rdoc.title = 'ExceptionLogger'
+ rdoc.options << '--line-numbers' << '--inline-source'
+ rdoc.rdoc_files.include('README')
+ rdoc.rdoc_files.include('lib/**/*.rb')
+end
28 assets/exception_logger.js
@@ -0,0 +1,28 @@
+ExceptionLogger = {
+ filters: ['exception_names', 'controller_actions', 'date_ranges'],
+ setPage: function(num) {
+ $('page').value = num;
+ $('query-form').onsubmit();
+ },
+
+ setFilter: function(context, name) {
+ var filterName = context + '_filter'
+ $(filterName).value = ($F(filterName) == name) ? '' : name;
+ this.deselect(context, filterName);
+ $('page').value = '1';
+ $('query-form').onsubmit();
+ },
+
+ deselect: function(context, filterName) {
+ $$('#' + context + ' a').each(function(a) {
+ var value = $(filterName) ? $F(filterName) : null;
+ a.className = (value && (a.getAttribute('title') == value || a.innerHTML == value)) ? 'selected' : '';
+ });
+ }
+}
+
+Event.observe(window, 'load', function() {
+ ExceptionLogger.filters.each(function(context) {
+ $(context + '_filter').value = '';
+ });
+});
292 assets/style.css
@@ -0,0 +1,292 @@
+body
+{
+ margin:0;
+ padding:0;
+ background:#000;
+ font-family:'Lucida Grande', Arial, Helvetica, sans-serif;
+ font-size:1.0em;
+ color:white;
+}
+
+#container
+{
+ xwidth:95%;
+ margin:0 auto;
+ min-width:800px;
+}
+
+#left
+{
+ xfloat:left;
+ xwidth:76%;
+ margin-right:235px;
+
+}
+
+#left .page
+{
+ font-size:0.9em;
+ background:white;
+ border:2px solid #999;
+ border-width:0 2px 2px 0;
+ padding:25px;
+ xmargin-bottom:1em;
+ color:black;
+}
+
+#right
+{
+ margin-top:1em;
+ float:right;
+ xwidth:22%;
+ font-size:0.9em;
+ width:200px;
+ margin-right:1.25em;
+}
+
+#right hr
+{
+ border:0;
+ border-top:1px solid #222;
+}
+
+#search
+{
+ margin-top:2em;
+ background:#111;
+ padding:10px 5px;
+ border:1px solid #222;
+ border-width:1px 0;
+}
+
+
+ul.filters
+{
+ list-style-type:none;
+ padding:0;
+ margin:0;
+ font-size:0.9em;
+}
+ul.filters li { margin-bottom:0.2em;}
+
+
+ul.filters a,
+ul.filters a:visited {
+ display:block;
+ padding:1px 7px;
+ color:#fff;
+ xbackground-color:#fff;
+ text-decoration:none;
+}
+
+ul.filters a:hover
+{
+ background:#666;
+ color:white;
+}
+
+ul.filters a.selected,
+ul.filters a.selected:visited,
+ul.filters a:active {
+ color:gold;
+ background-color:#333;
+ text-decoration:none;
+ font-weight:bold;
+}
+
+
+onclick a:hover
+{
+ color:#fff;
+ text-decoration:none;
+}
+
+#exceptions table
+{
+ width:99%;
+ border-collapse:collapse;
+}
+
+td
+{
+ padding:5px 10px;
+ xborder:1px solid #ddd;
+}
+
+#backtrace
+{
+ overflow:auto;
+ font-family:"Bitstream Vera Sans Mono", "Monaco", "Courier", monospace;;
+ font-size:0.85em;
+ margin-top:0.25em;
+}
+
+h2
+{
+ background-color:#ddd;
+ font-size:0.8em;
+ padding:3px 10px;
+}
+
+form {margin:0;}
+
+h3 {
+ font-size:0.8em;
+ color:#ddd;
+ background:#222;
+ padding:3px 7px;
+}
+
+div.date
+{
+ color:#666;
+ font-size:0.8em;
+}
+
+h1
+{
+ margin-top:0.25em;
+ font-size:1.25em;
+ padding-bottom:5px;
+ border-bottom:2px solid #ddd;
+}
+
+h1 span
+{
+ color:#aaa;
+ font-weight:normal;
+}
+
+a
+{
+ color:#369;
+ text-decoration:none;
+}
+a:hover
+{
+ color:blue;
+ text-decoration:underline;
+}
+
+th
+{
+ text-align:left;
+ xbackground:#333;
+ xcolor:gold;
+ font-size:0.75em;
+ padding:2px 10px;
+}
+
+tr { xcursor:pointer; }
+
+tr.eor td
+{
+ background:#e7e7e7;
+
+}
+
+/*
+tr:hover td,
+tr.eor:hover td
+{
+ background:#333;
+ color:white;
+}
+tr:hover td a,
+tr.eor:hover td a { color:gold; }
+*/
+
+.message
+{
+ font-size:0.8em;
+}
+
+a.util
+{
+ color:#c00;
+ font-size:0.7em;
+}
+
+.pipe
+{
+ font-size:0.75em;
+ color:#999;
+}
+
+.tools { float:right; }
+
+.time
+{
+ color:#666;
+ font-size:0.75em;
+ xvertical-align:top;
+}
+
+
+.expclass
+{
+ xcolor:#999;
+}
+.expclass a
+{
+ font-size:0.9em;
+}
+
+tr.deleted td {
+ color:#aaa;
+ text-decoration: line-through;
+}
+tr.deleted td a { color:#aaa; }
+
+.pages { float:right; margin-right:1em; }
+.pages a { text-decoration:underline; }
+.pages-bottom { border-top:2px solid #ddd; text-align:right; float:none;
+ padding-top:0.4em;
+ margin-top:0.4em;
+ padding-right:1em;
+ margin-right:0;
+ }
+
+/* right */
+
+#right h4
+{
+ font-size:0.75em;
+ xbackground:#171717;
+ xbackground:#333;
+ color:#999;
+ padding:3px 5px;
+ margin-bottom:0.5em;
+ font-weight:normal;
+}
+
+/* tabs */
+
+ul.tabs
+{
+ list-style-type:none;
+ padding:0;
+ margin:1em 0;
+ float:left;
+}
+ul.tabs li { float:left; display:inline; }
+ul.tabs li a
+{
+ font-size:0.8em;
+ padding:3px 7px;
+ margin-right:1em;
+ text-decoration:none;
+ color:black;
+}
+
+ul.tabs li a:hover
+{
+ background:#666;
+ color:white;
+}
+ul.tabs li.selected a
+{
+ background:black;
+ color:white;
+}
+
14 generators/exception_migration/USAGE
@@ -0,0 +1,14 @@
+Description:
+ The exception migration generator creates a migration for the logged exceptions model.
+
+ The generator takes a migration name as its argument. The migration name may be
+ given in CamelCase or under_score. 'add_exception_table' is the default.
+
+ The generator creates a migration class in db/migrate prefixed by its number
+ in the queue.
+
+Example:
+ ./script/generate exception_migration add_exception_table
+
+ With 4 existing migrations, this will create an AddExceptionTable migration in the
+ file db/migrate/5_add_exception_table.rb
14 generators/exception_migration/exception_migration_generator.rb
@@ -0,0 +1,14 @@
+class ExceptionMigrationGenerator < Rails::Generator::NamedBase
+ attr_reader :exception_table_name
+ def initialize(runtime_args, runtime_options = {})
+ @exception_table_name = (runtime_args.length < 2 ? 'logged_exceptions' : runtime_args[1]).tableize
+ runtime_args << 'add_exception_table' if runtime_args.empty?
+ super
+ end
+
+ def manifest
+ record do |m|
+ m.migration_template 'migration.rb', 'db/migrate'
+ end
+ end
+end
18 generators/exception_migration/templates/migration.rb
@@ -0,0 +1,18 @@
+class <%= class_name %> < ActiveRecord::Migration
+ def self.up
+ create_table "<%= exception_table_name %>", :force => true do |t|
+ t.column :exception_class, :string
+ t.column :controller_name, :string
+ t.column :action_name, :string
+ t.column :message, :string
+ t.column :backtrace, :text
+ t.column :environment, :text
+ t.column :request, :text
+ t.column :created_at, :datetime
+ end
+ end
+
+ def self.down
+ drop_table "<%= exception_table_name %>"
+ end
+end
1 init.rb
@@ -0,0 +1 @@
+# Include hook code here
1 install.rb
@@ -0,0 +1 @@
+# Install hook code here
90 lib/exception_loggable.rb
@@ -0,0 +1,90 @@
+require 'ipaddr'
+
+# Copyright (c) 2005 Jamis Buck
+#
+# Permission is hereby granted, free of charge, to any person obtaining
+# a copy of this software and associated documentation files (the
+# "Software"), to deal in the Software without restriction, including
+# without limitation the rights to use, copy, modify, merge, publish,
+# distribute, sublicense, and/or sell copies of the Software, and to
+# permit persons to whom the Software is furnished to do so, subject to
+# the following conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+module ExceptionLoggable
+ def self.included(target)
+ target.extend(ClassMethods)
+ end
+
+ module ClassMethods
+ def consider_local(*args)
+ local_addresses.concat(args.flatten.map { |a| IPAddr.new(a) })
+ end
+
+ def local_addresses
+ addresses = read_inheritable_attribute(:local_addresses)
+ unless addresses
+ addresses = [IPAddr.new("127.0.0.1")]
+ write_inheritable_attribute(:local_addresses, addresses)
+ end
+ addresses
+ end
+
+ def exception_data(deliverer=self)
+ if deliverer == self
+ read_inheritable_attribute(:exception_data)
+ else
+ write_inheritable_attribute(:exception_data, deliverer)
+ end
+ end
+ end
+
+ def local_request?
+ return false
+ remote = IPAddr.new(request.remote_ip)
+ !self.class.local_addresses.detect { |addr| addr.include?(remote) }.nil?
+ end
+
+ def render_404
+ respond_to do |type|
+ type.html { render :file => "#{RAILS_ROOT}/public/404.html", :status => "404 Not Found" }
+ type.all { render :nothing => true, :status => "404 Not Found" }
+ end
+ end
+
+ def render_500
+ respond_to do |type|
+ type.html { render :file => "#{RAILS_ROOT}/public/500.html", :status => "500 Error" }
+ type.all { render :nothing => true, :status => "500 Error" }
+ end
+ end
+
+ def rescue_action_in_public(exception)
+ case exception
+ when ActiveRecord::RecordNotFound, ActionController::UnknownController, ActionController::UnknownAction
+ render_404
+
+ else
+ render_500
+
+ deliverer = self.class.exception_data
+ data = case deliverer
+ when nil then {}
+ when Symbol then send(deliverer)
+ when Proc then deliverer.call(self)
+ end
+
+ LoggedException.create_from_exception(self, exception)
+ end
+ end
+
+end
62 lib/logged_exception.rb
@@ -0,0 +1,62 @@
+class LoggedException < ActiveRecord::Base
+ class << self
+ def create_from_exception(controller, exception)
+ create! \
+ :exception_class => exception.class.name,
+ :controller_name => controller.controller_name,
+ :action_name => controller.action_name,
+ :message => exception.message.inspect,
+ :backtrace => exception.backtrace,
+ :request => controller.request
+ end
+
+ def find_exception_class_names
+ connection.select_values "SELECT DISTINCT exception_class FROM #{table_name} ORDER BY exception_class"
+ end
+
+ def find_exception_controllers_and_actions
+ find(:all, :select => "DISTINCT controller_name, action_name", :order => "controller_name, action_name").collect(&:controller_action)
+ end
+ end
+
+ def backtrace=(backtrace)
+ if backtrace.is_a?(String)
+ write_attribute :backtrace, backtrace
+ else
+ re = Regexp.new(/^#{Regexp.escape(rails_root)}/)
+ write_attribute(:backtrace, backtrace.collect { |line| Pathname.new(line.gsub(re, "[RAILS_ROOT]")).cleanpath.to_s } * "\n")
+ end
+ end
+
+ def request=(request)
+ if request.is_a?(String)
+ write_attribute :request, request
+ else
+ max = request.env.keys.max { |a,b| a.length <=> b.length }
+ env = request.env.keys.sort.inject [] do |env, key|
+ env << '* ' + ("%*-s: %s" % [max.length, key, request.env[key].to_s.strip])
+ end
+ write_attribute(:environment, (env << "* Process: #{$$}" << "* Server : #{`hostname -s`.chomp}") * "\n")
+
+ write_attribute(:request, [
+ "* URL: #{request.protocol}#{request.env["HTTP_HOST"]}#{request.request_uri}",
+ "* Parameters: #{request.parameters.inspect}",
+ "* Rails Root: #{rails_root}"
+ ] * "\n")
+ end
+ end
+
+ def controller_action
+ "#{controller_name.camelcase}/#{action_name}"
+ end
+
+ private
+ def sanitize_backtrace(trace)
+ re = Regexp.new(/^#{Regexp.escape(rails_root)}/)
+ trace.map { |line| Pathname.new(line.gsub(re, "[RAILS_ROOT]")).cleanpath.to_s }
+ end
+
+ def rails_root
+ @rails_root ||= Pathname.new(RAILS_ROOT).cleanpath.to_s
+ end
+end
41 lib/logged_exceptions_controller.rb
@@ -0,0 +1,41 @@
+class LoggedExceptionsController < ActionController::Base
+ self.template_root = File.join(RAILS_ROOT, 'vendor/plugins/exception_logger/views')
+ layout nil
+
+ def index
+ @exception_names = LoggedException.find_exception_class_names
+ @controller_actions = LoggedException.find_exception_controllers_and_actions
+ query
+ end
+
+ def query
+ conditions = []
+ parameters = []
+ unless params[:query].blank?
+ conditions << 'message LIKE ?'
+ parameters << "%#{params[:query]}%"
+ end
+ unless params[:date_ranges_filter].blank?
+ conditions << 'created_at >= ?'
+ parameters << params[:date_ranges_filter].to_f.days.ago.utc
+ end
+ unless params[:exception_names_filter].blank?
+ conditions << 'exception_class = ?'
+ parameters << params[:exception_names_filter]
+ end
+ unless params[:controller_actions_filter].blank?
+ conditions << 'controller_name = ? AND action_name = ?'
+ parameters += params[:controller_actions_filter].split('/').collect(&:downcase)
+ end
+ @exception_pages, @exceptions = paginate :logged_exceptions, :order => 'created_at desc', :per_page => 30,
+ :conditions => conditions.empty? ? nil : parameters.unshift(conditions * ' and ')
+ end
+
+ def show
+ @exc = LoggedException.find params[:id]
+ end
+
+ def destroy
+ LoggedException.destroy params[:id]
+ end
+end
15 lib/logged_exceptions_helper.rb
@@ -0,0 +1,15 @@
+module LoggedExceptionsHelper
+ def filtered?
+ [:query, :date_ranges_filter, :exception_names_filter, :controller_actions_filter].any? { |p| params[p] }
+ end
+
+ def pagination_remote_links(paginator, options={}, html_options={})
+ name = options[:name] || ActionController::Pagination::DEFAULT_OPTIONS[:name]
+ params = (options[:params] || ActionController::Pagination::DEFAULT_OPTIONS[:params]).clone
+
+ pagination_links_each(paginator, options) do |n|
+ params[name] = n
+ link_to_function n.to_s, "ExceptionLogger.setPage(#{n})"
+ end
+ end
+end
4 tasks/exception_logger_tasks.rake
@@ -0,0 +1,4 @@
+# desc "Explaining what the task does"
+# task :exception_logger do
+# # Task goes here
+# end
8 test/exception_logger_test.rb
@@ -0,0 +1,8 @@
+require 'test/unit'
+
+class ExceptionLoggerTest < Test::Unit::TestCase
+ # Replace this with your real tests.
+ def test_this_plugin
+ flunk
+ end
+end
1 uninstall.rb
@@ -0,0 +1 @@
+# Uninstall hook code here
79 views/logged_exceptions/_exceptions.rhtml
@@ -0,0 +1,79 @@
+<div id="exceptions">
+
+<% if @exception_pages.page_count>1 %>
+<div class="pages">
+Pages: <strong><%= pagination_remote_links @exception_pages, :params => { :action => :index } %></strong>
+</div>
+<% end %>
+
+<h1>Exceptions <%= "<span>(filtered)</span>" if filtered? %> </h1>
+
+<table cellspacing="0">
+
+ <thead style="display:none;">
+ <tr>
+ <th>Exception</th>
+ <th>Date</th>
+<!--
+ <th></th>
+
+ <th><%= link_to_function "All", "$$('#exceptions table input').each(function(a){a.checked= (a.checked ? '' : 'checked'); });" %></th>
+-->
+ </tr>
+ </thead>
+
+ <tbody>
+ <% exceptions.each do |exc| -%>
+ <tr id="exception-<%= exc.id %>" class="<%= cycle("eor", "") %>">
+ <td>
+<div class="expclass">
+
+<%= link_to_remote "#{exc.exception_class} in #{exc.controller_action}", :url => { :action => 'show', :id => exc } %></div>
+<span class="message"><%=h exc.message %></span>
+
+</td>
+ <td nowrap=nowrap class="time">
+<!--
+
+-->
+<%
+if Date.today==exc.created_at.to_date
+ if exc.created_at > Time.now - 4.hours
+ %>
+ <%= time_ago_in_words(exc.created_at).gsub(/about /,"~ ") %> ago
+ <% else %>
+ Today, <%= exc.created_at.strftime("%l:%M %p") %>
+ <% end %>
+<% else %>
+<%= exc.created_at.strftime("%b %d, %Y") %>
+<% end %>
+</td>
+
+
+<!--
+ <td style="padding-right:0;">
+ <input type="checkbox" />
+ </td>
+-->
+ <td><%= link_to_remote 'Delete', {:url => { :action => 'destroy', :id => exc }} , :class => "util" %></td>
+
+
+ </tr>
+ <% end -%>
+ </tbody>
+
+</table>
+
+<!--
+<%= submit_tag "Delete", :style => "float:right;"%>
+<br />
+-->
+
+<% if @exception_pages.page_count>1 %>
+<div class="pages pages-bottom">
+Pages: <strong><%= pagination_remote_links @exception_pages, :params => { :action => :index } %></strong>
+</div>
+<% end %>
+
+
+</div> <!-- #exceptions -->
40 views/logged_exceptions/_show.rhtml
@@ -0,0 +1,40 @@
+<div class="tools">
+<%= link_to_remote 'Delete', {:url => { :action => 'destroy', :id => @exc }} , :class => "util" %>
+<span class="pipe">|</span>
+<%= link_to_function "Close", "$('showpage').style.display='none';", :class => "util" %>
+
+</div>
+
+<div class="date">
+ <%= @exc.created_at.strftime("%A, %b %d, %Y at %l:%M %p") %>
+</div>
+<h1>
+<%= @exc.exception_class %> in <%= @exc.controller_name.camelcase %>/<%= @exc.action_name %>
+</h1>
+
+<p class="intro">
+<%= @exc.message %>
+</p>
+
+
+<ul class="tabs">
+ <li class="selected"><a href="">Backtrace</a></li>
+ <li><a href="">Environment</a></li>
+ <li><a href="">Request</a></li>
+</ul>
+
+<br style="clear:left;" />
+
+<h2>Backtrace</h2>
+
+<div id="backtrace">
+<%=h(@exc.backtrace).gsub(/\n/,"<br />") %>
+</div>
+
+<h2>Environment</h2>
+
+<%= textilize(@exc.environment) %>
+
+<h2>Request</h2>
+
+<%= textilize(@exc.request) %>
2 views/logged_exceptions/destroy.rjs
@@ -0,0 +1,2 @@
+page["exception-#{params[:id]}"].addClassName('deleted')
+page[:showpage].hide
80 views/logged_exceptions/index.rhtml
@@ -0,0 +1,80 @@
+<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+ <title>Logged Exceptions</title>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8" />
+ <%= javascript_include_tag 'prototype','effects' %>
+ <script type="text/javascript">
+<%= IO.read(File.join(RAILS_ROOT, 'vendor/plugins/exception_logger/assets/exception_logger.js')) %>
+ </script>
+ <style type="text/css">
+<%= IO.read(File.join(RAILS_ROOT, 'vendor/plugins/exception_logger/assets/style.css')) %>
+ </style>
+</head>
+<body>
+<div id="container">
+
+
+<div id="right">
+
+<h3>Filters</h3>
+
+<h4>Exception</h4>
+
+<ul id="exception_names" class="filters">
+<% @exception_names.each do |name| -%>
+ <li><%= link_to_function name, "ExceptionLogger.setFilter('exception_names','#{escape_javascript name}')" %></li>
+<% end -%>
+</ul>
+
+<h4>Controller / Action</h4>
+
+<ul id="controller_actions" class="filters">
+<% @controller_actions.each do |action| -%>
+ <li><%= link_to_function action, "ExceptionLogger.setFilter('controller_actions','#{escape_javascript action}')" %></li>
+<% end -%>
+</ul>
+
+<h4>Dates</h4>
+
+<ul id="date_ranges" class="filters">
+ <li><a title="1" onclick="ExceptionLogger.setFilter('date_ranges', 1)">Today</a></li>
+ <li><a title="3" onclick="ExceptionLogger.setFilter('date_ranges', 3)">Last few days</a></li>
+ <li><a title="7" onclick="ExceptionLogger.setFilter('date_ranges', 7)">Last 7 days</a></li>
+ <li><a title="30" onclick="ExceptionLogger.setFilter('date_ranges', 30)">Last 30 days</a></li>
+</ul>
+
+
+<div id="search">
+ <%= form_remote_tag :url => { :action => 'query' }, :html => { :id => 'query-form' } %>
+ <div>
+
+ <%= text_field_tag :query, "", :size => 17 %>
+ <%= submit_tag :Find %>
+
+ <%= hidden_field_tag :exception_names_filter %>
+ <%= hidden_field_tag :date_ranges_filter %>
+ <%= hidden_field_tag :controller_actions_filter %>
+ <%= hidden_field_tag :page, params[:page] %>
+ </div>
+ </form>
+</div>
+
+
+</div> <!-- right -->
+
+<div id="left">
+ <div class="page" id="showpage" style="display:none; margin-bottom:1em;">
+
+ </div>
+ <div class="page">
+ <%= render :partial => "exceptions" %>
+ </div>
+</div>
+
+
+
+</div> <!-- container -->
+<xbr style="clear:both" />
+</body>
+</html>
2 views/logged_exceptions/query.rjs
@@ -0,0 +1,2 @@
+page[:exceptions].replace :partial => "exceptions"
+page[:showpage].hide
2 views/logged_exceptions/show.rjs
@@ -0,0 +1,2 @@
+page[:showpage].replace_html :partial => "show"
+page[:showpage].show

0 comments on commit e33986d

Please sign in to comment.