Merge pull request #499 from dsander/form-configurable

Configure agents via HTML forms rather then JSON

Dominik Sander 10 years ago
parent
commit
423fefe268

+ 4 - 0
Gemfile

@@ -85,6 +85,9 @@ group :development do
85 85
   gem 'better_errors', '~> 1.1'
86 86
   gem 'binding_of_caller'
87 87
   gem 'quiet_assets'
88
+  gem 'guard'
89
+  gem 'guard-livereload'
90
+  gem 'guard-rspec'
88 91
 end
89 92
 
90 93
 group :development, :test do
@@ -96,6 +99,7 @@ group :development, :test do
96 99
   gem 'rspec', '~> 3.0'
97 100
   gem 'rspec-collection_matchers', '~> 1.0.0'
98 101
   gem 'rspec-rails', '~> 3.0.1'
102
+  gem 'rspec-html-matchers', '~> 0.6.1'
99 103
   gem 'shoulda-matchers'
100 104
   gem 'spring'
101 105
   gem 'spring-commands-rspec'

+ 35 - 0
Gemfile.lock

@@ -55,6 +55,8 @@ GEM
55 55
       rails (>= 3.1)
56 56
     buftok (0.2.0)
57 57
     builder (3.2.2)
58
+    celluloid (0.15.2)
59
+      timers (~> 1.1.0)
58 60
     chronic (0.10.2)
59 61
     coderay (1.1.0)
60 62
     coffee-rails (4.0.1)
@@ -108,6 +110,9 @@ GEM
108 110
       http_parser.rb (>= 0.6.0)
109 111
     em-socksify (0.3.0)
110 112
       eventmachine (>= 1.0.0.beta.4)
113
+    em-websocket (0.5.1)
114
+      eventmachine (>= 0.12.9)
115
+      http_parser.rb (~> 0.6.0)
111 116
     equalizer (0.0.9)
112 117
     erector (0.10.0)
113 118
       treetop (>= 1.2.3)
@@ -134,6 +139,7 @@ GEM
134 139
     foreman (0.63.0)
135 140
       dotenv (>= 0.7)
136 141
       thor (>= 0.13.6)
142
+    formatador (0.2.5)
137 143
     geokit (1.8.5)
138 144
       multi_json (>= 1.3.2)
139 145
     geokit-rails (2.0.1)
@@ -150,6 +156,19 @@ GEM
150 156
       retriable (>= 1.4)
151 157
       signet (>= 0.5.0)
152 158
       uuidtools (>= 2.1.0)
159
+    guard (2.6.1)
160
+      formatador (>= 0.2.4)
161
+      listen (~> 2.7)
162
+      lumberjack (~> 1.0)
163
+      pry (>= 0.9.12)
164
+      thor (>= 0.18.1)
165
+    guard-livereload (2.2.0)
166
+      em-websocket (~> 0.5)
167
+      guard (~> 2.0)
168
+      multi_json (~> 1.8)
169
+    guard-rspec (4.3.1)
170
+      guard (~> 2.1)
171
+      rspec (>= 2.14, < 4.0)
153 172
     hashie (2.0.5)
154 173
     hike (1.2.3)
155 174
     hipchat (1.2.0)
@@ -178,6 +197,11 @@ GEM
178 197
       addressable (~> 2.3)
179 198
     libv8 (3.16.14.7)
180 199
     liquid (2.6.1)
200
+    listen (2.7.9)
201
+      celluloid (>= 0.15.2)
202
+      rb-fsevent (>= 0.9.3)
203
+      rb-inotify (>= 0.9)
204
+    lumberjack (1.0.9)
181 205
     macaddr (1.7.1)
182 206
       systemu (~> 2.6.2)
183 207
     mail (2.5.4)
@@ -262,6 +286,9 @@ GEM
262 286
       thor (>= 0.18.1, < 2.0)
263 287
     raindrops (0.13.0)
264 288
     rake (10.3.2)
289
+    rb-fsevent (0.9.4)
290
+    rb-inotify (0.9.5)
291
+      ffi (>= 0.5.0)
265 292
     rdoc (4.1.1)
266 293
       json (~> 1.4)
267 294
     ref (1.0.5)
@@ -283,6 +310,9 @@ GEM
283 310
     rspec-expectations (3.0.4)
284 311
       diff-lcs (>= 1.2.0, < 2.0)
285 312
       rspec-support (~> 3.0.0)
313
+    rspec-html-matchers (0.6.1)
314
+      nokogiri (~> 1)
315
+      rspec (~> 3)
286 316
     rspec-mocks (3.0.4)
287 317
       rspec-support (~> 3.0.0)
288 318
     rspec-rails (3.0.2)
@@ -350,6 +380,7 @@ GEM
350 380
     thor (0.19.1)
351 381
     thread_safe (0.3.4)
352 382
     tilt (1.4.1)
383
+    timers (1.1.0)
353 384
     tins (1.3.2)
354 385
     treetop (1.4.15)
355 386
       polyglot
@@ -438,6 +469,9 @@ DEPENDENCIES
438 469
   geokit (~> 1.8.4)
439 470
   geokit-rails (~> 2.0.1)
440 471
   google-api-client
472
+  guard
473
+  guard-livereload
474
+  guard-rspec
441 475
   hipchat (~> 1.2.0)
442 476
   httparty (~> 0.13)
443 477
   jquery-rails (~> 3.1.0)
@@ -466,6 +500,7 @@ DEPENDENCIES
466 500
   rr
467 501
   rspec (~> 3.0)
468 502
   rspec-collection_matchers (~> 1.0.0)
503
+  rspec-html-matchers (~> 0.6.1)
469 504
   rspec-rails (~> 3.0.1)
470 505
   rturk (~> 2.12.1)
