Subversion Repositories web.creative

Rev

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) {} // ignore

    if(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 table
      require_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 fieldtype
    return $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 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 $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 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')->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 this
    if(!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 trashOnDelete
      if($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;
  }


}