Skip to content

Commit

Permalink
[#124] Form Builder
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
peakpg committed Nov 7, 2013
1 parent 044a309 commit 656e2ba
Show file tree
Hide file tree
Showing 148 changed files with 2,424 additions and 820 deletions.
29 changes: 22 additions & 7 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@ Bundler::GemHelper.install_tasks

require 'rake/testtask'

Rake::TestTask.new('test:units' => ['project:ensure_db_exists', 'app:test:prepare']) do |t|
Rake::TestTask.new('units') do |t|
t.libs << 'lib'
t.libs << 'test'
t.pattern = 'test/unit/**/*_test.rb'
t.verbose = false
end

Rake::TestTask.new('spec') do |t|
t.libs << 'lib'
t.libs << 'spec'
t.pattern = "spec/**/*_spec.rb"
end

Rake::TestTask.new('test:functionals' => ['project:ensure_db_exists', 'app:test:prepare']) do |t|
t.libs << 'lib'
t.libs << 'test'
Expand All @@ -46,11 +52,11 @@ require 'cucumber'
require 'cucumber/rake/task'

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

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

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


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

desc 'Runs all the tests'
desc "Runs all unit level tests"
task 'test:units' => ['app:test:prepare'] do
run_tests ["units", "spec"]
end

desc 'Runs all the tests, specs and scenarios.'
task :test => ['project:ensure_db_exists', 'app:test:prepare'] do
tests_to_run = ENV['TEST'] ? ["test:single"] : %w(test:units test:functionals test:integration features)
tests_to_run = ENV['TEST'] ? ["test:single"] : %w(test:units spec test:functionals test:integration features)
run_tests(tests_to_run)
end

def run_tests(tests_to_run)
errors = tests_to_run.collect do |task|
begin
Rake::Task[task].invoke
nil
rescue => e
{ :task => task, :exception => e }
{:task => task, :exception => e}
end
end.compact

Expand Down
6 changes: 3 additions & 3 deletions app/assets/javascripts/cms/ajax.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ jQuery(function ($) {

// Defaults for AJAX requests
$.ajaxSetup({
error:function (x, status, error) {
alert("A " + x.status + " error occurred: " + error);
},
beforeSend: $.cms_ajax.asJSON()
});
$(document).ajaxError(function (x, status, error) {
alert("A " + x.status + " error occurred: " + error);
});
});
1 change: 1 addition & 0 deletions app/assets/javascripts/cms/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
//= require jquery.taglist
//= require cms/core_library
//= require cms/attachment_manager
//= require cms/form_builder
//= require bootstrap
//

253 changes: 253 additions & 0 deletions app/assets/javascripts/cms/form_builder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
//= require cms/ajax
//= require underscore

/**
* The UI for dynamically creating custom forms via the UI.
* @constructor
*
*/

// Determine if an element exists.
// i.e. if($('.some-class').exists()){ // do something }
jQuery.fn.exists = function() {
return this.length > 0;
};

var FormBuilder = function() {
};

// Add a new field to the form
// (Implementation: Clone existing hidden form elements rather than build new ones via HTML).
FormBuilder.prototype.newField = function(field_type) {
this.hideNewFormInstruction();
this.addPreviewFieldToForm(field_type);

};

FormBuilder.prototype.addPreviewFieldToForm = function(field_type) {
$("#placeHolder").load($('#placeHolder').data('new-path') + '?field_type=' + field_type + ' .control-group', function() {
var newField = $("#placeHolder").find('.control-group');
newField.insertBefore('#placeHolder');
formBuilder.enableFieldButtons();
formBuilder.resetAddFieldButton();
});
};

FormBuilder.prototype.resetAddFieldButton = function() {
$("#form_new_entry_new_field").val('1');
};

FormBuilder.prototype.removeCurrentField = function() {
this.field_being_editted.remove();
this.field_being_editted = null;
};

// Function that triggers when users click the 'Delete' field button.
FormBuilder.prototype.confirmDeleteFormField = function() {
formBuilder.field_being_editted = $(this).parents('.control-group');

var path = $(this).attr('data-path');
if (path == "") {
formBuilder.removeCurrentField();
} else {
$('#modal-confirm-delete-field').modal({
show: true
});
}
};

// Function that triggers when users click the 'Edit' field button.
FormBuilder.prototype.editFormField = function() {
// This is the overall container for the entire field.
formBuilder.field_being_editted = $(this).parents('.control-group');
$('#modal-edit-field').removeData('modal').modal({
show: true,
remote: $(this).attr('data-edit-path')
});

};


FormBuilder.prototype.hideNewFormInstruction = function() {
var no_fields = $("#no-field-instructions");
if (no_fields.exists()) {
no_fields.hide();
}
};

// Add handler to any edit field buttons.
FormBuilder.prototype.enableFieldButtons = function() {
$('.edit_form_button').unbind('click').on('click', formBuilder.editFormField);
$('.delete_field_button').unbind('click').on('click', formBuilder.confirmDeleteFormField);
};

FormBuilder.prototype.newFormField = function() {
return $('#ajax_form_field');
};

// Delete field from form, then remove it from the field
FormBuilder.prototype.deleteFormField = function() {
var element = formBuilder.field_being_editted.find('.delete_field_button');
var url = element.attr('data-path');
$.cms_ajax.delete({
url: url,
success: function(field) {
formBuilder.removeCurrentField();
formBuilder.removeFieldId(field.id);
}
});
};

// @param [Number] value The id of the field that is to be removed from the form.
FormBuilder.prototype.removeFieldId = function(value) {
var field_ids = $('#field_ids').val().split(" ");
field_ids.splice($.inArray(value.toString(), field_ids), 1);
formBuilder.setFieldIds(field_ids);
};

// @param [Array<String>] value
FormBuilder.prototype.setFieldIds = function(value) {
var spaced_string = value.join(" ");
$('#field_ids').val(spaced_string);
};

FormBuilder.prototype.addFieldIdToList = function(new_value) {
$('#field_ids').val($('#field_ids').val() + " " + new_value);
};

// Save a new Field to the database for the current form.
FormBuilder.prototype.createField = function() {
var form = formBuilder.newFormField();
var data = form.serialize();
var url = form.attr('action');

$.ajax({
type: "POST",
url: url,
data: data,
global: false,
datatype: $.cms_ajax.asJSON()
}).done(
function(field) {
formBuilder.clearFieldErrorsOnCurrentField();

formBuilder.addFieldIdToList(field.id);
formBuilder.field_being_editted.find('input').attr('data-id', field.id);
formBuilder.field_being_editted.find('label').html(field.label);
formBuilder.field_being_editted.find('a').attr('data-edit-path', field.edit_path);
formBuilder.field_being_editted.find('a.delete_field_button').attr('data-path', field.delete_path);
formBuilder.field_being_editted.find('.help-block').html(field.instructions);

}
).fail(function(xhr, textStatus, errorThrown) {
formBuilder.displayErrorOnField(formBuilder.field_being_editted, xhr.responseJSON);
});

};

FormBuilder.prototype.clearFieldErrorsOnCurrentField = function() {
var field = formBuilder.field_being_editted;
field.removeClass("error");
field.find('.help-inline').remove();
};

FormBuilder.prototype.displayErrorOnField = function(field, json) {
var error_message = json.errors[0];
// console.log(error_message);
field.addClass("error");
var input_field = field.find('.input-append');
input_field.after('<span class="help-inline">' + error_message + '</span>');
};

// Attaches behavior to the proper element.
FormBuilder.prototype.setup = function() {
var select_box = $('.add-new-field');
if (select_box.exists()) {
select_box.change(function() {
formBuilder.newField($(this).val());
});

this.enableFieldButtons();
$("#create_field").on('click', formBuilder.createField);
$("#delete_field").on('click', formBuilder.deleteFormField);

// Edit Field should handle Enter by submitting the form via AJAX.
// Enter within textareas should still add endlines as normal.
$('#modal-edit-field').on('shown', function() {
formBuilder.newFormField().on("keypress", function(e) {
if (e.which == 13 && e.target.tagName != 'TEXTAREA') {
formBuilder.createField();
e.preventDefault();
$('#modal-edit-field').modal('hide');
return false;
}
});
});

// Allow fields to be sorted.
$('#form-preview').sortable({
axis: 'y',
delay: 250,

// When form element is moved
update: function(event, ui) {
var field_id = ui.item.find('input').attr('data-id');
var new_position = ui.item.index() + 1;
formBuilder.moveFieldTo(field_id, new_position);
}
});
this.setupConfirmationBehavior();
this.enableFormCleanup();
}
};

// Since we create a form for the #new action, we need to delete it if the user doesn't save it explicitly.
FormBuilder.prototype.enableFormCleanup = function() {
var cleanup_element = $('#cleanup-before-abandoning');
if (cleanup_element.exists()) {
var cleanup_on_leave = true;
$(":submit").on('click', function() {
cleanup_on_leave = false;
});
$(window).bind('beforeunload', function() {
if (cleanup_on_leave) {
var path = cleanup_element.attr('data-path');
$.cms_ajax.delete({url: path, async: false});
}
});
}
};

// Updates the server with the new position for a given field.
FormBuilder.prototype.moveFieldTo = function(field_id, position) {
var url = '/cms/form_fields/' + field_id + '/insert_at/' + position;

var success = function(data) {
console.log("Success:", data);
};
console.log('For', field_id, 'to', position);
$.post(url, success);
};

FormBuilder.prototype.setupConfirmationBehavior = function() {
// Confirmation Behavior
$("#form_confirmation_behavior_show_text").on('click', function() {
$(".form_confirmation_text").show();
$(".form_confirmation_redirect").hide();
});
$("#form_confirmation_behavior_redirect").on('click', function() {
$(".form_confirmation_redirect").show();
$(".form_confirmation_text").hide();
});
$("#form_confirmation_behavior_show_text").trigger('click');
};
var formBuilder = new FormBuilder();

// Register FormBuilder handlers on page load.
$(function() {
formBuilder.setup();


// Include a text field to start (For easier testing)
// formBuilder.newField('text_field');
});
17 changes: 17 additions & 0 deletions app/assets/stylesheets/cms/bootstrap-customizations.css.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
@import "bootstrap";
@import "bootstrap-responsive";

#form-preview {

// Add hover borders when over an element.
.control-group {
border: 1px solid transparent;
}
.control-group:hover {
border: 1px dashed #3B699F !important;
}
}

// For classes that need to explicitly extend bootstrap classes.
// They must go in this file.

Expand Down Expand Up @@ -95,4 +106,10 @@ textarea,

#asset_add_uploader {
display: none;
}

// simpleform f.error generates this class for model wide attributes.
#base-errors .help-inline {
@extend .alert-error;
@extend .alert;
}
4 changes: 4 additions & 0 deletions app/assets/stylesheets/cms/default-forms.css.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Default styles for public CMS Forms (built via the forms module.

@import "bootstrap";
@import "bootstrap-responsive";
10 changes: 10 additions & 0 deletions app/controllers/cms/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,15 @@ class BaseController < Cms::ApplicationController

layout 'cms/application'


# Disables the default security level for actions, meaning they will be available for guests to access.
# Users will not need to login prior to accessing these methods.
#
# @param [Array<Symbol>] methods List of methods to disable security for.
def self.allow_guests_to(methods)
skip_before_action :login_required, only: methods
skip_before_action :cms_access_required, only: methods
end

end
end
Loading

0 comments on commit 656e2ba

Please sign in to comment.