Merge pull request #293 from open-sausages/feature/multi-page-forms-v2

API (Major) Multipage forms, nested field grouping, UX improvements
This commit is contained in:
Christopher Pitt 2015-08-18 11:33:54 +12:00
commit a96d25ce1a
66 changed files with 2992 additions and 775 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
.DS_Store
.sass-cache/

12
.scrutinizer.yml Normal file
View File

@ -0,0 +1,12 @@
inherit: true
tools:
external_code_coverage: true
checks:
php:
code_rating: true
duplication: true
filter:
paths: [code/*, tests/*]

View File

@ -4,8 +4,11 @@ language: php
sudo: false
php:
- 5.4
php:
- 5.3
- 5.4
- 5.5
- 5.6
env:
- DB=MYSQL CORE_RELEASE=3
@ -13,14 +16,27 @@ env:
- DB=PGSQL CORE_RELEASE=3.1
matrix:
include:
- php: 5.3
env: DB=MYSQL CORE_RELEASE=3.1
exclude:
- php: 5.4
env: DB=MYSQL CORE_RELEASE=3
- php: 5.5
env: DB=MYSQL CORE_RELEASE=3
- php: 5.6
env: DB=MYSQL CORE_RELEASE=3
- php: 5.4
env: DB=PGSQL CORE_RELEASE=3.1
- php: 5.5
env: DB=PGSQL CORE_RELEASE=3.1
- php: 5.6
env: DB=PGSQL CORE_RELEASE=3.1
before_script:
- git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support
- php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss
- cd ~/builds/ss
- git clone git://github.com/silverstripe-labs/silverstripe-travis-support.git ~/travis-support
- php ~/travis-support/travis_setup.php --source `pwd` --target ~/builds/ss
- cd ~/builds/ss
script:
- phpunit userforms/tests/
- phpunit --coverage-clover userforms/coverage.clover userforms/tests
- cd userforms
- wget https://scrutinizer-ci.com/ocular.phar
- php ocular.phar code-coverage:upload --format=php-clover coverage.clover

View File

@ -32,65 +32,101 @@ class UserFormFieldEditorExtension extends DataExtension {
* @return GridField
*/
public function getFieldEditorGrid() {
Requirements::javascript(USERFORMS_DIR . '/javascript/FieldEditor.js');
$fields = $this->owner->Fields();
$this->createInitialFormStep(true);
$editableColumns = new GridFieldEditableColumns();
$fieldClasses = singleton('EditableFormField')->getEditableFieldClasses();
$editableColumns->setDisplayFields(array(
'ClassName' => function($record, $column, $grid) {
return DropdownField::create($column, '', $this->getEditableFieldClasses());
'ClassName' => function($record, $column, $grid) use ($fieldClasses) {
if($record instanceof EditableFormField) {
return $record->getInlineClassnameField($column, $fieldClasses);
}
},
'Title' => function($record, $column, $grid) {
return TextField::create($column, ' ')
->setAttribute('placeholder', _t('UserDefinedForm.TITLE', 'Title'));
if($record instanceof EditableFormField) {
return $record->getInlineTitleField($column);
}
}
));
$config = GridFieldConfig::create()
->addComponents(
$editableColumns,
new GridFieldButtonRow(),
GridFieldAddClassesButton::create('EditableTextField')
->setButtonName(_t('UserFormFieldEditorExtension.ADD_FIELD', 'Add Field'))
->setButtonClass('ss-ui-action-constructive'),
GridFieldAddClassesButton::create('EditableFormStep')
->setButtonName(_t('UserFormFieldEditorExtension.ADD_PAGE_BREAK', 'Add Page Break')),
GridFieldAddClassesButton::create(array('EditableFieldGroup', 'EditableFieldGroupEnd'))
->setButtonName(_t('UserFormFieldEditorExtension.ADD_FIELD_GROUP', 'Add Field Group')),
new GridFieldEditButton(),
new GridFieldDeleteAction(),
new GridFieldToolbarHeader(),
new GridFieldOrderableRows('Sort'),
new GridFieldDetailForm()
);
$fieldEditor = GridField::create(
'Fields',
_t('UserDefinedForm.FIELDS', 'Fields'),
$fields,
GridFieldConfig::create()
->addComponents(
$editableColumns,
new GridFieldButtonRow(),
new GridFieldAddNewInlineButton(),
new GridFieldEditButton(),
new GridFieldDeleteAction(),
new GridFieldToolbarHeader(),
new GridFieldOrderableRows('Sort'),
new GridState_Component(),
new GridFieldDetailForm()
)
);
$config
)->addExtraClass('uf-field-editor');
return $fieldEditor;
}
/**
* @return array
* A UserForm must have at least one step.
* If no steps exist, create an initial step, and put all fields inside it.
*
* @param bool $force
* @return void
*/
public function getEditableFieldClasses() {
$classes = ClassInfo::getValidSubClasses('EditableFormField');
// Remove classes we don't want to display in the dropdown.
$classes = array_diff($classes, array(
'EditableFormField',
'EditableMultipleOptionField'
));
$editableFieldClasses = array();
foreach ($classes as $key => $className) {
$singleton = singleton($className);
if(!$singleton->canCreate()) {
continue;
}
$editableFieldClasses[$className] = $singleton->i18n_singular_name();
public function createInitialFormStep($force = false) {
// Only invoke once saved
if(!$this->owner->exists()) {
return;
}
return $editableFieldClasses;
// Check if first field is a step
$fields = $this->owner->Fields();
$firstField = $fields->first();
if($firstField instanceof EditableFormStep) {
return;
}
// Don't create steps on write if there are no formfields, as this
// can create duplicate first steps during publish of new records
if(!$force && !$firstField) {
return;
}
// Re-apply sort to each field starting at 2
$next = 2;
foreach($fields as $field) {
$field->Sort = $next++;
$field->write();
}
// Add step
$step = EditableFormStep::create();
$step->Title = _t('EditableFormStep.TITLE_FIRST', 'First Page');
$step->Sort = 1;
$step->write();
$fields->add($step);
}
/**
* Ensure that at least one page exists at the start
*/
public function onAfterWrite() {
$this->createInitialFormStep();
}
/**

View File

@ -0,0 +1,126 @@
<?php
class UserFormValidator extends RequiredFields {
public function php($data) {
if(!parent::php($data)) {
return false;
}
// Skip unsaved records
if(empty($data['ID']) || !is_numeric($data['ID'])) {
return true;
}
$fields = EditableFormField::get()->filter('ParentID', $data['ID'])->sort('"Sort" ASC');
// Current nesting
$stack = array();
$conditionalStep = false; // Is the current step conditional?
foreach($fields as $field) {
if($field instanceof EditableFormStep) {
// Page at top level, or after another page is ok
if(empty($stack) || (count($stack) === 1 && $stack[0] instanceof EditableFormStep)) {
$stack = array($field);
$conditionalStep = $field->DisplayRules()->count() > 0;
continue;
}
$this->validationError(
'FormFields',
_t(
"UserFormValidator.UNEXPECTED_BREAK",
"Unexpected page break '{name}' inside nested field '{group}'",
array(
'name' => $field->CMSTitle,
'group' => end($stack)->CMSTitle
)
),
'error'
);
return false;
}
// Validate no pages
if(empty($stack)) {
$this->validationError(
'FormFields',
_t(
"UserFormValidator.NO_PAGE",
"Field '{name}' found before any pages",
array(
'name' => $field->CMSTitle
)
),
'error'
);
return false;
}
// Nest field group
if($field instanceof EditableFieldGroup) {
$stack[] = $field;
continue;
}
// Unnest field group
if($field instanceof EditableFieldGroupEnd) {
$top = end($stack);
// Check that the top is a group at all
if(!$top instanceof EditableFieldGroup) {
$this->validationError(
'FormFields',
_t(
"UserFormValidator.UNEXPECTED_GROUP_END",
"'{name}' found without a matching group",
array(
'name' => $field->CMSTitle
)
),
'error'
);
return false;
}
// Check that the top is the right group
if($top->EndID != $field->ID) {
$this->validationError(
'FormFields',
_t(
"UserFormValidator.WRONG_GROUP_END",
"'{name}' found closes the wrong group '{group}'",
array(
'name' => $field->CMSTitle,
'group' => $top->CMSTitle
)
),
'error'
);
return false;
}
// Unnest group
array_pop($stack);
}
// Normal field type
if($conditionalStep && $field->Required) {
$this->validationError(
'FormFields',
_t(
"UserFormValidator.CONDITIONAL_REQUIRED",
"Required field '{name}' cannot be placed within a conditional page",
array(
'name' => $field->CMSTitle
)
),
'error'
);
return false;
}
}
return true;
}
}

View File

@ -0,0 +1,47 @@
<?php
/**
* Represents a composite field group, which may contain other groups
*/
abstract class UserFormsCompositeField extends CompositeField implements UserFormsFieldContainer {
/**
* Parent field
*
* @var UserFormsFieldContainer
*/
protected $parent = null;
public function getParent() {
return $this->parent;
}
public function setParent(UserFormsFieldContainer $parent) {
$this->parent = $parent;
return $this;
}
public function processNext(EditableFormField $field) {
// When we find a step, bubble up to the top
if($field instanceof EditableFormStep) {
return $this->getParent()->processNext($field);
}
// Skip over fields that don't generate formfields
$formField = $field->getFormField();
if(!$formField) {
return $this;
}
// Save this field
$this->push($formField);
// Nest fields that are containers
if($formField instanceof UserFormsFieldContainer) {
return $formField->setParent($this);
}
// Add any subsequent fields to this
return $this;
}
}

View File

@ -0,0 +1,30 @@
<?php
/**
* Represents a field container which can iteratively process nested fields, converting it into a fieldset
*/
interface UserFormsFieldContainer {
/**
* Process the next field in the list, returning the container to add the next field to.
*
* @param EditableFormField $field
* @return EditableContainerField
*/
public function processNext(EditableFormField $field);
/**
* Set the parent
*
* @param UserFormsFieldContainer $parent
* @return $this
*/
public function setParent(UserFormsFieldContainer $parent);
/**
* Get the parent
*
* @return UserFormsFieldContainer
*/
public function getParent();
}

View File

@ -0,0 +1,43 @@
<?php
/**
* A list of formfields which allows for iterative processing of nested composite fields
*/
class UserFormsFieldList extends FieldList implements UserFormsFieldContainer {
public function processNext(EditableFormField $field) {
$formField = $field->getFormField();
if(!$formField) {
return $this;
}
$this->push($formField);
if($formField instanceof UserFormsFieldContainer) {
return $formField->setParent($this);
}
return $this;
}
public function getParent() {
// Field list does not have a parent
return null;
}
public function setParent(UserFormsFieldContainer $parent) {
return $this;
}
/**
* Remove all empty steps
*/
public function clearEmptySteps() {
foreach($this as $field) {
if($field instanceof UserFormsStepField && count($field->getChildren()) === 0) {
$this->remove($field);
}
}
}
}

View File

@ -0,0 +1,27 @@
<?php
/**
* Front end composite field for userforms
*/
class UserFormsGroupField extends UserFormsCompositeField {
public function __construct($children = null) {
parent::__construct($children);
$this->setTag('fieldset');
}
public function getLegend() {
// Legend defaults to title
return parent::getLegend() ?: $this->Title();
}
public function processNext(EditableFormField $field) {
// When ending a group, jump up one level
if($field instanceof EditableFieldGroupEnd) {
return $this->getParent();
}
// Otherwise behave as per normal composite field
return parent::processNext($field);
}
}

View File

@ -0,0 +1,44 @@
<?php
/**
* Represents a page step in a form, which may contain form fields or other groups
*/
class UserFormsStepField extends UserFormsCompositeField {
private static $casting = array(
'StepNumber' => 'Int'
);
/**
* Numeric index (1 based) of this step
*
* Null if unassigned
*
* @var int|null
*/
protected $number = null;
public function FieldHolder($properties = array()) {
return $this->Field($properties);
}
/**
* Get the step number
*
* @return int|null
*/
public function getStepNumber() {
return $this->number;
}
/**
* Re-assign this step to another number
*
* @param type $number
* @return $this
*/
public function setStepNumber($number) {
$this->number = $number;
return $this;
}
}

View File

@ -0,0 +1,232 @@
<?php
/**
* A button which allows objects to be created with a specified classname(s)
*/
class GridFieldAddClassesButton extends Object implements GridField_HTMLProvider, GridField_ActionProvider {
/**
* Name of fragment to insert into
*
* @var string
*/
protected $targetFragment;
/**
* Button title
*
* @var string
*/
protected $buttonName;
/**
* Additonal CSS classes for the button
*
* @var string
*/
protected $buttonClass = null;
/**
* Class names
*
* @var array
*/
protected $modelClasses = null;
/**
* @param array $classes Class or list of classes to create.
* If you enter more than one class, each click of the "add" button will create one of each
* @param string $targetFragment The fragment to render the button into
*/
public function __construct($classes, $targetFragment = 'buttons-before-left') {
parent::__construct();
$this->setClasses($classes);
$this->setFragment($targetFragment);
}
/**
* Change the button name
*
* @param string $name
* @return $this
*/
public function setButtonName($name) {
$this->buttonName = $name;
return $this;
}
/**
* Get the button name
*
* @return string
*/
public function getButtonName() {
return $this->buttonName;
}
/**
* Gets the fragment name this button is rendered into.
*
* @return string
*/
public function getFragment() {
return $this->targetFragment;
}
/**
* Sets the fragment name this button is rendered into.
*
* @param string $fragment
* @return GridFieldAddNewInlineButton $this
*/
public function setFragment($fragment) {
$this->targetFragment = $fragment;
return $this;
}
/**
* Get extra button class
*
* @return string
*/
public function getButtonClass() {
return $this->buttonClass;
}
/**
* Sets extra CSS classes for this button
*
* @param string $buttonClass
* @return $this
*/
public function setButtonClass($buttonClass) {
$this->buttonClass = $buttonClass;
return $this;
}
/**
* Get the classes of the objects to create
*
* @return array
*/
public function getClasses() {
return $this->modelClasses;
}
/**
* Gets the list of classes which can be created, with checks for permissions.
* Will fallback to the default model class for the given DataGrid
*
* @param DataGrid $grid
* @return array
*/
public function getClassesCreate($grid) {
// Get explicit or fallback class list
$classes = $this->getClasses();
if(empty($classes) && $grid) {
$classes = array($grid->getModelClass());
}
// Filter out classes without permission
return array_filter($classes, function($class) {
return singleton($class)->canCreate();
});
}
/**
* Specify the classes to create
*
* @param array $classes
*/
public function setClasses($classes) {
if(!is_array($classes)) {
$classes = $classes ? array($classes) : array();
}
$this->modelClasses = $classes;
}
public function getHTMLFragments($grid) {
// Check create permission
$singleton = singleton($grid->getModelClass());
if(!$singleton->canCreate()) {
return array();
}
// Get button name
$buttonName = $this->getButtonName();
if(!$buttonName) {
// provide a default button name, can be changed by calling {@link setButtonName()} on this component
$objectName = $singleton->i18n_singular_name();
$buttonName = _t('GridField.Add', 'Add {name}', array('name' => $objectName));
}
$addAction = new GridField_FormAction(
$grid,
$this->getAction(),
$buttonName,
$this->getAction(),
array()
);
$addAction->setAttribute('data-icon', 'add');
if($this->getButtonClass()) {
$addAction->addExtraClass($this->getButtonClass());
}
return array(
$this->targetFragment => $addAction->forTemplate()
);
}
/**
* {@inheritDoc}
*/
public function getActions($gridField) {
return array(
$this->getAction()
);
}
/**
* Get the action suburl for this component
*
* @return string
*/
protected function getAction() {
return 'add-classes-' . strtolower(implode('-', $this->getClasses()));
}
public function handleAction(GridField $gridField, $actionName, $arguments, $data) {
switch(strtolower($actionName)) {
case $this->getAction():
return $this->handleAdd($gridField);
default:
return null;
}
}
/**
* Handles adding a new instance of a selected class.
*
* @param GridField $grid
* @return null
*/
public function handleAdd($grid) {
$classes = $this->getClassesCreate($grid);
if(empty($classes)) {
throw new SS_HTTPResponse_Exception(400);
}
// Add item to gridfield
$list = $grid->getList();
foreach($classes as $class) {
$item = $class::create();
$item->write();
$list->add($item);
}
// Should trigger a simple reload
return null;
}
}

View File

@ -22,6 +22,12 @@ class UserForm extends Form {
$this->getRequiredFields()
);
// Number each page
$stepNumber = 1;
foreach($this->getSteps() as $step) {
$step->setStepNumber($stepNumber++);
}
if($controller->DisableCsrfSecurityToken) {
$this->disableSecurityToken();
}
@ -35,57 +41,50 @@ class UserForm extends Form {
$this->extend('updateForm');
}
/**
* Used for partial caching in the template.
*
* @return string
*/
public function getLastEdited() {
return $this->controller->LastEdited;
}
/**
* @return bool
*/
public function getDisplayErrorMessagesAtTop() {
return (bool)$this->controller->DisplayErrorMessagesAtTop;
}
/**
* Return the fieldlist, filtered to only contain steps
*
* @return ArrayList
*/
public function getSteps() {
return $this->Fields()->filterByCallback(function($field) {
return $field instanceof UserFormsStepField;
});
}
/**
* Get the form fields for the form on this page. Can modify this FieldSet
* by using {@link updateFormFields()} on an {@link Extension} subclass which
* is applied to this controller.
*
* This will be a list of top level composite steps
*
* @return FieldList
*/
public function getFormFields() {
$fields = new FieldList();
foreach($this->controller->Fields() as $editableField) {
// get the raw form field from the editable version
$field = $editableField->getFormField();
if(!$field) continue;
// set the error / formatting messages
$field->setCustomValidationMessage($editableField->getErrorMessage());
// set the right title on this field
if($right = $editableField->RightTitle) {
// Since this field expects raw html, safely escape the user data prior
$field->setRightTitle(Convert::raw2xml($right));
}
// if this field is required add some
if($editableField->Required) {
$field->addExtraClass('requiredField');
if($identifier = UserDefinedForm::config()->required_identifier) {
$title = $field->Title() ." <span class='required-identifier'>". $identifier . "</span>";
$field->setTitle($title);
}
}
// if this field has an extra class
if($extraClass = $editableField->ExtraClass) {
$field->addExtraClass(Convert::raw2att($extraClass));
}
// set the values passed by the url to the field
$request = $this->controller->getRequest();
if($value = $request->getVar($field->getName())) {
$field->setValue($value);
}
$fields->push($field);
$fields = new UserFormsFieldList();
$target = $fields;
foreach ($this->controller->Fields() as $field) {
$target = $target->processNext($field);
}
$fields->clearEmptySteps();
$this->extend('updateFormFields', $fields);
return $fields;
}
@ -128,9 +127,7 @@ class UserForm extends Form {
->filter('Required', true)
->column('Name');
$required = new RequiredFields($requiredNames);
$this->extend('updateRequiredFields', $required);
return $required;
}
@ -155,4 +152,20 @@ class UserForm extends Form {
return true;
}
/**
* Override some we can add UserForm specific attributes to the form.
*
* @return array
*/
public function getAttributes() {
$attrs = parent::getAttributes();
$attrs['class'] = $attrs['class'] . ' userform';
$attrs['data-livevalidation'] = (bool)$this->controller->EnableLiveValidation;
$attrs['data-toperrors'] = (bool)$this->controller->DisplayErrorMessagesAtTop;
$attrs['data-hidefieldlabels'] = (bool)$this->controller->HideFieldLabels;
return $attrs;
}
}

View File

@ -98,6 +98,7 @@ class UserDefinedForm extends Page {
* @return FieldList
*/
public function getCMSFields() {
Requirements::css(USERFORMS_DIR . '/css/UserForm_cms.css');
$self = $this;
@ -169,7 +170,6 @@ SQL;
$config->addComponent($filter = new UserFormsGridFieldFilterHeader());
$config->addComponent(new GridFieldDataColumns());
$config->addComponent(new GridFieldEditButton());
$config->addComponent(new GridState_Component());
$config->addComponent(new GridFieldDeleteAction());
$config->addComponent(new GridFieldPageCount('toolbar-header-right'));
$config->addComponent($pagination = new GridFieldPaginator(25));
@ -283,6 +283,14 @@ SQL;
DB::alteration_message('Migrated userforms', 'changed');
}
/**
* Validate formfields
*/
public function getCMSValidator() {
return new UserFormValidator();
}
}
/**
@ -307,10 +315,11 @@ class UserDefinedForm_Controller extends Page_Controller {
// load the jquery
$lang = i18n::get_lang_from_locale(i18n::get_locale());
Requirements::css(USERFORMS_DIR . '/css/UserForm.css');
Requirements::javascript(FRAMEWORK_DIR .'/thirdparty/jquery/jquery.js');
Requirements::javascript(USERFORMS_DIR . '/thirdparty/jquery-validate/jquery.validate.min.js');
Requirements::add_i18n_javascript(USERFORMS_DIR . '/javascript/lang');
Requirements::javascript(USERFORMS_DIR . '/javascript/UserForm_frontend.js');
Requirements::javascript(USERFORMS_DIR . '/javascript/UserForm.js');
Requirements::javascript(
USERFORMS_DIR . "/thirdparty/jquery-validate/localization/messages_{$lang}.min.js"
);
@ -364,28 +373,10 @@ class UserDefinedForm_Controller extends Page_Controller {
*/
public function Form() {
$form = UserForm::create($this);
$this->generateConditionalJavascript();
$this->generateValidationJavascript($form);
return $form;
}
/**
* Build jQuery validation script and require as a custom script
*
* @param UserForm $form
*/
public function generateValidationJavascript(UserForm $form) {
// set the custom script for this form
Requirements::customScript(
$this
->customise(array('Form' => $form))
->renderWith('ValidationScript'),
'UserFormsValidation'
);
}
/**
* Generate the javascript for the conditional field show / hiding logic.
*
@ -400,15 +391,11 @@ class UserDefinedForm_Controller extends Page_Controller {
if($this->Fields()) {
foreach($this->Fields() as $field) {
$fieldId = $field->Name;
if($field instanceof EditableFormHeading) {
$fieldId = 'UserForm_Form_' . $field->Name;
}
$holderSelector = $field->getSelectorHolder();
// Is this Field Show by Default
if(!$field->ShowOnLoad) {
$default .= "$(\"#" . $fieldId . "\").hide();\n";
$default .= "{$holderSelector}.hide().trigger('userform.field.hide');\n";
}
// Check for field dependencies / default
@ -417,22 +404,8 @@ class UserDefinedForm_Controller extends Page_Controller {
// Get the field which is effected
$formFieldWatch = EditableFormField::get()->byId($rule->ConditionFieldID);
if($formFieldWatch->RecordClassName == 'EditableDropdown') {
// watch out for multiselect options - radios and check boxes
$fieldToWatch = "$(\"select[name='" . $formFieldWatch->Name . "']\")";
$fieldToWatchOnLoad = $fieldToWatch;
} else if($formFieldWatch->RecordClassName == 'EditableCheckboxGroupField') {
// watch out for checkboxs as the inputs don't have values but are 'checked
$fieldToWatch = "$(\"input[name='" . $formFieldWatch->Name . "[" . $rule->FieldValue . "]']\")";
$fieldToWatchOnLoad = $fieldToWatch;
} else if($formFieldWatch->RecordClassName == 'EditableRadioField') {
$fieldToWatch = "$(\"input[name='" . $formFieldWatch->Name . "']\")";
// We only want to trigger on load once for the radio group - hence we focus on the first option only.
$fieldToWatchOnLoad = "$(\"input[name='" . $formFieldWatch->Name . "']:first\")";
} else {
$fieldToWatch = "$(\"input[name='" . $formFieldWatch->Name . "']\")";
$fieldToWatchOnLoad = $fieldToWatch;
}
$fieldToWatch = $formFieldWatch->getSelectorField($rule);
$fieldToWatchOnLoad = $formFieldWatch->getSelectorField($rule, true);
// show or hide?
$view = ($rule->Display == 'Hide') ? 'hide' : 'show';
@ -442,7 +415,7 @@ class UserDefinedForm_Controller extends Page_Controller {
// @todo encapulsation
$action = "change";
if($formFieldWatch->ClassName == "EditableTextField") {
if($formFieldWatch instanceof EditableTextField) {
$action = "keyup";
}
@ -460,11 +433,11 @@ class UserDefinedForm_Controller extends Page_Controller {
// and what should we evaluate
switch($rule->ConditionOption) {
case 'IsNotBlank':
$expression = ($checkboxField || $radioField) ? '$(this).prop("checked")' :'$(this).val() != ""';
$expression = ($checkboxField || $radioField) ? '$(this).is(":checked")' :'$(this).val() != ""';
break;
case 'IsBlank':
$expression = ($checkboxField || $radioField) ? '!($(this).prop("checked"))' : '$(this).val() == ""';
$expression = ($checkboxField || $radioField) ? '!($(this).is(":checked"))' : '$(this).val() == ""';
break;
case 'HasValue':
@ -511,9 +484,9 @@ class UserDefinedForm_Controller extends Page_Controller {
$watch[$fieldToWatch] = array();
}
$watch[$fieldToWatch][] = array(
$watch[$fieldToWatch][] = array(
'expression' => $expression,
'field_id' => $fieldId,
'holder_selector' => $holderSelector,
'view' => $view,
'opposite' => $opposite,
'action' => $action
@ -530,16 +503,26 @@ class UserDefinedForm_Controller extends Page_Controller {
$actions = array();
foreach($values as $rule) {
// Register conditional behaviour with an element, so it can be triggered from many places.
$logic[] = sprintf(
'if(%s) { $("#%s").%s(); } else { $("#%2$s").%s(); }',
$rule['expression'],
$rule['field_id'],
$rule['view'],
$rule['opposite']
);
// Assign action
$actions[$rule['action']] = $rule['action'];
// Assign behaviour
$expression = $rule['expression'];
$holder = $rule['holder_selector'];
$view = $rule['view']; // hide or show
$opposite = $rule['opposite'];
// Generated javascript for triggering visibility
$logic[] = <<<"EOS"
if({$expression}) {
{$holder}
.{$view}()
.trigger('userform.field.{$view}');
} else {
{$holder}
.{$opposite}()
.trigger('userform.field.{$opposite}');
}
EOS;
}
$logic = implode("\n", $logic);
@ -581,41 +564,15 @@ JS
/**
* Process the form that is submitted through the site
*
*
* {@see UserForm::validate()} for validation step prior to processing
*
* @param array $data
* @param Form $form
*
* @return Redirection
*/
public function process($data, $form) {
Session::set("FormInfo.{$form->FormName()}.data",$data);
Session::clear("FormInfo.{$form->FormName()}.errors");
foreach($this->Fields() as $field) {
$messages[$field->Name] = $field->getErrorMessage()->HTML();
$formField = $field->getFormField();
if($field->Required && $field->DisplayRules()->Count() == 0) {
if(isset($data[$field->Name])) {
$formField->setValue($data[$field->Name]);
}
if(
!isset($data[$field->Name]) ||
!$data[$field->Name] ||
!$formField->validate($form->getValidator())
) {
$form->addErrorMessage($field->Name, $field->getErrorMessage(), 'bad');
}
}
}
if(Session::get("FormInfo.{$form->FormName()}.errors")){
Controller::curr()->redirectBack();
return;
}
$submittedForm = Object::create('SubmittedForm');
$submittedForm->SubmittedByID = ($id = Member::currentUserID()) ? $id : 0;
$submittedForm->ParentID = $this->ID;
@ -625,9 +582,7 @@ JS
$submittedForm->write();
}
$values = array();
$attachments = array();
$submittedFields = new ArrayList();
foreach($this->Fields() as $field) {
@ -809,10 +764,10 @@ JS
* Allows the use of field values in email body.
*
* @param ArrayList fields
* @return ViewableData
* @return ArrayData
*/
private function getMergeFieldsMap($fields = array()) {
$data = new ViewableData();
$data = new ArrayData(array());
foreach ($fields as $field) {
$data->setField($field->Name, DBField::create_field('Text', $field->Value));

View File

@ -14,17 +14,16 @@ class EditableCheckboxGroupField extends EditableMultipleOptionField {
private static $plural_name = "Checkbox Groups";
public function getFormField() {
$optionSet = $this->Options();
$optionMap = $optionSet->map('EscapedTitle', 'Title');
$field = new UserFormsCheckboxSetField($this->Name, $this->Title, $optionMap);
$field = new UserFormsCheckboxSetField($this->Name, $this->EscapedTitle, $this->getOptionsMap());
$field->setTemplate('forms/UserFormsCheckboxSetField');
// Set the default checked items
$defaultCheckedItems = $optionSet->filter('Default', 1);
$defaultCheckedItems = $this->getDefaultOptions();
if ($defaultCheckedItems->count()) {
$field->setDefaultItems($defaultCheckedItems->map('EscapedTitle')->keys());
}
$this->doUpdateFormField($field);
return $field;
}
@ -46,4 +45,14 @@ class EditableCheckboxGroupField extends EditableMultipleOptionField {
}
return $result;
}
public function getSelectorField(EditableCustomRule $rule, $forOnLoad = false) {
// watch out for checkboxs as the inputs don't have values but are 'checked
// @todo - Test this
if($rule->FieldValue) {
return "$(\"input[name='{$this->Name}[]'][value='{$rule->FieldValue}']\")";
} else {
return "$(\"input[name='{$this->Name}[]']:first\")";
}
}
}

View File

@ -23,7 +23,9 @@ class EditableCountryDropdownField extends EditableFormField {
}
public function getFormField() {
return CountryDropdownField::create($this->Name, $this->Title);
$field = CountryDropdownField::create($this->Name, $this->EscapedTitle);
$this->doUpdateFormField($field);
return $field;
}
public function getValueFromData($data) {
@ -36,4 +38,8 @@ class EditableCountryDropdownField extends EditableFormField {
public function getIcon() {
return USERFORMS_DIR . '/images/editabledropdown.png';
}
public function getSelectorField(EditableCustomRule $rule, $forOnLoad = false) {
return "$(\"select[name='{$this->Name}']\")";
}
}

View File

@ -43,17 +43,9 @@ class EditableDateField extends EditableFormField {
$defaultValue = $this->DefaultToToday
? SS_Datetime::now()->Format('Y-m-d')
: $this->Default;
$field = EditableDateField_FormField::create( $this->Name, $this->Title, $defaultValue);
$field = EditableDateField_FormField::create( $this->Name, $this->EscapedTitle, $defaultValue);
$field->setConfig('showcalendar', true);
if ($this->Required) {
// Required validation can conflict so add the Required validation messages
// as input attributes
$errorMessage = $this->getErrorMessage()->HTML();
$field->setAttribute('data-rule-required', 'true');
$field->setAttribute('data-msg-required', $errorMessage);
}
$this->doUpdateFormField($field);
return $field;
}
}

View File

@ -0,0 +1,45 @@
<?php
/**
* EditableDropdown
*
* Represents a modifiable dropdown (select) box on a form
*
* @package userforms
*/
class EditableDropdown extends EditableMultipleOptionField {
private static $singular_name = 'Dropdown Field';
private static $plural_name = 'Dropdowns';
/**
* @return FieldList
*/
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->removeByName('Default');
return $fields;
}
/**
* @return DropdownField
*/
public function getFormField() {
$field = DropdownField::create($this->Name, $this->EscapedTitle, $this->getOptionsMap());
// Set default
$defaultOption = $this->getDefaultOptions()->first();
if($defaultOption) {
$field->setValue($defaultOption->EscapedTitle);
}
$this->doUpdateFormField($field);
return $field;
}
public function getSelectorField(EditableCustomRule $rule, $forOnLoad = false) {
return "$(\"select[name='{$this->Name}']\")";
}
}

View File

@ -0,0 +1,36 @@
<?php
/**
* EditableEmailField
*
* Allow users to define a validating editable email field for a UserDefinedForm
*
* @package userforms
*/
class EditableEmailField extends EditableFormField {
private static $singular_name = 'Email Field';
private static $plural_name = 'Email Fields';
public function getSetsOwnError() {
return true;
}
public function getFormField() {
$field = EmailField::create($this->Name, $this->EscapedTitle, $this->Default);
$this->doUpdateFormField($field);
return $field;
}
/**
* Updates a formfield with the additional metadata specified by this field
*
* @param FormField $field
*/
protected function updateFormField($field) {
parent::updateFormField($field);
$field->setAttribute('data-rule-email', true);
}
}

View File

@ -0,0 +1,91 @@
<?php
/**
* Specifies that this ends a group of fields
*/
class EditableFieldGroup extends EditableFormField {
private static $has_one = array(
'End' => 'EditableFieldGroupEnd'
);
/**
* Disable selection of group class
*
* @config
* @var bool
*/
private static $hidden = true;
/**
* Non-data field type
*
* @var type
*/
private static $literal = true;
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->removeByName(array('MergeField', 'Default', 'Validation', 'DisplayRules'));
return $fields;
}
public function getCMSTitle() {
$title = $this->getFieldNumber()
?: $this->Title
?: 'group';
return _t(
'EditableFieldGroupEnd.FIELD_GROUP_START',
'Group {group}',
array(
'group' => $title
)
);
}
public function getInlineClassnameField($column, $fieldClasses) {
return new LabelField($column, $this->CMSTitle);
}
public function showInReports() {
return false;
}
public function getFormField() {
$field = UserFormsGroupField::create()
->setTitle($this->EscapedTitle ?: false);
$this->doUpdateFormField($field);
return $field;
}
protected function updateFormField($field) {
// set the right title on this field
if($this->RightTitle) {
// Since this field expects raw html, safely escape the user data prior
$field->setRightTitle(Convert::raw2xml($this->RightTitle));
}
// if this field has an extra class
if($this->ExtraClass) {
$field->addExtraClass($this->ExtraClass);
}
}
protected function onBeforeDelete() {
parent::onBeforeDelete();
// Ensures EndID is lazy-loaded for onAfterDelete
$this->EndID;
}
protected function onAfterDelete() {
parent::onAfterDelete();
// Delete end
if(($end = $this->End()) && $end->exists()) {
$end->delete();
}
}
}

View File

@ -0,0 +1,94 @@
<?php
/**
* Specifies that this ends a group of fields
*/
class EditableFieldGroupEnd extends EditableFormField {
private static $belongs_to = array(
'Group' => 'EditableFieldGroup'
);
/**
* Disable selection of group class
*
* @config
* @var bool
*/
private static $hidden = true;
/**
* Non-data type
*
* @config
* @var bool
*/
private static $literal = true;
public function getCMSTitle() {
$group = $this->Group();
return _t(
'EditableFieldGroupEnd.FIELD_GROUP_END',
'{group} end',
array(
'group' => ($group && $group->exists()) ? $group->CMSTitle : 'Group'
)
);
}
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->removeByName(array('MergeField', 'Default', 'Validation', 'DisplayRules'));
return $fields;
}
public function getInlineClassnameField($column, $fieldClasses) {
return new LabelField($column, $this->CMSTitle);
}
public function getInlineTitleField($column) {
return HiddenField::create($column);
}
public function getFormField() {
return null;
}
public function showInReports() {
return false;
}
public function onAfterWrite() {
parent::onAfterWrite();
// If this is not attached to a group, find the first group prior to this
// with no end attached
$group = $this->Group();
if(!($group && $group->exists()) && $this->ParentID) {
$group = EditableFieldGroup::get()
->filter(array(
'ParentID' => $this->ParentID,
'Sort:LessThanOrEqual' => $this->Sort
))
->where('"EditableFieldGroup"."EndID" IS NULL OR "EditableFieldGroup"."EndID" = 0')
->sort('"Sort" DESC')
->first();
// When a group is found, attach it to this end
if($group) {
$group->EndID = $this->ID;
$group->write();
}
}
}
protected function onAfterDelete() {
parent::onAfterDelete();
// Delete group
if(($group = $this->Group()) && $group->exists()) {
$group->delete();
}
}
}

View File

@ -35,7 +35,7 @@ class EditableFileField extends EditableFormField {
}
public function getFormField() {
$field = FileField::create($this->Name, $this->Title);
$field = FileField::create($this->Name, $this->EscapedTitle);
// filter out '' since this would be a regex problem on JS end
$field->getValidator()->setAllowedExtensions(
@ -49,13 +49,7 @@ class EditableFileField extends EditableFormField {
);
}
if ($this->Required) {
// Required validation can conflict so add the Required validation messages
// as input attributes
$errorMessage = $this->getErrorMessage()->HTML();
$field->setAttribute('data-rule-required', 'true');
$field->setAttribute('data-msg-required', $errorMessage);
}
$this->doUpdateFormField($field);
return $field;
}

View File

@ -1,13 +1,45 @@
<?php
use SilverStripe\Forms\SegmentField;
use SilverStripe\Forms\SegmentFieldModifier\IDSegmentFieldModifier;
use SilverStripe\Forms\SegmentFieldModifier\SlugSegmentFieldModifier;
/**
* Represents the base class of a editable form field
* object like {@link EditableTextField}.
* Represents the base class of a editable form field
* object like {@link EditableTextField}.
*
* @package userforms
*
* @property string Name
*
* @method DataList DisplayRules() List of EditableCustomRule objects
*/
class EditableFormField extends DataObject {
/**
* Set to true to hide from class selector
*
* @config
* @var bool
*/
private static $hidden = false;
/**
* Define this field as abstract (not inherited)
*
* @config
* @var bool
*/
private static $abstract = true;
/**
* Flag this field type as non-data (e.g. literal, header, html)
*
* @config
* @var bool
*/
private static $literal = false;
/**
* Default sort order
*
@ -15,7 +47,7 @@ class EditableFormField extends DataObject {
* @var string
*/
private static $default_sort = '"Sort"';
/**
* A list of CSS classes that can be added
*
@ -87,14 +119,14 @@ class EditableFormField extends DataObject {
* Set the visibility of an individual form field
*
* @param bool
*/
*/
public function setReadonly($readonly = true) {
$this->readonly = $readonly;
}
/**
* Returns whether this field is readonly
*
* Returns whether this field is readonly
*
* @return bool
*/
private function isReadonly() {
@ -130,7 +162,11 @@ class EditableFormField extends DataObject {
),
TextField::create('Title'),
TextField::create('Default', _t('EditableFormField.DEFAULT', 'Default value')),
TextField::create('RightTitle', _t('EditableFormField.RIGHTTITLE', 'Right title'))
TextField::create('RightTitle', _t('EditableFormField.RIGHTTITLE', 'Right title')),
SegmentField::create('Name')->setModifiers(array(
UnderscoreSegmentFieldModifier::create()->setDefault('FieldName'),
DisambiguationSegmentFieldModifier::create(),
))->setPreview($this->Name)
)
);
@ -168,21 +204,25 @@ class EditableFormField extends DataObject {
}
// Validation
$fields->addFieldsToTab(
'Root.Validation',
$this->getFieldValidationOptions()
);
$validationFields = $this->getFieldValidationOptions();
if($validationFields) {
$fields->addFieldsToTab(
'Root.Validation',
$this->getFieldValidationOptions()
);
}
$allowedClasses = array_keys($this->getEditableFieldClasses(false));
$editableColumns = new GridFieldEditableColumns();
$editableColumns->setDisplayFields(array(
'Display' => '',
'ConditionFieldID' => function($record, $column, $grid) {
'ConditionFieldID' => function($record, $column, $grid) use ($allowedClasses) {
return DropdownField::create(
$column,
'',
EditableFormField::get()
->filter(array(
'ParentID' => $this->ParentID
'ParentID' => $this->ParentID,
'ClassName' => $allowedClasses
))
->exclude(array(
'ID' => $this->ID
@ -209,8 +249,7 @@ class EditableFormField extends DataObject {
new GridFieldButtonRow(),
new GridFieldToolbarHeader(),
new GridFieldAddNewInlineButton(),
new GridFieldDeleteAction(),
new GridState_Component()
new GridFieldDeleteAction()
);
$fields->addFieldsToTab('Root.DisplayRules', array(
@ -238,6 +277,10 @@ class EditableFormField extends DataObject {
public function onBeforeWrite() {
parent::onBeforeWrite();
if($this->Name === 'Field') {
throw new ValidationException('Field name cannot be "Field"');
}
if(!$this->Sort && $this->ParentID) {
$parentID = $this->ParentID;
$this->Sort = EditableFormField::get()
@ -254,20 +297,20 @@ class EditableFormField extends DataObject {
// Set a field name.
if(!$this->Name) {
$this->Name = $this->RecordClassName . $this->ID;
$this->Name = get_class($this) . '_' . $this->ID;
$this->write();
}
}
/**
* Flag indicating that this field will set its own error message via data-msg='' attributes
*
*
* @return bool
*/
public function getSetsOwnError() {
return false;
}
/**
* Return whether a user can delete this form field
* based on whether they can edit the page
@ -281,7 +324,7 @@ class EditableFormField extends DataObject {
return true;
}
/**
* Return whether a user can edit this form field
* based on whether they can edit the page
@ -295,10 +338,10 @@ class EditableFormField extends DataObject {
return true;
}
/**
* Publish this Form Field to the live site
*
*
* Wrapper for the {@link Versioned} publish function
*/
public function doPublish($fromStage, $toStage, $createNewVersion = false) {
@ -309,7 +352,7 @@ class EditableFormField extends DataObject {
$rule->doPublish($fromStage, $toStage, $createNewVersion);
}
}
/**
* Delete this form from a given stage
*
@ -323,7 +366,7 @@ class EditableFormField extends DataObject {
$rule->deleteFromStage($stage);
}
}
/**
* checks wether record is new, copied from Sitetree
*/
@ -348,7 +391,7 @@ class EditableFormField extends DataObject {
return ($stageVersion && $stageVersion != $liveVersion);
}
/**
* @deprecated since version 4.0
*/
@ -356,7 +399,7 @@ class EditableFormField extends DataObject {
Deprecation::notice('4.0', 'getSettings is deprecated');
return (!empty($this->CustomSettings)) ? unserialize($this->CustomSettings) : array();
}
/**
* @deprecated since version 4.0
*/
@ -364,7 +407,7 @@ class EditableFormField extends DataObject {
Deprecation::notice('4.0', 'setSettings is deprecated');
$this->CustomSettings = serialize($settings);
}
/**
* @deprecated since version 4.0
*/
@ -372,13 +415,13 @@ class EditableFormField extends DataObject {
Deprecation::notice('4.0', "setSetting({$key}) is deprecated");
$settings = $this->getSettings();
$settings[$key] = $value;
$this->setSettings($settings);
}
/**
* Set the allowed css classes for the extraClass custom setting
*
*
* @param array The permissible CSS classes to add
*/
public function setAllowedCss(array $allowed) {
@ -403,7 +446,7 @@ class EditableFormField extends DataObject {
}
return '';
}
/**
* Get the path to the icon for this field type, relative to the site root.
*
@ -412,7 +455,7 @@ class EditableFormField extends DataObject {
public function getIcon() {
return USERFORMS_DIR . '/images/' . strtolower($this->class) . '.png';
}
/**
* Return whether or not this field has addable options
* such as a dropdown field or radio set
@ -422,42 +465,67 @@ class EditableFormField extends DataObject {
public function getHasAddableOptions() {
return false;
}
/**
* Return whether or not this field needs to show the extra
* options dropdown list
*
*
* @return bool
*/
public function showExtraOptions() {
return true;
}
/**
* Title field of the field in the backend of the page
*
* @return TextField
*/
public function TitleField() {
$label = _t('EditableFormField.ENTERQUESTION', 'Enter Question');
$field = new TextField('Title', $label, $this->getField('Title'));
$field->setName($this->getFieldName('Title'));
if(!$this->canEdit()) {
return $field->performReadonlyTransformation();
}
return $field;
}
/**
* Returns the Title for rendering in the front-end (with XML values escaped)
*
* @return string
*/
public function getTitle() {
return Convert::raw2att($this->getField('Title'));
public function getEscapedTitle() {
return Convert::raw2xml($this->Title);
}
/**
* Find the numeric indicator (1.1.2) that represents it's nesting value
*
* Only useful for fields attached to a current page, and that contain other fields such as pages
* or groups
*
* @return string
*/
public function getFieldNumber() {
// Check if exists
if(!$this->exists()) {
return null;
}
// Check parent
$form = $this->Parent();
if(!$form || !$form->exists() || !($fields = $form->Fields())) {
return null;
}
$prior = 0; // Number of prior group at this level
$stack = array(); // Current stack of nested groups, where the top level = the page
foreach($fields->map('ID', 'ClassName') as $id => $className) {
if($className === 'EditableFormStep') {
$priorPage = empty($stack) ? $prior : $stack[0];
$stack = array($priorPage + 1);
$prior = 0;
} elseif($className === 'EditableFieldGroup') {
$stack[] = $prior + 1;
$prior = 0;
} elseif($className === 'EditableFieldGroupEnd') {
$prior = array_pop($stack);
}
if($id == $this->ID) {
return implode('.', $stack);
}
}
return null;
}
public function getCMSTitle() {
return $this->i18n_singular_name() . ' (' . $this->Title . ')';
}
/**
@ -467,44 +535,91 @@ class EditableFormField extends DataObject {
Deprecation::notice('4.0', "getFieldName({$field}) is deprecated");
return ($field) ? "Fields[".$this->ID."][".$field."]" : "Fields[".$this->ID."]";
}
/**
* @deprecated since version 4.0
*/
public function getSettingName($field) {
Deprecation::notice('4.0', "getSettingName({$field}) is deprecated");
$name = $this->getFieldName('CustomSettings');
return $name . '[' . $field .']';
}
/**
* Append custom validation fields to the default 'Validation'
* Append custom validation fields to the default 'Validation'
* section in the editable options view
*
*
* @return FieldList
*/
public function getFieldValidationOptions() {
$fields = new FieldList(
CheckboxField::create('Required', _t('EditableFormField.REQUIRED', 'Is this field Required?')),
CheckboxField::create('Required', _t('EditableFormField.REQUIRED', 'Is this field Required?'))
->setDescription(_t('EditableFormField.REQUIRED_DESCRIPTION', 'Please note that conditional fields can\'t be required')),
TextField::create('CustomErrorMessage', _t('EditableFormField.CUSTOMERROR','Custom Error Message'))
);
$this->extend('updateFieldValidationOptions', $fields);
return $fields;
}
/**
* Return a FormField to appear on the front end. Implement on
* your subclass
* Return a FormField to appear on the front end. Implement on
* your subclass.
*
* @return FormField
*/
public function getFormField() {
user_error("Please implement a getFormField() on your EditableFormClass ". $this->ClassName, E_USER_ERROR);
}
/**
* Updates a formfield with extensions
*
* @param FormField $field
*/
public function doUpdateFormField($field) {
$this->extend('beforeUpdateFormField', $field);
$this->updateFormField($field);
$this->extend('afterUpdateFormField', $field);
}
/**
* Updates a formfield with the additional metadata specified by this field
*
* @param FormField $field
*/
protected function updateFormField($field) {
// set the error / formatting messages
$field->setCustomValidationMessage($this->getErrorMessage());
// set the right title on this field
if($this->RightTitle) {
// Since this field expects raw html, safely escape the user data prior
$field->setRightTitle(Convert::raw2xml($this->RightTitle));
}
// if this field is required add some
if($this->Required) {
// Required validation can conflict so add the Required validation messages as input attributes
$errorMessage = $this->getErrorMessage()->HTML();
$field->addExtraClass('requiredField');
$field->setAttribute('data-rule-required', 'true');
$field->setAttribute('data-msg-required', $errorMessage);
if($identifier = UserDefinedForm::config()->required_identifier) {
$title = $field->Title() . " <span class='required-identifier'>". $identifier . "</span>";
$field->setTitle($title);
}
}
// if this field has an extra class
if($this->ExtraClass) {
$field->addExtraClass($this->ExtraClass);
}
}
/**
* Return the instance of the submission field class
*
@ -513,8 +628,8 @@ class EditableFormField extends DataObject {
public function getSubmittedFormField() {
return new SubmittedFormField();
}
/**
* Show this form field (and its related value) in the reports and in emails.
*
@ -523,25 +638,7 @@ class EditableFormField extends DataObject {
public function showInReports() {
return true;
}
/**
* Return the validation information related to this field. This is
* interrupted as a JSON object for validate plugin and used in the
* PHP.
*
* @see http://docs.jquery.com/Plugins/Validation/Methods
* @return Array
*/
public function getValidation() {
return $this->Required
? array('required' => true)
: array();
}
public function getValidationJSON() {
return Convert::raw2json($this->getValidation());
}
/**
* Return the error message for this field. Either uses the custom
* one (if provided) or the default SilverStripe message
@ -551,10 +648,10 @@ class EditableFormField extends DataObject {
public function getErrorMessage() {
$title = strip_tags("'". ($this->Title ? $this->Title : $this->Name) . "'");
$standard = sprintf(_t('Form.FIELDISREQUIRED', '%s is required').'.', $title);
// only use CustomErrorMessage if it has a non empty value
$errorMessage = (!empty($this->CustomErrorMessage)) ? $this->CustomErrorMessage : $standard;
return DBField::create_field('Varchar', $errorMessage);
}
@ -575,11 +672,11 @@ class EditableFormField extends DataObject {
}
if(
!isset($data[$this->Name]) ||
!isset($data[$this->Name]) ||
!$data[$this->Name] ||
!$formField->validate($form->getValidator())
) {
$form->addErrorMessage($this->Name, $this->getErrorMessage(), 'bad');
$form->addErrorMessage($this->Name, $this->getErrorMessage()->HTML(), 'error', false);
}
}
@ -598,7 +695,7 @@ class EditableFormField extends DataObject {
$this->ShowOnLoad = $data['ShowOnLoad'] === '' || ($data['ShowOnLoad'] && $data['ShowOnLoad'] !== 'Hide');
unset($data['ShowOnLoad']);
}
// Migrate all other settings
foreach($data as $key => $value) {
if($this->hasField($key)) {
@ -606,4 +703,89 @@ class EditableFormField extends DataObject {
}
}
}
/**
* Get the formfield to use when editing this inline in gridfield
*
* @param string $column name of column
* @param array $fieldClasses List of allowed classnames if this formfield has a selectable class
* @return FormField
*/
public function getInlineClassnameField($column, $fieldClasses) {
return DropdownField::create($column, false, $fieldClasses);
}
/**
* Get the formfield to use when editing the title inline
*
* @param string $column
* @return FormField
*/
public function getInlineTitleField($column) {
return TextField::create($column, false)
->setAttribute('placeholder', _t('EditableFormField.TITLE', 'Title'))
->setAttribute('data-placeholder', _t('EditableFormField.TITLE', 'Title'));
}
/**
* Get the JS expression for selecting the holder for this field
*
* @return string
*/
public function getSelectorHolder() {
return "$(\"#{$this->Name}\")";
}
/**
* Gets the JS expression for selecting the value for this field
*
* @param EditableCustomRule $rule Custom rule this selector will be used with
* @param bool $forOnLoad Set to true if this will be invoked on load
*/
public function getSelectorField(EditableCustomRule $rule, $forOnLoad = false) {
return "$(\"input[name='{$this->Name}']\")";
}
/**
* Get the list of classes that can be selected and used as data-values
*
* @param $includeLiterals Set to false to exclude non-data fields
* @return array
*/
public function getEditableFieldClasses($includeLiterals = true) {
$classes = ClassInfo::getValidSubClasses('EditableFormField');
// Remove classes we don't want to display in the dropdown.
$editableFieldClasses = array();
foreach ($classes as $class) {
// Skip abstract / hidden classes
if(Config::inst()->get($class, 'abstract', Config::UNINHERITED) || Config::inst()->get($class, 'hidden')
) {
continue;
}
if(!$includeLiterals && Config::inst()->get($class, 'literal')) {
continue;
}
$singleton = singleton($class);
if(!$singleton->canCreate()) {
continue;
}
$editableFieldClasses[$class] = $singleton->i18n_singular_name();
}
asort($editableFieldClasses);
return $editableFieldClasses;
}
/**
* @return EditableFormFieldValidator
*/
public function getCMSValidator() {
return EditableFormFieldValidator::create()
->setRecord($this);
}
}

View File

@ -0,0 +1,61 @@
<?php
class EditableFormFieldValidator extends RequiredFields {
/**
*
* @var EditableFormField
*/
protected $record = null;
/**
*
* @param EditableFormField $record
* @return $this
*/
public function setRecord($record) {
$this->record = $record;
return $this;
}
/*
* @return EditableFormField
*/
public function getRecord() {
return $this->record;
}
public function php($data) {
if(!parent::php($data)) {
return false;
}
// Skip unsaved records
if(!$this->record || !$this->record->exists()) {
return true;
}
// Skip validation if not required
if(empty($data['Required'])) {
return;
}
// Skip validation if no rules
$count = EditableCustomRule::get()->filter('ParentID', $this->record->ID)->count();
if($count == 0) {
return true;
}
// Both required = true and rules > 0 should error
$this->validationError(
'Required_Error',
_t(
"EditableFormFieldValidator.REQUIRED_ERROR",
"Form fields cannot be required and have conditional display rules."
),
'error'
);
return false;
}
}

View File

@ -11,6 +11,8 @@ class EditableFormHeading extends EditableFormField {
private static $plural_name = 'Headings';
private static $literal = true;
private static $db = array(
'Level' => 'Int(3)', // From CustomSettings
'HideFromReports' => 'Boolean(0)' // from CustomSettings
@ -55,10 +57,24 @@ class EditableFormHeading extends EditableFormField {
}
public function getFormField() {
$labelField = new HeaderField($this->Name, $this->Title, $this->Level);
$labelField = new HeaderField($this->Name, $this->EscapedTitle, $this->Level);
$labelField->addExtraClass('FormHeading');
$labelField->setAttribute('data-id', $this->Name);
$this->doUpdateFormField($labelField);
return $labelField;
}
protected function updateFormField($field) {
// set the right title on this field
if($this->RightTitle) {
// Since this field expects raw html, safely escape the user data prior
$field->setRightTitle(Convert::raw2xml($this->RightTitle));
}
// if this field has an extra class
if($this->ExtraClass) {
$field->addExtraClass($this->ExtraClass);
}
}
public function showInReports() {
return !$this->HideFromReports;
@ -67,4 +83,8 @@ class EditableFormHeading extends EditableFormField {
public function getFieldValidationOptions() {
return false;
}
public function getSelectorHolder() {
return "$(\":header[data-id='{$this->Name}']\")";
}
}

View File

@ -0,0 +1,86 @@
<?php
/**
* A step in multi-page user form
*
* @package userforms
*/
class EditableFormStep extends EditableFormField {
private static $singular_name = 'Page Break';
private static $plural_name = 'Page Breaks';
/**
* Disable selection of step class
*
* @config
* @var bool
*/
private static $hidden = true;
/**
* @return FieldList
*/
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->removeByName(array('MergeField', 'Default', 'Validation'));
return $fields;
}
/**
* @return FormField
*/
public function getFormField() {
$field = UserFormsStepField::create()
->setName($this->Name)
->setTitle($this->EscapedTitle);
$this->doUpdateFormField($field);
return $field;
}
protected function updateFormField($field) {
// if this field has an extra class
if($this->ExtraClass) {
$field->addExtraClass($this->ExtraClass);
}
}
/**
* @return boolean
*/
public function showInReports() {
return false;
}
public function getInlineClassnameField($column, $fieldClasses) {
return new LabelField(
$column,
$this->CMSTitle
);
}
public function getCMSTitle() {
$title = $this->getFieldNumber()
?: $this->Title
?: '';
return _t(
'EditableFormStep.STEP_TITLE',
'Page {page}',
array(
'page' => $title
)
);
}
/**
* Get the JS expression for selecting the holder for this field
*
* @return string
*/
public function getSelectorHolder() {
return "$(\".step-button-wrapper[data-for='{$this->Name}']\")";
}
}

View File

@ -13,6 +13,14 @@ class EditableLiteralField extends EditableFormField {
private static $plural_name = 'HTML Blocks';
/**
* Mark as literal only
*
* @config
* @var bool
*/
private static $literal = true;
/**
* Get the name of the editor config to use for HTML sanitisation. Defaults to the active config.
*
@ -103,12 +111,16 @@ class EditableLiteralField extends EditableFormField {
}
public function getFormField() {
$label = $this->Title
? "<label class='left'>".Convert::raw2xml($this->Title)."</label>"
: "";
$classes = $this->Title ? "" : " nolabel";
// Build label and css classes
$label = '';
$classes = $this->ExtraClass;
if(empty($this->Title)) {
$classes .= " nolabel";
} else {
$label = "<label class='left'>{$this->EscapedTitle}</label>";
}
return new LiteralField(
$field = new LiteralField(
"LiteralField[{$this->ID}]",
sprintf(
"<div id='%s' class='field text%s'>
@ -121,6 +133,9 @@ class EditableLiteralField extends EditableFormField {
$this->Content
)
);
// When dealing with literal fields there is no further customisation that can be added at this point
return $field;
}
public function showInReports() {

View File

@ -42,7 +42,9 @@ class EditableMemberListField extends EditableFormField {
}
$members = Member::map_in_groups($this->GroupID);
return new DropdownField($this->Name, $this->Title, $members);
$field = new DropdownField($this->Name, $this->EscapedTitle, $members);
$this->doUpdateFormField($field);
return $field;
}
public function getValueFromData($data) {

View File

@ -14,6 +14,14 @@
*/
class EditableMultipleOptionField extends EditableFormField {
/**
* Define this field as abstract (not inherited)
*
* @config
* @var bool
*/
private static $abstract = true;
private static $has_many = array(
"Options" => "EditableOption"
@ -48,8 +56,7 @@ class EditableMultipleOptionField extends EditableFormField {
$editableColumns,
new GridFieldButtonRow(),
new GridFieldAddNewInlineButton(),
new GridFieldDeleteAction(),
new GridState_Component()
new GridFieldDeleteAction()
);
$optionsGrid = GridField::create(
@ -156,11 +163,25 @@ class EditableMultipleOptionField extends EditableFormField {
}
/**
* Return the form field for this object in the front end form view
* Gets map of field options suitable for use in a form
*
* @return FormField
* @return array
*/
public function getFormField() {
return user_error('Please implement getFormField() on '. $this->class, E_USER_ERROR);
protected function getOptionsMap() {
$optionSet = $this->Options();
$optionMap = $optionSet->map('EscapedTitle', 'Title');
if($optionMap instanceof SS_Map) {
return $optionMap->toArray();
}
return $optionMap;
}
/**
* Returns all default options
*
* @return SS_List
*/
protected function getDefaultOptions() {
return $this->Options()->filter('Default', 1);
}
}

View File

@ -26,18 +26,9 @@ class EditableNumericField extends EditableFormField {
* @return NumericField
*/
public function getFormField() {
$field = new NumericField($this->Name, $this->Title);
$field = new NumericField($this->Name, $this->EscapedTitle, $this->Default);
$field->addExtraClass('number');
$field->setValue($this->Default);
if ($this->Required) {
// Required and numeric validation can conflict so add the
// required validation messages as input attributes
$errorMessage = $this->getErrorMessage()->HTML();
$field->setAttribute('data-rule-required', 'true');
$field->setAttribute('data-msg-required', $errorMessage);
}
$this->doUpdateFormField($field);
return $field;
}
@ -54,14 +45,20 @@ class EditableNumericField extends EditableFormField {
return $fields;
}
public function getValidation() {
$options = array();
/**
* Updates a formfield with the additional metadata specified by this field
*
* @param FormField $field
*/
protected function updateFormField($field) {
parent::updateFormField($field);
if($this->MinValue) {
$options['min'] = (int)$this->MinValue;
$field->setAttribute('data-rule-min', $this->MinValue);
}
if($this->MaxValue) {
$options['max'] = (int)$this->MaxValue;
$field->setAttribute('data-rule-max', $this->MaxValue);
}
return $options;
}
}

View File

@ -49,39 +49,6 @@ class EditableOption extends DataObject {
return ($this->Parent()->canDelete($member));
}
/**
* Template for the editing view of this option field
*/
public function EditSegment() {
return $this->renderWith('EditableOption');
}
/**
* The Title Field for this object
*
* @return FormField
*/
public function TitleField() {
return new TextField("Fields[{$this->ParentID}][{$this->ID}][Title]", null, $this->Title );
}
/**
* Name of this field in the form
*
* @return String
*/
public function FieldName() {
return "Fields[{$this->ParentID}][{$this->ID}]";
}
/**
* Make this option readonly
*/
public function ReadonlyOption() {
$this->readonly = true;
return $this->EditSegment();
}
public function getEscapedTitle() {
return Convert::raw2att($this->Title);
}

View File

@ -0,0 +1,44 @@
<?php
/**
* EditableRadioField
*
* Represents a set of selectable radio buttons
*
* @package userforms
*/
class EditableRadioField extends EditableMultipleOptionField {
private static $singular_name = 'Radio Group';
private static $plural_name = 'Radio Groups';
/**
* @return FieldList
*/
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->removeByName('Default');
return $fields;
}
public function getFormField() {
$field = OptionsetField::create($this->Name, $this->EscapedTitle, $this->getOptionsMap());
// Set default item
$defaultOption = $this->getDefaultOptions()->first();
if($defaultOption) {
$field->setValue($defaultOption->EscapedTitle);
}
$this->doUpdateFormField($field);
return $field;
}
public function getSelectorField(EditableCustomRule $rule, $forOnLoad = false) {
// We only want to trigger on load once for the radio group - hence we focus on the first option only.
$first = $forOnLoad ? ':first' : '';
return "$(\"input[name='{$this->Name}']{$first}\")";
}
}

View File

@ -65,41 +65,29 @@ class EditableTextField extends EditableFormField {
*/
public function getFormField() {
if($this->Rows > 1) {
$field = TextareaField::create($this->Name, $this->Title);
$field = TextareaField::create($this->Name, $this->EscapedTitle, $this->Default);
$field->setRows($this->Rows);
} else {
$field = TextField::create($this->Name, $this->Title, null, $this->MaxLength);
$field = TextField::create($this->Name, $this->EscapedTitle, $this->Default, $this->MaxLength);
}
if ($this->Required) {
// Required validation can conflict so add the Required validation messages
// as input attributes
$errorMessage = $this->getErrorMessage()->HTML();
$field->setAttribute('data-rule-required', 'true');
$field->setAttribute('data-msg-required', $errorMessage);
}
$field->setValue($this->Default);
$this->doUpdateFormField($field);
return $field;
}
/**
* Return the validation information related to this field. This is
* interrupted as a JSON object for validate plugin and used in the
* PHP.
* Updates a formfield with the additional metadata specified by this field
*
* @see http://docs.jquery.com/Plugins/Validation/Methods
* @return array
* @param FormField $field
*/
public function getValidation() {
$options = parent::getValidation();
protected function updateFormField($field) {
parent::updateFormField($field);
if($this->MinLength) {
$options['minlength'] = (int)$this->MinLength;
}
if($this->MaxLength) {
$options['maxlength'] = (int)$this->MaxLength;
$field->setAttribute('data-rule-minlength', $this->MinLength);
}
if($this->MaxLength) {
$field->setAttribute('data-rule-maxlength', $this->MaxLength);
}
return $options;
}
}

View File

@ -32,16 +32,8 @@ class EditableCheckbox extends EditableFormField {
}
public function getFormField() {
$field = CheckboxField::create($this->Name, $this->Title, $this->CheckedDefault);
if ($this->Required) {
// Required validation can conflict so add the Required validation messages
// as input attributes
$errorMessage = $this->getErrorMessage()->HTML();
$field->setAttribute('data-rule-required', 'true');
$field->setAttribute('data-msg-required', $errorMessage);
}
$field = CheckboxField::create($this->Name, $this->EscapedTitle, $this->CheckedDefault);
$this->doUpdateFormField($field);
return $field;
}

View File

@ -1,57 +0,0 @@
<?php
/**
* EditableDropdown
*
* Represents a modifiable dropdown (select) box on a form
*
* @package userforms
*/
class EditableDropdown extends EditableMultipleOptionField {
private static $singular_name = 'Dropdown Field';
private static $plural_name = 'Dropdowns';
/**
* @return FieldList
*/
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->removeByName('Default');
return $fields;
}
/**
* @return DropdownField
*/
public function getFormField() {
$optionSet = $this->Options();
$defaultOptions = $optionSet->filter('Default', 1);
$options = array();
if($optionSet) {
foreach($optionSet as $option) {
$options[$option->Title] = $option->Title;
}
}
$field = DropdownField::create($this->Name, $this->Title, $options);
if ($this->Required) {
// Required validation can conflict so add the Required validation messages
// as input attributes
$errorMessage = $this->getErrorMessage()->HTML();
$field->setAttribute('data-rule-required', 'true');
$field->setAttribute('data-msg-required', $errorMessage);
}
if($defaultOptions->count()) {
$field->setValue($defaultOptions->First()->EscapedTitle);
}
return $field;
}
}

View File

@ -1,50 +0,0 @@
<?php
/**
* EditableEmailField
*
* Allow users to define a validating editable email field for a UserDefinedForm
*
* @package userforms
*/
class EditableEmailField extends EditableFormField {
private static $singular_name = 'Email Field';
private static $plural_name = 'Email Fields';
public function getSetsOwnError() {
return true;
}
public function getFormField() {
$field = EmailField::create($this->Name, $this->Title);
if ($this->Required) {
// Required and Email validation can conflict so add the Required validation messages
// as input attributes
$errorMessage = $this->getErrorMessage()->HTML();
$field->setAttribute('data-rule-required', 'true');
$field->setAttribute('data-msg-required', $errorMessage);
}
$field->setValue($this->Default);
return $field;
}
/**
* Return the validation information related to this field. This is
* interrupted as a JSON object for validate plugin and used in the
* PHP.
*
* @see http://docs.jquery.com/Plugins/Validation/Methods
* @return Array
*/
public function getValidation() {
return array_merge(parent::getValidation(), array(
'email' => true
));
}
}

View File

@ -1,46 +0,0 @@
<?php
/**
* EditableRadioField
*
* Represents a set of selectable radio buttons
*
* @package userforms
*/
class EditableRadioField extends EditableMultipleOptionField {
private static $singular_name = 'Radio field';
private static $plural_name = 'Radio fields';
/**
* @return FieldList
*/
public function getCMSFields() {
$fields = parent::getCMSFields();
$fields->removeByName('Default');
return $fields;
}
public function getFormField() {
$optionSet = $this->Options();
$defaultOptions = $optionSet->filter('Default', 1);
$options = array();
if($optionSet) {
foreach($optionSet as $option) {
$options[$option->EscapedTitle] = $option->Title;
}
}
$field = OptionsetField::create($this->Name, $this->Title, $options);
if($defaultOptions->count()) {
$field->setValue($defaultOptions->First()->EscapedTitle);
}
return $field;
}
}

View File

@ -88,7 +88,6 @@ class UserDefinedForm_EmailRecipient extends DataObject {
new GridFieldButtonRow('before'),
new GridFieldToolbarHeader(),
new GridFieldAddNewInlineButton(),
new GridState_Component(),
new GridFieldDeleteAction(),
$columns = new GridFieldEditableColumns()
);

View File

@ -0,0 +1,54 @@
<?php
use SilverStripe\Forms\SegmentFieldModifier\AbstractSegmentFieldModifier;
class DisambiguationSegmentFieldModifier extends AbstractSegmentFieldModifier {
/**
* @inheritdoc
*
* @param string $value
*
* @return string
*/
public function getPreview($value) {
if($this->form instanceof Form && $record = $this->form->getRecord()) {
$parent = $record->Parent();
$try = $value;
$sibling = EditableformField::get()
->filter('ParentID', $parent->ID)
->filter('Name', $try)
->where('"ID" != ' . $record->ID)
->first();
$counter = 1;
while($sibling !== null) {
$try = $value . '_' . $counter++;
$sibling = EditableformField::get()
->filter('ParentID', $parent->ID)
->filter('Name', $try)
->first();
}
if ($try !== $value) {
return $try;
}
}
return $value;
}
/**
* @inheritdoc
*
* @param string $value
*
* @return string
*/
public function getSuggestion($value) {
return $this->getPreview($value);
}
}

View File

@ -0,0 +1,27 @@
<?php
use SilverStripe\Forms\SegmentFieldModifier\SlugSegmentFieldModifier;
class UnderscoreSegmentFieldModifier extends SlugSegmentFieldModifier {
/**
* @inheritdoc
*
* @param string $value
*
* @return string
*/
public function getPreview($value) {
return str_replace('-', '_', parent::getPreview($value));
}
/**
* @inheritdoc
*
* @param string $value
*
* @return string
*/
public function getSuggestion($value) {
return str_replace('-', '_', parent::getSuggestion($value));
}
}

View File

@ -11,7 +11,8 @@
"require": {
"silverstripe/framework": ">=3.1.0",
"silverstripe/cms": ">=3.1.0",
"silverstripe-australia/gridfieldextensions": "1.1.0"
"silverstripe-australia/gridfieldextensions": "1.1.0",
"silverstripe/segment-field": "^1.0"
},
"suggest": {
"colymba/gridfield-bulk-editing-tools": "Allows for bulk management of form submissions"

12
config.rb Normal file
View File

@ -0,0 +1,12 @@
require 'compass/import-once/activate'
# Require any additional compass plugins here.
# Set this to the root of your project when deployed:
http_path = "/"
css_dir = "css"
sass_dir = "scss"
images_dir = "images"
javascripts_dir = "javascript"
line_comments = false

57
css/UserForm.css Normal file
View File

@ -0,0 +1,57 @@
/**
* Lightweight base styles for the front-end form.
*/
.userform-progress .progress {
position: relative;
height: 1em;
background: #eee;
}
.userform-progress .progress-bar {
position: absolute;
height: 1em;
background: #666;
}
.userform-progress .step-buttons {
margin-left: 0;
position: relative;
}
.userform-progress .step-button-wrapper {
display: inline-block;
list-style-type: none;
}
.userform-progress .step-button-wrapper.viewed .step-button-jump {
opacity: 1;
}
.userform-progress .step-button-jump {
position: absolute;
top: 0;
opacity: .7;
}
.step-navigation .step-buttons {
margin-left: 0;
}
.step-navigation .step-button-wrapper {
display: inline-block;
list-style-type: none;
}
.userform {
clear: both;
width: 100%;
max-width: 100%;
}
.userformsgroup {
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
margin-top: 12px;
margin-bottom: 12px;
}
.userformsgroup legend {
padding-left: 4px;
padding-right: 4px;
border: 0;
width: auto;
}

68
css/UserForm_cms.css Normal file
View File

@ -0,0 +1,68 @@
/**
* Styles for cms
*/
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item, .cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item:hover {
background: white;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item td {
border-right-width: 0;
border-top: 1px solid #EEE;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item td:last-child {
border-right-width: 1px;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item.inFieldGroup, .cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item.inFieldGroup:hover {
background: #f2f9fd;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item.inFieldGroup td {
border-bottom: 0;
border-top: 1px solid #eee;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item.inFieldGroup .col-reorder, .cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item.inFieldGroup .handle {
background: #BEE0F8;
border-top: 0;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item.inFieldGroup.inFieldGroup-level-2 .col-reorder, .cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item.inFieldGroup.inFieldGroup-level-2 .handle {
background: #99CEF4;
border-top: 0;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item.inFieldGroup.inFieldGroup-level-3 .col-reorder, .cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item.inFieldGroup.inFieldGroup-level-3 .handle {
background: #89BEF4;
border-top: 0;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFormStep'], .cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFormStep']:hover {
background: #dae2e7;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFormStep'] label {
font-weight: bold;
color: black;
font-size: 1.1em;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFormStep'] td {
border-top: 1px solid #a6b6c1;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFormStep'] + .ss-gridfield-item td {
border-top: 1px solid #dae2e7;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFormStep'] + .ss-gridfield-item[data-class='EditableFieldGroup'] td {
border-top: 1px solid #a8d7f5;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFieldGroup'] td {
border-top: 1px solid #a8d7f5;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFieldGroup'] label {
font-weight: bold;
color: #444;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFieldGroupEnd'] td {
border-bottom: 1px solid #a8d7f5;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFieldGroupEnd'] + .ss-gridfield-item[data-class='EditableFieldGroupEnd'] {
border-top: 0;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFieldGroupEnd'] .col-buttons .action {
display: none;
}
.cms .uf-field-editor table.ss-gridfield-table .ss-gridfield-item[data-class='EditableFieldGroupEnd'] label {
color: #777;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -24,9 +24,9 @@ and without getting involved in any PHP code.
## Documentation
* [Installation instructions](installation.md)
* [Troubleshooting](troubleshooting.md)
* [User Documentation](user-documentation.md)
* [Installation instructions](installation)
* [Troubleshooting](troubleshooting)
* [User Documentation](user-documentation)
## Thanks

View File

@ -52,8 +52,7 @@ puts the form at the end of all the content.
## Adding fields
To add a field to the form, click on the "Form" tab under the "Content" tab in the
Editing Pane. Click the "Add" button then select the type of field you want from the dropdown.
To add a field to the form, click on the "Form Fields" in the Editing Pane. Click the "Add Field" button then select the type of field you want from the dropdown.
Save or publish the form to start editing your new field's properties.
![Adding fields](_images/add-fields.png)
@ -174,6 +173,18 @@ to determine the size and the number of rows in a text field.
* Use [HTML Block](#html-block), with the appropriate level [Heading](#heading).
## Creating a multi-page form
To create a multi-page form, simply click the 'Add Page Break' button.
This will create a page break field which is used to create a new page in the form. You can drag and drop this page break to separate form fields at the desired location.
![Multi-page forms](_images/multi-page-forms.png)
## Adding fieldgroups
Creating fieldgroups is as simple as clicking the 'Add fieldgroup' button, this create two 'markers' which act as the beginning/end for the fieldgroup. Any fields place between these two markers will be automatically included within the fieldgroup.
![Adding fieldgroups](_images/fieldgroups.png)
## Viewing form submissions

71
javascript/FieldEditor.js Normal file
View File

@ -0,0 +1,71 @@
/**
* form builder behaviour.
*/
(function($) {
$.entwine('ss', function($) {
$(".uf-field-editor tbody").entwine({
onmatch: function() {
var i, thisLevel, depth = 0;
this._super();
// Loop through all rows and set necessary styles
this.find('.ss-gridfield-item').each(function() {
switch($(this).data('class')) {
case 'EditableFormStep': {
depth = 0;
return;
}
case 'EditableFieldGroup': {
thisLevel = ++depth;
break;
}
case 'EditableFieldGroupEnd': {
thisLevel = depth--;
break;
}
default: {
thisLevel = depth;
}
}
$(this).toggleClass('inFieldGroup', thisLevel > 0);
for(i = 1; i <= 5; i++) {
$(this).toggleClass('inFieldGroup-level-'+i, thisLevel >= i);
}
});
},
onunmatch: function () {
this._super();
}
});
// When new fields are added..
$('.uf-field-editor .ss-gridfield-buttonrow .action').entwine({
onclick: function (e) {
this._super(e);
this.trigger('addnewinline');
}
});
$('.uf-field-editor').entwine({
onmatch: function () {
var self = this;
// When the 'Add field' button is clicked set a one time listener.
// When the GridField is reloaded focus on the newly added field.
this.on('addnewinline', function () {
self.one('reload', function () {
//If fieldgroup, focus on the start marker
if ($('.uf-field-editor .ss-gridfield-item').last().attr('data-class') === 'EditableFieldGroupEnd') {
$('.uf-field-editor .ss-gridfield-item').last().prev().find('.col-Title input').focus();
} else {
$('.uf-field-editor .ss-gridfield-item:last-child .col-Title input').focus();
}
});
});
}
});
});
}(jQuery));

700
javascript/UserForm.js Normal file
View File

@ -0,0 +1,700 @@
/**
* @file Manages the multi-step navigation.
*/
jQuery(function ($) {
// A reference to the UserForm instance.
var userform = null;
// Settings that come from the CMS.
var CONSTANTS = {};
// Common functions that extend multiple classes.
var commonMixin = {
/**
* @func show
* @desc Show the form step. Looks after aria attributes too.
*/
show: function () {
this.$el.attr('aria-hidden', false).show();
},
/**
* @func hide
* @desc Hide the form step. Looks after aria attributes too.
*/
hide: function () {
this.$el.attr('aria-hidden', true).hide();
}
};
/**
* @func UserForm
* @constructor
* @param {object} element
* @return {object} - The UserForm instance.
* @desc The form
*/
function UserForm(element) {
var self = this;
this.$el = element instanceof jQuery ? element : $(element);
this.steps = [];
// Add an error container which displays a list of invalid steps on form submission.
this.errorContainer = new ErrorContainer(this.$el.children('.error-container'));
// Listen for events triggered my form steps.
this.$el.on('userform.action.prev', function (e) {
self.prevStep();
});
this.$el.on('userform.action.next', function (e) {
self.nextStep();
});
// Listen for events triggered by the progress bar.
$('#userform-progress').on('userform.progress.changestep', function (e, stepNumber) {
self.jumpToStep(stepNumber - 1);
});
// When a field becomes valid, remove errors from the error container.
this.$el.on('userform.form.valid', function (e, fieldId) {
self.errorContainer.removeStepLink(fieldId);
});
this.$el.validate(this.validationOptions);
return this;
}
/*
* Default options for step validation. These get extended in main().
*/
UserForm.prototype.validationOptions = {
ignore: ':hidden',
errorClass: 'error',
errorElement: 'span',
errorPlacement: function (error, element) {
error.addClass('message');
if(element.is(':radio') || element.parents('.checkboxset').length > 0) {
error.insertAfter(element.closest('ul'));
} else {
error.insertAfter(element);
}
},
// Callback for handling the actual submit when the form is valid.
// Submission in the jQuery.validate sence is handled at step level.
// So when the final step is submitted we have to also check all previous steps are valid.
submitHandler: function (form, e) {
var isValid = true;
// validate the current step
if(userform.currentStep) {
userform.currentStep.valid = $(form).valid();
}
// Check for invalid previous steps.
$.each(userform.steps, function (i, step) {
if (!step.valid && !step.conditionallyHidden()) {
isValid = false;
userform.errorContainer.addStepLink(step);
}
});
if (isValid) {
form.submit();
} else {
userform.errorContainer.show();
}
},
// When a field becomes valid.
success: function (error) {
var errorId = $(error).attr('id');
error.remove();
// Pass the field's ID with the event.
userform.$el.trigger('userform.form.valid', [errorId.substr(0, errorId.indexOf('-error'))]);
}
};
/**
* @func UserForm.addStep
* @param {object} step - An instance of FormStep.
* @desc Adds a step to the UserForm.
*/
UserForm.prototype.addStep = function (step) {
// Make sure we're dealing with a form step.
if (!step instanceof FormStep) {
return;
}
step.id = this.steps.length;
this.steps.push(step);
};
/**
* @func UserForm.setCurrentStep
* @param {object} step - An instance of FormStep.
* @desc Sets the step the user is currently on.
*/
UserForm.prototype.setCurrentStep = function (step) {
// Make sure we're dealing with a form step.
if (!(step instanceof FormStep)) {
return;
}
this.currentStep = step;
this.currentStep.show();
// Record the user has viewed the step.
step.viewed = true;
step.$el.addClass('viewed');
};
/**
* @func UserForm.jumpToStep
* @param {number} stepNumber
* @param {boolean} [direction] - Defaults to forward (true).
* @desc Jumps to a specific form step.
*/
UserForm.prototype.jumpToStep = function (stepNumber, direction) {
var targetStep = this.steps[stepNumber],
isValid = false,
forward = direction === void 0 ? true : direction;
// Make sure the target step exists.
if (targetStep === void 0) {
return;
}
// Make sure the step we're trying to set as current is not
// hidden by custom display rules. If it is then jump to the next step.
if (targetStep.conditionallyHidden()) {
if (forward) {
this.jumpToStep(stepNumber + 1);
} else {
this.jumpToStep(stepNumber - 1);
}
return;
}
// Validate the form.
// This well effectivly validate the current step and not the entire form.
// This is because hidden fields are excluded from validation, and all fields
// on all other steps, are currently hidden.
isValid = this.$el.valid();
// Set the 'valid' property on the current step.
this.currentStep.valid = isValid;
// Users can navigate to step's they've already viewed even if the current step is invalid.
if (isValid === false && targetStep.viewed === false) {
return;
}
this.currentStep.hide();
this.setCurrentStep(targetStep);
this.$el.trigger('userform.form.changestep', [targetStep.id]);
};
/**
* @func UserForm.nextStep
* @desc Advances the form to the next step.
*/
UserForm.prototype.nextStep = function () {
this.jumpToStep(this.steps.indexOf(this.currentStep) + 1, true);
};
/**
* @func UserForm.prevStep
* @desc Goes back one step (not bound to browser history).
*/
UserForm.prototype.prevStep = function () {
this.jumpToStep(this.steps.indexOf(this.currentStep) - 1, false);
};
/**
* @func ErrorContainer
* @constructor
* @param {object} element - The error container element.
* @return {object} - The ErrorContainer instance.
* @desc Creates an error container. Used to display step error messages at the top.
*/
function ErrorContainer(element) {
this.$el = element instanceof jQuery ? element : $(element);
// Set the error container's heading.
this.$el.find('h4').text(ss.i18n._t('UserForms.ERROR_CONTAINER_HEADER', 'Please correct the following errors and try again:'));
return this;
}
/**
* @func hasErrors
* @return boolean
* @desc Checks if the error container has any error messages.
*/
ErrorContainer.prototype.hasErrors = function () {
return this.$el.find('.error-list').children().length > 0;
};
/**
* @func removeErrorMessage
* @desc Removes an error message from the error container.
*/
ErrorContainer.prototype.removeErrorMessage = function (fieldId) {
this.$el.find('#' + fieldId + '-top-error').remove();
// If there are no more error then hide the container.
if (!this.hasErrors()) {
this.hide();
}
};
/**
* @func addStepLink
* @param {object} step - FormStep instance.
* @desc Adds a link to a form step as an error message.
*/
ErrorContainer.prototype.addStepLink = function (step) {
var self = this,
itemID = step.$el.attr('id') + '-error-link',
$itemElement = this.$el.find('#' + itemID),
stepID = step.$el.attr('id'),
stepTitle = step.$el.data('title');
// If the item already exists we don't need to do anything.
if ($itemElement.length) {
return;
}
$itemElement = $('<li id="' + itemID + '"><a href="#' + stepID + '">' + stepTitle + '</a></li>');
$itemElement.on('click', function (e) {
e.preventDefault();
userform.jumpToStep(step.id);
});
this.$el.find('.error-list').append($itemElement);
};
/**
* @func removeStepLink
* @param {object} step - FormStep instance.
* @desc Removes a step link from the error container.
*/
ErrorContainer.prototype.removeStepLink = function (fieldId) {
var stepID = $('#' + fieldId).closest('.form-step').attr('id');
this.$el.find('#' + stepID + '-error-link').remove();
// Hide the error container if we've just removed the last error.
if (this.$el.find('.error-list').is(':empty')) {
this.hide();
}
};
/**
* @func ErrorContainer.updateErrorMessage
* @param {object} $input - The jQuery input object which contains the field to validate.
* @param {object} message - The error message to display (html escaped).
* @desc Update an error message (displayed at the top of the form).
*/
ErrorContainer.prototype.updateErrorMessage = function ($input, message) {
var inputID = $input.attr('id'),
anchor = '#' + inputID,
elementID = inputID + '-top-error',
messageElement = $('#' + elementID),
describedBy = $input.attr('aria-describedby');
// The 'message' param will be an empty string if the field is valid.
if (!message) {
// Style issues as fixed if they already exist
messageElement.addClass('fixed');
return;
}
messageElement.removeClass('fixed');
this.show();
if (messageElement.length === 1) {
// Update the existing error message.
messageElement.show().find('a').html(message);
} else {
// Generate better link to field
$input.closest('.field[id]').each(function(){
anchor = '#' + $(this).attr('id');
});
// Add a new error message
messageElement = $('<li><a></a></li>');
messageElement
.attr('id', elementID)
.find('a')
.attr('href', location.pathname + location.search + anchor)
.html(message);
this.$el.find('ul').append(messageElement);
// link back to original input via aria
// Respect existing non-error aria-describedby
if (!describedBy) {
describedBy = elementID;
} else if (!describedBy.match(new RegExp('\\b' + elementID + '\\b'))) {
// Add to end of list if not already present
describedBy += " " + elementID;
}
$input.attr('aria-describedby', describedBy);
}
};
/**
* @func FormStep
* @constructor
* @param {object} element
* @return {object} - The FormStep instance.
* @desc Creates a form step.
*/
function FormStep(element) {
var self = this;
this.$el = element instanceof jQuery ? element : $(element);
// Find button for this step
this.$elButton = $(".step-button-wrapper[data-for='" + this.$el.prop('id') + "']");
// Has the step been viewed by the user?
this.viewed = false;
// Is the form step valid?
// This value is used on form submission, which fails, if any of the steps are invalid.
this.valid = false;
// The internal id of the step. Used for getting the step from the UserForm.steps array.
this.id = null;
this.hide();
if (CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP) {
this.errorContainer = new ErrorContainer(this.$el.find('.error-container'));
// Listen for errors on the UserForm.
userform.$el.on('userform.form.error', function (e, validator) {
// The step only cares about errors if it's currently visible.
if (!self.$el.is(':visible')) {
return;
}
// Add or update each error in the list.
$.each(validator.errorList, function (i, error) {
self.errorContainer.updateErrorMessage($(error.element), error.message);
});
});
// Listen for fields becoming valid
userform.$el.on('userform.form.valid', function (e, fieldId) {
self.errorContainer.removeErrorMessage(fieldId);
});
}
// Ensure that page visibilty updates the step navigation
this
.$elButton
.on('userform.field.hide userform.field.show', function(){
userform.$el.trigger('userform.form.conditionalstep');
});
return this;
}
/**
* Determine if this step is conditionally disabled
*
* @returns {Boolean}
*/
FormStep.prototype.conditionallyHidden = function(){
// Because the element itself could be visible but 0 height, so check visibility of button
return ! this
.$elButton
.find('button')
.is(':visible');
};
/**
* @func ProgressBar
* @constructor
* @param {object} element
* @return {object} - The Progress bar instance.
* @desc Creates a progress bar.
*/
function ProgressBar(element) {
var self = this;
this.$el = element instanceof jQuery ? element : $(element);
this.$buttons = this.$el.find('.step-button-jump');
this.$jsAlign = this.$el.find('.js-align');
// Update the progress bar when 'step' buttons are clicked.
this.$buttons.each(function (i, stepButton) {
$(stepButton).on('click', function (e) {
e.preventDefault();
self.$el.trigger('userform.progress.changestep', [parseInt($(this).text(), 10)]);
});
});
// Update the progress bar when 'prev' and 'next' buttons are clicked.
userform.$el.on('userform.form.changestep', function (e, stepID) {
self.update(stepID);
});
// Listen for steps being conditionally shown / hidden by display rules.
// We need to update step related UI like the number of step buttons
// and any text that shows the total number of steps.
userform.$el.on('userform.form.conditionalstep', function () {
// Update the step numbers on the buttons.
var $visibleButtons = self.$buttons.filter(':visible');
$visibleButtons.each(function (i, button) {
$(button).text(i + 1);
});
// Update the actual progress bar.
self.$el.find('.progress-bar').attr('aria-valuemax', $visibleButtons.length);
// Update any text that uses the total number of steps.
self.$el.find('.total-step-number').text($visibleButtons.length);
});
// Spaces out the steps below progress bar evenly
this.$jsAlign.each(function (index, button) {
var $button = $(button),
leftPercent = (100 / (self.$jsAlign.length - 1) * index + '%'),
buttonOffset = -1 * ($button.innerWidth() / 2);
$button.css({left: leftPercent, marginLeft: buttonOffset});
// First and last buttons are kept within userform-progress container
if (index === self.$jsAlign.length - 1) {
$button.css({marginLeft: buttonOffset * 2});
} else if (index === 0) {
$button.css({marginLeft: 0});
}
});
this.update(0);
return this;
}
/**
* @func ProgressBar.update
* @param {number} stepID - Zero based index of the new step.
* @desc Update the progress element to show a new step.
*/
ProgressBar.prototype.update = function (stepID) {
var $newStepElement = $($('.form-step')[stepID]),
stepNumber = 0;
// Set the current step number.
this.$buttons.each(function (i, button) {
if (i > stepID) {
return false; // break the loop
}
if ($(button).is(':visible')) {
stepNumber += 1;
}
});
// Update elements that contain the current step number.
this.$el.find('.current-step-number').each(function (i, element) {
$(element).text(stepNumber);
});
// Update aria attributes.
this.$el.find('[aria-valuenow]').each(function (i, element) {
$(element).attr('aria-valuenow', stepNumber);
});
// Update the CSS classes on step buttons.
this.$buttons.each(function (i, element) {
var $element = $(element),
$item = $element.parent();
if (parseInt($element.text(), 10) === stepNumber && $element.is(':visible')) {
$item.addClass('current viewed');
$element.removeAttr('disabled');
return;
}
$item.removeClass('current');
});
// Update the progress bar's title with the new step's title.
this.$el.find('.progress-title').text($newStepElement.data('title'));
// Update the width of the progress bar.
this.$el.find('.progress-bar').width(stepID / (this.$buttons.length - 1) * 100 + '%');
};
/**
* @func FormActions
* @constructor
* @param {object} element
* @desc Creates the navigation and actions (Prev, Next, Submit buttons).
*/
function FormActions (element) {
var self = this;
this.$el = element instanceof jQuery ? element : $(element);
// Bind the step navigation event listeners.
this.$el.find('.step-button-prev').on('click', function (e) {
e.preventDefault();
self.$el.trigger('userform.action.prev');
});
this.$el.find('.step-button-next').on('click', function (e) {
e.preventDefault();
self.$el.trigger('userform.action.next');
});
// Listen for changes to the current form step, or conditional pages,
// so we can show hide buttons appropriatly.
userform.$el.on('userform.form.changestep userform.form.conditionalstep', function () {
self.update();
});
this.update();
return this;
}
/**
* @func FormAcrions.update
* @param {number} stepID - Zero based ID of the current step.
* @desc Updates the form actions element to reflect the current state of the page.
*/
FormActions.prototype.update = function () {
var numberOfSteps = userform.steps.length,
stepID = userform.currentStep.id,
i, lastStep;
// Update the "Prev" button.
this.$el.find('.step-button-prev')[stepID === 0 ? 'hide' : 'show']();
// Find last step, skipping hidden ones
for(i = numberOfSteps - 1; i >= 0; i--) {
lastStep = userform.steps[i];
// Skip if step is hidden
if(lastStep.conditionallyHidden()) {
continue;
}
// Update the "Next" button.
this.$el.find('.step-button-next')[stepID >= i ? 'hide' : 'show']();
// Update the "Actions".
this.$el.find('.Actions')[stepID >= i ? 'show' : 'hide']();
// Stop processing last step
break;
}
};
/**
* @func main
* @desc Bootstraps the front-end.
*/
function main() {
var progressBar = null,
formActions = null,
$userform = $('.userform');
CONSTANTS.ENABLE_LIVE_VALIDATION = $userform.data('livevalidation') !== void 0;
CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP = $userform.data('toperrors') !== void 0;
CONSTANTS.HIDE_FIELD_LABELS = $userform.data('hidefieldlabels') !== void 0;
// Extend the default validation options with conditional options
// that are set by the user in the CMS.
if (CONSTANTS.ENABLE_LIVE_VALIDATION === false) {
$.extend(UserForm.prototype.validationOptions, {
onfocusout: false
});
}
if (CONSTANTS.DISPLAY_ERROR_MESSAGES_AT_TOP) {
$.extend(UserForm.prototype.validationOptions, {
// Callback for custom code when an invalid form / step is submitted.
invalidHandler: function (event, validator) {
$userform.trigger('userform.form.error', [validator]);
},
onfocusout: false
});
}
// Display all the things that are hidden when JavaScript is disabled.
$('.userform-progress, .step-navigation').attr('aria-hidden', false).show();
// Extend classes with common functionality.
$.extend(FormStep.prototype, commonMixin);
$.extend(ErrorContainer.prototype, commonMixin);
userform = new UserForm($userform);
// Conditionally hide field labels and use HTML5 placeholder instead.
if (CONSTANTS.HIDE_FIELD_LABELS) {
$userform.find('label.left').each(function () {
var $label = $(this);
$('[name="' + $label.attr('for') + '"]').attr('placeholder', $label.text());
$label.remove();
});
}
// Initialise the form steps.
userform.$el.find('.form-step').each(function (i, element) {
var step = new FormStep(element);
userform.addStep(step);
});
userform.setCurrentStep(userform.steps[0]);
// Initialise actions and progressbar
progressBar = new ProgressBar($('#userform-progress'));
formActions = new FormActions($('#step-navigation'));
// Hide the form-wide actions on multi-step forms.
// Because JavaScript is enabled we'll use the actions contained
// in the final step's navigation.
if (userform.steps.length > 1) {
userform.$el.children('.Actions').attr('aria-hidden', true).hide();
}
// Enable jQuery UI datepickers
$(document).on('click', 'input.text[data-showcalendar]', function() {
var $element = $(this);
$element.ssDatepicker();
if($element.data('datepicker')) {
$element.datepicker('show');
}
});
// Make sure the form doesn't expire on the user. Pings every 3 mins.
setInterval(function () {
$.ajax({ url: 'UserDefinedForm_Controller/ping' });
}, 180 * 1000);
}
main();
});

View File

@ -1,9 +0,0 @@
jQuery(function($) {
/**
* Make sure the form does not expire on the user.
*/
setInterval(function() {
// Ping every 3 mins.
$.ajax({url: "UserDefinedForm_Controller/ping"});
}, 180*1000);
});

View File

@ -100,8 +100,8 @@ en:
PLURALNAME: 'Editable Options'
SINGULARNAME: 'Editable Option'
EditableRadioField:
PLURALNAME: 'Radio fields'
SINGULARNAME: 'Radio field'
PLURALNAME: 'Radio Groups'
SINGULARNAME: 'Radio Group'
EditableTextField:
NUMBERROWS: 'Number of rows'
PLURALNAME: 'Text Fields'

69
scss/UserForm.scss Normal file
View File

@ -0,0 +1,69 @@
/**
* Lightweight base styles for the front-end form.
*/
.userform-progress {
.progress {
position: relative;
height: 1em;
background: #eee;
}
.progress-bar {
position: absolute;
height: 1em;
background: #666;
}
.step-buttons {
margin-left: 0;
position: relative;
}
.step-button-wrapper {
display: inline-block;
list-style-type: none;
&.viewed .step-button-jump {
opacity: 1;
}
}
.step-button-jump {
position: absolute;
top: 0;
opacity: .7;
}
}
.step-navigation {
.step-buttons {
margin-left: 0;
}
.step-button-wrapper {
display: inline-block;
list-style-type: none;
}
}
.userform {
clear: both;
width: 100%;
max-width: 100%;
}
.userformsgroup {
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
margin-top: 12px;
margin-bottom: 12px;
legend {
padding-left: 4px;
padding-right: 4px;
border: 0;
width: auto;
}
}

113
scss/UserForm_cms.scss Normal file
View File

@ -0,0 +1,113 @@
/**
* Styles for cms
*/
.cms {
.uf-field-editor {
// Row styles
table.ss-gridfield-table {
// Standard rows
.ss-gridfield-item {
&, &:hover {
background: white;
}
td {
border-right-width: 0;
border-top: 1px solid #EEE;
&:last-child {
border-right-width: 1px;
}
}
}
.ss-gridfield-item.inFieldGroup {
&, &:hover {
background: #f2f9fd;
}
td {
border-bottom: 0;
border-top: 1px solid #eee;
}
.col-reorder, .handle {
background: #BEE0F8;
border-top: 0;
}
&.inFieldGroup-level-2 {
.col-reorder, .handle {
background: #99CEF4;
border-top: 0;
}
}
&.inFieldGroup-level-3 {
.col-reorder, .handle {
background: #89BEF4;
border-top: 0;
}
}
}
.ss-gridfield-item[data-class='EditableFormStep'] {
&, &:hover {
background: #dae2e7;
}
label {
font-weight: bold;
color: black;
font-size: 1.1em;
}
td {
border-top: 1px solid #a6b6c1;
}
+ .ss-gridfield-item td {
border-top: 1px solid #dae2e7;
}
+ .ss-gridfield-item[data-class='EditableFieldGroup'] td {
border-top: 1px solid #a8d7f5;
}
}
.ss-gridfield-item[data-class='EditableFieldGroup'] {
td {
border-top: 1px solid #a8d7f5;
}
label {
font-weight: bold;
color: #444;
}
}
.ss-gridfield-item[data-class='EditableFieldGroupEnd'] {
td {
border-bottom: 1px solid #a8d7f5;
}
+ .ss-gridfield-item[data-class='EditableFieldGroupEnd'] {
border-top: 0;
}
.col-buttons .action{
display: none;
}
label {
color: #777;
}
}
}
}
}

View File

@ -0,0 +1,19 @@
<% if $Steps.Count > 1 %>
<div id="userform-progress" class="userform-progress" aria-hidden="true" style="display:none;">
<h2 class="progress-title"></h2>
<p>Step <span class="current-step-number">1</span> of <span class="total-step-number">$Steps.Count</span></p>
<div class="progress">
<div class="progress-bar" role="progressbar" aria-valuenow="1" aria-valuemin="1" aria-valuemax="$Steps.Count"></div>
</div>
<nav>
<ul class="step-buttons">
<% loop $Steps %>
<li class="step-button-wrapper<% if $First %> current<% end_if %>" data-for="$Name">
<%-- Remove js-align class to remove javascript positioning --%>
<button class="step-button-jump js-align" disabled="disabled">$Pos</button>
</li>
<% end_loop %>
</ul>
</nav>
</div>
<% end_if %>

View File

@ -0,0 +1,8 @@
<% if $Steps.Count > 1 %>
<fieldset class="error-container form-wide-errors" aria-hidden="true" style="display: none;">
<div>
<h4></h4>
<ul class="error-list"></ul>
</div>
</fieldset>
<% end_if %>

View File

@ -0,0 +1,17 @@
<nav id="step-navigation" class="step-navigation" aria-hidden="true" style="display:none;">
<ul class="step-buttons">
<li class="step-button-wrapper">
<button class="step-button-prev">Prev</button>
</li>
<li class="step-button-wrapper">
<button class="step-button-next">Next</button>
</li>
<% if $Actions %>
<li class="step-button-wrapper Actions">
<% loop $Actions %>
$Field
<% end_loop %>
</li>
<% end_if %>
</ul>
</nav>

44
templates/UserForm.ss Normal file
View File

@ -0,0 +1,44 @@
<form $AttributesHTML>
<% include UserFormProgress %>
<% include UserFormStepErrors %>
<% if $Message %>
<p id="{$FormName}_error" class="message $MessageType">$Message</p>
<% else %>
<p id="{$FormName}_error" class="message $MessageType" aria-hidden="true" style="display: none;"></p>
<% end_if %>
<fieldset>
<% if $Legend %><legend>$Legend</legend><% end_if %>
<% loop $Fields %>
$FieldHolder
<% end_loop %>
<div class="clear"><!-- --></div>
</fieldset>
<%--
Include step navigation if it's a multi-page form.
The markup inside this include is hidden by default and displayed if JavaScript is enabled.
--%>
<% if $Steps.Count > 1 %>
<% include UserFormStepNav %>
<% end_if %>
<%--
When JavaScript is disabled, multi-page forms are diaplayed as a single page form,
and these actions are used instead of the step navigation include.
These actions are hidden by JavaScript on multi-page forms.
--%>
<% if $Actions %>
<div class="Actions">
<% loop $Actions %>
$Field
<% end_loop %>
</div>
<% end_if %>
</form>

View File

@ -0,0 +1,15 @@
<fieldset id="$Name" class="form-step $extraClass" data-title="$Title">
<% if $Form.DisplayErrorMessagesAtTop %>
<fieldset class="error-container" aria-hidden="true" style="display: none;">
<div>
<h4></h4>
<ul class="error-list"></ul>
</div>
</fieldset>
<% end_if %>
<% loop $Children %>
$FieldHolder
<% end_loop %>
</fieldset>

View File

@ -1,157 +0,0 @@
(function($) {
$(document).ready(function() {
var formId = "{$Form.FormName.JS}",
errorContainerId = "{$ErrorContainerID.JS}",
errorContainer = $('<fieldset><div><h4></h4><ul></ul></div></fieldset>');
var messages = {<% loop $Fields %><% if $ErrorMessage && not $SetsOwnError %><% if $ClassName == 'EditableCheckboxGroupField' %>
'{$Name.JS}[]': '{$ErrorMessage.JS}'<% if not Last %>,<% end_if %><% else %>
'{$Name.JS}': '{$ErrorMessage.JS}'<% if not Last %>,<% end_if %><% end_if %><% end_if %><% end_loop %>
};
$(document).on("click", "input.text[data-showcalendar]", function() {
$(this).ssDatepicker();
if($(this).data('datepicker')) {
$(this).datepicker('show');
}
});
$("#" + formId).validate({
ignore: ':hidden',
errorClass: "required",
errorElement: "span",
errorPlacement: function(error, element) {
error.addClass('message');
if(element.is(":radio") || element.parents(".checkboxset").length > 0) {
error.insertAfter(element.closest("ul"));
} else {
error.insertAfter(element);
}
<% if $DisplayErrorMessagesAtTop %>
applyTopErrorMessage(element, error.html());
<% end_if %>
},
success: function (error) {
error.remove();
},
messages: messages,
rules: {
<% loop $Fields %>
<% if $Validation %><% if ClassName == EditableCheckboxGroupField %>
'{$Name.JS}[]': {$ValidationJSON.RAW},
<% else %>
'{$Name.JS}': {$ValidationJSON.RAW},
<% end_if %><% end_if %>
<% end_loop %>
}
/*
* Conditional options.
* Using leading commas so we don't get a trailing comma on
* the last option. Trailing commas can break IE.
*/
<% if $EnableLiveValidation %>
// Enable live validation
,onfocusout: function (element) { this.element(element); }
<% end_if %>
<% if $DisplayErrorMessagesAtTop %>
,invalidHandler: function (event, validator) {
var errorList = $('#' + errorContainerId + ' ul');
// Update the error list with errors from the validator.
// We do this because top messages are not part of the regular
// error message life cycle, which jquery.validate handles for us.
errorList.empty();
$.each(validator.errorList, function () {
applyTopErrorMessage($(this.element), this.message);
});
}
,onfocusout: false
<% end_if %>
});
<% if $HideFieldLabels %>
// Hide field labels (use HTML5 placeholder instead)
$("#" + formId + "label.left").each(function() {
$("#"+$(this).attr("for"))
.attr("placeholder", $(this).text());
$(this).remove();
});
Placeholders.init();
<% end_if %>
<% if $DisplayErrorMessagesAtTop %>
/**
* @applyTopErrorMessage
* @param {jQuery} input - The jQuery input object which contains the field to validate
* @param {string} message - The error message to display (html escaped)
* @desc Update an error message (displayed at the top of the form).
*/
function applyTopErrorMessage(input, message) {
var inputID = input.attr('id'),
anchor = '#' + inputID,
elementID = inputID + '-top-error',
errorContainer = $('#' + errorContainerId),
messageElement = $('#' + elementID),
describedBy = input.attr('aria-describedby');
// The 'message' param will be an empty string if the field is valid.
if (!message) {
// Style issues as fixed if they already exist
messageElement.addClass('fixed');
return;
}
messageElement.removeClass('fixed');
errorContainer.show();
if (messageElement.length === 1) {
// Update the existing error message.
messageElement.show().find('a').html(message);
} else {
// Generate better link to field
input.closest('.field[id]').each(function(){
anchor = '#' + $(this).attr('id');
});
// Add a new error message
messageElement = $('<li><a></a></li>');
messageElement
.attr('id', elementID)
.find('a')
.attr('href', location.pathname + location.search + anchor)
.html(message);
errorContainer
.find('ul')
.append(messageElement);
// link back to original input via aria
// Respect existing non-error aria-describedby
if ( !describedBy ) {
describedBy = elementID;
} else if ( !describedBy.match( new RegExp( "\\b" + elementID + "\\b" ) ) ) {
// Add to end of list if not already present
describedBy += " " + elementID;
}
input.attr( "aria-describedby", describedBy );
}
}
// Build container
errorContainer
.hide()
.attr('id', errorContainerId)
.find('h4')
.text(ss.i18n._t(
"UserForms.ERROR_CONTAINER_HEADER",
"Please correct the following errors and try again:"
));
$('#' + formId).prepend(errorContainer);
<% end_if %>
});
})(jQuery);

View File

@ -80,27 +80,6 @@ class EditableFormFieldTest extends FunctionalTest {
$this->assertEquals(array('Option 5' => 'Option 5', 'Option 6' => 'Option 6'), $values);
}
function testTitleField() {
$text = $this->objFromFixture('EditableTextField', 'basic-text');
$this->logInWithPermission('ADMIN');
$title = $text->TitleField();
$this->assertThat($title, $this->isInstanceOf('TextField'));
$this->assertEquals($title->Title(), "Enter Question");
$this->assertEquals($title->Value(), "Basic Text Field");
$member = Member::currentUser();
$member->logOut();
// read only version
$title = $text->TitleField();
$this->assertThat($title, $this->isInstanceOf('ReadonlyField'));
$this->assertEquals($title->Title(), "Enter Question");
$this->assertEquals($title->Value(), "Basic Text Field");
}
function testMultipleOptionDuplication() {
$dropdown = $this->objFromFixture('EditableDropdown','basic-dropdown');

View File

@ -6,7 +6,7 @@
class UserDefinedFormControllerTest extends FunctionalTest {
static $fixture_file = 'userforms/tests/UserDefinedFormTest.yml';
static $fixture_file = 'UserDefinedFormTest.yml';
function testProcess() {
$form = $this->setupFormFrontend();
@ -15,10 +15,13 @@ class UserDefinedFormControllerTest extends FunctionalTest {
$this->autoFollowRedirection = false;
$this->clearEmails();
// load the form
$this->get($form->URLSegment);
$response = $this->submitForm('UserForm_Form', null, array('basic-text-name' => 'Basic Value'));
$this->get($form->URLSegment);
$field = $this->objFromFixture('EditableTextField', 'basic-text');
$response = $this->submitForm('UserForm_Form', null, array($field->Name => 'Basic Value'));
// should have a submitted form field now
$submitted = DataObject::get('SubmittedFormField', "\"Name\" = 'basic-text-name'");
@ -83,7 +86,7 @@ class UserDefinedFormControllerTest extends FunctionalTest {
function testForm() {
$form = $this->objFromFixture('UserDefinedForm', 'basic-form-page');
$controller = new UserDefinedFormControllerTest_Controller($form);
// test form
@ -106,30 +109,35 @@ class UserDefinedFormControllerTest extends FunctionalTest {
$controller = new UserDefinedFormControllerTest_Controller($form);
$fields = $controller->Form()->getFormFields();
$this->assertEquals($fields->Count(), 1);
$formSteps = $controller->Form()->getFormFields();
$firstStep = $formSteps->first();
$this->assertEquals($formSteps->Count(), 1);
$this->assertEquals($firstStep->getChildren()->Count(), 1);
// custom error message on a form field
$requiredForm = $this->objFromFixture('UserDefinedForm', 'validation-form');
$controller = new UserDefinedFormControllerTest_Controller($requiredForm);
UserDefinedForm::config()->required_identifier = "*";
$fields = $controller->Form()->getFormFields();
$this->assertEquals($fields->First()->getCustomValidationMessage()->getValue(), 'Custom Error Message');
$this->assertEquals($fields->First()->Title(), 'Required Text Field <span class=\'required-identifier\'>*</span>');
$formSteps = $controller->Form()->getFormFields();
$firstStep = $formSteps->first();
$firstField = $firstStep->getChildren()->first();
$this->assertEquals('Custom Error Message', $firstField->getCustomValidationMessage()->getValue());
$this->assertEquals($firstField->Title(), 'Required Text Field <span class=\'required-identifier\'>*</span>');
// test custom right title
$field = $form->Fields()->First();
$field = $form->Fields()->limit(1, 1)->First();
$field->RightTitle = 'Right Title';
$field->write();
$controller = new UserDefinedFormControllerTest_Controller($form);
$fields = $controller->Form()->getFormFields();
$formSteps = $controller->Form()->getFormFields();
$firstStep = $formSteps->first();
$this->assertEquals($fields->First()->RightTitle(), "Right Title");
$this->assertEquals($firstStep->getChildren()->First()->RightTitle(), "Right Title");
// test empty form
$emptyForm = $this->objFromFixture('UserDefinedForm', 'empty-form');

View File

@ -3,13 +3,11 @@
/**
* @package userforms
*/
class UserDefinedFormTest extends FunctionalTest {
static $fixture_file = 'UserDefinedFormTest.yml';
function testRollbackToVersion() {
public function testRollbackToVersion() {
$this->markTestSkipped(
'UserDefinedForm::rollback() has not been implemented completely'
);
@ -37,7 +35,7 @@ class UserDefinedFormTest extends FunctionalTest {
$this->assertEquals($orignal->SubmitButtonText, 'Button Text');
}
function testGetCMSFields() {
public function testGetCMSFields() {
$this->logInWithPermission('ADMIN');
$form = $this->objFromFixture('UserDefinedForm', 'basic-form-page');
@ -49,7 +47,7 @@ class UserDefinedFormTest extends FunctionalTest {
$this->assertTrue($fields->dataFieldByName('OnCompleteMessage') != null);
}
function testEmailRecipientPopup() {
public function testEmailRecipientPopup() {
$this->logInWithPermission('ADMIN');
$form = $this->objFromFixture('UserDefinedForm', 'basic-form-page');
@ -160,13 +158,13 @@ class UserDefinedFormTest extends FunctionalTest {
$live = Versioned::get_one_by_stage("UserDefinedForm", "Live", "\"UserDefinedForm_Live\".\"ID\" = $form->ID");
$this->assertNotNull($live);
$this->assertEquals($live->Fields()->Count(), 1);
$this->assertEquals(2, $live->Fields()->Count()); // one page and one field
$dropdown = $this->objFromFixture('EditableDropdown', 'basic-dropdown');
$form->Fields()->add($dropdown);
$stage = Versioned::get_one_by_stage("UserDefinedForm", "Stage", "\"UserDefinedForm\".\"ID\" = $form->ID");
$this->assertEquals($stage->Fields()->Count(), 2);
$this->assertEquals(3, $stage->Fields()->Count());
// should not have published the dropdown
$liveDropdown = Versioned::get_one_by_stage("EditableFormField", "Live", "\"EditableFormField_Live\".\"ID\" = $dropdown->ID");
@ -176,11 +174,10 @@ class UserDefinedFormTest extends FunctionalTest {
$form->doPublish();
$live = Versioned::get_one_by_stage("UserDefinedForm", "Live", "\"UserDefinedForm_Live\".\"ID\" = $form->ID");
$this->assertEquals($live->Fields()->Count(), 2);
$this->assertEquals(3, $live->Fields()->Count());
// edit the title
$text = $form->Fields()->First();
$text = $form->Fields()->limit(1, 1)->First();
$text->Title = 'Edited title';
$text->write();
@ -197,21 +194,20 @@ class UserDefinedFormTest extends FunctionalTest {
$this->logInWithPermission('ADMIN');
$form = $this->objFromFixture('UserDefinedForm', 'basic-form-page');
$form->write();
$this->assertEquals(0, DB::query("SELECT COUNT(*) FROM \"EditableFormField_Live\"")->value());
$form->doPublish();
// assert that it exists and has a field
$live = Versioned::get_one_by_stage("UserDefinedForm", "Live", "\"UserDefinedForm_Live\".\"ID\" = $form->ID");
$this->assertTrue(isset($live));
$this->assertEquals(DB::query("SELECT COUNT(*) FROM \"EditableFormField_Live\"")->value(), 1);
$this->assertEquals(2, DB::query("SELECT COUNT(*) FROM \"EditableFormField_Live\"")->value());
// unpublish
$form->doUnpublish();
$this->assertNull(Versioned::get_one_by_stage("UserDefinedForm", "Live", "\"UserDefinedForm_Live\".\"ID\" = $form->ID"));
$this->assertEquals(DB::query("SELECT COUNT(*) FROM \"EditableFormField_Live\"")->value(), 0);
$this->assertEquals(0, DB::query("SELECT COUNT(*) FROM \"EditableFormField_Live\"")->value());
}
function testDoRevertToLive() {

View File

@ -1,3 +1,19 @@
EditableFormStep:
form1step1:
Title: 'Step 1'
form3step1:
Title: 'Step 1'
form4step1:
Title: 'Step 1'
form5step1:
Title: 'Step 1'
form6step1:
Title: 'Step 1'
form6step2:
Title: 'Step 2'
form6step3:
Title: 'Step 2'
EditableOption:
option-1:
Name: Option1
@ -74,6 +90,12 @@ EditableTextField:
CustomErrorMessage: Custom Error Message
Required: true
field-1:
Name: Field1
field-2:
Name: Field2
EditableDropdown:
basic-dropdown:
Name: basic-dropdown
@ -183,7 +205,7 @@ UserDefinedForm_EmailRecipient:
UserDefinedForm:
basic-form-page:
Title: User Defined Form
Fields: =>EditableTextField.basic-text
Fields: =>EditableFormStep.form1step1,=>EditableTextField.basic-text
EmailRecipients: =>UserDefinedForm_EmailRecipient.recipient-1, =>UserDefinedForm_EmailRecipient.no-html, =>UserDefinedForm_EmailRecipient.no-data
form-with-reset-and-custom-action:
@ -193,15 +215,19 @@ UserDefinedForm:
validation-form:
Title: Validation Form
Fields: =>EditableTextField.required-text
Fields: =>EditableFormStep.form3step1,=>EditableTextField.required-text
custom-rules-form:
Title: Custom Rules Form
Fields: =>EditableCheckbox.checkbox-2, =>EditableTextField.basic-text-2
Fields: =>EditableFormStep.form4step1,=>EditableCheckbox.checkbox-2, =>EditableTextField.basic-text-2
empty-form:
Title: Empty Form
filtered-form-page:
Title: 'Page with filtered recipients'
Fields: =>EditableCheckboxGroupField.checkbox-group, =>EditableTextField.your-name-field, =>EditableTextField.street-field, =>EditableTextField.city-field
Fields: =>EditableFormStep.form5step1,=>EditableCheckboxGroupField.checkbox-group, =>EditableTextField.your-name-field, =>EditableTextField.street-field, =>EditableTextField.city-field
EmailRecipients: =>UserDefinedForm_EmailRecipient.unfiltered-recipient-1, =>UserDefinedForm_EmailRecipient.filtered-recipient-1, =>UserDefinedForm_EmailRecipient.filtered-recipient-2
empty-page:
Title: 'Page with empty step'
Fields: =>EditableFormStep.form6step1, =>EditableTextField.field-1, =>EditableFormStep.form6step2, =>EditableTextField.field-2, =>EditableFormStep.form6step3

18
tests/UserFormTest.php Normal file
View File

@ -0,0 +1,18 @@
<?php
class UserFormTest extends SapphireTest {
protected static $fixture_file = 'UserDefinedFormTest.yml';
/**
* Tests that a form will not generate empty pages
*/
public function testEmptyPages() {
$page = $this->objFromFixture('UserDefinedForm', 'empty-page');
$this->assertEquals(5, $page->Fields()->count());
$controller = ModelAsController::controller_for($page);
$form = new UserForm($controller);
$this->assertEquals(2, $form->getSteps()->count());
}
}