471 506
   ruby-growl (~> 4.1.0)

+ 25 - 0
Guardfile

@@ -0,0 +1,25 @@
1
+
2
+guard 'livereload' do
3
+  watch(%r{app/views/.+\.(erb|haml|slim)$})
4
+  watch(%r{app/helpers/.+\.rb})
5
+  watch(%r{public/.+\.(css|js|html)})
6
+  watch(%r{config/locales/.+\.yml})
7
+  # Rails Assets Pipeline
8
+  watch(%r{(app|vendor)(/assets/\w+/(.+\.(css|js|html|png|jpg))).*}) { |m| "/assets/#{m[3]}" }
9
+end
10
+
11
+guard :rspec, cmd: 'bundle exec spring rspec' do
12
+  watch(%r{^spec/.+_spec\.rb$})
13
+  watch(%r{^lib/(.+)\.rb$})     { |m| "spec/lib/#{m[1]}_spec.rb" }
14
+  watch('spec/spec_helper.rb')  { "spec" }
15
+
16
+  # Rails example
17
+  watch(%r{^app/(.+)\.rb$})                           { |m| "spec/#{m[1]}_spec.rb" }
18
+  watch(%r{^app/(.*)(\.erb|\.haml|\.slim)$})          { |m| "spec/#{m[1]}#{m[2]}_spec.rb" }
19
+  watch(%r{^app/controllers/(.+)_(controller)\.rb$})  { |m| ["spec/routing/#{m[1]}_routing_spec.rb", "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", "spec/acceptance/#{m[1]}_spec.rb"] }
20
+  watch(%r{^spec/support/(.+)\.rb$})                  { "spec" }
21
+  watch('config/routes.rb')                           { "spec/routing" }
22
+  watch('app/controllers/application_controller.rb')  { "spec/controllers" }
23
+  watch('spec/rails_helper.rb')                       { "spec" }
24
+end
25
+

+ 1 - 0
app/assets/javascripts/application.js.coffee

@@ -5,6 +5,7 @@
5 5
 #= require select2
6 6
 #= require json2
7 7
 #= require jquery.json-editor
8
+#= require jquery.serializeObject
8 9
 #= require latlon_and_geo
9 10
 #= require spectrum
10 11
 #= require_tree ./components

+ 76 - 0
app/assets/javascripts/components/form_configurable.js.coffee

@@ -0,0 +1,76 @@
1
+$ ->
2
+  getFormData = (elem) ->
3
+    form_data = $("#edit_agent, #new_agent").serializeObject()
4
+    attribute = $(elem).data('attribute')
5
+    form_data['attribute'] = attribute
6
+    delete form_data['_method']
7
+    form_data
8
+
9
+  window.initializeFormCompletable = ->
10
+    returnedResults = {}
11
+    completableDefaultOptions = (input) ->
12
+      results: [
13
+        (returnedResults[$(input).data('attribute')] || {text: 'Options', children: [{id: undefined, text: 'loading ...'}]}),
14
+        {
15
+          text: 'Current',
16
+          children: [id: $(input).val(), text: $(input).val()]
17
+        },
18
+        {
19
+          text: 'Custom',
20
+          children: [id: 'manualInput', text: 'manual input']
21
+        },
22
+      ]
23
+
24
+    $("input[role~=validatable], select[role~=validatable]").on 'change', (e) =>
25
+      form_data = getFormData(e.currentTarget)
26
+      form_group = $(e.currentTarget).closest('.form-group')
27
+      $.ajax '/agents/validate',
28
+        type: 'POST',
29
+        data: form_data
30
+        success: (data) ->
31
+          form_group.addClass('has-feedback').removeClass('has-error')
32
+          form_group.find('span').addClass('hidden')
33
+          form_group.find('.glyphicon-ok').removeClass('hidden')
34
+          returnedResults = {}
35
+        error: (data) ->
36
+          form_group.addClass('has-feedback').addClass('has-error')
37
+          form_group.find('span').addClass('hidden')
38
+          form_group.find('.glyphicon-remove').removeClass('hidden')
39
+          returnedResults = {}
40
+
41
+    $("input[role~=validatable], select[role~=validatable]").trigger('change')
42
+
43
+    $.each $("input[role~=completable]"), (i, input) ->
44
+      $(input).select2(
45
+        data: ->
46
+          completableDefaultOptions(input)
47
+      ).on("change", (e) ->
48
+        if e.added && e.added.id == 'manualInput'
49
+          $(e.currentTarget).select2("destroy")
50
+          $(e.currentTarget).val(e.removed.id)
51
+      )
52
+
53
+    updateDropdownData = (form_data, element, data) ->
54
+      returnedResults[form_data.attribute] = {text: 'Options', children: data}
55
+      $(element).trigger('change')
56
+      $(element).select2('open')
57
+
58
+    $("input[role~=completable]").on 'select2-open', (e) ->
59
+      form_data = getFormData(e.currentTarget)
60
+      return if returnedResults[form_data.attribute]
61
+
62
+      $.ajax '/agents/complete',
63
+        type: 'POST',
64
+        data: form_data
65
+        success: (data) ->
66
+          updateDropdownData(form_data, e.currentTarget, data)
67
+        error: (data) ->
68
+          updateDropdownData(form_data, e.currentTarget, [{id: undefined, text: 'Error loading data.'}])
69
+
70
+    $("input[type=radio][role~=form-configurable]").change (e) ->
71
+      input = $(e.currentTarget).parents().siblings("input[data-attribute=#{$(e.currentTarget).data('attribute')}]")
72
+      if $(e.currentTarget).val() == 'manual'
73
+        input.removeClass('hidden')
74
+      else
75
+        input.val($(e.currentTarget).val())
76
+        input.addClass('hidden')

