Subversion Repositories web.active

Rev

Rev 22 | Blame | Compare with Previous | Last modification | View Log | Download

<?php namespace ProcessWire;

/**
 * ProcessWire Select Options Fieldtype
 *
 * ProcessWire 3.x, Copyright 2023 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' => 2,
    );
  }

  /**
   * @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) {
    /** @var SelectableOptionArray $a */
    $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';
    /** @var InputfieldSelect $inputfield */
    $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 setup options and setup functions for new fields
   *
   * @return array
   * @since 3.0.213
   *
   */
  public function ___getFieldSetups() {
    $setups = parent::___getFieldSetups();
    $setups = array_merge($setups, $this->getInputfieldClassOptions(true));
    return $setups;
  }


  /**
   * 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) && $field->get('inputfieldClass') === 'InputfieldTextTags') {
      /** @var InputfieldTextTags $inputfield */
      $inputfield = $this->wire()->modules->getModule('InputfieldTextTags', array('noInit' => true)); 
      $value = $inputfield->tagStringToArray($value);
      $value = $this->manager->getOptions($field, array('id' => $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 = '') {
    $inputfieldClass = $field->get('inputfieldClass');
    if($inputfieldClass && $value instanceof WireArray) {
      // render single value rather than list of them when input only accepts 1 selection
      $interfaces = wireClassImplements($inputfieldClass);
      if(!in_array('InputfieldHasArrayValue', $interfaces)) $value = $value->first();
    }
    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) {
      /** @var SelectableOption $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) {
      if(is_string($value) && strpos($value, self::multiValueSeparator) !== false) {
        $value = explode(self::multiValueSeparator, $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 PageFinderDatabaseQuerySelect $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)) {
          $s = rtrim($subfield, '0123456789');
          if($subfield && $this->manager->useLanguages() && SelectableOption::isProperty($s)) {
            // property and language id, i.e. title1234 or value1235
          } else {
            // if empty subfield or not a subfield we recognize, just assume title
            $subfield = 'title';
          }
        }
        $options = $this->manager->findOptionsByProperty($query->field, $subfield, $operator, $value);
      }
      
      if($operator != '=' && $operator != '!=') {
        // for fulltext operations...
        // since we are now just matching IDs of already found options
        $operator = '=';
      }

      $subfield = 'data';
      
      if(count($options) < 2) {
        /** @var SelectableOption $option */
        $option = $options->first();
        $value = $option ? $option->id : '';
      } else {
        $value = array();
        foreach($options as $option) {
          $value[] = $option->id;
        }
      }
    }
    
    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 or update query to sort by given $field or $subfield
   *
   * Return false if this Fieldtype does not have built-in sort logic and PageFinder should handle it.
   * Return string of query to add to ORDER BY statement, or boolean true if method added it already.
   *
   * #pw-internal
   *
   * @param Field $field
   * @param DatabaseQuerySelect $query
   * @param string $table
   * @param string $subfield
   * @param bool $desc
   * @return bool
   * @since 3.0.167
   *
   */
  public function getMatchQuerySort(Field $field, $query, $table, $subfield, $desc) {
    // FieldtypeOptions uses an separate table for option values/labels
    $subfield = $this->wire()->sanitizer->fieldName($subfield);
    if(empty($subfield)) return false;
    $table2 = "_sort_options_{$field->name}_$subfield";
    $bindKey = $query->bindValueGetKey($field->id, \PDO::PARAM_INT);
    $query->leftjoin("fieldtype_options AS $table2 ON $table2.fields_id=$bindKey AND $table.data=$table2.option_id");
    $query->orderby("$table2.$subfield " . ($desc ? 'DESC' : ''), true);
    return true;
  }

  /**
   * 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) {
      /** @var SelectableOption $option */
      $info['options'][$option->id] = $option->getTitle();
    }
    $subfields = array(
      'title' => array(
        'name' => 'title', 
        'input' => 'text',
        'operators' => array('%=', '=', '!=', '^=', '$=', '*=', '~='), 
        'label' => $this->_('Title'), 
      ),
      'value' => array(
        'name' => 'value',
        'input' => 'text',
        'operators' => array('%=', '=', '!=', '^=', '$=', '*=', '~='),
        'label' => $this->_('Value text'),
      ),
      'id' => array(
        'name' => 'id',
        'input' => 'integer',
        'operators' => array('=', '!=', '>', '>=' ,'<', '<='), 
      ), 
    );
  
    $languages = $this->wire()->languages;
    if($languages) {
      foreach($languages as $language) {
        if($language->isDefault()) continue;
        
        $subfield = $subfields['title'];
        $subfield['name'] .= $language->id;
        $subfield['label'] .= " ($language->name)";
        $subfields["title$language"] = $subfield;
        
        $subfield = $subfields['value'];
        $subfield['name'] .= $language->id;
        $subfield['label'] .= " ($language->name)";
        $subfields["value$language"] = $subfield;
      }
      
      ksort($subfields);
    }
    
    $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);
  }
  
  /**
   * Delete the given field
   *
   * @param Field $field Field object
   * @return bool True on success, false on DB delete failure.
   *
   */
  public function ___deleteField(Field $field) {
    $result = parent::___deleteField($field);
    try {
      $this->manager->deleteAllOptionsForField($field);
    } catch(\Exception $e) {
      $result = false;
      $this->error($e->getMessage());
      $this->trackException($e);
    }
    return $result;
  }

  /**
   * 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'); 
    /** @var SelectableOptionConfig $cfg */
    $cfg = $this->wire(new SelectableOptionConfig($field, $inputfields)); 
    $inputfields = $cfg->getConfigInputfields();
    return $inputfields; 
  }
  
  /**
   * Get Inputfield options
   * 
   * #pw-internal
   *
   * @param bool $getFieldSetups
   * @return array
   * @since 3.0.213
   *
   */
  public function getInputfieldClassOptions($getFieldSetups = false) {
    $options = array();
    $modules = $this->wire()->modules;
    $labelSingle = $this->_('Single option:');
    $labelMulti = $this->_('Multiple options:');
    $labelSortable = $this->_('Multiple sortable options:');

    foreach($modules->findByPrefix('Inputfield') as $moduleName) {
      $module = $modules->getModule($moduleName, array('noInit' => true));
      if(!$module instanceof InputfieldHasSelectableOptions) continue;
      $title = str_replace('Inputfield', '', $moduleName);
      if($module instanceof InputfieldHasSortableValue) {
        $title = "$labelSortable $title";
      } else if($module instanceof InputfieldSelectMultiple) {
        $title = "$labelMulti $title";
      } else {
        $title = "$labelSingle $title";
      }
      if($getFieldSetups) {
        $name = str_replace('Inputfield', '', $moduleName);
        $options[$name] = array(
          'title' => $title,
          'inputfieldClass' => $moduleName,
        );
      } else {
        $options[$moduleName] = $title;
      }
    }
    
    asort($options);

    return $options;
  }

  /**
   * 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($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($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);
  }

  /**
   * Upgrade module version 
   * 
   * @param string $fromVersion
   * @param string $toVersion
   * 
   */
  public function upgrade($fromVersion, $toVersion) {
    $this->manager->upgrade($fromVersion, $toVersion); 
  }

}