Subversion Repositories web.active

Rev

Rev 22 | Go to most recent revision | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire Select Options Fieldtype
 *
 * ProcessWire 3.x, Copyright 2020 by Ryan Cramer
 * https://processwire.com
 * 
 * @property SelectableOptionManager $manager
 *
 */

class FieldtypeOptions extends FieldtypeMulti implements Module {

  public static function getModuleInfo() {
    return array(
      'title' => __('Select Options', __FILE__),
      'summary' => __('Field that stores single and multi select options.', __FILE__),
      'version' => 1,
    );
  }

  /**
   * @var SelectableOptionManager
   * 
   */
  protected $manager;

  /**
   * Construct
   * 
   */
  public function __construct() {
    $path = dirname(__FILE__) . '/'; 
    // TBA for planned LanguagesValueInterface support
    // require_once($this->wire('config')->paths->modules . 'LanguageSupport/LanguagesValueInterface.php');
    require_once($path . 'SelectableOption.php');
    require_once($path . 'SelectableOptionArray.php');
    require_once($path . 'SelectableOptionManager.php');  
    parent::__construct();
  }
  
  public function wired() {
    $this->manager = $this->wire(new SelectableOptionManager());
    parent::wired();
  }

  /**
   * Get a property from the Fieldtype
   * 
   * @param string $key
   * @return mixed|SelectableOptionManager
   * 
   */
  public function get($key) {
    if($key == 'manager') return $this->manager;
    return parent::get($key); 
  }

  /**
   * Get a blank SelectableOptionArray
   * 
   * @param Page $page
   * @param Field $field
   * @return SelectableOptionArray
   * 
   */
  public function getBlankValue(Page $page, Field $field) {
    $a = $this->wire(new SelectableOptionArray());
    $a->setPage($page);
    $a->setField($field);
    return $a; 
  }

  /**
   * Get schema for the Fieldtype's database table
   * 
   * @param Field $field
   * @return array
   * 
   */
  public function getDatabaseSchema(Field $field) {
    $schema = parent::getDatabaseSchema($field);
    $schema['data'] = 'int unsigned NOT NULL';
    $schema['sort'] = 'int unsigned NOT NULL';
    $schema['keys']['primary'] = 'PRIMARY KEY (pages_id, sort)';
    return $schema;
  }

  /**
   * Get the Inputfield that provides input for this Fieldtype
   * 
   * @param Page $page
   * @param Field $field
   * @return Inputfield
   * 
   */
  public function getInputfield(Page $page, Field $field) {
    
    $inputfieldClass = $field->get('inputfieldClass'); 
    if(!$inputfieldClass) $inputfieldClass = 'InputfieldSelect';
    $inputfield = $this->wire('modules')->get($inputfieldClass);
    if(!$inputfield) $inputfield = $this->wire('modules')->get('InputfieldSelect'); 
    
    foreach($this->manager->getOptions($field) as $option) {
      $inputfield->addOption((int) $option->id, $option->getTitle()); 
    }
  
    if($field->get('initValue')) {
      $value = $page->getUnformatted($field->name); 
      if($field->required && !$field->requiredIf) {
        if(empty($value) || !wireCount($value)) {
          $page->set($field->name, $field->get('initValue'));
        }
      } else if($this->wire('process') != 'ProcessField' && !wireCount($value)) {
        $this->warning(
          $field->getLabel() . " ($field->name): " . 
          $this->_('Configured pre-selection not populated since value is not always required. Please correct this field configuration.') 
        );
      }
    }
  
    return $inputfield;
  }

  /**
   * Get Fieldtypes that are known compatible with this one 
   * 
   * @param Field $field
   * @return Fieldtypes
   * @throws WireException
   * 
   */
  public function ___getCompatibleFieldtypes(Field $field) {
    $fieldtypes = $this->wire(new Fieldtypes());
    foreach($this->wire('fieldtypes') as $fieldtype) {
      if($fieldtype instanceof FieldtypeOptions) $fieldtypes->add($fieldtype);
    }
    return $fieldtypes;
  }