+ 5 - 5
app/assets/javascripts/pages/agent-edit-page.js.coffee

@@ -18,7 +18,6 @@ class @AgentEditPage
18 18
     else
19 19
       $(".agent-settings").show()
20 20
       $("#agent-spinner").fadeIn()
21
-      $("#agent_source_ids").select2("val", {})
22 21
       $(".model-errors").hide() unless firstTime
23 22
       $.getJSON "/agents/type_details", { type: type }, (json) =>
24 23
         if json.can_be_scheduled
@@ -46,11 +45,12 @@ class @AgentEditPage
46 45
 
47 46
         $(".description").show().html(json.description_html) if json.description_html?
48 47
 
49
-        $('.oauthable-form').html(json.form) if json.form?
50
-
51 48
         unless firstTime
52
-          window.jsonEditor.json = json.options
53
-          window.jsonEditor.rebuild()
49
+          $('.oauthable-form').html(json.oauthable) if json.oauthable?
50
+          $('.agent-options').html(json.form_options) if json.form_options?
51
+          window.jsonEditor = setupJsonEditor()[0]
52
+
53
+        window.initializeFormCompletable()
54 54
 
55 55
         $("#agent-spinner").stop(true, true).fadeOut();
56 56
 

+ 66 - 0
app/concerns/form_configurable.rb

@@ -0,0 +1,66 @@
1
+module FormConfigurable
2
+  extend ActiveSupport::Concern
3
+
4
+  included do
5
+    class_attribute :_form_configurable_fields
6
+    self._form_configurable_fields = HashWithIndifferentAccess.new { |h,k| h[k] = [] }
7
+  end
8
+
9
+  delegate :form_configurable_attributes, to: :class
10
+  delegate :form_configurable_fields, to: :class
11
+
12
+  def is_form_configurable?
13
+    true
14
+  end
15
+
16
+  def validate_option(method)
17
+    if self.respond_to? "validate_#{method}".to_sym
18
+      self.send("validate_#{method}".to_sym)
19
+    else
20
+      false
21
+    end
22
+  end
23
+
24
+  def complete_option(method)
25
+    if self.respond_to? "complete_#{method}".to_sym
26
+      self.send("complete_#{method}".to_sym)
27
+    end
28
+  end
29
+
30
+  module ClassMethods
31
+    def form_configurable(name, *args)
32
+      options = args.extract_options!.reverse_merge(roles: [], type: :string)
33
+
34
+      if args.all? { |arg| arg.is_a?(Symbol) }
35
+        options.assert_valid_keys([:type, :roles, :values])
36
+      end
37
+
38
+      if options[:type] == :array && (options[:values].blank? || !options[:values].is_a?(Array))
39
+        raise ArgumentError.new('When using :array as :type you need to provide the :values as an Array')
40
+      end
41
+
42
+      if options[:roles].is_a?(Symbol)
43
+        options[:roles] = [options[:roles]]
44
+      end
45
+
46
+      if options[:type] == :array
47
+        options[:roles] << :completable
48
+        class_eval <<-EOF
49
+          def complete_#{name}
50
+            #{options[:values]}.map { |v| {text: v, id: v} }
51
+          end
52
+        EOF
53
+      end
54
+
55
+      _form_configurable_fields[name] = options
56
+    end
57
+
58
+    def form_configurable_fields
59
+      self._form_configurable_fields
60
+    end
61
+
62
+    def form_configurable_attributes
63
+      form_configurable_fields.keys
64
+    end
65
+  end
66
+end

+ 38 - 4
app/controllers/agents_controller.rb

@@ -35,6 +35,8 @@ class AgentsController < ApplicationController
35 35
 
36 36
   def type_details
37 37
     @agent = Agent.build_for_type(params[:type], current_user, {})
38
+    initialize_presenter
39
+
38 40
     render :json => {
39 41
         :can_be_scheduled => @agent.can_be_scheduled?,
40 42
         :default_schedule => @agent.default_schedule,
@@ -43,7 +45,8 @@ class AgentsController < ApplicationController
43 45
         :can_control_other_agents => @agent.can_control_other_agents?,
44 46
         :options => @agent.default_options,
45 47
         :description_html => @agent.html_description,
46
-        :form => render_to_string(partial: 'oauth_dropdown', locals: { agent: @agent })
48
+        :oauthable => render_to_string(partial: 'oauth_dropdown', locals: { agent: @agent }),
49
+        :form_options => render_to_string(partial: 'options', locals: { agent: @agent })
47 50
     }
48 51
   end
49 52
 
@@ -92,6 +95,7 @@ class AgentsController < ApplicationController
92 95
     else
93 96
       @agent = agents.build
94 97
     end
98
+    initialize_presenter
95 99
 
96 100
     respond_to do |format|
97 101
       format.html
@@ -101,17 +105,18 @@ class AgentsController < ApplicationController
101 105
 
102 106
   def edit
103 107
     @agent = current_user.agents.find(params[:id])
108
+    initialize_presenter
104 109
   end
105 110
 
106 111
   def create
107
-    @agent = Agent.build_for_type(params[:agent].delete(:type),
108
-                                  current_user,
109
-                                  params[:agent])
112
+    build_agent
113
+
110 114
     respond_to do |format|
111 115
       if @agent.save
112 116
         format.html { redirect_back "'#{@agent.name}' was successfully created." }
113 117
         format.json { render json: @agent, status: :ok, location: agent_path(@agent) }
114 118
       else
119
+        initialize_presenter
115 120
         format.html { render action: "new" }
116 121
         format.json { render json: @agent.errors, status: :unprocessable_entity }
117 122
       end
@@ -126,6 +131,7 @@ class AgentsController < ApplicationController
126 131
         format.html { redirect_back "'#{@agent.name}' was successfully updated." }
