Skip to content

Commit 656e2ba

Browse files
committed
[browsermedia#124] Form Builder
Allow editors to create form pages that can be used to collect information from visitors (i.e. Contact Us, Support requests, etc). Basically, any quick collect a few fields using a consistent form styling. h2. Features include: 1. Forms can have multiple fields which can be text fields, textareas or multiple choice dropdowns. Field management is done via AJAX and fields can be added/reordered/removed without leaving the page. 2. Fields can be required, have instructions and default values. Choices are added as lines of text for each dropdown field. Dropdowns use the first value as the default. 3. Entries are stored in the database and can optionally notify someone via email when new submissions are created. 4. Editors can manage entries via the admin (CRUD) 5. Visitors can be redirected to another URL after submitting their entry or display a customizable 'success' message. 6. Forms generate the HTML display using bootstrap's CSS by default. Projects can customize this in the application.rb with config.cms.form_builder_css h2. Refactorings * Created a 'spec' directory and 'rake spec' tasks to run minitest specs. * All content blocks are now namespaced (and will be generated as such). Blocks created in projects will be namespaced under the project name. This consistency allows use to use Rails standard polymorphic_paths for content blocks, while being able to easily deterimine which 'Engine' the block belongs to. * Projects will need to modify their existing contentblocks to move them under the project namespace. * Moved all 'testing' content types under the 'Dummy' namespace. * Added 'engine_aware_path' method that works like 'polymporphic_path' but can guess the engine that the resource belongs to. A number of old path generating helpers were removed including block_path/blocks_path/new_block_path/cms_connectable_path. See app/helpers/cms/path_helper.rb for new methods. * Add 'Cms::BaseController#allow_guests_to' which provides a way to selectively have actions be publicly available. * Added concept of mailbot_address which is used by default for system generated emails. Worked based on 'config.cms.site_domain' by default, but can be configured via config.cms.mailbot.
1 parent 044a309 commit 656e2ba

File tree

148 files changed

+2424
-820
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

148 files changed

+2424
-820
lines changed

Rakefile

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,19 @@ Bundler::GemHelper.install_tasks
2020

2121
require 'rake/testtask'
2222

23-
Rake::TestTask.new('test:units' => ['project:ensure_db_exists', 'app:test:prepare']) do |t|
23+
Rake::TestTask.new('units') do |t|
2424
t.libs << 'lib'
2525
t.libs << 'test'
2626
t.pattern = 'test/unit/**/*_test.rb'
2727
t.verbose = false
2828
end
2929

30+
Rake::TestTask.new('spec') do |t|
31+
t.libs << 'lib'
32+
t.libs << 'spec'
33+
t.pattern = "spec/**/*_spec.rb"
34+
end
35+
3036
Rake::TestTask.new('test:functionals' => ['project:ensure_db_exists', 'app:test:prepare']) do |t|
3137
t.libs << 'lib'
3238
t.libs << 'test'
@@ -46,11 +52,11 @@ require 'cucumber'
4652
require 'cucumber/rake/task'
4753

4854
Cucumber::Rake::Task.new(:features => ['project:ensure_db_exists', 'app:test:prepare']) do |t|
49-
t.cucumber_opts = "features --format progress"
55+
t.cucumber_opts = "launch_on_failure=false features --format progress"
5056
end
5157

5258
Cucumber::Rake::Task.new('features:fast' => ['project:ensure_db_exists', 'app:test:prepare']) do |t|
53-
t.cucumber_opts = "features --format progress --tags ~@cli"
59+
t.cucumber_opts = "launch_on_failure=false features --format progress --tags ~@cli"
5460
end
5561

5662
Cucumber::Rake::Task.new('features:cli' => ['project:ensure_db_exists', 'app:test:prepare']) do |t|
@@ -59,17 +65,26 @@ end
5965

6066

6167
desc "Run everything but the command line (slow) tests"
62-
task 'test:fast' => %w{test:units test:functionals test:integration features:fast}
68+
task 'test:fast' => %w{app:test:prepare test:units test:functionals test:integration features:fast}
6369

64-
desc 'Runs all the tests'
70+
desc "Runs all unit level tests"
71+
task 'test:units' => ['app:test:prepare'] do
72+
run_tests ["units", "spec"]
73+
end
74+
75+
desc 'Runs all the tests, specs and scenarios.'
6576
task :test => ['project:ensure_db_exists', 'app:test:prepare'] do
66-
tests_to_run = ENV['TEST'] ? ["test:single"] : %w(test:units test:functionals test:integration features)
77+
tests_to_run = ENV['TEST'] ? ["test:single"] : %w(test:units spec test:functionals test:integration features)
78+
run_tests(tests_to_run)
79+
end
80+
81+
def run_tests(tests_to_run)
6782
errors = tests_to_run.collect do |task|
6883
begin
6984
Rake::Task[task].invoke
7085
nil
7186
rescue => e
72-
{ :task => task, :exception => e }
87+
{:task => task, :exception => e}
7388
end
7489
end.compact
7590

app/assets/javascripts/cms/ajax.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ jQuery(function ($) {
5252

5353
// Defaults for AJAX requests
5454
$.ajaxSetup({
55-
error:function (x, status, error) {
56-
alert("A " + x.status + " error occurred: " + error);
57-
},
5855
beforeSend: $.cms_ajax.asJSON()
5956
});
57+
$(document).ajaxError(function (x, status, error) {
58+
alert("A " + x.status + " error occurred: " + error);
59+
});
6060
});

app/assets/javascripts/cms/application.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
//= require jquery.taglist
99
//= require cms/core_library
1010
//= require cms/attachment_manager
11+
//= require cms/form_builder
1112
//= require bootstrap
1213
//
1314

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
//= require cms/ajax
2+
//= require underscore
3+
4+
/**
5+
* The UI for dynamically creating custom forms via the UI.
6+
* @constructor
7+
*
8+
*/
9+
10+
// Determine if an element exists.
11+
// i.e. if($('.some-class').exists()){ // do something }
12+
jQuery.fn.exists = function() {
13+
return this.length > 0;
14+
};
15+
16+
var FormBuilder = function() {
17+
};
18+
19+
// Add a new field to the form
20+
// (Implementation: Clone existing hidden form elements rather than build new ones via HTML).
21+
FormBuilder.prototype.newField = function(field_type) {
22+
this.hideNewFormInstruction();
23+
this.addPreviewFieldToForm(field_type);
24+
25+
};
26+
27+
FormBuilder.prototype.addPreviewFieldToForm = function(field_type) {
28+
$("#placeHolder").load($('#placeHolder').data('new-path') + '?field_type=' + field_type + ' .control-group', function() {
29+
var newField = $("#placeHolder").find('.control-group');
30+
newField.insertBefore('#placeHolder');
31+
formBuilder.enableFieldButtons();
32+
formBuilder.resetAddFieldButton();
33+
});
34+
};
35+
36+
FormBuilder.prototype.resetAddFieldButton = function() {
37+
$("#form_new_entry_new_field").val('1');
38+
};
39+
40+
FormBuilder.prototype.removeCurrentField = function() {
41+
this.field_being_editted.remove();
42+
this.field_being_editted = null;
43+
};
44+
45+
// Function that triggers when users click the 'Delete' field button.
46+
FormBuilder.prototype.confirmDeleteFormField = function() {
47+
formBuilder.field_being_editted = $(this).parents('.control-group');
48+
49+
var path = $(this).attr('data-path');
50+
if (path == "") {
51+
formBuilder.removeCurrentField();
52+
} else {
53+
$('#modal-confirm-delete-field').modal({
54+
show: true
55+
});
56+
}
57+
};
58+
59+
// Function that triggers when users click the 'Edit' field button.
60+
FormBuilder.prototype.editFormField = function() {
61+
// This is the overall container for the entire field.
62+
formBuilder.field_being_editted = $(this).parents('.control-group');
63+
$('#modal-edit-field').removeData('modal').modal({
64+
show: true,
65+
remote: $(this).attr('data-edit-path')
66+
});
67+
68+
};
69+
70+
71+
FormBuilder.prototype.hideNewFormInstruction = function() {
72+
var no_fields = $("#no-field-instructions");
73+
if (no_fields.exists()) {
74+
no_fields.hide();
75+
}
76+
};
77+
78+
// Add handler to any edit field buttons.
79+
FormBuilder.prototype.enableFieldButtons = function() {
80+
$('.edit_form_button').unbind('click').on('click', formBuilder.editFormField);
81+
$('.delete_field_button').unbind('click').on('click', formBuilder.confirmDeleteFormField);
82+
};
83+
84+
FormBuilder.prototype.newFormField = function() {
85+
return $('#ajax_form_field');
86+
};
87+
88+
// Delete field from form, then remove it from the field
89+
FormBuilder.prototype.deleteFormField = function() {
90+
var element = formBuilder.field_being_editted.find('.delete_field_button');
91+
var url = element.attr('data-path');
92+
$.cms_ajax.delete({
93+
url: url,
94+
success: function(field) {
95+
formBuilder.removeCurrentField();
96+
formBuilder.removeFieldId(field.id);
97+
}
98+
});
99+
};
100+
101+
// @param [Number] value The id of the field that is to be removed from the form.
102+
FormBuilder.prototype.removeFieldId = function(value) {
103+
var field_ids = $('#field_ids').val().split(" ");
104+
field_ids.splice($.inArray(value.toString(), field_ids), 1);
105+
formBuilder.setFieldIds(field_ids);
106+
};
107+
108+
// @param [Array<String>] value
109+
FormBuilder.prototype.setFieldIds = function(value) {
110+
var spaced_string = value.join(" ");
111+
$('#field_ids').val(spaced_string);
112+
};
113+
114+
FormBuilder.prototype.addFieldIdToList = function(new_value) {
115+
$('#field_ids').val($('#field_ids').val() + " " + new_value);
116+
};
117+
118+
// Save a new Field to the database for the current form.
119+
FormBuilder.prototype.createField = function() {
120+
var form = formBuilder.newFormField();
121+
var data = form.serialize();
122+
var url = form.attr('action');
123+
124+
$.ajax({
125+
type: "POST",
126+
url: url,
127+
data: data,
128+
global: false,
129+
datatype: $.cms_ajax.asJSON()
130+
}).done(
131+
function(field) {
132+
formBuilder.clearFieldErrorsOnCurrentField();
133+
134+
formBuilder.addFieldIdToList(field.id);
135+
formBuilder.field_being_editted.find('input').attr('data-id', field.id);
136+
formBuilder.field_being_editted.find('label').html(field.label);
137+
formBuilder.field_being_editted.find('a').attr('data-edit-path', field.edit_path);
138+
formBuilder.field_being_editted.find('a.delete_field_button').attr('data-path', field.delete_path);
139+
formBuilder.field_being_editted.find('.help-block').html(field.instructions);
140+
141+
}
142+
).fail(function(xhr, textStatus, errorThrown) {
143+
formBuilder.displayErrorOnField(formBuilder.field_being_editted, xhr.responseJSON);
144+
});
145+
146+
};
147+
148+
FormBuilder.prototype.clearFieldErrorsOnCurrentField = function() {
149+
var field = formBuilder.field_being_editted;
150+
field.removeClass("error");
151+
field.find('.help-inline').remove();
152+
};
153+
154+
FormBuilder.prototype.displayErrorOnField = function(field, json) {
155+
var error_message = json.errors[0];
156+
// console.log(error_message);
157+
field.addClass("error");
158+
var input_field = field.find('.input-append');
159+
input_field.after('<span class="help-inline">' + error_message + '</span>');
160+
};
161+
162+
// Attaches behavior to the proper element.
163+
FormBuilder.prototype.setup = function() {
164+
var select_box = $('.add-new-field');
165+
if (select_box.exists()) {
166+
select_box.change(function() {
167+
formBuilder.newField($(this).val());
168+
});
169+
170+
this.enableFieldButtons();
171+
$("#create_field").on('click', formBuilder.createField);
172+
$("#delete_field").on('click', formBuilder.deleteFormField);
173+
174+
// Edit Field should handle Enter by submitting the form via AJAX.
175+
// Enter within textareas should still add endlines as normal.
176+
$('#modal-edit-field').on('shown', function() {
177+
formBuilder.newFormField().on("keypress", function(e) {
178+
if (e.which == 13 && e.target.tagName != 'TEXTAREA') {
179+
formBuilder.createField();
180+
e.preventDefault();
181+
$('#modal-edit-field').modal('hide');
182+
return false;
183+
}
184+
});
185+
});
186+
187+
// Allow fields to be sorted.
188+
$('#form-preview').sortable({
189+
axis: 'y',
190+
delay: 250,
191+
192+
// When form element is moved
193+
update: function(event, ui) {
194+
var field_id = ui.item.find('input').attr('data-id');
195+
var new_position = ui.item.index() + 1;
196+
formBuilder.moveFieldTo(field_id, new_position);
197+
}
198+
});
199+
this.setupConfirmationBehavior();
200+
this.enableFormCleanup();
201+
}
202+
};
203+
204+
// Since we create a form for the #new action, we need to delete it if the user doesn't save it explicitly.
205+
FormBuilder.prototype.enableFormCleanup = function() {
206+
var cleanup_element = $('#cleanup-before-abandoning');
207+
if (cleanup_element.exists()) {
208+
var cleanup_on_leave = true;
209+
$(":submit").on('click', function() {
210+
cleanup_on_leave = false;
211+
});
212+
$(window).bind('beforeunload', function() {
213+
if (cleanup_on_leave) {
214+
var path = cleanup_element.attr('data-path');
215+
$.cms_ajax.delete({url: path, async: false});
216+
}
217+
});
218+
}
219+
};
220+
221+
// Updates the server with the new position for a given field.
222+
FormBuilder.prototype.moveFieldTo = function(field_id, position) {
223+
var url = '/cms/form_fields/' + field_id + '/insert_at/' + position;
224+
225+
var success = function(data) {
226+
console.log("Success:", data);
227+
};
228+
console.log('For', field_id, 'to', position);
229+
$.post(url, success);
230+
};
231+
232+
FormBuilder.prototype.setupConfirmationBehavior = function() {
233+
// Confirmation Behavior
234+
$("#form_confirmation_behavior_show_text").on('click', function() {
235+
$(".form_confirmation_text").show();
236+
$(".form_confirmation_redirect").hide();
237+
});
238+
$("#form_confirmation_behavior_redirect").on('click', function() {
239+
$(".form_confirmation_redirect").show();
240+
$(".form_confirmation_text").hide();
241+
});
242+
$("#form_confirmation_behavior_show_text").trigger('click');
243+
};
244+
var formBuilder = new FormBuilder();
245+
246+
// Register FormBuilder handlers on page load.
247+
$(function() {
248+
formBuilder.setup();
249+
250+
251+
// Include a text field to start (For easier testing)
252+
// formBuilder.newField('text_field');
253+
});

app/assets/stylesheets/cms/bootstrap-customizations.css.scss

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
@import "bootstrap";
22
@import "bootstrap-responsive";
33

4+
#form-preview {
5+
6+
// Add hover borders when over an element.
7+
.control-group {
8+
border: 1px solid transparent;
9+
}
10+
.control-group:hover {
11+
border: 1px dashed #3B699F !important;
12+
}
13+
}
14+
415
// For classes that need to explicitly extend bootstrap classes.
516
// They must go in this file.
617

@@ -95,4 +106,10 @@ textarea,
95106

96107
#asset_add_uploader {
97108
display: none;
109+
}
110+
111+
// simpleform f.error generates this class for model wide attributes.
112+
#base-errors .help-inline {
113+
@extend .alert-error;
114+
@extend .alert;
98115
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// Default styles for public CMS Forms (built via the forms module.
2+
3+
@import "bootstrap";
4+
@import "bootstrap-responsive";

app/controllers/cms/base_controller.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,15 @@ class BaseController < Cms::ApplicationController
77

88
layout 'cms/application'
99

10+
11+
# Disables the default security level for actions, meaning they will be available for guests to access.
12+
# Users will not need to login prior to accessing these methods.
13+
#
14+
# @param [Array<Symbol>] methods List of methods to disable security for.
15+
def self.allow_guests_to(methods)
16+
skip_before_action :login_required, only: methods
17+
skip_before_action :cms_access_required, only: methods
18+
end
19+
1020
end
1121
end

0 commit comments

Comments
 (0)