silverstripe-framework/src/Forms/TreeMultiselectField.php

323 lines
10 KiB
PHP
Raw Permalink Normal View History

<?php
namespace SilverStripe\Forms;
use http\Exception\InvalidArgumentException;
use SilverStripe\Control\Controller;
use SilverStripe\Core\Convert;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
2016-06-23 01:37:22 +02:00
use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\ORM\Relation;
use SilverStripe\ORM\SS_List;
use SilverStripe\Security\Group;
use SilverStripe\View\ViewableData;
/**
* This formfield represents many-many joins using a tree selector shown in a dropdown styled element
* which can be added to any form usually in the CMS.
2014-08-15 08:53:05 +02:00
*
* This form class allows you to represent Many-Many Joins in a handy single field. The field has javascript which
* generates a AJAX tree of the site structure allowing you to save selected options to a component set on a given
* {@link DataObject}.
2014-08-15 08:53:05 +02:00
*
* <b>Saving</b>
2014-08-15 08:53:05 +02:00
*
* This field saves a {@link ComponentSet} object which is present on the {@link DataObject} passed by the form,
* returned by calling a function with the same name as the field. The Join is updated by running setByIDList on the
* {@link ComponentSet}
2014-08-15 08:53:05 +02:00
*
* <b>Customizing Save Behaviour</b>
2014-08-15 08:53:05 +02:00
*
* Before the data is saved, you can modify the ID list sent to the {@link ComponentSet} by specifying a function on
* the {@link DataObject} called "onChange[fieldname](&items)". This will be passed by reference the IDlist (an array
* of ID's) from the Treefield to be saved to the component set.
2014-08-15 08:53:05 +02:00
*
* Returning false on this method will prevent treemultiselect from saving to the {@link ComponentSet} of the given
* {@link DataObject}
2014-08-15 08:53:05 +02:00
*
* <code>
* // Called when we try and set the Parents() component set
* // by Tree Multiselect Field in the administration.
* function onChangeParents(&$items) {
* // This ensures this DataObject can never be a parent of itself
2016-11-29 00:31:16 +01:00
* if($items){
* foreach($items as $k => $id){
* if($id == $this->ID){
* unset($items[$k]);
* }
* }
* }
* return true;
* }
2014-08-15 08:53:05 +02:00
* </code>
*
* @see TreeDropdownField for the sample implementation, but only allowing single selects
*/
2016-11-29 00:31:16 +01:00
class TreeMultiselectField extends TreeDropdownField
{
public function __construct(
$name,
$title = null,
$sourceObject = Group::class,
$keyField = "ID",
$labelField = "Title"
) {
2016-11-29 00:31:16 +01:00
parent::__construct($name, $title, $sourceObject, $keyField, $labelField);
$this->removeExtraClass('single');
$this->addExtraClass('multiple');
$this->value = 'unchanged';
}
2017-07-26 08:13:56 +02:00
public function getSchemaDataDefaults()
{
$data = parent::getSchemaDataDefaults();
2017-07-26 08:13:56 +02:00
$data['data'] = array_merge($data['data'], [
'hasEmptyDefault' => false,
'multiple' => true,
]);
return $data;
}
2017-07-26 08:13:56 +02:00
public function getSchemaStateDefaults()
{
$data = parent::getSchemaStateDefaults();
unset($data['data']['valueObject']);
2017-07-26 08:13:56 +02:00
$items = $this->getItems();
$values = [];
foreach ($items as $item) {
if ($item instanceof DataObject) {
$values[] = [
2017-07-26 08:13:56 +02:00
'id' => $item->obj($this->getKeyField())->getValue(),
'title' => $item->obj($this->getTitleField())->getValue(),
'parentid' => $item->ParentID,
2017-07-26 08:13:56 +02:00
'treetitle' => $item->obj($this->getLabelField())->getSchemaValue(),
];
} else {
$values[] = $item;
}
}
$data['data']['valueObjects'] = $values;
2017-07-26 08:13:56 +02:00
// cannot rely on $this->value as this could be a many-many relationship
2022-04-14 03:12:59 +02:00
$value = array_column($values ?? [], 'id');
if ($value) {
sort($value);
$data['value'] = $value;
} else {
$data['value'] = 'unchanged';
}
2017-07-26 08:13:56 +02:00
return $data;
}
2016-11-29 00:31:16 +01:00
/**
* Return this field's linked items
* @return ArrayList|DataList $items
2016-11-29 00:31:16 +01:00
*/
public function getItems()
{
$value = $this->Value();
2017-07-26 08:13:56 +02:00
// If unchanged, load from record
if ($value === 'unchanged') {
// Verify a form exists
$form = $this->getForm();
if (!$form) {
return ArrayList::create();
}
2017-07-26 08:13:56 +02:00
// Verify this form has an attached record with the necessary relation
$fieldName = $this->getName();
$record = $form->getRecord();
if ($record instanceof DataObject && $record->hasMethod($fieldName)) {
return $record->$fieldName();
2016-11-29 00:31:16 +01:00
}
// No relation on parent record found
return ArrayList::create();
}
2017-07-26 08:13:56 +02:00
// Value is a list
if ($value instanceof SS_List) {
return $value;
}
// Parse ids from value string / array
$ids = [];
if (is_string($value)) {
2022-04-14 03:12:59 +02:00
$ids = preg_split("#\s*,\s*#", trim($value ?? ''));
} elseif (is_array($value)) {
2022-04-14 03:12:59 +02:00
$ids = array_values($value ?? []);
}
// Filter out empty strings
2022-04-14 03:12:59 +02:00
$ids = array_filter($ids ?? []);
// No value
if (empty($ids)) {
return ArrayList::create();
}
// Query source records by value field
return DataObject::get($this->getSourceObject())
->filter($this->getKeyField(), $ids);
}
public function setValue($value, $source = null)
{
// If loading from a dataobject, get items by relation
if ($source instanceof DataObject) {
$name = $this->getName();
if ($source->hasMethod($name)) {
$value = $source->$name();
2016-11-29 00:31:16 +01:00
}
}
2017-07-26 08:13:56 +02:00
// Handle legacy value; form-submitted `unchanged` implies empty set.
// See TreeDropdownField.js
if ($value === 'unchanged') {
$value = [];
}
return parent::setValue($value);
}
public function dataValue()
{
return $this->getItems()->column($this->getKeyField());
2016-11-29 00:31:16 +01:00
}
/**
* We overwrite the field attribute to add our hidden fields, as this
* formfield can contain multiple values.
*
* @param array $properties
* @return DBHTMLText
*/
public function Field($properties = [])
2016-11-29 00:31:16 +01:00
{
$value = '';
$titleArray = [];
$idArray = [];
2016-11-29 00:31:16 +01:00
$items = $this->getItems();
2017-04-20 03:15:24 +02:00
$emptyTitle = _t('SilverStripe\\Forms\\DropdownField.CHOOSE', '(Choose)', 'start value of a dropdown');
2016-11-29 00:31:16 +01:00
2022-04-14 03:12:59 +02:00
if ($items && count($items ?? [])) {
2016-11-29 00:31:16 +01:00
foreach ($items as $item) {
$idArray[] = $item->ID;
$titleArray[] = ($item instanceof ViewableData)
2017-07-26 08:13:56 +02:00
? $item->obj($this->getLabelField())->forTemplate()
: Convert::raw2xml($item->{$this->getLabelField()});
2016-11-29 00:31:16 +01:00
}
$title = implode(", ", $titleArray);
sort($idArray);
2016-11-29 00:31:16 +01:00
$value = implode(",", $idArray);
} else {
$title = $emptyTitle;
}
$dataUrlTree = '';
if ($this->form) {
$dataUrlTree = $this->Link('tree');
if (!empty($idArray)) {
2018-01-16 19:39:30 +01:00
$dataUrlTree = Controller::join_links($dataUrlTree, '?forceValue=' . implode(',', $idArray));
2016-11-29 00:31:16 +01:00
}
}
$properties = array_merge(
$properties,
[
2016-11-29 00:31:16 +01:00
'Title' => $title,
'EmptyTitle' => $emptyTitle,
'Link' => $dataUrlTree,
'Value' => $value
]
2016-11-29 00:31:16 +01:00
);
return FormField::Field($properties);
}
/**
* Save the results into the form
* Calls function $record->onChange($items) before saving to the assumed
2016-11-29 00:31:16 +01:00
* Component set.
*
* @param DataObjectInterface $record
*/
public function saveInto(DataObjectInterface $record)
{
$fieldName = $this->getName();
2016-11-29 00:31:16 +01:00
/** @var Relation $saveDest */
$saveDest = $record->$fieldName();
if (!$saveDest) {
$recordClass = get_class($record);
throw new \RuntimeException(
"TreeMultiselectField::saveInto() Field '$fieldName' not found on"
. " {$recordClass}.{$record->ID}"
);
}
2017-07-26 08:13:56 +02:00
$itemIDs = $this->getItems()->column('ID');
2016-11-29 00:31:16 +01:00
// Allows you to modify the itemIDs on your object before save
$funcName = "onChange$fieldName";
if ($record->hasMethod($funcName)) {
$result = $record->$funcName($itemIDs);
if (!$result) {
return;
2016-11-29 00:31:16 +01:00
}
}
$saveDest->setByIDList($itemIDs);
2016-11-29 00:31:16 +01:00
}
/**
* Changes this field to the readonly field.
*/
public function performReadonlyTransformation()
{
2017-07-26 08:13:56 +02:00
/** @var TreeMultiselectField_Readonly $copy */
$copy = $this->castedCopy(TreeMultiselectField_Readonly::class);
$copy->setKeyField($this->getKeyField());
$copy->setLabelField($this->getLabelField());
$copy->setSourceObject($this->getSourceObject());
$copy->setTitleField($this->getTitleField());
2016-11-29 00:31:16 +01:00
return $copy;
}
/**
* {@inheritdoc}
*
2018-11-12 22:20:49 +01:00
* @internal To be removed in 5.0
*/
protected function objectForKey($key)
{
/**
* Fixes https://github.com/silverstripe/silverstripe-framework/issues/8332
*
* Due to historic reasons, the default (empty) value for this field is 'unchanged', even though
* the field is usually integer on the database side.
* MySQL handles that gracefully and returns an empty result in that case,
* whereas some other databases (e.g. PostgreSQL) do not support comparison
* of numeric types with string values, issuing a database error.
*
* This fix is not ideal, but supposed to keep backward compatibility for SS4.
*
* In 5.0 this method to be removed and NULL should be used instead of 'unchanged' (or an empty array. to be decided).
* In 5.0 this class to be refactored so that $this->value is always an array of values (or null)
*/
2018-11-12 22:20:49 +01:00
if ($this->getKeyField() === 'ID' && $key === 'unchanged') {
$key = null;
} elseif (is_string($key)) {
2022-04-14 03:12:59 +02:00
$key = preg_split('/\s*,\s*/', trim($key ?? ''));
}
return parent::objectForKey($key);
}
}