127 132
         format.json { render json: @agent, status: :ok, location: agent_path(@agent) }
128 133
       else
134
+        initialize_presenter
129 135
         format.html { render action: "edit" }
130 136
         format.json { render json: @agent.errors, status: :unprocessable_entity }
131 137
       end
@@ -153,6 +159,22 @@ class AgentsController < ApplicationController
153 159
     end
154 160
   end
155 161
 
162
+  def validate
163
+    build_agent
164
+
165
+    if @agent.validate_option(params[:attribute])
166
+      render text: 'ok'
167
+    else
168
+      render text: 'error', status: 403
169
+    end
170
+  end
171
+
172
+  def complete
173
+    build_agent
174
+
175
+    render json: @agent.complete_option(params[:attribute])
176
+  end
177
+
156 178
   protected
157 179
 
158 180
   # Sanitize params[:return] to prevent open redirect attacks, a common security issue.
@@ -167,4 +189,16 @@ class AgentsController < ApplicationController
167 189
 
168 190
     redirect_to path, notice: message
169 191
   end
192
+
193
+  def build_agent
194
+    @agent = Agent.build_for_type(params[:agent].delete(:type),
195
+                                  current_user,
196
+                                  params[:agent])
197
+  end
198
+
199
+  def initialize_presenter
200
+    if @agent.present? && @agent.is_form_configurable?
201
+      @agent = FormConfigurableAgentPresenter.new(@agent, view_context)
202
+    end
203
+  end
170 204
 end

+ 4 - 0
app/models/agent.rb

@@ -88,6 +88,10 @@ class Agent < ActiveRecord::Base
88 88
     # Implement me in your subclass of Agent.
89 89
   end
90 90
 
91
+  def is_form_configurable?
92
+    false
93
+  end
94
+
91 95
   def receive_web_request(params, method, format)
92 96
     # Implement me in your subclass of Agent.
93 97
     ["not implemented", 404]

+ 23 - 12
app/models/agents/basecamp_agent.rb

@@ -1,21 +1,16 @@
1 1
 module Agents
2 2
   class BasecampAgent < Agent
3
-    cannot_receive_events!
4
-
3
+    include FormConfigurable
5 4
     include Oauthable
6 5
     valid_oauth_providers :'37signals'
7 6
 
7
+    cannot_receive_events!
8
+
8 9
     description <<-MD
9 10
       The BasecampAgent checks a Basecamp project for new Events
10 11
 
11 12
       To be able to use this Agent you need to authenticate with 37signals in the [Services](/services) section first.
12 13
 
13
-      You need to provide the `project_id` of the project you want to monitor.
14
-      If you have your Basecamp project opened in your browser you can find the user_id and project_id as follows:
15
-
16
-      `https://basecamp.com/123456/projects/`
17
-      project_id
18
-      `-explore-basecamp`
19 14
     MD
20 15
 
21 16
     event_description <<-MD
@@ -50,6 +45,14 @@ module Agents
50 45
       }
51 46
     end
52 47
 
48
+    form_configurable :project_id, roles: :completable
49
+
50
+    def complete_project_id
51
+      service.prepare_request
52
+      response = HTTParty.get projects_url, request_options.merge(query_parameters)
53
+      response.map { |p| {text: "#{p['name']} (#{p['id']})", id: p['id']}}
54
+    end
55
+
53 56
     def validate_options
54 57
       errors.add(:base, "you need to specify the basecamp project id of which you want to receive events") unless options['project_id'].present?
55 58
     end
@@ -60,8 +63,8 @@ module Agents
60 63
 
61 64
     def check
62 65
       service.prepare_request
63
-      reponse = HTTParty.get request_url, request_options.merge(query_parameters)
64
-      events = JSON.parse(reponse.body)
66
+      response = HTTParty.get events_url, request_options.merge(query_parameters)
67
+      events = JSON.parse(response.body)
65 68
       if !memory[:last_event].nil?
66 69
         events.each do |event|
67 70
           create_event :payload => event
@@ -72,8 +75,16 @@ module Agents
72 75
     end
73 76
 
74 77
   private
75
-    def request_url
76
-      "https://basecamp.com/#{URI.encode(service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json"
78
+    def base_url
79
+      "https://basecamp.com/#{URI.encode(service.options[:user_id].to_s)}/api/v1/"
80
+    end
81
+
82
+    def events_url
83
+      base_url + "projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json"
84
+    end
85
+
86
+    def projects_url
87
+      base_url + "projects.json"
77 88
     end
78 89
 
79 90
     def request_options

+ 33 - 4
app/models/agents/hipchat_agent.rb

@@ -1,5 +1,7 @@
1 1
 module Agents
2 2
   class HipchatAgent < Agent
3
+    include FormConfigurable
4
+
3 5
     cannot_be_scheduled!
4 6
     cannot_create_events!
5 7
 
@@ -15,8 +17,10 @@ module Agents
15 17
 
16 18
       Change the `room_name` to the name of the room you want to send notifications to.
17 19
 
