Blame | Last modification | View Log | Download
<?php namespace ProcessWire;/*** ProcessWire Page Table Fieldtype** Concept by Antti Peisa* Code by Ryan Cramer* Sponsored by Avoine** ProcessWire 3.x, Copyright 2019 by Ryan Cramer* https://processwire.com**/class FieldtypePageTable extends FieldtypeMulti implements Module {public static function getModuleInfo() {return array('title' => 'ProFields: Page Table','version' => 8,'summary' => 'A fieldtype containing a group of editable pages.','installs' => 'InputfieldPageTable','autoload' => true,);}/*** Initialize the PageTable hooks**/public function init() {$pages = $this->wire('pages');$pages->addHookAfter('delete', $this, 'hookPagesDelete');$pages->addHookAfter('deleteReady', $this, 'hookPagesDeleteReady');$pages->addHookAfter('trashed', $this, 'hookPagesTrashed');$pages->addHookAfter('unpublished', $this, 'hookPagesUnpublished');$pages->addHookAfter('published', $this, 'hookPagesPublished');$pages->addHookAfter('cloned', $this, 'hookPagesCloned');}/*** Hook called when a page is deleted** Used to delete references to the page in any PageTable tables** @param HookEvent $event**/public function hookPagesDelete(HookEvent $event) {$page = $event->arguments(0);foreach($this->wire('fields') as $field) {if(!$field->type instanceof FieldtypePageTable) continue;$table = $this->wire('database')->escapeTable($field->table);$sql = "DELETE FROM `$table` WHERE pages_id=:pages_id OR data=:data";$query = $this->wire('database')->prepare($sql);$query->bindValue(':pages_id', (int) $page->id);$query->bindValue(':data', (int) $page->id);$query->execute();}}/*** Hook called when a page is about to be deleted** This automatically trashes the PageTable pages that a deleted page owns, if the unpubOnDelete option is true.* This is really only applicable when PageTable pages are stored somewhere other than as children of the* deleted page.** @param HookEvent $event**/public function hookPagesDeleteReady(HookEvent $event) {$page = $event->arguments(0);foreach($page->template->fieldgroup as $field) {if(!$field->type instanceof FieldtypePageTable) continue;if(is_null($field->trashOnDelete) && !is_null($field->autoTrash)) $field->trashOnDelete = $field->autoTrash;if(!$field->parent_id || !$field->trashOnDelete) continue;$value = $page->getUnformatted($field->name);if(!wireCount($value)) continue;foreach($value as $item) {/** @var Page $item */$deleted = false;if($field->trashOnDelete == 2) {$this->wire('pages')->message("Auto Delete PageTable Item: $item->url", Notice::debug);try {$this->wire('pages')->delete($item);$deleted = true;} catch(\Exception $e) {$this->wire('pages')->error($e->getMessage(), Notice::debug);}}if(!$deleted) {if($item->isTrash()) continue;$this->wire('pages')->message("Auto Trash PageTable Item: $item->url", Notice::debug);$this->wire('pages')->trash($item);}}}}/*** Hook called when a page has been trashed** @param HookEvent $event**/public function hookPagesTrashed(HookEvent $event) {$page = $event->arguments(0);foreach($page->template->fieldgroup as $field) {if(!$field->type instanceof FieldtypePageTable) continue;if(!$field->parent_id || !$field->unpubOnTrash) continue;$value = $page->getUnformatted($field->name);if(!wireCount($value)) continue;foreach($value as $item) {/** @var Page $item */$this->wire('pages')->message("Auto Unpublish PageTable Item: $item->url", Notice::debug);$of = $item->of();$item->of(false);$item->addStatus(Page::statusUnpublished);$item->save();$item->of($of);}}}/*** Hook called when a page has been unpublished** @param HookEvent $event**/public function hookPagesUnpublished(HookEvent $event) {$page = $event->arguments(0);if($this->wire('pages')->cloning) return;foreach($page->template->fieldgroup as $field) {if(!$field->type instanceof FieldtypePageTable) continue;if(!$field->parent_id || !$field->unpubOnUnpub) continue;$value = $page->getUnformatted($field->name);if(!wireCount($value)) continue;foreach($value as $item) {/** @var Page $item */$of = $item->of();$item->of(false);if($field->unpubOnUnpub == 2) {$this->wire('pages')->message("Auto Hide PageTable Item: $item->url", Notice::debug);$item->addStatus(Page::statusHidden);} else {$this->wire('pages')->message("Auto Unpublish PageTable Item: $item->url", Notice::debug);$item->addStatus(Page::statusUnpublished);}$item->save();$item->of($of);}}}/*** Hook called when a page has been published** @param HookEvent $event**/public function hookPagesPublished(HookEvent $event) {$page = $event->arguments(0);foreach($page->template->fieldgroup as $field) {if(!$field->type instanceof FieldtypePageTable) continue;if(!$field->parent_id || $field->unpubOnUnpub != 2) continue;$value = $page->getUnformatted($field->name);if(!wireCount($value)) continue;foreach($value as $item) {/** @var Page $item */if(!$item->hasStatus(Page::statusHidden)) continue;$of = $item->of();$item->of(false);$this->wire('pages')->message("Auto Un-hide PageTable Item: $item->url", Notice::debug);$item->removeStatus(Page::statusHidden);$item->save();$item->of($of);}}}/*** Hook called when a page is cloned** We use this to clone and save any PageTable fields owned by the cloned page.* This ensures we don't get two pages referring to the same PageTable fields.** @param HookEvent $event**/public function hookPagesCloned(HookEvent $event) {static $clonedIDs = array();$page = $event->arguments(0);$copy = $event->arguments(1);if($page) {} // ignoreif(in_array($copy->id, $clonedIDs)) return;$clonedIDs[] = $copy->id;foreach($copy->template->fieldgroup as $field) {if(!$field->type instanceof FieldtypePageTable) continue;//if(!$field->parent_id) continue; // let that be handled manually since recursive clones are already an option$parent = $field->parent_id ? $this->wire('pages')->get($field->parent_id) : $copy;$value = $copy->getUnformatted($field->name);if(!wireCount($value)) continue;$newValue = $this->wire('pages')->newPageArray();foreach($value as $item) {try {$newItem = null;if(!$field->parent_id && $copy->numChildren) {// value was already cloned by API with recursive option?$newItem = $this->wire('pages')->get("parent=$copy, name=$item->name, include=all");if(!$newItem->id) $newItem = null;}if(!$newItem) $newItem = $this->wire('pages')->clone($item, $parent);if($newItem->id) {$newValue->add($newItem);$this->wire('pages')->message("Cloned item $item->path", Notice::debug);}} catch(\Exception $e) {$this->wire('pages')->error("Error cloning $item->path");$this->wire('pages')->error($e->getMessage(), Notice::debug);}}$copy->set($field->name, $newValue);$copy->save($field->name);}}/*** Install our ajax lister at ready() time, if the conditions are right** Note that additional conditions are required and checked for by InputfieldPageTableAjax class.**/public function ready() {if( $this->wire('config')->ajax &&$this->wire('input')->get('InputfieldPageTableField') &&$this->wire('user')->isLoggedin() &&$this->wire('page')->template == 'admin') {// handle ajax request to render tablerequire_once($this->wire('config')->paths->InputfieldPageTable . 'InputfieldPageTableAjax.php');new InputfieldPageTableAjax();}}/*** Return the database schema used by this Fieldtype** @param Field $field* @return array**/public function getDatabaseSchema(Field $field) {$schema = parent::getDatabaseSchema($field);$schema['data'] = 'int NOT NULL';$schema['keys']['data'] = 'KEY data (data, pages_id, sort)';$schema['xtra']['all'] = false; // indicate that this schema doesn't hold all data managed by this fieldtypereturn $schema;}/*** Get the match query for page selection, delegated to FieldtypePage** @param DatabaseQuerySelect $query* @param string $table* @param string $subfield* @param string $operator* @param mixed $value* @return DatabaseQuery**/public function getMatchQuery($query, $table, $subfield, $operator, $value) {return $this->wire('modules')->get('FieldtypePage')->getMatchQuery($query, $table, $subfield, $operator, $value);}/*** Get the Inputfield used for input by PageTable** @param Page $page* @param Field $field* @return Inputfield**/public function getInputfield(Page $page, Field $field) {/** @var InputfieldPageTable $inputfield */$inputfield = $this->modules->get('InputfieldPageTable');$value = $page->getUnformatted($field->name);$inputfield->attr('value', $value);$templateID = $field->get('template_id');if(!$field->get('parent_id') && !empty($templateID) && $page->numChildren > wireCount($value)) {$orphans = $this->findOrphans($page, $field);if(wireCount($orphans)) $inputfield->setOrphans($orphans);}return $inputfield;}/*** Sanitize a PageTable value** @param Page $page* @param Field $field* @param int|object|string|WireArray $value* @return int|object|PageArray|string|WireArray**/public function sanitizeValue(Page $page, Field $field, $value) {if(is_array($value) && wireCount($value)) $value = $this->wakeupValue($page, $field, $value);if(!$value instanceof PageArray) return $this->wire('pages')->newPageArray();foreach($value as $item) {if($this->isValidItem($page, $field, $item)) continue;$value->remove($item);}return $value;}/*** Return true or false as to whether the item is valid for this PageTable** @param Page $page* @param Field $field* @param Page $item* @return bool**/protected function isValidItem(Page $page, Field $field, Page $item) {if($page) {} // ignore$template_id = $field->get('template_id');if(is_array($template_id)) {if(in_array($item->template->id, $template_id)) return true;} else {// old style for backwards compatibilityif($template_id == $item->template->id) return true;}return false;}/*** Return a blank value used by a PageTable** @param Page $page* @param Field $field* @return PageArray**/public function getBlankValue(Page $page, Field $field) {return $this->wire('pages')->newPageArray();}/*** Return a formatted PageTable value, which is essentially a new PageArray with hidden items removed** @param Page $page* @param Field $field* @param PageArray $value* @return PageArray**/public function ___formatValue(Page $page, Field $field, $value) {$formatted = $this->wire('pages')->newPageArray();if(!$value instanceof PageArray) return $formatted;foreach($value as $item) {if($item->status >= Page::statusHidden) continue;$formatted->add($item);}$formatted->data('notSaveable', true);return $formatted;}/*** Prep a value for storage** @param Page $page* @param Field $field* @param PageArray $value* @throws WireException* @return array**/public function ___sleepValue(Page $page, Field $field, $value) {$sleepValue = array();if(!$value instanceof PageArray) return $sleepValue;if($field->get('sortfields')) $value->sort($field->get('sortfields'));if($value->data('notSaveable')) throw new WireException("Field '$field->name' from page $page->id is not saveable because it is a formatted value.");foreach($value as $item) {if(!$item->id) continue;if(!$this->isValidItem($page, $field, $item)) continue;$sleepValue[] = $item->id;}return $sleepValue;}/*** Wake up a stored value** @param Page $page* @param Field $field* @param array $value* @return PageArray**/public function ___wakeupValue(Page $page, Field $field, $value) {if(!is_array($value) || !wireCount($value) || empty($field->template_id)) return $this->getBlankValue($page, $field);$template_id = $field->get('template_id');if(!is_array($template_id)) {$template_id = $template_id ? array($template_id) : array();}if(wireCount($template_id) == 1) {$template = $this->wire('templates')->get(reset($template_id));} else {$template = null;}$loadOptions = array('cache' => false);if($template) $loadOptions['template'] = $template;$items = $this->wire('pages')->getById($value, $loadOptions);$sortfields = $field->get('sortfields');if($sortfields) {$sorts = array();foreach(explode(',', $sortfields) as $sortfield) {$sorts[] = $this->wire('sanitizer')->name(trim($sortfield));}if(wireCount($sorts)) $items->sort($sorts);}foreach($items as $item) {$item->setQuietly('_pageTableField', $field->id);$item->setQuietly('_pageTableParent', $page->id);}return $items;}/*** Get information used by selectors for querying this field** @param Field $field* @param array $data* @return array**/public function ___getSelectorInfo(Field $field, array $data = array()) {$info = $this->wire('modules')->get('FieldtypePage')->getSelectorInfo($field, $data);$info['operators'] = array(); // force it to be non selectable, subfields onlyreturn $info;}/*** Export configuration values for external consumption** Use this method to externalize any config values when necessary.* For example, internal IDs should be converted to GUIDs where possible.** @param Field $field* @param array $data* @return array**/public function ___exportConfigData(Field $field, array $data) {$data = $this->wire('fieldtypes')->get('FieldtypePage')->exportConfigData($field, $data);if(is_array($data['template_id'])) {// convert template IDs to names$names = array();foreach($data['template_id'] as $id) {$template = $this->wire('templates')->get((int) $id);if($template) $names[] = $template->name;}$data['template_id'] = $names;}return $data;}/*** Convert an array of exported data to a format that will be understood internally (opposite of exportConfigData)** @param Field $field* @param array $data* @return array Data as given and modified as needed. Also included is $data[errors], an associative array* indexed by property name containing errors that occurred during import of config data.**/public function ___importConfigData(Field $field, array $data) {$templateIDs = array();if(!empty($data['template_id'])) {if(!is_array($data['template_id'])) $data['template_id'] = array($data['template_id']);$errorTemplates = array();foreach($data['template_id'] as $name) {$template = $this->wire('templates')->get($name);if($template) {$templateIDs[] = $template->id;} else {$errorTemplates[] = $name;}}$data['template_id'] = 0;if(wireCount($errorTemplates)) {$data['errors']['template_id'] = "Unable to find template(s): " . implode(', ', $errorTemplates);}}$data = $this->wire('fieldtypes')->get('FieldtypePage')->importConfigData($field, $data);$data['template_id'] = $templateIDs;return $data;}/*** Return orphan pages that match the PageTable settings** Applicable only to PageTable fields utilizing the editable page's children as PageTable items.* (i.e. no parent_id is set)** @param Page $page* @param Field $field* @return PageArray Found orphans**/public function findOrphans(Page $page, Field $field) {$orphans = $this->wire('pages')->newPageArray();if($field->get('parent_id')) return $orphans;$templateID = $field->get('template_id');if(!$templateID) return $orphans; // we need at least a template to do thisif(!is_array($templateID)) $templateID = array($templateID);$value = $page->getUnformatted($field->name);if(!$value instanceof PageArray) $value = $this->wire('pages')->newPageArray();if($page->numChildren <= $value->count()) return $orphans; // nothing new$templateNames = array();foreach($templateID as $id) {$template = $this->wire('templates')->get($id);if($template) $templateNames[] = $template->name;}$selector = "include=unpublished, template=" . implode('|', $templateNames);if($value->count()) $selector .= ", id!=$value";foreach($page->children($selector) as $item) $orphans->add($item);return $orphans;}/*** Return configuration fields definable for each FieldtypePage** @param Field $field* @return InputfieldWrapper**/public function ___getConfigInputfields(Field $field) {if($field->get('autoTrash') !== null) { // autoTrash was renamed to trashOnDeleteif($field->get('trashOnDelete') === null) {$field->set('trashOnDelete', $field->get('autoTrash'));}unset($field->autoTrash);}$inputfields = parent::___getConfigInputfields($field);/** @var InputfieldAsmSelect $f */$f = $this->wire('modules')->get('InputfieldAsmSelect');$f->attr('name', 'template_id');$f->label = $this->_('Select one or more templates for items');foreach($this->wire('templates') as $template) {if($template->flags & Template::flagSystem) continue;$f->addOption($template->id, $template->name);}$value = $field->get('template_id');if(!is_array($value)) $value = $value ? array($value) : array();$f->attr('value', $value);$f->required = true;$f->description = $this->_('These are the templates that will be used by pages managed from this field. You may wish to create a new template specific to the needs of this field.'); // Templates selection description$f->notes = $this->_('Please hit Save after selecting a template and the remaining configuration on the Input tab will contain more context.'); // Templates selection notes$inputfields->add($f);/** @var InputfieldPageListSelect $f */$f = $this->wire('modules')->get('InputfieldPageListSelect');$f->attr('name', 'parent_id');$f->label = $this->_('Select a parent for items');$f->description = $this->_('All items created and managed from this field will live under the parent you select here.');$f->notes = $this->_('If no parent is selected, then items will be placed as children of the page being edited.');$f->collapsed = $field->get('parent_id') ? Inputfield::collapsedNo : Inputfield::collapsedYes;$f->attr('value', (int) $field->get('parent_id'));$inputfields->add($f);/*$f = $this->wire('modules')->get('InputfieldCheckbox');$f->attr('name', 'autoTrash');$f->attr('value', 1);if($field->autoTrash) $f->attr('checked', 'checked');$f->label = $this->_('Trash items when page is deleted?');$f->description = $this->_('When checked, items created/managed by a given page will be automatically trashed when that page is deleted. If not checked, the items will remain under the parent you selected above.'); // autoTrash option description$f->notes = $this->_('This option applies only if you have selected a parent above.');$f->collapsed = Inputfield::collapsedBlank;$inputfields->add($f);*//** @var InputfieldFieldset $fieldset */$fieldset = $this->wire('modules')->get('InputfieldFieldset');$fieldset->label = $this->_('Page behaviors');$fieldset->showIf = 'parent_id!=""';$inputfields->add($fieldset);$labels = array('nothing' => $this->_('Nothing'),'trash' => $this->_('Trash them'),'delete' => $this->_('Delete them'),'unpub' => $this->_('Unpublish them'),'hide' => $this->_('Hide them'),);/** @var InputfieldRadios $f */$f = $this->wire('modules')->get('InputfieldRadios');$f->attr('name', 'trashOnDelete');$f->label = $this->_('Delete');$f->description = sprintf($this->_('What should happen to "%s" items when the containing page is permanently deleted?'), $field->name);$f->addOption(0, $labels['nothing']);$f->addOption(1, $labels['trash']);$f->addOption(2, $labels['delete']);$f->attr('value', (int) $field->get('trashOnDelete')); // aka autoTrash$f->columnWidth = 33;$fieldset->add($f);$f = $this->wire('modules')->get('InputfieldRadios');$f->attr('name', 'unpubOnTrash');$f->label = $this->_('Trash');$f->description = sprintf($this->_('What should happen to "%s" items when the containing page is trashed?'), $field->name);$f->addOption(0, $labels['nothing']);$f->addOption(1, $labels['unpub']);$f->attr('value', (int) $field->get('unpubOnTrash'));$f->columnWidth = 33;$fieldset->add($f);$f = $this->wire('modules')->get('InputfieldRadios');$f->attr('name', 'unpubOnUnpub');$f->label = $this->_('Unpublish');$f->description = sprintf($this->_('What should happen to "%s" items when the containing page is unpublished?'), $field->name);$f->addOption(0, $labels['nothing']);$f->addOption(1, $labels['unpub']);$f->addOption(2, $labels['hide']);$f->attr('value', (int) $field->get('unpubOnUnpub'));$f->columnWidth = 33;$fieldset->add($f);/** @var InputfieldText $f */$f = $this->wire('modules')->get('InputfieldText');$f->attr('name', 'sortfields');$f->label = $this->_('Sort fields');$f->description = $this->_('Enter the field name that you want your table to sort by. For a descending sort, precede the field name with a hyphen, i.e. "-date" rather than "date".'); // sort description 1$f->description .= ' ' . $this->_('You may specify multiple sort fields by separating each with a comma, i.e. "last_name, first_name, -birthday".'); // sort description 2$f->notes = $this->_('Leave this blank for manual drag-and-drop sorting (default).');$f->collapsed = Inputfield::collapsedBlank;$f->attr('value', $field->get('sortfields'));$inputfields->add($f);return $inputfields;}}