Added a concern to configure Agents via HTML forms

Validate input forms based on a callback
Offer valid options for parameters

Dominik Sander 10 years ago
parent
commit
60956c49b9

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

@@ -0,0 +1,69 @@
1
+$ ->
2
+  $.fn.serializeObject = ->
3
+    o = {}
4
+    a = @serializeArray()
5
+    $.each a, ->
6
+      if o[@name] isnt `undefined`
7
+        o[@name] = [o[@name]]  unless o[@name].push
8
+        o[@name].push @value or ""
9
+      else
10
+        o[@name] = @value or ""
11
+      return
12
+    o
13
+
14
+  getFormData = (elem) ->
15
+    form_data = $("#edit_agent, #new_agent").serializeObject()
16
+    attribute = $(elem).data('attribute')
17
+    form_data['attribute'] = attribute
18
+    delete form_data['_method']
19
+    form_data
20
+
21
+  window.initializeFormCompletable = ->
22
+    returnedResults = {}
23
+    completableDefaultOptions = (input) ->
24
+      results: [
25
+        (returnedResults[$(input).data('attribute')] || {text: 'Options', children: [{id: '', text: 'loading ...'}]})
26
+        {
27
+          text: 'Current',
28
+          children: [id: $(input).val(), text: $(input).val()]
29
+        },
30
+        {
31
+          text: 'Custom',
32
+          children: [id: 'manualInput', text: 'manual input']
33
+        },
34
+      ]
35
+
36
+    $("input[role=validatable], select[role=validatable]").on 'change', (e) =>
37
+      form_data = getFormData(e.currentTarget)
38
+      form_group = $(e.currentTarget).closest('.form-group')
39
+      $.ajax '/agents/validate',
40
+        type: 'POST',
41
+        data: form_data
42
+        success: (data) ->
43
+          form_group.addClass('has-feedback').removeClass('has-error')
44
+          form_group.find('span').addClass('hidden')
45
+          form_group.find('.glyphicon-ok').removeClass('hidden')
46
+        error: (data) ->
47
+          form_group.addClass('has-feedback').addClass('has-error')
48
+          form_group.find('span').addClass('hidden')
49
+          form_group.find('.glyphicon-remove').removeClass('hidden')
50
+
51
+    $("input[role=validatable], select[role=validatable]").trigger('change')
52
+
53
+    $.each $("input[role~=completable]"), (i, input) ->
54
+      $(input).select2
55
+        data: ->
56
+          completableDefaultOptions(input)
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
+          console.log data
67
+          returnedResults[form_data.attribute] = {text: 'Options', children: $.map(data, (d) -> {id: d.value, text: d.name})}
68
+          $(e.currentTarget).trigger('change')
69
+          $(e.currentTarget).select2('open')

+ 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[:roles].include?(:completable) && !self.method_defined?("complete_#{name}".to_sym)
47
+        # Not really sure, but method_defined? does not seem to work because we do not have the 'full' Agent class here
48
+        #raise ArgumentError.new("'complete_#{name}' needs to be defined to validate '#{name}'")
49
+      end
50
+
51
+      if options[:roles].include?(:validatable) && !self.method_defined?("validate_#{name}".to_sym)
52
+        #raise ArgumentError.new("'validate_#{name}' needs to be defined to validate '#{name}'")
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]

+ 2 - 0
app/models/agents/basecamp_agent.rb

@@ -1,5 +1,7 @@
1 1
 module Agents
2 2
   class BasecampAgent < Agent
3
+    include FormConfigurable
4
+
3 5
     cannot_receive_events!
4 6
 
5 7
     include Oauthable

+ 21 - 0
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
 
@@ -33,6 +35,24 @@ module Agents
33 35
       }
34 36
     end
35 37
 
38
+    form_configurable :auth_token, roles: :validatable
39
+    form_configurable :room_name, roles: :completable
40
+    form_configurable :username
41
+    form_configurable :message, type: :text
42
+    form_configurable :notify, type: :boolean
43
+    form_configurable :color, type: :array, values: ['yellow', 'red', 'green', 'purple', 'gray', 'random']
44
+
45
+    def validate_auth_token
46
+      client.rooms
47
+      true
48
+    rescue HipChat::UnknownResponseCode
49
+      return false
50
+    end
51
+
52
+    def complete_room_name
53
+      client.rooms.collect { |room| {name: room.name, value: room.name} }
54
+    end
55
+
36 56
     def validate_options
37 57
       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 58
       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?
@@ -49,6 +69,7 @@ module Agents
49 69
       end
50 70
     end
51 71
 
72
+    private
52 73
     def client
53 74
       @client ||= HipChat::Client.new(interpolated[:auth_token] || credential('hipchat_auth_token'))
54 75
     end

+ 41 - 0
app/presenters/form_configurable_agent_presenter.rb

@@ -0,0 +1,41 @@
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].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}]", 'true', @agent.send(:boolify, value), html_options
28
+          @view.concat "Yes"
29
+        end)
30
+        @view.concat(@view.content_tag('label', class: 'radio-inline') do
31
+          @view.concat @view.radio_button_tag "agent[options][#{attribute}]", 'false', !@agent.send(:boolify, value), html_options
32
+          @view.concat "No"
33
+        end)
34
+      end
35
+    when :array
36
+      @view.select_tag "agent[options][#{attribute}]", @view.options_for_select(data[:values], value), html_options.merge(class: "form-control")
37
+    when :string
38
+      @view.text_field_tag "agent[options][#{attribute}]", value, html_options.merge(:class => 'form-control')
39
+    end
40
+  end
41
+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