18
-      You can provide a `username` and a `message`. When sending a HTML formatted message change `format` to "html".
19
-      If you want your message to notify the room members change `notify` to "true".
20
+      You can provide a `username` and a `message`. If you want to use mentions change `format` to "text" ([details](https://www.hipchat.com/docs/api/method/rooms/message)).
21
+
22
+      If you want your message to notify the room members change `notify` to "True".
23
+
20 24
       Modify the background color of your message via the `color` attribute (one of "yellow", "red", "green", "purple", "gray", or "random")
21 25
 
22 26
       Have a look at the [Wiki](https://github.com/cantino/huginn/wiki/Formatting-Events-using-Liquid) to learn more about liquid templating.
@@ -30,9 +34,29 @@ module Agents
30 34
         'message' => "Hello from Huginn!",
31 35
         'notify' => false,
32 36
         'color' => 'yellow',
37
+        'format' => 'html'
33 38
       }
34 39
     end
35 40
 
41
+    form_configurable :auth_token, roles: :validatable
42
+    form_configurable :room_name, roles: :completable
43
+    form_configurable :username
44
+    form_configurable :message, type: :text
45
+    form_configurable :notify, type: :boolean
46
+    form_configurable :color, type: :array, values: ['yellow', 'red', 'green', 'purple', 'gray', 'random']
47
+    form_configurable :format, type: :array, values: ['html', 'text']
48
+
49
+    def validate_auth_token
50
+      client.rooms
51
+      true
52
+    rescue HipChat::UnknownResponseCode
53
+      return false
54
+    end
55
+
56
+    def complete_room_name
57
+      client.rooms.collect { |room| {text: room.name, id: room.name} }
58
+    end
59
+
36 60
     def validate_options
37 61
       errors.add(:base, "you need to specify a hipchat auth_token or provide a credential named hipchat_auth_token") unless options['auth_token'].present? || credential('hipchat_auth_token').present?
38 62
       errors.add(:base, "you need to specify a room_name or a room_name_path") if options['room_name'].blank? && options['room_name_path'].blank?
@@ -45,12 +69,17 @@ module Agents
45 69
     def receive(incoming_events)
46 70
       incoming_events.each do |event|
47 71
         mo = interpolated(event)
48
-        client[mo[:room_name]].send(mo[:username][0..14], mo[:message], :notify => boolify(mo[:notify]), :color => mo[:color])
72
+        client[mo[:room_name]].send(mo[:username][0..14], mo[:message],
73
+                                      notify: boolify(mo[:notify]),
74
+                                      color: mo[:color],
75
+                                      message_format: mo[:format].presence || 'html'
76
+                                    )
49 77
       end
50 78
     end
51 79
 
80
+    private
52 81
     def client
53
-      @client ||= HipChat::Client.new(interpolated[:auth_token] || credential('hipchat_auth_token'))
82
+      @client ||= HipChat::Client.new(interpolated[:auth_token].presence || credential('hipchat_auth_token'))
54 83
     end
55 84
   end
56 85
 end

+ 44 - 0
app/presenters/form_configurable_agent_presenter.rb

@@ -0,0 +1,44 @@
1
+require 'delegate'
2
+
3
+class Decorator < SimpleDelegator
4
+  def class
5
+    __getobj__.class
6
+  end
7
+end
8
+
9
+class FormConfigurableAgentPresenter < Decorator
10
+  def initialize(agent, view)
11
+    @agent = agent
12
+    @view = view
13
+    super(agent)
14
+  end
15
+
16
+  def option_field_for(attribute)
17
+    data = @agent.form_configurable_fields[attribute]
18
+    value = @agent.options[attribute.to_s] || @agent.default_options[attribute.to_s]
19
+    html_options = {role: (data[:roles] + ['form-configurable']).join(' '), data: {attribute: attribute}}
20
+
21
+    case data[:type]
22
+    when :text
23
+      @view.text_area_tag "agent[options][#{attribute}]", value, html_options.merge(class: 'form-control', rows: 3)
24
+    when :boolean
25
+      @view.content_tag 'div' do
26
+        @view.concat(@view.content_tag('label', class: 'radio-inline') do
27
+          @view.concat @view.radio_button_tag "agent[options][#{attribute}_radio]", 'true', @agent.send(:boolify, value) == true, html_options
28
+          @view.concat "True"
29
+        end)
30
+        @view.concat(@view.content_tag('label', class: 'radio-inline') do
31
+          @view.concat @view.radio_button_tag "agent[options][#{attribute}_radio]", 'false', @agent.send(:boolify, value) == false, html_options
32
+          @view.concat "False"
33
+        end)
34
+        @view.concat(@view.content_tag('label', class: 'radio-inline') do
35
+          @view.concat @view.radio_button_tag "agent[options][#{attribute}_radio]", 'manual', @agent.send(:boolify, value) == nil, html_options
36
+          @view.concat "Manual Input"
37
+        end)
38
+        @view.concat(@view.text_field_tag "agent[options][#{attribute}]", value, html_options.merge(:class => "form-control #{@agent.send(:boolify, value) != nil ? 'hidden' : ''}"))
39
+      end
40
+    when :array, :string
41
+      @view.text_field_tag "agent[options][#{attribute}]", value, html_options.merge(:class => 'form-control')
42
+    end
43
+  end
44
+end

+ 4 - 16
app/views/agents/_form.html.erb

@@ -16,8 +16,7 @@
16 16
     <div class="col-md-6">
17 17
       <div class="row">
18 18
 
19
-        <!-- Form controls width restricted -->
20
-        <div class="col-md-8">
19
+        <div class="col-md-12">
21 20
           <% if @agent.new_record? %>
22 21
             <div class="form-group type-select">
23 22
               <%= f.label :type %>
@@ -27,7 +26,7 @@
27 26
         </div>
28 27
 
29 28
         <div class="agent-settings">
30
-          <div class="col-md-8">
29
+          <div class="col-md-12">
31 30
             <div class="form-group">
32 31
               <%= f.label :name %>
33 32
               <%= f.text_field :name, :class => 'form-control' %>
@@ -105,19 +104,8 @@
105 104
 
106 105
           </div>
107 106
 
108
-          <!-- Form controls full width -->
109
-          <div class="col-md-12">
110
-            <div class="form-group">
111
-              <%= f.label :options %>
112
-              <span class="glyphicon glyphicon-question-sign hover-help" data-content="In this JSON hash, interpolation is available in almost all values using the Liquid templating language.<p>Available template variables include the following:<dl><dt><code>message</code>, <code>url</code>, etc.</dt><dd>Refers to the corresponding key's value of each incoming event's payload.</dd><dt><code>agent</code></dt><dd>Refers to the agent that created each incoming event.  It has attributes like <code>type</code>, <code>name</code> and <code>options</code>, so <code>{{agent.type}}</code> will expand to <code>WebsiteAgent</code> if an incoming event is created by that agent.</dd></dl></p><p>To access user credentials, use the <code>credential</code> tag like this: <code>{% credential <em>bare_key_name</em> %}</code></p>"></span>
113
-              <textarea rows="15" id="agent_options" name="agent[options]" class="form-control live-json-editor">
114
-                <%= Utils.jsonify((@agent.new_record? && @agent.options == {}) ? @agent.default_options : @agent.options) %>
115
-              </textarea>
116
-            </div>
117
-
118
-            <div class="form-group">
119
-              <%= f.submit "Save", :class => "btn btn-primary" %>
120
-            </div>
107
+          <div class="col-md-12 agent-options">
108
+            <%= render partial: 'options', locals: { agent: @agent } %>
121 109
           </div>
122 110
         </div>
123 111
       </div>

+ 27 - 0
app/views/agents/_options.erb

@@ -0,0 +1,27 @@
1
+<% if agent.is_form_configurable? %>
2
+  <fieldset>
3
+    <% if agent.persisted? %>
4
+      <%= hidden_field_tag 'agent[type]', @agent.type %>
5
+    <% end %>
6
+    <legend>Options</legend>
7
+    <% agent.form_configurable_attributes.each do |attribute| %>
8
+      <div class="form-group">
9
+        <%= label_tag attribute %>
10
+        <%= agent.option_field_for(attribute) %>
11
+        <span class="glyphicon glyphicon-ok form-control-feedback hidden"></span>
12
+        <span class="glyphicon glyphicon-remove form-control-feedback hidden"></span>
13
+      </div>
14
+    <% end %>
15
+  </fieldset>
16
+<% else %>
17
+  <div class="form-group">
18
+    <%= label_tag :options %>
19
+    <span class="glyphicon glyphicon-question-sign hover-help" data-content="In this JSON hash, interpolation is available in almost all values using the Liquid templating language.<p>Available template variables include the following:<dl><dt><code>message</code>, <code>url</code>, etc.</dt><dd>Refers to the corresponding key's value of each incoming event's payload.</dd><dt><code>agent</code></dt><dd>Refers to the agent that created each incoming event.  It has attributes like <code>type</code>, <code>name</code> and <code>options</code>, so <code>{{agent.type}}</code> will expand to <code>WebsiteAgent</code> if an incoming event is created by that agent.</dd></dl></p><p>To access user credentials, use the <code>credential</code> tag like this: <code>{% credential <em>bare_key_name</em> %}</code></p>"></span>
20
+    <textarea rows="15" id="agent_options" name="agent[options]" class="form-control live-json-editor">
21
+      <%= Utils.jsonify((agent.new_record? && agent.options == {}) ? agent.default_options : agent.options) %>
22
+    </textarea>
23
+  </div>
24
+<% end %>
25
+<div class="form-group">
26
+  <%= submit_tag "Save", :class => "btn btn-primary" %>
27
+</div>

+ 3 - 2
app/views/agents/edit.html.erb

@@ -9,8 +9,9 @@
9 9
       </div>
10 10
     </div>
11 11
   </div>
12
-
13
-  <%= render 'form' %>
12
+  <div id="agent-form">
13
+    <%= render 'form' %>
14
+  </div>
14 15
 
15 16
   <hr>
16 17
 

+ 3 - 1
app/views/agents/new.html.erb

@@ -8,7 +8,9 @@
8 8
         </h2>
9 9
       </div>
10 10
 
11
-      <%= render 'form' %>
11
+      <div id="agent-form">
12
+        <%= render 'form' %>
13
+      </div>
12 14
 
13 15
       <hr>
14 16
 

+ 1 - 1
config/application.rb

@@ -13,7 +13,7 @@ module Huginn
13 13
     # -- all .rb files in that directory are automatically loaded.
14 14
 
15 15
     # Custom directories with classes and modules you want to be autoloadable.
16
-    config.autoload_paths += %W(#{config.root}/lib)
16
+    config.autoload_paths += %W(#{config.root}/lib #{config.root}/app/presenters)
17 17
 
18 18
     # Activate observers that should always be running.
19 19
     # config.active_record.observers = :cacher, :garbage_collector, :forum_observer

+ 2 - 0
config/routes.rb

@@ -11,6 +11,8 @@ Huginn::Application.routes.draw do
11 11
       post :propagate
12 12
       get :type_details
13 13
       get :event_descriptions
14
+      post :validate
15
+      post :complete
14 16
     end
15 17
 
16 18
     resources :logs, :only => [:index] do

+ 56 - 0
spec/concerns/form_configurable_spec.rb

@@ -0,0 +1,56 @@
1
+require 'spec_helper'
2
+
3
+describe FormConfigurable do
4
+  class Agent1
5
+    include FormConfigurable
6
+
7
+    def validate_test
8
+      true
9
+    end
10
+
11
+    def complete_test
12
+      [{name: 'test', value: 1234}]
13
+    end
14
+  end
15
+
16
+  class Agent2 < Agent
17
+  end
18
+
19
+  before(:all) do
20
+    @agent1 = Agent1.new
21
+    @agent2 = Agent2.new
22
+  end
23
+
24
+  it "#is_form_configurable" do
25
+    expect(@agent1.is_form_configurable?).to be true
26
+    expect(@agent2.is_form_configurable?).to be false
27
+  end
28
+
29
+  describe "#validete_option" do
30
+    it "should call the validation method if it is defined" do
31
+      expect(@agent1.validate_option('test')).to be true
32
+    end
33
+
34
+    it "should return false of the method is undefined" do
35
+      expect(@agent1.validate_option('undefined')).to be false
36
+    end
37
+  end
38
+
39
+  it "#complete_option" do
40
+    expect(@agent1.complete_option('test')).to eq [{name: 'test', value: 1234}]
41
+  end
42
+
43
+  describe "#form_configurable" do
44
+    it "should raise an ArgumentError for invalid  options" do
45
+      expect { Agent1.form_configurable(:test, invalid: true) }.to raise_error(ArgumentError)
46
+    end
47
+
48
+    it "should raise an ArgumentError when not providing an array with type: array" do
49
+      expect { Agent1.form_configurable(:test, type: :array, values: 1) }.to raise_error(ArgumentError)
50
+    end
51
+
52
+    it "should not require any options for the default values" do
53
+      expect { Agent1.form_configurable(:test) }.to change(Agent1, :form_configurable_attributes).by(['test'])
54
+    end
55
+  end
56
+end

+ 40 - 0
spec/controllers/agents_controller_spec.rb

@@ -307,4 +307,44 @@ describe AgentsController do
307 307
       expect(response).to redirect_to scenario_path(scenarios(:bob_weather))
308 308
     end
309 309
   end
310
+
311
+  describe "#form_configurable actions" do
312
+    before(:each) do
313
+      @params = {attribute: 'auth_token', agent: valid_attributes(:type => "Agents::HipchatAgent", options: {auth_token: '12345'})}
314
+      sign_in users(:bob)
315
+    end
316
+    describe "POST validate" do
317
+
318
+      it "returns with status 200 when called with a valid option" do
319
+        any_instance_of(Agents::HipchatAgent) do |klass|
320
+          stub(klass).validate_option { true }
321
+        end
322
+
323
+        post :validate, @params
324
+        expect(response.status).to eq 200
325
+      end
326
+
327
+      it "returns with status 403 when called with an invalid option" do
328
+        any_instance_of(Agents::HipchatAgent) do |klass|
329
+          stub(klass).validate_option { false }
330
+        end
331
+
332
+        post :validate, @params
333
+        expect(response.status).to eq 403
334
+      end
335
+    end
336
+
337
+    describe "POST complete" do
338
+      it "callsAgent#complete_option and renders json" do
339
+        any_instance_of(Agents::HipchatAgent) do |klass|
340
+          stub(klass).complete_option { [{name: 'test', value: 1}] }
341
+        end
342
+
343
+        post :complete, @params
344
+        expect(response.status).to eq 200
345
+        expect(response.header['Content-Type']).to include('application/json')
346
+
347
+      end
348
+    end
349
+  end
310 350
 end

+ 28 - 5
spec/models/agents/basecamp_agent_spec.rb

@@ -5,8 +5,21 @@ describe Agents::BasecampAgent do
5 5
   it_behaves_like Oauthable
6 6
 
7 7
   before(:each) do
8
-    stub_request(:get, /json$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"})
9
-    stub_request(:get, /02:00$/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")), :status => 200, :headers => {"Content-Type" => "text/json"})
8
+    stub_request(:get, /events.json$/).to_return(
9
+      :body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")),
10
+      :status => 200,
11
+      :headers => {"Content-Type" => "text/json"}
12
+    )
13
+    stub_request(:get, /projects.json$/).to_return(
14
+      :body => JSON.dump([{name: 'test', id: 1234},{name: 'test1', id: 1235}]),
15
+      :status => 200,
16
+      :headers => {"Content-Type" => "text/json"}
17
+    )
18
+    stub_request(:get, /02:00$/).to_return(
19
+      :body => File.read(Rails.root.join("spec/data_fixtures/basecamp.json")),
20
+      :status => 200,
21
+      :headers => {"Content-Type" => "text/json"}
22
+    )
10 23
     @valid_params = { :project_id => 6789 }
11 24
 
12 25
     @checker = Agents::BasecampAgent.new(:name => "somename", :options => @valid_params)
@@ -32,10 +45,13 @@ describe Agents::BasecampAgent do
32 45
       expect(@checker.send(:request_options)).to eq({:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)", "Authorization" => 'Bearer "1234token"'}})
33 46
     end
34 47
 
35
-    it "should generate the currect request url" do
36
-      expect(@checker.send(:request_url)).to eq("https://basecamp.com/12345/api/v1/projects/6789/events.json")
48
+    it "should generate the correct events url" do
49
+      expect(@checker.send(:events_url)).to eq("https://basecamp.com/12345/api/v1/projects/6789/events.json")
37 50
     end
38 51
 
52
+    it "should generate the correct projects url" do
53
+      expect(@checker.send(:projects_url)).to eq("https://basecamp.com/12345/api/v1/projects.json")
54
+    end
39 55
 
40 56
     it "should not provide the since attribute on first run" do
41 57
       expect(@checker.send(:query_parameters)).to eq({})
@@ -48,6 +64,13 @@ describe Agents::BasecampAgent do
48 64
       expect(@checker.reload.send(:query_parameters)).to eq({:query => {:since => time}})
49 65
     end
50 66
   end
67
+
68
+  describe "#complete_project_id" do
69
+    it "should return a array of hashes" do
70
+      expect(@checker.complete_project_id).to eq [{text: 'test (1234)', id: 1234}, {text: 'test1 (1235)', id: 1235}]
71
+    end
72
+  end
73
+
51 74
   describe "#check" do
52 75
     it "should not emit events on its first run" do
53 76
       expect { @checker.check }.to change { Event.count }.by(0)
@@ -60,7 +83,7 @@ describe Agents::BasecampAgent do
60 83
   end
61 84
 
62 85
   describe "#working?" do
63
-    it "it is working when at least one event was emited" do
86
+    it "it is working when at least one event was emitted" do
64 87
       expect(@checker).not_to be_working
65 88
       @checker.memory[:last_event] = '2014-04-17T10:25:31.000+02:00'
66 89
       @checker.check

+ 25 - 0
spec/models/agents/hipchat_agent_spec.rb

@@ -50,6 +50,31 @@ describe Agents::HipchatAgent do
50 50
     end
51 51
   end
52 52
 
53
+  describe "#validate_auth_token" do
54
+    it "should return true when valid" do
55
+      any_instance_of(HipChat::Client) do |klass|
56
+        stub(klass).rooms { true }
57
+      end
58
+      expect(@checker.validate_auth_token).to be true
59
+    end
60
+
61
+    it "should return false when invalid" do
62
+      any_instance_of(HipChat::Client) do |klass|
63
+        stub(klass).rooms { raise HipChat::UnknownResponseCode.new }
64
+      end
65
+      expect(@checker.validate_auth_token).to be false
66
+    end
67
+  end
68
+
69
+  describe "#complete_room_name" do
70
+    it "should return a array of hashes" do
71
+      any_instance_of(HipChat::Client) do |klass|
72
+        stub(klass).rooms { [OpenStruct.new(name: 'test'), OpenStruct.new(name: 'test1')] }
73
+      end
74
+      expect(@checker.complete_room_name).to eq [{text: 'test', id: 'test'},{text: 'test1', id: 'test1'}]
75
+    end
76
+  end
77
+
53 78
   describe "#receive" do
54 79
     it "send a message to the hipchat" do
55 80
       any_instance_of(HipChat::Room) do |obj|

+ 1 - 1
spec/models/service_spec.rb

@@ -15,7 +15,7 @@ describe Service do
15 15
       expect(@service.global).to eq(false)
16 16
     end
17 17
 
18
-    it "disconnects agents and disables them if the previously global service is made private again", focus: true do
18
+    it "disconnects agents and disables them if the previously global service is made private again" do
19 19
       agent = agents(:bob_basecamp_agent)
20 20
       jane_agent = agents(:jane_basecamp_agent)
21 21
 

+ 40 - 0
spec/presenters/form_configurable_agent_presenter_spec.rb

@@ -0,0 +1,40 @@
1
+require 'spec_helper'
2
+
3
+describe FormConfigurableAgentPresenter do
4
+  class FormConfigurableAgentPresenterAgent < Agent
5
+    include FormConfigurable
6
+
7
+    form_configurable :string, roles: :validatable
8
+    form_configurable :text, type: :text, roles: :completable
9
+    form_configurable :boolean, type: :boolean
10
+    form_configurable :array, type: :array, values: [1, 2, 3]
11
+  end
12
+
13
+  before(:all) do
14
+    @presenter = FormConfigurableAgentPresenter.new(FormConfigurableAgentPresenterAgent.new, ActionController::Base.new.view_context)
15
+  end
16
+
17
+  it "works for the type :string" do
18
+    expect(@presenter.option_field_for(:string)).to(
19
+      have_tag('input', with: {:'data-attribute' => 'string', role: 'validatable form-configurable', type: 'text', name: 'agent[options][string]'})
20
+    )
21
+  end
22
+
23
+  it "works for the type :text" do
24
+    expect(@presenter.option_field_for(:text)).to(
25
+      have_tag('textarea', with: {:'data-attribute' => 'text', role: 'completable form-configurable', name: 'agent[options][text]'})
26
+    )
27
+  end
28
+
29
+  it "works for the type :boolean" do
30
+    expect(@presenter.option_field_for(:boolean)).to(
31
+      have_tag('input', with: {:'data-attribute' => 'boolean', role: 'form-configurable', name: 'agent[options][boolean_radio]', type: 'radio'})
32
+    )
33
+  end
34
+
35
+  it "works for the type :array" do
36
+    expect(@presenter.option_field_for(:array)).to(
37
+      have_tag('input', with: {:'data-attribute' => 'array', role: 'completable form-configurable', type: 'text', name: 'agent[options][array]'})
38
+    )
39
+  end
40
+end

+ 40 - 0
vendor/assets/javascripts/jquery.serializeObject.js

@@ -0,0 +1,40 @@
1
+//
2
+// Use internal $.serializeArray to get list of form elements which is
3
+// consistent with $.serialize
4
+//
5
+// From version 2.0.0, $.serializeObject will stop converting [name] values
6
+// to camelCase format. This is *consistent* with other serialize methods:
7
+//
8
+//   - $.serialize
9
+//   - $.serializeArray
10
+//
11
+// If you require camel casing, you can either download version 1.0.4 or map
12
+// them yourself.
13
+//
14
+
15
+(function($){
16
+  $.fn.serializeObject = function () {
17
+    "use strict";
18
+
19
+    var result = {};
20
+    var extend = function (i, element) {
21
+      var node = result[element.name];
22
+
23
+  // If node with same name exists already, need to convert it to an array as it
24
+  // is a multi-value field (i.e., checkboxes)
25
+
26
+      if ('undefined' !== typeof node && node !== null) {
27
+        if ($.isArray(node)) {
28
+          node.push(element.value);
29
+        } else {
30
+          result[element.name] = [node, element.value];
31
+        }
32
+      } else {
33
+        result[element.name] = element.value;
34
+      }
35
+    };
36
+
37
+    $.each(this.serializeArray(), extend);
38
+    return result;
39
+  };
40
+})(jQuery);