Subversion Repositories web.active

Rev

Rev 22 | Blame | Compare with Previous | 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 2023 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'); 
    parent::init();
  }

  /**
   * Get class name to use Field objects of this type (must be class that extends Field class)
   *
   * @param array $a Field data from DB (if needed)
   * @return string Return class name or blank to use default Field class
   *
   */
  public function getFieldClass(array $a = array()) {
    return 'PageTableField';
  }

  /**
   * 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) {
    $database = $this->wire()->database;
    $page = $event->arguments(0); /** @var Page $page */
    foreach($this->wire()->fields as $field) {
      if(!$field->type instanceof FieldtypePageTable) continue; 
      $table = $database->escapeTable($field->table); 
      $sql = "DELETE FROM `$table` WHERE pages_id=:pages_id OR data=:data";
      $query = $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) {
    
    $pages = $this->wire()->pages;
    $page = $event->arguments(0); /** @var Page $page */
    
    foreach($page->template->fieldgroup as $field) {
      /** @var PageTableField $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) {
          $pages->message("Auto Delete PageTable Item: $item->url", Notice::debug); 
          try {
            $pages->delete($item); 
            $deleted = true; 
          } catch(\Exception $e) {
            $pages->error($e->getMessage(), Notice::debug); 
          }
        }
        if(!$deleted) {
          if($item->isTrash()) continue; 
          $pages->message("Auto Trash PageTable Item: $item->url", Notice::debug);
          $pages->trash($item);
        }
      }
    }
  }

  /**
   * Hook called when a page has been trashed
   * 
   * @param HookEvent $event
   *
   */
  public function hookPagesTrashed(HookEvent $event) {
    
    $pages = $this->wire()->pages;
    $page = $event->arguments(0); /** @var Page $page */
    
    foreach($page->template->fieldgroup as $field) {
      if(!$field->type instanceof FieldtypePageTable) continue;
      /** @var PageTableField $field */
      if(!$field->parent_id || !$field->unpubOnTrash) continue;
      $value = $page->getUnformatted($field->name);
      if(!wireCount($value)) continue;
      foreach($value as $item) {
        /** @var Page $item */
        $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) {
    
    $pages = $this->wire()->pages;
    $page = $event->arguments(0); /** @var Page $page */
    
    if($pages->cloning) return;
    
    foreach($page->template->fieldgroup as $field) {
      if(!$field->type instanceof FieldtypePageTable) continue;
      /** @var PageTableField $field */
      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) {
          $pages->message("Auto Hide PageTable Item: $item->url", Notice::debug);
          $item->addStatus(Page::statusHidden);
        } else {
          $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) {
    
    $pages = $this->wire()->pages;
    $page = $event->arguments(0);
    
    foreach($page->template->fieldgroup as $field) {
      if(!$field->type instanceof FieldtypePageTable) continue;
      /** @var PageTableField $field */
      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);
        $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();

    $pages = $this->wire()->pages;
    $page = $event->arguments(0); /** @var Page $page */
    $copy = $event->arguments(1); /** @var Page $copy */
    
    if($page) {} // ignore

    if(in_array($copy->id, $clonedIDs)) return;
    $clonedIDs[] = $copy->id; 
  
    foreach($copy->template->fieldgroup as $field) {
      if(!$field->type instanceof FieldtypePageTable) continue; 
      /** @var PageTableField $field */
      //if(!$field->parent_id) continue; // let that be handled manually since recursive clones are already an option
      $parent = $field->parent_id ? $pages->get($field->parent_id) : $copy; 
      $value = $copy->getUnformatted($field->name); 
      if(!wireCount($value)) continue; 
      $newValue = $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 = $pages->get("parent=$copy, name=$item->name, include=all"); 
            if(!$newItem->id) $newItem = null; 
          }
          if(!$newItem) $newItem = $pages->clone($item, $parent); 
          if($newItem->id) {
            $newValue->add($newItem); 
            $pages->message("Cloned item $item->path", Notice::debug); 
          }
        } catch(\Exception $e) {
          $pages->error("Error cloning $item->path"); 
          $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() {
    $config = $this->wire()->config;
    if( $config->ajax && 
      $this->wire()->input->get('InputfieldPageTableField') && 
      $this->wire()->user->isLoggedin() && 
      $this->wire()->page->template->name === 'admin') {
      // handle ajax request to render table
      require_once($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 fieldtype
    return $schema;
  }

  /**
   * Get the match query for page selection, delegated to FieldtypePage
   * 
   * @param DatabaseQuerySelect|PageFinderDatabaseQuerySelect $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()->fieldtypes->FieldtypePage->getMatchQuery($query, $table, $subfield, $operator, $value);   
  }

  /**
   * Get the Inputfield used for input by PageTable
   * 
   * @param Page $page
   * @param Field|PageTableField $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 PageArray
   * 
   */
  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|PageTableField $field
   * @param Page $item
   * @return bool
   * 
   */
  protected function isValidItem(Page $page, Field $field, Page $item) {
    $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 compatibility
      if($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|PageTableField $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|PageTableField $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);
    $sanitizer = $this->wire()->sanitizer;
    
    $sortfields = $field->get('sortfields');
    if($sortfields) {
      $sorts = array();
      foreach(explode(',', $sortfields) as $sortfield) {
        $sorts[] = $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()->fieldtypes->FieldtypePage->getSelectorInfo($field, $data); 
    $info['operators'] = array(); // force it to be non selectable, subfields only
    return $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->FieldtypePage->exportConfigData($field, $data);
    if(isset($data['template_id']) && is_array($data['template_id'])) {
      // convert template IDs to names
      $templates = $this->wire()->templates;
      $names = array();
      foreach($data['template_id'] as $id) {
        $template = $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) {
    
    $templates = $this->wire()->templates;
    $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 = $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->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) { 
    $pages = $this->wire()->pages;
    
    $orphans = $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 this
    
    if(!is_array($templateID)) $templateID = array($templateID); 
    
    $value = $page->getUnformatted($field->name); 
    if(!$value instanceof PageArray) $value = $pages->newPageArray();
    
    if($page->numChildren <= $value->count()) return $orphans; // nothing new
    
    $templateNames = array();
    $templates = $this->wire()->templates;
    
    foreach($templateID as $id) {
      $template = $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|PageTableField $field
   * @return InputfieldWrapper
   *
   */
  public function ___getConfigInputfields(Field $field) {
    
    if($field->get('autoTrash') !== null) { // autoTrash was renamed to trashOnDelete
      if($field->get('trashOnDelete') === null) { 
        $field->set('trashOnDelete', $field->get('autoTrash')); 
      }
      unset($field->autoTrash); 
    }

    $inputfields = parent::___getConfigInputfields($field);

    $f = $inputfields->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);

    $f = $inputfields->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);
    
    $fieldset = $inputfields->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'), 
    );
  
    $f = $inputfields->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); 
    unset($f);

    $f = $inputfields->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); 
    unset($f);
    
    $f = $inputfields->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); 
  
    $f = $inputfields->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;
  }
}

require_once(__DIR__ . '/PageTableField.php');