  /**
   * Sanitize value for storage in a page
   * 
   * @param Page $page
   * @param Field $field
   * @param mixed $value
   * @return SelectableOptionArray
   *
   */
  public function sanitizeValue(Page $page, Field $field, $value) {
    
    if(is_string($value)) {
      // convert delimited string to array
      if(strpos($value, '|') !== false) {
        $value = explode("|", $value); 
      } else if(strpos($value, "\n") !== false) {
        $value = explode("\n", $value); 
      }
      if(is_array($value)) {
        foreach($value as $k => $v) {
          $value[$k] = trim($v); 
        }
      }
    }
    
    if(empty($value)) return $this->getBlankValue($page, $field); 
  
    if($value instanceof SelectableOptionArray) {
      // fantastic, this is our target
      
    } else if($value instanceof SelectableOption) {
      // one option: convert to SelectableOptionArray
      $a = $this->getBlankValue($page, $field); 
      $a->add($value); 
      $value = $a; 
      
    } else if(is_int($value) || (is_string($value) && ctype_digit("$value"))) {
      // assumed to be an option ID
      $value = $this->manager->getOptionsByID($field, array((int) $value)); 
      
    } else if(is_string($value)) {
      // may be option 'title' (first) or option 'value' (second)
      $_value = $value; // save for second check
      $value = $this->manager->getOptions($field, array('title' => $value)); 
      if(!$value->count()) $value = $this->manager->getOptions($field, array('value' => $_value));
      unset($_value);
      
    } else if(is_array($value) && count($value)) {
      // populated array of id, title or value
      if(ctype_digit(implode('0', $value))) {
        // array of IDs
        $value = $this->manager->getOptions($field, array('id' => $value));
      } else {
        // array of titles or values
        $_value = $value; // save for second check
        $value = $this->manager->getOptions($field, array('title' => $value)); 
        if(!$value->count()) $value = $this->manager->getOptions($field, array('value' => $_value)); 
        unset($_value);
      }
      
    } else if(is_array($value)) {
      // blank array
      $value = $this->getBlankValue($page, $field); 
    }
    
    return $value;
  }

  /**
   * Render a markup string of the value
   *
   * @param Page $page Page that $value comes from
   * @param Field $field Field that $value comes from
   * @param mixed $value Optionally specify the $page->field value. If null or not specified, it will be retrieved.
   * @param string $property Optionally specify the property or index to render. If omitted, entire value is rendered.
   * @return string
   *
   */
  public function ___markupValue(Page $page, Field $field, $value = null, $property = '') {
    if(empty($property)) $property = 'title';
    return parent::___markupValue($page, $field, $value, $property); 
  }

  /**
   * Prep value for DB storage
   * 
   * @param Page $page
   * @param Field $field
   * @param SelectableOptionArray $value
   * @return array
   * @throws WireException if given invalid value
   * 
   */
  public function ___sleepValue(Page $page, Field $field, $value) {
    $sleepValue = array();
    if(empty($value) || !count($value)) {
      // value is empty
      if($field->required && $field->get('initValue')) {
        // value is required, and an initial value is supplied
        // so populate the initial value
        $initValue = $field->get('initValue');  
        if(!is_array($initValue)) $initValue = array($initValue);
        foreach($initValue as $v) {
          $sleepValue[] = (int) $v;
        }
      }
      return $sleepValue;
    }
    if(!$value instanceof SelectableOptionArray) throw new WireException("sleepValue requires SelectableOptionArray"); 
    
    foreach($value as $option) {
      $sleepValue[] = (int) $option->id;
    }
    return $sleepValue; 
  }

  /**
   * Prep value from DB for storage in Page
   * 
   * @param Page $page
   * @param Field $field
   * @param array $value
   * @return SelectableOptionArray
   * 
   */
  public function ___wakeupValue(Page $page, Field $field, $value) {
    if($value) {
      $wakeupValue = $this->manager->getOptions($field, array('id' => $value));
    } else {
      $wakeupValue = $this->getBlankValue($page, $field); 
    }
    return $wakeupValue; 
  }

