diff --git a/css/ArrayListField.scss.css b/css/ArrayListField.scss.css new file mode 100644 index 0000000..fab0f12 --- /dev/null +++ b/css/ArrayListField.scss.css @@ -0,0 +1,51 @@ +.zauberfisch\\SerializedDataObject\\Form\\ArrayListField > label.left { + display: block; + float: none; + padding-bottom: 10px; } + +.zauberfisch\\SerializedDataObject\\Form\\ArrayListField > .middleColumn { + margin-left: 0; } + +.zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.field { + border: 0; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + margin: 0; + padding: 0 0 5px; } + +.zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list { + border-radius: 3px; + border: 1px solid #CDCCD0; + background: rgba(0, 0, 0, 0.05); + margin: 0 0 10px; } + .zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list .record { + padding: 20px 10px 10px; + border-bottom: 1px solid #CDCCD0; + position: relative; } + .zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list .record:last-child { + border-bottom: 0; } + .zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list .record.pre-delete { + background: rgba(255, 0, 0, 0.05); } + .zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list .record .controls { + position: absolute; + right: 5px; + top: 10px; } + .zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list .record .controls > * { + display: block; + float: left; + margin: 0 5px 0 0; } + .zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list .record .controls .orderable-handle { + height: 22px; + width: 16px; + cursor: move; + background: center center no-repeat url("../images/orderable-handle.png"); } + .zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list .record .controls .delete-record { + padding: 2px; } + .zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list .record .controls .delete-record:before { + margin: 0; } + .zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list .record .controls .delete-record span { + display: none; } + .zauberfisch\\SerializedDataObject\\Form\\ArrayListField div.record-list .record .controls .delete-record:hover { + background: transparent; + opacity: .6; } diff --git a/css/SortableUploadField.scss.css b/css/SortableUploadField.scss.css index 043a819..a35115c 100644 --- a/css/SortableUploadField.scss.css +++ b/css/SortableUploadField.scss.css @@ -1,13 +1,11 @@ .ui-sortable .preview img { - cursor: move; -} + cursor: move; } .zauberfisch\\serializeddataobject\\form\\sortableupload .ui-sortable-helper { - background-color: #fff !important; - border: 1px solid #b3b3b3 !important; - border-left: 0 !important; - border-right: 0 !important; - -webkit-box-shadow: 0px 9px 5px -5px rgba(0, 0, 0, 0.3); - -moz-box-shadow: 0px 9px 5px -5px rgba(0, 0, 0, 0.3); - box-shadow: 0px 9px 5px -5px rgba(0, 0, 0, 0.3); -} + background-color: #fff !important; + border: 1px solid #b3b3b3 !important; + border-left: 0 !important; + border-right: 0 !important; + -webkit-box-shadow: 0px 9px 5px -5px rgba(0, 0, 0, 0.3); + -moz-box-shadow: 0px 9px 5px -5px rgba(0, 0, 0, 0.3); + box-shadow: 0px 9px 5px -5px rgba(0, 0, 0, 0.3); } diff --git a/images/orderable-handle.png b/images/orderable-handle.png new file mode 100644 index 0000000..e65815e Binary files /dev/null and b/images/orderable-handle.png differ diff --git a/javascript/ArrayListField.js b/javascript/ArrayListField.js new file mode 100644 index 0000000..3653870 --- /dev/null +++ b/javascript/ArrayListField.js @@ -0,0 +1,69 @@ +(function ($) { + $('.zauberfisch\\\\SerializedDataObject\\\\Form\\\\ArrayListField').entwine({ + getRecordList: function () { + return this.find('.record-list'); + } + }); + $('.zauberfisch\\\\SerializedDataObject\\\\Form\\\\ArrayListField *').entwine({ + getRootForm: function () { + return this.closest('form'); + }, + getContainerField: function () { + return this.closest('.zauberfisch\\\\SerializedDataObject\\\\Form\\\\ArrayListField'); + } + }); + $('.zauberfisch\\\\SerializedDataObject\\\\Form\\\\ArrayListField.orderable .record-list').entwine({ + onmatch: function () { + // enable sorting functionality + var self = this, + rootForm = this.closest('form'); + self.sortable({ + handle: ".orderable-handle", + axis: "y" + }); + this._super(); + }, + onunmatch: function () { + try { + $(this).sortable("destroy"); + } catch (e) { + } + this._super(); + } + }); + $('.zauberfisch\\\\SerializedDataObject\\\\Form\\\\ArrayListField .add-record').entwine({ + onclick: function () { + var field = this.getContainerField(), + recordList = field.getRecordList(), + _this = this, + newIndex = recordList.find('.record').length, + url = field.data('add-record-url') + '?index=' + newIndex; + this.addClass('loading'); + this.getRootForm().addClass('changed'); + $.get(url, function (content) { + recordList.append(content); + _this.removeClass('loading'); + _this.blur(); + }); + return false; + } + }); + $('.zauberfisch\\\\SerializedDataObject\\\\Form\\\\ArrayListField .delete-record').entwine({ + onclick: function () { + var container = this.closest('.record'), + _this = this; + container.addClass('pre-delete'); + setTimeout(function () { + if (confirm(_this.data('confirm'))) { + container.fadeOut(function () { + container.remove(); + }); + } + container.removeClass('pre-delete'); + }, 100); + this.blur(); + return false; + } + }); +}) +(jQuery); diff --git a/scss/ArrayListField.scss b/scss/ArrayListField.scss new file mode 100644 index 0000000..ec91431 --- /dev/null +++ b/scss/ArrayListField.scss @@ -0,0 +1,69 @@ +.zauberfisch\\SerializedDataObject\\Form\\ArrayListField { + > label.left { + display: block; + float: none; + padding-bottom: 10px; + } + > .middleColumn { + margin-left: 0; + } + div.field { + border: 0; + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; + margin: 0; + padding: 0 0 5px; + } + div.record-list { + border-radius: 3px; + border: 1px solid #CDCCD0; + background: rgba(#000, .05); + margin: 0 0 10px; + + .record { + padding: 20px 10px 10px; + border-bottom: 1px solid #CDCCD0; + position: relative; + + &:last-child { + border-bottom: 0; + } + &.pre-delete { + background: rgba(red, .05); + } + .controls { + position: absolute; + right: 5px; + top: 10px; + + > * { + display: block; + float: left; + margin: 0 5px 0 0; + } + + .orderable-handle { + height: 22px; + width: 16px; + cursor: move; + background: center center no-repeat url('../images/orderable-handle.png'); + } + .delete-record { + padding: 2px; + + &:before { + margin: 0; + } + span { + display: none; + } + &:hover { + background: transparent; + opacity: .6; + } + } + } + } + } +} diff --git a/src/Form/ArrayListField.php b/src/Form/ArrayListField.php new file mode 100644 index 0000000..a23aa4c --- /dev/null +++ b/src/Form/ArrayListField.php @@ -0,0 +1,317 @@ +recordClassName = $recordClassName; + parent::__construct($name, $title); + } + + /** + * @param ArrayListDBField|array|string $val + * @return $this + * @throws \Exception + */ + public function setValue($val) { + if (is_a($val, ArrayListDBField::class)) { + $this->value = $val; + } else { + $this->value = new ArrayListDBField(); + $this->value->setValue('', null, true); + if ($val) { + // value is an array after form submission, lets turn it into an object + if (is_array($val)) { + $this->value->setValue($this->createValueFromArray($val)); + } elseif (is_string($val)) { + $this->value->setValue($val); + } else { + throw new \Exception('unexpected value'); + } + } + } + return $this; + } + + /** + * @return ArrayListDBField + */ + public function Value() { + $return = parent::Value(); + if (!$return) { + $this->setValue(null); + $return = parent::Value(); + } + return $return; + } + + public function createValueFromArray($array) { + $records = []; + $class = $this->recordClassName; + foreach ($array as $recordArray) { + /** @var AbstractDataObject $record */ + $record = new $class(); + $record->update($recordArray); + $records[] = $record; + } + return new ArrayList($records); + } + + /** + * @return array + */ + public function getAttributes() { + return $this->attributes; + } + + /** + * @param array $properties + * @return string + */ + public function FieldHolder($properties = []) { + $this->addExtraClass(self::class); + if ($this->orderable) { + $this->addExtraClass('orderable'); + } + $this->setAttribute('data-name', $this->getName()); + $this->setAttribute('data-add-record-url', $this->getAddRecordLink()); + return parent::FieldHolder($properties); + } + + /** + * @param array $properties + * @return string + * @throws \Exception + */ + public function Field($properties = []) { + \Requirements::javascript(SERIALIZED_DATAOBJECT_DIR . '/javascript/ArrayListField.js'); + \Requirements::css(SERIALIZED_DATAOBJECT_DIR . '/css/ArrayListField.scss.css'); + $records = $this->Value()->getValue(); + $fields = []; + foreach ($records as $i => $record) { + if (is_a($record, \__PHP_Incomplete_Class::class)) { + } else { + $fields[] = $this->getRecordFields($i, $record); + } + } + /** @noinspection PhpParamsInspection */ + return (new \CompositeField([ + (new \CompositeField($fields))->addExtraClass('record-list'), + (new \FormAction('addRecord', _t(self::class . '.AddRecord', 'add record'))) + ->setUseButtonTag(true) + ->addExtraClass('font-icon-plus') + ->addExtraClass('add-record'), + ]))->FieldHolder()->forTemplate(); + } + + /** + * @param int $index + * @param AbstractDataObject|null $record + * @return \CompositeField + * @throws \Exception + */ + protected function getRecordFields($index, AbstractDataObject $record = null) { + /** @var \FieldList $recordFields */ + $recordFields = call_user_func($this->getRecordFieldsCallback(), $this, $record); + if (!is_a($recordFields, \FieldList::class)) { + throw new \Exception(sprintf( + 'RecordFieldsCallback is expected to return FieldList, but returned "%s"', + is_object($recordFields) ? get_class($recordFields) : gettype($recordFields) + )); + } + $recordFields->setForm($this->form); + $this->loadDataFromRecord($recordFields->dataFields(), $record); + $controls = [ + (new \FormAction('ArrayListFieldControlsDelete', '')) + ->setUseButtonTag(true) + ->addExtraClass('delete-record') + ->addExtraClass('font-icon-cancel-circled') + ->setAttribute('data-confirm', _t(self::class . '.ConfirmDelete', 'Are you sure you want to delete this record?')), + ]; + if ($this->orderable) { + $controls [] = new \LiteralField('ArrayListFieldControlsOrderableHandle', '
'); + } + $recordFields->unshift( + (new CompositeField($controls)) + ->setName('ArrayListFieldControls') + ->addExtraClass('controls') + ); + $this->prefixRecordFields($index, $recordFields); + return (new \CompositeField($recordFields))->addExtraClass('record'); + } + + const MERGE_DEFAULT = 0; + const MERGE_CLEAR_MISSING = 1; + const MERGE_IGNORE_FALSEISH = 2; + + protected function loadDataFromRecord($dataFields, $data) { + $mergeStrategy = 0; + foreach ($dataFields as $field) { + /** @var \FormField $field */ + $name = $field->getName(); + + // First check looks for (fieldname)_unchanged, an indicator that we shouldn't overwrite the field value + if (is_array($data) && isset($data[$name . '_unchanged'])) continue; + + // Does this property exist on $data? + $exists = false; + // The value from $data for this field + $val = null; + + if (is_object($data)) { + $exists = ( + isset($data->$name) || + $data->hasMethod($name) || + ($data->hasMethod('hasField') && $data->hasField($name)) + ); + + if ($exists) { + $val = $data->__get($name); + } + } else if (is_array($data)) { + if (array_key_exists($name, $data)) { + $exists = true; + $val = $data[$name]; + } // If field is in array-notation we need to access nested data + else if (preg_match_all('/(.*)\[(.*)\]/U', $name, $matches)) { + //discard first match which is just the whole string + array_shift($matches); + + $keys = array_pop($matches); + $name = array_shift($matches); + $name = array_shift($name); + + if (array_key_exists($name, $data)) { + $tmpData = &$data[$name]; + // drill down into the data array looking for the corresponding value + foreach ($keys as $arrayKey) { + if ($arrayKey !== '') { + $tmpData = &$tmpData[$arrayKey]; + } else { + //empty square brackets means new array + if (is_array($tmpData)) { + $tmpData = array_shift($tmpData); + } + } + } + if ($tmpData) { + $val = $tmpData; + $exists = true; + } + } + } + } + + // save to the field if either a value is given, or loading of blank/undefined values is forced + if ($exists) { + if ($val != false || ($mergeStrategy & self::MERGE_IGNORE_FALSEISH) != self::MERGE_IGNORE_FALSEISH) { + // pass original data as well so composite fields can act on the additional information + $field->setValue($val, $data); + } + } else if (($mergeStrategy & self::MERGE_CLEAR_MISSING) == self::MERGE_CLEAR_MISSING) { + $field->setValue($val, $data); + } + } + } + + /** + * @param \FieldList $fields + */ + protected function prefixRecordFields($index, $fields) { + foreach ($fields as $field) { + /** @var \FormField|\CompositeField $field */ + $name = $field->getName(); + if ($name) { + $field->setName($this->getPrefixedRecordFieldName($index, $name)); + } + if ($field->isComposite()) { + $this->prefixRecordFields($index, $field->FieldList()); + } + } + } + + public function getPrefixedRecordFieldName($index, $fieldName) { + return sprintf('%s[%s][%s]', $this->getName(), $index, $fieldName); + } + + /** + * @param \DataObjectInterface $record + */ + public function saveInto(\DataObjectInterface $record) { + $record->{$this->name} = $this->Value(); + } + + private static $allowed_actions = [ + 'addRecord', + ]; + + public function addRecord(\SS_HTTPRequest $r) { + $index = (int)$r->getVar('index'); + return $this->getRecordFields($index)->FieldHolder()->forTemplate(); + } + + public function getAddRecordLink() { + return $this->Link('addRecord'); + } + + /** + * @param bool $bool + * @return ArrayListField + */ + public function setOrderable($bool) { + $this->orderable = $bool; + return $this; + } + + /** + * @return bool + */ + public function isOrderable() { + return $this->orderable; + } + + public function setForm($form) { + parent::setForm($form); + } + + /** + * @param mixed $recordFieldsCallback + * @return ArrayListField + */ + public function setRecordFieldsCallback($recordFieldsCallback) { + $this->recordFieldsCallback = $recordFieldsCallback; + return $this; + } + + /** + * @return mixed + */ + public function getRecordFieldsCallback() { + $callback = $this->recordFieldsCallback; + if (!is_callable($callback)) { + $class = $this->recordClassName; + /** + * @param ArrayListField $field + * @param AbstractDataObject $record + * @return mixed + */ + $callback = function (ArrayListField $field, $record = null) use ($class) { + if (!$record) { + $record = $class::singleton(); + } + return $record->getCMSFields(); + }; + } + return $callback; + } +} diff --git a/templates/SerializedDataObject-Form-ArrayListField.ss b/templates/SerializedDataObject-Form-ArrayListField.ss new file mode 100644 index 0000000..e77b170 --- /dev/null +++ b/templates/SerializedDataObject-Form-ArrayListField.ss @@ -0,0 +1,9 @@ +
+ <% if $Title %><% end_if %> +
+ $Field +
+ <% if $RightTitle %><% end_if %> + <% if $Message %>$Message<% end_if %> + <% if $Description %>$Description<% end_if %> +
diff --git a/templates/SerializedDataObject-Form-ArrayListField_holder.ss b/templates/SerializedDataObject-Form-ArrayListField_holder.ss new file mode 100644 index 0000000..e77b170 --- /dev/null +++ b/templates/SerializedDataObject-Form-ArrayListField_holder.ss @@ -0,0 +1,9 @@ +
+ <% if $Title %><% end_if %> +
+ $Field +
+ <% if $RightTitle %><% end_if %> + <% if $Message %>$Message<% end_if %> + <% if $Description %>$Description<% end_if %> +