2022-03-08 15:55:41 +01:00
<?php namespace ProcessWire;
/**
* ProcessWire Page Table Inputfield
*
* Concept by Antti Peisa
* Code by Ryan Cramer
* Sponsored by Avoine
*
2024-04-04 14:37:20 +02:00
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
2022-03-08 15:55:41 +01:00
* https://processwire.com
*
* @todo add renderValue support (perhaps delegating to Fieldtype::markupValue), likewise for repeaters.
*
* @property int $parent_id
* @property int $template_id
* @property string $columns
* @property string $nameFormat
* @property int|bool $noclose
* @property string $blankLabel
*
* @method string renderTable(array $columns)
*
*/
class InputfieldPageTable extends Inputfield {
public static function getModuleInfo() {
return array(
'title' => __('ProFields: Page Table', __FILE__), // Module Title
'summary' => __('Inputfield to accompany FieldtypePageTable', __FILE__), // Module Summary
'version' => 14,
'requires' => 'FieldtypePageTable'
2024-04-04 14:37:20 +02:00
);
2022-03-08 15:55:41 +01:00
}
/**
* Labels for native fields, indexed by native field name
*
* @var array
*
*/
protected $nativeLabels = array();
/**
* Array of Template objects used for each row
*
* @var array
*
*/
protected $rowTemplates = array();
/**
* Possible orphan pages that may be added (set by the Fieldtype)
*
* @var PageArray|null
*
*/
protected $orphans = null;
/**
* Whether or not a separate "edit" column is needed
*
* @var bool
*
*/
protected $needsEditColumn = false;
/**
* True when in renderValue mode
*
* @var bool
*
*/
protected $renderValueMode = false;
/**
* Initialize and establish default values
*
*/
public function init() {
// fieldtype and inputfield config settings
$this->set('parent_id', 0);
$this->set('template_id', 0); // placeholder only
$this->set('columns', '');
$this->set('nameFormat', '');
$this->set('noclose', 0);
$this->set('blankLabel', $this->_('[blank]'));
// local settings
$this->nativeLabels = array(
'id' => $this->_x('ID', 'th'),
'name' => $this->_x('Name', 'th'),
'created' => $this->_x('Created', 'th'),
'modified' => $this->_x('Modified', 'th'),
'published' => $this->_x('Published', 'th'),
'modifiedUser' => $this->_x('Modified By', 'th'),
'createdUser' => $this->_x('Created By', 'th'),
'url' => $this->_x('URL', 'th'),
'path' => $this->_x('Path', 'th'),
'template' => $this->_x('Template', 'th'),
'parent' => $this->_x('Parent', 'th'),
'numChildren' => $this->_x('Children', 'th'),
'status' => $this->_x('Status', 'th'),
2024-04-04 14:37:20 +02:00
);
2022-03-08 15:55:41 +01:00
parent::init();
}
public function renderReady(Inputfield $parent = null, $renderValueMode = false) {
$this->addClass('InputfieldNoFocus', 'wrapClass');
2024-04-04 14:37:20 +02:00
$jQueryUI = $this->wire()->modules->get('JqueryUI'); /** @var JqueryUI $jQueryUI */
$jQueryUI->use('modal');
2022-03-08 15:55:41 +01:00
return parent::renderReady($parent, $renderValueMode);
}
/**
* Render the PageTable Inputfield
*
* @return string
*
*/
public function ___render() {
2024-04-04 14:37:20 +02:00
$sanitizer = $this->wire()->sanitizer;
$modules = $this->wire()->modules;
$process = $this->wire()->process;
$config = $this->wire()->config;
$input = $this->wire()->input;
2022-03-08 15:55:41 +01:00
// make sure we've got enough info to generate a table
$errors = array();
if(!count($this->rowTemplates)) $errors[] = $this->_('Please configure this field with a template selection before using it.');
if(!$this->columns) $errors[] = $this->_('Please enter one or more columns in your field settings before using this field.');
if(count($errors)) return "<p class='ui-state-error'>" . implode('<br />', $errors) . "</p>";
// determine what columns we'll show in the table
$columnsString = $this->columns ? $this->columns : $this->getConfigDefaultColumns();
$columns = array();
2024-04-04 14:37:20 +02:00
2022-03-08 15:55:41 +01:00
foreach(explode("\n", $columnsString) as $column) {
$width = 0;
if(strpos($column, '=') !== false) list($column, $width) = explode('=', $column);
$column = trim($column);
$width = (int) $width;
$columns[$column] = $width;
}
// render the table
$out = $this->renderTable($columns);
if($this->renderValueMode) return $out;
2024-04-04 14:37:20 +02:00
$editID = (int) $input->get('id');
if(!$editID && $process instanceof WirePageEditor) $editID = $process->getPage()->id;
2022-03-08 15:55:41 +01:00
$parentID = $this->parent_id ? $this->parent_id : $editID;
// render the 'Add New' buttons for each template
$btn = '';
foreach($this->rowTemplates as $template) {
/** @var Template $template */
2024-04-04 14:37:20 +02:00
/** @var InputfieldButton $button */
$button = $modules->get('InputfieldButton');
2022-03-08 15:55:41 +01:00
$button->icon = 'plus-circle';
$button->value = count($this->rowTemplates) == 1 ? $this->_x('Add New', 'button') : $template->getLabel();
2024-04-04 14:37:20 +02:00
$url = $config->urls->admin . "page/add/?modal=1&template_id=$template->id&parent_id=$parentID&context=PageTable";
if($this->nameFormat) $url .= "&name_format=" . $sanitizer->entities($this->nameFormat);
2022-03-08 15:55:41 +01:00
$btn .= "<span class='InputfieldPageTableAdd' data-url='$url'>" . $button->render() . "</span>";
}
2024-04-04 14:37:20 +02:00
2022-03-08 15:55:41 +01:00
if(count($this->rowTemplates) > 1) $btn = "<small>$btn</small>";
$out .= "<div class='InputfieldPageTableButtons ui-helper-clearfix'>$btn</div>";
2024-04-04 14:37:20 +02:00
if(!$input->get('InputfieldPageTableField')) {
2022-03-08 15:55:41 +01:00
$url = "./?id=$editID&InputfieldPageTableField=$this->name";
$out = "<div class='InputfieldPageTableContainer' data-url='$url' data-noclose='$this->noclose'>$out</div>";
// input for sorting purposes
2024-04-04 14:37:20 +02:00
$value = $sanitizer->entities($this->attr('value'));
$name = $sanitizer->entities($this->attr('name'));
2022-03-08 15:55:41 +01:00
$out .= "<input type='hidden' name='$name' class='InputfieldPageTableSort' value='$value' />";
$out .= "<input type='hidden' name='{$name}__delete' class='InputfieldPageTableDelete' value='' />";
if($this->orphans && count($this->orphans)) {
$out .= "<p class='InputfieldPageTableOrphans'>";
$out .= "<span>" . $this->_('Children were found that may be added to this table. Check the box next to any you would like to add.') . "</span> ";
2024-04-04 14:37:20 +02:00
if(count($this->orphans) > 1) {
$out .= "<br /><a class='InputfieldPageTableOrphansAll' href='#'>" . $this->_('Select all') . "</a> ";
}
2022-03-08 15:55:41 +01:00
foreach($this->orphans as $item) {
$label = $item->title;
if(!strlen($label)) $label = $item->name;
$out .= "<label><input type='checkbox' name='{$this->name}__add_orphan[]' value='$item->id' /> <span class='detail'>$label</span></label>";
}
$out .= "</p>";
}
}
return $out;
}
/**
* Render non-editable value
*
* @return string
*
*/
public function ___renderValue() {
$this->renderValueMode = true;
$out = $this->render();
$this->renderValueMode = false;
return $out;
}
/**
* Render the outputted PageTable <table>
*
* @param array $columns Array of column name => width percent
* @return string
*
*/
protected function ___renderTable(array $columns) {
2024-04-04 14:37:20 +02:00
$fields = $this->wire()->fields;
2022-03-08 15:55:41 +01:00
$this->needsEditColumn = false;
/** @var PageArray $value */
$value = $this->attr('value');
2024-04-04 14:37:20 +02:00
$this->wire()->modules->get('MarkupAdminDataTable'); // for styles
2022-03-08 15:55:41 +01:00
if(!count($value)) return ''; // if nothing in the value, just return blank
// $template = $this->template_id ? $this->wire('templates')->get((int) $this->template_id) : null;
$template = count($this->rowTemplates) > 0 ? reset($this->rowTemplates) : null;
2024-04-04 14:37:20 +02:00
$fieldsByCol = array();
$labelsByCol = array();
2022-03-08 15:55:41 +01:00
2024-04-04 14:37:20 +02:00
// populate $fieldsByCol and $labelsByCol
2022-03-08 15:55:41 +01:00
foreach($columns as $column => $width) {
$field = null;
$fieldName = $column;
$label = '';
// check if field contains field.subfield
if(strpos($column, '.') !== false) {
$parentField = null;
list($parentFieldName, $fieldName) = explode('.', $column);
if($template) $parentField = $template->fieldgroup->getFieldContext($parentFieldName);
2024-04-04 14:37:20 +02:00
if(!$parentField) $parentField = $fields->get($parentFieldName);
2022-03-08 15:55:41 +01:00
if($parentField) {
$label = $parentField->getLabel();
} else if(isset($this->nativeLabels[$parentFieldName])) {
$label = $this->nativeLabels[$parentFieldName];
} else {
$label = $parentFieldName;
}
$label .= " > ";
}
if($template) $field = $template->fieldgroup->getFieldContext($fieldName);
2024-04-04 14:37:20 +02:00
if(!$field) $field = $fields->get($fieldName);
2022-03-08 15:55:41 +01:00
if($field) {
$label .= $field->getLabel();
2024-04-04 14:37:20 +02:00
$fieldsByCol[$column] = $field;
2022-03-08 15:55:41 +01:00
} else if(isset($this->nativeLabels[$fieldName])) {
$label .= $this->nativeLabels[$fieldName];
} else {
$label .= $column;
}
2024-04-04 14:37:20 +02:00
$labelsByCol[$column] = $label;
2022-03-08 15:55:41 +01:00
}
2024-04-04 14:37:20 +02:00
$out = $this->renderTableBody($value, $columns, $fieldsByCol); // render order intentional
$out = $this->renderTableHead($columns, $labelsByCol) . $out;
2022-03-08 15:55:41 +01:00
return $out;
}
/**
* Render the table head, from <table> to </thead>
*
* @param array $columns
* @param array $labels
* @return string
*
*/
protected function renderTableHead(array $columns, array $labels) {
/** @var MarkupAdminDataTable $module */
2024-04-04 14:37:20 +02:00
$module = $this->wire()->modules->get('MarkupAdminDataTable');
$sanitizer = $this->wire()->sanitizer;
2022-03-08 15:55:41 +01:00
$classes = array();
foreach(array('class', 'addClass', 'responsiveClass', 'responsiveAltClass') as $key) {
$value = $module->settings($key);
if(!empty($value)) $classes[] = $value;
}
$tableClass = implode(' ', $classes);
$out = "<table class='$tableClass'><thead><tr>";
if($this->needsEditColumn) $out .= "<th> </th>";
foreach($columns as $column => $width) {
$attr = $width ? " style='width: $width%'" : '';
$label = $labels[$column];
2024-04-04 14:37:20 +02:00
$out .= "<th$attr>" . $sanitizer->entities($label) . "</th>";
2022-03-08 15:55:41 +01:00
}
if(!$this->renderValueMode) $out .= "<th> </th>";
$out .= "</tr></thead>";
return $out;
}
/**
* Render the table body, from <tbody> to </table>
*
* @param PageArray $items
* @param array $columns
* @param array $fields
* @return string
*
*/
protected function renderTableBody(PageArray $items, array $columns, array $fields) {
$rows = array();
foreach($items as $key => $item) {
/** @var Page $item */
$of = $item->of();
$item->of(true);
$rows[$key] = $this->renderTableRow($item, $columns, $fields);
$item->of($of);
}
if($this->needsEditColumn) {
foreach($rows as $key => $row) {
list($s1, $s2) = explode('>', $row, 2);
$item = $items[$key];
$rows[$key] = "$s1><td>" . $this->renderItemLink($item, wireIconMarkup('edit')) . "</td>$s2";
}
}
return "<tbody>" . implode("", $rows) . "</tbody></table>";
}
/**
* Render an individual table row <tr> for a given PageTable item
*
* @param Page $item
* @param array $columns
* @param array $fields
* @return string
*
*/
protected function renderTableRow(Page $item, array $columns, array $fields) {
$out = '';
$n = 0;
foreach($columns as $column => $width) {
$linkURL = ($n++ && !$this->renderValueMode ? '' : $this->getItemEditURL($item));
$out .= $this->renderTableCol($item, $fields, $column, $width, $linkURL);
}
// append a delete column/link
if(!$this->renderValueMode) {
$a = "<a class='InputfieldPageTableDelete' href='#'>" . wireIconMarkup('trash-o') . "</a>";
if(!$item->deletable()) $a = " ";
$out .= "<td>$a</td>";
}
// wrap the row in a <tr>
$class = '';
if($item->hasStatus(Page::statusUnpublished)) $class .= 'PageListStatusUnpublished ';
if($item->hasStatus(Page::statusHidden)) $class .= 'PageListStatusHidden ';
if($item->isTrash()) $class .= 'PageListStatusTrash';
if($class) $class = " class='" . trim($class) . "'";
return "<tr data-id='$item->id'$class>$out</tr>";
}
/**
* Render an individual <td> for a table row
*
* @param Page $item
* @param array $fields
* @param $column
* @param $width
* @param string $linkURL
* @return string
*
*/
protected function renderTableCol(Page $item, array $fields, $column, $width, $linkURL = '') {
$out = $this->getItemValue($item, $fields, $column);
if($linkURL && !$this->renderValueMode) {
if(stripos($out, '<a ') !== false || stripos($out, '<li') !== false || !strlen($out)) {
// table will need a separate edit column since this item doesn't work as a link
$this->needsEditColumn = true;
if(!strlen($out)) $out = "<span class='detail'>$this->blankLabel</span>";
} else {
$out = $this->renderItemLink($item, $out, $linkURL);
}
}
$attr = $width ? " style='width: $width%'" : '';
return "<td$attr>$out</td>";
}
/**
* Get the value for the given Page field identified by $column
*
* @param Page $item
* @param array $fields
* @param $column
* @return mixed|object|string
*
*/
protected function getItemValue(Page $item, array $fields, $column) {
$fieldName = $column;
$subfieldName = '';
2024-04-04 14:37:20 +02:00
if(strpos($column, '.') !== false) {
list($fieldName, $subfieldName) = explode('.', $column);
}
2022-03-08 15:55:41 +01:00
if(isset($fields[$column])) {
// custom
2024-04-04 14:37:20 +02:00
$field = $fields[$column]; /** @var Field $field */
2022-03-08 15:55:41 +01:00
$v = $item->getFormatted($fieldName);
$value = (string) $field->type->markupValue($item, $field, $v, $subfieldName);
} else {
// native
$value = $item->get($fieldName);
if(is_object($value) && $subfieldName) {
$value = $this->objectToString($value, $subfieldName);
$fieldName = $subfieldName;
}
if($fieldName == 'modified' || $fieldName == 'created' || $fieldName == 'published') {
$value = wireDate($this->_('Y-m-d H:i'), (int) $value); // Date format for created/modified/published
}
}
return $value;
}
/**
* Render an item edit link surrounded by the given output
*
* @param Page $item
* @param string $out
* @param string $url Optional
* @return string
*
*/
protected function renderItemLink(Page $item, $out, $url = '') {
if(!$url) $url = $this->getItemEditURL($item);
2024-04-04 14:37:20 +02:00
return "<a class='InputfieldPageTableEdit' data-url='$url' href='$url'>$out</a>";
2022-03-08 15:55:41 +01:00
}
/**
* Get an item edit URL
*
* @param Page $item
* @return string
*
*/
protected function getItemEditURL(Page $item) {
2024-04-04 14:37:20 +02:00
return $this->wire()->config->urls->admin . "page/edit/?id=$item->id&modal=1&context=PageTable";
2022-03-08 15:55:41 +01:00
}
/**
* Convert an object to a string for rendering in a table
*
* @param $object
* @param string $property Property to display from the object (default=title|name)
* @return string
*
*/
protected function objectToString($object, $property = '') {
if($object instanceof WireArray) {
if(!$property) $property = 'title|name';
if($property == 'count') {
$value = $object->count();
} else {
$value = $object->implode("\n", $property);
}
} else if($property) {
$value = $object->$property;
if(is_object($value)) $value = $this->objectToString($value);
} else {
$value = (string) $object;
}
2024-04-04 14:37:20 +02:00
$value = $this->wire()->sanitizer->entities(strip_tags($value));
2022-03-08 15:55:41 +01:00
$value = nl2br($value);
2024-04-04 14:37:20 +02:00
2022-03-08 15:55:41 +01:00
return $value;
}
/**
* Process input submitted to a PageTable Inputfield
*
* @param WireInputData $input
* @return $this
*
*/
public function ___processInput(WireInputData $input) {
2024-04-04 14:37:20 +02:00
$pages = $this->wire()->pages;
2022-03-08 15:55:41 +01:00
$name = $this->attr('name');
$deleteName = $name . '__delete';
$deleteIDs = explode('|', $input->$deleteName);
$ids = explode('|', $input->$name);
2024-04-04 14:37:20 +02:00
$value = $this->attr('value'); /** @var PageArray $value */
$sorted = $pages->newPageArray();
2022-03-08 15:55:41 +01:00
$changed = false;
// trash items that have been deleted
foreach($deleteIDs as $id) {
foreach($value as $item) {
/** @var Page $item */
if($id != $item->id) continue;
if(!$item->deleteable()) continue;
$value->remove($item);
2024-04-04 14:37:20 +02:00
$pages->trash($item);
2022-03-08 15:55:41 +01:00
$changed = true;
}
}
foreach($ids as $id) {
if(in_array($id, $deleteIDs)) continue;
foreach($value as $item) {
if($id == $item->id) $sorted->add($item);
}
}
// add in new items that may have been added after a sort
foreach($value as $item) {
if(!in_array($item->id, $ids)) $sorted->add($item);
}
// check for orphans that may have been added
$orphanInputName = $name . '__add_orphan';
$orphanIDs = $input->$orphanInputName;
2024-04-04 14:37:20 +02:00
if(is_array($orphanIDs) && count($orphanIDs) && $this->orphans) {
2022-03-08 15:55:41 +01:00
$numOrphansAdded = 0;
foreach($orphanIDs as $orphanID) {
foreach($this->orphans as $orphan) {
if($orphan->id == $orphanID) {
$sorted->add($orphan);
$numOrphansAdded++;
}
}
}
if($numOrphansAdded) {
$this->message(sprintf($this->_('Added %d existing page(s) to table'), $numOrphansAdded) . " ($this->name)");
}
}
if("$value" != "$sorted") $changed = true;
if($changed) {
$this->setAttribute('value', $sorted);
$this->trackChange('value');
}
// check if we need to setup a name format for any pages
foreach($value as $n => $item) {
2024-04-04 14:37:20 +02:00
$name = $pages->setupPageName($item, array('format' => $this->nameFormat));
2022-03-08 15:55:41 +01:00
if($name) {
$this->message("Auto assigned name '$name' to item #" . ($n+1), Notice::debug);
$item->save();
}
}
return $this;
}
/**
* Set a property to this Inputfield
*
* @param string $key
* @param mixed $value
* @return $this
*
*/
public function set($key, $value) {
2024-04-04 14:37:20 +02:00
if($key === 'template_id' && $value) {
2022-03-08 15:55:41 +01:00
// convert template_id to $this->rowTemplates array
2024-04-04 14:37:20 +02:00
$templates = $this->wire()->templates;
2022-03-08 15:55:41 +01:00
if(!is_array($value)) $value = array($value);
foreach($value as $id) {
2024-04-04 14:37:20 +02:00
$template = $templates->get($id);
2022-03-08 15:55:41 +01:00
if($template) $this->rowTemplates[$id] = $template;
}
return $this;
} else {
return parent::set($key, $value);
}
}
/**
* Set an attribute to the Inputfield
*
* In this case we capture set to the 'value' attribute to make sure it can only be a PageArray
*
* @param array|string $key
* @param int|string $value
* @return $this
* @throws WireException
*
*/
public function setAttribute($key, $value) {
if($key == 'value') {
2024-04-04 14:37:20 +02:00
if($value === null) $value = $this->wire()->pages->newPageArray();
if(!$value instanceof PageArray) {
throw new WireException('This Inputfield only accepts a PageArray for its value attribute.');
}
2022-03-08 15:55:41 +01:00
}
return parent::setAttribute($key, $value);
}
public function setOrphans(PageArray $orphans) {
$this->orphans = $orphans;
}
/**
* Determine a default set of columns for the PageTable based on the fields defined in the defined template
*
* @return string of newline separated field names
*
*/
protected function getConfigDefaultColumns() {
$out = '';
if(!count($this->rowTemplates)) return $out;
$fieldCounts = array();
foreach($this->rowTemplates as $template) {
foreach($template->fieldgroup as $field) {
2024-04-04 14:37:20 +02:00
if(!isset($fieldCounts[$field->name])) {
$fieldCounts[$field->name] = 1;
} else {
$fieldCounts[$field->name]++;
}
2022-03-08 15:55:41 +01:00
}
}
// sort most used to least used
if(count($this->rowTemplates) > 1) arsort($fieldCounts);
$n = 0;
foreach(array_keys($fieldCounts) as $fieldName) {
$out .= $fieldName . "\n";
if(++$n >= 5) break;
}
return trim($out);
}
/**
* Get field configuration for input tab
*
* @return InputfieldWrapper
*
*/
public function ___getConfigInputfields() {
$inputfields = parent::___getConfigInputfields();
2024-04-04 14:37:20 +02:00
$f = $inputfields->InputfieldTextarea;
2022-03-08 15:55:41 +01:00
$f->attr('name', 'columns');
$f->label = $this->_('Table fields to display in admin');
2024-04-04 14:37:20 +02:00
$f->description =
$this->_('Enter the names of the fields (1 per line) that you want to display as columns in the table.') . ' ' .
$this->_('To specify a column width for the field, specify "field_name=30" where "30" is the width (in percent) of the column.') . ' ' .
$this->_('When specifying widths, make the total of all columns add up to 100.');
$f->notes =
$this->_('You may specify any native or custom field.') . ' ' .
$this->_('You may also use subfields (field.subfield) with fields that contain multiple properties, like page references.') . ' ';
2022-03-08 15:55:41 +01:00
$columns = $this->columns ? $this->columns : $this->getConfigDefaultColumns();
$f->attr('value', $columns);
if(count($this->rowTemplates)) {
$options = array();
foreach($this->rowTemplates as $template) {
foreach($template->fieldgroup as $item) $options[$item->name] = $item->name;
}
$f->notes .= $this->_('Custom fields assigned to your selected templates include the following:') . ' **';
$f->notes .= implode(', ', $options) . '**';
} else {
$f->notes .= $this->_('To see a list of possible custom fields here, select a template on the Details tab, Save, and come back here.');
}
$inputfields->add($f);
2024-04-04 14:37:20 +02:00
$f = $inputfields->InputfieldText;
2022-03-08 15:55:41 +01:00
$f->attr('name', 'nameFormat');
$f->attr('value', $this->nameFormat);
$f->label = $this->_('Automatic Page Name Format');
2024-04-04 14:37:20 +02:00
$f->description =
$this->_('When populated, pages will be created automatically using this name format whenever a user clicks the "Add New" button.') . ' ' . // page name format description 1
$this->_('If left blank, the user will be asked to enter a name for the page before it is created.'); // page name format description 2
$f->notes =
sprintf(
$this->_('If the name format contains any non-alphanumeric characters, it is considered to be a [PHP date](%s) format.'),
'https://www.php.net/manual/en/datetime.format.php'
). ' ' .
$this->_('If it contains only alphanumeric characters then it will be used directly, with a number appended to the end (when necessary) to ensure uniqueness.'); // page name format notes
2022-03-08 15:55:41 +01:00
$f->notes .= ' ' . $this->_('Example: **Ymd:His** is a good name format for date/time based page names.');
$f->collapsed = Inputfield::collapsedBlank;
$inputfields->add($f);
2024-04-04 14:37:20 +02:00
$f = $inputfields->InputfieldRadios;
2022-03-08 15:55:41 +01:00
$f->attr('name', 'noclose');
$f->label = $this->_('Modal edit window behavior');
$f->addOption(0, $this->_('Automatically close on save (default)'));
$f->addOption(1, $this->_('Keep window open, close manually'));
$f->attr('value', (int) $this->noclose);
if(!$this->noclose) $f->collapsed = Inputfield::collapsedYes;
$inputfields->add($f);
return $inputfields;
}
}