apotomo 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. data/Gemfile +10 -0
  2. data/Gemfile.lock +47 -0
  3. data/README +141 -0
  4. data/README.rdoc +141 -0
  5. data/Rakefile +78 -0
  6. data/TODO +36 -0
  7. data/app/cells/apotomo/child_switch_widget/switch.html.erb +1 -0
  8. data/app/cells/apotomo/child_switch_widget/switch.rhtml +1 -0
  9. data/app/cells/apotomo/deep_link_widget.rb +27 -0
  10. data/app/cells/apotomo/deep_link_widget/setup.html.erb +20 -0
  11. data/app/cells/apotomo/java_script_widget.rb +12 -0
  12. data/app/cells/apotomo/tab_panel_widget.rb +87 -0
  13. data/app/cells/apotomo/tab_panel_widget/display.html.erb +57 -0
  14. data/app/cells/apotomo/tab_widget.rb +18 -0
  15. data/app/cells/apotomo/tab_widget/display.html.erb +1 -0
  16. data/config/routes.rb +3 -0
  17. data/generators/widget/USAGE +15 -0
  18. data/generators/widget/templates/functional_test.rb +8 -0
  19. data/generators/widget/templates/view.html.erb +2 -0
  20. data/generators/widget/templates/view.html.haml +3 -0
  21. data/generators/widget/templates/widget.rb +8 -0
  22. data/generators/widget/widget_generator.rb +34 -0
  23. data/lib/apotomo.rb +59 -0
  24. data/lib/apotomo/caching.rb +37 -0
  25. data/lib/apotomo/container_widget.rb +10 -0
  26. data/lib/apotomo/deep_link_methods.rb +90 -0
  27. data/lib/apotomo/event.rb +9 -0
  28. data/lib/apotomo/event_handler.rb +23 -0
  29. data/lib/apotomo/event_methods.rb +102 -0
  30. data/lib/apotomo/invoke_event_handler.rb +24 -0
  31. data/lib/apotomo/javascript_generator.rb +57 -0
  32. data/lib/apotomo/persistence.rb +139 -0
  33. data/lib/apotomo/proc_event_handler.rb +18 -0
  34. data/lib/apotomo/rails/controller_methods.rb +161 -0
  35. data/lib/apotomo/rails/view_helper.rb +95 -0
  36. data/lib/apotomo/rails/view_methods.rb +7 -0
  37. data/lib/apotomo/request_processor.rb +92 -0
  38. data/lib/apotomo/stateful_widget.rb +8 -0
  39. data/lib/apotomo/transition.rb +46 -0
  40. data/lib/apotomo/tree_node.rb +186 -0
  41. data/lib/apotomo/version.rb +5 -0
  42. data/lib/apotomo/widget.rb +289 -0
  43. data/lib/apotomo/widget_shortcuts.rb +36 -0
  44. data/rails/init.rb +0 -0
  45. data/test/fixtures/application_widget_tree.rb +2 -0
  46. data/test/rails/controller_methods_test.rb +206 -0
  47. data/test/rails/rails_integration_test.rb +99 -0
  48. data/test/rails/view_helper_test.rb +77 -0
  49. data/test/rails/view_methods_test.rb +40 -0
  50. data/test/rails/widget_generator_test.rb +47 -0
  51. data/test/support/assertions_helper.rb +13 -0
  52. data/test/support/test_case_methods.rb +68 -0
  53. data/test/test_helper.rb +77 -0
  54. data/test/unit/apotomo_test.rb +20 -0
  55. data/test/unit/container_test.rb +20 -0
  56. data/test/unit/event_handler_test.rb +67 -0
  57. data/test/unit/event_methods_test.rb +83 -0
  58. data/test/unit/event_test.rb +30 -0
  59. data/test/unit/invoke_test.rb +123 -0
  60. data/test/unit/javascript_generator_test.rb +90 -0
  61. data/test/unit/onfire_integration_test.rb +19 -0
  62. data/test/unit/persistence_test.rb +240 -0
  63. data/test/unit/render_test.rb +203 -0
  64. data/test/unit/request_processor_test.rb +178 -0
  65. data/test/unit/stateful_widget_test.rb +135 -0
  66. data/test/unit/test_addressing.rb +111 -0
  67. data/test/unit/test_caching.rb +54 -0
  68. data/test/unit/test_jump_to_state.rb +89 -0
  69. data/test/unit/test_tab_panel.rb +72 -0
  70. data/test/unit/test_widget_shortcuts.rb +45 -0
  71. data/test/unit/transition_test.rb +33 -0
  72. data/test/unit/widget_shortcuts_test.rb +68 -0
  73. data/test/unit/widget_test.rb +24 -0
  74. metadata +215 -0