  /**
   * Prep a value for front-end output
   * 
   * This returns a cloned copy of $value with output formatting enabled. 
   * 
   * @param Page $page
   * @param Field $field
   * @param SelectableOptionArray $value
   * @return SelectableOptionArray
   * 
   */
  public function ___formatValue(Page $page, Field $field, $value) {
    $_value = $this->getBlankValue($page, $field); 
    foreach($value as $option) {
      $_option = clone $option; 
      $_value->add($_option); 
    }
    $_value->of(true);
    return $_value; 
  }

  /**
   * Update a database query for finding values from this Fieldtype
   * 
   * @param DatabaseQuerySelect $query
   * @param string $table
   * @param string $subfield
   * @param string $operator
   * @param mixed $value
   * @return DatabaseQuery
   * @throws WireException
   * 
   */
  public function getMatchQuery($query, $table, $subfield, $operator, $value) {
  
    if($subfield == 'count') return parent::getMatchQuery($query, $table, $subfield, $operator, $value); 
    if($subfield == 'data' && (ctype_digit("$value") || empty($value))) {
      // this is fine (presumed to be an option_id)
    } else {
      // some other subfield, which needs to be mapped to either value or title
      $options = array();
      
      if($subfield === 'data' && ($operator === '=' || $operator === '!=')) {
        // subfield not specified: matching some string value, is it a value or a title? (allow for either)
        $options = $this->manager->getOptions($query->field, array(
          'value' => $value, 
          'title' => $value, 
          'or' => true
        ));
      }
    
      if(!count($options)) {
        if(!$subfield || !SelectableOption::isProperty($subfield)) {
          // if empty subfield or not a subfield we recognize, just assume title
          $subfield = 'title';
        }
        $options = $this->manager->findOptionsByProperty($query->field, $subfield, $operator, $value);
      }
      
      $option = $options->first();
      
      if($operator != '=' && $operator != '!=') {
        // for fulltext operations...
        // since we are now just matching IDs of already found options
        $operator = '='; 
      }
      
      $subfield = 'data';
      $value = $option ? $option->id : null;
    }
    
    if($operator == '!=') {
      // force a != for one value to prevent matching, even if more than one value
      $database = $this->wire('database'); 
      $t = $database->escapeTable($query->field->getTable());
      $s = $database->escapeCol($subfield); 
      
      $bindKey = $query->bindValueGetKey($value);
      $query->where("(SELECT COUNT(*) FROM $t WHERE $t.pages_id=pages.id AND $t.$s=$bindKey)=0");
      
      $bindKey = $query->parentQuery->bindValueGetKey($value);
      $query->parentQuery->where("($table.data IS NULL OR $table.$s!=$bindKey)");
      
      return $query;
    }
    
    return parent::getMatchQuery($query, $table, $subfield, $operator, $value); 
  }

  /**
   * Get information used for InputfieldSelector interactive selector builder
   * 
   * @param Field $field
   * @param array $data
   * @return array
   * 
   */
  public function ___getSelectorInfo(Field $field, array $data = array()) {
    $info = parent::___getSelectorInfo($field, $data);
    $info['input'] = 'select';
    $info['options'] = array();
    $info['operators'] = array('=', '!=', '@=', '@!=', '=""', '!=""'); 
    foreach($this->manager->getOptions($field) as $option) {
      $info['options'][$option->id] = $option->title;
    }
    $subfields = array(
      'title' => array(
        'name' => 'title', 
        'input' => 'text',
        'operators' => array('%=', '=', '!=', '^=', '$=', '*=', '~='), 
      ),
      'value' => array(
        'name' => 'value',
        'input' => 'text',
        'operators' => array('%=', '=', '!=', '^=', '$=', '*=', '~='),
      ),
      'id' => array(
        'name' => 'id',
        'input' => 'integer',
        'operators' => array('=', '!=', '>', '>=' ,'<', '<='), 
      ), 
    );
    
    $info['subfields'] = array_merge($info['subfields'], $subfields); 
    
    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 = parent::___exportConfigData($field, $data); 
    if(isset($data['_options'])) {
      $data['export_options'] = array('default' => $data['_options']); 
      unset($data['_options']); 
    }
    if($this->manager->useLanguages()) foreach($this->wire('languages') as $language) {
      if($language->isDefault()) continue; 
      $key = "_options__$language";
      if(isset($data[$key])) {
        // use language name rather than id for more portability
        $data['export_options'][$language->name] = $data[$key]; 
        unset($data[$key]); 
      }
    }
    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) {
    $data = parent::___importConfigData($field, $data); 
    $data['errors']['export_options'] = $this->_('Import of options is not yet implemented. Though they can easily be imported from one site to another by copy/paste directly from the field edit screen.'); 
    return $data;
  }

