diff --git a/administrator/templates/hathor/css/colour_blue.css b/administrator/templates/hathor/css/colour_blue.css index 3517a37764e39..c7d391a6a8489 100644 --- a/administrator/templates/hathor/css/colour_blue.css +++ b/administrator/templates/hathor/css/colour_blue.css @@ -328,6 +328,21 @@ border-radius: 3px; width: 175px; } +.subform-repeatable-wrapper div.btn-toolbar { + float: none; +} +.subform-repeatable-wrapper .text-right { + text-align: right; +} +.subform-repeatable-wrapper .ui-sortable-helper { + background: #ffffff; +} +.subform-repeatable-wrapper tr.ui-sortable-helper { + display: table; +} +.subform-repeatable-wrapper .subform-repeatable-group { + clear: both; +} .label, .badge { display: inline-block; diff --git a/administrator/templates/hathor/css/colour_brown.css b/administrator/templates/hathor/css/colour_brown.css index 8f98d221f0316..cd941f3badedb 100644 --- a/administrator/templates/hathor/css/colour_brown.css +++ b/administrator/templates/hathor/css/colour_brown.css @@ -328,6 +328,21 @@ border-radius: 3px; width: 175px; } +.subform-repeatable-wrapper div.btn-toolbar { + float: none; +} +.subform-repeatable-wrapper .text-right { + text-align: right; +} +.subform-repeatable-wrapper .ui-sortable-helper { + background: #ffffff; +} +.subform-repeatable-wrapper tr.ui-sortable-helper { + display: table; +} +.subform-repeatable-wrapper .subform-repeatable-group { + clear: both; +} .label, .badge { display: inline-block; diff --git a/administrator/templates/hathor/css/colour_standard.css b/administrator/templates/hathor/css/colour_standard.css index cf849953d8d32..90fd8e02f9db5 100644 --- a/administrator/templates/hathor/css/colour_standard.css +++ b/administrator/templates/hathor/css/colour_standard.css @@ -328,6 +328,21 @@ border-radius: 3px; width: 175px; } +.subform-repeatable-wrapper div.btn-toolbar { + float: none; +} +.subform-repeatable-wrapper .text-right { + text-align: right; +} +.subform-repeatable-wrapper .ui-sortable-helper { + background: #ffffff; +} +.subform-repeatable-wrapper tr.ui-sortable-helper { + display: table; +} +.subform-repeatable-wrapper .subform-repeatable-group { + clear: both; +} .label, .badge { display: inline-block; diff --git a/administrator/templates/hathor/less/forms.less b/administrator/templates/hathor/less/forms.less index d4ba17dc371a5..bf43faa937dc7 100644 --- a/administrator/templates/hathor/less/forms.less +++ b/administrator/templates/hathor/less/forms.less @@ -170,3 +170,27 @@ .border-radius(@inputBorderRadius); width: 175px; } + +/* Field subform repeatable */ +.subform-repeatable-wrapper{ + + div.btn-toolbar{ + float: none; + } + + .text-right{ + text-align: right; + } + + .ui-sortable-helper{ + background: @white; + } + + tr.ui-sortable-helper{ + display: table; + } + + .subform-repeatable-group{ + clear: both; + } +} diff --git a/administrator/templates/isis/css/template-rtl.css b/administrator/templates/isis/css/template-rtl.css index a50d5fa4ca508..1d2be9a2e2cc8 100644 --- a/administrator/templates/isis/css/template-rtl.css +++ b/administrator/templates/isis/css/template-rtl.css @@ -1674,6 +1674,15 @@ legend + .control-group { .control-label .hasTooltip { display: inline-block; } +.subform-repeatable-wrapper .btn-group>.btn.button { + min-width: 0; +} +.subform-repeatable-wrapper .ui-sortable-helper { + background: #fff; +} +.subform-repeatable-wrapper tr.ui-sortable-helper { + display: table; +} table { max-width: 100%; background-color: transparent; diff --git a/administrator/templates/isis/css/template.css b/administrator/templates/isis/css/template.css index fa27790ee7a31..1a4dfe32d6c0a 100644 --- a/administrator/templates/isis/css/template.css +++ b/administrator/templates/isis/css/template.css @@ -1674,6 +1674,15 @@ legend + .control-group { .control-label .hasTooltip { display: inline-block; } +.subform-repeatable-wrapper .btn-group>.btn.button { + min-width: 0; +} +.subform-repeatable-wrapper .ui-sortable-helper { + background: #fff; +} +.subform-repeatable-wrapper tr.ui-sortable-helper { + display: table; +} table { max-width: 100%; background-color: transparent; diff --git a/layouts/joomla/form/field/subform/default.php b/layouts/joomla/form/field/subform/default.php new file mode 100644 index 0000000000000..ae5908de372cc --- /dev/null +++ b/layouts/joomla/form/field/subform/default.php @@ -0,0 +1,37 @@ + + +
+getGroup('') as $field): ?> + renderField(); ?> + +
+ diff --git a/layouts/joomla/form/field/subform/repeatable-table.php b/layouts/joomla/form/field/subform/repeatable-table.php new file mode 100644 index 0000000000000..938aaf27c9d63 --- /dev/null +++ b/layouts/joomla/form/field/subform/repeatable-table.php @@ -0,0 +1,104 @@ +getFieldsets() as $fieldset) { + $table_head .= '' . JText::_($fieldset->label); + + if (!empty($fieldset->description)) + { + $table_head .= '
' . JText::_($fieldset->description) . ''; + } + + $table_head .= ''; + } + + $sublayout = 'section-byfieldsets'; +} +else +{ + foreach($tmpl->getGroup('') as $field) { + $table_head .= '' . strip_tags($field->label); + $table_head .= '
' . JText::_($field->description) . ''; + $table_head .= ''; + } + + $sublayout = 'section'; +} + +?> + +
+
+
+ + + + + + + + + + + + $form): + echo $this->sublayout($sublayout, array('form' => $form, 'basegroup' => $fieldname, 'group' => $fieldname . $k, 'buttons' => $buttons)); + endforeach; + ?> + +
+ +
+ +
+ +
+ + + +
+
+
diff --git a/layouts/joomla/form/field/subform/repeatable-table/section-byfieldsets.php b/layouts/joomla/form/field/subform/repeatable-table/section-byfieldsets.php new file mode 100644 index 0000000000000..8386cc2e0dd89 --- /dev/null +++ b/layouts/joomla/form/field/subform/repeatable-table/section-byfieldsets.php @@ -0,0 +1,41 @@ + + + + getFieldsets() as $fieldset): ?> + + getFieldset($fieldset->name) as $field): ?> + renderField(); ?> + + + + + +
+ + + +
+ + + diff --git a/layouts/joomla/form/field/subform/repeatable-table/section.php b/layouts/joomla/form/field/subform/repeatable-table/section.php new file mode 100644 index 0000000000000..3769d41366b86 --- /dev/null +++ b/layouts/joomla/form/field/subform/repeatable-table/section.php @@ -0,0 +1,39 @@ + + + + getGroup('') as $field): ?> + + renderField(); ?> + + + + +
+ + + +
+ + + diff --git a/layouts/joomla/form/field/subform/repeatable.php b/layouts/joomla/form/field/subform/repeatable.php new file mode 100644 index 0000000000000..a15fe26be9190 --- /dev/null +++ b/layouts/joomla/form/field/subform/repeatable.php @@ -0,0 +1,63 @@ + + +
+
+
+ +
+
+ +
+
+ + $form): + echo $this->sublayout($sublayout, array('form' => $form, 'basegroup' => $fieldname, 'group' => $fieldname . $k, 'buttons' => $buttons)); + endforeach; + ?> + + + +
+
+
diff --git a/layouts/joomla/form/field/subform/repeatable/section-byfieldsets.php b/layouts/joomla/form/field/subform/repeatable/section-byfieldsets.php new file mode 100644 index 0000000000000..980c81f285d4b --- /dev/null +++ b/layouts/joomla/form/field/subform/repeatable/section-byfieldsets.php @@ -0,0 +1,45 @@ + + +
+ +
+
+ + + +
+
+ +
+getFieldsets() as $fieldset): ?> +
+ label)):?> + label); ?> + +getFieldset($fieldset->name) as $field): ?> + renderField(); ?> + +
+ +
+
diff --git a/layouts/joomla/form/field/subform/repeatable/section.php b/layouts/joomla/form/field/subform/repeatable/section.php new file mode 100644 index 0000000000000..1a6962e865285 --- /dev/null +++ b/layouts/joomla/form/field/subform/repeatable/section.php @@ -0,0 +1,38 @@ + + +
+ +
+
+ + + +
+
+ + +getGroup('') as $field): ?> + renderField(); ?> + +
diff --git a/libraries/joomla/form/fields/repeatable.php b/libraries/joomla/form/fields/repeatable.php index cc316d343b4db..80dcb895d2462 100644 --- a/libraries/joomla/form/fields/repeatable.php +++ b/libraries/joomla/form/fields/repeatable.php @@ -13,7 +13,9 @@ * Form Field class for the Joomla Platform. * Display a JSON loaded window with a repeatable set of sub fields * - * @since 3.2 + * @since 3.2 + * + * @deprecated 4.0 Use JFormFieldSubform */ class JFormFieldRepeatable extends JFormField { @@ -34,6 +36,8 @@ class JFormFieldRepeatable extends JFormField */ protected function getInput() { + JLog::add('JFormFieldRepeatable is deprecated. Use JFormFieldSubform instead.', JLog::WARNING, 'deprecated'); + // Initialize variables. $subForm = new JForm($this->name, array('control' => 'jform')); $xml = $this->element->children()->asXml(); diff --git a/libraries/joomla/form/fields/subform.php b/libraries/joomla/form/fields/subform.php new file mode 100644 index 0000000000000..d9338849c0329 --- /dev/null +++ b/libraries/joomla/form/fields/subform.php @@ -0,0 +1,348 @@ + + * + * @since 3.6 + */ +class JFormFieldSubform extends JFormField +{ + /** + * The form field type. + * @var string + */ + protected $type = 'Subform'; + + /** + * Form source + * @var string + */ + protected $formsource; + + /** + * Minimum items in repeat mode + * @var int + */ + protected $min = 0; + + /** + * Maximum items in repeat mode + * @var int + */ + protected $max = 1000; + + /** + * Layout to render the form + * @var string + */ + protected $layout = 'joomla.form.field.subform.default'; + + /** + * Whether group subform fields by it`s fieldset + * @var boolean + */ + protected $groupByFieldset = false; + + /** + * Which buttons to show in miltiple mode + * @var array $buttons + */ + protected $buttons = array('add' => true, 'remove' => true, 'move' => true); + + /** + * Method to get certain otherwise inaccessible properties from the form field object. + * + * @param string $name The property name for which to the the value. + * + * @return mixed The property value or null. + * + * @since 3.6 + */ + public function __get($name) + { + switch ($name) + { + case 'formsource': + case 'min': + case 'max': + case 'layout': + case 'groupByFieldset': + case 'buttons': + return $this->$name; + } + + return parent::__get($name); + } + + /** + * Method to set certain otherwise inaccessible properties of the form field object. + * + * @param string $name The property name for which to the the value. + * @param mixed $value The value of the property. + * + * @return void + * + * @since 3.6 + */ + public function __set($name, $value) + { + switch ($name) + { + case 'formsource': + $this->formsource = (string) $value; + + // Add root path if we have a path to XML file + if (strrpos($this->formsource, '.xml') === strlen($this->formsource) - 4) + { + $this->formsource = JPath::clean(JPATH_ROOT . '/' . $this->formsource); + } + + break; + + case 'min': + $this->min = (int) $value; + break; + + case 'max': + $this->max = max(1, (int) $value); + break; + + case 'groupByFieldset': + $value = (string) $value; + $this->groupByFieldset = !($value === 'false' || $value === 'off' || $value === '0'); + break; + + case 'layout': + $this->layout = (string) $value; + + // Make sure the layout is not empty. + if (!$this->layout) + { + // Set default value depend from "multiple" mode + $this->layout = !$this->multiple ? 'joomla.form.field.subform.default' : 'joomla.form.field.subform.repeatable'; + } + + break; + + case 'buttons': + + if (!$this->multiple) + { + $this->buttons = array(); + break; + } + + if ($value && !is_array($value)) + { + $value = explode(',', (string) $value); + $value = array_fill_keys(array_filter($value), true); + } + + if ($value) + { + $value = array_merge(array('add' => false, 'remove' => false, 'move' => false), $value); + $this->buttons = $value; + } + + break; + + default: + parent::__set($name, $value); + } + } + + /** + * Method to attach a JForm object to the field. + * + * @param SimpleXMLElement $element The SimpleXMLElement object representing the tag for the form field object. + * @param mixed $value The form field value to validate. + * @param string $group The field name group control value. + * + * @return boolean True on success. + * + * @since 3.6 + */ + public function setup(SimpleXMLElement $element, $value, $group = null) + { + if (!parent::setup($element, $value, $group)) + { + return false; + } + + foreach (array('formsource', 'min', 'max', 'layout', 'groupByFieldset', 'buttons') as $attributeName) + { + $this->__set($attributeName, $element[$attributeName]); + } + + if ($this->value && is_string($this->value)) + { + // Guess here is the JSON string from 'default' attribute + $this->value = json_decode($this->value, true); + } + + return true; + } + + /** + * Method to get the field input markup. + * + * @return string The field input markup. + * + * @since 3.6 + */ + protected function getInput() + { + $value = $this->value ? $this->value : array(); + + // Prepare data for renderer + $data = parent::getLayoutData(); + $tmpl = null; + $forms = array(); + $control = $this->name; + + try + { + // Prepare the form template + $formname = 'subform' . ($this->group ? $this->group . '.' : '.') . $this->fieldname; + $tmplcontrol = !$this->multiple ? $control : $control . '[' . $this->fieldname . 'X]'; + $tmpl = JForm::getInstance($formname, $this->formsource, array('control' => $tmplcontrol)); + + // Prepare the forms for exiting values + if ($this->multiple) + { + $value = array_values($value); + $c = max($this->min, min(count($value), $this->max)); + for ($i = 0; $i < $c; $i++) + { + $itemcontrol = $control . '[' . $this->fieldname . $i . ']'; + $itemform = JForm::getInstance($formname . $i, $this->formsource, array('control' => $itemcontrol)); + + if (!empty($value[$i])) + { + $itemform->bind($value[$i]); + } + + $forms[] = $itemform; + } + } + else + { + $tmpl->bind($value); + $forms[] = $tmpl; + } + } + catch (Exception $e) + { + return $e->getMessage(); + } + + $data['tmpl'] = $tmpl; + $data['forms'] = $forms; + $data['min'] = $this->min; + $data['max'] = $this->max; + $data['control'] = $control; + $data['buttons'] = $this->buttons; + $data['fieldname'] = $this->fieldname; + $data['groupByFieldset'] = $this->groupByFieldset; + + // Prepare renderer + $renderer = $this->getRenderer($this->layout); + + // Allow to define some JLayout options as attribute of the element + if ($this->element['component']) + { + $renderer->setComponent((string) $this->element['component']); + } + + if ($this->element['client']) + { + $renderer->setClient((string) $this->element['client']); + } + + // Render + $html = $renderer->render($data); + + // Add hidden input on front of the subform inputs, in multiple mode + // for allow to submit an empty value + if ($this->multiple) + { + $html = '' . $html; + } + + return $html; + } + + /** + * Method to get the name used for the field input tag. + * + * @param string $fieldName The field element name. + * + * @return string The name to be used for the field input tag. + * + * @since 3.6 + */ + protected function getName($fieldName) + { + $name = ''; + + // If there is a form control set for the attached form add it first. + if ($this->formControl) + { + $name .= $this->formControl; + } + + // If the field is in a group add the group control to the field name. + if ($this->group) + { + // If we already have a name segment add the group control as another level. + $groups = explode('.', $this->group); + + if ($name) + { + foreach ($groups as $group) + { + $name .= '[' . $group . ']'; + } + } + else + { + $name .= array_shift($groups); + + foreach ($groups as $group) + { + $name .= '[' . $group . ']'; + } + } + } + + // If we already have a name segment add the field name as another level. + if ($name) + { + $name .= '[' . $fieldName . ']'; + } + else + { + $name .= $fieldName; + } + + return $name; + } + +} diff --git a/media/jui/less/forms.less b/media/jui/less/forms.less index 99c9b96fa6d5a..0d3371c352511 100644 --- a/media/jui/less/forms.less +++ b/media/jui/less/forms.less @@ -693,3 +693,19 @@ legend + .control-group { .control-label .hasTooltip { display: inline-block; } + +/* Field subform repeatable */ +.subform-repeatable-wrapper{ + + .btn-group>.btn.button{ + min-width: 0; + } + + .ui-sortable-helper{ + background: @white; + } + + tr.ui-sortable-helper{ + display: table; + } +} diff --git a/media/system/js/subform-repeatable-uncompressed.js b/media/system/js/subform-repeatable-uncompressed.js new file mode 100644 index 0000000000000..31df79a879b47 --- /dev/null +++ b/media/system/js/subform-repeatable-uncompressed.js @@ -0,0 +1,324 @@ +/** + * @copyright Copyright (C) 2005 - 2015 Open Source Matters, Inc. All rights reserved. + * @license GNU General Public License version 2 or later; see LICENSE.txt + */ + +;(function($){ + "use strict"; + $.subformRepeatable = function(container, options){ + this.$container = $(container); + + // check if alredy exist + if(this.$container.data("subformRepeatable")){ + return self; + } + + // Add a reverse reference to the DOM object + this.$container.data("subformRepeatable", self); + + // merge options + this.options = $.extend({}, $.subformRepeatable.defaults, options); + + // template for the repeating group + this.template = ''; + + // prepare a row template, and find available field names + this.prepareTemplate(); + + // check rows container + this.$containerRows = this.options.rowsContainer ? this.$container.find(this.options.rowsContainer) : this.$container; + + // last row number, help to avoid the name duplications + this.lastRowNum = this.$containerRows.find(this.options.repeatableElement).length; + + // To avoid scope issues, + var self = this; + + // bind add button + this.$container.on('click', this.options.btAdd, function (e) { + e.preventDefault(); + var after = $(this).parents(self.options.repeatableElement); + if(!after.length){ + after = null; + } + self.addRow(after); + }); + + // bind remove button + this.$container.on('click', this.options.btRemove, function (e) { + e.preventDefault(); + var $row = $(this).parents(self.options.repeatableElement); + self.removeRow($row); + }); + + // bind move button + if(this.options.btMove){ + this.$containerRows.sortable({ + items: this.options.repeatableElement, + handle: this.options.btMove, + tolerance: 'pointer' + }); + } + + // tell all that we a ready + this.$container.trigger('subform-ready'); + }; + + // prepare a template that we will use repeating + $.subformRepeatable.prototype.prepareTemplate = function(){ + // create from template + if(this.options.rowTemplateSelector){ + var tmplElement = this.$container.find(this.options.rowTemplateSelector)[0] || {}; + this.template = $.trim(tmplElement.text || tmplElement.textContent); //(text || textContent) is IE8 fix + } + // create from existing rows + else { + //find first available + var row = this.$container.find(this.options.repeatableElement).get(0), + $row = $(row).clone(); + + // clear scripts that can be attached to the fields + try { + this.clearScripts($row); + } catch (e) { + if(window.console){ + console.log(e); + } + } + + this.template = $row.prop('outerHTML'); + } + }; + + // add new row + $.subformRepeatable.prototype.addRow = function(after){ + // count how much we already have + var count = this.$containerRows.find(this.options.repeatableElement).length; + if(count >= this.options.maximum){ + return null; + } + + // make new from template + var row = $.parseHTML(this.template); + + //add to container + if(after){ + $(after).after(row); + } else { + this.$containerRows.append(row); + } + + var $row = $(row); + //add marker that it is new + $row.attr('data-new', 'true'); + // fix names and id`s, and reset values + this.fixUniqueAttributes($row, count); + + // try find out with related scripts, + // tricky thing, so be careful + try { + this.fixScripts($row); + } catch (e) { + if(window.console){ + console.log(e); + } + } + + // tell everyone about the new row + this.$container.trigger('subform-row-add', $row); + return $row; + }; + + // remove row + $.subformRepeatable.prototype.removeRow = function($row){ + // count how much we have + var count = this.$containerRows.find(this.options.repeatableElement).length; + if(count <= this.options.minimum){ + return; + } + + // tell everyoune about the row will be removed + this.$container.trigger('subform-row-remove', $row); + $row.remove(); + }; + + // fix names ind id`s for field that in $row + $.subformRepeatable.prototype.fixUniqueAttributes = function($row, count){ + this.lastRowNum++; + var group = $row.attr('data-group'),// current group name + basename = $row.attr('data-base-name'), // group base name, without count + count = count || 0, + countnew = Math.max(this.lastRowNum, count + 1), + groupnew = basename + countnew; // new group name + + this.lastRowNum = countnew; + $row.attr('data-group', groupnew); + + // fix inputs that have a "name" attribute + var haveName = $row.find('*[name]'), + ids = {}; // collect existing id`s for fix checkboxes and radio + for(var i=0, l = haveName.length; i fix + //check if multiple + if(name.match(/\[\]$/)){ + // replace a group label "for" + var groupLbl = $row.find('label[for="' + id + '"]'); + if(groupLbl.length){ + groupLbl.attr('for', idNew); + $el.parents('fieldset.checkboxes').attr('id', idNew); + } + // recount id + var count = ids[id] ? ids[id].length : 0; + forOldAttr = forOldAttr + count; + idNew = idNew + count; + } + } + else if($el.prop('type') === 'radio'){// fix + // recount id + var count = ids[id] ? ids[id].length : 0; + forOldAttr = forOldAttr + count; + idNew = idNew + count; + } + + //cache ids + if(ids[id]){ + ids[id].push(true); + } else { + ids[id] = [true]; + } + + // replace name to new + $el.attr('name', nameNew); + // set new id + $el.attr('id', idNew); + // guess there a lable for this input + $row.find('label[for="' + forOldAttr + '"]').attr('for', idNew); + } + }; + + // remove scripts attached to fields + // @TODO: make thing better when something like that will be accepted https://github.com/joomla/joomla-cms/pull/6357 + $.subformRepeatable.prototype.clearScripts = function($row){ + // destroy chosen if any + if($.fn.chosen){ + $row.find('select.chzn-done').each(function(){ + var $el = $(this); + $el.next('.chzn-container').remove(); + $el.show().addClass('fix-chosen'); + }); + } + + // colorpicker + if($.fn.minicolors){ + $row.find('.minicolors input').each(function(){ + $(this).removeData('minicolors-initialized') + .removeData('minicolors-settings') + .removeProp('size') + .removeProp('maxlength') + .removeClass('minicolors-input') + // move out from + .parents('span.minicolors').parent().append(this); + }); + $row.find('span.minicolors').remove(); + } + }; + + // method for hack the scripts that can be related + // to the one of field that in given $row + $.subformRepeatable.prototype.fixScripts = function($row){ + // init chosen if any + if($.fn.chosen){ + $row.find('select.advancedSelect').chosen(); + } + + //color picker + $row.find('.minicolors').each(function() { + var $el = $(this); + $el.minicolors({ + control: $el.attr('data-control') || 'hue', + position: $el.attr('data-position') || 'right', + theme: 'bootstrap' + }); + }); + + // fix media field + $row.find('a[onclick*="jInsertFieldValue"]').each(function(){ + var $el = $(this), + inputId = $el.siblings('input[type="text"]').attr('id'), + $select = $el.prev(), + oldHref = $select.attr('href'); + // update the clear button + $el.attr('onclick', "jInsertFieldValue('', '" + inputId + "');return false;") + // update select button + $select.attr('href', oldHref.replace(/&fieldid=(.+)&/, '&fieldid=' + inputId + '&')); + }); + + // bootstrap based Media field + if($.fn.fieldMedia){ + $row.find('.field-media-wrapper').fieldMedia(); + } + + // bootstrap tooltips + if($.fn.tooltip){ + $row.find('.hasTooltip').tooltip({html: true, container: "body"}); + } + + // bootstrap based User field + if($.fn.fieldUser){ + $row.find('.field-user-wrapper').fieldUser(); + } + + // another modals + if(window.SqueezeBox && window.SqueezeBox.assign){ + SqueezeBox.assign($row.find('a.modal').get(), {parse: 'rel'}); + } + }; + + // defaults + $.subformRepeatable.defaults = { + btAdd: ".group-add", // button selector for "add" action + btRemove: ".group-remove",// button selector for "remove" action + btMove: ".group-move",// button selector for "move" action + minimum: 0, // minimum repeating + maximum: 10, // maximum repeating + repeatableElement: ".subform-repeatable-group", + rowTemplateSelector: 'script.subform-repeatable-template-section', // selector for the row template