@@ -0,0 +1,9 @@
1
+ module Apotomo
2
+ # Events are created by Apotomo in #fire. They bubble up from their source to root and trigger
3
+ # event handlers.
4
+ class Event < Onfire::Event
5
+ def _dump(depth)
6
+ raise "You're trying to serialize an instance of Apotomo::Event. Don't do that."
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,23 @@
1
+ module Apotomo
2
+ # EventHandlers are "callbacks", not knowing why they exist, but what to do.
3
+ class EventHandler
4
+
5
+ def process_event(event)
6
+ # do something, and return content.
7
+ nil
8
+ end
9
+
10
+ def ==(other)
11
+ self.to_s == other.to_s
12
+ end
13
+
14
+ # Invoked by Onfire.
15
+ def call(event)
16
+ event.source.root.page_updates << process_event(event)
17
+ end
18
+
19
+ end
20
+ end
21
+
22
+ require 'apotomo/invoke_event_handler'
23
+ require 'apotomo/proc_event_handler'
@@ -0,0 +1,102 @@
1
+ require 'apotomo/event_handler'
2
+
3
+ module Apotomo
4
+ # Introduces event-processing functions into the StatefulWidget.
5
+
6
+ module EventMethods
7
+ attr_writer :page_updates
8
+ # Replacement for the EventProcessor singleton queue.
9
+ def page_updates
10
+ @page_updates ||= []
11
+ end
12
+
13
+ def self.included(base)
14
+ base.extend(ClassMethods)
15
+ base.initialize_hooks << :add_class_event_handlers
16
+ end
17
+
18
+ def add_class_event_handlers(*)
19
+ self.class.responds_to_event_options.each { |options| respond_to_event(*options) }
20
+ end
21
+
22
+ module ClassMethods
23
+ def responds_to_event(*options)
24
+ responds_to_event_options << options
25
+ end
26
+ alias_method :respond_to_event, :responds_to_event
27
+
28
+ def responds_to_event_options
29
+ @responds_to_event_options ||= []
30
+ end
31
+ end
32
+ # Instructs the widget to look out for <tt>type</tt> Events that are passing by while bubbling.
33
+ # If an appropriate event is encountered the widget will send the targeted widget (or itself) to another
34
+ # state, which implies an update of the invoked widget.
35
+ #
36
+ # You may configure the event handler with the following <tt>options</tt>:
37
+ # :with => (required) the state to invoke on the target widget
38
+ # :on => (optional) the targeted widget's id, defaults to <tt>self.name</tt>
39
+ # :from => (optional) the source id of the widget that triggered the event, defaults to any widget
40
+ #
41
+ # Example:
42
+ #
43
+ # trap = cell(:input_field, :smell_like_cheese, 'mouse_trap')
44
+ # trap.respond_to_event :mouseOver, :with => :catch_mouse
45
+ #
46
+ # This would instruct <tt>trap</tt> to catch a <tt>:mouseOver</tt> event from any widget (including itself) and
47
+ # to invoke the state <tt>:catch_mouse</tt> on itself as trigger.
48
+ #
49
+ #
50
+ # hunter = cell(:form, :hunt_for_mice, 'my_form')
51
+ # hunter << cell(:input_field, :smell_like_cheese, 'mouse_trap')
52
+ # hunter << cell(:text_area, :stick_like_honey, 'bear_trap')
53
+ # hunter.respond_to_event :captured, :from => 'mouse_trap', :with => :refill_cheese, :on => 'mouse_trap'
54
+ #
55
+ # As both the bear- and the mouse trap can trigger a <tt>:captured</tt> event the later <tt>respond_to_event</tt>
56
+ # would invoke <tt>:refill_cheese</tt> on the <tt>mouse_trap</tt> widget as soon as this and only this widget fired.
57
+ # It is important to understand the <tt>:from</tt> parameter as it filters the event source - it wouldn't make
58
+ # sense to refill the mouse trap if the bear trap snapped, would it?
59
+
60
+ def respond_to_event(type, options)
61
+ options[:once] = true if options[:once].nil?
62
+
63
+ handler_opts = {}
64
+ handler_opts[:widget_id] = options[:on] || self.name
65
+ handler_opts[:state] = options[:with]
66
+
67
+ handler = InvokeEventHandler.new(handler_opts)
68
+
69
+ return if options[:once] and event_table.all_handlers_for(type, options[:from]).include?(handler)
70
+
71
+ on(type, :do => handler, :from => options[:from])
72
+ end
73
+
74
+ def trigger(*args)
75
+ fire(*args)
76
+ end
77
+
78
+ # Invokes <tt>state</tt> on the widget <em>and</end> updates itself on the page. This should
79
+ # never be called from outside but in setters when some internal value changed and must be
80
+ # displayed instantly.
81
+ #
82
+ # Implements the following pattern (TODO: remove example as soon as invoke! proofed):
83
+ #
84
+ # def title=(str)
85
+ # @title = str
86
+ # peek(:update, self.name, :display, self.name)
87
+ # trigger(:update)
88
+ # end
89
+ def invoke!(state)
90
+ ### TODO: encapsulate in PageUpdateQueue:
91
+ Apotomo::EventProcessor.instance.processed_handlers << [name, invoke(:state)]
92
+ end
93
+
94
+
95
+
96
+ protected
97
+ # Get all handlers from self for the passed event (overriding Onfire#local_event_handlers).
98
+ def local_event_handlers(event)
99
+ event_table.all_handlers_for(event.type, event.source.name) # we key with widget_id.
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,24 @@
1
+ module Apotomo
2
+ class InvokeEventHandler < EventHandler
3
+ attr_accessor :widget_id, :state
4
+
5
+ def initialize(opts={})
6
+ @widget_id = opts.delete(:widget_id)
7
+ @state = opts.delete(:state)
8
+ end
9
+
10
+ def process_event(event)
11
+ target = event.source.root.find_by_path(widget_id) ### DISCUSS: widget_id or widget_selector?
12
+
13
+ #::Rails.logger.debug "EventHandler: invoking #{target.name}##{state}"
14
+ ### DISCUSS: let target access event?
15
+ ### pass additional opts to #invoke?
16
+ ### DISCUSS: pass block here?
17
+ target.opts[:event] = event
18
+
19
+ target.invoke(state)
20
+ end
21
+
22
+ def to_s; "InvokeEventHandler:#{widget_id}##{state}"; end
23
+ end
24
+ end
@@ -0,0 +1,57 @@
1
+ module Apotomo
2
+ class JavascriptGenerator
3
+ def initialize(framework)
4
+ raise "No JS framework specified" if framework.blank?
5
+ extend "apotomo/javascript_generator/#{framework}".camelize.constantize
6
+ end
7
+
8
+ def <<(javascript)
9
+ "#{javascript}"
10
+ end
11
+
12
+ # Copied from ActionView::Helpers::JavascriptHelper.
13
+ JS_ESCAPE_MAP = {
14
+ '\\' => '\\\\',
15
+ '</' => '<\/',
16
+ "\r\n" => '\n',
17
+ "\n" => '\n',
18
+ "\r" => '\n',
19
+ '"' => '\\"',
20
+ "'" => "\\'" }
21
+
22
+ # Escape carrier returns and single and double quotes for JavaScript segments.
23
+ def self.escape(javascript)
24
+ return javascript.gsub(/(\\|<\/|\r\n|[\n\r"'])/) { JS_ESCAPE_MAP[$1] } if javascript
25
+
26
+ ''
27
+ end
28
+
29
+ def escape(javascript)
30
+ self.class.escape(javascript)
31
+ end
32
+
33
+ module Prototype
34
+ def prototype; end
35
+ def element(id); "$(\"#{id}\")"; end
36
+ def xhr(url); "new Ajax.Request(\"#{url}\")"; end
37
+ def update(id, markup); element(id) + '.update("'+escape(markup)+'")'; end
38
+ def replace(id, markup); element(id) + '.replace("'+escape(markup)+'")'; end
39
+ end
40
+
41
+ module Right
42
+ def right; end
43
+ def element(id); "$(\"#{id}\")"; end
44
+ def xhr(url); "new Xhr(\"#{url}\", {evalScripts:true}).send()"; end
45
+ def update(id, markup); element(id) + '.update("'+escape(markup)+'")'; end
46
+ def replace(id, markup); element(id) + '.replace("'+escape(markup)+'")'; end
47
+ end
48
+
49
+ module Jquery
50
+ def jquery; end
51
+ def element(id); "$(\"##{id}\")"; end
52
+ def xhr(url); "$.ajax({url: \"#{url}\"})"; end
53
+ def update(id, markup); element(id) + '.html("'+escape(markup)+'")'; end
54
+ def replace(id, markup); element(id) + '.replaceWith("'+escape(markup)+'")'; end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,139 @@
1
+ module Apotomo
2
+ # Methods needed to serialize the widget tree and back.
3
+ module Persistence
4
+ def self.included(base)
5
+ base.extend(ClassMethods)
6
+ end
7
+
8
+ # For Ruby 1.8/1.9 compatibility.
9
+ def symbolized_instance_variables
10
+ instance_variables.map { |ivar| ivar.to_sym }
11
+ end
12
+
13
+ def freeze_ivars_to(storage)
14
+ frozen = {}
15
+ (symbolized_instance_variables - unfreezable_ivars).each do |ivar|
16
+ frozen[ivar] = instance_variable_get(ivar)
17
+ end
18
+ storage[path] = frozen
19
+ end
20
+
21
+ ### FIXME: rewrite so that root might be stateless as well.
22
+ def freeze_data_to(storage)
23
+ freeze_ivars_to(storage)# if self.kind_of?(StatefulWidget)
24
+ children.each { |child| child.freeze_data_to(storage) if child.kind_of?(StatefulWidget) }
25
+ end
26
+
27
+ def freeze_to(storage)
28
+ storage[:apotomo_root] = self # save structure.
29
+ storage[:apotomo_widget_ivars] = {}
30
+ freeze_data_to(storage[:apotomo_widget_ivars]) # save ivars.
31
+ end
32
+
33
+ def thaw_ivars_from(storage)
34
+ storage.fetch(path, {}).each do |k, v|
35
+ instance_variable_set(k, v)
36
+ end
37
+ end
38
+
39
+ def thaw_data_from(storage)
40
+ thaw_ivars_from(storage)
41
+ children.each { |child| child.thaw_data_from(storage) }
42
+ end
43
+
44
+
45
+ # Serializes the widget node structure (not children, not content).
46
+ def dump_node
47
+ field_sep = self.class.field_sep
48
+ "#{@name}#{field_sep}#{self.class}#{field_sep}#{root? ? @name : parent.name}"
49
+ end
50
+
51
+ # Serializes the tree structure.
52
+ def _dump(depth)
53
+ inject("") { |str, node| str << node.dump_node << self.class.node_sep }
54
+ end
55
+
56
+ module ClassMethods
57
+ def field_sep; '|'; end
58
+ def node_sep; "\n"; end
59
+
60
+ # Creates an empty widget instance from <tt>line</tt>.
61
+ def load_node(line)
62
+ name, klass, parent = line.split(field_sep)
63
+ [klass.constantize.new(name, nil), parent]
64
+ end
65
+
66
+ def _load(str)
67
+ nodes = {}
68
+ root = nil
69
+ str.split(node_sep).each do |line|
70
+ node, parent = load_node(line)
71
+ nodes[node.name] = node
72
+
73
+ if node.name == parent # we're at the root node.
74
+ root = node and next
75
+ end
76
+
77
+ nodes[parent].add(node)
78
+ end
79
+ root
80
+ end
81
+
82
+ def freeze_for(storage, root)
83
+ storage[:apotomo_stateful_branches] = []
84
+ storage[:apotomo_widget_ivars] = {}
85
+
86
+ stateful_branches_for(root).each do |branch|
87
+ branch.freeze_data_to(storage[:apotomo_widget_ivars]) # save ivars.
88
+ storage[:apotomo_stateful_branches] << [branch, branch.parent.name]
89
+ branch.root! # disconnect from tree.
90
+ end
91
+ end
92
+
93
+ def thaw_for(storage, root)
94
+ branches = storage.delete(:apotomo_stateful_branches) || []
95
+ branches.each do |config|
96
+ branch = config.first
97
+ parent = root.find_widget(config.last) or raise "Couldn't find parent `#{config.last}` for `#{branch.name}`"
98
+
99
+ parent << branch
100
+ branch.thaw_data_from(storage.delete(:apotomo_widget_ivars) || {})
101
+ end
102
+
103
+ root
104
+ end
105
+
106
+ def thaw_from(storage)
107
+ root = storage[:apotomo_root]
108
+ root.thaw_data_from(storage.fetch(:apotomo_widget_ivars, {}))
109
+ root
110
+ end
111
+
112
+ def frozen_widget_in?(storage)
113
+ branches = storage[:apotomo_stateful_branches]
114
+ branches.present? and branches.first.first.kind_of? Apotomo::StatefulWidget
115
+ end
116
+
117
+ def flush_storage(storage)
118
+ storage[:apotomo_stateful_branches] = nil
119
+ storage[:apotomo_widget_ivars] = nil
120
+ end
121
+
122
+ # Find the first stateful widgets on each branch from +root+.
123
+ def stateful_branches_for(root)
124
+ to_traverse = [root]
125
+ stateful_roots = []
126
+
127
+ while node = to_traverse.shift
128
+ if node.kind_of?(StatefulWidget)
129
+ stateful_roots << node and next
130
+ end
131
+ to_traverse += node.children
132
+ end
133
+
134
+ stateful_roots
135
+ end
136
+
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,18 @@
1
+ module Apotomo
2
+ class ProcEventHandler < EventHandler
3
+ attr_accessor :proc
4
+
5
+ def initialize(opts={})
6
+ @proc = opts.delete(:proc)
7
+ end
8
+
9
+ def process_event(event)
10
+ Rails.logger.debug "ProcEventHandler: calling #{@proc}"
11
+ #@proc.call(event)
12
+ event.source.controller.send(@proc, event)
13
+ nil ### DISCUSS: needed so that controller doesn't evaluate the "content".
14
+ end
15
+
16
+ def to_s; "ProcEventHandler:#{proc}"; end
17
+ end
18
+ end
@@ -0,0 +1,161 @@
1
+ require 'apotomo/request_processor'
2
+ require 'apotomo/rails/view_methods'
3
+
4
+ module Apotomo
5
+ module Rails
6
+ module ControllerMethods
7
+ include WidgetShortcuts
8
+
9
+ def self.included(base) #:nodoc:
10
+ base.class_eval do
11
+ extend WidgetShortcuts
12
+ extend ClassMethods
13
+
14
+ class_inheritable_array :uses_widgets_blocks
15
+ self.uses_widgets_blocks = []
16
+
17
+ helper ::Apotomo::Rails::ViewMethods
18
+
19
+ after_filter :apotomo_freeze
20
+ end
21
+ end
22
+
23
+ module ClassMethods
24
+ def uses_widgets(&block)
25
+ uses_widgets_blocks << block
26
+ end
27
+
28
+ alias_method :has_widgets, :uses_widgets
29
+ end
30
+
31
+ def bound_use_widgets_blocks
32
+ session[:bound_use_widgets_blocks] ||= ProcHash.new
33
+ end
34
+
35
+ def flush_bound_use_widgets_blocks
36
+ session[:bound_use_widgets_blocks] = nil
37
+ end
38
+
39
+ def apotomo_request_processor
40
+ return @apotomo_request_processor if @apotomo_request_processor
41
+
42
+ # happens once per request:
43
+ ### DISCUSS: policy in production?
44
+ options = { :flush_widgets => params[:flush_widgets],
45
+ :js_framework => Apotomo.js_framework || :prototype,
46
+ } ### TODO: process rails options (flush_tree, version)
47
+
48
+ @apotomo_request_processor = Apotomo::RequestProcessor.new(session, options, self.class.uses_widgets_blocks)
49
+
50
+ flush_bound_use_widgets_blocks if @apotomo_request_processor.widgets_flushed?
51
+
52
+
53
+ @apotomo_request_processor
54
+ end
55
+
56
+ def apotomo_root
57
+ apotomo_request_processor.root
58
+ end
59
+
60
+ # Yields the root widget for manipulating the widget tree in a controller action.
61
+ # Note that the passed block is executed once per session and not in every request.
62
+ #
63
+ # Example:
64
+ # def login
65
+ # use_widgets do |root|
66
+ # root << cell(:login, :form, 'login_box')
67
+ # end
68
+ #
69
+ # @box = render_widget 'login_box'
70
+ # end
71
+ def use_widgets(&block)
72
+ root = apotomo_root ### DISCUSS: let RequestProcessor initialize so we get flushed, eventually. maybe add a :before filter for that? or move #use_widgets to RequestProcessor?
73
+
74
+ return if bound_use_widgets_blocks.include?(block)
75
+
76
+ yield root
77
+
78
+ bound_use_widgets_blocks << block # remember the proc.
79
+ end
80
+
81
+
82
+ def render_widget(widget, options={}, &block)
83
+ apotomo_request_processor.render_widget_for(widget, options, self, &block)
84
+ end
85
+
86
+ def apotomo_freeze
87
+ apotomo_request_processor.freeze!
88
+ end
89
+
90
+ def render_event_response
91
+ page_updates = apotomo_request_processor.process_for({:type => params[:type], :source => params[:source]}, self)
92
+
93
+ return render_iframe_updates(page_updates) if params[:apotomo_iframe]
94
+
95
+ render :text => apotomo_request_processor.render_page_updates(page_updates), :content_type => Mime::JS
96
+ end
97
+
98
+ # Returns the url to trigger a +type+ event from +:source+, which is a non-optional parameter.
99
+ # Additional +options+ will be appended to the query string.
100
+ #
101
+ # Note that this method will use the framework's internal routing if available (e.g. #url_for in Rails).
102
+ #
103
+ # Example:
104
+ # url_for_event(:paginate, :source => 'mouse', :page => 2)
105
+ # #=> http://apotomo.de/mouse/process_event_request?type=paginate&source=mouse&page=2
106
+ def url_for_event(type, options)
107
+ options.reverse_merge!(:type => type)
108
+
109
+ apotomo_event_path(apotomo_request_processor.address_for(options))
110
+ end
111
+
112
+ protected
113
+
114
+
115
+ # Renders the page updates through an iframe. Copied from responds_to_parent,
116
+ # see http://github.com/markcatley/responds_to_parent .
117
+ def render_iframe_updates(page_updates)
118
+ script = apotomo_request_processor.render_page_updates(page_updates)
119
+ escaped_script = Apotomo::JavascriptGenerator.escape(script)
120
+
121
+ render :text => "<html><body><script type='text/javascript' charset='utf-8'>
122
+ var loc = document.location;
123
+ with(window.parent) { setTimeout(function() { window.eval('#{escaped_script}'); window.loc && loc.replace('about:blank'); }, 1) }
124
+ </script></body></html>", :content_type => 'text/html'
125
+ end
126
+
127
+ def respond_to_event(type, options)
128
+ handler = ProcEventHandler.new
129
+ handler.proc = options[:with]
130
+ ### TODO: pass :from => (event source).
131
+
132
+ # attach once, not every request:
133
+ apotomo_root.evt_table.add_handler_once(handler, :event_type => type)
134
+ end
135
+
136
+
137
+
138
+ ### DISCUSS: rename? should say "this controller action wants apotomo's deep linking!"
139
+ ### DISCUSS: move to deep_link_methods?
140
+ def respond_to_url_change
141
+ return if apotomo_root.find_widget('deep_link') # add only once.
142
+ apotomo_root << widget("apotomo/deep_link_widget", :setup, 'deep_link')
143
+ end
144
+
145
+
146
+ class ProcHash < Array
147
+ def id_for_proc(proc)
148
+ proc.to_s.split('@').last
149
+ end
150
+
151
+ def <<(proc)
152
+ super(id_for_proc(proc))
153
+ end
154
+
155
+ def include?(proc)
156
+ super(id_for_proc(proc))
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end