  /**
   * Return a cloned copy of $field
   *
   * @param Field $field
   * @return Field cloned copy
   *
   */
  public function ___cloneField(Field $field) {
    $this->wire('fields')->addHookAfter('cloned', $this, 'hookCloned');
    return parent::___cloneField($field);
  }

  /**
   * Hook called when field is cloned, to clone the selectable options from old field to new field
   * 
   * #pw-internal
   * 
   * @param HookEvent $event
   * 
   */
  public function hookCloned(HookEvent $event) {
    /** @var Field $oldField */
    $oldField = $event->arguments(0);
    /** @var Field $newField */
    $newField = $event->arguments(1);
    /** @var FieldtypeOptions $fieldtype */
    $fieldtype = $oldField->type; 
    if(!$fieldtype instanceof FieldtypeOptions) return;
    $options = $fieldtype->getOptions($oldField);
    $options->setField($newField);
    $fieldtype->addOptions($newField, $options);
    $event->removeHook(null);
  }
  
  /**
   * Get Inputfields needed to configure this Fieldtype
   * 
   * @param Field $field
   * @return InputfieldWrapper
   * 
   */
  public function ___getConfigInputfields(Field $field) {
    $inputfields = parent::___getConfigInputfields($field); 
    include_once(dirname(__FILE__) . '/SelectableOptionConfig.php'); 
    $cfg = $this->wire(new SelectableOptionConfig($field, $inputfields)); 
    $inputfields = $cfg->getConfigInputfields();
    return $inputfields; 
  }
  
  /**
   * Given a FieldtypeOptions field id, name or object, return the object
   * @param $field
   * @return Field
   * @throws WireException
   *
   */
  protected function _getField($field) {
    if(!$field instanceof Field) {
      $_field = $field;
      $field = $this->wire('fields')->get($field);
      if(!$field) throw new WireException("Unknown field: $_field");
    }
    if(!$field->type instanceof FieldtypeOptions) {
      throw new WireException("Field $field->name is not of type FieldtypeOptions");
    }
    return $field; 
  }

  public function ___install() {
    $this->manager->install();
  }

  public function ___uninstall() {
    $this->manager->uninstall();
  }

  /**************************************************************************************
   * Public API methods
   * 
   * These provide a simpler interface to the internally-used SelectableOptionManager.
   * 
   * Accessible from $field->type->[get|add|delete|update]Options($field, $options); 
   * 
   */

  /**
   * Get all options available for the given field
   * 
   * @param int|string|Field $field Field name, id or object
   * @return SelectableOptionArray
   * @throws WireException
   * 
   */
  public function getOptions($field) {
    return $this->manager->getOptions($field = $this->_getField($field)); 
  }

  /**
   * Update, add, delete as needed to match the given $options
   *
   * @param int|string|Field $field Field name, id or object
   * @param SelectableOptionArray $options Options to save
   * @return array Summary of what occurred
   * @throws WireException
   *
   */
  public function setOptions($field, SelectableOptionArray $options) {
    return $this->manager->setOptions($field = $this->_getField($field), $options, true); 
  }

  /**
   * Add the given new options
   * 
   * @param $field
   * @param SelectableOptionArray $options
   * @return int Number of options added
   * @throws WireException
   * 
   */
  public function addOptions($field, SelectableOptionArray $options) {
    return $this->manager->addOptions($this->_getField($field), $options); 
  }

  /**
   * Delete the given options
   *
   * @param $field
   * @param SelectableOptionArray $options
   * @return int Number of options added
   * @throws WireException
   *
   */
  public function deleteOptions($field, SelectableOptionArray $options) {
    return $this->manager->deleteOptions($this->_getField($field), $options);
  }

}