'Selector', 'version' => 28, 'summary' => 'Build a page finding selector visually.', 'author' => 'Avoine + ProcessWire', 'autoload' => "template=admin", 'addFlag' => Modules::flagsNoUserConfig ); } const debug = false; /** * Contains all possible operators, indexed by operator with values as labels describing the operator * * @var array */ protected $operators = array(); /** * Array indexed by input types to operator arrays where values are the operators (no labels) * * @var array * */ protected $operatorsByType = array(); /** * Characters that should be automatically trimmed from any operators * * @var string * */ protected $operatorTrimChars = ''; /** * Array of predefined system fields indexed by field name and Fieldtype::getSelectorInfo style arrays as the value * * @var array * */ protected $systemFields = array(); /** * Same as $systemFields except contains only system fields applicable to page references * * @var array * */ protected $systemPageFields = array(); /** * Fields that modify behaviors of selectors but don't refer to any data: sort, limit, include * * @var array * */ protected $modifierFields = array(); /** * Instance of Selectors, when in the process of rendering * * @var Selectors * */ protected $selectors = null; /** * If initValue has a SINGLE template defined, it will be present here * * @var Template|null * */ protected $initTemplate = null; /** * Has the ready method been called? * @var bool * */ protected $isReady = false; /** * Variable names that should be picked up through the session each time * */ protected $sessionVarNames = array( 'allowSystemCustomFields', 'allowSystemNativeFields', 'allowSystemTemplates', 'allowSubselectors', 'allowSubfields', 'allowSubfieldGroups', 'allowModifiers', 'allowAddRemove', 'showFieldLabels', 'fieldsWhitelist', 'dateFormat', 'datePlaceholder', 'timeFormat', 'timePlaceholder', 'previewColumns', 'limitFields', 'maxUsers', ); /** * Default values of each setting, for outside configuration * * @var array * */ protected $defaultSettings = array(); /** * Initialize the default values used in Selector * * See the comments a the top of this file for descriptions of all settings initialized here. * */ public function __construct() { $this->attr('name', 'selector'); // default name, which presumably will be changed $this->setting('preview', 1); // whether to show a live preview in notes section $this->setting('previewColumns', array()); // column names to use for bookmarked previews of selector results (in Lister) $this->setting('counter', 1); // whether to show the live ajax number-of-page-matches counter $this->setting('initValue', ''); // initial selector value: not changeable by user $this->setting('addIcon', 'plus-circle'); $this->setting('addLabel', $this->_('Add Field')); $this->setting('parseVars', true); // whether variables like [user.id] will be parsed $this->set('lastSelector', ''); // last created selector $this->set('subfieldIdentifier', ' …'); $this->set('groupIdentifier', ' ' . $this->_('(1)')); // group identifier // what is allowed $this->setting('allowAddRemove', true); $this->setting('allowSystemCustomFields', false); $this->setting('allowSystemNativeFields', true); $this->setting('allowSystemTemplates', false); $this->setting('allowSubselectors', true); $this->setting('allowSubfields', true); $this->setting('allowSubfieldGroups', true); $this->setting('allowModifiers', true); $this->setting('allowBlankValues', false); // blank values are allowed in selector or if not present, blank values allowed and represented by ="" $this->setting('showFieldLabels', false); // makes it use field labels, rather than names (when true) $this->setting('showOptgroups', true); // show option groups to separate system, field, subfield, etc. $this->setting('optgroupsOrder', 'system,field,subfield,group,modifier,adjust'); // field selection option groups and order to render them in (applicable only if showOptgroups=true) $this->setting('exclude', ''); // CSV fields to disallow $this->setting('limitFields', array()); // only allow these fields (empty=allow any) $this->setting('dateFormat', $this->_('Y-m-d')); // date format $this->setting('datePlaceholder', $this->_('yyyy-mm-dd')); // date format placeholder (what users see) $this->setting('timeFormat', $this->_('H:i')); // time format $this->setting('timePlaceholder', $this->_('hh:mm')); // time format placeholder (what users see) $this->setting('maxUsers', 20); // text input rather than select used when useres qty is greater than this $this->setting('maxSelectOptions', 100); // if quantity of pages meets or exceeds this number, use autocomplete for Page reference fields parent::__construct(); } /** * Same as set() except that this remembers the default value populated to it for later retrieval from getDefaultSettings() * * @param $key * @param $value * */ protected function setting($key, $value) { $this->set($key, $value); $this->defaultSettings[$key] = $value; } /** * Return the default settings * * @return array of key=>value * */ public function getDefaultSettings() { return $this->defaultSettings; } /** * Return the configured settings * * @return array of key=>value * */ public function getSettings() { $settings = $this->defaultSettings; foreach($this->getArray() as $key => $value) { if(array_key_exists($key, $settings)) $settings[$key] = $value; } return $settings; } /** * Monitor and respond to AJAX events * */ public function ready() { if($this->isReady) return; $this->isReady = true; // settings specified in $config->InputfieldSelector $configDefaults = array( 'selectClass' => '', 'inputClass' => '', 'checkboxClass' => '', ); $config = $this->wire()->config; $configSettings = $config->InputfieldSelector; $configSettings = is_array($configSettings) ? array_merge($configDefaults, $configSettings) : $configDefaults; foreach($configSettings as $key => $value) { $this->setting($key, $value); } if(!self::debug && !$this->wire()->config->ajax) return; $input = $this->wire()->input; $name = $input->get('name'); $action = $input->get($this->className()); if(!$action || !$name) return; $sanitizer = $this->wire()->sanitizer; $this->attr('name', $sanitizer->fieldName($name)); // for session validity if(!$this->sessionGet('valid')) return; if(!$this->wire()->user->isLoggedin()) return; $this->set('initValue', $this->sessionGet('initValue')); foreach($this->sessionVarNames as $key) { $this->set($key, $this->sessionGet($key)); } $this->setup(); $fieldName = $input->get('field'); if($fieldName !== null) $fieldName = $sanitizer->name($fieldName); $type = $input->get('type'); if($type !== null) $type = $sanitizer->name($type); $q = $input->get('q'); if($q !== null) $q = $sanitizer->text($q); if($action === 'field') { $out = $this->renderSelectField(); } else if($action === 'subfield' && $fieldName) { $out = $this->renderSelectSubfield($fieldName); } else if($action === 'opval' && $fieldName) { $out = $this->renderOpval($fieldName, $type); } else if($action === 'subfield-opval') { $out = $this->renderSelectSubfield($fieldName) . '' . $this->renderOpval($fieldName, $type); } else if($action === 'test' && ($selector = $input->post('selector'))) { $out = $this->renderTestSelector($selector); } else if($action === 'autocomplete' && $fieldName && strlen($q)) { $out = $this->renderAutocompleteJSON($fieldName, $q); } else { $out = "Ajax request missing required info"; } echo $this->prep($out); exit; } /** * Prepare output for send * * @param $out * @return string * */ protected function prep($out) { // ensures id attributes in Inputfields (like dates) don't get array-like names $out = preg_replace('/ id=[\'"]Inputfield_[a-zA-Z0-9_]+__value\[\][\'"]/', ' ', $out); return $out; } /** * Setup the shared structures and data used by Selector * */ public function setup() { $notLabel = $this->_('Not: %s'); $findOperators = array(); $findNotOperators = array(); $showFieldLabels = $this->showFieldLabels; $operators = Selectors::getOperators(array( 'getIndexType' => 'operator', 'getValueType' => 'verbose', )); foreach($operators as $operator => $info) { $this->operators[$operator] = $info['label']; if($info['compareType'] & Selector::compareTypeFind) { $findOperators[$operator] = $info['label']; $findNotOperators["!$operator"] = sprintf($notLabel, $info['label']); } } $this->operators = array_merge($this->operators, $findNotOperators, array( '.=' => $this->_('Ascending By'), '.=-' => $this->_('Descending By'), '@=' => $this->_('Has'), '@!=' => $this->_('Does Not Have'), '=""' => $this->_('Is Empty'), '!=""' => $this->_('Is Not Empty'), //'#=' => $this->_('Matches'), //'#!=' => $this->_('Does Not Match'), )); // operators by input type // this is a backup and/or for system fields, as these may also be specified // with fieldtype's getSelectorInfo() method, which takes precedence $this->operatorsByType = array( 'name' => array('=', '!=', '%='), // 'text' => array('%=', '!%=', '*=', '!*=', '~=', '!~=', '^=', '!^=', '$=', '!$=', '=', '!=', '=""', '!=""'), 'text' => array_merge($findOperators, array('=', '!=', '<', '<=', '>', '>='), $findNotOperators), 'autocomplete' => array('=', '!='), 'number' => array('=', '!=', '<', '>', '<=', '>=', '=""', '!=""'), 'datetime' => array('=', '!=', '<', '>', '<=', '>='), 'page' => array('@=', '@!='), 'checkbox' => array('=', '!='), 'sort' => array('.=', '.=-'), 'status' => array('@=', '@!=', '<', '<=', '>', '>='), //'selector' => array('#=', '#!='), 'selector' => array('=', '!=', '<', '>', '<=', '>='), ); // chars that are trimmed off operators before being used // enables different contexts for the same operator $this->operatorTrimChars = '.@'; $templates = array(); $templatesByLabel = array(); $initTemplates = $this->getTemplatesFromInitValue($this->initValue); foreach($this->wire()->templates as $template) { if(($template->flags & Template::flagSystem) && !$this->allowSystemTemplates) continue; if(count($initTemplates) && !in_array($template->id, $initTemplates)) continue; $label = $template->getLabel(); if(!$showFieldLabels || $showFieldLabels > 1 || isset($templatesByLabel[$label])) { if(strtolower($template->name) != strtolower($label)) $label .= " ($template->name)"; } $templatesByLabel[$label] = $template; $templates[$template->id] = $label; } unset($templatesByLabel); // make users selectable if there are under $maxUsers of them // otherwise utilize the user ID property $users = array(); $userTemplates = implode('|', $this->wire()->config->userTemplateIDs); $numUsers = $this->wire()->pages->count("template=$userTemplates, include=all"); if($numUsers < $this->maxUsers) { $usersInput = 'select'; $usersOperators = array('=', '!='); foreach($this->wire()->users as $user) { $users[$user->id] = $user->name; } } else { $usersInput = 'name'; $usersOperators = array('=', '%=', '!='); } $titleField = $this->wire()->fields->get('title'); // system fields definitions $this->systemFields = array( 'count' => array( 'input' => 'number', 'label' => $this->_('Count'), 'sanitizer' => 'integer', ), 'created' => array( 'input' => 'datetime', 'label' => $this->_('Created date'), 'operators' => $this->operatorsByType['datetime'], ), 'created_users_id' => array( 'input' => $usersInput, 'label' => $this->_('Created by user'), 'options' => $users, 'operators' => $usersOperators ), '_custom' => array( 'input' => 'text', 'label' => $this->_('Custom (field=value)'), 'operators' => array(), 'placeholder' => $this->_('field=value'), ), 'has_parent' => array( 'input' => 'text', 'label' => $this->_('Has parent/ancestor'), 'operators' => array('=', '!='), ), 'id' => array( 'input' => 'number', 'label' => $this->_('ID'), 'sanitizer' => 'integer', ), 'modified' => array( 'input' => 'datetime', 'label' => $this->_('Modified date'), 'operators' => $this->operatorsByType['datetime'], ), 'modified_users_id' => array( 'input' => $usersInput, 'label' => $this->_('Modified by user'), 'options' => $users, 'operators' => $usersOperators ), 'name' => array( 'input' => 'text', 'label' => $this->_('Name'), 'sanitizer' => 'pageNameUTF8', 'operators' => array('=', '!=', '%='), ), 'num_children' => array( 'input' => 'number', 'label' => $this->_('Number of children'), 'sanitizer' => 'integer', ), 'parent' => array( 'input' => 'number', // select 'label' => $this->_x('Parent', 'parent-only'), 'operators' => array('=', '!='), // $this->operatorsByType['page'] // 'options' => array(id => label) ), 'parent.' => array( 'input' => 'subfields', 'label' => $this->_x('Parent',' parent-with-subfield'), ), 'path' => array( 'input' => 'text', 'label' => $this->_('Path/URL'), 'operators' => $this->operatorsByType['text'], ), 'published' => array( 'input' => 'datetime', 'label' => $this->_('Published date'), 'operators' => $this->operatorsByType['datetime'], ), 'status' => array( 'input' => 'select', 'label' => $this->_('Status'), 'options' => array( 'hidden' => $this->_('Hidden'), 'unpublished' => $this->_('Unpublished'), 'locked' => $this->_('Locked'), 'trash' => $this->_('Trash'), 'temp' => $this->_('Temp'), ), 'sanitizer' => 'integer', 'operators' => $this->operatorsByType['status'], ), 'template' => array( 'input' => 'select', 'label' => $this->_('Template'), 'options' => $templates, 'sanitizer' => 'integer', 'operators' => array('=', '!='), ), 'title' => array( 'input' => 'text', 'label' => ($titleField ? $titleField->getLabel() : 'Title'), 'operators' => $this->operatorsByType['text'] ), //'parent' => $this->_('parent'), ); $ignoreStatuses = array( Page::statusOn, Page::statusReserved, Page::statusSystem, Page::statusSystemID, ); foreach(Page::getStatuses() as $name => $status) { if($status > Page::statusTrash) continue; if($status === Page::statusDraft && !$this->wire()->modules->isInstalled('ProDrafts')) continue; if(in_array($status, $ignoreStatuses)) continue; if(isset($this->systemFields['status']['options'][$name])) continue; // use existing label $this->systemFields['status']['options'][$name] = ucfirst($name); } if(!count($users)) { unset($this->systemFields['modified_users_id']['options']); unset($this->systemFields['created_users_id']['options']); } // system fields for page references $this->systemPageFields = array( 'created' => $this->systemFields['created'], 'id' => $this->systemFields['id'], 'modified' => $this->systemFields['modified'], 'name' => $this->systemFields['name'], 'published' => $this->systemFields['published'], 'status' => $this->systemFields['status'], ); $this->modifierFields = array( 'include' => array( 'input' => 'select', 'label' => $this->_('Include'), 'options' => array( 'hidden' => $this->_('Hidden'), 'unpublished' => $this->_('Hidden + Unpublished'), 'trash' => $this->_('Hidden + Unpublished + Trash'), 'all' => $this->_('All'), ), 'operators' => array('=') ), 'limit' => array( 'input' => 'integer', 'label' => $this->_('Limit'), 'operators' => array('=') ), 'sort' => array( 'input' => 'select', 'label' => $this->_('Sort'), 'sanitizer' => 'fieldName', 'operators' => array('.=', '.=-'), 'options' => array() // populated below ), ); // populate the sort options $options = array(); foreach($this->systemFields as $name => $f) { $options[$name] = $f['label']; } foreach($this->wire()->fields as $f) { if(strpos($f->type, 'FieldtypeFieldset') === 0) continue; $options[$f->name] = $f->name; } ksort($options); $this->modifierFields['sort']['options'] = $options; } /** * Set a session variable specific to this Inputfield instance * * @param string $key * @param mixed $value * @return $this * */ protected function sessionSet($key, $value) { $session = $this->wire()->session; if(!$session) return $this; $s = $session->get($this->className()); if(!is_array($s)) $s = array(); if(count($s) > 100) $s = array_slice($s, -100); // prevent from growing too large $id = 'id' . $this->wire()->page->id . "_" . $this->wire()->sanitizer->fieldName($this->attr('name')); if(!isset($s[$id])) $s[$id] = array(); $s[$id][$key] = $value; $session->set($this->className(), $s); return $this; } /** * Retrieve a session variable specific to this Inputfield instance * * @param string $key * @return mixed * */ protected function sessionGet($key) { $session = $this->wire()->session; if(!$session) return null; $s = $session->get($this->className()); if(!$s) return null; $id = 'id' . $this->wire()->page->id . "_" . $this->wire()->sanitizer->fieldName($this->attr('name')); if(empty($s[$id])) return null; if(empty($s[$id][$key])) return null; return $s[$id][$key]; } /** * Returns an array of selector information for the given Field or field name * * Front-end to Fieldtype::getSelectorInfo * * @param string|Field $field * @return array Blank array if information not available * */ public function getSelectorInfo($field) { $fields = $this->wire()->fields; $languages = $this->wire()->languages; if(is_string($field)) { if(isset($this->systemFields[$field])) { return $this->systemFields[$field]; } if(isset($this->modifierFields[$field])) { return $this->modifierFields[$field]; } $field = $fields->get($field); } if(!$field instanceof Field || !$field->type) return array(); $info = $field->type->getSelectorInfo($field); if($info['input'] == 'page') { $info['subfields'] = array_merge($info['subfields'], $this->systemPageFields); } if(!empty($info['subfields'])) { $subfields = array(); foreach($info['subfields'] as $name => $subfield) { // consider multi-language if(strpos($name, 'data') === 0 && $languages) { list($unused, $languageID) = explode('data', "x$name"); if($unused) {} if(ctype_digit($languageID)) { $language = $languages->get((int) $languageID); if($language && $language->id) { $subfield['label'] = $field->getLabel() . " (" . $language->get('title|name') . ")"; } } } if(isset($this->systemFields[$name])) { $label = isset($this->systemFields[$name]['label']) ? $this->systemFields[$name]['label'] : $name; } else if(!empty($subfield['label'])) { $label = $subfield['label']; } else if(strpos($name, 'data') === 0 && ctype_digit(substr($name, 4)) && $languages) { $label = $languages->get((int) substr($name, 4))->get('title|name'); } else { $f = $fields->get($name); $label = $f ? $f->getLabel() : $name; } $subfield['label'] = $label; $key = $this->showFieldLabels ? "$label\t$name" : $name; while(isset($subfields[$key])) $key .= " "; $subfields[$key] = $subfield; } ksort($subfields); if($this->showFieldLabels) { // convert back to name-based keys $_subfields = array(); foreach($subfields as $key => $subfield) { list($label, $name) = explode("\t", $key); if($label) {} $_subfields[$name] = $subfield; } $subfields = $_subfields; } $info['subfields'] = $subfields; } return $info; } /** * Does the given $field have subfields? * * @param Field $field * @return bool * @deprecated not currently in use * */ protected function hasSubfields(Field $field) { $info = $this->getSelectorInfo($field); return count($info['subfields']) > 0; } /** * Render the results of a selector test: how many pages match * * @param $selector * @return string * */ protected function renderTestSelector($selector) { try { $selector = $this->sanitizeSelectorString($selector); $cnt = $this->wire()->pages->count($selector, array('allowCustom' => true)); $out = ''; // take into account a limit=n if(strpos($selector, 'limit=') !== false && preg_match('/\blimit=(\d+)/', $selector, $matches)) { $out = ' ' . sprintf($this->_('(%d without limit)'), $cnt); if($cnt > $matches[1]) $cnt = $matches[1]; } $out = sprintf($this->_n('matches %d page', 'matches %d pages', $cnt), $cnt) . $out; if(self::debug) $out .= " (" . $selector . ")"; if($cnt > 0) { // bookmarks for Lister $bookmark = array( 'defaultSelector' => $selector, 'initSelector' => $this->initValue, ); $previewColumns = $this->sessionGet('previewColumns'); if($previewColumns && is_string($previewColumns)) { $previewColumns = explode(',', str_replace(' ', ',', $previewColumns)); } if(is_array($previewColumns) && count($previewColumns)) { $a = array(); $sanitizer = $this->wire()->sanitizer; foreach($previewColumns as $col) { $col = empty($col) ? '' : $sanitizer->name($col); if(strlen("$col")) $a[] = $col; } if(count($a)) $bookmark['columns'] = $a; } $this->wire()->modules->includeModule('ProcessPageLister'); $id = ((int) $this->wire()->input->get('id')) . '_' . $this->attr('name'); $url = ProcessPageLister::addSessionBookmark($id, $bookmark); if($url) { $title = $this->_('Pages that match your selector'); $out .= " (" . $this->_('show') . ")"; } } } catch(\Exception $e) { $out = $e->getMessage(); } return $out; } /** * Render the primary field $settings[prepend]"; if(!$this->showOptgroups) { ksort($outAll); foreach($outAll as $o) $out .= $o; $this->optgroupsOrder = 'modifier,adjust'; } foreach(explode(',', $this->optgroupsOrder) as $name) { $name = strtolower(trim($name)); if(empty($outSections[$name])) continue; $optgroupLabel = $outLabels[$name]; $out .= "$outSections[$name]"; } $out .= "$settings[append]"; return $out; } /** * Given a field, return a "|" separated string of template IDs using that field * * @param Field $field * @return string * */ protected function getTemplateIdsUsingField(Field $field) { static $fieldsToTemplates = array(); if(isset($fieldsToTemplates[$field->name])) return $fieldsToTemplates[$field->name]; $templateIDs = array(); foreach($this->wire()->templates as $template) { if($template->fieldgroup->hasField($field)) $templateIDs[] = $template->id; } $idStr = implode('|', $templateIDs); $fieldsToTemplates[$field->name] = $idStr; return $idStr; } /** * Render a single "; } $out .= ""; return $out; } /** * Render the operator or box for the value $selectClass = trim("$this->selectClass select-value input-value"); $out .= ""; } else if($type == 'autocomplete') { // render autocomplete input $placeholder = $this->_('Start typing...'); $selectedValueTitle = ''; if($selectedValueEntities) { $selectedValuePage = $pages->get((int) $selectedValueEntities); if($selectedValuePage->id) { $selectedValueTitle = $selectedValuePage->get('title|name'); if($_type == 'page' && $field) { $inputfield = $field->getInputfield($pages->newNullPage(), $field); if($inputfield instanceof InputfieldPage) { $selectedValueTitle = $sanitizer->entities1($inputfield->getPageLabel($selectedValuePage)); } } } } $out .= ""; $inputClass = trim("$this->inputClass input-value-autocomplete"); $out .= ""; } else if($type == 'datetime' || $type == 'date') { // render date/datetime input $useTime = $type == 'datetime'; if(!$useTime && $field && $field->get('timeInputFormat')) $useTime = true; $out .= $this->renderDateInput($inputName, $selectedValue, $useTime); } else { // render other input that uses an whether text, number or selector $inputType = $type; $inputClass = trim("$this->inputClass input-value input-value-$type"); if($type == 'number' || $type == 'selector' || $fieldName == 'id' || $subfield == 'id') { // adding this class tells InputfieldSelector.js that selector strings are allowed for this input $inputClass .= " input-value-subselect"; $inputType = 'text'; } $out .= ""; } // end the opval row by rendering a checkbox for the OR option $orLabel = $this->_('Check box to make this row OR rather than AND'); $orChecked = $orChecked ? ' checked' : ''; $checkboxClass = trim("$this->checkboxClass input-or"); $out .= ""; return $out; } /** * Render a datepicker input * * @param $name * @param $value * @param bool $useTime * @return string * */ protected function renderDateInput($name, $value, $useTime = false) { /** @var InputfieldDatetime $inputfield */ $inputfield = $this->wire()->modules->get('InputfieldDatetime'); $inputfield->attr('name', $name); $inputfield->datepicker = InputfieldDatetime::datepickerFocus; $inputfield->placeholder = $this->datePlaceholder; $inputfield->dateInputFormat = $this->dateFormat; $inputfield->addClass('input-value'); if($useTime) { $inputfield->timeInputFormat = $this->timeFormat; $inputfield->placeholder .= ' ' . $this->timePlaceholder; } $inputfield->attr('value', $value); $inputfield->renderReady(); return $inputfield->render(); } /** * Render a subfield "; //$out .= ""; $out .= "" . ""; // determine if there is a current value string and if it contains a selector string $selectorValue = is_null($selector) ? '' : $selector->value; if(is_array($selectorValue)) $selectorValue = reset($selectorValue); $valueHasSelectorString = strlen($selectorValue) > 0 && Selectors::stringHasSelector($selectorValue); $limitSubfields = array(); if(is_array($this->limitFields)) { foreach($this->limitFields as $limitField) { if(strpos($limitField, '.') === false) continue; if(strpos($limitField, $fieldName) !== 0) continue; list($limitField, $limitSubfield) = explode('.', $limitField); if($limitField) { } // ignore if($limitSubfield) $limitSubfields[$limitSubfield] = $limitSubfield; } } // render all the subfield options foreach($selectorInfo['subfields'] as $name => $info) { if(count($limitSubfields) && !isset($limitSubfields[$name])) continue; $label = $sanitizer->entities($info['label']); // render primary subfield selection (unless selector info says not to) if(isset($info['input']) && $info['input'] != 'none') { $selected = $selectedValue == $name && (!$valueHasSelectorString || empty($info['subfields'])) ? ' selected' : ''; $out .= "$label"; } // render 'id' option if there are subfields: this enables one to specify a sub-selector string // since selectors don't allow things like field.subfield.tertiaryfield if(!empty($info['subfields']) && $this->allowSubselectors) { $selected = $selectedValue == $name && $valueHasSelectorString ? ' selected' : ''; $outSub .= "$label"; } } if($outSub) { $label = $this->_('Match by ID (subselector)'); $out .= "$outSub"; } $out .= ""; return $out; } /** * Whether or not to use autocomplete * * If no, blank string is returned. * If yes, then the selector string to find pages is returned. * * @param Field $field * @param int $threshold If determined selectable quantity is <= this number, function will return blank. (default=-1 to use configured maxSelectOptions) * @param bool $checkQuantity * @return string Selector string. Blank string means don't use autocomplete. * */ protected function useAutocomplete(Field $field, $threshold = -1, $checkQuantity = true) { if($threshold === -1) $threshold = (int) $this->maxSelectOptions; //$selectorInfo = $this->getSelectorInfo($field); if(!$field->type instanceof FieldtypePage) return ''; $selector = ''; $hasPageListSelect = strpos($field->get('inputfield'), 'PageListSelect') !== false; // determine autocomplete state based on field settings and quantity of pages involved $findPagesSelector = $field->get('findPagesSelector|findPagesSelect'); if($findPagesSelector) { // user-specified selector determines which pages match $selector = trim($findPagesSelector, ', '); if(strpos($selector, 'page.') !== false) { // remove page.something impossible reference, if present $selector = preg_replace('/[_a-zA-Z0-9]+[=<>!]+page\.[_a-zA-Z0-9]+[\s,]*/', '', $selector); //$selector = preg_replace('/(^|,)\s*page\.[_a-zA-Z0-9][=<>!]+[^,]*/', '', $selector); } if($field->get('parent_id')) $selector .= ",has_parent=" . (int) $field->get('parent_id'); } else if($field->get('parent_id')) { if($hasPageListSelect) { $selector = "has_parent=" . (int) $field->get('parent_id'); } else { $selector = "parent_id=" . (int) $field->get('parent_id'); } } if($field->get('template_id')) { $selector .= ",templates_id="; if(is_array($field->get('template_id'))) { if(count($field->get('template_id'))) $selector .= implode('|', $field->get('template_id')); } else { $selector .= (int) $field->get('template_id'); } $templateIDs = $field->get('template_ids'); if(is_array($templateIDs) && count($templateIDs)) $selector .= "|" . implode('|', $templateIDs); } if(empty($selector)) { // if it's using a runtime code to determine, then we can't use autocomplete if($field->get('findPagesCode')) return ''; // otherwise just populate a selector that can match anything $selector = "id>0"; } if(!$checkQuantity) return $selector; if($hasPageListSelect) return $selector; $quantity = $this->wire()->pages->count($selector); return $quantity > $threshold ? $selector : ''; } /** * Render autocomplete results and return JSON string * * @param string $fieldName * @param string $q Query string * @return string JSON * */ protected function renderAutocompleteJSON($fieldName, $q) { header("Content-Type: application/json"); $fields = $this->wire()->fields; // format for our returned JSON $data = array( 'field' => "$fieldName", 'status' => 0, // 0=error, 1=success 'selector' => '', 'items' => array() ); $selector = ''; $subfield = ''; if(strpos($fieldName, '.') !== false) { list($fieldName, $subfield) = explode('.', $fieldName, 2); } $field = $fields->get($fieldName); $selectorInfo = $this->getSelectorInfo($field); if($subfield && isset($selectorInfo['subfields'][$subfield])) { $selectorInfo = $selectorInfo['subfields'][$subfield]; if($selectorInfo['input'] == 'autocomplete' && isset($selectorInfo['selector'])) { $selector = $selectorInfo['selector']; } } if(!$selector && $subfield) { $fieldName = $subfield; $field = $fields->get($fieldName); } if(!$field) { $data['error'] = 'Field does not exist: ' . $fieldName; return json_encode($data); } if(!$selector) $selector = $this->useAutocomplete($field, $this->maxSelectOptions, false); if(!$selector) { $data['error'] = "Field '$field->name' does not require autocomplete"; return json_encode($data); } /** @var Field|InputfieldPageAutocomplete $field */ $searchFields = $field->searchFields; // used by InputfieldPageAutocomplete $labelFieldName = $field->labelFieldName; $labelFieldFormat = $field->labelFieldFormat; if(!$searchFields && isset($selectorInfo['searchFields'])) $searchFields = $selectorInfo['searchFields']; if(!$labelFieldName && !$labelFieldFormat && isset($selectorInfo['labelFieldName'])) { $labelFieldName = $selectorInfo['labelFieldName']; } $labelField = $labelFieldName ? $fields->get($labelFieldName) : null; $template_id = (int) (is_array($field->template_id) ? reset($field->template_id) : $field->template_id); $template = $template_id ? $this->wire()->templates->get($template_id) : null; if($labelFieldFormat) { /** @var InputfieldPage $inputfield */ $inputfield = $field->getInputfield(new NullPage(), $field); } else { $inputfield = null; } if($searchFields) { $searchFields = str_replace(array(', ', ',', ' '), '|', trim($searchFields)); } else if($labelField && $labelField->type instanceof FieldtypeText) { $searchFields = $labelFieldName; } else if($template && $template->fieldgroup->hasField('title')) { $searchFields = 'title'; $labelFieldName = 'title'; } else { $searchFields = 'name'; $labelFieldName = 'name'; } $selector .= ", $searchFields%=" . $this->wire()->sanitizer->selectorValue($q); $selector .= ", limit=50, include=hidden"; foreach($this->wire()->pages->find($selector) as $item) { $label = $inputfield ? $inputfield->getPageLabel($item) : $item->get("$labelFieldName|name"); $data['items'][] = array( 'value' => $item->id, 'label' => $label ); } $data['status'] = 1; $data['selector'] = $selector; return json_encode($data); } /** * Render a selector row
  • * * @param string $select Rendered output for the if applicable * @param string $opval Rendered output for the operator * @param string $class Optional class for the row * @return string * */ public function renderRow($select, $subfield, $opval, $class = '') { if($this->allowAddRemove) { $icon = wireIconMarkup('trash-o'); $delete = "$icon"; } else { $delete = ''; } return "
  • $select $subfield $opval   $delete
  • "; } /** * Set an attribute to this Inputfield, overridden from Inputfield class * * @param array|string $key * @param int|string $value * @return InputfieldSelector|WireData * */ public function setAttribute($key, $value) { if($key === 'value' && $value !== null) { if($this->initValue && strpos($value, $this->initValue) === 0) { // remove initValue from value so that inputs aren't drawn for it $value = substr($value, strlen($this->initValue)); $value = trim($value, ', '); // @todo solve this scenario, which leaves you with just "29" // value: template=29 // initValue: template= } $this->lastSelector = trim("$this->initValue, $value", ', '); if($this->initTemplate) { $lastTemplates = array($this->initTemplate->id); } else if(strpos($this->lastSelector, 'template=') !== false) { $lastTemplates = array(); foreach(new Selectors($this->lastSelector) as $item) { if($item->field != 'template') continue; foreach($item->values as $templateValue) { $template = $this->wire()->templates->get($templateValue); if($template instanceof Template) $lastTemplates[] = $template->id; } } } else { $lastTemplates = array(); } $this->sessionSet('lastTemplates', $lastTemplates); } return parent::setAttribute($key, $value); } /** * Set property * * @param string $key * @param mixed $value * @return $this|Inputfield|WireData * @throws WireException * */ public function set($key, $value) { if($key === 'initTemplate') { if($value && !$value instanceof Template) { $value = $this->wire()->templates->get($value); } if($value === null || $value instanceof Template) { $this->initTemplate = $value; return $this; } else { throw new WireException('initTemplate must be a Template or null'); } } if($key === 'limitFields' && !is_array($value)) { $value = $this->wire()->sanitizer->array($value); } return parent::set($key, $value); } /** * Get property * * @param string $key * @return mixed * */ public function get($key) { if($key === 'initTemplate') return $this->initTemplate; return parent::get($key); } /** * Makes sure that existing fields present in selector are added to limitFields * */ protected function prepareLimitFields() { if(!count($this->limitFields)) return; $limitFields = $this->limitFields; foreach($this->selectors as $selector) { foreach($selector->fields as $field) { // if limitFields is in use and a field is present in the selector that isn't present // in limitFields then make sure it is added to limitFields if(!in_array($field, $limitFields)) { $limitFields[] = $field; } } } $this->limitFields = $limitFields; } /** * Primary Inputfield render method * * @return string * */ public function ___render() { $this->ready(); // tell jQuery UI we want it to load the modal component which makes a.modal open modal windows $jQueryUI = $this->wire()->modules->getModule('JqueryUI'); /** @var JqueryUI $jQueryUI */ $jQueryUI->use('modal'); if(self::debug) $this->counter = true; // force load the CSS/JS files used for dates, since we don't know if they will be needed or not $this->renderDateInput('tmp', '', true); // build the structures and default values $this->setup(); $this->wrapClass .= ' InputfieldSelector_' . ($this->showFieldLabels ? 'labels' : 'names'); // convert the value attribute to a Selectors object try { $value = trim((string) $this->attr('value')); $this->selectors = $this->wire(new Selectors()); if(!$this->parseVars) $this->selectors->setParseVars(false); $this->selectors->setSelectorString($value); } catch(\Exception $e) { $this->error($e->getMessage()); } $this->prepareLimitFields(); // set a session variable so that ajax request know there has been a valid request $this->sessionSet('valid', true); $this->sessionSet('initValue', $this->initValue); // all other session variables that need to be remembered foreach($this->sessionVarNames as $key) { $this->sessionSet($key, $this->$key); } // determine if there are any initValue templates in play, so that we can pre-limit what fields are available $templates = array(); $renderSelectOptions = array(); if($this->initValue) { foreach(new Selectors($this->initValue) as $selector) { if($selector->field == 'template') { $templateValue = $selector->value; if(!is_array($templateValue)) $templateValue = array($templateValue); foreach($templateValue as $t) { $template = $this->wire()->templates->get($t); if($template) { $templates[] = $template->name; if(count($templateValue) == 1) $this->initTemplate = $template; } } } } if(count($templates)) $renderSelectOptions['templates'] = $templates; } $select = $this->renderSelectField($renderSelectOptions); $previewClass = $this->preview ? '' : ' selector-preview-disabled'; $counterClass = $this->counter ? '' : ' selector-counter-disabled'; // render the template row to start: this is duplicated with JS when a field added $rows = $this->renderRow($select, '', '', 'selector-template-row'); // render all the rows for existing selector values already in this Inputfield's value foreach($this->selectors as $selector) { /** @var Selector $selector */ $rowClass = ''; $orChecked = false; $fields = $selector->fields; $quote = $selector->quote; if(!$this->parseVars && $this->selectors->selectorHasVar($selector)) { // allow for "_custom (field=value)" where value contains API var reference $selector->value = implode('|', $fields) . "$selector->operator[$selector->value]"; $fields = array('_custom'); } // render a row for each field in the $selector (usually 1) foreach($fields as $fieldNum => $field) { $field1 = $field; $field2 = ''; $group = is_null($selector->group) ? '' : '@'; $dot = strpos($field, '.'); if($dot && !isset($this->systemFields[$field])) { $field1 = substr($field, 0, $dot); $field2 = substr($field, $dot+1); $hasSubfields = true; /* } else if($field1 === 'parent') { $hasSubfields = true; if($dot) $field2 = substr($field, $dot + 1); // else $field2 = '.'; */ } else { $selectorInfo = $this->getSelectorInfo($field1); $hasSubfields = $selectorInfo && isset($selectorInfo['subfields']) ? count($selectorInfo['subfields']) : false; } $select = $this->renderSelectField(array(), $group . $field); $select2 = $hasSubfields ? $this->renderSelectSubfield($field1, $field2, $selector) : ''; if($select2) $rowClass .= " has-subfield"; if($fieldNum > 0) $rowClass .= " has-or-field"; $values = $selector->value; if(!is_array($values)) $values = array($values); // render a row for each value in the selector (usually 1) foreach($values as $valueNum => $value) { if($valueNum > 0 || $quote == '(') $rowClass .= " has-or-value"; if($fieldNum > 0 || $valueNum > 0 || $quote == '(') $orChecked = true; if(!strlen($value) && $quote) $value = "$quote{$value}$quote"; $operator = $selector->operator; // convert to not operator when finding a not selector if($selector->not && isset($this->operators["!$selector->operator"])) $operator = "!$selector->operator"; //$opval = $this->renderOpval(($field2 ? $field2 : $field1), '', $operator, $value, $orChecked); $opval = $this->renderOpval($field, '', $operator, $value, $orChecked); $rows .= $this->renderRow($select, $select2, $opval, $rowClass); } } } $notes = $this->_('Each selector row above says: field must match value. These are called AND conditions. In cases where the same field or value appears in more than one row, an OR condition is possible. The presence of a checkbox at the end of the row indicates this. Check this box to make the row an OR condition rather than an AND condition.'); // Description of OR checkbox // attributes for our hidden input, populated by javascript as filters are added/changed/removed $attr = array( 'type' => 'hidden', 'id' => $this->attr('id'), 'name' => $this->attr('name'), 'value' => $this->attr('value'), 'class' => 'selector-value', 'data-template-ids' => implode(',', $this->getTemplatesFromInitValue($this->initValue)), ); if($this->allowBlankValues) $attr['class'] .= ' allow-blank'; $attrStr = $this->getAttributesString($attr); $attr['value'] = $this->wire('sanitizer')->entities($attr['value']); $initValue = $this->wire('sanitizer')->entities($this->initValue); // starting output $out = "
      $rows
    "; if($this->allowAddRemove) $out .= " $this->addLabel "; $out .= "

    $attr[value]

    $notes

    "; return $this->prep($out); } /** * Sanitize a selector string and return it * * @param $selectorString * @param bool $parseVars Whether variables like [user.id] should be converted to actual value * @return string * */ public function sanitizeSelectorString($selectorString, $parseVars = true) { $sanitizer = $this->wire()->sanitizer; $users = $this->wire()->users; $initSelectors = $this->wire(new Selectors()); /** @var Selectors $initSelectors */ $userSelectors = $this->wire(new Selectors()); /** @var Selectors $userSelectors */ if(!$parseVars) { $initSelectors->setParseVars(false); $userSelectors->setParseVars(false); } $initSelectors->setSelectorString($this->initValue); $userSelectors->setSelectorString($selectorString); foreach($userSelectors as $s) { if($s->quote == '[' && !$this->allowSubselectors) { $this->error("Subselectors are disabled"); $userSelectors->remove($s); $userSelectors->add(new SelectorLessThan('id', 0)); // forced non match } if(in_array($s->field, array('modified_users_id', 'created_users_id')) && !ctype_digit("$s->value")) { $ids = array(); foreach($s->values as $name) { $property = ctype_digit("$name") ? 'id' : 'name'; $operator = $s->operator === '!=' ? '=' : $s->operator; if($property === 'name') { $value = $sanitizer->selectorValue($sanitizer->pageNameUTF8($name)); } else { $value = (int) $name; // id if($value === 0) $value = ''; } if($value !== '') $ids = array_merge($ids, $users->findIDs("$property$operator$value")); } if($s->operator != '=' && $s->operator != '!=') { $userSelectors->remove($s); $s = new SelectorEqual($s->field, count($ids) ? $ids : ''); $userSelectors->add($s); } else { $s->value = count($ids) ? $ids : ''; } } } return trim("$initSelectors, $userSelectors", ", "); } /** * Returns array of template IDs that correspond with any templates specified in the initValue * * @param string $initValue * @return array of template IDs * */ protected function getTemplatesFromInitValue($initValue) { // determine if a template is enforced and populate allowedTemplates $templates = $this->wire()->templates; $templateIds = array(); if(!$initValue || strpos($initValue, 'template=') === false) return array(); foreach(new Selectors($initValue) as $selector) { if($selector->field != 'template') continue; $value = is_array($selector->value) ? $selector->value : array($selector->value); foreach($value as $name) { $t = $templates->get($name); if($t) $templateIds[] = $t->id; } } return $templateIds; } /** * Process input submitted to this Inputfield * * @param WireInputData $input * @return $this * */ public function ___processInput(WireInputData $input) { parent::___processInput($input); $value = $this->attr('value'); $this->attr('value', $this->sanitizeSelectorString($value, false)); return $this; } /** * Module settings: provide a sandbox area for playing with the Inputfield * * @param array $data * @return InputfieldWrapper * */ public function getModuleConfigInputfields(array $data) { if($data) {} // ignore $form = $this->wire(new InputfieldWrapper()); /** @var InputfieldSelector $f */ $f = $this->wire()->modules->get('InputfieldSelector'); $f->name = 'test'; $f->label = $this->_('Selector Sandbox'); $f->description = $this->_('This is here just in case you want to test out the functionality of this Inputfield.'); $form->add($f); return $form; } /** * Get parent pages of pages using the given template * * @param Template $template * @return PageArray * @throws WireException * */ public function getParentPages(Template $template) { $templates = $this->wire()->templates; $parentPages = $templates->getParentPages($template, true, Page::statusUnpublished); if($parentPages->count()) return $parentPages; $parentIDs = array(); $sql = 'SELECT parent_id FROM pages ' . 'WHERE pages.templates_id=:templates_id ' . 'AND status<' . Page::statusTrash . ' ' . 'GROUP BY parent_id LIMIT 500'; $query = $this->wire()->database->prepare($sql); $query->bindValue('templates_id', $template->id, \PDO::PARAM_INT); $query->execute(); while($row = $query->fetch(\PDO::FETCH_NUM)) { $parentID = (int) $row[0]; $parentIDs[$parentID] = $parentID; } $query->closeCursor(); $parentPages = count($parentIDs) ? $this->wire()->pages->getById($parentIDs) : new PageArray(); foreach($parentPages as $parentPage) { if(!$parentPage->viewable(false)) $parentPages->remove($parentPage); } return $parentPages; } }